fix(schema): stop declaring ON DELETE actions the database does not have (BS#2239) - #2246
Conversation
…ave (BS#2239) Five foreign keys declared a referential action in schema.ts that the deployed database has never had -- verified confdeltype='a' (NO ACTION) in both the CI database built from the full migration chain and in production. drizzle-kit generate cannot see this: the snapshot already records the schema-source values, so no catch-up migration is ever emitted and the drift never surfaces on its own. Resolved by correcting schema.ts to match the database rather than by migrating the database to match schema.ts. Nothing in production deletes these parent rows -- no Drizzle .delete() and no raw DELETE against shows, artists, genres, or specialty_shows -- so de-declaring is a zero-runtime-change edit, whereas adding the cascades would arm five destructive cascades across decades of flowsheet and library history for no current caller. This is deliberately the opposite direction from BS#1126, and the code carries comments saying so. Adds a general guard: every foreign key Drizzle declares is compared against pg_constraint.confdeltype, so any future divergence fails regardless of whether anyone remembered to list it. BS#1126's spec hard-coded its own five constraints and a different five drifted underneath it -- an allowlist demonstrably does not prevent recurrence. That spec is left intact; it also exercises real delete behaviour, which this guard does not replace. Constraints are matched by (schema, table, columns) rather than by name: Postgres truncates identifiers over 63 bytes, and migration 0067 created flowsheet_linkage_review's FK with an inline column constraint that Postgres named by its own convention. Neither is real drift.
Schema constraint shape reportno new constraints detected in this diff (uniqueIndex, .unique(), SET NOT NULL, CHECK, FK) |
…oin key Three review findings on #2239. Snapshot divergence (the one with teeth). De-declaring the five false onDelete clauses left schema.ts and meta/0154_snapshot.json disagreeing where they had previously agreed. Drizzle diffs schema.ts against the LATEST snapshot, so the next drizzle:generate -- for some unrelated change, by someone not thinking about foreign keys -- would have silently bundled five DROP CONSTRAINT + five ADD CONSTRAINT statements into that migration. Re-adding a FK takes ACCESS EXCLUSIVE on both tables plus a full validation scan of the child; on artist_library_crossreference and genre_artist_crossreference that is a prod-sized scan riding along inside a migration nobody reviewed for it. The irony was the point: #2239 argues that recorded state must match reality, and in correcting schema.ts it left the snapshot lying. Noting the divergence in the PR body would have re-created the same someone-has-to-remember failure the issue exists to kill. Migration 0155 fixes it and deliberately carries no DDL. The generated statements drop each constraint and re-add it with the exact semantics it already has -- every one of the five is plain NO ACTION in prod AND in a CI database built from this chain from empty. The database needs no change; only Drizzle's bookkeeping does, and that lives in the snapshot alongside it. Validator check 8 (constraint-adding migrations want a precondition guard) stays silent for the same reason, and lint:migrations passes at 153 entries / 0 warnings. Guard join key. Both Maps built from identityKey() -- declared and observed -- used a bare Map.set, so two foreign keys over the same (schema, table, columns) would silently evict one another and drop a constraint out of the guard. That is the exact "coverage rots" failure this spec was written to prevent, so it must not be reintroduced by the matching key itself. setUnique() now refuses the overwrite and names both the side and the key. The review flagged only the declared side; observed had it too. No such pair exists today, which the guard passing confirms. Test timeout. jest.config.json sets testTimeout 30000 but jest.parallel.config.json sets none, so test:integration:parallel and ci:test:parallel fall back to Jest's 5s default. This spec opens with a synchronous execFileSync that boots Node and requires both drizzle-orm/pg-core and the built @wxyc/database; execFileSync blocks the worker, so Jest cannot interrupt it and only reports the timeout after the fact. Pinned explicitly so both configs behave the same. Verified: migrations + both FK specs green together (3 suites, 9 tests).
|
Pushed The substantive one: de-declaring the five Worth naming plainly: that is this issue's own failure mode one level up. The PR argues recorded state must match reality, and in fixing Migration Also fixed: both CI green on |
…ION block main is red. #2246 removed five false `onDelete` declarations from schema.ts but left `schema.fk-cascades.test.ts` asserting they were still there, so the suite fails on main at ddfa944 with 5 failures. CI did not catch it, and the reason generalizes. The unit-tests job runs Jest in affected-tests mode, and this spec reads schema.ts with fs.readFileSync rather than importing it — no edge in Jest's dependency graph, so changing schema.ts never marks the spec as related and it was never selected. #2246's run executed 244 suites / 4917 tests against a full suite of 462 / 8050. Filed as #2249. The five assertions are re-homed rather than deleted. The spec already had a "should NOT have onDelete (intentional NO ACTION)" block, which is now exactly where they belong, so the test keeps its protective value pointing the other way: it pins #2239's decision and fails if someone re-adds a cascade to schema.ts without also moving the deployed constraint. Deleting them would have left the decision unguarded at the unit level. `artist_library_crossreference.library_id` deliberately stays under "cascade" — #2239 did not touch it, and migration 0147 (BS#2112) repaired that constraint to CASCADE in the database, so the declaration is accurate. Adds an `expectNoOnDelete` helper mirroring the existing `expectOnDelete`, since the NO ACTION block was repeating the same four lines per case. Verified: 19/19 in this spec (unchanged count), and the full unit suite at 462 suites / 8050 tests passes, confirming this was the only breakage affected-mode was hiding. Refs #2239, #2246, #2249.
The `unit-tests` job ran `jest --changedSince=origin/<base>` on PRs. Jest builds that selection from the module dependency graph, so a spec that reads a source file as text (`fs.readFileSync`) rather than importing it has no edge to that file and is never selected by a change to the file it guards. This repo leans on that pattern heavily: 69 specs under `tests/unit/` read source as text, and 52 of them import nothing but `fs` and `path`. Measured against the real resolver, `--findRelatedTests shared/database/src/schema.ts` selects 250 suites and only 3 of the 31 `tests/unit/database/schema.*` specs written to guard that file; `shared/authentication/src/auth.definition.ts` selects 1 suite and none of its 5 guards. Volume is not the signal — a big green number is exactly what the failure mode looks like. That is how PR #2246 merged green while breaking 5 assertions in `tests/unit/database/schema.fk-cascades.test.ts`, the spec that holds #2239's per-constraint decision in place. The nightly full-suite run was the only backstop, one day late. The optimization was not buying time either. `unit-tests` gates no other job (`Integration-Tests` needs `[detect-changes, lint-and-typecheck]`), and it runs beside `lint-and-typecheck`, which is consistently about twice as long — 45-91s against 113-154s across recent runs. The largest observed affected run was 252 suites / 5002 tests in 38.3s; the full suite is 472 suites / 8181 tests in ~32s locally, so roughly +30s of runner time inside a minute of existing slack. The repo is public, so those minutes are free. Also drops `fetch-depth: 0` from the checkout, which existed only to give `--changedSince` history, and `--passWithNoTests`, which is only meaningful under selection. An allowlist of text-reading suites was considered and rejected: it reintroduces the list-somebody-must-remember that let a second set of five FKs drift underneath BS#1126's hardcoded regression spec, which is why #2239 shipped a general guard instead. `tests/unit/scripts/ci-unit-tests-full-suite.test.ts` pins the decision, asserting the job carries no selection flag and that the npm scripts it calls are unfiltered. Verified it fails when `--changedSince` is put back.
Closes #2239
Problem
Five foreign keys declared a referential action in
shared/database/src/schema.tsthat the deployed database has never had. The constraints are all plainNO ACTION(pg_constraint.confdeltype = 'a'), verified in both the CI database built from the full migration chain (aci:db-initfrom empty) and production — so this is not a local-only artifact.schema.tsdeclaredshow_djs.show_idonDelete: 'cascade'NO ACTIONartist_library_crossreference.artist_idonDelete: 'cascade'NO ACTIONgenre_artist_crossreference.artist_idonDelete: 'cascade'NO ACTIONgenre_artist_crossreference.genre_idonDelete: 'cascade'NO ACTIONschedule.specialty_idonDelete: 'set null'NO ACTIONdrizzle-kit generatestructurally cannot see this. The snapshot already records the schema-source values, so onceschema.tsand the snapshot agree with each other, no catch-up migration is emitted — the drift never surfaces on its own, no matter how many times the authoring loop runs. The database is the only witness.Direction:
schema.tswas corrected to match the database, not the reverseOne migration is included and it deliberately contains no DDL. The database already matches the corrected declarations; this PR removes five false declarations from
schema.tsand changes nothing about the deployed schema. Migration0155exists solely to re-sync the Drizzle snapshot — see "The snapshot had to be re-synced too" below.The two directions are not symmetric:
apps/andshared/: no Drizzle.delete(shows | artists | genres | specialty_shows)and no rawDELETE FROMagainst any of them, outside tests. Removing the declarations ends the falsehood immediately and forces any future delete path to make its cleanup explicit, in the PR that actually needs it.show_djs.show_idis the case with the clearest semantics — ashow_djsrow is meaningless without its show — and if a show-delete path is ever built, that PR should add the cascade deliberately. Today it would be arming a gun for a caller that does not exist.This is deliberately the opposite direction from #1126
tests/integration/fk-on-delete-flowsheet-rotation-reviews.spec.js(#1126) documents the identical bug class in a different five FKs, and resolved it by patching the database to matchschema.ts(migrations 0094/0097). This PR goes the other way, for the reason above.The inconsistency is intentional. Each de-declared FK in
schema.tscarries a comment saying so and pointing at #2239, so a future reader does not "fix" it back.The guard is general, not another allowlist
The #1126 spec hard-codes its own five constraints. A different five drifted underneath it anyway. That is empirical evidence that a hand-listed regression spec does not prevent recurrence of this class — nothing forces a new or edited FK onto the list.
tests/integration/fk-on-delete-general-guard.spec.jstherefore enumerates every foreign key Drizzle knows about (viagetTableConfigover every exported table in@wxyc/database, coveringpublicwhere better-auth's tables live and the domain schema) and compares each declaredonDeleteagainst the live database'spg_constraint.confdeltype. Any divergence fails, in either direction — including a FK the database has thatschema.tsnever declared. No list to remember to update.The #1126 spec is left intact: it also exercises real delete behaviour, which this guard does not replace.
Two implementation details that are load-bearing
Both look like they could be simplified. Neither can.
1. FKs are matched by
(schema, table, columns), not by constraint name. Two naming quirks make name-string matching produce false drift:getName()never matches what is actually stored for the longauth_oauth_*constraints.flowsheet_linkage_review's FK with an inlineREFERENCES … ON DELETE CASCADEcolumn constraint, which Postgres named by its own<table>_<column>_fkeyconvention rather than Drizzle's<table>_<column>_<reftable>_<refcolumn>_fk.Neither is real drift — both constraints match
schema.ts. They are naming artifacts, and the column tuple is what Postgres actually enforces identity by.2. The declaration collection runs in a plain
nodesubprocess (tests/utils/collect-declared-foreign-keys.js), not inside Jest. Requiringdrizzle-orm/pg-coreand@wxyc/databasein the same Jest file flips the latter's resolution from the builtdist/to the TypeScript source, which then pulls in a Jest-automockeddrizzle-ormwhose mock file fails to parse under the integration config's non-TS transform. Confirmed by isolating the two requires: either one alone resolves correctly, and adding the other in the same file (in either order) flips it. The subprocess sidesteps the interaction entirely rather than depending on it.Verification
tests/integration/fk-on-delete-general-guard.spec.jspasses against the CI database.The guard was also proven to fail on reintroduced drift — a guard that has never been seen fail is not a guard. Putting
{ onDelete: 'cascade' }back onshow_djs.show_id, rebuilding@wxyc/database, and re-running produced:It names the constraint and both sides. Then reverted and rebuilt.
Both FK specs run together green: 2 suites, 5 tests — this guard plus #1126's
fk-on-delete-flowsheet-rotation-reviews.spec.js.The full integration suite was not re-run locally: an earlier failed
ci:testmockrun leaves a poisoned database volume behind, and re-running against it produces a large number of red suites that are pure artifact. GitHub Actions CI is the authoritative clean-environment check for this PR.Files
shared/database/src/schema.ts— fiveonDeleteclauses removed, each replaced with a comment recording the Five foreign keys declare an ON DELETE action the database does not have (drizzle-kit cannot see it) #2239 decision and the deliberate divergence from FK ON DELETE drifted: flowsheet, rotation, reviews mismatch schema vs DB #1126.tests/integration/fk-on-delete-general-guard.spec.js— new general guard.tests/utils/collect-declared-foreign-keys.js— new subprocess helper that emits every declared FK as JSON.No schema change and no production behaviour change. The one migration included (
0155) carries the snapshot re-sync and no DDL whatsoever.The snapshot had to be re-synced too (review follow-up)
Code review caught a real hole in the first version of this PR, and it is worth recording because it is the same bug class the PR is about.
De-declaring the five
onDeleteclauses leftschema.tsandmeta/0154_snapshot.jsondisagreeing where they had previously agreed. Drizzle diffsschema.tsagainst the latest snapshot, so the nextdrizzle:generate— for some unrelated change, run by someone not thinking about foreign keys — would have silently bundled fiveDROP CONSTRAINT+ fiveADD CONSTRAINTstatements into that migration. Re-adding a foreign key takesACCESS EXCLUSIVEon both tables plus a full validation scan of the child; onartist_library_crossreferenceandgenre_artist_crossreferencethat is a prod-sized scan riding along inside a migration nobody reviewed for it.That is exactly this issue's own failure mode, one level up: the PR argues that recorded state must match reality, and in fixing
schema.tsit left the snapshot lying. Documenting the divergence here instead of fixing it would have re-created the same someone-has-to-remember dependency #2239 exists to remove.Migration
0155fixes it and carries no DDL. The statementsdrizzle-kitwanted to emit drop each constraint and re-add it with the exact semantics it already has — all five are plainNO ACTIONin production and in a CI database built from this chain from empty. The database needs no change; only Drizzle's bookkeeping does, and that lives inmeta/0155_snapshot.jsonnext to it. The.sqlfile is a comment block explaining why it is empty and instructing future readers not to "restore" the DDL.Two supporting facts, both verified rather than assumed:
scripts/validate-migrations.mjspasses at 153 entries / 0 warnings. Check 8 (constraint-adding migrations want a precondition guard) stays silent precisely because there is no constraint DDL — keeping the generated statements would have tripped it.CREATE/DROP, so a statement-less migration is a shape this repo's tooling already reasons in, not a novel pattern.Two smaller review fixes
identityKey()— theschema.tsside and the database side — used a bareMap.set, so two foreign keys over the same(schema, table, columns)would silently evict one another and one would vanish from the guard. That is the same "coverage rots" failure this spec was written to prevent, so it must not be reintroduced by the matching key itself.setUnique()now refuses the overwrite and names both the side and the key. Review flagged only theschema.tsside; the database side had it too. No such pair exists today — the guard passing is the proof.jest.config.jsonsetstestTimeout: 30000, butjest.parallel.config.json(test:integration:parallel,ci:test:parallel) sets none and falls back to Jest's 5 s default. This spec opens with a synchronousexecFileSyncthat boots Node and requires bothdrizzle-orm/pg-coreand the built@wxyc/database; because it blocks the worker, Jest cannot interrupt it and only reports the timeout after the fact. Pinned so both configs behave identically.The snapshot file is ~6.5k lines. That is a generated full-schema dump, not hand-written diff surface.