Migration Checklist
Go through this before opening a PR that adds a migration. It exists because the same handful of mistakes account for nearly every migration that has hurt us, and every one of them is cheap to catch at review time and expensive to catch in production. Items marked automated are enforced by the build or CI. You do not need to verify them by hand; they are listed so you know what the failure means when you see it.1. Numbering and placement
- Version is a UTC timestamp.
date -u +%Y%m%d%H%M%S. (automated —validateMigrationVersion; writing170out of habit fails the build.) - It is a file, not a slice entry.
internal/database/migrations/<stamp>_<name>.sql. Only migrations that genuinely need Go — schema discovery at apply time, SQLite table rebuilds — go in thelegacyMigrationsslice. - The stamp is newer than every committed migration. Append, never insert. (automated — strictly-ascending test.)
- Nothing at or below v169 was touched. (automated — CI
lint-migrationsfingerprints every shipped entry, slice or file.)
2. Is it reversible without a rollback script?
We do not writedown migrations. The rollback story is the pre-migration
snapshot plus the previous binary, and an untested down would be a worse
promise than no promise. That puts the burden here instead:
- Could an operator restore the snapshot and run the old binary? If your migration deletes data the old code needs, the answer is no and the change needs splitting.
- Nothing is dropped in the same release that stops using it. Removing a column is two releases: stop reading it in release N, drop it in N+1. An instance that upgrades slowly runs the old code against the new schema for as long as it takes them.
3. Cost — will this be downtime?
Migrations run before the server serves. Their duration is upgrade downtime, and it scales with the customer’s data, not with our test fixtures.- Does it rewrite every row of a table?
UPDATEwith no narrowingWHERE,ALTER TABLEthat rebuilds, adding a column with a computed default. If yes, keep reading; if no, skip to §4. - Estimate the cost. Measured on this schema: rewriting
journal_entriesruns ~59µs/row. A million rows is a minute of downtime, ten million is ten minutes.journal_entries,pipeline_runsandchatsare the tables that grow without bound. - If the estimate is more than a few seconds on a large install, move
the data half to
migrations/post_deploy/and read that directory’s README first. The schema half (adding the column) stays in the normal lane; the backfill goes post-deploy.
Splitting is not just a performance trick — it changes what the running code
must tolerate. A post-deployment migration has not run when the new code
starts serving, so read paths must cope with the column being unfilled for
some rows. If that is not acceptable, take the downtime honestly instead.
4. Correctness
-
ADD COLUMNhas a default, or the code handles NULL. SQLite cannot add aNOT NULLcolumn without one. - A
CHECKconstraint change means a table rebuild. SQLite cannot alter a constraint in place. See migration v169 for the pattern: create the new table, copy the rows, drop, rename — and carry live rows across rather than dropping them. - Foreign keys point at something that exists at this version. A migration referencing a table added later fails only on a fresh install, which is the one path nobody tests locally.
- New index is worth its write cost. Every index slows every insert to
that table.
journal_entriestakes an insert per agent action.
5. Idempotency — only if you cannot use a transaction
Almost every migration runs inside a transaction with its ledger row, so a failure rolls back cleanly and idempotency is not your problem. Two cases escape that, and both make it your problem:-
fnNoTxmigrations run outside the wrapper transaction and the ledger row is written afterwards. A crash midway re-runs them. There is exactly one today (v167). - Post-deployment migrations commit per batch and resume. The
statement must exclude rows it has already handled —
WHERE col IS NULL, notSET counter = counter + 1.
6. Tests
- A migration that transforms data has a test that transforms data. A schema-only assertion passes while the rows are being mangled.
- Seed rows at the version before yours, then migrate. Seeding after means your migration never sees them.
- Re-running is a no-op. Every restart re-enters
Migrate.
What CI will tell you
Related
- Migrations — the numbering scheme, the collision guard,
and
crewship db repair-ledger. - Upgrades — snapshots and
crewship db restore-snapshot. internal/database/migrations/post_deploy/README.md— the contract for the batched lane.