Accepted
The API runs against Postgres. The team evaluated three ways to evolve the schema:
- Drizzle / Kysely auto-migrations (generated from TypeScript models).
- Prisma's
prisma migrate devworkflow. - A hand-rolled migration runner that executes versioned SQL files.
We landed on a hand-rolled runner. This ADR captures why so a future contributor doesn't ask "why isn't this Prisma?" and silently re-litigate the decision.
apps/api/scripts/migrate.ts runs versioned SQL files from
migrations/ against the configured database.
- Each migration is a numbered
.sqlfile (NNNN_<slug>.sql). - Up-direction only by default — Postgres-shaped, write-only. Down-migrations are added on a case-by-case basis when the reverting transformation is genuinely safe and short.
- The runner uses an advisory lock so two pods racing to migrate the same database produce one applied sequence, not corruption.
- A
migrations_historytable records every applied id + hash; the runner refuses to re-run a migration whose hash changed (catches hand-edits of already-applied files).
- Auditable: every schema change is a plain SQL file in git that reviewers can read end-to-end. ORM-generated migrations often obscure intent behind generated text.
- Postgres-shaped: we lean on Postgres features (partial
indexes, RLS, expression indexes,
CHECKconstraints,generated columns) that ORM migration generators frequently emit as opaque escape hatches anyway. - No drift channel: the schema lives in SQL files, not in a
TypeScript model that has to be kept in lock-step. The runtime
query layer (sql-template-strings
- a thin type generator) reads from the live schema.
- Operational simplicity: zero new third-party tools in the
production runbook.
pnpm migrateis just a thin wrapper aroundpsql.
Considered alternatives:
- Prisma migrate: tempting because of the model-first DX, but
adoption would force us to express our existing schema in
schema.prismathen generate migrations from it. Net loss in reviewability for non-trivial migrations. - Drizzle migrate: similar trade-off; the generator is cleaner than Prisma's, but we still pay the model-first cost.
- Knex migrations: viable but pulls a runtime dep we don't otherwise need.
- Every schema change requires writing SQL. No magic table / column auto-creation. The team treats this as a feature, not a burden.
migrate.tsis now load-bearing — bugs in the runner can corrupt prod. Tested viapnpm migrate:dryrunin CI on every PR that touchesmigrations/.- Down migrations remain rare; when they exist they're explicit and reviewed alongside their up counterpart.
- New contributors learn one tool (
pnpm migrate) instead of two (an ORM model API + an ORM CLI).
apps/api/scripts/migrate.tsmigrations/- Closes #68.