Database
Drizzle against Postgres, push against migrate, and the two extensions the schema assumes.
The schema lives in two files and is loaded as one:
drizzle.config.ts points at both ./drizzle/schema.ts and
../auth/src/auth-schema.ts, because Better Auth owns its own tables and we do
not copy them. Edit the auth tables in the auth package, never in
drizzle/schema.ts.
Two extensions, created before anything else
CREATE EXTENSION IF NOT EXISTS vector; -- embeddings, packages/ai
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- trigram search, packages/db/src/search.tsdocker-compose.yml handles this locally: the image is
pgvector/pgvector:pg16 and scripts/init-db.sql is mounted into
docker-entrypoint-initdb.d, so both extensions exist on the container's first
boot. That is why bun run setup works on a clean clone.
The moment you point at a Postgres you provisioned yourself, this step is
yours. drizzle-kit push does not create extensions and does not explain
itself; it fails with type "vector" does not exist and leaves you looking at
your schema file. Extensions require a superuser, so run the two statements as
postgres, not as the application role.
The init script only runs on an empty data directory. Adding it to a compose stack whose volume already exists does nothing; drop the volume or run the statements by hand.
Push or migrate
Both are wired. They are for different phases and mixing them is how a database ends up disagreeing with its own migration history.
bun run db:pushDiffs the schema against the live database and applies the difference immediately. No files, no history. Use it while the shape is still moving and the data is disposable, which is most of the first week.
It will happily drop a column to make the database match the file. On a database with real rows, that is the whole risk.
bun run db:generate # writes a timestamped SQL file under drizzle/migrations
bun run db:migrate # applies pending filesGenerate, read the SQL, commit it, apply it. Use this the moment the database holds data somebody would miss, and in every environment you cannot recreate.
The generated file is editable. Drizzle writes a mechanical diff; a rename
arrives as a drop plus an add, which loses the column's data. Rewrite it as
ALTER TABLE ... RENAME COLUMN before applying.
Seeding
bun run db:seedCreates the demo admin through Better Auth's own sign-up API rather than by
inserting a row, so the password is hashed the way the login path expects, then
promotes the account to admin and marks the email verified. It also inserts
three sample projects, so the dashboard, the projects page and the empty state
are three distinguishable screens on a fresh clone rather than three copies of
"nothing here yet".
It is idempotent: run it twice and it ensures rather than duplicates.
The seed refuses to plant its default credentials when NODE_ENV=production
unless SEED_ADMIN_PASSWORD is set explicitly. A boilerplate that ships a
known admin password into production is a boilerplate with a CVE, so this one
stops instead.
Connecting
packages/db exports a single pooled client. Import it rather than constructing
your own:
import { drizzleDb, eq, projects } from "@workspace/db"
const rows = await drizzleDb
.select()
.from(projects)
.where(eq(projects.ownerId, user.id))Pool sizing and timeouts are environment variables, all optional, all read in
packages/env: DB_POOL_MAX, DB_POOL_MIN, DB_IDLE_TIMEOUT,
DB_CONNECT_TIMEOUT, DB_MAX_LIFETIME, DB_STATEMENT_TIMEOUT,
DB_IDLE_IN_TRANSACTION_TIMEOUT, DB_QUERY_TIMEOUT. Leave them alone until a
number tells you to change one.
Ownership is the security boundary
Every domain table carries an owner column and every query scopes to it. The
API context hands each route a user, and a query that reads an id from the
request body instead of from user.id is the bug this convention exists to
prevent. Cross-user reads go through the admin RBAC macro, never through a
query parameter.
Inspecting
bun run db:studio # Drizzle Studio against the configured databasedrizzle.config.ts imports @workspace/env/load-root-env first, because
drizzle-kit runs with its cwd set to packages/db and would otherwise fall
back to the compose defaults even when the root .env points somewhere else.
If Studio opens the wrong database, that import is the first thing to check.