fix(migrations): keep revision tables aligned with their models - #548
fix(migrations): keep revision tables aligned with their models#548smartive-nicolai[bot] wants to merge 3 commits into
Conversation
Two independent gaps let `<Model>Revision` tables drift away from the model
definitions without `generate-migration` ever noticing.
1. `createRevisionTable` hardcoded `createdById` as `notNullable()`, ignoring
the model's `creatable: { createdBy: { nonNull: false } }` /
`updatable: { updatedBy: { nonNull: false } }`. A revision's author is the
creator on the first revision and the updater on every later one, so the
column can only be non-null when both entity columns are — it now mirrors
that instead of always being NOT NULL.
2. The generator only ever diffed model *fields* into an existing revision
table (`createRevisionFields`), never the fixed preamble. Revision tables
created before the deleteRoot feature therefore never received
`deleteRootType`/`deleteRootId`, and a `createdById` nullability that
contradicts the model was never corrected. `syncRevisionPreamble` now
reconciles both, and backfills a missing `deleted` from the entity table
rather than defaulting it, so existing revisions do not all claim the row
was never deleted.
Verified against a real consumer schema (56 revision tables): released 29.3.0
reports "no migration needed" for a database where 41 tables are missing the
deleteRoot columns and 5 contradict their model's createdBy nullability; the
patched build detects it, generates exactly those changes and nothing else,
and converges after applying. Rollback restores the previous state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
One caveat worth a reviewer's eye, since it is the one case I could not exercise against a real schema. The nullability sync goes both ways. Relaxing I left it failing loudly rather than inventing a backfill, because there is no defensible default author to write. But it means a consumer upgrading across this change may need a one-off data fix before the generated migration applies. Happy to soften it if you would rather the generator skipped the tightening and warned instead. For reference, the |
Per review: the cascade root belongs on the entity only. `createRevision` never writes these two columns, nothing ever reads them from a revision table, and the restore logic and permission checks both join the entity — so on revisions they are a snapshot taken once at table-creation time and never maintained. `createRevisionTable` no longer emits them, the initial-populate batch no longer copies them, and `syncRevisionPreamble` now drops them where an older generator left them behind (reversible). `deleted` stays: unlike the cascade root it is genuine per-revision state that `createRevision` maintains on every write. Setup migration regenerated accordingly — only the two revision tables change, the entities keep their columns. The full suite including the DB-backed delete/restore/inheritance api tests passes without them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed Why the evidence agrees:
So
Re-verifiedFull suite green against a real Postgres: 16 suites / 168 tests, including the DB-backed delete / restore / inheritance api tests — which still pass with the columns gone, which is the strongest evidence they were unused. Lint and build green. Against the same consumer schema as before, the generated migration is now much smaller and points the other way:
Applied → The |
…on tables" This reverts commit 0f43bc0.
|
Reverted in
Done as a revert commit rather than a force-push so the intermediate reasoning stays readable — happy to squash on merge. Re-verified after the revert: The |
<Model>Revisiontables can drift away from their model definitions in waysgqm generate-migrationnever reports. Two independent gaps, both insrc/migrations/generate.ts.1.
createdByIdignored the modelcreateRevisionTablehardcoded:regardless of the model's
creatable: { createdBy: { nonNull: false } }/updatable: { updatedBy: { nonNull: false } }. A revision's author is whoever performed the mutation — the creator on the first revision, the updater on every later one (createRevisionwritescreatedById: ctx.user?.id) — so the column can only be non-null when both entity columns are. It now mirrors that.2. The preamble was never reconciled on existing tables
For a revision table that already exists, the generator only diffed model fields (
createRevisionFields). The fixed preamble —<model>Id,createdById,createdAt, anddeleted/deleteRootType/deleteRootId— was never re-checked. Consequences:deleteRootType/deleteRootId, and never will;createdByIdnullability that contradicts the model is never corrected (revisionFieldNeedsSchemaAlterdeliberately diffs withrespectNullability: false, which is right for model fields — they are all nullable in revisions by design — but it means nothing covers the preamble).syncRevisionPreamblenow reconciles both directions, with reversibledownsteps. A missingdeletedis backfilled from the entity table rather than defaulted, so existing revisions don't all claim the row was never deleted.Verification
Unit tests (
tests/unit/migration-revision-tables.spec.ts, 8 cases) cover both create-time and existing-table paths, including two no-op guards so an aligned schema still generates nothing.Checked end-to-end against a real consumer schema with 56 revision tables:
gqm check-needs-migrationon a drifted DBdeleteRootType/deleteRootId, 5 getcreatedByIdrelaxed to match their modelcheck-needs-migrationclean;migrate:rollbackrestores the previous stateThe 5 tables whose
createdByIdcontradicted their model were exactly the ones whose revision table had been created by the generator under a nullable-createdBymodel; every table created by a hand-written migration already matched.npm run lint,npx jest tests/unit(13 suites / 151 tests) andnpm run buildare green. The checked-in setup migration is unchanged, since the test models all usecreatable: true/updatable: true.🤖 Generated with Claude Code