Migration Chain Guard
Every migration folder under packages/app/server/db/migrations/ carries a snapshot.json whose id/prevIds fields encode the migration lineage. A hand-cloned or hand-edited snapshot forks that chain, and today the fork surfaces only when the next pnpm db:gen fails with Non-commutative migrations detected — on a different machine, in a different session, long after the offending commit. The invariant is mechanical, so CI should assert it.
What works today
db:gen is the only sanctioned producer of snapshot.json, and the repo guidance (agent guide + drizzle skill) bans hand-cloning. Nothing verifies the invariant after the fact — a bad snapshot merges silently.
What this adds
One test file in packages/db-schema that globs */snapshot.json in the migrations folder and asserts:
- Unique ids — no two snapshots share an
id. - Linear chain — every snapshot's
prevIdshas exactly one element (the initial migration excepted), each referenced id exists, and walking parent links from the single head visits every snapshot exactly once — no forks, no orphans, no cycles. - Chain order matches folder order — the lineage walk sorted oldest-first matches the folders' timestamp-prefix sort, so a renamed folder can't silently reorder application.
- Folder shape — every migration folder contains exactly
migration.sql+snapshot.json, and every folder name keeps theYYYYMMDDHHMMSS_descriptionform.
Failure semantics
Assertion messages name the offending folder and the broken invariant (duplicate id, dangling prevIds, fork point, order mismatch). The fix is always the same: delete the hand-made state and regenerate with pnpm db:gen — the test's error message says so.
Key files
| File | Role |
|---|---|
packages/app/server/db/migrations/ | the validated tree — one folder per migration |
packages/db-schema/drizzle.config.ts | source of the migrations-folder path |
Notes
migration.sqlcontent is deliberately not validated: hand-fixing un-applied SQL is allowed and expected (data-preserving rewrites); only the machine-statesnapshot.jsonchain is guarded.- Zero new dependencies — a plain Vitest file reading JSON.