Database
PostgreSQL with Drizzle: the schema, and the generate-then-migrate loop.
Your database schema is TypeScript. The tables in packages/db/src/schema/ are the single source of truth: Drizzle Kit generates the SQL migration from them, and Drizzle types every query against them, so a renamed column is a compile error in the API, not a null in production.
You never write DDL by hand or edit the database directly. You edit the schema, generate the SQL, review it, and apply it.
Why Drizzle
Drizzle is not a query builder wrapped around SQL strings. Because the schema is plain TypeScript, the db client in packages/db/src/index.ts is fully typed from it, every select and insert returns typed rows, and Drizzle Kit writes each migration by diffing your schema against the last snapshot, so you review real SQL instead of trusting an ORM to guess.
The schema
Three files under packages/db/src/schema/, all re-exported from index.ts. Files group by concern, not by table, so a table joins the file its neighbours are in rather than getting one of its own:
auth.ts, the Better Auth tables:user,session,account,verification, plusorganization,member,team,teamMember, andinvitation, with their Drizzle relations and foreign-key indexes. See Auth & Organizations for what each one holds.console.ts, the console's own two tables:allowlistbehind Console > Access, whosekindis a generated column so the rulevalueis the only thing a row stores, andactivitybehind Console > History, the trail of who changed what. They share a file because they are one concern, the same way the nine auth tables do.waitlist.ts: a standalonewaitlisttable (no foreign keys) behind the public/waitlistpage and Console > Waitlist.
Conventions across the schema: text primary keys, snake_case column names, timestamp("created_at").defaultNow().notNull(), CASCADE on foreign-key deletes, and an index on every foreign-key column. Nothing else is indexed. user.role is the case worth naming: the console filters and sorts by it, and it still carries no index, because four distinct values over a table the size of your staff list is a sequential scan either way. Add indexes when your own row counts and query plans ask for them, not in advance. The console.ts tables are the exception on both counts. Their actor_id is SET NULL, because deleting the admin who added a rule must not delete the rule, nor the history they made; and neither table carries an index beyond its primary key, because at console sizes the sequential scan wins and an index guessed in advance is one to maintain for nothing. Each also stores an actor text alongside the id, so a row stays readable once the id is null, and can name an actor that was never an account (a matching rule, or a script run from a terminal).
The generate → migrate loop
# 1. edit the schema in packages/db/src/schema/
# 2. generate a SQL migration from the diff
bun run db:generate
# 3. review the SQL in packages/db/drizzle/, then apply it
bun run db:migratedb:generate diffs your schema against the snapshot in packages/db/drizzle/meta/ and writes a numbered .sql file plus a fresh snapshot; it never touches the database. db:migrate runs the pending files against POSTGRES_URL. You review the SQL in between because it is the exact DDL that will run against your data.
Adding a table is the same loop. Create the file, export it, generate, migrate:
// packages/db/src/schema/project.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core"
import { organization } from "@/schema/auth"
export const project = pgTable("project", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
})Add export * from "@/schema/project" to packages/db/src/schema/index.ts, then run the two commands. The db-migration skill hands an agent this exact procedure, including the trap below.
The running stack won't see a new table until you rebuild
The API imports @packages/db's built output, and bun --hot does not reliably pick up new files. After generating and migrating, rebuild the package (bunx turbo run build --filter=@packages/db) and restart bun run dev. Never edit an applied migration; generate a new one instead.
In production
The schema, the SQL, and the snapshot travel together in the same commit as the change that needs them. On Vercel, the API build applies pending migrations automatically on production and canary deploys (.github/scripts/migrate-on-deploy.ts); PR previews are skipped. Point POSTGRES_URL at any managed Postgres: Neon, Supabase, Railway. For a throwaway local database, bunx pglaunch -k creates one instantly.
Browsing data
bun run db:studioDrizzle Studio opens a browser UI to read and edit rows directly.
Next
- Auth & Organizations: what the tables in
auth.tsactually do. - Environment Variables: where
POSTGRES_URLcomes from. - AI Skills: the
db-migrationskill and the rest of the catalog.