Database migrations
FitKit uses Drizzle ORM. Migrations are SQL files committed to libs/db/drizzle/, tracked in libs/db/drizzle/meta/_journal.json, and applied by the drizzle-kit migrator.
This runbook is the single source of truth for the migration workflow. Diverging from it has caused at least one production outage (2026-04-18).
Hard rules
- Never run
pnpm db:push. It bypasses the journal and skews dev vs prod. The script is inpackage.jsononly because Drizzle’s docs reference it; treat it as deleted. - Never run
pnpm db:migratewithout explicit approval from the owner. Dev and prod both hold real data. - The
_journal.jsonwhenvalues must be strictly monotonic. See the monotonic-when section below.
Standard workflow
# 1. Edit the schema
$EDITOR libs/db/src/lib/schema/<domain>.ts
# 2. Generate the SQL migration
pnpm db:generate
# 3. Review the generated SQL — do NOT blindly trust drizzle-kit
git diff libs/db/drizzle/
# 4. Apply locally
pnpm db:migrate
# 5. Verify the change in Drizzle Studio
pnpm db:studioWhat pnpm db:generate does
- Compares
libs/db/src/lib/schema/*.tsagainst the current snapshot. - Emits a new SQL file in
libs/db/drizzle/0XXX_<slug>.sql. - Appends to
libs/db/drizzle/meta/_journal.jsonan entry like:
{
"idx": 41,
"version": "7",
"when": 1748000000000,
"tag": "0041_<slug>",
"breakpoints": true
}The when is a millisecond timestamp at generation time. This timestamp is load-bearing — see below.
Monotonic when: the 2026-04-18 outage
Drizzle’s migrator picks pending migrations with:
SELECT ... FROM __drizzle_migrations
WHERE created_at > (
SELECT COALESCE(MAX(created_at), 0) FROM __drizzle_migrations
)If you merge a branch whose migration was generated before main’s most recent migration was generated, your migration’s when is smaller than the latest applied one. The migrator silently skips it — pnpm db:migrate reports success, but your DDL never ran.
The 2026-04-18 outage happened exactly this way: a branch added a NOT NULL column with no backfill; the migration silently skipped in prod; the next deploy’s code expected the column and crashed on startup.
Symptoms
pnpm db:migratesays “migrations applied successfully” but the schema is unchanged.- API boot fails with
column "X" does not exist. _journal.jsonhas an entry whosewhenis less thanMAX(when)of previously applied entries.
Mitigation when merging a branch with a stale migration
- After rebasing/merging onto main, regenerate the migration so its
whenbumps:rm libs/db/drizzle/00XX_<your_slug>.sql git checkout libs/db/drizzle/meta/_journal.json # revert your entry pnpm db:generate - Confirm
whenin the new entry exceeds the previous entry’s. - Apply.
Destructive changes (drops, type changes, NOT NULL on existing data)
pnpm db:generate will produce the SQL but will not warn you about data loss. Before merging:
- Renames: split into two migrations (add new column → backfill → drop old column).
- New
NOT NULLcolumn: add nullable → backfill → enforceNOT NULLin a follow-up. - Type narrowing: same pattern.
- Drops: confirm no code references the table/column; consider archiving data before dropping.
There is no automatic backfill mechanism — write data backfills as a separate script in scripts/ or as part of a deploy.
Production deploys
The application does NOT run migrations automatically on boot. Migrations are applied by the deployer (currently the owner) as a separate step before rolling the API. The Railway deploy pipeline runs pnpm db:migrate against the production DB before promoting the new container.
Until audit logging lands (FIT-20), there is no auditable record of who ran a migration when, beyond __drizzle_migrations.created_at.
Pooler vs direct connection
- Application reads/writes go through PgBouncer (
DATABASE_URL). - Migrations bypass the pooler via
DIRECT_DATABASE_URL(set inlibs/db/drizzle.config.ts). DDL holds server connections for long durations and would starve the pooler.
Rollback
There is no down-migration support. Drizzle generates forward-only SQL. Rollback strategies:
- Same-day: write a follow-up forward migration that reverses the schema change. Apply it.
- Mid-deploy data loss: restore from the latest managed-DB snapshot (Railway / Neon / Supabase backup), then forward-migrate again.
Plan migrations so that the new code is compatible with both the pre-migration and post-migration schema for at least one deploy cycle. That way a partial rollout isn’t catastrophic.
Migration review checklist
Before merging any PR that touches libs/db/drizzle/:
- The generated SQL matches the schema diff (no unexpected drops).
-
_journal.jsonwhenis greater than the previous entry’s. - No
NOT NULLadded to a populated column without a backfill. - No column rename without the two-step pattern above.
- No FK added to a column that may have orphan rows.
- The PR description explains the change in plain English (for future you).
- If destructive: a clear rollback plan in the PR.