ADR-026 — SQL-first migrations
Status: Accepted · Date: 2026-10-26 · Issue: #931 · Supersedes: the mechanics of ADR-021 (who migrates and the rollback order stay)
Context
The schema was built by 147 EF Core migrations (148 files, one never applied: #948) and a 5,626-line
model snapshot. Reading what a migration does meant reading generated C#; what EF could not model —
pg_trgm, vector, the full-text function and trigger, a generated column, trigram indexes — lived
in raw migrationBuilder.Sql(...) strings scattered across years of files. The database cleanup
(#926) is a long series of schema steps, each of which must be reviewable and reversible by reading
it, and data access is moving off EF Core module by module, so the schema cannot stay owned by the
EF model.
Decision
- The schema is plain SQL in
database/migrations/:NNNN_<name>.up.sql+NNNN_<name>.down.sql, each up file opening with a header (what, why, issue, compatibility, what down does).0000_baseline.up.sqlis production’s schema on 2026-10-26 (pg_dump --schema-onlyof a restored backup, grouped by domain, + seed rows); it has no down. database/migrate.sh(POSIX sh + psql) applies them:up,down <version>,status,verify. Ledgerschema_migrations(version, name, checksum, applied_at, execution_ms). Each file runs with its ledger row in one transaction (--single-transaction,ON_ERROR_STOP); each transaction takespg_advisory_xact_lockand re-reads the ledger, so concurrent runners serialize. Applied files are never edited: a changed checksum stopsupanddown.- Production bootstrap: no ledger +
__EFMigrationsHistoryending at the last EF migration → create the ledger, record0000as applied, run nothing. Any other EF history is refused.0001then drops the EF history table (#961); its down file puts it back, rows included, for a rollback to an EF-migrator image. - ADR-021 holds: only the
migratorcompose service migrates (now a ~22 MB alpine image with psql anddatabase/); the Api never does — not even fordotnet run, which used to migrate behind a launchSettings flag. The Api embeds the up files at build time; at start and on every/health/readyit compares them with the ledger. Same contract:components.schema = { status: "behind", pending: ["0007_x", …] }and 503 until the migrator catches up. - Rollback is an argument, not an environment variable:
docker compose run --rm migrator down <version>(make migrate-down V=<version>).MIGRATE_TARGETand its.envhazard are gone. downrefuses versions this copy has no file for — ADR-021 step 5, unchanged in meaning: an older image cannot undo a newer migration, so it must not report success.- The EF model is checked against the schema while it exists:
SchemaDriftTests(CI, on the databasemigrate.shbuilt) fails if a mapped column is missing or differs in type or nullability, or a NOT NULL column without a default on a mapped table is not in the model.
Rolling back a release with a migration — order unchanged from ADR-021
- Schema first, with the image deployed now (it has the down files):
docker compose run --rm migrator down <last version of the target release>. - Verify:
/api/health/readyshowsschema: behindwith exactly the reverted versions inpending. - Then the code: deploy
rollback_commit. Its migrator has no file for the reverted versions and nothing pending:upis a no-op; the old Api reportsschema: ok.
The reverse order cannot work: after step 3 the migrator on the box has no down files for the newer
versions, and down exits 1 saying so.
Release rules
- Expand, then contract. Every migration leaves the code deployed now working: add first; remove in a later release, once nothing deployed uses the old shape. That is what makes step 3 above safe.
- Back up before a contract step. A data-destroying down file recreates structure only; its header says the data comes from backup.
- One transaction per file rules out
CREATE INDEX CONCURRENTLY; the tables are small enough for a plainCREATE INDEX. A non-transactional mode is added when one isn’t.
Alternatives
| option | verdict | why |
|---|---|---|
| Keep EF migrations, squash to one baseline | rejected | still generated C# to review, still a snapshot to keep in step, and the Dapper move would leave the schema owned by a model that no longer describes it |
| A migration tool (Flyway, Liquibase, DbUp, sqitch) | rejected | the needs fit ~200 lines of sh: a ledger, checksums, transactions, a lock, one refusal rule. A tool brings a runtime (JVM / .NET) back into the migrator and its own file conventions, and hides the mechanics this repo wants readable |
One transaction for the whole up batch |
rejected | a late failure would also undo earlier, valid migrations, and the ledger could no longer say how far a run got |
Session pg_advisory_lock held across the run |
rejected | psql opens one session per file; holding a lock across them needs a second long-lived connection. A transaction lock + ledger re-read gives the same guarantee per file |
Consequences
- New schema change = a numbered up/down pair + a
data-model.mdrow change, in one PR with an issue.database/tests/roundtrip.sh(CI) proves each down undoes its up: after each down the schema must equal a fresh database built only that far, and up again must restore the full schema. dotnet ef migrations addno longer exists here (Microsoft.EntityFrameworkCore.Design, the migrations folder andAppDbContextFactoryare deleted). Entity changes are written twice — the mapping and the SQL — and the drift test keeps them together until EF is gone.- Local development migrates with
make migrate(the migrator image), including fordotnet run. __EFMigrationsHistorywas dropped by0001(#961). Its down file restores it with all 147 rows, so rolling the code back past #960 isdown 0with the current image, then the code.