From 699720c8c6cc58be5e4cc442d2467701cb2345f3 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 12:48:00 +0300 Subject: [PATCH 01/39] feat(persistence): replace studies.variant CHECK constraint with FK to variants(code) --- docs/PROJECT_STATE.md | 18 ++- docs/ROADMAP.md | 2 +- .../migrations/0028_studies_variant_fk.sql | 8 ++ .../test/studies.integration.test.ts | 110 ++++++++++++++++-- scripts/check-variant-parity.mjs | 12 +- scripts/test/check-variant-parity.test.mjs | 5 + 6 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 packages/persistence/migrations/0028_studies_variant_fk.sql diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index ec95d5ad..dc689b22 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -4,7 +4,23 @@ > to read **only this file** and continue immediately. Updated after every > milestone and every significant architectural step. -_Last updated: 2026-08-29 — M15 Increment 41: Chess960 production integration (ADR-0137)._ +_Last updated: 2026-08-31 — M15 Increment 42: Studies variant database integrity (FK conversion)._ + +## M15 Increment 42 — Studies variant database integrity (FK conversion) + +`studies.variant` now derives directly from the canonical `variants` lookup table via a foreign key +constraint `studies_variant_fk` (`REFERENCES variants(code)` in migration `0028_studies_variant_fk.sql`), +replacing the duplicated inline `CHECK (variant IN (...))` constraint introduced in migration `0022`. + +All database variant columns (`games.variant`, `ratings.variant`, `seeks.variant`, and `studies.variant`) +now share identical relational integrity semantics: +- Inserting an unsupported variant code into `studies.variant` is rejected by PostgreSQL with SQLSTATE + `23503` (`foreign_key_violation`) referencing `studies_variant_fk`. +- Existing study rows retain `NOT NULL DEFAULT 'standard'`. +- Foreign key semantics use default `NO ACTION` (RESTRICT) to protect `variants(code)` against accidental + deletions while referenced by active studies. +- `scripts/check-variant-parity.mjs` verifies that `studies.variant` derives from `variants(code)` without + maintaining a redundant SQL CHECK mirror. ## M15 Increment 41 — Chess960 production integration (ADR-0137) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d6846cb5..862aae2b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,7 +28,7 @@ The correctness-critical foundation everything else depends on. - **Perft suites for each variant (RESOLVED in Increment 32 / ADR-0098 and completed in Increment 42).** All eight variants now have perft verification against published reference vectors or equality/divergence invariants. `horde` and `racingkings` coverage added from official `lichess-org/scalachess` perft resources (ADR-0098), resolving variant-rule defects in Horde rank-1 pawn double pushes and Racing Kings 8th-rank goal turn semantics. - **Chess960 was a label with nothing behind it (RESOLVED in M15 Increment 41 / ADR-0137; rules in ADR-0136).** Bigger than the "castling-by-file" wording suggested, and verified by running the code. (1) `Position.initial('chess960')` returns the standard array on every call, and `packages/game/src/game.ts:92` uses it for any seek without an explicit FEN — so a Chess960 game was ordinary chess. (2) `generateCastles` in `packages/chess-core/src/movegen.ts` pins the king to e1/e8 and looks for rooks at fixed offsets, so castling generates for exactly one of the 960 start positions, and that one is standard chess: king on b1 with rooks a1/h1 produces 0 castling moves, as does king g1 with rooks f1/h1. (3) `packages/chess-core/src/fen.ts` discards file-letter castling rights, so `HAha` on kiwipete gives `perft(1) = 46`, identical to no rights, against 48 for `KQkq`. **Withheld in Increment 33 (ADR-0099):** removed from the lobby's offered variants so nobody receives a mislabelled standard game; still accepted by the API and still a rule set in `chess-core`. **Open:** implementing it — 960-position generation, castling from arbitrary king and rook squares, Shredder/X-FEN in and out, the UCI king-takes-rook encoding, SAN, and perft against published values. **Server contract closed in M15 Increment 14 (ADR-0123):** withholding it in the lobby only protected browser users — `OFFERED_VARIANTS` is a list in the web bundle, and every other client (script, bot, mobile, curl) still reached `Game.create`, which wrote `variant: 'chess960'` beside a standard `initialFen` into an append-only event store. That is a durable falsehood, not a UI wart: afterwards nothing can tell such a row from a real Chess960 game. `Game.create` now refuses the variant outright — the one place every game is born, so seek acceptance, the bot route and the tournament launcher all inherit it — and `CREATABLE_VARIANTS` carries the same rule at the three creation routes so the refusal arrives as the API’s ordinary 422 rather than the 500 an unmapped `GameError` would produce. Seek acceptance re-checks the stored variant (409) because that value comes from a row, not a request. `chess960` remains valid everywhere that reads — the enum, the `variants` table, and every View schema — and only the three Request schemas narrowed. **Rules implemented in ADR-0136:** all 960 arrangements from the Scharnagl numbering, castling from arbitrary king and rook squares, Shredder-FEN in and canonical X-FEN out, the UCI king-takes-rook encoding, SAN unchanged, and perft against all 960 published reference positions — with the refusal deliberately kept, because the engine could now play any arrangement but nothing could yet *tell* it which one. **RESOLVED in M15 Increment 41 (ADR-0137):** `GameCreated` carries an optional `chess960StartId`, and the server draws it — `crypto.randomInt` at seek acceptance and on the bot route, derived from the launch identity for tournaments, so racing replicas agree on the arrangement instead of each drawing their own. Replay validates the stored id against the stored FEN rather than trusting either alone, and a legacy `chess960` event with no id replays from its FEN and reports its start as unknown, never as 518 — the guess that would look plausible. `Game.create` requires the id for the variant and refuses it for every other; `CREATABLE_VARIANTS` and `OFFERED_VARIANTS` admit `chess960`; `openapi.json` is regenerated. The seek-accept 409 is *kept* rather than removed as the checklist said, because `seek.variant` is read from a database column and the type system does not span the SQL (ADR-0137 §6). Move *input* needed no Chess960 logic in the browser — `BoardInteraction` is oracle-driven and the server's legal-move map already spells castling king-takes-rook — but move *projection* did: `applyMove` advances the client's own board between snapshots and recognised castling only at exactly two files, projecting `d1a1` as a king on a1 with the rook deleted. It now treats a king landing on a friendly rook as a castle (ADR-0137 §8). **Nothing left open on the variant.** - **`Position.snapshot()` lost three-check state (RESOLVED in M15 Increment 8).** `snapshot()` in `packages/chess-core/src/position.ts` round-tripped through `parseFen(this.fen(), variant)`, and `toFen` does not serialise `checkCount`, so both counters reset to zero. Found during the Increment 33 audit (ADR-0099 §4) and recorded there as "latent, not live" on the grounds that a repetition key uses only the first four FEN fields. **That assessment was wrong.** `packages/chess-core/src/repetition.ts` had appended the delivered-check counters to the key for `threecheck` since 2026-07-13 — three weeks before the audit — and `packages/game/src/game.ts` builds that key from the lossy snapshot on both the live and replay paths. Every three-check position therefore reported `0+0`, and a board that repeated while the check counts climbed was treated as a repetition: `Re1+ Kf8 Rd1 Ke8 Re1+ Kf8 Rd1 Ke8` was declared a threefold draw with White one check from winning. **Resolved in M15 Increment 8:** `snapshot()` returns `cloneState(this.state)`, the existing authoritative deep copy, so no `PositionState` field is dropped; the line above now continues and White wins `1-0` on the third check. Serialising three-check counters into FEN was deferred to M15 Increment 9 and is **RESOLVED** there (ADR-0120): `toFen` emits the canonical Fairy-Stockfish field — `N+M` remaining, in field five — and `parseFen` accepts that, the trailing `+N+M` delivered form, and the legacy six-field form. The engine defect it was hiding is closed with it: Fairy-Stockfish 14 reads a missing counter field as `1+1`, so every three-check analysis had been scored as though one check won the game. -- **The supported-variant list is written out seven times (GUARDED in M15 Increment 10).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, the `variants` lookup table, and the `CHECK` on `studies.variant` added by Increment 9 — seven hand-maintained copies, none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the six mirrors to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Open:** converting `studies.variant` to `REFERENCES variants(code)` like every other variant column, which would remove one copy — deferred deliberately, since it does not change the failure mode the guard closes. +- **The supported-variant list is written out seven times (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, the `variants` lookup table, and the `CHECK` on `studies.variant` added by Increment 9 — seven hand-maintained copies, none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the six mirrors to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Resolved in M15 Increment 42:** `studies.variant` now references `variants(code)` via `FOREIGN KEY` (migration `0028_studies_variant_fk.sql`), dropping the duplicated `CHECK` constraint so that all database variant columns derive from the canonical `variants` lookup table. - **CI depends on the Ubuntu package mirror for Stockfish (RESOLVED in M15 Increment 11 / ADR-0121).** The `analysis smoke` job apt-installs Stockfish, and that step has now stalled indefinitely three times: once on PR #140 (cancelled and re-run successfully) and twice post-merge on `cbe6bce` (jobs `96156044656` and `96200357632`, the rerun cancelled after ~34 minutes). Each stall was in the mirror step, before Fairy-Stockfish was installed and before any smoke test ran, so it proves nothing about the code and costs the full job timeout. Fairy-Stockfish in the same job is already a pinned, checksummed release download and has never stalled. **Resolved in M15 Increment 11 (ADR-0121):** Stockfish now comes from release `sf_16`, asset `stockfish-ubuntu-x86-64.tar`, pinned by SHA-256 `efca1c60ec11fd9628425f3ee40644ad1618535ddf881c16385a86f7fc9e0983`, extracted one member by exact path, `chmod`ed only after verification, and asserted to report `id name Stockfish 16` before the suite runs. `sf_16` is the version apt was already serving, so the engine under test is unchanged. `apt-get` no longer appears in any executable line of any workflow, and the job now carries `timeout-minutes: 15` so a future stall is capped rather than inheriting the six-hour default. Production Docker images used apt until **M15 Increment 12**, which closed the last of it. Before that, `release.yml` built `Dockerfile.api` and `Dockerfile.gateway` on a `v*` tag push and those builds ran the apt layers — meaning production shipped Debian bookworm's `stockfish 15.1-4` while CI proved the engine boundary against 16, and a base-image move to trixie would have made it 17 with no commit of ours. Both images now take the binary from a pinned `stockfish` artefact stage using the same release, asset and digest as CI, with the licence and corresponding source copied beside it for GHCR redistribution, and a `docker-images` CI job builds both before merge instead of first exercising them at release time. `scripts/check-engine-pin-parity.mjs` fails if the four copies of the pin ever disagree. - **The web image was published but never built before the tag (RESOLVED in M15 Increment 12).** `release.yml` pushes three images to GHCR on a `v*` tag — `Dockerfile.api`, `Dockerfile.gateway` and `Dockerfile.web` — but the `docker-images` CI job as first written built only the two that carry the pinned engine. `Dockerfile.web` copies `docker/web/nginx.conf.template` into `/etc/nginx/templates/`, where the image entrypoint runs `envsubst` over it at container start, so a broken template is not a build error at all: the image builds clean and the container dies on boot, and nothing before the release tag rendered it. Raised in the Qodo review of PR #143. **Resolved in the same increment:** the job builds all three images and checks the web one for what can actually break in it — the entrypoint renders the template with representative loopback upstreams (`nginx -t` resolves a literal `proxy_pass` host at config-load time, so the compose defaults would fail on a runner for the wrong reason), `nginx -t` must accept the result, both upstreams must appear substituted, and `$http_host` and `$uri` must survive, which is what `NGINX_ENVSUBST_FILTER` exists to guarantee and nothing tested. `docker/` joins the `images` path filter so the filter and the job cover the same set. - **A study movetext walker that never asked which variant it was reading (RESOLVED in M15 Increment 13 / ADR-0122).** `importGame` in `packages/studies/src/import.ts` resolved every SAN through `resolveSan(reader, fen, san)` and `reader.play(fen, san)` with no variant, and `resolveSan` defaults to standard rather than failing — so a Crazyhouse or Three-Check game imported through it would have been validated against standard chess, rejecting legal moves and accepting illegal ones with no error to say why. Latent, not live: a whole-repository search found it referenced only by its own definition and its own test file, and both real import paths (`InMemoryStudiesRepository.buildTreeFromMovetext` via `appendNode`, and `PgStudiesRepository.buildTreeFromMovetextInternal` via a required parameter) already thread the study variant correctly. It was also a third implementation of a descent the two adapters already have, and ADR-0091 §10 records what happened the last time two copies of this walk diverged. **Resolved in M15 Increment 13 (ADR-0122):** deleted, together with the types and the `START_FEN` alias that existed only to serve it; `chapterNameFor` stays, because both adapters import it. `resolveSan`’s standard default is kept and now pinned by a test that states why — `@chess-platform/learning` relies on it and lessons carry no variant of their own. The coverage that was only reachable through the dead function moved onto `resolveSan` itself, and variant propagation through side variations — previously unverified, since the existing three-check test had no variations — is now a mutation-checked regression test. diff --git a/packages/persistence/migrations/0028_studies_variant_fk.sql b/packages/persistence/migrations/0028_studies_variant_fk.sql new file mode 100644 index 00000000..5355e317 --- /dev/null +++ b/packages/persistence/migrations/0028_studies_variant_fk.sql @@ -0,0 +1,8 @@ +-- Migration 0028: Replace studies.variant CHECK constraint with FOREIGN KEY referencing variants(code) + +ALTER TABLE studies + DROP CONSTRAINT studies_variant_check; + +ALTER TABLE studies + ADD CONSTRAINT studies_variant_fk + FOREIGN KEY (variant) REFERENCES variants(code); diff --git a/packages/persistence/test/studies.integration.test.ts b/packages/persistence/test/studies.integration.test.ts index c1f45d47..ee99e69e 100644 --- a/packages/persistence/test/studies.integration.test.ts +++ b/packages/persistence/test/studies.integration.test.ts @@ -28,6 +28,19 @@ class CorePositionReader implements PositionReader { } } +interface PgErrorShape { + code?: string; + constraint?: string; +} + +function isPgConstraintViolation(err: unknown, code: string, constraint?: string): boolean { + if (typeof err !== 'object' || err === null) return false; + const pgErr = err as PgErrorShape; + if (pgErr.code !== code) return false; + if (constraint !== undefined && pgErr.constraint !== constraint) return false; + return true; +} + test('pg studies repository integration tests', { skip }, async () => { const pool = createPool(); await migrate(pool, join(process.cwd(), 'migrations')); @@ -70,7 +83,7 @@ test('pg studies repository integration tests', { skip }, async () => { `INSERT INTO study_collaborators (study_id, player_id, role) VALUES ($1, $2, 'owner')`, [studyId1, bob] ), - (err: any) => err && err.code === '23505' + (err: unknown) => isPgConstraintViolation(err, '23505') ); // 2. Collaborators and Ownership Transfer (demotes before promoting) @@ -81,7 +94,7 @@ test('pg studies repository integration tests', { skip }, async () => { // New owner must already be a collaborator await assert.rejects( async () => repo.transferOwnership(studyId1, alice, charlie, t2), - (err: any) => err instanceof StudyRuleError && err.code === 'not_found' + (err: unknown) => err instanceof StudyRuleError && err.code === 'not_found' ); // Transfer ownership from Alice to Bob @@ -97,7 +110,7 @@ test('pg studies repository integration tests', { skip }, async () => { // Non-collaborator gets not_found await assert.rejects( async () => repo.getStudy(privStudyId, charlie), - (err: any) => err instanceof StudyRuleError && err.code === 'not_found' + (err: unknown) => err instanceof StudyRuleError && err.code === 'not_found' ); // Unlisted study @@ -126,7 +139,7 @@ test('pg studies repository integration tests', { skip }, async () => { `INSERT INTO study_chapters (id, study_id, name, order_index, starting_fen) VALUES ($1, $2, 'Bad Order', 0, $3)`, [uuidv7(), studyId1, ch1.startingFen] ), - (err: any) => err && err.code === '23505' + (err: unknown) => isPgConstraintViolation(err, '23505') ); // Reorder chapters @@ -142,7 +155,7 @@ test('pg studies repository integration tests', { skip }, async () => { await assert.rejects( async () => repo.deleteChapter(chId2, bob, t2), - (err: any) => err instanceof StudyRuleError && err.code === 'invalid_transition' + (err: unknown) => err instanceof StudyRuleError && err.code === 'invalid_transition' ); // 5. Tree Node Management (append, resolve SAN, return existing child, delete node cascade) @@ -217,13 +230,13 @@ test('pg studies repository integration tests', { skip }, async () => { await assert.rejects( async () => repo.importPgn(studyId1, bob, `[Event "Bad"]\n\n1. e4 invalidmove *`, reader, t2), - (err: any) => err instanceof StudyRuleError && err.code === 'invalid_input' + (err: unknown) => err instanceof StudyRuleError && err.code === 'invalid_input' ); // `Nf6` is perfectly good SAN — there is simply no white knight that can reach f6 on move 2. await assert.rejects( async () => repo.importPgn(studyId1, bob, `[Event "Bad"]\n\n1. e4 e5 2. Nf6 *`, reader, t2), - (err: any) => + (err: unknown) => err instanceof StudyRuleError && err.code === 'invalid_move' && /Nf6/.test(err.message) ); @@ -270,7 +283,7 @@ test('pg studies repository integration tests', { skip }, async () => { for (const badId of ['not-a-uuid', 'bad-id-123']) { await assert.rejects( async () => repo.getStudy(badId, alice), - (err: any) => err instanceof StudyRuleError && err.code === 'not_found', + (err: unknown) => err instanceof StudyRuleError && err.code === 'not_found', `getStudy('${badId}') must yield not_found` ); } @@ -284,7 +297,86 @@ test('pg studies repository integration tests', { skip }, async () => { await assert.rejects( async () => repo.getStudy(cascadeStudyId, alice), - (err: any) => err instanceof StudyRuleError && err.code === 'not_found' + (err: unknown) => err instanceof StudyRuleError && err.code === 'not_found' + ); + + // 10. Variant Foreign Key and Integrity (Migration 0028) + // 10.1 Metadata verification: FK constraint exists and points to variants(code) + const fkRes = await pool.query<{ + constraint_name: string; + table_name: string; + column_name: string; + foreign_table_name: string; + foreign_column_name: string; + }>(` + SELECT + tc.constraint_name, + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema + WHERE tc.table_name = 'studies' + AND tc.constraint_type = 'FOREIGN KEY' + AND kcu.column_name = 'variant' + `); + assert.equal(fkRes.rows.length, 1, 'expected exactly one FK constraint on studies.variant'); + assert.equal(fkRes.rows[0]?.constraint_name, 'studies_variant_fk'); + assert.equal(fkRes.rows[0]?.foreign_table_name, 'variants'); + assert.equal(fkRes.rows[0]?.foreign_column_name, 'code'); + + // 10.2 Absence of old CHECK constraint + const oldCheckRes = await pool.query<{ conname: string }>(` + SELECT conname + FROM pg_constraint + WHERE conrelid = 'studies'::regclass + AND contype = 'c' + AND conname = 'studies_variant_check' + `); + assert.equal(oldCheckRes.rows.length, 0, 'old CHECK constraint studies_variant_check must no longer exist'); + + // 10.3 Invalid variant insertion rejected by FK constraint (23503) + const badStudyId = uuidv7(); + await assert.rejects( + async () => + pool.query( + `INSERT INTO studies (id, owner_id, name, description, visibility, variant, created_at, updated_at) + VALUES ($1, $2, 'Bad Variant Study', '', 'public', 'nonexistent_variant', NOW(), NOW())`, + [badStudyId, alice] + ), + (err: unknown) => isPgConstraintViolation(err, '23503', 'studies_variant_fk'), + 'inserting an invalid variant must raise foreign_key_violation (23503) referencing studies_variant_fk' + ); + + // 10.4 All 8 canonical StudyVariants can be created and queried through repository + const ALL_VARIANTS: readonly StudyVariant[] = [ + 'standard', + 'chess960', + 'kingofthehill', + 'atomic', + 'crazyhouse', + 'threecheck', + 'horde', + 'racingkings', + ]; + for (const v of ALL_VARIANTS) { + const vStudyId = uuidv7(); + const vStudy = await repo.createStudy(vStudyId, alice, `Study ${v}`, '', 'public', t0, { variant: v }); + assert.equal(vStudy.id, vStudyId); + assert.equal(vStudy.variant, v); + const fetched = await repo.getStudy(vStudyId, alice); + assert.equal(fetched.variant, v); + } + + // 10.5 Referencing study protects variants(code) from deletion (NO ACTION / RESTRICT) + await assert.rejects( + async () => pool.query(`DELETE FROM variants WHERE code = 'standard'`), + (err: unknown) => isPgConstraintViolation(err, '23503', 'studies_variant_fk'), + 'deleting a referenced variant row must be rejected with foreign_key_violation (23503)' ); } finally { if (createdUserIds.length > 0) { diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index fcb840b2..1f4bbc31 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -6,14 +6,12 @@ * nothing derives from anything else. That is survivable only while they match, and there was no * check that they do. * - * The sharp edge is the database. Every other variant column is + * The database variant columns (games, ratings, seeks, and studies via migration 0028) are * `variant TEXT NOT NULL REFERENCES variants(code)`, so once a row exists in the `variants` lookup - * table the database accepts that value in the games and ratings columns. `studies.variant` alone - * (migration 0022, M15 Increment 9) is governed by an inline `CHECK (variant IN (...))`, so the - * same row does nothing for studies: the type system says the variant is fine, the API accepts it, - * and Postgres rejects the insert at runtime as a constraint violation. The application-level - * declarations below still need their own updates in either case — the lookup row settles only - * what the *database* will store. + * table the database accepts that value uniformly. `studies.variant` was initially governed by an + * inline `CHECK (variant IN (...))` in migration 0022 and converted to `REFERENCES variants(code)` + * in migration 0028. The application-level declarations below still need their own updates in either + * case — the lookup row settles what the *database* will store. * * `chess-core`'s `Variant` is treated as the root: it is the type the engine actually branches on, * so a variant that is not there is not a variant at all. Every other list is compared to it. diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 80573815..c69d7060 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -22,6 +22,7 @@ import { disagreements, ROOT, TS_MIRRORS, + MIGRATIONS_DIR, } from '../check-variant-parity.mjs'; /** A throwaway migration directory. Files are named so the runner's ordering applies. */ @@ -307,6 +308,10 @@ ALTER TABLE studies ADD CONSTRAINT studies_variant_fk } }); +test('the committed migrations directory leaves studies.variant derived from foreign key with no CHECK', () => { + assert.equal(effectiveStudyVariantConstraint(MIGRATIONS_DIR), null); +}); + test('a renamed declaration fails loudly instead of checking nothing', () => { // The failure mode that makes a guard worse than no guard: it keeps passing having stopped // looking at anything. From 55ba0b6a7e71ed0e75f88653572de96da923828f Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 12:58:04 +0300 Subject: [PATCH 02/39] test(persistence): isolate variant deletion test to custom variant for exact constraint match --- .../test/studies.integration.test.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/persistence/test/studies.integration.test.ts b/packages/persistence/test/studies.integration.test.ts index ee99e69e..a5c1de67 100644 --- a/packages/persistence/test/studies.integration.test.ts +++ b/packages/persistence/test/studies.integration.test.ts @@ -372,12 +372,26 @@ test('pg studies repository integration tests', { skip }, async () => { assert.equal(fetched.variant, v); } - // 10.5 Referencing study protects variants(code) from deletion (NO ACTION / RESTRICT) - await assert.rejects( - async () => pool.query(`DELETE FROM variants WHERE code = 'standard'`), - (err: unknown) => isPgConstraintViolation(err, '23503', 'studies_variant_fk'), - 'deleting a referenced variant row must be rejected with foreign_key_violation (23503)' + // 10.5 Referencing study specifically protects variants(code) via studies_variant_fk (NO ACTION / RESTRICT) + await pool.query( + `INSERT INTO variants (code, name, enabled) VALUES ('test_fk_protection_variant', 'Test FK Variant', true)` + ); + const customVarStudyId = uuidv7(); + await pool.query( + `INSERT INTO studies (id, owner_id, name, description, visibility, variant, created_at, updated_at) + VALUES ($1, $2, 'Custom Variant Study', '', 'public', 'test_fk_protection_variant', NOW(), NOW())`, + [customVarStudyId, alice] ); + try { + await assert.rejects( + async () => pool.query(`DELETE FROM variants WHERE code = 'test_fk_protection_variant'`), + (err: unknown) => isPgConstraintViolation(err, '23503', 'studies_variant_fk'), + 'deleting a variant referenced only by studies must raise foreign_key_violation on studies_variant_fk' + ); + } finally { + await pool.query(`DELETE FROM studies WHERE id = $1`, [customVarStudyId]); + await pool.query(`DELETE FROM variants WHERE code = 'test_fk_protection_variant'`); + } } finally { if (createdUserIds.length > 0) { await pool.query(`DELETE FROM users WHERE id = ANY($1)`, [createdUserIds]); From 883ef9c551845a7a813ad9b8343b6e18eda6a440 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 13:07:43 +0300 Subject: [PATCH 03/39] fix(persistence): split FK addition and validation into migrations 0028/0029 and harden parity guards --- docs/PROJECT_STATE.md | 11 ++++--- docs/ROADMAP.md | 2 +- .../migrations/0028_studies_variant_fk.sql | 2 +- .../0029_validate_studies_variant_fk.sql | 4 +++ .../test/studies.integration.test.ts | 8 +++++ scripts/check-variant-parity.mjs | 32 +++++++++++++++++-- scripts/test/check-variant-parity.test.mjs | 17 ++++++++++ 7 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 packages/persistence/migrations/0029_validate_studies_variant_fk.sql diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index dc689b22..ce8f27dc 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -9,18 +9,19 @@ _Last updated: 2026-08-31 — M15 Increment 42: Studies variant database integri ## M15 Increment 42 — Studies variant database integrity (FK conversion) `studies.variant` now derives directly from the canonical `variants` lookup table via a foreign key -constraint `studies_variant_fk` (`REFERENCES variants(code)` in migration `0028_studies_variant_fk.sql`), -replacing the duplicated inline `CHECK (variant IN (...))` constraint introduced in migration `0022`. +constraint `studies_variant_fk` (`REFERENCES variants(code)` added with `NOT VALID` in migration +`0028_studies_variant_fk.sql` and validated in `0029_validate_studies_variant_fk.sql`), replacing the +duplicated inline `CHECK (variant IN (...))` constraint introduced in migration `0022`. All database variant columns (`games.variant`, `ratings.variant`, `seeks.variant`, and `studies.variant`) now share identical relational integrity semantics: - Inserting an unsupported variant code into `studies.variant` is rejected by PostgreSQL with SQLSTATE `23503` (`foreign_key_violation`) referencing `studies_variant_fk`. - Existing study rows retain `NOT NULL DEFAULT 'standard'`. -- Foreign key semantics use default `NO ACTION` (RESTRICT) to protect `variants(code)` against accidental - deletions while referenced by active studies. +- Foreign key semantics use default `NO ACTION` to protect `variants(code)` against accidental deletions + while referenced by active studies. - `scripts/check-variant-parity.mjs` verifies that `studies.variant` derives from `variants(code)` without - maintaining a redundant SQL CHECK mirror. + maintaining a redundant SQL CHECK mirror, and validates foreign key presence across migrations. ## M15 Increment 41 — Chess960 production integration (ADR-0137) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 862aae2b..5648b7f4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,7 +28,7 @@ The correctness-critical foundation everything else depends on. - **Perft suites for each variant (RESOLVED in Increment 32 / ADR-0098 and completed in Increment 42).** All eight variants now have perft verification against published reference vectors or equality/divergence invariants. `horde` and `racingkings` coverage added from official `lichess-org/scalachess` perft resources (ADR-0098), resolving variant-rule defects in Horde rank-1 pawn double pushes and Racing Kings 8th-rank goal turn semantics. - **Chess960 was a label with nothing behind it (RESOLVED in M15 Increment 41 / ADR-0137; rules in ADR-0136).** Bigger than the "castling-by-file" wording suggested, and verified by running the code. (1) `Position.initial('chess960')` returns the standard array on every call, and `packages/game/src/game.ts:92` uses it for any seek without an explicit FEN — so a Chess960 game was ordinary chess. (2) `generateCastles` in `packages/chess-core/src/movegen.ts` pins the king to e1/e8 and looks for rooks at fixed offsets, so castling generates for exactly one of the 960 start positions, and that one is standard chess: king on b1 with rooks a1/h1 produces 0 castling moves, as does king g1 with rooks f1/h1. (3) `packages/chess-core/src/fen.ts` discards file-letter castling rights, so `HAha` on kiwipete gives `perft(1) = 46`, identical to no rights, against 48 for `KQkq`. **Withheld in Increment 33 (ADR-0099):** removed from the lobby's offered variants so nobody receives a mislabelled standard game; still accepted by the API and still a rule set in `chess-core`. **Open:** implementing it — 960-position generation, castling from arbitrary king and rook squares, Shredder/X-FEN in and out, the UCI king-takes-rook encoding, SAN, and perft against published values. **Server contract closed in M15 Increment 14 (ADR-0123):** withholding it in the lobby only protected browser users — `OFFERED_VARIANTS` is a list in the web bundle, and every other client (script, bot, mobile, curl) still reached `Game.create`, which wrote `variant: 'chess960'` beside a standard `initialFen` into an append-only event store. That is a durable falsehood, not a UI wart: afterwards nothing can tell such a row from a real Chess960 game. `Game.create` now refuses the variant outright — the one place every game is born, so seek acceptance, the bot route and the tournament launcher all inherit it — and `CREATABLE_VARIANTS` carries the same rule at the three creation routes so the refusal arrives as the API’s ordinary 422 rather than the 500 an unmapped `GameError` would produce. Seek acceptance re-checks the stored variant (409) because that value comes from a row, not a request. `chess960` remains valid everywhere that reads — the enum, the `variants` table, and every View schema — and only the three Request schemas narrowed. **Rules implemented in ADR-0136:** all 960 arrangements from the Scharnagl numbering, castling from arbitrary king and rook squares, Shredder-FEN in and canonical X-FEN out, the UCI king-takes-rook encoding, SAN unchanged, and perft against all 960 published reference positions — with the refusal deliberately kept, because the engine could now play any arrangement but nothing could yet *tell* it which one. **RESOLVED in M15 Increment 41 (ADR-0137):** `GameCreated` carries an optional `chess960StartId`, and the server draws it — `crypto.randomInt` at seek acceptance and on the bot route, derived from the launch identity for tournaments, so racing replicas agree on the arrangement instead of each drawing their own. Replay validates the stored id against the stored FEN rather than trusting either alone, and a legacy `chess960` event with no id replays from its FEN and reports its start as unknown, never as 518 — the guess that would look plausible. `Game.create` requires the id for the variant and refuses it for every other; `CREATABLE_VARIANTS` and `OFFERED_VARIANTS` admit `chess960`; `openapi.json` is regenerated. The seek-accept 409 is *kept* rather than removed as the checklist said, because `seek.variant` is read from a database column and the type system does not span the SQL (ADR-0137 §6). Move *input* needed no Chess960 logic in the browser — `BoardInteraction` is oracle-driven and the server's legal-move map already spells castling king-takes-rook — but move *projection* did: `applyMove` advances the client's own board between snapshots and recognised castling only at exactly two files, projecting `d1a1` as a king on a1 with the rook deleted. It now treats a king landing on a friendly rook as a castle (ADR-0137 §8). **Nothing left open on the variant.** - **`Position.snapshot()` lost three-check state (RESOLVED in M15 Increment 8).** `snapshot()` in `packages/chess-core/src/position.ts` round-tripped through `parseFen(this.fen(), variant)`, and `toFen` does not serialise `checkCount`, so both counters reset to zero. Found during the Increment 33 audit (ADR-0099 §4) and recorded there as "latent, not live" on the grounds that a repetition key uses only the first four FEN fields. **That assessment was wrong.** `packages/chess-core/src/repetition.ts` had appended the delivered-check counters to the key for `threecheck` since 2026-07-13 — three weeks before the audit — and `packages/game/src/game.ts` builds that key from the lossy snapshot on both the live and replay paths. Every three-check position therefore reported `0+0`, and a board that repeated while the check counts climbed was treated as a repetition: `Re1+ Kf8 Rd1 Ke8 Re1+ Kf8 Rd1 Ke8` was declared a threefold draw with White one check from winning. **Resolved in M15 Increment 8:** `snapshot()` returns `cloneState(this.state)`, the existing authoritative deep copy, so no `PositionState` field is dropped; the line above now continues and White wins `1-0` on the third check. Serialising three-check counters into FEN was deferred to M15 Increment 9 and is **RESOLVED** there (ADR-0120): `toFen` emits the canonical Fairy-Stockfish field — `N+M` remaining, in field five — and `parseFen` accepts that, the trailing `+N+M` delivered form, and the legacy six-field form. The engine defect it was hiding is closed with it: Fairy-Stockfish 14 reads a missing counter field as `1+1`, so every three-check analysis had been scored as though one check won the game. -- **The supported-variant list is written out seven times (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, the `variants` lookup table, and the `CHECK` on `studies.variant` added by Increment 9 — seven hand-maintained copies, none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the six mirrors to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Resolved in M15 Increment 42:** `studies.variant` now references `variants(code)` via `FOREIGN KEY` (migration `0028_studies_variant_fk.sql`), dropping the duplicated `CHECK` constraint so that all database variant columns derive from the canonical `variants` lookup table. +- **The supported-variant list is written out seven times (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, the `variants` lookup table, and the `CHECK` on `studies.variant` added by Increment 9 — seven hand-maintained copies, none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the six mirrors to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Resolved in M15 Increment 42:** `studies.variant` now references `variants(code)` via `FOREIGN KEY` (added with `NOT VALID` in `0028_studies_variant_fk.sql` and validated in `0029_validate_studies_variant_fk.sql`), dropping the duplicated `CHECK` constraint so that all database variant columns derive from the canonical `variants` lookup table. - **CI depends on the Ubuntu package mirror for Stockfish (RESOLVED in M15 Increment 11 / ADR-0121).** The `analysis smoke` job apt-installs Stockfish, and that step has now stalled indefinitely three times: once on PR #140 (cancelled and re-run successfully) and twice post-merge on `cbe6bce` (jobs `96156044656` and `96200357632`, the rerun cancelled after ~34 minutes). Each stall was in the mirror step, before Fairy-Stockfish was installed and before any smoke test ran, so it proves nothing about the code and costs the full job timeout. Fairy-Stockfish in the same job is already a pinned, checksummed release download and has never stalled. **Resolved in M15 Increment 11 (ADR-0121):** Stockfish now comes from release `sf_16`, asset `stockfish-ubuntu-x86-64.tar`, pinned by SHA-256 `efca1c60ec11fd9628425f3ee40644ad1618535ddf881c16385a86f7fc9e0983`, extracted one member by exact path, `chmod`ed only after verification, and asserted to report `id name Stockfish 16` before the suite runs. `sf_16` is the version apt was already serving, so the engine under test is unchanged. `apt-get` no longer appears in any executable line of any workflow, and the job now carries `timeout-minutes: 15` so a future stall is capped rather than inheriting the six-hour default. Production Docker images used apt until **M15 Increment 12**, which closed the last of it. Before that, `release.yml` built `Dockerfile.api` and `Dockerfile.gateway` on a `v*` tag push and those builds ran the apt layers — meaning production shipped Debian bookworm's `stockfish 15.1-4` while CI proved the engine boundary against 16, and a base-image move to trixie would have made it 17 with no commit of ours. Both images now take the binary from a pinned `stockfish` artefact stage using the same release, asset and digest as CI, with the licence and corresponding source copied beside it for GHCR redistribution, and a `docker-images` CI job builds both before merge instead of first exercising them at release time. `scripts/check-engine-pin-parity.mjs` fails if the four copies of the pin ever disagree. - **The web image was published but never built before the tag (RESOLVED in M15 Increment 12).** `release.yml` pushes three images to GHCR on a `v*` tag — `Dockerfile.api`, `Dockerfile.gateway` and `Dockerfile.web` — but the `docker-images` CI job as first written built only the two that carry the pinned engine. `Dockerfile.web` copies `docker/web/nginx.conf.template` into `/etc/nginx/templates/`, where the image entrypoint runs `envsubst` over it at container start, so a broken template is not a build error at all: the image builds clean and the container dies on boot, and nothing before the release tag rendered it. Raised in the Qodo review of PR #143. **Resolved in the same increment:** the job builds all three images and checks the web one for what can actually break in it — the entrypoint renders the template with representative loopback upstreams (`nginx -t` resolves a literal `proxy_pass` host at config-load time, so the compose defaults would fail on a runner for the wrong reason), `nginx -t` must accept the result, both upstreams must appear substituted, and `$http_host` and `$uri` must survive, which is what `NGINX_ENVSUBST_FILTER` exists to guarantee and nothing tested. `docker/` joins the `images` path filter so the filter and the job cover the same set. - **A study movetext walker that never asked which variant it was reading (RESOLVED in M15 Increment 13 / ADR-0122).** `importGame` in `packages/studies/src/import.ts` resolved every SAN through `resolveSan(reader, fen, san)` and `reader.play(fen, san)` with no variant, and `resolveSan` defaults to standard rather than failing — so a Crazyhouse or Three-Check game imported through it would have been validated against standard chess, rejecting legal moves and accepting illegal ones with no error to say why. Latent, not live: a whole-repository search found it referenced only by its own definition and its own test file, and both real import paths (`InMemoryStudiesRepository.buildTreeFromMovetext` via `appendNode`, and `PgStudiesRepository.buildTreeFromMovetextInternal` via a required parameter) already thread the study variant correctly. It was also a third implementation of a descent the two adapters already have, and ADR-0091 §10 records what happened the last time two copies of this walk diverged. **Resolved in M15 Increment 13 (ADR-0122):** deleted, together with the types and the `START_FEN` alias that existed only to serve it; `chapterNameFor` stays, because both adapters import it. `resolveSan`’s standard default is kept and now pinned by a test that states why — `@chess-platform/learning` relies on it and lessons carry no variant of their own. The coverage that was only reachable through the dead function moved onto `resolveSan` itself, and variant propagation through side variations — previously unverified, since the existing three-check test had no variations — is now a mutation-checked regression test. diff --git a/packages/persistence/migrations/0028_studies_variant_fk.sql b/packages/persistence/migrations/0028_studies_variant_fk.sql index 5355e317..786b0595 100644 --- a/packages/persistence/migrations/0028_studies_variant_fk.sql +++ b/packages/persistence/migrations/0028_studies_variant_fk.sql @@ -5,4 +5,4 @@ ALTER TABLE studies ALTER TABLE studies ADD CONSTRAINT studies_variant_fk - FOREIGN KEY (variant) REFERENCES variants(code); + FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID; diff --git a/packages/persistence/migrations/0029_validate_studies_variant_fk.sql b/packages/persistence/migrations/0029_validate_studies_variant_fk.sql new file mode 100644 index 00000000..69a1d971 --- /dev/null +++ b/packages/persistence/migrations/0029_validate_studies_variant_fk.sql @@ -0,0 +1,4 @@ +-- Migration 0029: Validate studies_variant_fk constraint without blocking concurrent writes + +ALTER TABLE studies + VALIDATE CONSTRAINT studies_variant_fk; diff --git a/packages/persistence/test/studies.integration.test.ts b/packages/persistence/test/studies.integration.test.ts index a5c1de67..676bfbbc 100644 --- a/packages/persistence/test/studies.integration.test.ts +++ b/packages/persistence/test/studies.integration.test.ts @@ -33,6 +33,14 @@ interface PgErrorShape { constraint?: string; } +/** + * Type guard verifying whether an unknown error is a PostgreSQL constraint violation matching a code and optional constraint name. + * + * @param err The unknown error caught in an assert.rejects handler. + * @param code The expected 5-character PostgreSQL SQLSTATE error code. + * @param constraint Optional constraint name to match against the error's constraint property. + * @returns boolean indicating if the error matches the expected PostgreSQL constraint violation. + */ function isPgConstraintViolation(err: unknown, code: string, constraint?: string): boolean { if (typeof err !== 'object' || err === null) return false; const pgErr = err as PgErrorShape; diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 1f4bbc31..f8f0c193 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -6,12 +6,12 @@ * nothing derives from anything else. That is survivable only while they match, and there was no * check that they do. * - * The database variant columns (games, ratings, seeks, and studies via migration 0028) are + * The database variant columns (games, ratings, seeks, and studies via migrations 0028/0029) are * `variant TEXT NOT NULL REFERENCES variants(code)`, so once a row exists in the `variants` lookup * table the database accepts that value uniformly. `studies.variant` was initially governed by an * inline `CHECK (variant IN (...))` in migration 0022 and converted to `REFERENCES variants(code)` - * in migration 0028. The application-level declarations below still need their own updates in either - * case — the lookup row settles what the *database* will store. + * in migration 0028 (validated in 0029). The application-level declarations below still need their + * own updates in either case — the lookup row settles what the *database* will store. * * `chess-core`'s `Variant` is treated as the root: it is the type the engine actually branches on, * so a variant that is not there is not a variant at all. Every other list is compared to it. @@ -307,6 +307,32 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { return current; } +/** + * Returns true if the effective migration schema defines a foreign key from studies.variant to variants(code). + * + * @param {string} dir The migrations directory to replay. + * @returns {boolean} Whether studies.variant has an active foreign key referencing variants(code). + */ +export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { + let fkFound = false; + for (const file of migrationFiles(dir)) { + const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); + for (const statement of splitStatements(sql)) { + if (!TARGETS_STUDIES.test(statement)) continue; + if (/\bvariant\b[\s\S]*?REFERENCES\s+variants\s*\(\s*code\s*\)/i.test(statement)) { + fkFound = true; + } + if (/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?studies_variant_fk"?/i.test(statement)) { + fkFound = false; + } + if (/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) { + fkFound = false; + } + } + } + return fkFound; +} + /** The root. Everything else is measured against this one. */ export const ROOT = { label: 'chess-core `Variant`', diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index c69d7060..7716d7c9 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -19,6 +19,7 @@ import { migrationFiles, effectiveLookupVariants, effectiveStudyVariantConstraint, + effectiveStudyVariantForeignKey, disagreements, ROOT, TS_MIRRORS, @@ -310,6 +311,22 @@ ALTER TABLE studies ADD CONSTRAINT studies_variant_fk test('the committed migrations directory leaves studies.variant derived from foreign key with no CHECK', () => { assert.equal(effectiveStudyVariantConstraint(MIGRATIONS_DIR), null); + assert.equal(effectiveStudyVariantForeignKey(MIGRATIONS_DIR), true); +}); + +test('dropping the CHECK without adding a foreign key leaves effectiveStudyVariantForeignKey false', () => { + const dir = migrations({ + '0022_study_variant.sql': `ALTER TABLE studies + ADD COLUMN variant TEXT NOT NULL DEFAULT 'standard' + CHECK (variant IN ('standard', 'atomic'));`, + '0025_drop_only.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_check;`, + }); + try { + assert.equal(effectiveStudyVariantConstraint(dir), null); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); test('a renamed declaration fails loudly instead of checking nothing', () => { From de8d8720b12fb6357781f42a0fd5985840f43591 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 13:10:20 +0300 Subject: [PATCH 04/39] test(persistence): harden test variant insertion and deletion cleanup in integration suite --- .../test/studies.integration.test.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/persistence/test/studies.integration.test.ts b/packages/persistence/test/studies.integration.test.ts index 676bfbbc..a229cc7f 100644 --- a/packages/persistence/test/studies.integration.test.ts +++ b/packages/persistence/test/studies.integration.test.ts @@ -380,25 +380,35 @@ test('pg studies repository integration tests', { skip }, async () => { assert.equal(fetched.variant, v); } - // 10.5 Referencing study specifically protects variants(code) via studies_variant_fk (NO ACTION / RESTRICT) - await pool.query( - `INSERT INTO variants (code, name, enabled) VALUES ('test_fk_protection_variant', 'Test FK Variant', true)` - ); + // 10.5 Referencing study specifically protects variants(code) via studies_variant_fk (NO ACTION) + let customVarInserted = false; + let customStudyInserted = false; + const customVarCode = 'test_fk_protection_variant'; const customVarStudyId = uuidv7(); - await pool.query( - `INSERT INTO studies (id, owner_id, name, description, visibility, variant, created_at, updated_at) - VALUES ($1, $2, 'Custom Variant Study', '', 'public', 'test_fk_protection_variant', NOW(), NOW())`, - [customVarStudyId, alice] - ); try { + await pool.query( + `INSERT INTO variants (code, name, enabled) VALUES ($1, 'Test FK Variant', true)`, + [customVarCode] + ); + customVarInserted = true; + await pool.query( + `INSERT INTO studies (id, owner_id, name, description, visibility, variant, created_at, updated_at) + VALUES ($1, $2, 'Custom Variant Study', '', 'public', $3, NOW(), NOW())`, + [customVarStudyId, alice, customVarCode] + ); + customStudyInserted = true; await assert.rejects( - async () => pool.query(`DELETE FROM variants WHERE code = 'test_fk_protection_variant'`), + async () => pool.query(`DELETE FROM variants WHERE code = $1`, [customVarCode]), (err: unknown) => isPgConstraintViolation(err, '23503', 'studies_variant_fk'), 'deleting a variant referenced only by studies must raise foreign_key_violation on studies_variant_fk' ); } finally { - await pool.query(`DELETE FROM studies WHERE id = $1`, [customVarStudyId]); - await pool.query(`DELETE FROM variants WHERE code = 'test_fk_protection_variant'`); + if (customStudyInserted) { + await pool.query(`DELETE FROM studies WHERE id = $1`, [customVarStudyId]); + } + if (customVarInserted) { + await pool.query(`DELETE FROM variants WHERE code = $1`, [customVarCode]); + } } } finally { if (createdUserIds.length > 0) { From 0be6410585604639804fd64c70a8fa902b688b14 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 13:17:48 +0300 Subject: [PATCH 05/39] fix(scripts): refine variant foreign-key tracking by table/inline form and constraint name --- scripts/check-variant-parity.mjs | 46 +++++++++++++++++----- scripts/test/check-variant-parity.test.mjs | 32 +++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index f8f0c193..25c9cdf2 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -248,9 +248,20 @@ const TARGETS_STUDIES = */ const IMPLICIT_CONSTRAINT_NAME = 'studies_variant_check'; +/** The name PostgreSQL gives a foreign key on `studies.variant` written without an explicit name. */ +const IMPLICIT_FK_CONSTRAINT_NAME = 'studies_variant_fkey'; + /** A `CHECK (variant IN (...))`, with the constraint name when the statement gives one. */ const VARIANT_CHECK = /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*variant\s+IN\s*\(([\s\S]*?)\)\s*\)/gi; +/** Table-level `FOREIGN KEY (variant) REFERENCES variants(code)`. */ +const VARIANT_FK_TABLE = + /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*"?variant"?\s*\)\s*REFERENCES\s+variants\s*\(\s*code\s*\)/i; + +/** Inline-column `variant TEXT ... REFERENCES variants(code)`. */ +const VARIANT_FK_INLINE = + /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[\s\S]*?"?variant"?\s+TEXT[\s\S]*?REFERENCES\s+variants\s*\(\s*code\s*\)/i; + const normalise = (name) => name.replace(/"/g, '').toLowerCase(); export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { @@ -299,7 +310,7 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { } // Once the column derives from the lookup table there is no second list left to drift. - if (/\bvariant\b[\s\S]*?REFERENCES\s+variants\s*\(\s*code\s*\)/i.test(statement)) { + if (VARIANT_FK_TABLE.test(statement) || VARIANT_FK_INLINE.test(statement)) { current = null; } } @@ -308,29 +319,46 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { } /** - * Returns true if the effective migration schema defines a foreign key from studies.variant to variants(code). + * Returns true if the effective migration schema defines an active foreign key on studies.variant referencing variants(code). * * @param {string} dir The migrations directory to replay. * @returns {boolean} Whether studies.variant has an active foreign key referencing variants(code). */ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { - let fkFound = false; + let activeFk = null; for (const file of migrationFiles(dir)) { const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); for (const statement of splitStatements(sql)) { if (!TARGETS_STUDIES.test(statement)) continue; - if (/\bvariant\b[\s\S]*?REFERENCES\s+variants\s*\(\s*code\s*\)/i.test(statement)) { - fkFound = true; + + const renamed = /RENAME\s+CONSTRAINT\s+"?(\w+)"?\s+TO\s+"?(\w+)"?/i.exec(statement); + if (renamed !== null && activeFk !== null && normalise(renamed[1]) === activeFk.name) { + activeFk = { file, name: normalise(renamed[2]) }; } - if (/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?studies_variant_fk"?/i.test(statement)) { - fkFound = false; + + const dropped = /DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/i.exec(statement); + if (dropped !== null && activeFk !== null && normalise(dropped[1]) === activeFk.name) { + activeFk = null; } if (/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) { - fkFound = false; + activeFk = null; + } + + const tableMatch = VARIANT_FK_TABLE.exec(statement); + if (tableMatch !== null) { + activeFk = { + file, + name: normalise(tableMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME), + }; + } else if (VARIANT_FK_INLINE.test(statement)) { + activeFk = { + file, + name: IMPLICIT_FK_CONSTRAINT_NAME, + }; } } } - return fkFound; + return activeFk !== null; } /** The root. Everything else is measured against this one. */ diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 7716d7c9..c23d7bff 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -329,6 +329,38 @@ test('dropping the CHECK without adding a foreign key leaves effectiveStudyVaria } }); +test('effectiveStudyVariantForeignKey tracks named foreign keys through drop and rename', () => { + const dir = migrations({ + '0001_fk.sql': `ALTER TABLE studies ADD CONSTRAINT custom_fk FOREIGN KEY (variant) REFERENCES variants(code);`, + '0002_rename.sql': `ALTER TABLE studies RENAME CONSTRAINT custom_fk TO renamed_fk;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // Dropping under the old name does nothing because it was renamed + writeFileSync(join(dir, '0003_drop_old.sql'), `ALTER TABLE studies DROP CONSTRAINT custom_fk;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // Dropping under the new name clears active FK + writeFileSync(join(dir, '0004_drop_new.sql'), `ALTER TABLE studies DROP CONSTRAINT renamed_fk;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { + const dir = migrations({ + '0001_inline.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('a renamed declaration fails loudly instead of checking nothing', () => { // The failure mode that makes a guard worse than no guard: it keeps passing having stopped // looking at anything. From 3206735d32671bd67cdc74a23d4a2527c2ca1c76 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 13:40:40 +0300 Subject: [PATCH 06/39] fix(scripts): capture explicit inline foreign key constraint name and add drop regression test --- scripts/check-variant-parity.mjs | 9 +++++---- scripts/test/check-variant-parity.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 25c9cdf2..168e985f 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -258,9 +258,9 @@ const VARIANT_CHECK = /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*variant\s+IN\s const VARIANT_FK_TABLE = /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*"?variant"?\s*\)\s*REFERENCES\s+variants\s*\(\s*code\s*\)/i; -/** Inline-column `variant TEXT ... REFERENCES variants(code)`. */ +/** Inline-column `variant TEXT ... [CONSTRAINT name] REFERENCES variants(code)`. */ const VARIANT_FK_INLINE = - /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[\s\S]*?"?variant"?\s+TEXT[\s\S]*?REFERENCES\s+variants\s*\(\s*code\s*\)/i; + /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[\s\S]*?"?variant"?\s+TEXT[\s\S]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+variants\s*\(\s*code\s*\)/i; const normalise = (name) => name.replace(/"/g, '').toLowerCase(); @@ -345,15 +345,16 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { } const tableMatch = VARIANT_FK_TABLE.exec(statement); + const inlineMatch = VARIANT_FK_INLINE.exec(statement); if (tableMatch !== null) { activeFk = { file, name: normalise(tableMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME), }; - } else if (VARIANT_FK_INLINE.test(statement)) { + } else if (inlineMatch !== null) { activeFk = { file, - name: IMPLICIT_FK_CONSTRAINT_NAME, + name: normalise(inlineMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME), }; } } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index c23d7bff..38846f37 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -361,6 +361,26 @@ test('effectiveStudyVariantForeignKey recognizes inline column references on stu } }); +test('effectiveStudyVariantForeignKey tracks explicit inline constraint names and clears on drop', () => { + const dir = migrations({ + '0001_inline_named.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CONSTRAINT custom_inline_fk REFERENCES variants(code) + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + writeFileSync( + join(dir, '0002_drop_inline.sql'), + `ALTER TABLE studies DROP CONSTRAINT custom_inline_fk;`, + 'utf8', + ); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('a renamed declaration fails loudly instead of checking nothing', () => { // The failure mode that makes a guard worse than no guard: it keeps passing having stopped // looking at anything. From c0bd73485c11671b2165d474b66d560e6f6b6e9a Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 15:46:12 +0300 Subject: [PATCH 07/39] fix(scripts): enforce foreign key verification in parity CLI and restrict inline column regex --- scripts/check-variant-parity.mjs | 20 ++++++++---- scripts/test/check-variant-parity.test.mjs | 37 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 168e985f..1bec063d 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -260,7 +260,7 @@ const VARIANT_FK_TABLE = /** Inline-column `variant TEXT ... [CONSTRAINT name] REFERENCES variants(code)`. */ const VARIANT_FK_INLINE = - /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[\s\S]*?"?variant"?\s+TEXT[\s\S]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+variants\s*\(\s*code\s*\)/i; + /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?\bvariant\b\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+variants\s*\(\s*code\s*\)/i; const normalise = (name) => name.replace(/"/g, '').toLowerCase(); @@ -422,6 +422,7 @@ export function collectMirrors(dir = MIGRATIONS_DIR) { variants: effectiveLookupVariants(dir), }); const study = effectiveStudyVariantConstraint(dir); + const hasStudyVariantFk = effectiveStudyVariantForeignKey(dir); if (study !== null) { mirrors.push({ label: '`studies.variant` CHECK constraint, after all migrations', @@ -429,12 +430,12 @@ export function collectMirrors(dir = MIGRATIONS_DIR) { variants: study.variants, }); } - return { mirrors, studyConstraint: study }; + return { mirrors, studyConstraint: study, hasStudyVariantFk }; } function main() { const root = extractRegion(ROOT); - const { mirrors, studyConstraint } = collectMirrors(); + const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(); const failures = []; console.log(`root: ${root.label} (${root.file})`); @@ -451,9 +452,16 @@ function main() { } if (studyConstraint === null) { - console.log( - ' -- `studies.variant` has no CHECK left; it derives from `variants(code)`, nothing to compare', - ); + if (hasStudyVariantFk) { + console.log( + ' -- `studies.variant` has no CHECK left; it derives from `variants(code)`, nothing to compare', + ); + } else { + console.log( + ' FAIL `studies.variant` has no CHECK constraint and no foreign key referencing `variants(code)`', + ); + failures.push('`studies.variant` missing foreign key'); + } } if (failures.length > 0) { diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 38846f37..94e0c667 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -20,6 +20,7 @@ import { effectiveLookupVariants, effectiveStudyVariantConstraint, effectiveStudyVariantForeignKey, + collectMirrors, disagreements, ROOT, TS_MIRRORS, @@ -381,6 +382,42 @@ test('effectiveStudyVariantForeignKey tracks explicit inline constraint names an } }); +test('effectiveStudyVariantForeignKey does not match subsequent column referencing variants', () => { + const dir = migrations({ + '0001_distinct_columns.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL, + source TEXT REFERENCES variants(code) + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('collectMirrors exposes whether effective studies.variant foreign key is present', () => { + const committed = collectMirrors(MIGRATIONS_DIR); + assert.equal(committed.studyConstraint, null); + assert.equal(committed.hasStudyVariantFk, true); + + const dropOnlyDir = migrations({ + '0001_variants.sql': `INSERT INTO variants (code) VALUES ('standard');`, + '0022_study_variant.sql': `ALTER TABLE studies + ADD COLUMN variant TEXT NOT NULL DEFAULT 'standard' + CHECK (variant IN ('standard', 'atomic'));`, + '0025_drop_only.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_check;`, + }); + try { + const dropOnly = collectMirrors(dropOnlyDir); + assert.equal(dropOnly.studyConstraint, null); + assert.equal(dropOnly.hasStudyVariantFk, false); + } finally { + rmSync(dropOnlyDir, { recursive: true, force: true }); + } +}); + test('a renamed declaration fails loudly instead of checking nothing', () => { // The failure mode that makes a guard worse than no guard: it keeps passing having stopped // looking at anything. From 52d740efcb1cccbd87369128e968fe1cf83b43d5 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 15:58:50 +0300 Subject: [PATCH 08/39] fix(scripts): track active foreign keys as a Set of constraint names --- scripts/check-variant-parity.mjs | 25 +++++++++------------- scripts/test/check-variant-parity.test.mjs | 18 ++++++++++++++++ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 1bec063d..75de056e 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -325,41 +325,36 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { * @returns {boolean} Whether studies.variant has an active foreign key referencing variants(code). */ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { - let activeFk = null; + const activeFks = new Set(); for (const file of migrationFiles(dir)) { const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); for (const statement of splitStatements(sql)) { if (!TARGETS_STUDIES.test(statement)) continue; const renamed = /RENAME\s+CONSTRAINT\s+"?(\w+)"?\s+TO\s+"?(\w+)"?/i.exec(statement); - if (renamed !== null && activeFk !== null && normalise(renamed[1]) === activeFk.name) { - activeFk = { file, name: normalise(renamed[2]) }; + if (renamed !== null && activeFks.has(normalise(renamed[1]))) { + activeFks.delete(normalise(renamed[1])); + activeFks.add(normalise(renamed[2])); } const dropped = /DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/i.exec(statement); - if (dropped !== null && activeFk !== null && normalise(dropped[1]) === activeFk.name) { - activeFk = null; + if (dropped !== null) { + activeFks.delete(normalise(dropped[1])); } if (/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) { - activeFk = null; + activeFks.clear(); } const tableMatch = VARIANT_FK_TABLE.exec(statement); const inlineMatch = VARIANT_FK_INLINE.exec(statement); if (tableMatch !== null) { - activeFk = { - file, - name: normalise(tableMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME), - }; + activeFks.add(normalise(tableMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); } else if (inlineMatch !== null) { - activeFk = { - file, - name: normalise(inlineMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME), - }; + activeFks.add(normalise(inlineMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); } } } - return activeFk !== null; + return activeFks.size > 0; } /** The root. Everything else is measured against this one. */ diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 94e0c667..2862970a 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -348,6 +348,24 @@ test('effectiveStudyVariantForeignKey tracks named foreign keys through drop and } }); +test('effectiveStudyVariantForeignKey tracks multiple foreign keys independently when one is dropped', () => { + const dir = migrations({ + '0001_fk1.sql': `ALTER TABLE studies ADD CONSTRAINT fk_one FOREIGN KEY (variant) REFERENCES variants(code);`, + '0002_fk2.sql': `ALTER TABLE studies ADD CONSTRAINT fk_two FOREIGN KEY (variant) REFERENCES variants(code);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // Dropping fk_one leaves fk_two active + writeFileSync(join(dir, '0003_drop_one.sql'), `ALTER TABLE studies DROP CONSTRAINT fk_one;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // Dropping fk_two clears all + writeFileSync(join(dir, '0004_drop_two.sql'), `ALTER TABLE studies DROP CONSTRAINT fk_two;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { const dir = migrations({ '0001_inline.sql': `CREATE TABLE studies ( From 70bf7686b1775bf91b3e76e2ac9ced36a4a15b2f Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 16:10:14 +0300 Subject: [PATCH 09/39] fix(scripts): support multi-drop constraint statements and preserve coexisting CHECK and FK tracking --- scripts/check-variant-parity.mjs | 17 +++++--------- scripts/test/check-variant-parity.test.mjs | 27 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 75de056e..7aad49bc 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -295,9 +295,10 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { // against the one being tracked — a substring test for "variant" both missed a legitimately // named constraint (`DROP CONSTRAINT allowed_codes`) and would have fired on an unrelated one // that happened to contain the word. Raised in the CodeRabbit review of PR #141. - const dropped = /DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/i.exec(statement); - if (dropped !== null && current !== null && normalise(dropped[1]) === current.name) { - current = null; + for (const m of statement.matchAll(/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/gi)) { + if (current !== null && normalise(m[1]) === current.name) { + current = null; + } } if (/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) current = null; @@ -308,11 +309,6 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { variants: [...m[2].matchAll(/'([a-z0-9]+)'/g)].map((t) => t[1]), }; } - - // Once the column derives from the lookup table there is no second list left to drift. - if (VARIANT_FK_TABLE.test(statement) || VARIANT_FK_INLINE.test(statement)) { - current = null; - } } } return current; @@ -337,9 +333,8 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { activeFks.add(normalise(renamed[2])); } - const dropped = /DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/i.exec(statement); - if (dropped !== null) { - activeFks.delete(normalise(dropped[1])); + for (const m of statement.matchAll(/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/gi)) { + activeFks.delete(normalise(m[1])); } if (/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) { activeFks.clear(); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 2862970a..408fa8e9 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -400,6 +400,18 @@ test('effectiveStudyVariantForeignKey tracks explicit inline constraint names an } }); +test('effectiveStudyVariantForeignKey handles multiple DROP CONSTRAINT clauses in a single statement', () => { + const dir = migrations({ + '0001_fk.sql': `ALTER TABLE studies ADD CONSTRAINT custom_fk FOREIGN KEY (variant) REFERENCES variants(code);`, + '0002_multi_drop.sql': `ALTER TABLE studies DROP CONSTRAINT unrelated_constraint, DROP CONSTRAINT custom_fk;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('effectiveStudyVariantForeignKey does not match subsequent column referencing variants', () => { const dir = migrations({ '0001_distinct_columns.sql': `CREATE TABLE studies ( @@ -415,6 +427,21 @@ test('effectiveStudyVariantForeignKey does not match subsequent column referenci } }); +test('effectiveStudyVariantConstraint preserves active CHECK constraint when FK is added without dropping CHECK', () => { + const dir = migrations({ + '0001_check.sql': `ALTER TABLE studies ADD COLUMN variant TEXT NOT NULL DEFAULT 'standard' CHECK (variant IN ('standard', 'atomic'));`, + '0002_fk.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fk FOREIGN KEY (variant) REFERENCES variants(code);`, + }); + try { + const check = effectiveStudyVariantConstraint(dir); + assert.notEqual(check, null); + assert.deepEqual(check.variants, ['standard', 'atomic']); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('collectMirrors exposes whether effective studies.variant foreign key is present', () => { const committed = collectMirrors(MIGRATIONS_DIR); assert.equal(committed.studyConstraint, null); From 7794db2f7c7cef40e4760e29ae20a5c8d1e17213 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 16:22:59 +0300 Subject: [PATCH 10/39] fix(scripts): support comma-separated ADD CONSTRAINT in parity checker and add unit test --- scripts/check-variant-parity.mjs | 15 +++++++-------- scripts/test/check-variant-parity.test.mjs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 7aad49bc..5bd9dfca 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -256,11 +256,11 @@ const VARIANT_CHECK = /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*variant\s+IN\s /** Table-level `FOREIGN KEY (variant) REFERENCES variants(code)`. */ const VARIANT_FK_TABLE = - /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*"?variant"?\s*\)\s*REFERENCES\s+variants\s*\(\s*code\s*\)/i; + /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*"?variant"?\s*\)\s*REFERENCES\s+variants\s*\(\s*code\s*\)/gi; /** Inline-column `variant TEXT ... [CONSTRAINT name] REFERENCES variants(code)`. */ const VARIANT_FK_INLINE = - /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?\bvariant\b\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+variants\s*\(\s*code\s*\)/i; + /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?\bvariant\b\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+variants\s*\(\s*code\s*\)/gi; const normalise = (name) => name.replace(/"/g, '').toLowerCase(); @@ -340,12 +340,11 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { activeFks.clear(); } - const tableMatch = VARIANT_FK_TABLE.exec(statement); - const inlineMatch = VARIANT_FK_INLINE.exec(statement); - if (tableMatch !== null) { - activeFks.add(normalise(tableMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); - } else if (inlineMatch !== null) { - activeFks.add(normalise(inlineMatch[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); + for (const m of statement.matchAll(VARIANT_FK_TABLE)) { + activeFks.add(normalise(m[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); + } + for (const m of statement.matchAll(VARIANT_FK_INLINE)) { + activeFks.add(normalise(m[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); } } } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 408fa8e9..58771424 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -366,6 +366,23 @@ test('effectiveStudyVariantForeignKey tracks multiple foreign keys independently } }); +test('effectiveStudyVariantForeignKey tracks multiple foreign keys added in a single comma-separated statement', () => { + const dir = migrations({ + '0001_multi_add.sql': `ALTER TABLE studies + ADD CONSTRAINT fk_alpha FOREIGN KEY (variant) REFERENCES variants(code), + ADD CONSTRAINT fk_beta FOREIGN KEY (variant) REFERENCES variants(code);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + writeFileSync(join(dir, '0002_drop_alpha.sql'), `ALTER TABLE studies DROP CONSTRAINT fk_alpha;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + writeFileSync(join(dir, '0003_drop_beta.sql'), `ALTER TABLE studies DROP CONSTRAINT fk_beta;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { const dir = migrations({ '0001_inline.sql': `CREATE TABLE studies ( From 62d910c5fccde8246912e2f2bfbf9c1cf91704b1 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 16:34:15 +0300 Subject: [PATCH 11/39] fix(scripts): clear active constraints on studies.variant column rename and add test --- scripts/check-variant-parity.mjs | 4 ++-- scripts/test/check-variant-parity.test.mjs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 5bd9dfca..620a0049 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -300,7 +300,7 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { current = null; } } - if (/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) current = null; + if (/(?:DROP|RENAME)\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) current = null; for (const m of statement.matchAll(VARIANT_CHECK)) { current = { @@ -336,7 +336,7 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { for (const m of statement.matchAll(/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/gi)) { activeFks.delete(normalise(m[1])); } - if (/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) { + if (/(?:DROP|RENAME)\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) { activeFks.clear(); } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 58771424..51f555da 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -383,6 +383,22 @@ test('effectiveStudyVariantForeignKey tracks multiple foreign keys added in a si } }); +test('effectiveStudyVariantForeignKey clears active foreign keys when variant column is renamed', () => { + const dir = migrations({ + '0001_inline.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_rename_col.sql': `ALTER TABLE studies RENAME COLUMN variant TO old_variant;`, + '0003_readd_unconstrained.sql': `ALTER TABLE studies ADD COLUMN variant TEXT NOT NULL DEFAULT 'standard';`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { const dir = migrations({ '0001_inline.sql': `CREATE TABLE studies ( From 091361b607e9762ec51d67f2abc728373cb1e087 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 16:44:17 +0300 Subject: [PATCH 12/39] fix(scripts): clear constraints on table lifecycle events in parity checker and add tests --- scripts/check-variant-parity.mjs | 12 ++++++- scripts/test/check-variant-parity.test.mjs | 38 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 620a0049..ae8584a8 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -238,7 +238,7 @@ export function effectiveLookupVariants(dir = MIGRATIONS_DIR) { * Raised in the CodeRabbit review of PR #141. */ const TARGETS_STUDIES = - /^\s*(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE(?:\s+IF\s+EXISTS)?)\s+(?:ONLY\s+)?"?studies"?[\s(]/i; + /^\s*(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE(?:\s+IF\s+EXISTS)?|DROP\s+TABLE(?:\s+IF\s+EXISTS)?)\s+(?:ONLY\s+)?"?studies"?(?:[\s(;]|$)/i; /** * The name PostgreSQL gives a `CHECK` on `studies.variant` that was written without one. @@ -278,6 +278,11 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { for (const statement of splitStatements(sql)) { if (!TARGETS_STUDIES.test(statement)) continue; + if (/^\s*DROP\s+TABLE/i.test(statement) || /RENAME\s+TO\b/i.test(statement)) { + current = null; + continue; + } + // A rename would leave every name tracked below pointing at something that no longer answers // to it, and the drop that follows would look like an unrelated constraint. There is no // half-right answer available, so say so rather than report a schema that is not there. @@ -327,6 +332,11 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { for (const statement of splitStatements(sql)) { if (!TARGETS_STUDIES.test(statement)) continue; + if (/^\s*DROP\s+TABLE/i.test(statement) || /RENAME\s+TO\b/i.test(statement)) { + activeFks.clear(); + continue; + } + const renamed = /RENAME\s+CONSTRAINT\s+"?(\w+)"?\s+TO\s+"?(\w+)"?/i.exec(statement); if (renamed !== null && activeFks.has(normalise(renamed[1]))) { activeFks.delete(normalise(renamed[1])); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 51f555da..10fb9ec7 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -399,6 +399,44 @@ test('effectiveStudyVariantForeignKey clears active foreign keys when variant co } }); +test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear state when studies table is dropped or renamed', () => { + const dropDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_table.sql': `DROP TABLE studies;`, + '0003_recreate_unconstrained.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dropDir), false); + assert.equal(effectiveStudyVariantConstraint(dropDir), null); + } finally { + rmSync(dropDir, { recursive: true, force: true }); + } + + const renameDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_rename_table.sql': `ALTER TABLE studies RENAME TO old_studies;`, + '0003_recreate_unconstrained.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(renameDir), false); + assert.equal(effectiveStudyVariantConstraint(renameDir), null); + } finally { + rmSync(renameDir, { recursive: true, force: true }); + } +}); + test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { const dir = migrations({ '0001_inline.sql': `CREATE TABLE studies ( From 75ab07c8f24f7851468af6db8f94d60849eed4ec Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 16:55:39 +0300 Subject: [PATCH 13/39] fix(scripts): support quoted FK identifiers, unnamed FK multiplicity, and shorthand column rename --- scripts/check-variant-parity.mjs | 24 ++++++--- scripts/test/check-variant-parity.test.mjs | 60 +++++++++++++++++++++- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index ae8584a8..48375ca7 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -252,15 +252,15 @@ const IMPLICIT_CONSTRAINT_NAME = 'studies_variant_check'; const IMPLICIT_FK_CONSTRAINT_NAME = 'studies_variant_fkey'; /** A `CHECK (variant IN (...))`, with the constraint name when the statement gives one. */ -const VARIANT_CHECK = /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*variant\s+IN\s*\(([\s\S]*?)\)\s*\)/gi; +const VARIANT_CHECK = /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*"?variant"?\s+IN\s*\(([\s\S]*?)\)\s*\)/gi; /** Table-level `FOREIGN KEY (variant) REFERENCES variants(code)`. */ const VARIANT_FK_TABLE = - /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*"?variant"?\s*\)\s*REFERENCES\s+variants\s*\(\s*code\s*\)/gi; + /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*"?variant"?\s*\)\s*REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; /** Inline-column `variant TEXT ... [CONSTRAINT name] REFERENCES variants(code)`. */ const VARIANT_FK_INLINE = - /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?\bvariant\b\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+variants\s*\(\s*code\s*\)/gi; + /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?"?variant"?\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; const normalise = (name) => name.replace(/"/g, '').toLowerCase(); @@ -305,7 +305,7 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { current = null; } } - if (/(?:DROP|RENAME)\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) current = null; + if (/(?:DROP\s+COLUMN(?:\s+IF\s+EXISTS)?|RENAME(?:\s+COLUMN)?)\s+"?variant"?/i.test(statement)) current = null; for (const m of statement.matchAll(VARIANT_CHECK)) { current = { @@ -327,6 +327,7 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { */ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { const activeFks = new Set(); + let implicitFkCounter = 0; for (const file of migrationFiles(dir)) { const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); for (const statement of splitStatements(sql)) { @@ -334,6 +335,7 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { if (/^\s*DROP\s+TABLE/i.test(statement) || /RENAME\s+TO\b/i.test(statement)) { activeFks.clear(); + implicitFkCounter = 0; continue; } @@ -346,15 +348,23 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { for (const m of statement.matchAll(/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/gi)) { activeFks.delete(normalise(m[1])); } - if (/(?:DROP|RENAME)\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?variant"?/i.test(statement)) { + if (/(?:DROP\s+COLUMN(?:\s+IF\s+EXISTS)?|RENAME(?:\s+COLUMN)?)\s+"?variant"?/i.test(statement)) { activeFks.clear(); } for (const m of statement.matchAll(VARIANT_FK_TABLE)) { - activeFks.add(normalise(m[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); + const name = m[1] + ? normalise(m[1]) + : (implicitFkCounter === 0 ? IMPLICIT_FK_CONSTRAINT_NAME : `${IMPLICIT_FK_CONSTRAINT_NAME}${implicitFkCounter}`); + if (!m[1]) implicitFkCounter++; + activeFks.add(name); } for (const m of statement.matchAll(VARIANT_FK_INLINE)) { - activeFks.add(normalise(m[1] ?? IMPLICIT_FK_CONSTRAINT_NAME)); + const name = m[1] + ? normalise(m[1]) + : (implicitFkCounter === 0 ? IMPLICIT_FK_CONSTRAINT_NAME : `${IMPLICIT_FK_CONSTRAINT_NAME}${implicitFkCounter}`); + if (!m[1]) implicitFkCounter++; + activeFks.add(name); } } } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 10fb9ec7..3b294b2c 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -383,8 +383,8 @@ test('effectiveStudyVariantForeignKey tracks multiple foreign keys added in a si } }); -test('effectiveStudyVariantForeignKey clears active foreign keys when variant column is renamed', () => { - const dir = migrations({ +test('effectiveStudyVariantForeignKey clears active foreign keys when variant column is renamed (with or without COLUMN keyword)', () => { + const dirWithColumn = migrations({ '0001_inline.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL REFERENCES variants(code) @@ -393,6 +393,62 @@ test('effectiveStudyVariantForeignKey clears active foreign keys when variant co '0003_readd_unconstrained.sql': `ALTER TABLE studies ADD COLUMN variant TEXT NOT NULL DEFAULT 'standard';`, }); try { + assert.equal(effectiveStudyVariantForeignKey(dirWithColumn), false); + } finally { + rmSync(dirWithColumn, { recursive: true, force: true }); + } + + const dirShorthand = migrations({ + '0001_inline.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_rename_shorthand.sql': `ALTER TABLE studies RENAME variant TO old_variant;`, + '0003_readd_unconstrained.sql': `ALTER TABLE studies ADD COLUMN variant TEXT NOT NULL DEFAULT 'standard';`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dirShorthand), false); + } finally { + rmSync(dirShorthand, { recursive: true, force: true }); + } +}); + +test('effectiveStudyVariantForeignKey recognizes double-quoted identifiers in table-level and inline foreign keys', () => { + const dirTable = migrations({ + '0001_table_quoted.sql': `ALTER TABLE "studies" ADD CONSTRAINT "fk_quoted" FOREIGN KEY ("variant") REFERENCES "variants"("code");`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dirTable), true); + } finally { + rmSync(dirTable, { recursive: true, force: true }); + } + + const dirInline = migrations({ + '0001_inline_quoted.sql': `CREATE TABLE "studies" ( + "id" UUID PRIMARY KEY, + "variant" TEXT NOT NULL REFERENCES "variants"("code") + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dirInline), true); + } finally { + rmSync(dirInline, { recursive: true, force: true }); + } +}); + +test('effectiveStudyVariantForeignKey tracks multiple unnamed foreign keys with non-colliding names', () => { + const dir = migrations({ + '0001_two_unnamed.sql': `ALTER TABLE studies + ADD FOREIGN KEY (variant) REFERENCES variants(code), + ADD FOREIGN KEY (variant) REFERENCES variants(code);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // Dropping the first generated name studies_variant_fkey leaves the second active + writeFileSync(join(dir, '0002_drop_first.sql'), `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // Dropping the second generated name studies_variant_fkey1 clears all + writeFileSync(join(dir, '0003_drop_second.sql'), `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey1;`, 'utf8'); assert.equal(effectiveStudyVariantForeignKey(dir), false); } finally { rmSync(dir, { recursive: true, force: true }); From a1bdbd21c35bcdbff72ea06711c97cd8ef31071e Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 17:05:45 +0300 Subject: [PATCH 14/39] fix(scripts): require exact variant identifier in inline FK regex and add test --- scripts/check-variant-parity.mjs | 7 ++++--- scripts/test/check-variant-parity.test.mjs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 48375ca7..6b390381 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -252,15 +252,16 @@ const IMPLICIT_CONSTRAINT_NAME = 'studies_variant_check'; const IMPLICIT_FK_CONSTRAINT_NAME = 'studies_variant_fkey'; /** A `CHECK (variant IN (...))`, with the constraint name when the statement gives one. */ -const VARIANT_CHECK = /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*"?variant"?\s+IN\s*\(([\s\S]*?)\)\s*\)/gi; +const VARIANT_CHECK = + /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*(?:\bvariant\b|"variant")\s+IN\s*\(([\s\S]*?)\)\s*\)/gi; /** Table-level `FOREIGN KEY (variant) REFERENCES variants(code)`. */ const VARIANT_FK_TABLE = - /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*"?variant"?\s*\)\s*REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; + /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*(?:\bvariant\b|"variant")\s*\)\s*REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; /** Inline-column `variant TEXT ... [CONSTRAINT name] REFERENCES variants(code)`. */ const VARIANT_FK_INLINE = - /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?"?variant"?\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; + /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?(?:\bvariant\b|"variant")\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; const normalise = (name) => name.replace(/"/g, '').toLowerCase(); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 3b294b2c..7ab4afa2 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -554,6 +554,21 @@ test('effectiveStudyVariantForeignKey does not match subsequent column referenci } }); +test('effectiveStudyVariantForeignKey does not match prefix column like archived_variant referencing variants', () => { + const dir = migrations({ + '0001_archived_variant.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL, + archived_variant TEXT REFERENCES variants(code) + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('effectiveStudyVariantConstraint preserves active CHECK constraint when FK is added without dropping CHECK', () => { const dir = migrations({ '0001_check.sql': `ALTER TABLE studies ADD COLUMN variant TEXT NOT NULL DEFAULT 'standard' CHECK (variant IN ('standard', 'atomic'));`, From e93b3a04b4908b2e549a1097d1a05cdc28cdd6fe Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 17:15:12 +0300 Subject: [PATCH 15/39] fix(scripts): clear active foreign keys on DROP TABLE variants CASCADE and add test --- scripts/check-variant-parity.mjs | 6 ++++++ scripts/test/check-variant-parity.test.mjs | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 6b390381..5c7984b1 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -332,6 +332,12 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { for (const file of migrationFiles(dir)) { const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); for (const statement of splitStatements(sql)) { + if (/^\s*DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+(?:ONLY\s+)?"?variants"?\s+CASCADE\b/i.test(statement)) { + activeFks.clear(); + implicitFkCounter = 0; + continue; + } + if (!TARGETS_STUDIES.test(statement)) continue; if (/^\s*DROP\s+TABLE/i.test(statement) || /RENAME\s+TO\b/i.test(statement)) { diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 7ab4afa2..265cad6d 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -491,6 +491,20 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(renameDir, { recursive: true, force: true }); } + + const dropVariantsCascadeDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_variants.sql': `DROP TABLE variants CASCADE;`, + '0003_recreate_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dropVariantsCascadeDir), false); + } finally { + rmSync(dropVariantsCascadeDir, { recursive: true, force: true }); + } }); test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { From 6790c7f5adc8d559c987331c0bf26588407d3061 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 17:25:17 +0300 Subject: [PATCH 16/39] fix(scripts): select lowest available implicit FK constraint name from active set and add test --- scripts/check-variant-parity.mjs | 24 ++++++++++++---------- scripts/test/check-variant-parity.test.mjs | 20 ++++++++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 5c7984b1..6d818de5 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -320,6 +320,17 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { return current; } +const nextImplicitFkName = (activeFks) => { + if (!activeFks.has(IMPLICIT_FK_CONSTRAINT_NAME)) { + return IMPLICIT_FK_CONSTRAINT_NAME; + } + let i = 1; + while (activeFks.has(`${IMPLICIT_FK_CONSTRAINT_NAME}${i}`)) { + i++; + } + return `${IMPLICIT_FK_CONSTRAINT_NAME}${i}`; +}; + /** * Returns true if the effective migration schema defines an active foreign key on studies.variant referencing variants(code). * @@ -328,13 +339,11 @@ export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { */ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { const activeFks = new Set(); - let implicitFkCounter = 0; for (const file of migrationFiles(dir)) { const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); for (const statement of splitStatements(sql)) { if (/^\s*DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+(?:ONLY\s+)?"?variants"?\s+CASCADE\b/i.test(statement)) { activeFks.clear(); - implicitFkCounter = 0; continue; } @@ -342,7 +351,6 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { if (/^\s*DROP\s+TABLE/i.test(statement) || /RENAME\s+TO\b/i.test(statement)) { activeFks.clear(); - implicitFkCounter = 0; continue; } @@ -360,17 +368,11 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { } for (const m of statement.matchAll(VARIANT_FK_TABLE)) { - const name = m[1] - ? normalise(m[1]) - : (implicitFkCounter === 0 ? IMPLICIT_FK_CONSTRAINT_NAME : `${IMPLICIT_FK_CONSTRAINT_NAME}${implicitFkCounter}`); - if (!m[1]) implicitFkCounter++; + const name = m[1] ? normalise(m[1]) : nextImplicitFkName(activeFks); activeFks.add(name); } for (const m of statement.matchAll(VARIANT_FK_INLINE)) { - const name = m[1] - ? normalise(m[1]) - : (implicitFkCounter === 0 ? IMPLICIT_FK_CONSTRAINT_NAME : `${IMPLICIT_FK_CONSTRAINT_NAME}${implicitFkCounter}`); - if (!m[1]) implicitFkCounter++; + const name = m[1] ? normalise(m[1]) : nextImplicitFkName(activeFks); activeFks.add(name); } } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 265cad6d..f498a9a0 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -455,6 +455,26 @@ test('effectiveStudyVariantForeignKey tracks multiple unnamed foreign keys with } }); +test('effectiveStudyVariantForeignKey reuses base unnamed constraint name in add-drop-add-drop sequence', () => { + const dir = migrations({ + '0001_add_first.sql': `ALTER TABLE studies ADD FOREIGN KEY (variant) REFERENCES variants(code);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // 0002 drops the first generated name studies_variant_fkey + writeFileSync(join(dir, '0002_drop_first.sql'), `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + // 0003 adds another unnamed FK which reuses the available base name studies_variant_fkey + writeFileSync(join(dir, '0003_add_second.sql'), `ALTER TABLE studies ADD FOREIGN KEY (variant) REFERENCES variants(code);`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + // 0004 drops studies_variant_fkey again + writeFileSync(join(dir, '0004_drop_second.sql'), `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey;`, 'utf8'); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear state when studies table is dropped or renamed', () => { const dropDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( From 19159a72fb7225efdc5e082ddabb6493a74a4abe Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 17:34:39 +0300 Subject: [PATCH 17/39] fix(scripts): support multi-table cascaded DROP TABLE and add test --- scripts/check-variant-parity.mjs | 2 +- scripts/test/check-variant-parity.test.mjs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 6d818de5..255ff9a0 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -342,7 +342,7 @@ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { for (const file of migrationFiles(dir)) { const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); for (const statement of splitStatements(sql)) { - if (/^\s*DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+(?:ONLY\s+)?"?variants"?\s+CASCADE\b/i.test(statement)) { + if (/^\s*DROP\s+TABLE\b[^;]*?(?:\bvariants\b|"variants")[^;]*?\bCASCADE\b/i.test(statement)) { activeFks.clear(); continue; } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index f498a9a0..5005d686 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -525,6 +525,20 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropVariantsCascadeDir, { recursive: true, force: true }); } + + const dropMultiVariantsCascadeDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_multi_cascade.sql': `DROP TABLE archive, variants CASCADE;`, + '0003_recreate_unconstrained.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dropMultiVariantsCascadeDir), false); + } finally { + rmSync(dropMultiVariantsCascadeDir, { recursive: true, force: true }); + } }); test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { From 3c80a9fff0b79cb8c2c7fa12dcbf9743fe98652d Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 18:12:57 +0300 Subject: [PATCH 18/39] fix(scripts): implement SQL tokenizer and schema replayer for variant parity guard --- docs/PROJECT_STATE.md | 4 + scripts/check-variant-parity.mjs | 755 +++++++++++++++++---- scripts/test/check-variant-parity.test.mjs | 190 ++++++ 3 files changed, 820 insertions(+), 129 deletions(-) diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index ce8f27dc..ed20bb96 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -13,6 +13,10 @@ constraint `studies_variant_fk` (`REFERENCES variants(code)` added with `NOT VAL `0028_studies_variant_fk.sql` and validated in `0029_validate_studies_variant_fk.sql`), replacing the duplicated inline `CHECK (variant IN (...))` constraint introduced in migration `0022`. +This completes the database integrity conversion candidate originally deferred in M15 Increment 10 +("Decided and not done: studies.variant stays a CHECK, for now"). Historical entries in earlier +increment logs record the pre-migration state when the column was governed by a CHECK constraint. + All database variant columns (`games.variant`, `ratings.variant`, `seeks.variant`, and `studies.variant`) now share identical relational integrity semantics: - Inserting an unsupported variant code into `studies.variant` is rejected by PostgreSQL with SQLSTATE diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 255ff9a0..b35513bf 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -41,6 +41,10 @@ import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +/** + * Directory holding the database migration SQL scripts. + * @type {string} + */ export const MIGRATIONS_DIR = 'packages/persistence/migrations'; /** @@ -52,8 +56,9 @@ export const MIGRATIONS_DIR = 'packages/persistence/migrations'; * the first because the escape is consumed, the second because the closing quote immediately reopens * a new string. * - * @param {string} text - * @param {'ts' | 'sql'} dialect + * @param {string} text The raw source code to strip. + * @param {'ts' | 'sql'} dialect The language dialect determining comment syntax. + * @returns {string} Comment-stripped source code with preserved offsets. */ export function stripComments(text, dialect) { const lineMarker = dialect === 'sql' ? '--' : '//'; @@ -107,6 +112,190 @@ export function stripComments(text, dialect) { return out; } +/** + * Token represents a lexical unit extracted from SQL source text. + * @typedef {Object} SqlToken + * @property {'word' | 'ident' | 'string' | 'punct'} type The syntactic category of the token. + * @property {string} value The normalized token value (lowercase for identifiers/keywords). + * @property {string} raw The verbatim token text from source. + * @property {number} pos The starting character offset in the source. + */ + +/** + * Tokenizes SQL source into a flat array of lexical tokens. + * + * Correctly distinguishes single-quoted strings (with doubled quote escaping `''`), + * double-quoted identifiers (with doubled quote escaping `""`), keywords/unquoted words, + * and punctuation tokens. + * + * @param {string} sql Comment-stripped SQL text. + * @returns {SqlToken[]} Array of SQL tokens. + */ +export function tokenizeSql(sql) { + /** @type {SqlToken[]} */ + const tokens = []; + let i = 0; + while (i < sql.length) { + const ch = sql[i]; + + if (/\s/.test(ch)) { + i++; + continue; + } + + if (ch === "'") { + const start = i; + let val = ''; + i++; + while (i < sql.length) { + if (sql[i] === "'") { + if (sql[i + 1] === "'") { + val += "'"; + i += 2; + continue; + } + i++; + break; + } + val += sql[i]; + i++; + } + tokens.push({ + type: 'string', + value: val, + raw: sql.slice(start, i), + pos: start, + }); + continue; + } + + if (ch === '"') { + const start = i; + let val = ''; + i++; + while (i < sql.length) { + if (sql[i] === '"') { + if (sql[i + 1] === '"') { + val += '"'; + i += 2; + continue; + } + i++; + break; + } + val += sql[i]; + i++; + } + tokens.push({ + type: 'ident', + value: val.toLowerCase(), + raw: sql.slice(start, i), + pos: start, + }); + continue; + } + + if (/[a-zA-Z_]/.test(ch)) { + const start = i; + while (i < sql.length && /[a-zA-Z0-9_$]/.test(sql[i])) { + i++; + } + const word = sql.slice(start, i); + tokens.push({ + type: 'word', + value: word.toLowerCase(), + raw: word, + pos: start, + }); + continue; + } + + if (ch === ';' || ch === ',' || ch === '(' || ch === ')' || ch === '.' || ch === '*') { + tokens.push({ + type: 'punct', + value: ch, + raw: ch, + pos: i, + }); + i++; + continue; + } + + i++; + } + return tokens; +} + +/** + * Splits a stream of SQL tokens into individual statements delimited by top-level semicolons. + * + * @param {SqlToken[]} tokens Array of SQL tokens. + * @returns {SqlToken[][]} Array of statement token arrays. + */ +export function splitSqlStatements(tokens) { + const statements = []; + let current = []; + for (const token of tokens) { + if (token.type === 'punct' && token.value === ';') { + if (current.length > 0) { + statements.push(current); + current = []; + } + } else { + current.push(token); + } + } + if (current.length > 0) { + statements.push(current); + } + return statements; +} + +/** + * Parses a table reference from a token stream starting at `startIndex`. + * Handles optional `ONLY` and optional `schema.` qualifiers. + * + * @param {SqlToken[]} tokens Array of SQL tokens. + * @param {number} startIndex Position in token stream to begin parsing table reference. + * @returns {{ schema: string, table: string, nextIndex: number } | null} Parsed table reference or null if invalid. + */ +export function parseQualifiedTableTarget(tokens, startIndex) { + let idx = startIndex; + if (tokens[idx]?.value === 'only') idx++; + + if (!tokens[idx] || (tokens[idx].type !== 'word' && tokens[idx].type !== 'ident')) { + return null; + } + + const firstIdent = tokens[idx].value; + idx++; + + if (tokens[idx]?.type === 'punct' && tokens[idx].value === '.') { + idx++; + if (!tokens[idx] || (tokens[idx].type !== 'word' && tokens[idx].type !== 'ident')) { + return null; + } + const secondIdent = tokens[idx].value; + idx++; + return { schema: firstIdent, table: secondIdent, nextIndex: idx }; + } + + return { schema: 'public', table: firstIdent, nextIndex: idx }; +} + +/** + * Determines if a parsed table reference matches a specified table and default schema. + * + * @param {{ schema: string, table: string } | null} ref Parsed table reference. + * @param {string} targetTable Expected table name. + * @param {string} [targetSchema='public'] Expected schema name (defaults to 'public'). + * @returns {boolean} True if the table reference matches the target. + */ +function isTableTarget(ref, targetTable, targetSchema = 'public') { + if (ref === null) return false; + return ref.table === targetTable && (!ref.schema || ref.schema === targetSchema); +} + /** * Pulls the quoted variant codes out of one region of a source file. * @@ -117,7 +306,8 @@ export function stripComments(text, dialect) { * A region that does not match is a hard failure, never an empty list. A guard that silently starts * checking nothing after a rename is worse than no guard, because the green tick still gets trusted. * - * @param {{label: string, file: string, open: RegExp, close: RegExp, dialect?: 'ts' | 'sql', text?: string}} spec + * @param {{label: string, file: string, open: RegExp, close: RegExp, dialect?: 'ts' | 'sql', text?: string}} spec Target region specification. + * @returns {{ label: string, file: string, variants: string[] }} Extracted variant list. */ export function extractRegion({ label, file, open, close, dialect = 'ts', text }) { const source = stripComments(text ?? readFileSync(file, 'utf8'), dialect); @@ -149,6 +339,9 @@ export function extractRegion({ label, file, open, close, dialect = 'ts', text } * names this repository uses the two orders coincide, but `9_x.sql` and `10_y.sql` would apply * as `10` then `9`, and a guard that sorted numerically would disagree with the schema on disk. * Fidelity to the runner is the invariant, not numeric intuition. + * + * @param {string} [dir=MIGRATIONS_DIR] Directory containing migration SQL files. + * @returns {string[]} Sorted migration file names. */ export function migrationFiles(dir = MIGRATIONS_DIR) { return readdirSync(dir) @@ -160,6 +353,9 @@ export function migrationFiles(dir = MIGRATIONS_DIR) { * Splits SQL into statements, respecting string literals so a `;` inside one does not end one. * * Comment stripping runs first, so only quotes are left to worry about. + * + * @param {string} sql Comment-stripped SQL text. + * @returns {string[]} Individual SQL statements. */ export function splitStatements(sql) { const statements = []; @@ -194,6 +390,10 @@ export function splitStatements(sql) { * migration and 0001 can never change. Any statement that mutates the table in a way this does not * model is a hard failure: quietly returning a set that ignores a `DELETE` would be a guard * confidently reporting the wrong schema. + * + * @param {string} [dir=MIGRATIONS_DIR] Path to the directory containing migration files. + * @returns {string[]} Array of variant codes present in the lookup table. + * @throws {Error} If unmodelled mutations (DELETE/UPDATE) or no INSERT statements are found. */ export function effectiveLookupVariants(dir = MIGRATIONS_DIR) { const codes = []; @@ -220,164 +420,461 @@ export function effectiveLookupVariants(dir = MIGRATIONS_DIR) { } /** - * How `studies.variant` is constrained after every migration has run. + * Allocates the next implicit constraint name following PostgreSQL's naming convention. * - * The last migration to define the constraint wins, so replacing it (`DROP CONSTRAINT ... , - * ADD CONSTRAINT ... CHECK (...)`) is a supported forward change rather than a reason to edit 0022. + * If the candidate base name is free on the table, it is chosen. If already occupied anywhere + * in the table's constraint namespace, the lowest available positive integer suffix is appended. * - * A migration that swaps the `CHECK` for `REFERENCES variants(code)` — the conversion recorded as a - * candidate in PROJECT_STATE — makes the column derive from the lookup table, at which point there - * is no separate list left to drift. That returns `null`, and the caller skips the mirror. + * @param {Set} constraintNamespace The set of all constraint names currently active on the table. + * @param {string} baseName The base constraint name (e.g. 'studies_variant_check' or 'studies_variant_fkey'). + * @returns {string} The allocated unique constraint name. */ +function nextImplicitConstraintName(constraintNamespace, baseName) { + if (!constraintNamespace.has(baseName)) { + return baseName; + } + let suffix = 1; + while (constraintNamespace.has(`${baseName}${suffix}`)) { + suffix++; + } + return `${baseName}${suffix}`; +} + /** - * `CREATE TABLE studies` / `ALTER TABLE studies`, and nothing else. + * Splits action clauses of an ALTER TABLE statement by comma at parenthesis nesting depth 0. * - * `IF EXISTS` and `IF NOT EXISTS` are both accepted because both are valid PostgreSQL and a - * migration is exactly where the defensive form gets written. Skipping `ALTER TABLE IF EXISTS - * studies` would leave the guard comparing against a constraint that statement had just replaced. - * Raised in the CodeRabbit review of PR #141. + * @param {SqlToken[]} tokens Action tokens following the table target. + * @returns {SqlToken[][]} Array of token arrays, one per action clause. */ -const TARGETS_STUDIES = - /^\s*(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE(?:\s+IF\s+EXISTS)?|DROP\s+TABLE(?:\s+IF\s+EXISTS)?)\s+(?:ONLY\s+)?"?studies"?(?:[\s(;]|$)/i; +function splitAlterActions(tokens) { + const actions = []; + let current = []; + let depth = 0; + for (const token of tokens) { + if (token.type === 'punct') { + if (token.value === '(') depth++; + else if (token.value === ')') depth--; + else if (token.value === ',' && depth === 0) { + if (current.length > 0) { + actions.push(current); + current = []; + } + continue; + } + } + current.push(token); + } + if (current.length > 0) { + actions.push(current); + } + return actions; +} /** - * The name PostgreSQL gives a `CHECK` on `studies.variant` that was written without one. + * Replays all migrations through a deterministic schema state machine to evaluate constraints on `studies.variant`. * - * `__check` is the server's own convention, so this is what a later migration has to - * name in its `DROP CONSTRAINT` — which makes it the right default to track against. + * Tracks table drops (including multi-table and cascaded variants drops), table renames, column drops, + * column renames, explicit constraint additions/drops/renames, and implicit PostgreSQL constraint name allocation. + * + * @param {string} [dir=MIGRATIONS_DIR] Migrations directory path. + * @returns {{ + * check: { file: string, name: string, variants: string[] } | null, + * hasForeignKey: boolean + * }} Effective check constraint on studies.variant and whether an active foreign key referencing variants(code) exists. */ -const IMPLICIT_CONSTRAINT_NAME = 'studies_variant_check'; - -/** The name PostgreSQL gives a foreign key on `studies.variant` written without an explicit name. */ -const IMPLICIT_FK_CONSTRAINT_NAME = 'studies_variant_fkey'; - -/** A `CHECK (variant IN (...))`, with the constraint name when the statement gives one. */ -const VARIANT_CHECK = - /(?:CONSTRAINT\s+"?(\w+)"?\s+)?CHECK\s*\(\s*(?:\bvariant\b|"variant")\s+IN\s*\(([\s\S]*?)\)\s*\)/gi; +export function replayStudiesSchema(dir = MIGRATIONS_DIR) { + const constraintNamespace = new Set(); + /** @type {Map} */ + const activeChecks = new Map(); + /** @type {Set} */ + const activeFks = new Set(); -/** Table-level `FOREIGN KEY (variant) REFERENCES variants(code)`. */ -const VARIANT_FK_TABLE = - /(?:CONSTRAINT\s+"?(\w+)"?\s+)?FOREIGN\s+KEY\s*\(\s*(?:\bvariant\b|"variant")\s*\)\s*REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; + for (const file of migrationFiles(dir)) { + const rawSql = readFileSync(join(dir, file), 'utf8'); + const stripped = stripComments(rawSql, 'sql'); + const tokens = tokenizeSql(stripped); + const statements = splitSqlStatements(tokens); + + for (const stmt of statements) { + if (stmt.length < 2) continue; + + // ----------------------------------------------------------------------- + // 1. DROP TABLE [IF EXISTS] [ONLY] table1 [, table2 ...] [CASCADE | RESTRICT] + // ----------------------------------------------------------------------- + if (stmt[0].value === 'drop' && stmt[1].value === 'table') { + let idx = 2; + if (stmt[idx]?.value === 'if' && stmt[idx + 1]?.value === 'exists') { + idx += 2; + } -/** Inline-column `variant TEXT ... [CONSTRAINT name] REFERENCES variants(code)`. */ -const VARIANT_FK_INLINE = - /(?:ADD\s+COLUMN|CREATE\s+TABLE)\s+[^;]*?(?:\bvariant\b|"variant")\s+TEXT\b[^,;)]*?(?:CONSTRAINT\s+"?(\w+)"?\s+)?REFERENCES\s+"?variants"?\s*\(\s*"?code"?\s*\)/gi; + const hasCascade = stmt.some((t) => t.value === 'cascade'); + + while (idx < stmt.length) { + if (stmt[idx]?.value === 'cascade' || stmt[idx]?.value === 'restrict') { + break; + } + const ref = parseQualifiedTableTarget(stmt, idx); + if (ref === null) break; + idx = ref.nextIndex; + + if (isTableTarget(ref, 'studies')) { + constraintNamespace.clear(); + activeChecks.clear(); + activeFks.clear(); + } + + if (isTableTarget(ref, 'variants') && hasCascade) { + activeFks.clear(); + } + + if (stmt[idx]?.type === 'punct' && stmt[idx].value === ',') { + idx++; + } else { + break; + } + } + continue; + } -const normalise = (name) => name.replace(/"/g, '').toLowerCase(); + // ----------------------------------------------------------------------- + // 2. ALTER TABLE [IF EXISTS] [ONLY] target ... + // ----------------------------------------------------------------------- + if (stmt[0].value === 'alter' && stmt[1].value === 'table') { + let idx = 2; + if (stmt[idx]?.value === 'if' && stmt[idx + 1]?.value === 'exists') { + idx += 2; + } -export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { - let current = null; - for (const file of migrationFiles(dir)) { - const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); + const ref = parseQualifiedTableTarget(stmt, idx); + if (ref === null || !isTableTarget(ref, 'studies')) { + continue; + } + idx = ref.nextIndex; + + // Table rename: ALTER TABLE studies RENAME TO new_name + if (stmt[idx]?.value === 'rename' && stmt[idx + 1]?.value === 'to') { + constraintNamespace.clear(); + activeChecks.clear(); + activeFks.clear(); + continue; + } - // Per statement, and only statements that name `studies` as their table. Testing the file as a - // whole let any other table move the answer: one migration that touches `studies` and also gives - // some other table its own `variant` CHECK would have overwritten this, and a `REFERENCES - // variants(code)` elsewhere in the same file — which is how games and ratings are already - // declared — would have cleared it, silently skipping the mirror the guard exists to compare. - // Raised in the CodeRabbit review of PR #141. - for (const statement of splitStatements(sql)) { - if (!TARGETS_STUDIES.test(statement)) continue; - - if (/^\s*DROP\s+TABLE/i.test(statement) || /RENAME\s+TO\b/i.test(statement)) { - current = null; + const actionTokens = stmt.slice(idx); + const actionClauses = splitAlterActions(actionTokens); + + for (const action of actionClauses) { + if (action.length === 0) continue; + + // Action A: DROP CONSTRAINT [IF EXISTS] + if (action[0].value === 'drop' && action[1]?.value === 'constraint') { + let cIdx = 2; + if (action[cIdx]?.value === 'if' && action[cIdx + 1]?.value === 'exists') { + cIdx += 2; + } + if (action[cIdx]?.type === 'word' || action[cIdx]?.type === 'ident') { + const name = action[cIdx].value; + constraintNamespace.delete(name); + activeChecks.delete(name); + activeFks.delete(name); + } + continue; + } + + // Action B: RENAME CONSTRAINT TO + if (action[0].value === 'rename' && action[1]?.value === 'constraint') { + const oldName = action[2]?.value; + if (oldName && activeChecks.has(oldName)) { + throw new Error( + `${file} renames the constraint governing \`studies.variant\` ` + + `(\`${oldName}\`). Teach this guard \`RENAME CONSTRAINT\` rather than leaving it ` + + `tracking a name nothing answers to.`, + ); + } + if (oldName && action[3]?.value === 'to' && action[4]) { + const newName = action[4].value; + if (constraintNamespace.has(oldName)) { + constraintNamespace.delete(oldName); + constraintNamespace.add(newName); + } + if (activeFks.has(oldName)) { + activeFks.delete(oldName); + activeFks.add(newName); + } + } + continue; + } + + // Action C: DROP [COLUMN] [IF EXISTS] + if (action[0].value === 'drop') { + let cIdx = 1; + if (action[cIdx]?.value === 'column') cIdx++; + if (action[cIdx]?.value === 'if' && action[cIdx + 1]?.value === 'exists') cIdx += 2; + if (action[cIdx]?.value === 'variant') { + // Drops all constraints on variant + activeChecks.clear(); + activeFks.clear(); + } + continue; + } + + // Action D: RENAME [COLUMN] TO + if (action[0].value === 'rename') { + let cIdx = 1; + if (action[cIdx]?.value === 'column') cIdx++; + if (action[cIdx]?.value === 'variant' && action[cIdx + 1]?.value === 'to') { + // variant column is renamed away + activeChecks.clear(); + activeFks.clear(); + } + continue; + } + + // Action E: ADD [CONSTRAINT ] CHECK (variant IN (...)) + // or ADD [COLUMN] variant TEXT ... CHECK (variant IN (...)) + let explicitName = null; + let aIdx = 0; + if (action[aIdx]?.value === 'add') aIdx++; + if (action[aIdx]?.value === 'constraint') { + explicitName = action[aIdx + 1]?.value ?? null; + aIdx += 2; + } + + // Search for CHECK (variant IN (...)) + const checkIdx = action.findIndex((t) => t.value === 'check'); + if (checkIdx !== -1 && action[checkIdx + 1]?.value === '(') { + let pIdx = checkIdx + 2; + if (action[pIdx]?.value === 'variant' && action[pIdx + 1]?.value === 'in' && action[pIdx + 2]?.value === '(') { + if (explicitName === null && checkIdx >= 2 && action[checkIdx - 2]?.value === 'constraint') { + explicitName = action[checkIdx - 1]?.value ?? null; + } + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < action.length && action[vIdx]?.value !== ')') { + if (action[vIdx].type === 'string') { + variantTokens.push(action[vIdx].value); + } + vIdx++; + } + const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + constraintNamespace.add(name); + activeChecks.set(name, { file, name, variants: variantTokens }); + continue; + } + } + + // Action F: ADD [CONSTRAINT ] FOREIGN KEY (variant) REFERENCES [public.]variants(code) + const fkIdx = action.findIndex((t) => t.value === 'foreign'); + if ( + fkIdx !== -1 && + action[fkIdx + 1]?.value === 'key' && + action[fkIdx + 2]?.value === '(' && + action[fkIdx + 3]?.value === 'variant' && + action[fkIdx + 4]?.value === ')' + ) { + let rIdx = fkIdx + 5; + if (action[rIdx]?.value === 'references') { + const refTarget = parseQualifiedTableTarget(action, rIdx + 1); + if (refTarget && isTableTarget(refTarget, 'variants')) { + const afterRef = refTarget.nextIndex; + if (action[afterRef]?.value === '(' && action[afterRef + 1]?.value === 'code' && action[afterRef + 2]?.value === ')') { + const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); + constraintNamespace.add(name); + activeFks.add(name); + continue; + } + } + } + } + + // Action G: ADD [COLUMN] [IF NOT EXISTS] variant TEXT ... [CONSTRAINT ] REFERENCES [public.]variants(code) + let colIdx = 0; + if (action[colIdx]?.value === 'add') colIdx++; + if (action[colIdx]?.value === 'column') colIdx++; + if (action[colIdx]?.value === 'if' && action[colIdx + 1]?.value === 'not' && action[colIdx + 2]?.value === 'exists') { + colIdx += 3; + } + if (action[colIdx]?.value === 'variant' && action[colIdx + 1]?.value === 'text') { + const inlineRefIdx = action.findIndex((t) => t.value === 'references'); + if (inlineRefIdx !== -1) { + const inlineRef = parseQualifiedTableTarget(action, inlineRefIdx + 1); + if (inlineRef && isTableTarget(inlineRef, 'variants')) { + const afterRef = inlineRef.nextIndex; + if (action[afterRef]?.value === '(' && action[afterRef + 1]?.value === 'code' && action[afterRef + 2]?.value === ')') { + const inlineConIdx = action.findIndex((t) => t.value === 'constraint'); + const inlineConName = inlineConIdx !== -1 ? action[inlineConIdx + 1]?.value : null; + const name = inlineConName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); + constraintNamespace.add(name); + activeFks.add(name); + continue; + } + } + } + } + + // Register any other explicitly named constraint + if (explicitName !== null) { + constraintNamespace.add(explicitName); + } + } continue; } - // A rename would leave every name tracked below pointing at something that no longer answers - // to it, and the drop that follows would look like an unrelated constraint. There is no - // half-right answer available, so say so rather than report a schema that is not there. - const renamed = /RENAME\s+CONSTRAINT\s+"?(\w+)"?/i.exec(statement); - if (renamed !== null && current !== null && normalise(renamed[1]) === current.name) { - throw new Error( - `${file} renames the constraint governing \`studies.variant\` ` + - `(\`${renamed[1]}\`). Teach this guard \`RENAME CONSTRAINT\` rather than leaving it ` + - `tracking a name nothing answers to.`, - ); - } + // ----------------------------------------------------------------------- + // 3. CREATE TABLE [IF NOT EXISTS] studies (...) + // ----------------------------------------------------------------------- + if (stmt[0].value === 'create' && stmt[1].value === 'table') { + let idx = 2; + if (stmt[idx]?.value === 'if' && stmt[idx + 1]?.value === 'not' && stmt[idx + 2]?.value === 'exists') { + idx += 3; + } + const ref = parseQualifiedTableTarget(stmt, idx); + if (ref === null || !isTableTarget(ref, 'studies')) { + continue; + } - // Order matters: `DROP CONSTRAINT` then `ADD CONSTRAINT ... CHECK` is how a constraint is - // replaced without editing the migration that first defined it. The name is compared exactly - // against the one being tracked — a substring test for "variant" both missed a legitimately - // named constraint (`DROP CONSTRAINT allowed_codes`) and would have fired on an unrelated one - // that happened to contain the word. Raised in the CodeRabbit review of PR #141. - for (const m of statement.matchAll(/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/gi)) { - if (current !== null && normalise(m[1]) === current.name) { - current = null; + // Fresh table creation clears prior state + constraintNamespace.clear(); + activeChecks.clear(); + activeFks.clear(); + + const openParen = stmt.findIndex((t) => t.type === 'punct' && t.value === '('); + if (openParen === -1) continue; + + const bodyTokens = stmt.slice(openParen + 1); + const clauses = splitAlterActions(bodyTokens); + + for (const clause of clauses) { + let explicitName = null; + let cIdx = 0; + if (clause[cIdx]?.value === 'constraint') { + explicitName = clause[cIdx + 1]?.value ?? null; + cIdx += 2; + } + + // Table-level CHECK (variant IN (...)) + const checkIdx = clause.findIndex((t) => t.value === 'check'); + if (checkIdx !== -1 && clause[checkIdx + 1]?.value === '(') { + let pIdx = checkIdx + 2; + if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < clause.length && clause[vIdx]?.value !== ')') { + if (clause[vIdx].type === 'string') { + variantTokens.push(clause[vIdx].value); + } + vIdx++; + } + const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + constraintNamespace.add(name); + activeChecks.set(name, { file, name, variants: variantTokens }); + continue; + } + } + + // Table-level FOREIGN KEY (variant) REFERENCES [public.]variants(code) + const fkIdx = clause.findIndex((t) => t.value === 'foreign'); + if ( + fkIdx !== -1 && + clause[fkIdx + 1]?.value === 'key' && + clause[fkIdx + 2]?.value === '(' && + clause[fkIdx + 3]?.value === 'variant' && + clause[fkIdx + 4]?.value === ')' + ) { + let rIdx = fkIdx + 5; + if (clause[rIdx]?.value === 'references') { + const refTarget = parseQualifiedTableTarget(clause, rIdx + 1); + if (refTarget && isTableTarget(refTarget, 'variants')) { + const afterRef = refTarget.nextIndex; + if (clause[afterRef]?.value === '(' && clause[afterRef + 1]?.value === 'code' && clause[afterRef + 2]?.value === ')') { + const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); + constraintNamespace.add(name); + activeFks.add(name); + continue; + } + } + } + } + + // Column-level: variant TEXT ... + if (clause[0]?.value === 'variant' && clause[1]?.value === 'text') { + const inlineCheckIdx = clause.findIndex((t) => t.value === 'check'); + if (inlineCheckIdx !== -1 && clause[inlineCheckIdx + 1]?.value === '(') { + let pIdx = inlineCheckIdx + 2; + if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { + let inlineName = null; + if (inlineCheckIdx >= 2 && clause[inlineCheckIdx - 2]?.value === 'constraint') { + inlineName = clause[inlineCheckIdx - 1]?.value; + } + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < clause.length && clause[vIdx]?.value !== ')') { + if (clause[vIdx].type === 'string') { + variantTokens.push(clause[vIdx].value); + } + vIdx++; + } + const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + constraintNamespace.add(name); + activeChecks.set(name, { file, name, variants: variantTokens }); + continue; + } + } + + const inlineRefIdx = clause.findIndex((t) => t.value === 'references'); + if (inlineRefIdx !== -1) { + const inlineRef = parseQualifiedTableTarget(clause, inlineRefIdx + 1); + if (inlineRef && isTableTarget(inlineRef, 'variants')) { + const afterRef = inlineRef.nextIndex; + if (clause[afterRef]?.value === '(' && clause[afterRef + 1]?.value === 'code' && clause[afterRef + 2]?.value === ')') { + const inlineConIdx = clause.findIndex((t) => t.value === 'constraint'); + const inlineConName = inlineConIdx !== -1 ? clause[inlineConIdx + 1]?.value : null; + const name = inlineConName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); + constraintNamespace.add(name); + activeFks.add(name); + continue; + } + } + } + } + + if (explicitName !== null) { + constraintNamespace.add(explicitName); + } } } - if (/(?:DROP\s+COLUMN(?:\s+IF\s+EXISTS)?|RENAME(?:\s+COLUMN)?)\s+"?variant"?/i.test(statement)) current = null; - - for (const m of statement.matchAll(VARIANT_CHECK)) { - current = { - file, - name: normalise(m[1] ?? IMPLICIT_CONSTRAINT_NAME), - variants: [...m[2].matchAll(/'([a-z0-9]+)'/g)].map((t) => t[1]), - }; - } } } - return current; -} -const nextImplicitFkName = (activeFks) => { - if (!activeFks.has(IMPLICIT_FK_CONSTRAINT_NAME)) { - return IMPLICIT_FK_CONSTRAINT_NAME; + let latestCheck = null; + for (const item of activeChecks.values()) { + latestCheck = item; } - let i = 1; - while (activeFks.has(`${IMPLICIT_FK_CONSTRAINT_NAME}${i}`)) { - i++; - } - return `${IMPLICIT_FK_CONSTRAINT_NAME}${i}`; -}; + + return { + check: latestCheck, + hasForeignKey: activeFks.size > 0, + }; +} /** - * Returns true if the effective migration schema defines an active foreign key on studies.variant referencing variants(code). + * Replays all migrations to compute the effective CHECK constraint governing `studies.variant`. + * + * @param {string} [dir=MIGRATIONS_DIR] Path to the migrations directory. + * @returns {{ file: string, name: string, variants: string[] } | null} The active CHECK constraint or null if none exists. + */ +export function effectiveStudyVariantConstraint(dir = MIGRATIONS_DIR) { + return replayStudiesSchema(dir).check; +} + +/** + * Replays all migrations to determine whether `studies.variant` has an active foreign key + * referencing `variants(code)`. * * @param {string} dir The migrations directory to replay. * @returns {boolean} Whether studies.variant has an active foreign key referencing variants(code). */ export function effectiveStudyVariantForeignKey(dir = MIGRATIONS_DIR) { - const activeFks = new Set(); - for (const file of migrationFiles(dir)) { - const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); - for (const statement of splitStatements(sql)) { - if (/^\s*DROP\s+TABLE\b[^;]*?(?:\bvariants\b|"variants")[^;]*?\bCASCADE\b/i.test(statement)) { - activeFks.clear(); - continue; - } - - if (!TARGETS_STUDIES.test(statement)) continue; - - if (/^\s*DROP\s+TABLE/i.test(statement) || /RENAME\s+TO\b/i.test(statement)) { - activeFks.clear(); - continue; - } - - const renamed = /RENAME\s+CONSTRAINT\s+"?(\w+)"?\s+TO\s+"?(\w+)"?/i.exec(statement); - if (renamed !== null && activeFks.has(normalise(renamed[1]))) { - activeFks.delete(normalise(renamed[1])); - activeFks.add(normalise(renamed[2])); - } - - for (const m of statement.matchAll(/DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/gi)) { - activeFks.delete(normalise(m[1])); - } - if (/(?:DROP\s+COLUMN(?:\s+IF\s+EXISTS)?|RENAME(?:\s+COLUMN)?)\s+"?variant"?/i.test(statement)) { - activeFks.clear(); - } - - for (const m of statement.matchAll(VARIANT_FK_TABLE)) { - const name = m[1] ? normalise(m[1]) : nextImplicitFkName(activeFks); - activeFks.add(name); - } - for (const m of statement.matchAll(VARIANT_FK_INLINE)) { - const name = m[1] ? normalise(m[1]) : nextImplicitFkName(activeFks); - activeFks.add(name); - } - } - } - return activeFks.size > 0; + return replayStudiesSchema(dir).hasForeignKey; } /** The root. Everything else is measured against this one. */ diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 5005d686..3f3c9e50 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -15,6 +15,10 @@ import { join } from 'node:path'; import { stripComments, splitStatements, + tokenizeSql, + splitSqlStatements, + parseQualifiedTableTarget, + replayStudiesSchema, extractRegion, migrationFiles, effectiveLookupVariants, @@ -539,6 +543,157 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropMultiVariantsCascadeDir, { recursive: true, force: true }); } + + const dropPublicStudiesDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_public_studies.sql': `DROP TABLE public.studies;`, + '0003_recreate_unconstrained.sql': `CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dropPublicStudiesDir), false); + assert.equal(effectiveStudyVariantConstraint(dropPublicStudiesDir), null); + } finally { + rmSync(dropPublicStudiesDir, { recursive: true, force: true }); + } + + const dropMultiStudiesDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_multi_studies.sql': `DROP TABLE studies, studies_backup;`, + '0003_recreate_unconstrained.sql': `CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dropMultiStudiesDir), false); + assert.equal(effectiveStudyVariantConstraint(dropMultiStudiesDir), null); + } finally { + rmSync(dropMultiStudiesDir, { recursive: true, force: true }); + } + + const dropVariantsSchemaArchiveDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_other_schema.sql': `DROP TABLE variants.archive CASCADE;`, + }); + try { + // variants.archive drops table archive in schema variants; canonical public.variants FK remains intact + assert.equal(effectiveStudyVariantForeignKey(dropVariantsSchemaArchiveDir), true); + } finally { + rmSync(dropVariantsSchemaArchiveDir, { recursive: true, force: true }); + } + + const dropQuotedVariantsNameDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_other_table.sql': `DROP TABLE "archived variants" CASCADE;`, + }); + try { + // "archived variants" is not the variants table; public.variants FK remains intact + assert.equal(effectiveStudyVariantForeignKey(dropQuotedVariantsNameDir), true); + } finally { + rmSync(dropQuotedVariantsNameDir, { recursive: true, force: true }); + } + + const dropPublicVariantsCascadeDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_public_variants.sql': `DROP TABLE public.variants CASCADE;`, + '0003_recreate_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dropPublicVariantsCascadeDir), false); + } finally { + rmSync(dropPublicVariantsCascadeDir, { recursive: true, force: true }); + } + + const dropVariantsRestrictDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_variants_restrict.sql': `DROP TABLE variants RESTRICT;`, + }); + try { + // RESTRICT does not cascade to dependent FKs + assert.equal(effectiveStudyVariantForeignKey(dropVariantsRestrictDir), true); + } finally { + rmSync(dropVariantsRestrictDir, { recursive: true, force: true }); + } +}); + +test('effectiveStudyVariantForeignKey ignores string literals containing RENAME TO keyword', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_add_column_with_default.sql': `ALTER TABLE studies ADD COLUMN note TEXT DEFAULT 'RENAME TO archive';`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('effectiveStudyVariantForeignKey distinguishes escaped quoted identifier from variant column', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + "archived""variant" TEXT NOT NULL REFERENCES variants(code), + variant TEXT NOT NULL + );`, + }); + try { + // "archived""variant" is a separate column from "variant", so studies.variant has no FK + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('effectiveStudyVariantForeignKey tracks occupied constraint namespace across constraint types', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + );`, + // Unrelated constraint occupies the base name studies_variant_fkey + '0002_occupy_name.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fkey CHECK (id IS NOT NULL);`, + // Unnamed FK receives next free name studies_variant_fkey1 + '0003_add_unnamed_fk.sql': `ALTER TABLE studies ADD FOREIGN KEY (variant) REFERENCES variants(code);`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + + // Dropping studies_variant_fkey drops the unrelated CHECK, leaving the FK studies_variant_fkey1 active + writeFileSync( + join(dir, '0004_drop_unrelated.sql'), + `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey;`, + 'utf8', + ); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + + // Dropping studies_variant_fkey1 drops the FK + writeFileSync( + join(dir, '0005_drop_fk.sql'), + `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey1;`, + 'utf8', + ); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); test('effectiveStudyVariantForeignKey recognizes inline column references on studies', () => { @@ -677,3 +832,38 @@ test('every mirror the guard claims to read is really there', () => { assert.ok(found.variants.includes('standard'), `${spec.label} is missing 'standard'`); } }); + +test('tokenizeSql correctly distinguishes string literals, escaped quotes, and punctuation', () => { + const sql = `ALTER TABLE "public"."studies" ADD COLUMN note TEXT DEFAULT 'It''s a ''quoted'' string; not a stmt';`; + const tokens = tokenizeSql(sql); + + assert.equal(tokens[0].value, 'alter'); + assert.equal(tokens[1].value, 'table'); + assert.equal(tokens[2].type, 'ident'); + assert.equal(tokens[2].value, 'public'); + assert.equal(tokens[3].value, '.'); + assert.equal(tokens[4].type, 'ident'); + assert.equal(tokens[4].value, 'studies'); + + const stringToken = tokens.find((t) => t.type === 'string'); + assert.notEqual(stringToken, undefined); + assert.equal(stringToken.value, "It's a 'quoted' string; not a stmt"); + + const stmts = splitSqlStatements(tokens); + assert.equal(stmts.length, 1, 'semicolon inside string literal must not split statement'); +}); + +test('parseQualifiedTableTarget handles schema qualification and ONLY keyword', () => { + const t1 = tokenizeSql('ONLY "public"."studies"'); + const ref1 = parseQualifiedTableTarget(t1, 0); + assert.deepEqual(ref1, { schema: 'public', table: 'studies', nextIndex: 4 }); + + const t2 = tokenizeSql('studies'); + const ref2 = parseQualifiedTableTarget(t2, 0); + assert.deepEqual(ref2, { schema: 'public', table: 'studies', nextIndex: 1 }); + + const t3 = tokenizeSql('variants.archive'); + const ref3 = parseQualifiedTableTarget(t3, 0); + assert.deepEqual(ref3, { schema: 'variants', table: 'archive', nextIndex: 3 }); +}); + From 578ef3ffd74d24adcdfa351277b53b6396a955ef Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 18:26:41 +0300 Subject: [PATCH 19/39] fix(scripts): support arbitrary inline FK column types, collect all active checks, and split tests --- docs/ROADMAP.md | 2 +- scripts/check-variant-parity.mjs | 22 ++++++++++----------- scripts/test/check-variant-parity.test.mjs | 23 ++++++++++++++++++---- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5648b7f4..7aa0c084 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,7 +28,7 @@ The correctness-critical foundation everything else depends on. - **Perft suites for each variant (RESOLVED in Increment 32 / ADR-0098 and completed in Increment 42).** All eight variants now have perft verification against published reference vectors or equality/divergence invariants. `horde` and `racingkings` coverage added from official `lichess-org/scalachess` perft resources (ADR-0098), resolving variant-rule defects in Horde rank-1 pawn double pushes and Racing Kings 8th-rank goal turn semantics. - **Chess960 was a label with nothing behind it (RESOLVED in M15 Increment 41 / ADR-0137; rules in ADR-0136).** Bigger than the "castling-by-file" wording suggested, and verified by running the code. (1) `Position.initial('chess960')` returns the standard array on every call, and `packages/game/src/game.ts:92` uses it for any seek without an explicit FEN — so a Chess960 game was ordinary chess. (2) `generateCastles` in `packages/chess-core/src/movegen.ts` pins the king to e1/e8 and looks for rooks at fixed offsets, so castling generates for exactly one of the 960 start positions, and that one is standard chess: king on b1 with rooks a1/h1 produces 0 castling moves, as does king g1 with rooks f1/h1. (3) `packages/chess-core/src/fen.ts` discards file-letter castling rights, so `HAha` on kiwipete gives `perft(1) = 46`, identical to no rights, against 48 for `KQkq`. **Withheld in Increment 33 (ADR-0099):** removed from the lobby's offered variants so nobody receives a mislabelled standard game; still accepted by the API and still a rule set in `chess-core`. **Open:** implementing it — 960-position generation, castling from arbitrary king and rook squares, Shredder/X-FEN in and out, the UCI king-takes-rook encoding, SAN, and perft against published values. **Server contract closed in M15 Increment 14 (ADR-0123):** withholding it in the lobby only protected browser users — `OFFERED_VARIANTS` is a list in the web bundle, and every other client (script, bot, mobile, curl) still reached `Game.create`, which wrote `variant: 'chess960'` beside a standard `initialFen` into an append-only event store. That is a durable falsehood, not a UI wart: afterwards nothing can tell such a row from a real Chess960 game. `Game.create` now refuses the variant outright — the one place every game is born, so seek acceptance, the bot route and the tournament launcher all inherit it — and `CREATABLE_VARIANTS` carries the same rule at the three creation routes so the refusal arrives as the API’s ordinary 422 rather than the 500 an unmapped `GameError` would produce. Seek acceptance re-checks the stored variant (409) because that value comes from a row, not a request. `chess960` remains valid everywhere that reads — the enum, the `variants` table, and every View schema — and only the three Request schemas narrowed. **Rules implemented in ADR-0136:** all 960 arrangements from the Scharnagl numbering, castling from arbitrary king and rook squares, Shredder-FEN in and canonical X-FEN out, the UCI king-takes-rook encoding, SAN unchanged, and perft against all 960 published reference positions — with the refusal deliberately kept, because the engine could now play any arrangement but nothing could yet *tell* it which one. **RESOLVED in M15 Increment 41 (ADR-0137):** `GameCreated` carries an optional `chess960StartId`, and the server draws it — `crypto.randomInt` at seek acceptance and on the bot route, derived from the launch identity for tournaments, so racing replicas agree on the arrangement instead of each drawing their own. Replay validates the stored id against the stored FEN rather than trusting either alone, and a legacy `chess960` event with no id replays from its FEN and reports its start as unknown, never as 518 — the guess that would look plausible. `Game.create` requires the id for the variant and refuses it for every other; `CREATABLE_VARIANTS` and `OFFERED_VARIANTS` admit `chess960`; `openapi.json` is regenerated. The seek-accept 409 is *kept* rather than removed as the checklist said, because `seek.variant` is read from a database column and the type system does not span the SQL (ADR-0137 §6). Move *input* needed no Chess960 logic in the browser — `BoardInteraction` is oracle-driven and the server's legal-move map already spells castling king-takes-rook — but move *projection* did: `applyMove` advances the client's own board between snapshots and recognised castling only at exactly two files, projecting `d1a1` as a king on a1 with the rook deleted. It now treats a king landing on a friendly rook as a castle (ADR-0137 §8). **Nothing left open on the variant.** - **`Position.snapshot()` lost three-check state (RESOLVED in M15 Increment 8).** `snapshot()` in `packages/chess-core/src/position.ts` round-tripped through `parseFen(this.fen(), variant)`, and `toFen` does not serialise `checkCount`, so both counters reset to zero. Found during the Increment 33 audit (ADR-0099 §4) and recorded there as "latent, not live" on the grounds that a repetition key uses only the first four FEN fields. **That assessment was wrong.** `packages/chess-core/src/repetition.ts` had appended the delivered-check counters to the key for `threecheck` since 2026-07-13 — three weeks before the audit — and `packages/game/src/game.ts` builds that key from the lossy snapshot on both the live and replay paths. Every three-check position therefore reported `0+0`, and a board that repeated while the check counts climbed was treated as a repetition: `Re1+ Kf8 Rd1 Ke8 Re1+ Kf8 Rd1 Ke8` was declared a threefold draw with White one check from winning. **Resolved in M15 Increment 8:** `snapshot()` returns `cloneState(this.state)`, the existing authoritative deep copy, so no `PositionState` field is dropped; the line above now continues and White wins `1-0` on the third check. Serialising three-check counters into FEN was deferred to M15 Increment 9 and is **RESOLVED** there (ADR-0120): `toFen` emits the canonical Fairy-Stockfish field — `N+M` remaining, in field five — and `parseFen` accepts that, the trailing `+N+M` delivered form, and the legacy six-field form. The engine defect it was hiding is closed with it: Fairy-Stockfish 14 reads a missing counter field as `1+1`, so every three-check analysis had been scored as though one check won the game. -- **The supported-variant list is written out seven times (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, the `variants` lookup table, and the `CHECK` on `studies.variant` added by Increment 9 — seven hand-maintained copies, none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the six mirrors to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Resolved in M15 Increment 42:** `studies.variant` now references `variants(code)` via `FOREIGN KEY` (added with `NOT VALID` in `0028_studies_variant_fk.sql` and validated in `0029_validate_studies_variant_fk.sql`), dropping the duplicated `CHECK` constraint so that all database variant columns derive from the canonical `variants` lookup table. +- **The supported-variant list is written out across mirrors (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, and the `variants` lookup table — originally seven hand-maintained copies (including an inline `CHECK` on `studies.variant`), none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the active mirrors (the four TypeScript sites plus the lookup table) to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Resolved in M15 Increment 42:** `studies.variant` now references `variants(code)` via `FOREIGN KEY` (added with `NOT VALID` in `0028_studies_variant_fk.sql` and validated in `0029_validate_studies_variant_fk.sql`), dropping the duplicated `CHECK` constraint so that all database variant columns derive uniformly from the canonical `variants` lookup table. - **CI depends on the Ubuntu package mirror for Stockfish (RESOLVED in M15 Increment 11 / ADR-0121).** The `analysis smoke` job apt-installs Stockfish, and that step has now stalled indefinitely three times: once on PR #140 (cancelled and re-run successfully) and twice post-merge on `cbe6bce` (jobs `96156044656` and `96200357632`, the rerun cancelled after ~34 minutes). Each stall was in the mirror step, before Fairy-Stockfish was installed and before any smoke test ran, so it proves nothing about the code and costs the full job timeout. Fairy-Stockfish in the same job is already a pinned, checksummed release download and has never stalled. **Resolved in M15 Increment 11 (ADR-0121):** Stockfish now comes from release `sf_16`, asset `stockfish-ubuntu-x86-64.tar`, pinned by SHA-256 `efca1c60ec11fd9628425f3ee40644ad1618535ddf881c16385a86f7fc9e0983`, extracted one member by exact path, `chmod`ed only after verification, and asserted to report `id name Stockfish 16` before the suite runs. `sf_16` is the version apt was already serving, so the engine under test is unchanged. `apt-get` no longer appears in any executable line of any workflow, and the job now carries `timeout-minutes: 15` so a future stall is capped rather than inheriting the six-hour default. Production Docker images used apt until **M15 Increment 12**, which closed the last of it. Before that, `release.yml` built `Dockerfile.api` and `Dockerfile.gateway` on a `v*` tag push and those builds ran the apt layers — meaning production shipped Debian bookworm's `stockfish 15.1-4` while CI proved the engine boundary against 16, and a base-image move to trixie would have made it 17 with no commit of ours. Both images now take the binary from a pinned `stockfish` artefact stage using the same release, asset and digest as CI, with the licence and corresponding source copied beside it for GHCR redistribution, and a `docker-images` CI job builds both before merge instead of first exercising them at release time. `scripts/check-engine-pin-parity.mjs` fails if the four copies of the pin ever disagree. - **The web image was published but never built before the tag (RESOLVED in M15 Increment 12).** `release.yml` pushes three images to GHCR on a `v*` tag — `Dockerfile.api`, `Dockerfile.gateway` and `Dockerfile.web` — but the `docker-images` CI job as first written built only the two that carry the pinned engine. `Dockerfile.web` copies `docker/web/nginx.conf.template` into `/etc/nginx/templates/`, where the image entrypoint runs `envsubst` over it at container start, so a broken template is not a build error at all: the image builds clean and the container dies on boot, and nothing before the release tag rendered it. Raised in the Qodo review of PR #143. **Resolved in the same increment:** the job builds all three images and checks the web one for what can actually break in it — the entrypoint renders the template with representative loopback upstreams (`nginx -t` resolves a literal `proxy_pass` host at config-load time, so the compose defaults would fail on a runner for the wrong reason), `nginx -t` must accept the result, both upstreams must appear substituted, and `$http_host` and `$uri` must survive, which is what `NGINX_ENVSUBST_FILTER` exists to guarantee and nothing tested. `docker/` joins the `images` path filter so the filter and the job cover the same set. - **A study movetext walker that never asked which variant it was reading (RESOLVED in M15 Increment 13 / ADR-0122).** `importGame` in `packages/studies/src/import.ts` resolved every SAN through `resolveSan(reader, fen, san)` and `reader.play(fen, san)` with no variant, and `resolveSan` defaults to standard rather than failing — so a Crazyhouse or Three-Check game imported through it would have been validated against standard chess, rejecting legal moves and accepting illegal ones with no error to say why. Latent, not live: a whole-repository search found it referenced only by its own definition and its own test file, and both real import paths (`InMemoryStudiesRepository.buildTreeFromMovetext` via `appendNode`, and `PgStudiesRepository.buildTreeFromMovetextInternal` via a required parameter) already thread the study variant correctly. It was also a third implementation of a descent the two adapters already have, and ADR-0091 §10 records what happened the last time two copies of this walk diverged. **Resolved in M15 Increment 13 (ADR-0122):** deleted, together with the types and the `START_FEN` alias that existed only to serve it; `chapterNameFor` stays, because both adapters import it. `resolveSan`’s standard default is kept and now pinned by a test that states why — `@chess-platform/learning` relies on it and lessons carry no variant of their own. The coverage that was only reachable through the dead function moved onto `resolveSan` itself, and variant propagation through side variations — previously unverified, since the existing three-check test had no variations — is now a mutation-checked regression test. diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index b35513bf..24ac5dab 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -686,14 +686,14 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } } - // Action G: ADD [COLUMN] [IF NOT EXISTS] variant TEXT ... [CONSTRAINT ] REFERENCES [public.]variants(code) + // Action G: ADD [COLUMN] [IF NOT EXISTS] variant ... [CONSTRAINT ] REFERENCES [public.]variants(code) let colIdx = 0; if (action[colIdx]?.value === 'add') colIdx++; if (action[colIdx]?.value === 'column') colIdx++; if (action[colIdx]?.value === 'if' && action[colIdx + 1]?.value === 'not' && action[colIdx + 2]?.value === 'exists') { colIdx += 3; } - if (action[colIdx]?.value === 'variant' && action[colIdx + 1]?.value === 'text') { + if (action[colIdx]?.value === 'variant') { const inlineRefIdx = action.findIndex((t) => t.value === 'references'); if (inlineRefIdx !== -1) { const inlineRef = parseQualifiedTableTarget(action, inlineRefIdx + 1); @@ -795,8 +795,8 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } } - // Column-level: variant TEXT ... - if (clause[0]?.value === 'variant' && clause[1]?.value === 'text') { + // Column-level: variant ... + if (clause[0]?.value === 'variant') { const inlineCheckIdx = clause.findIndex((t) => t.value === 'check'); if (inlineCheckIdx !== -1 && clause[inlineCheckIdx + 1]?.value === '(') { let pIdx = inlineCheckIdx + 2; @@ -852,6 +852,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { return { check: latestCheck, + checks: Array.from(activeChecks.values()), hasForeignKey: activeFks.size > 0, }; } @@ -936,16 +937,15 @@ export function collectMirrors(dir = MIGRATIONS_DIR) { file: dir, variants: effectiveLookupVariants(dir), }); - const study = effectiveStudyVariantConstraint(dir); - const hasStudyVariantFk = effectiveStudyVariantForeignKey(dir); - if (study !== null) { + const replayed = replayStudiesSchema(dir); + for (const checkItem of replayed.checks) { mirrors.push({ - label: '`studies.variant` CHECK constraint, after all migrations', - file: join(dir, study.file), - variants: study.variants, + label: `\`studies.variant\` CHECK constraint (${checkItem.name}), after all migrations`, + file: join(dir, checkItem.file), + variants: checkItem.variants, }); } - return { mirrors, studyConstraint: study, hasStudyVariantFk }; + return { mirrors, studyConstraint: replayed.check, hasStudyVariantFk: replayed.hasForeignKey }; } function main() { diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 3f3c9e50..a298c40a 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -479,7 +479,7 @@ test('effectiveStudyVariantForeignKey reuses base unnamed constraint name in add } }); -test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear state when studies table is dropped or renamed', () => { +test('DROP TABLE studies clears constraint and foreign key state', () => { const dropDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -497,7 +497,9 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropDir, { recursive: true, force: true }); } +}); +test('ALTER TABLE studies RENAME TO clears constraint and foreign key state', () => { const renameDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -515,7 +517,9 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(renameDir, { recursive: true, force: true }); } +}); +test('DROP TABLE variants CASCADE clears active studies foreign key', () => { const dropVariantsCascadeDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -529,7 +533,9 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropVariantsCascadeDir, { recursive: true, force: true }); } +}); +test('DROP TABLE multi-table containing variants with CASCADE clears active studies foreign key', () => { const dropMultiVariantsCascadeDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -543,7 +549,9 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropMultiVariantsCascadeDir, { recursive: true, force: true }); } +}); +test('DROP TABLE public.studies with schema qualifier clears state', () => { const dropPublicStudiesDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -558,7 +566,9 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropPublicStudiesDir, { recursive: true, force: true }); } +}); +test('DROP TABLE multi-table containing studies clears state', () => { const dropMultiStudiesDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -573,7 +583,9 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropMultiStudiesDir, { recursive: true, force: true }); } +}); +test('DROP TABLE variants.archive CASCADE in another schema does not clear public.variants foreign keys', () => { const dropVariantsSchemaArchiveDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -582,12 +594,13 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear '0002_drop_other_schema.sql': `DROP TABLE variants.archive CASCADE;`, }); try { - // variants.archive drops table archive in schema variants; canonical public.variants FK remains intact assert.equal(effectiveStudyVariantForeignKey(dropVariantsSchemaArchiveDir), true); } finally { rmSync(dropVariantsSchemaArchiveDir, { recursive: true, force: true }); } +}); +test('DROP TABLE "archived variants" CASCADE with quoted name does not clear public.variants foreign keys', () => { const dropQuotedVariantsNameDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -596,12 +609,13 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear '0002_drop_other_table.sql': `DROP TABLE "archived variants" CASCADE;`, }); try { - // "archived variants" is not the variants table; public.variants FK remains intact assert.equal(effectiveStudyVariantForeignKey(dropQuotedVariantsNameDir), true); } finally { rmSync(dropQuotedVariantsNameDir, { recursive: true, force: true }); } +}); +test('DROP TABLE public.variants CASCADE with schema qualification clears active studies foreign key', () => { const dropPublicVariantsCascadeDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -615,7 +629,9 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear } finally { rmSync(dropPublicVariantsCascadeDir, { recursive: true, force: true }); } +}); +test('DROP TABLE variants RESTRICT does not cascade to clear active studies foreign key', () => { const dropVariantsRestrictDir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -624,7 +640,6 @@ test('effectiveStudyVariantForeignKey and effectiveStudyVariantConstraint clear '0002_drop_variants_restrict.sql': `DROP TABLE variants RESTRICT;`, }); try { - // RESTRICT does not cascade to dependent FKs assert.equal(effectiveStudyVariantForeignKey(dropVariantsRestrictDir), true); } finally { rmSync(dropVariantsRestrictDir, { recursive: true, force: true }); From 14c52b45b0b6c0c2815a16aa1704aa05e1603a7a Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 18:37:19 +0300 Subject: [PATCH 20/39] fix(scripts): release dropped constraint names from namespace on cascaded table and column drops --- scripts/check-variant-parity.mjs | 15 +++++++++++++++ scripts/test/check-variant-parity.test.mjs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 24ac5dab..7264817d 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -524,6 +524,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } if (isTableTarget(ref, 'variants') && hasCascade) { + for (const fkName of activeFks) { + constraintNamespace.delete(fkName); + } activeFks.clear(); } @@ -611,6 +614,12 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (action[cIdx]?.value === 'if' && action[cIdx + 1]?.value === 'exists') cIdx += 2; if (action[cIdx]?.value === 'variant') { // Drops all constraints on variant + for (const checkName of activeChecks.keys()) { + constraintNamespace.delete(checkName); + } + for (const fkName of activeFks) { + constraintNamespace.delete(fkName); + } activeChecks.clear(); activeFks.clear(); } @@ -623,6 +632,12 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (action[cIdx]?.value === 'column') cIdx++; if (action[cIdx]?.value === 'variant' && action[cIdx + 1]?.value === 'to') { // variant column is renamed away + for (const checkName of activeChecks.keys()) { + constraintNamespace.delete(checkName); + } + for (const fkName of activeFks) { + constraintNamespace.delete(fkName); + } activeChecks.clear(); activeFks.clear(); } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index a298c40a..2094a686 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -533,6 +533,24 @@ test('DROP TABLE variants CASCADE clears active studies foreign key', () => { } finally { rmSync(dropVariantsCascadeDir, { recursive: true, force: true }); } + + const cascadeRecreateDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_variants.sql': `DROP TABLE variants CASCADE;`, + '0003_recreate_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY);`, + '0004_readd_fk.sql': `ALTER TABLE studies ADD FOREIGN KEY (variant) REFERENCES variants(code);`, + '0005_drop_fk.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey;`, + }); + try { + // Releasing the cascaded FK name allows the re-added FK to use base name studies_variant_fkey, + // so dropping studies_variant_fkey properly clears it. + assert.equal(effectiveStudyVariantForeignKey(cascadeRecreateDir), false); + } finally { + rmSync(cascadeRecreateDir, { recursive: true, force: true }); + } }); test('DROP TABLE multi-table containing variants with CASCADE clears active studies foreign key', () => { From 439b110f76208e112af58349ff1e5657b2ef7488 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 18:50:34 +0300 Subject: [PATCH 21/39] fix(scripts): support multi-constraint column definitions and dependent column constraint tracking --- scripts/check-variant-parity.mjs | 260 ++++++++++++--------- scripts/test/check-variant-parity.test.mjs | 75 ++++++ 2 files changed, 220 insertions(+), 115 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 7264817d..9f4dd4bc 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -470,6 +470,61 @@ function splitAlterActions(tokens) { return actions; } +/** + * Scans a column definition clause for CHECK and REFERENCES constraints on variant. + * + * @param {SqlToken[]} clause Tokens making up the column definition. + * @param {string} file Current migration filename. + * @param {Set} constraintNamespace Set of active table constraint names. + * @param {Set} variantConstraints Set of constraint names dependent on the variant column. + * @param {Map} activeChecks Active CHECK map. + * @param {Set} activeFks Active foreign key set. + */ +function scanColumnConstraints(clause, file, constraintNamespace, variantConstraints, activeChecks, activeFks) { + for (let i = 0; i < clause.length; i++) { + // Check for inline CHECK (variant IN (...)) + if (clause[i].value === 'check' && clause[i + 1]?.value === '(') { + const pIdx = i + 2; + if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { + let inlineName = null; + if (i >= 2 && clause[i - 2]?.value === 'constraint') { + inlineName = clause[i - 1]?.value; + } + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < clause.length && clause[vIdx]?.value !== ')') { + if (clause[vIdx].type === 'string') { + variantTokens.push(clause[vIdx].value); + } + vIdx++; + } + const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + constraintNamespace.add(name); + variantConstraints.add(name); + activeChecks.set(name, { file, name, variants: variantTokens }); + } + } + + // Check for inline REFERENCES variants(code) + if (clause[i].value === 'references') { + const inlineRef = parseQualifiedTableTarget(clause, i + 1); + if (inlineRef && isTableTarget(inlineRef, 'variants')) { + const afterRef = inlineRef.nextIndex; + if (clause[afterRef]?.value === '(' && clause[afterRef + 1]?.value === 'code' && clause[afterRef + 2]?.value === ')') { + let inlineName = null; + if (i >= 2 && clause[i - 2]?.value === 'constraint') { + inlineName = clause[i - 1]?.value; + } + const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); + constraintNamespace.add(name); + variantConstraints.add(name); + activeFks.add(name); + } + } + } + } +} + /** * Replays all migrations through a deterministic schema state machine to evaluate constraints on `studies.variant`. * @@ -479,11 +534,13 @@ function splitAlterActions(tokens) { * @param {string} [dir=MIGRATIONS_DIR] Migrations directory path. * @returns {{ * check: { file: string, name: string, variants: string[] } | null, + * checks: Array<{ file: string, name: string, variants: string[] }>, * hasForeignKey: boolean * }} Effective check constraint on studies.variant and whether an active foreign key referencing variants(code) exists. */ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { const constraintNamespace = new Set(); + const variantConstraints = new Set(); /** @type {Map} */ const activeChecks = new Map(); /** @type {Set} */ @@ -519,6 +576,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (isTableTarget(ref, 'studies')) { constraintNamespace.clear(); + variantConstraints.clear(); activeChecks.clear(); activeFks.clear(); } @@ -526,6 +584,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (isTableTarget(ref, 'variants') && hasCascade) { for (const fkName of activeFks) { constraintNamespace.delete(fkName); + variantConstraints.delete(fkName); } activeFks.clear(); } @@ -557,6 +616,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Table rename: ALTER TABLE studies RENAME TO new_name if (stmt[idx]?.value === 'rename' && stmt[idx + 1]?.value === 'to') { constraintNamespace.clear(); + variantConstraints.clear(); activeChecks.clear(); activeFks.clear(); continue; @@ -577,6 +637,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (action[cIdx]?.type === 'word' || action[cIdx]?.type === 'ident') { const name = action[cIdx].value; constraintNamespace.delete(name); + variantConstraints.delete(name); activeChecks.delete(name); activeFks.delete(name); } @@ -599,6 +660,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { constraintNamespace.delete(oldName); constraintNamespace.add(newName); } + if (variantConstraints.has(oldName)) { + variantConstraints.delete(oldName); + variantConstraints.add(newName); + } if (activeFks.has(oldName)) { activeFks.delete(oldName); activeFks.add(newName); @@ -613,13 +678,11 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (action[cIdx]?.value === 'column') cIdx++; if (action[cIdx]?.value === 'if' && action[cIdx + 1]?.value === 'exists') cIdx += 2; if (action[cIdx]?.value === 'variant') { - // Drops all constraints on variant - for (const checkName of activeChecks.keys()) { - constraintNamespace.delete(checkName); - } - for (const fkName of activeFks) { - constraintNamespace.delete(fkName); + // Drops all constraints depending on variant + for (const name of variantConstraints) { + constraintNamespace.delete(name); } + variantConstraints.clear(); activeChecks.clear(); activeFks.clear(); } @@ -631,21 +694,27 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { let cIdx = 1; if (action[cIdx]?.value === 'column') cIdx++; if (action[cIdx]?.value === 'variant' && action[cIdx + 1]?.value === 'to') { - // variant column is renamed away - for (const checkName of activeChecks.keys()) { - constraintNamespace.delete(checkName); - } - for (const fkName of activeFks) { - constraintNamespace.delete(fkName); - } + // variant column is renamed away; constraints remain on renamed column in namespace + variantConstraints.clear(); activeChecks.clear(); activeFks.clear(); } continue; } - // Action E: ADD [CONSTRAINT ] CHECK (variant IN (...)) - // or ADD [COLUMN] variant TEXT ... CHECK (variant IN (...)) + // Action E: ADD [COLUMN] variant ... + let colIdx = 0; + if (action[colIdx]?.value === 'add') colIdx++; + if (action[colIdx]?.value === 'column') colIdx++; + if (action[colIdx]?.value === 'if' && action[colIdx + 1]?.value === 'not' && action[colIdx + 2]?.value === 'exists') { + colIdx += 3; + } + if (action[colIdx]?.value === 'variant') { + scanColumnConstraints(action.slice(colIdx), file, constraintNamespace, variantConstraints, activeChecks, activeFks); + continue; + } + + // Action F: Table-level ADD [CONSTRAINT ] CHECK (variant IN (...)) or FOREIGN KEY let explicitName = null; let aIdx = 0; if (action[aIdx]?.value === 'add') aIdx++; @@ -654,30 +723,35 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { aIdx += 2; } - // Search for CHECK (variant IN (...)) - const checkIdx = action.findIndex((t) => t.value === 'check'); - if (checkIdx !== -1 && action[checkIdx + 1]?.value === '(') { - let pIdx = checkIdx + 2; - if (action[pIdx]?.value === 'variant' && action[pIdx + 1]?.value === 'in' && action[pIdx + 2]?.value === '(') { - if (explicitName === null && checkIdx >= 2 && action[checkIdx - 2]?.value === 'constraint') { - explicitName = action[checkIdx - 1]?.value ?? null; - } - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < action.length && action[vIdx]?.value !== ')') { - if (action[vIdx].type === 'string') { - variantTokens.push(action[vIdx].value); + let handled = false; + + // Search for table-level CHECK (variant IN (...)) + for (let i = 0; i < action.length; i++) { + if (action[i].value === 'check' && action[i + 1]?.value === '(') { + const pIdx = i + 2; + if (action[pIdx]?.value === 'variant' && action[pIdx + 1]?.value === 'in' && action[pIdx + 2]?.value === '(') { + let name = explicitName; + if (name === null && i >= 2 && action[i - 2]?.value === 'constraint') { + name = action[i - 1]?.value ?? null; + } + const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < action.length && action[vIdx]?.value !== ')') { + if (action[vIdx].type === 'string') { + variantTokens.push(action[vIdx].value); + } + vIdx++; } - vIdx++; + constraintNamespace.add(assignedName); + variantConstraints.add(assignedName); + activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); + handled = true; } - const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - constraintNamespace.add(name); - activeChecks.set(name, { file, name, variants: variantTokens }); - continue; } } - // Action F: ADD [CONSTRAINT ] FOREIGN KEY (variant) REFERENCES [public.]variants(code) + // Search for table-level FOREIGN KEY (variant) REFERENCES [public.]variants(code) const fkIdx = action.findIndex((t) => t.value === 'foreign'); if ( fkIdx !== -1 && @@ -694,37 +768,15 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (action[afterRef]?.value === '(' && action[afterRef + 1]?.value === 'code' && action[afterRef + 2]?.value === ')') { const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); constraintNamespace.add(name); + variantConstraints.add(name); activeFks.add(name); - continue; + handled = true; } } } } - // Action G: ADD [COLUMN] [IF NOT EXISTS] variant ... [CONSTRAINT ] REFERENCES [public.]variants(code) - let colIdx = 0; - if (action[colIdx]?.value === 'add') colIdx++; - if (action[colIdx]?.value === 'column') colIdx++; - if (action[colIdx]?.value === 'if' && action[colIdx + 1]?.value === 'not' && action[colIdx + 2]?.value === 'exists') { - colIdx += 3; - } - if (action[colIdx]?.value === 'variant') { - const inlineRefIdx = action.findIndex((t) => t.value === 'references'); - if (inlineRefIdx !== -1) { - const inlineRef = parseQualifiedTableTarget(action, inlineRefIdx + 1); - if (inlineRef && isTableTarget(inlineRef, 'variants')) { - const afterRef = inlineRef.nextIndex; - if (action[afterRef]?.value === '(' && action[afterRef + 1]?.value === 'code' && action[afterRef + 2]?.value === ')') { - const inlineConIdx = action.findIndex((t) => t.value === 'constraint'); - const inlineConName = inlineConIdx !== -1 ? action[inlineConIdx + 1]?.value : null; - const name = inlineConName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); - constraintNamespace.add(name); - activeFks.add(name); - continue; - } - } - } - } + if (handled) continue; // Register any other explicitly named constraint if (explicitName !== null) { @@ -749,6 +801,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Fresh table creation clears prior state constraintNamespace.clear(); + variantConstraints.clear(); activeChecks.clear(); activeFks.clear(); @@ -759,6 +812,14 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { const clauses = splitAlterActions(bodyTokens); for (const clause of clauses) { + if (clause.length === 0) continue; + + // Column-level: variant ... + if (clause[0]?.value === 'variant') { + scanColumnConstraints(clause, file, constraintNamespace, variantConstraints, activeChecks, activeFks); + continue; + } + let explicitName = null; let cIdx = 0; if (clause[cIdx]?.value === 'constraint') { @@ -766,23 +827,31 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { cIdx += 2; } + let handled = false; + // Table-level CHECK (variant IN (...)) - const checkIdx = clause.findIndex((t) => t.value === 'check'); - if (checkIdx !== -1 && clause[checkIdx + 1]?.value === '(') { - let pIdx = checkIdx + 2; - if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < clause.length && clause[vIdx]?.value !== ')') { - if (clause[vIdx].type === 'string') { - variantTokens.push(clause[vIdx].value); + for (let i = 0; i < clause.length; i++) { + if (clause[i].value === 'check' && clause[i + 1]?.value === '(') { + const pIdx = i + 2; + if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { + let name = explicitName; + if (name === null && i >= 2 && clause[i - 2]?.value === 'constraint') { + name = clause[i - 1]?.value ?? null; + } + const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < clause.length && clause[vIdx]?.value !== ')') { + if (clause[vIdx].type === 'string') { + variantTokens.push(clause[vIdx].value); + } + vIdx++; } - vIdx++; + constraintNamespace.add(assignedName); + variantConstraints.add(assignedName); + activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); + handled = true; } - const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - constraintNamespace.add(name); - activeChecks.set(name, { file, name, variants: variantTokens }); - continue; } } @@ -803,54 +872,15 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (clause[afterRef]?.value === '(' && clause[afterRef + 1]?.value === 'code' && clause[afterRef + 2]?.value === ')') { const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); constraintNamespace.add(name); + variantConstraints.add(name); activeFks.add(name); - continue; + handled = true; } } } } - // Column-level: variant ... - if (clause[0]?.value === 'variant') { - const inlineCheckIdx = clause.findIndex((t) => t.value === 'check'); - if (inlineCheckIdx !== -1 && clause[inlineCheckIdx + 1]?.value === '(') { - let pIdx = inlineCheckIdx + 2; - if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { - let inlineName = null; - if (inlineCheckIdx >= 2 && clause[inlineCheckIdx - 2]?.value === 'constraint') { - inlineName = clause[inlineCheckIdx - 1]?.value; - } - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < clause.length && clause[vIdx]?.value !== ')') { - if (clause[vIdx].type === 'string') { - variantTokens.push(clause[vIdx].value); - } - vIdx++; - } - const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - constraintNamespace.add(name); - activeChecks.set(name, { file, name, variants: variantTokens }); - continue; - } - } - - const inlineRefIdx = clause.findIndex((t) => t.value === 'references'); - if (inlineRefIdx !== -1) { - const inlineRef = parseQualifiedTableTarget(clause, inlineRefIdx + 1); - if (inlineRef && isTableTarget(inlineRef, 'variants')) { - const afterRef = inlineRef.nextIndex; - if (clause[afterRef]?.value === '(' && clause[afterRef + 1]?.value === 'code' && clause[afterRef + 2]?.value === ')') { - const inlineConIdx = clause.findIndex((t) => t.value === 'constraint'); - const inlineConName = inlineConIdx !== -1 ? clause[inlineConIdx + 1]?.value : null; - const name = inlineConName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); - constraintNamespace.add(name); - activeFks.add(name); - continue; - } - } - } - } + if (handled) continue; if (explicitName !== null) { constraintNamespace.add(explicitName); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 2094a686..f29afc09 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -900,3 +900,78 @@ test('parseQualifiedTableTarget handles schema qualification and ONLY keyword', assert.deepEqual(ref3, { schema: 'variants', table: 'archive', nextIndex: 3 }); }); +test('inline column definition containing both CHECK and REFERENCES records both constraints', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CONSTRAINT allowed_check CHECK (variant IN ('standard', 'atomic')) REFERENCES variants(code) + );`, + '0002_drop_check.sql': `ALTER TABLE studies DROP CONSTRAINT allowed_check;`, + }); + try { + assert.equal(effectiveStudyVariantConstraint(dir), null); + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('column definition containing multiple inline CHECK constraints records all of them', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + CONSTRAINT check1 CHECK (variant IN ('standard', 'atomic')) + CONSTRAINT check2 CHECK (variant IN ('standard', 'atomic', 'chess960')) + );`, + }); + try { + const replayed = replayStudiesSchema(dir); + assert.equal(replayed.checks.length, 2); + assert.equal(replayed.checks[0].name, 'check1'); + assert.deepEqual(replayed.checks[0].variants, ['standard', 'atomic']); + assert.equal(replayed.checks[1].name, 'check2'); + assert.deepEqual(replayed.checks[1].variants, ['standard', 'atomic', 'chess960']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('renaming column variant preserves existing constraint names in namespace for new unnamed FKs', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_rename_col.sql': `ALTER TABLE studies RENAME COLUMN variant TO old_variant;`, + '0003_add_new_variant.sql': `ALTER TABLE studies ADD COLUMN variant TEXT NOT NULL REFERENCES variants(code);`, + '0004_drop_new_fk.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey1;`, + }); + try { + // The renamed column kept studies_variant_fkey, so the new FK was assigned studies_variant_fkey1. + // Dropping studies_variant_fkey1 clears the active FK on the new variant column. + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('dropping column variant releases dependent constraint names from namespace', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_drop_col.sql': `ALTER TABLE studies DROP COLUMN variant;`, + '0003_readd_col.sql': `ALTER TABLE studies ADD COLUMN variant TEXT NOT NULL REFERENCES variants(code);`, + '0004_drop_readded_fk.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_fkey;`, + }); + try { + // Dropping the variant column released studies_variant_fkey, so re-adding allows reusing studies_variant_fkey. + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 55920a69158d5f4de4c14033b4049b2626d63058 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 19:00:36 +0300 Subject: [PATCH 22/39] fix(scripts): support conditional creation and enforce compound check predicate closing --- scripts/check-variant-parity.mjs | 46 ++++++++++++++++++- scripts/test/check-variant-parity.test.mjs | 53 ++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 9f4dd4bc..989c4889 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -498,6 +498,13 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra } vIdx++; } + if (clause[vIdx + 1]?.value !== ')') { + throw new Error( + `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + + `rather than ignoring suffix expressions.`, + ); + } const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); constraintNamespace.add(name); variantConstraints.add(name); @@ -539,6 +546,8 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra * }} Effective check constraint on studies.variant and whether an active foreign key referencing variants(code) exists. */ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { + let hasStudiesTable = false; + let hasVariantColumn = false; const constraintNamespace = new Set(); const variantConstraints = new Set(); /** @type {Map} */ @@ -575,6 +584,8 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { idx = ref.nextIndex; if (isTableTarget(ref, 'studies')) { + hasStudiesTable = false; + hasVariantColumn = false; constraintNamespace.clear(); variantConstraints.clear(); activeChecks.clear(); @@ -615,6 +626,8 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Table rename: ALTER TABLE studies RENAME TO new_name if (stmt[idx]?.value === 'rename' && stmt[idx + 1]?.value === 'to') { + hasStudiesTable = false; + hasVariantColumn = false; constraintNamespace.clear(); variantConstraints.clear(); activeChecks.clear(); @@ -678,6 +691,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (action[cIdx]?.value === 'column') cIdx++; if (action[cIdx]?.value === 'if' && action[cIdx + 1]?.value === 'exists') cIdx += 2; if (action[cIdx]?.value === 'variant') { + hasVariantColumn = false; // Drops all constraints depending on variant for (const name of variantConstraints) { constraintNamespace.delete(name); @@ -694,6 +708,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { let cIdx = 1; if (action[cIdx]?.value === 'column') cIdx++; if (action[cIdx]?.value === 'variant' && action[cIdx + 1]?.value === 'to') { + hasVariantColumn = false; // variant column is renamed away; constraints remain on renamed column in namespace variantConstraints.clear(); activeChecks.clear(); @@ -702,14 +717,20 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { continue; } - // Action E: ADD [COLUMN] variant ... + // Action E: ADD [COLUMN] [IF NOT EXISTS] variant ... let colIdx = 0; if (action[colIdx]?.value === 'add') colIdx++; if (action[colIdx]?.value === 'column') colIdx++; + let isColIfNotExists = false; if (action[colIdx]?.value === 'if' && action[colIdx + 1]?.value === 'not' && action[colIdx + 2]?.value === 'exists') { + isColIfNotExists = true; colIdx += 3; } if (action[colIdx]?.value === 'variant') { + if (hasVariantColumn && isColIfNotExists) { + continue; + } + hasVariantColumn = true; scanColumnConstraints(action.slice(colIdx), file, constraintNamespace, variantConstraints, activeChecks, activeFks); continue; } @@ -743,6 +764,13 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } vIdx++; } + if (action[vIdx + 1]?.value !== ')') { + throw new Error( + `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + + `(\`${action.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + + `rather than ignoring suffix expressions.`, + ); + } constraintNamespace.add(assignedName); variantConstraints.add(assignedName); activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); @@ -791,7 +819,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // ----------------------------------------------------------------------- if (stmt[0].value === 'create' && stmt[1].value === 'table') { let idx = 2; + let isTableIfNotExists = false; if (stmt[idx]?.value === 'if' && stmt[idx + 1]?.value === 'not' && stmt[idx + 2]?.value === 'exists') { + isTableIfNotExists = true; idx += 3; } const ref = parseQualifiedTableTarget(stmt, idx); @@ -799,7 +829,13 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { continue; } + if (hasStudiesTable && isTableIfNotExists) { + continue; + } + // Fresh table creation clears prior state + hasStudiesTable = true; + hasVariantColumn = false; constraintNamespace.clear(); variantConstraints.clear(); activeChecks.clear(); @@ -816,6 +852,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Column-level: variant ... if (clause[0]?.value === 'variant') { + hasVariantColumn = true; scanColumnConstraints(clause, file, constraintNamespace, variantConstraints, activeChecks, activeFks); continue; } @@ -847,6 +884,13 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } vIdx++; } + if (clause[vIdx + 1]?.value !== ')') { + throw new Error( + `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + + `rather than ignoring suffix expressions.`, + ); + } constraintNamespace.add(assignedName); variantConstraints.add(assignedName); activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index f29afc09..2039f81c 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -974,4 +974,57 @@ test('dropping column variant releases dependent constraint names from namespace } }); +test('CREATE TABLE IF NOT EXISTS studies skips constraints when table already exists', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + title TEXT NOT NULL + );`, + '0002_conditional_recreate.sql': `CREATE TABLE IF NOT EXISTS studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + }); + try { + // The second CREATE TABLE IF NOT EXISTS is a no-op in PostgreSQL because studies already exists. + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ALTER TABLE ADD COLUMN IF NOT EXISTS variant skips constraints when variant already exists', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + );`, + '0002_conditional_add.sql': `ALTER TABLE studies ADD COLUMN IF NOT EXISTS variant TEXT NOT NULL REFERENCES variants(code);`, + }); + try { + // The conditional column add is a no-op in PostgreSQL because variant already exists. + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('compound CHECK predicate with suffix fails loudly rather than ignoring predicate', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'atomic') AND variant <> 'atomic') + );`, + }); + try { + assert.throws( + () => replayStudiesSchema(dir), + /defines a compound or non-standard CHECK predicate on `studies.variant`/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 01fba65cec8182bc48fb1865efaef8d396f5d8df Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 19:10:31 +0300 Subject: [PATCH 23/39] fix(scripts): skip ALTER TABLE IF EXISTS actions when studies table is absent --- scripts/check-variant-parity.mjs | 7 +++++++ scripts/test/check-variant-parity.test.mjs | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 989c4889..51e2e4ad 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -614,7 +614,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // ----------------------------------------------------------------------- if (stmt[0].value === 'alter' && stmt[1].value === 'table') { let idx = 2; + let isTableIfExists = false; if (stmt[idx]?.value === 'if' && stmt[idx + 1]?.value === 'exists') { + isTableIfExists = true; idx += 2; } @@ -622,6 +624,11 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (ref === null || !isTableTarget(ref, 'studies')) { continue; } + + if (!hasStudiesTable && isTableIfExists) { + continue; + } + hasStudiesTable = true; idx = ref.nextIndex; // Table rename: ALTER TABLE studies RENAME TO new_name diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 2039f81c..03d9346e 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1026,5 +1026,23 @@ test('compound CHECK predicate with suffix fails loudly rather than ignoring pre } }); +test('ALTER TABLE IF EXISTS studies skips actions when table does not exist', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + title TEXT NOT NULL + );`, + '0002_drop.sql': `DROP TABLE studies;`, + '0003_conditional_alter.sql': `ALTER TABLE IF EXISTS studies ADD COLUMN variant TEXT REFERENCES variants(code);`, + }); + try { + // ALTER TABLE IF EXISTS is a no-op in PostgreSQL because studies was dropped. + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 22a5b9dd233e238a023ab48be8bd49e3c2bfe42d Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 19:20:12 +0300 Subject: [PATCH 24/39] fix(scripts): fail loudly on unsupported check predicate shapes on studies.variant --- scripts/check-variant-parity.mjs | 187 +++++++++++++-------- scripts/test/check-variant-parity.test.mjs | 36 ++++ 2 files changed, 155 insertions(+), 68 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 51e2e4ad..c892b1ff 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -482,33 +482,50 @@ function splitAlterActions(tokens) { */ function scanColumnConstraints(clause, file, constraintNamespace, variantConstraints, activeChecks, activeFks) { for (let i = 0; i < clause.length; i++) { - // Check for inline CHECK (variant IN (...)) + // Check for inline CHECK (variant ...) if (clause[i].value === 'check' && clause[i + 1]?.value === '(') { - const pIdx = i + 2; - if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { - let inlineName = null; - if (i >= 2 && clause[i - 2]?.value === 'constraint') { - inlineName = clause[i - 1]?.value; - } - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < clause.length && clause[vIdx]?.value !== ')') { - if (clause[vIdx].type === 'string') { - variantTokens.push(clause[vIdx].value); + let depth = 1; + let endIdx = i + 2; + while (endIdx < clause.length && depth > 0) { + if (clause[endIdx].value === '(') depth++; + else if (clause[endIdx].value === ')') depth--; + endIdx++; + } + const checkTokens = clause.slice(i + 2, endIdx - 1); + const referencesVariant = checkTokens.some((t) => (t.type === 'word' || t.type === 'ident') && t.value === 'variant'); + if (referencesVariant) { + const pIdx = i + 2; + if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { + let inlineName = null; + if (i >= 2 && clause[i - 2]?.value === 'constraint') { + inlineName = clause[i - 1]?.value; } - vIdx++; - } - if (clause[vIdx + 1]?.value !== ')') { + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < clause.length && clause[vIdx]?.value !== ')') { + if (clause[vIdx].type === 'string') { + variantTokens.push(clause[vIdx].value); + } + vIdx++; + } + if (clause[vIdx + 1]?.value !== ')') { + throw new Error( + `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + + `rather than ignoring suffix expressions.`, + ); + } + const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + constraintNamespace.add(name); + variantConstraints.add(name); + activeChecks.set(name, { file, name, variants: variantTokens }); + } else { throw new Error( - `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + - `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + - `rather than ignoring suffix expressions.`, + `${file} defines an unsupported CHECK predicate shape on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard non-standard CHECK predicates ` + + `rather than ignoring the constraint.`, ); } - const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - constraintNamespace.add(name); - variantConstraints.add(name); - activeChecks.set(name, { file, name, variants: variantTokens }); } } @@ -753,35 +770,52 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { let handled = false; - // Search for table-level CHECK (variant IN (...)) + // Search for table-level CHECK (variant ...) for (let i = 0; i < action.length; i++) { if (action[i].value === 'check' && action[i + 1]?.value === '(') { - const pIdx = i + 2; - if (action[pIdx]?.value === 'variant' && action[pIdx + 1]?.value === 'in' && action[pIdx + 2]?.value === '(') { - let name = explicitName; - if (name === null && i >= 2 && action[i - 2]?.value === 'constraint') { - name = action[i - 1]?.value ?? null; - } - const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < action.length && action[vIdx]?.value !== ')') { - if (action[vIdx].type === 'string') { - variantTokens.push(action[vIdx].value); + let depth = 1; + let endIdx = i + 2; + while (endIdx < action.length && depth > 0) { + if (action[endIdx].value === '(') depth++; + else if (action[endIdx].value === ')') depth--; + endIdx++; + } + const checkTokens = action.slice(i + 2, endIdx - 1); + const referencesVariant = checkTokens.some((t) => (t.type === 'word' || t.type === 'ident') && t.value === 'variant'); + if (referencesVariant) { + const pIdx = i + 2; + if (action[pIdx]?.value === 'variant' && action[pIdx + 1]?.value === 'in' && action[pIdx + 2]?.value === '(') { + let name = explicitName; + if (name === null && i >= 2 && action[i - 2]?.value === 'constraint') { + name = action[i - 1]?.value ?? null; } - vIdx++; - } - if (action[vIdx + 1]?.value !== ')') { + const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < action.length && action[vIdx]?.value !== ')') { + if (action[vIdx].type === 'string') { + variantTokens.push(action[vIdx].value); + } + vIdx++; + } + if (action[vIdx + 1]?.value !== ')') { + throw new Error( + `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + + `(\`${action.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + + `rather than ignoring suffix expressions.`, + ); + } + constraintNamespace.add(assignedName); + variantConstraints.add(assignedName); + activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); + handled = true; + } else { throw new Error( - `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + - `(\`${action.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + - `rather than ignoring suffix expressions.`, + `${file} defines an unsupported CHECK predicate shape on \`studies.variant\` ` + + `(\`${action.map((t) => t.raw).join(' ')}\`). Teach this guard non-standard CHECK predicates ` + + `rather than ignoring the constraint.`, ); } - constraintNamespace.add(assignedName); - variantConstraints.add(assignedName); - activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); - handled = true; } } } @@ -873,35 +907,52 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { let handled = false; - // Table-level CHECK (variant IN (...)) + // Table-level CHECK (variant ...) for (let i = 0; i < clause.length; i++) { if (clause[i].value === 'check' && clause[i + 1]?.value === '(') { - const pIdx = i + 2; - if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { - let name = explicitName; - if (name === null && i >= 2 && clause[i - 2]?.value === 'constraint') { - name = clause[i - 1]?.value ?? null; - } - const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < clause.length && clause[vIdx]?.value !== ')') { - if (clause[vIdx].type === 'string') { - variantTokens.push(clause[vIdx].value); + let depth = 1; + let endIdx = i + 2; + while (endIdx < clause.length && depth > 0) { + if (clause[endIdx].value === '(') depth++; + else if (clause[endIdx].value === ')') depth--; + endIdx++; + } + const checkTokens = clause.slice(i + 2, endIdx - 1); + const referencesVariant = checkTokens.some((t) => (t.type === 'word' || t.type === 'ident') && t.value === 'variant'); + if (referencesVariant) { + const pIdx = i + 2; + if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { + let name = explicitName; + if (name === null && i >= 2 && clause[i - 2]?.value === 'constraint') { + name = clause[i - 1]?.value ?? null; } - vIdx++; - } - if (clause[vIdx + 1]?.value !== ')') { + const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); + const variantTokens = []; + let vIdx = pIdx + 3; + while (vIdx < clause.length && clause[vIdx]?.value !== ')') { + if (clause[vIdx].type === 'string') { + variantTokens.push(clause[vIdx].value); + } + vIdx++; + } + if (clause[vIdx + 1]?.value !== ')') { + throw new Error( + `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + + `rather than ignoring suffix expressions.`, + ); + } + constraintNamespace.add(assignedName); + variantConstraints.add(assignedName); + activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); + handled = true; + } else { throw new Error( - `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + - `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + - `rather than ignoring suffix expressions.`, + `${file} defines an unsupported CHECK predicate shape on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard non-standard CHECK predicates ` + + `rather than ignoring the constraint.`, ); } - constraintNamespace.add(assignedName); - variantConstraints.add(assignedName); - activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); - handled = true; } } } diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 03d9346e..7f2ad3df 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1043,6 +1043,42 @@ test('ALTER TABLE IF EXISTS studies skips actions when table does not exist', () } }); +test('unsupported inline CHECK predicate shape fails loudly rather than being ignored', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant = ANY (ARRAY['standard', 'atomic'])) + );`, + }); + try { + assert.throws( + () => replayStudiesSchema(dir), + /defines an unsupported CHECK predicate shape on `studies.variant`/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('unsupported table-level CHECK predicate shape fails loudly rather than being ignored', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL, + CONSTRAINT chk_custom CHECK (studies.variant IN ('standard', 'atomic')) + );`, + }); + try { + assert.throws( + () => replayStudiesSchema(dir), + /defines an unsupported CHECK predicate shape on `studies.variant`/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 9983b6ed0dec707027da7811fe34d6ec8e335973 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 19:30:02 +0300 Subject: [PATCH 25/39] fix(scripts): parse strict IN-list literals rejecting operators and expressions --- scripts/check-variant-parity.mjs | 113 +++++++++++++-------- scripts/test/check-variant-parity.test.mjs | 18 ++++ 2 files changed, 91 insertions(+), 40 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index c892b1ff..163d1874 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -210,17 +210,13 @@ export function tokenizeSql(sql) { continue; } - if (ch === ';' || ch === ',' || ch === '(' || ch === ')' || ch === '.' || ch === '*') { - tokens.push({ - type: 'punct', - value: ch, - raw: ch, - pos: i, - }); - i++; - continue; - } - + // Punctuation and operators (;, ,, (, ), ., *, |, =, <, >, :, +, -, etc.) + tokens.push({ + type: 'punct', + value: ch, + raw: ch, + pos: i, + }); i++; } return tokens; @@ -470,6 +466,43 @@ function splitAlterActions(tokens) { return actions; } +/** + * Parses a strict IN-list of string literals: `('literal1', 'literal2', ...)`. + * + * @param {SqlToken[]} tokens Array of tokens. + * @param {number} startIndex Index of first token inside the `IN (` list. + * @returns {{ variants: string[], nextIndex: number } | null} Parsed variants and next token index, or null if malformed. + */ +function parseStrictVariantInList(tokens, startIndex) { + let idx = startIndex; + const variants = []; + let expectLiteral = true; + + while (idx < tokens.length) { + const t = tokens[idx]; + if (expectLiteral) { + if (t.type === 'string') { + variants.push(t.value); + expectLiteral = false; + idx++; + } else { + return null; + } + } else { + if (t.type === 'punct' && t.value === ',') { + expectLiteral = true; + idx++; + } else if (t.type === 'punct' && t.value === ')') { + return { variants, nextIndex: idx + 1 }; + } else { + return null; + } + } + } + + return null; +} + /** * Scans a column definition clause for CHECK and REFERENCES constraints on variant. * @@ -500,15 +533,15 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra if (i >= 2 && clause[i - 2]?.value === 'constraint') { inlineName = clause[i - 1]?.value; } - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < clause.length && clause[vIdx]?.value !== ')') { - if (clause[vIdx].type === 'string') { - variantTokens.push(clause[vIdx].value); - } - vIdx++; + const parsedIn = parseStrictVariantInList(clause, pIdx + 3); + if (parsedIn === null) { + throw new Error( + `${file} defines an unsupported CHECK predicate shape on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard non-standard CHECK predicates ` + + `rather than ignoring the constraint.`, + ); } - if (clause[vIdx + 1]?.value !== ')') { + if (clause[parsedIn.nextIndex]?.value !== ')') { throw new Error( `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + @@ -518,7 +551,7 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra const name = inlineName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); constraintNamespace.add(name); variantConstraints.add(name); - activeChecks.set(name, { file, name, variants: variantTokens }); + activeChecks.set(name, { file, name, variants: parsedIn.variants }); } else { throw new Error( `${file} defines an unsupported CHECK predicate shape on \`studies.variant\` ` + @@ -789,25 +822,25 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (name === null && i >= 2 && action[i - 2]?.value === 'constraint') { name = action[i - 1]?.value ?? null; } - const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < action.length && action[vIdx]?.value !== ')') { - if (action[vIdx].type === 'string') { - variantTokens.push(action[vIdx].value); - } - vIdx++; + const parsedIn = parseStrictVariantInList(action, pIdx + 3); + if (parsedIn === null) { + throw new Error( + `${file} defines an unsupported CHECK predicate shape on \`studies.variant\` ` + + `(\`${action.map((t) => t.raw).join(' ')}\`). Teach this guard non-standard CHECK predicates ` + + `rather than ignoring the constraint.`, + ); } - if (action[vIdx + 1]?.value !== ')') { + if (action[parsedIn.nextIndex]?.value !== ')') { throw new Error( `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + `(\`${action.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + `rather than ignoring suffix expressions.`, ); } + const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); constraintNamespace.add(assignedName); variantConstraints.add(assignedName); - activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); + activeChecks.set(assignedName, { file, name: assignedName, variants: parsedIn.variants }); handled = true; } else { throw new Error( @@ -926,25 +959,25 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (name === null && i >= 2 && clause[i - 2]?.value === 'constraint') { name = clause[i - 1]?.value ?? null; } - const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - const variantTokens = []; - let vIdx = pIdx + 3; - while (vIdx < clause.length && clause[vIdx]?.value !== ')') { - if (clause[vIdx].type === 'string') { - variantTokens.push(clause[vIdx].value); - } - vIdx++; + const parsedIn = parseStrictVariantInList(clause, pIdx + 3); + if (parsedIn === null) { + throw new Error( + `${file} defines an unsupported CHECK predicate shape on \`studies.variant\` ` + + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard non-standard CHECK predicates ` + + `rather than ignoring the constraint.`, + ); } - if (clause[vIdx + 1]?.value !== ')') { + if (clause[parsedIn.nextIndex]?.value !== ')') { throw new Error( `${file} defines a compound or non-standard CHECK predicate on \`studies.variant\` ` + `(\`${clause.map((t) => t.raw).join(' ')}\`). Teach this guard compound CHECK predicates ` + `rather than ignoring suffix expressions.`, ); } + const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); constraintNamespace.add(assignedName); variantConstraints.add(assignedName); - activeChecks.set(assignedName, { file, name: assignedName, variants: variantTokens }); + activeChecks.set(assignedName, { file, name: assignedName, variants: parsedIn.variants }); handled = true; } else { throw new Error( diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 7f2ad3df..92fb2741 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1078,6 +1078,24 @@ test('unsupported table-level CHECK predicate shape fails loudly rather than bei } }); +test('operators or expressions inside IN-list fail loudly rather than extracting partial literals', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard' || 'chess960', 'atomic')) + );`, + }); + try { + assert.throws( + () => replayStudiesSchema(dir), + /defines an unsupported CHECK predicate shape on `studies.variant`/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 9af7cda6c2ae8bc22f60f8566ed450ab942e3288 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 19:40:10 +0300 Subject: [PATCH 26/39] fix(scripts): preserve constraint state across table rename round-trips --- scripts/check-variant-parity.mjs | 25 +++++++++++----- scripts/test/check-variant-parity.test.mjs | 35 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 163d1874..16873e9d 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -597,6 +597,7 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra */ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { let hasStudiesTable = false; + let currentStudiesTableName = 'studies'; let hasVariantColumn = false; const constraintNamespace = new Set(); const variantConstraints = new Set(); @@ -633,8 +634,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (ref === null) break; idx = ref.nextIndex; - if (isTableTarget(ref, 'studies')) { + if (isTableTarget(ref, currentStudiesTableName) || isTableTarget(ref, 'studies')) { hasStudiesTable = false; + currentStudiesTableName = 'studies'; hasVariantColumn = false; constraintNamespace.clear(); variantConstraints.clear(); @@ -671,7 +673,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } const ref = parseQualifiedTableTarget(stmt, idx); - if (ref === null || !isTableTarget(ref, 'studies')) { + if (ref === null || (!isTableTarget(ref, currentStudiesTableName) && !isTableTarget(ref, 'studies'))) { continue; } @@ -683,12 +685,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Table rename: ALTER TABLE studies RENAME TO new_name if (stmt[idx]?.value === 'rename' && stmt[idx + 1]?.value === 'to') { - hasStudiesTable = false; - hasVariantColumn = false; - constraintNamespace.clear(); - variantConstraints.clear(); - activeChecks.clear(); - activeFks.clear(); + const newName = stmt[idx + 2]?.value; + if (newName) { + currentStudiesTableName = newName; + } continue; } @@ -909,6 +909,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Fresh table creation clears prior state hasStudiesTable = true; + currentStudiesTableName = 'studies'; hasVariantColumn = false; constraintNamespace.clear(); variantConstraints.clear(); @@ -1025,6 +1026,14 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } } + if (currentStudiesTableName !== 'studies' || !hasStudiesTable) { + return { + check: null, + checks: [], + hasForeignKey: false, + }; + } + let latestCheck = null; for (const item of activeChecks.values()) { latestCheck = item; diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 92fb2741..400aa6fe 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1095,6 +1095,41 @@ test('operators or expressions inside IN-list fail loudly rather than extracting } }); +test('table rename round-trip preserves foreign key constraint when renamed back to studies', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_rename_away.sql': `ALTER TABLE studies RENAME TO studies_temp;`, + '0003_rename_back.sql': `ALTER TABLE studies_temp RENAME TO studies;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('table rename round-trip preserves CHECK constraint when renamed back to studies', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'atomic')) + );`, + '0002_rename_away.sql': `ALTER TABLE studies RENAME TO studies_temp;`, + '0003_rename_back.sql': `ALTER TABLE studies_temp RENAME TO studies;`, + }); + try { + const found = effectiveStudyVariantConstraint(dir); + assert.equal(found?.file, '0001_initial.sql'); + assert.deepEqual(found?.variants, ['standard', 'atomic']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 2ef10e72292af62b125b74fabac675456da3a98f Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 20:25:35 +0300 Subject: [PATCH 27/39] test(scripts): add column constraint permutation and lifecycle regressions --- scripts/test/check-variant-parity.test.mjs | 136 +++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 400aa6fe..6376d3b5 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1129,6 +1129,142 @@ test('table rename round-trip preserves CHECK constraint when renamed back to st } }); +test('inline column definition with REFERENCES and CHECK in reverse order records both', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL DEFAULT 'standard' + REFERENCES variants(code) + CHECK (variant IN ('standard', 'atomic')) + );`, + }); + try { + const replayed = replayStudiesSchema(dir); + assert.equal(replayed.checks.length, 1); + assert.equal(replayed.checks[0].name, 'studies_variant_check'); + assert.deepEqual(replayed.checks[0].variants, ['standard', 'atomic']); + assert.equal(replayed.hasForeignKey, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('inline column definition with explicit CONSTRAINT names on both CHECK and REFERENCES', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + CONSTRAINT chk_study_variant CHECK (variant IN ('standard', 'atomic')) + CONSTRAINT fk_study_variant REFERENCES variants(code) + );`, + }); + try { + const replayed = replayStudiesSchema(dir); + assert.equal(replayed.checks.length, 1); + assert.equal(replayed.checks[0].name, 'chk_study_variant'); + assert.deepEqual(replayed.checks[0].variants, ['standard', 'atomic']); + assert.equal(replayed.hasForeignKey, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('dropping only FK constraint leaves CHECK active when both defined on same column', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + CONSTRAINT chk_variant CHECK (variant IN ('standard', 'atomic')) + CONSTRAINT fk_variant REFERENCES variants(code) + );`, + '0002_drop_fk.sql': `ALTER TABLE studies DROP CONSTRAINT fk_variant;`, + }); + try { + const replayed = replayStudiesSchema(dir); + assert.notEqual(replayed.check, null); + assert.equal(replayed.check?.name, 'chk_variant'); + assert.deepEqual(replayed.check?.variants, ['standard', 'atomic']); + assert.equal(replayed.hasForeignKey, false); + assert.equal(effectiveStudyVariantForeignKey(dir), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation succeeds when surviving FK provides integrity after CHECK drop', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + CONSTRAINT chk_v CHECK (variant IN ('standard', 'atomic')) + CONSTRAINT fk_v REFERENCES variants(code) + );`, + '0003_drop_chk.sql': `ALTER TABLE studies DROP CONSTRAINT chk_v;`, + }); + try { + const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(dir); + assert.equal(studyConstraint, null); + assert.equal(hasStudyVariantFk, true); + const lookupMirror = mirrors.find((m) => m.label.includes('variants')); + assert.notEqual(lookupMirror, undefined); + assert.deepEqual(disagreements(extractRegion(ROOT).variants, lookupMirror.variants), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation succeeds when surviving CHECK provides integrity after FK drop', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + CONSTRAINT chk_v CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) + CONSTRAINT fk_v REFERENCES variants(code) + );`, + '0003_drop_fk.sql': `ALTER TABLE studies DROP CONSTRAINT fk_v;`, + }); + try { + const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(dir); + assert.notEqual(studyConstraint, null); + assert.equal(hasStudyVariantFk, false); + const studyMirror = mirrors.find((m) => m.label.includes('studies.variant') && m.label.includes('CHECK')); + assert.notEqual(studyMirror, undefined); + assert.deepEqual(disagreements(extractRegion(ROOT).variants, studyMirror?.variants ?? []), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation flags failure when both CHECK and FK are removed from studies.variant', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES ('standard');`, + '0002_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + CONSTRAINT chk_v CHECK (variant IN ('standard')) + CONSTRAINT fk_v REFERENCES variants(code) + );`, + '0003_drop_both.sql': `ALTER TABLE studies DROP CONSTRAINT chk_v, DROP CONSTRAINT fk_v;`, + }); + try { + const { studyConstraint, hasStudyVariantFk } = collectMirrors(dir); + assert.equal(studyConstraint, null); + assert.equal(hasStudyVariantFk, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 3fdb2783dc3cbab59bbd98f691630f183f8dd79e Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 20:39:02 +0300 Subject: [PATCH 28/39] fix(scripts): isolate table schema state and preserve quoted identifier case --- scripts/check-variant-parity.mjs | 233 ++++++++++++--------- scripts/test/check-variant-parity.test.mjs | 49 ++++- 2 files changed, 182 insertions(+), 100 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 16873e9d..02fe1ab6 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -188,7 +188,7 @@ export function tokenizeSql(sql) { } tokens.push({ type: 'ident', - value: val.toLowerCase(), + value: val, raw: sql.slice(start, i), pos: start, }); @@ -596,15 +596,31 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra * }} Effective check constraint on studies.variant and whether an active foreign key referencing variants(code) exists. */ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { - let hasStudiesTable = false; - let currentStudiesTableName = 'studies'; - let hasVariantColumn = false; - const constraintNamespace = new Set(); - const variantConstraints = new Set(); - /** @type {Map} */ - const activeChecks = new Map(); - /** @type {Set} */ - const activeFks = new Set(); + /** + * @type {Map, + * variantConstraints: Set, + * activeChecks: Map, + * activeFks: Set + * }>} + */ + const tables = new Map(); + + function getOrCreateTable(tableName) { + let t = tables.get(tableName); + if (!t) { + t = { + hasVariantColumn: false, + constraintNamespace: new Set(), + variantConstraints: new Set(), + activeChecks: new Map(), + activeFks: new Set(), + }; + tables.set(tableName, t); + } + return t; + } for (const file of migrationFiles(dir)) { const rawSql = readFileSync(join(dir, file), 'utf8'); @@ -634,24 +650,18 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (ref === null) break; idx = ref.nextIndex; - if (isTableTarget(ref, currentStudiesTableName) || isTableTarget(ref, 'studies')) { - hasStudiesTable = false; - currentStudiesTableName = 'studies'; - hasVariantColumn = false; - constraintNamespace.clear(); - variantConstraints.clear(); - activeChecks.clear(); - activeFks.clear(); - } - if (isTableTarget(ref, 'variants') && hasCascade) { - for (const fkName of activeFks) { - constraintNamespace.delete(fkName); - variantConstraints.delete(fkName); + for (const tbl of tables.values()) { + for (const fkName of tbl.activeFks) { + tbl.constraintNamespace.delete(fkName); + tbl.variantConstraints.delete(fkName); + } + tbl.activeFks.clear(); } - activeFks.clear(); } + tables.delete(ref.table); + if (stmt[idx]?.type === 'punct' && stmt[idx].value === ',') { idx++; } else { @@ -673,21 +683,23 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } const ref = parseQualifiedTableTarget(stmt, idx); - if (ref === null || (!isTableTarget(ref, currentStudiesTableName) && !isTableTarget(ref, 'studies'))) { + if (ref === null || (!isTableTarget(ref, 'studies') && !tables.has(ref.table))) { continue; } - if (!hasStudiesTable && isTableIfExists) { + if (isTableIfExists && !tables.has(ref.table)) { continue; } - hasStudiesTable = true; + + const currentTable = getOrCreateTable(ref.table); idx = ref.nextIndex; - // Table rename: ALTER TABLE studies RENAME TO new_name + // Table rename: ALTER TABLE target RENAME TO new_name if (stmt[idx]?.value === 'rename' && stmt[idx + 1]?.value === 'to') { const newName = stmt[idx + 2]?.value; if (newName) { - currentStudiesTableName = newName; + tables.delete(ref.table); + tables.set(newName, currentTable); } continue; } @@ -706,10 +718,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } if (action[cIdx]?.type === 'word' || action[cIdx]?.type === 'ident') { const name = action[cIdx].value; - constraintNamespace.delete(name); - variantConstraints.delete(name); - activeChecks.delete(name); - activeFks.delete(name); + currentTable.constraintNamespace.delete(name); + currentTable.variantConstraints.delete(name); + currentTable.activeChecks.delete(name); + currentTable.activeFks.delete(name); } continue; } @@ -717,7 +729,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Action B: RENAME CONSTRAINT TO if (action[0].value === 'rename' && action[1]?.value === 'constraint') { const oldName = action[2]?.value; - if (oldName && activeChecks.has(oldName)) { + if (oldName && currentTable.activeChecks.has(oldName)) { throw new Error( `${file} renames the constraint governing \`studies.variant\` ` + `(\`${oldName}\`). Teach this guard \`RENAME CONSTRAINT\` rather than leaving it ` + @@ -726,17 +738,17 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } if (oldName && action[3]?.value === 'to' && action[4]) { const newName = action[4].value; - if (constraintNamespace.has(oldName)) { - constraintNamespace.delete(oldName); - constraintNamespace.add(newName); + if (currentTable.constraintNamespace.has(oldName)) { + currentTable.constraintNamespace.delete(oldName); + currentTable.constraintNamespace.add(newName); } - if (variantConstraints.has(oldName)) { - variantConstraints.delete(oldName); - variantConstraints.add(newName); + if (currentTable.variantConstraints.has(oldName)) { + currentTable.variantConstraints.delete(oldName); + currentTable.variantConstraints.add(newName); } - if (activeFks.has(oldName)) { - activeFks.delete(oldName); - activeFks.add(newName); + if (currentTable.activeFks.has(oldName)) { + currentTable.activeFks.delete(oldName); + currentTable.activeFks.add(newName); } } continue; @@ -748,14 +760,13 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (action[cIdx]?.value === 'column') cIdx++; if (action[cIdx]?.value === 'if' && action[cIdx + 1]?.value === 'exists') cIdx += 2; if (action[cIdx]?.value === 'variant') { - hasVariantColumn = false; - // Drops all constraints depending on variant - for (const name of variantConstraints) { - constraintNamespace.delete(name); + currentTable.hasVariantColumn = false; + for (const name of currentTable.variantConstraints) { + currentTable.constraintNamespace.delete(name); } - variantConstraints.clear(); - activeChecks.clear(); - activeFks.clear(); + currentTable.variantConstraints.clear(); + currentTable.activeChecks.clear(); + currentTable.activeFks.clear(); } continue; } @@ -765,11 +776,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { let cIdx = 1; if (action[cIdx]?.value === 'column') cIdx++; if (action[cIdx]?.value === 'variant' && action[cIdx + 1]?.value === 'to') { - hasVariantColumn = false; - // variant column is renamed away; constraints remain on renamed column in namespace - variantConstraints.clear(); - activeChecks.clear(); - activeFks.clear(); + currentTable.hasVariantColumn = false; + currentTable.variantConstraints.clear(); + currentTable.activeChecks.clear(); + currentTable.activeFks.clear(); } continue; } @@ -784,11 +794,11 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { colIdx += 3; } if (action[colIdx]?.value === 'variant') { - if (hasVariantColumn && isColIfNotExists) { + if (currentTable.hasVariantColumn && isColIfNotExists) { continue; } - hasVariantColumn = true; - scanColumnConstraints(action.slice(colIdx), file, constraintNamespace, variantConstraints, activeChecks, activeFks); + currentTable.hasVariantColumn = true; + scanColumnConstraints(action.slice(colIdx), file, currentTable.constraintNamespace, currentTable.variantConstraints, currentTable.activeChecks, currentTable.activeFks); continue; } @@ -837,10 +847,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { `rather than ignoring suffix expressions.`, ); } - const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - constraintNamespace.add(assignedName); - variantConstraints.add(assignedName); - activeChecks.set(assignedName, { file, name: assignedName, variants: parsedIn.variants }); + const assignedName = name ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'studies_variant_check'); + currentTable.constraintNamespace.add(assignedName); + currentTable.variantConstraints.add(assignedName); + currentTable.activeChecks.set(assignedName, { file, name: assignedName, variants: parsedIn.variants }); handled = true; } else { throw new Error( @@ -868,10 +878,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (refTarget && isTableTarget(refTarget, 'variants')) { const afterRef = refTarget.nextIndex; if (action[afterRef]?.value === '(' && action[afterRef + 1]?.value === 'code' && action[afterRef + 2]?.value === ')') { - const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); - constraintNamespace.add(name); - variantConstraints.add(name); - activeFks.add(name); + const name = explicitName ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'studies_variant_fkey'); + currentTable.constraintNamespace.add(name); + currentTable.variantConstraints.add(name); + currentTable.activeFks.add(name); handled = true; } } @@ -882,14 +892,14 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Register any other explicitly named constraint if (explicitName !== null) { - constraintNamespace.add(explicitName); + currentTable.constraintNamespace.add(explicitName); } } continue; } // ----------------------------------------------------------------------- - // 3. CREATE TABLE [IF NOT EXISTS] studies (...) + // 3. CREATE TABLE [IF NOT EXISTS] target (...) // ----------------------------------------------------------------------- if (stmt[0].value === 'create' && stmt[1].value === 'table') { let idx = 2; @@ -899,22 +909,22 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { idx += 3; } const ref = parseQualifiedTableTarget(stmt, idx); - if (ref === null || !isTableTarget(ref, 'studies')) { + if (ref === null || (!isTableTarget(ref, 'studies') && !tables.has(ref.table))) { continue; } - if (hasStudiesTable && isTableIfNotExists) { + if (tables.has(ref.table) && isTableIfNotExists) { continue; } - // Fresh table creation clears prior state - hasStudiesTable = true; - currentStudiesTableName = 'studies'; - hasVariantColumn = false; - constraintNamespace.clear(); - variantConstraints.clear(); - activeChecks.clear(); - activeFks.clear(); + const currentTable = { + hasVariantColumn: false, + constraintNamespace: new Set(), + variantConstraints: new Set(), + activeChecks: new Map(), + activeFks: new Set(), + }; + tables.set(ref.table, currentTable); const openParen = stmt.findIndex((t) => t.type === 'punct' && t.value === '('); if (openParen === -1) continue; @@ -927,8 +937,8 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { // Column-level: variant ... if (clause[0]?.value === 'variant') { - hasVariantColumn = true; - scanColumnConstraints(clause, file, constraintNamespace, variantConstraints, activeChecks, activeFks); + currentTable.hasVariantColumn = true; + scanColumnConstraints(clause, file, currentTable.constraintNamespace, currentTable.variantConstraints, currentTable.activeChecks, currentTable.activeFks); continue; } @@ -975,10 +985,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { `rather than ignoring suffix expressions.`, ); } - const assignedName = name ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_check'); - constraintNamespace.add(assignedName); - variantConstraints.add(assignedName); - activeChecks.set(assignedName, { file, name: assignedName, variants: parsedIn.variants }); + const assignedName = name ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'studies_variant_check'); + currentTable.constraintNamespace.add(assignedName); + currentTable.variantConstraints.add(assignedName); + currentTable.activeChecks.set(assignedName, { file, name: assignedName, variants: parsedIn.variants }); handled = true; } else { throw new Error( @@ -1006,10 +1016,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (refTarget && isTableTarget(refTarget, 'variants')) { const afterRef = refTarget.nextIndex; if (clause[afterRef]?.value === '(' && clause[afterRef + 1]?.value === 'code' && clause[afterRef + 2]?.value === ')') { - const name = explicitName ?? nextImplicitConstraintName(constraintNamespace, 'studies_variant_fkey'); - constraintNamespace.add(name); - variantConstraints.add(name); - activeFks.add(name); + const name = explicitName ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'studies_variant_fkey'); + currentTable.constraintNamespace.add(name); + currentTable.variantConstraints.add(name); + currentTable.activeFks.add(name); handled = true; } } @@ -1019,14 +1029,15 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (handled) continue; if (explicitName !== null) { - constraintNamespace.add(explicitName); + currentTable.constraintNamespace.add(explicitName); } } } } } - if (currentStudiesTableName !== 'studies' || !hasStudiesTable) { + const studiesTable = tables.get('studies'); + if (!studiesTable) { return { check: null, checks: [], @@ -1035,14 +1046,14 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } let latestCheck = null; - for (const item of activeChecks.values()) { + for (const item of studiesTable.activeChecks.values()) { latestCheck = item; } return { check: latestCheck, - checks: Array.from(activeChecks.values()), - hasForeignKey: activeFks.size > 0, + checks: Array.from(studiesTable.activeChecks.values()), + hasForeignKey: studiesTable.activeFks.size > 0, }; } @@ -1137,11 +1148,40 @@ export function collectMirrors(dir = MIGRATIONS_DIR) { return { mirrors, studyConstraint: replayed.check, hasStudyVariantFk: replayed.hasForeignKey }; } -function main() { +/** + * Evaluates variant parity across TypeScript mirrors and SQL migrations. + * + * @param {string} [dir=MIGRATIONS_DIR] Path to the migrations directory. + * @returns {{ + * failures: string[], + * mirrors: Array<{ label: string, file: string, variants: string[] }>, + * studyConstraint: { file: string, name: string, variants: string[] } | null, + * hasStudyVariantFk: boolean + * }} Parity evaluation results and any failure descriptions. + */ +export function evaluateParity(dir = MIGRATIONS_DIR) { const root = extractRegion(ROOT); - const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(); + const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(dir); const failures = []; + for (const mirror of mirrors) { + const problems = disagreements(root.variants, mirror.variants); + if (problems.length > 0) { + failures.push(`${mirror.label} (${mirror.file}): ${problems.join('; ')}`); + } + } + + if (studyConstraint === null && !hasStudyVariantFk) { + failures.push('`studies.variant` has no CHECK constraint and no foreign key referencing `variants(code)`'); + } + + return { failures, mirrors, studyConstraint, hasStudyVariantFk }; +} + +function main() { + const root = extractRegion(ROOT); + const { failures, mirrors, studyConstraint, hasStudyVariantFk } = evaluateParity(); + console.log(`root: ${root.label} (${root.file})`); console.log(` ${root.variants.join(', ')}\n`); @@ -1151,7 +1191,6 @@ function main() { console.log(` ok ${mirror.label}`); } else { console.log(` FAIL ${mirror.label} (${mirror.file}): ${problems.join('; ')}`); - failures.push(mirror.label); } } @@ -1164,7 +1203,6 @@ function main() { console.log( ' FAIL `studies.variant` has no CHECK constraint and no foreign key referencing `variants(code)`', ); - failures.push('`studies.variant` missing foreign key'); } } @@ -1192,3 +1230,4 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.a process.exit(1); } } + diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 6376d3b5..ab214ae9 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -25,6 +25,7 @@ import { effectiveStudyVariantConstraint, effectiveStudyVariantForeignKey, collectMirrors, + evaluateParity, disagreements, ROOT, TS_MIRRORS, @@ -1246,24 +1247,66 @@ INSERT INTO variants (code) VALUES test('parity evaluation flags failure when both CHECK and FK are removed from studies.variant', () => { const dir = migrations({ '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); -INSERT INTO variants (code) VALUES ('standard');`, +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, '0002_studies.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL - CONSTRAINT chk_v CHECK (variant IN ('standard')) + CONSTRAINT chk_v CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) CONSTRAINT fk_v REFERENCES variants(code) );`, '0003_drop_both.sql': `ALTER TABLE studies DROP CONSTRAINT chk_v, DROP CONSTRAINT fk_v;`, }); try { - const { studyConstraint, hasStudyVariantFk } = collectMirrors(dir); + const { failures, studyConstraint, hasStudyVariantFk } = evaluateParity(dir); assert.equal(studyConstraint, null); assert.equal(hasStudyVariantFk, false); + assert.equal(failures.length, 1); + assert.match(failures[0], /`studies\.variant` has no CHECK constraint and no foreign key referencing `variants\(code\)`/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('quoted identifier case is preserved so renaming to "Studies" leaves effectiveStudyVariantForeignKey false', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_rename_case.sql': `ALTER TABLE studies RENAME TO "Studies";`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), false); + assert.equal(effectiveStudyVariantConstraint(dir), null); } finally { rmSync(dir, { recursive: true, force: true }); } }); +test('shadow table recreation and drop preserves original renamed table constraints on rename back', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_rename_away.sql': `ALTER TABLE studies RENAME TO studies_backup;`, + '0003_create_shadow.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + note TEXT + );`, + '0004_drop_shadow.sql': `DROP TABLE studies;`, + '0005_rename_back.sql': `ALTER TABLE studies_backup RENAME TO studies;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 88e73199fc8f47fdb36777fc302358f0e0ee7d3c Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 20:50:01 +0300 Subject: [PATCH 29/39] fix(scripts): qualify table state keys by schema to isolate public.studies --- scripts/check-variant-parity.mjs | 48 +++++++++++++++------- scripts/test/check-variant-parity.test.mjs | 20 +++++++++ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 02fe1ab6..7e836fc2 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -607,8 +607,13 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { */ const tables = new Map(); - function getOrCreateTable(tableName) { - let t = tables.get(tableName); + function tableKey(ref) { + const schema = ref.schema || 'public'; + return `${schema}.${ref.table}`; + } + + function getOrCreateTable(key) { + let t = tables.get(key); if (!t) { t = { hasVariantColumn: false, @@ -617,7 +622,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { activeChecks: new Map(), activeFks: new Set(), }; - tables.set(tableName, t); + tables.set(key, t); } return t; } @@ -650,7 +655,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (ref === null) break; idx = ref.nextIndex; - if (isTableTarget(ref, 'variants') && hasCascade) { + const key = tableKey(ref); + + if (key === 'public.variants' && hasCascade) { for (const tbl of tables.values()) { for (const fkName of tbl.activeFks) { tbl.constraintNamespace.delete(fkName); @@ -660,7 +667,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } } - tables.delete(ref.table); + if (key === 'public.studies' || tables.has(key)) { + tables.delete(key); + } if (stmt[idx]?.type === 'punct' && stmt[idx].value === ',') { idx++; @@ -683,23 +692,29 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } const ref = parseQualifiedTableTarget(stmt, idx); - if (ref === null || (!isTableTarget(ref, 'studies') && !tables.has(ref.table))) { + if (ref === null) { + continue; + } + + const key = tableKey(ref); + if (key !== 'public.studies' && !tables.has(key)) { continue; } - if (isTableIfExists && !tables.has(ref.table)) { + if (isTableIfExists && !tables.has(key)) { continue; } - const currentTable = getOrCreateTable(ref.table); + const currentTable = getOrCreateTable(key); idx = ref.nextIndex; // Table rename: ALTER TABLE target RENAME TO new_name if (stmt[idx]?.value === 'rename' && stmt[idx + 1]?.value === 'to') { const newName = stmt[idx + 2]?.value; if (newName) { - tables.delete(ref.table); - tables.set(newName, currentTable); + const newKey = `${ref.schema || 'public'}.${newName}`; + tables.delete(key); + tables.set(newKey, currentTable); } continue; } @@ -909,11 +924,16 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { idx += 3; } const ref = parseQualifiedTableTarget(stmt, idx); - if (ref === null || (!isTableTarget(ref, 'studies') && !tables.has(ref.table))) { + if (ref === null) { + continue; + } + + const key = tableKey(ref); + if (key !== 'public.studies' && !tables.has(key)) { continue; } - if (tables.has(ref.table) && isTableIfNotExists) { + if (tables.has(key) && isTableIfNotExists) { continue; } @@ -924,7 +944,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { activeChecks: new Map(), activeFks: new Set(), }; - tables.set(ref.table, currentTable); + tables.set(key, currentTable); const openParen = stmt.findIndex((t) => t.type === 'punct' && t.value === '('); if (openParen === -1) continue; @@ -1036,7 +1056,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } } - const studiesTable = tables.get('studies'); + const studiesTable = tables.get('public.studies'); if (!studiesTable) { return { check: null, diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index ab214ae9..d6509037 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1306,6 +1306,26 @@ test('shadow table recreation and drop preserves original renamed table constrai } }); +test('creating or altering table in another schema does not overwrite public.studies constraints', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_archive_schema.sql': `CREATE TABLE archive.studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'atomic')) + );`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + assert.equal(effectiveStudyVariantConstraint(dir), null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 5d53e299abe55419dea9a8069b942e48d7bc3e4a Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 21:00:03 +0300 Subject: [PATCH 30/39] fix(scripts): support ALTER TABLE SET SCHEMA during schema replay --- scripts/check-variant-parity.mjs | 11 ++++++++ scripts/test/check-variant-parity.test.mjs | 33 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 7e836fc2..ab88b18d 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -719,6 +719,17 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { continue; } + // Table schema move: ALTER TABLE target SET SCHEMA new_schema + if (stmt[idx]?.value === 'set' && stmt[idx + 1]?.value === 'schema') { + const newSchema = stmt[idx + 2]?.value; + if (newSchema) { + const newKey = `${newSchema}.${ref.table}`; + tables.delete(key); + tables.set(newKey, currentTable); + } + continue; + } + const actionTokens = stmt.slice(idx); const actionClauses = splitAlterActions(actionTokens); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index d6509037..f3675519 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1325,6 +1325,39 @@ test('creating or altering table in another schema does not overwrite public.stu } }); +test('ALTER TABLE SET SCHEMA moving studies out of public leaves effectiveStudyVariantForeignKey false', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_move_schema.sql': `ALTER TABLE studies SET SCHEMA archive;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), false); + assert.equal(effectiveStudyVariantConstraint(dir), null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ALTER TABLE SET SCHEMA moving table into public restores effectiveStudyVariantForeignKey true', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_move_to_archive.sql': `ALTER TABLE studies SET SCHEMA archive;`, + '0003_move_back_to_public.sql': `ALTER TABLE archive.studies SET SCHEMA public;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 6aeeb97f90d0e1b13d070d80e1b2000f5bdc2e0d Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Mon, 31 Aug 2026 21:57:18 +0300 Subject: [PATCH 31/39] fix(scripts): support dollar-quoted SQL bodies and structured table identity in parity guard --- scripts/check-variant-parity.mjs | 73 ++++++++--- scripts/test/check-variant-parity.test.mjs | 136 +++++++++++++++++++++ 2 files changed, 195 insertions(+), 14 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index ab88b18d..6690139a 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -88,6 +88,20 @@ export function stripComments(text, dialect) { continue; } + if (dialect === 'sql' && ch === '$') { + const match = /^\$([a-zA-Z0-9_]*)\$/.exec(text.slice(i)); + if (match !== null) { + const tag = match[0]; + const endStr = text.indexOf(tag, i + tag.length); + if (endStr !== -1) { + const fullDollar = text.slice(i, endStr + tag.length); + out += fullDollar; + i += fullDollar.length; + continue; + } + } + } + if (ch === lineMarker[0] && next === lineMarker[1]) { while (i < text.length && text[i] !== '\n') { out += ' '; @@ -143,6 +157,28 @@ export function tokenizeSql(sql) { continue; } + if (ch === '$') { + const match = /^\$([a-zA-Z0-9_]*)\$/.exec(sql.slice(i)); + if (match !== null) { + const tag = match[0]; + const start = i; + const endStr = sql.indexOf(tag, start + tag.length); + if (endStr === -1) { + throw new Error(`unterminated dollar-quoted string at position ${start}`); + } + const raw = sql.slice(start, endStr + tag.length); + const body = sql.slice(start + tag.length, endStr); + tokens.push({ + type: 'string', + value: body, + raw: raw, + pos: start, + }); + i = endStr + tag.length; + continue; + } + } + if (ch === "'") { const start = i; let val = ''; @@ -279,17 +315,31 @@ export function parseQualifiedTableTarget(tokens, startIndex) { return { schema: 'public', table: firstIdent, nextIndex: idx }; } +/** + * Computes a collision-free structured tuple key for a table reference. + * + * @param {{ schema?: string, table: string } | null} ref Parsed table reference. + * @returns {string} Structured key encoding [schema, table]. + */ +export function tableKey(ref) { + if (ref === null) return ''; + return JSON.stringify([ref.schema || 'public', ref.table]); +} + +export const STUDIES_TABLE_KEY = tableKey({ schema: 'public', table: 'studies' }); +export const VARIANTS_TABLE_KEY = tableKey({ schema: 'public', table: 'variants' }); + /** * Determines if a parsed table reference matches a specified table and default schema. * - * @param {{ schema: string, table: string } | null} ref Parsed table reference. + * @param {{ schema?: string, table: string } | null} ref Parsed table reference. * @param {string} targetTable Expected table name. * @param {string} [targetSchema='public'] Expected schema name (defaults to 'public'). * @returns {boolean} True if the table reference matches the target. */ function isTableTarget(ref, targetTable, targetSchema = 'public') { if (ref === null) return false; - return ref.table === targetTable && (!ref.schema || ref.schema === targetSchema); + return tableKey(ref) === tableKey({ schema: targetSchema, table: targetTable }); } /** @@ -607,11 +657,6 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { */ const tables = new Map(); - function tableKey(ref) { - const schema = ref.schema || 'public'; - return `${schema}.${ref.table}`; - } - function getOrCreateTable(key) { let t = tables.get(key); if (!t) { @@ -657,7 +702,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { const key = tableKey(ref); - if (key === 'public.variants' && hasCascade) { + if (key === VARIANTS_TABLE_KEY && hasCascade) { for (const tbl of tables.values()) { for (const fkName of tbl.activeFks) { tbl.constraintNamespace.delete(fkName); @@ -667,7 +712,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } } - if (key === 'public.studies' || tables.has(key)) { + if (key === STUDIES_TABLE_KEY || tables.has(key)) { tables.delete(key); } @@ -697,7 +742,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } const key = tableKey(ref); - if (key !== 'public.studies' && !tables.has(key)) { + if (key !== STUDIES_TABLE_KEY && !tables.has(key)) { continue; } @@ -712,7 +757,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (stmt[idx]?.value === 'rename' && stmt[idx + 1]?.value === 'to') { const newName = stmt[idx + 2]?.value; if (newName) { - const newKey = `${ref.schema || 'public'}.${newName}`; + const newKey = tableKey({ schema: ref.schema || 'public', table: newName }); tables.delete(key); tables.set(newKey, currentTable); } @@ -723,7 +768,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { if (stmt[idx]?.value === 'set' && stmt[idx + 1]?.value === 'schema') { const newSchema = stmt[idx + 2]?.value; if (newSchema) { - const newKey = `${newSchema}.${ref.table}`; + const newKey = tableKey({ schema: newSchema, table: ref.table }); tables.delete(key); tables.set(newKey, currentTable); } @@ -940,7 +985,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } const key = tableKey(ref); - if (key !== 'public.studies' && !tables.has(key)) { + if (key !== STUDIES_TABLE_KEY && !tables.has(key)) { continue; } @@ -1067,7 +1112,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } } - const studiesTable = tables.get('public.studies'); + const studiesTable = tables.get(STUDIES_TABLE_KEY); if (!studiesTable) { return { check: null, diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index f3675519..648b9fc2 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -26,6 +26,9 @@ import { effectiveStudyVariantForeignKey, collectMirrors, evaluateParity, + tableKey, + STUDIES_TABLE_KEY, + VARIANTS_TABLE_KEY, disagreements, ROOT, TS_MIRRORS, @@ -1357,6 +1360,139 @@ test('ALTER TABLE SET SCHEMA moving table into public restores effectiveStudyVar } }); +test('tokenizeSql and splitSqlStatements treat untagged dollar-quoted body with semicolons as one statement', () => { + const sql = `DO $$ + BEGIN + PERFORM 1; + PERFORM 2; + END + $$;`; + const tokens = tokenizeSql(sql); + const statements = splitSqlStatements(tokens); + assert.equal(statements.length, 1); + assert.equal(tokens[1].type, 'string'); +}); + +test('tokenizeSql and splitSqlStatements treat tagged dollar-quoted body with semicolons as one statement', () => { + const sql = `DO $migration$ + BEGIN + PERFORM 1; + PERFORM 2; + END + $migration$;`; + const tokens = tokenizeSql(sql); + const statements = splitSqlStatements(tokens); + assert.equal(statements.length, 1); + assert.equal(tokens[1].type, 'string'); +}); + +test('fake DDL inside dollar-quoted body does not alter replay state', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_fake_ddl.sql': `DO $$ + BEGIN + DROP TABLE studies; + ALTER TABLE studies ADD COLUMN variant TEXT; + END + $$;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('fake DROP TABLE variants CASCADE inside dollar-quoted body does not clear active studies FK', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_fake_cascade.sql': `DO $fn$ + BEGIN + DROP TABLE variants CASCADE; + END + $fn$;`, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('dollar-quoted string requires exact matching tag and does not stop on mismatched inner tag', () => { + const sql = `DO $outer$ body with $inner$ inner text $inner$ more body $outer$;`; + const tokens = tokenizeSql(sql); + assert.equal(tokens.length, 3); // DO, string, ; + assert.equal(tokens[1].type, 'string'); + assert.equal(tokens[1].value, ' body with $inner$ inner text $inner$ more body '); +}); + +test('unterminated dollar-quoted string throws explicit error', () => { + assert.throws(() => tokenizeSql('DO $$ BEGIN PERFORM 1;'), /unterminated dollar-quoted string/); + assert.throws(() => tokenizeSql('DO $tag$ BEGIN PERFORM 1; $different$'), /unterminated dollar-quoted string/); +}); + +test('ordinary single-quoted strings and positional parameters are not confused with dollar quotes', () => { + const sql = `SELECT 'hello $world$', $1, $2 FROM t;`; + const tokens = tokenizeSql(sql); + assert.equal(tokens[0].value, 'select'); + assert.equal(tokens[1].type, 'string'); + assert.equal(tokens[1].value, 'hello $world$'); + assert.equal(tokens[3].type, 'punct'); + assert.equal(tokens[3].value, '$'); + assert.equal(tokens[4].type, 'punct'); + assert.equal(tokens[4].value, '1'); +}); + +test('structured tableKey distinguishes quoted identifiers containing periods', () => { + const key1 = tableKey({ schema: 'archive.x', table: 'studies' }); + const key2 = tableKey({ schema: 'archive', table: 'x.studies' }); + assert.notEqual(key1, key2); + assert.equal(key1, '["archive.x","studies"]'); + assert.equal(key2, '["archive","x.studies"]'); +}); + +test('structured tableKey distinguishes public.studies from a table named "public.studies" in public schema', () => { + const canonical = tableKey({ schema: 'public', table: 'studies' }); + const literalDotted = tableKey({ schema: 'public', table: 'public.studies' }); + assert.notEqual(canonical, literalDotted); + assert.equal(canonical, STUDIES_TABLE_KEY); + assert.equal(literalDotted, '["public","public.studies"]'); +}); + +test('end-to-end parity failure when colliding-under-old-model table has variant constraints but public.studies is unconstrained', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_unconstrained_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL + );`, + '0003_colliding_archive.sql': `CREATE TABLE "public.studies" ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + }); + try { + const { failures, studyConstraint, hasStudyVariantFk } = evaluateParity(dir); + assert.equal(studyConstraint, null); + assert.equal(hasStudyVariantFk, false); + assert.equal(failures.length, 1); + assert.match(failures[0], /`studies\.variant` has no CHECK constraint and no foreign key referencing `variants\(code\)`/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 286d2e186e77bd950981f81f22ea639dcd639bce Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Tue, 1 Sep 2026 08:30:17 +0300 Subject: [PATCH 32/39] fix(scripts): support Unicode dollar tags and fail loudly on procedural DO migration blocks --- scripts/check-variant-parity.mjs | 56 ++++++-- scripts/test/check-variant-parity.test.mjs | 148 ++++++++++++++++++++- 2 files changed, 189 insertions(+), 15 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 6690139a..86dee6f9 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -47,6 +47,29 @@ import { pathToFileURL } from 'node:url'; */ export const MIGRATIONS_DIR = 'packages/persistence/migrations'; +const DOLLAR_QUOTE_REGEX = /^\$(?:[_\p{L}\p{Nl}][_\p{L}\p{Nl}\p{Nd}\p{Mn}\p{Mc}]*)?\$/u; + +/** + * Scans a valid PostgreSQL dollar-quote delimiter at the given source offset. + * + * PostgreSQL dollar tags follow unquoted identifier rules except that `$` is forbidden + * within the tag. Delimiters can be untagged `$$` or tagged `$tag$`. + * + * @param {string} source Source text. + * @param {number} offset Starting character offset in source text. + * @returns {{ delimiter: string, end: number } | null} Delimiter metadata or null if invalid. + */ +export function readDollarQuoteDelimiter(source, offset) { + if (source[offset] !== '$') return null; + const match = DOLLAR_QUOTE_REGEX.exec(source.slice(offset)); + if (match === null) return null; + const delimiter = match[0]; + return { + delimiter, + end: offset + delimiter.length, + }; +} + /** * Blanks out comments, leaving everything else at its original offset. * @@ -89,10 +112,10 @@ export function stripComments(text, dialect) { } if (dialect === 'sql' && ch === '$') { - const match = /^\$([a-zA-Z0-9_]*)\$/.exec(text.slice(i)); - if (match !== null) { - const tag = match[0]; - const endStr = text.indexOf(tag, i + tag.length); + const delim = readDollarQuoteDelimiter(text, i); + if (delim !== null) { + const tag = delim.delimiter; + const endStr = text.indexOf(tag, delim.end); if (endStr !== -1) { const fullDollar = text.slice(i, endStr + tag.length); out += fullDollar; @@ -158,11 +181,11 @@ export function tokenizeSql(sql) { } if (ch === '$') { - const match = /^\$([a-zA-Z0-9_]*)\$/.exec(sql.slice(i)); - if (match !== null) { - const tag = match[0]; + const delim = readDollarQuoteDelimiter(sql, i); + if (delim !== null) { + const tag = delim.delimiter; const start = i; - const endStr = sql.indexOf(tag, start + tag.length); + const endStr = sql.indexOf(tag, delim.end); if (endStr === -1) { throw new Error(`unterminated dollar-quoted string at position ${start}`); } @@ -679,6 +702,23 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { const statements = splitSqlStatements(tokens); for (const stmt of statements) { + if (stmt.length === 0) continue; + + // Fail loudly on procedural DO migration blocks that could mutate schema state + if (stmt[0]?.value === 'do') { + const bodyToken = stmt.find((t) => t.type === 'string'); + const bodyText = bodyToken ? bodyToken.value : ''; + const codeOnly = bodyText.replace(/'(?:[^']|'')*'/g, ' '); + if (/\b(?:execute|alter|drop|create|truncate)\b/i.test(codeOnly) || /studies|variants/i.test(codeOnly)) { + throw new Error( + `${file} contains an unsupported procedural DO block modifying or referencing schema state. ` + + `The deterministic variant parity replay cannot statically verify procedural schema mutations. ` + + `Use declarative DDL in migrations or model the change explicitly.`, + ); + } + continue; + } + if (stmt.length < 2) continue; // ----------------------------------------------------------------------- diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 648b9fc2..608d46c5 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -18,6 +18,7 @@ import { tokenizeSql, splitSqlStatements, parseQualifiedTableTarget, + readDollarQuoteDelimiter, replayStudiesSchema, extractRegion, migrationFiles, @@ -1386,37 +1387,114 @@ test('tokenizeSql and splitSqlStatements treat tagged dollar-quoted body with se assert.equal(tokens[1].type, 'string'); }); -test('fake DDL inside dollar-quoted body does not alter replay state', () => { +test('top-level DO statement with static ALTER TABLE fails loudly as unsupported procedural migration block', () => { const dir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL REFERENCES variants(code) );`, - '0002_fake_ddl.sql': `DO $$ + '0002_procedural_drop.sql': `DO $$ + BEGIN + ALTER TABLE studies DROP CONSTRAINT studies_variant_fk; + END + $$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('top-level DO statement with DROP TABLE studies fails loudly', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_procedural_drop.sql': `DO $$ BEGIN DROP TABLE studies; - ALTER TABLE studies ADD COLUMN variant TEXT; END $$;`, }); try { - assert.equal(effectiveStudyVariantForeignKey(dir), true); + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test('fake DROP TABLE variants CASCADE inside dollar-quoted body does not clear active studies FK', () => { +test('top-level DO statement with DROP TABLE variants CASCADE fails loudly', () => { const dir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL REFERENCES variants(code) );`, - '0002_fake_cascade.sql': `DO $fn$ + '0002_procedural_cascade.sql': `DO $$ BEGIN DROP TABLE variants CASCADE; END - $fn$;`, + $$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('top-level DO statement with dynamic SQL EXECUTE fails loudly', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_dynamic_ddl.sql': `DO $$ + BEGIN + EXECUTE 'ALTER TABLE studies DROP CONSTRAINT studies_variant_fk'; + END + $$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('tagged top-level DO statement fails loudly', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_tagged_do.sql': `DO $migration$ + BEGIN + ALTER TABLE studies DROP CONSTRAINT studies_variant_fk; + END + $migration$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('non-DO dollar-quoted function body remains lexically atomic and is ignored by replay', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_func.sql': `CREATE OR REPLACE FUNCTION log_change() RETURNS trigger AS $$ + BEGIN + -- Semicolons inside function do not split top-level statements + PERFORM 1; + RETURN NEW; + END; + $$ LANGUAGE plpgsql;`, }); try { assert.equal(effectiveStudyVariantForeignKey(dir), true); @@ -1433,6 +1511,62 @@ test('dollar-quoted string requires exact matching tag and does not stop on mism assert.equal(tokens[1].value, ' body with $inner$ inner text $inner$ more body '); }); +test('Unicode dollar tag with CJK characters is tokenized as one atomic string', () => { + const sql = `$函数$\nSELECT 1;\nSELECT 2;\n$函数$;`; + const tokens = tokenizeSql(sql); + const statements = splitSqlStatements(tokens); + assert.equal(statements.length, 1); + assert.equal(tokens[0].type, 'string'); + assert.equal(tokens[0].value, '\nSELECT 1;\nSELECT 2;\n'); +}); + +test('Unicode dollar tag with accented Latin characters is tokenized as one atomic string', () => { + const sql = `$étiquette$\nSELECT 1;\n$étiquette$;`; + const tokens = tokenizeSql(sql); + const statements = splitSqlStatements(tokens); + assert.equal(statements.length, 1); + assert.equal(tokens[0].type, 'string'); + assert.equal(tokens[0].value, '\nSELECT 1;\n'); +}); + +test('dollar tag starting with underscore and containing alphanumeric characters works', () => { + const sql = `$_tag123$\nSELECT 1;\n$_tag123$;`; + const tokens = tokenizeSql(sql); + const statements = splitSqlStatements(tokens); + assert.equal(statements.length, 1); + assert.equal(tokens[0].type, 'string'); +}); + +test('Unicode dollar tag requires exact matching tag and does not stop on different tag', () => { + const sql = `$函数$\nSELECT 1;\n$different$\nSELECT 2;\n$函数$;`; + const tokens = tokenizeSql(sql); + assert.equal(tokens.length, 2); // string, ; + assert.equal(tokens[0].type, 'string'); + assert.equal(tokens[0].value, '\nSELECT 1;\n$different$\nSELECT 2;\n'); +}); + +test('unterminated Unicode-tagged dollar quote throws explicit error', () => { + assert.throws(() => tokenizeSql('$函数$ SELECT 1;'), /unterminated dollar-quoted string/); + assert.throws(() => tokenizeSql('$étiquette$ SELECT 1; $different$'), /unterminated dollar-quoted string/); +}); + +test('dollar tag starting with digit like $9bad$ is rejected by readDollarQuoteDelimiter', () => { + assert.equal(readDollarQuoteDelimiter('$9bad$', 0), null); + const tokens = tokenizeSql('$9bad$'); + assert.equal(tokens[0].type, 'punct'); + assert.equal(tokens[0].value, '$'); + assert.equal(tokens[1].type, 'punct'); + assert.equal(tokens[1].value, '9'); +}); + +test('comment stripping and tokenizer recognize the exact same dollar delimiter set', () => { + const codeWithComment = `$étiquette$\n-- this is not a comment line\nSELECT 1;\n$étiquette$;`; + const stripped = stripComments(codeWithComment, 'sql'); + const tokens = tokenizeSql(stripped); + assert.equal(tokens[0].type, 'string'); + assert.match(tokens[0].value, /-- this is not a comment line/); +}); + test('unterminated dollar-quoted string throws explicit error', () => { assert.throws(() => tokenizeSql('DO $$ BEGIN PERFORM 1;'), /unterminated dollar-quoted string/); assert.throws(() => tokenizeSql('DO $tag$ BEGIN PERFORM 1; $different$'), /unterminated dollar-quoted string/); From 7e3d3843f0e6fb5d6b0d0bd10dc6d024e69bfa5e Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Tue, 1 Sep 2026 09:10:27 +0300 Subject: [PATCH 33/39] fix(scripts): enforce fail-closed procedural DO policy with cryptographic historical allowlist --- scripts/check-variant-parity.mjs | 56 +++++-- scripts/test/check-variant-parity.test.mjs | 175 +++++++++++++++++++-- 2 files changed, 203 insertions(+), 28 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 86dee6f9..8c4ee111 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -37,10 +37,31 @@ * * Run: node scripts/check-variant-parity.mjs */ +import { createHash } from 'node:crypto'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +/** + * Pinned allowlist of reviewed, immutable historical migrations containing top-level PostgreSQL DO blocks. + * + * Deterministic variant-parity replay cannot evaluate arbitrary procedural PL/pgSQL code. + * Any migration file containing a top-level DO block that is not explicitly pinned by both + * its relative filename, its canonical UTF-8 SHA-256 digest, and its expected DO statement count + * will fail closed immediately. + * + * @type {ReadonlyMap} + */ +export const KNOWN_HISTORICAL_PROCEDURAL_MIGRATIONS = new Map([ + [ + '0021_engine_bots.sql', + { + sha256: 'a59b1e4e9cefed19bca7de45cbd5f7d533a85f13fa197300d6bad9226c255508', + expectedDoCount: 1, + }, + ], +]); + /** * Directory holding the database migration SQL scripts. * @type {string} @@ -701,24 +722,27 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { const tokens = tokenizeSql(stripped); const statements = splitSqlStatements(tokens); - for (const stmt of statements) { - if (stmt.length === 0) continue; - - // Fail loudly on procedural DO migration blocks that could mutate schema state - if (stmt[0]?.value === 'do') { - const bodyToken = stmt.find((t) => t.type === 'string'); - const bodyText = bodyToken ? bodyToken.value : ''; - const codeOnly = bodyText.replace(/'(?:[^']|'')*'/g, ' '); - if (/\b(?:execute|alter|drop|create|truncate)\b/i.test(codeOnly) || /studies|variants/i.test(codeOnly)) { - throw new Error( - `${file} contains an unsupported procedural DO block modifying or referencing schema state. ` + - `The deterministic variant parity replay cannot statically verify procedural schema mutations. ` + - `Use declarative DDL in migrations or model the change explicitly.`, - ); - } - continue; + const doStatements = statements.filter((stmt) => stmt[0]?.value === 'do'); + if (doStatements.length > 0) { + const known = KNOWN_HISTORICAL_PROCEDURAL_MIGRATIONS.get(file); + const canonicalSql = rawSql.replace(/\r\n/g, '\n'); + const fileHash = createHash('sha256').update(canonicalSql, 'utf8').digest('hex'); + if ( + !known || + fileHash !== known.sha256 || + doStatements.length !== known.expectedDoCount + ) { + throw new Error( + `${file} contains an unsupported top-level PostgreSQL DO block. ` + + `The deterministic variant-parity replay cannot prove schema state across procedural execution. ` + + `Use declarative DDL or explicitly review and pin the immutable historical migration.`, + ); } + } + for (const stmt of statements) { + if (stmt.length === 0) continue; + if (stmt[0]?.value === 'do') continue; if (stmt.length < 2) continue; // ----------------------------------------------------------------------- diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 608d46c5..cc44ea94 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -12,6 +12,7 @@ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createHash } from 'node:crypto'; import { stripComments, splitStatements, @@ -30,6 +31,7 @@ import { tableKey, STUDIES_TABLE_KEY, VARIANTS_TABLE_KEY, + KNOWN_HISTORICAL_PROCEDURAL_MIGRATIONS, disagreements, ROOT, TS_MIRRORS, @@ -1387,26 +1389,64 @@ test('tokenizeSql and splitSqlStatements treat tagged dollar-quoted body with se assert.equal(tokens[1].type, 'string'); }); -test('top-level DO statement with static ALTER TABLE fails loudly as unsupported procedural migration block', () => { +test('unknown harmless DO statement fails closed', () => { const dir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL REFERENCES variants(code) );`, - '0002_procedural_drop.sql': `DO $$ + '0002_harmless_do.sql': `DO $$ + BEGIN + RAISE NOTICE 'hello'; + END + $$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('stored-function invocation inside DO statement fails closed', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_stored_proc.sql': `DO $$ + BEGIN + PERFORM remove_variant_fk(); + END + $$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('top-level DO statement with direct DDL fails closed', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_direct_ddl.sql': `DO $$ BEGIN ALTER TABLE studies DROP CONSTRAINT studies_variant_fk; END $$;`, }); try { - assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test('top-level DO statement with DROP TABLE studies fails loudly', () => { +test('top-level DO statement with DROP TABLE studies fails closed', () => { const dir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -1419,13 +1459,13 @@ test('top-level DO statement with DROP TABLE studies fails loudly', () => { $$;`, }); try { - assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test('top-level DO statement with DROP TABLE variants CASCADE fails loudly', () => { +test('top-level DO statement with DROP TABLE variants CASCADE fails closed', () => { const dir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -1438,13 +1478,13 @@ test('top-level DO statement with DROP TABLE variants CASCADE fails loudly', () $$;`, }); try { - assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test('top-level DO statement with dynamic SQL EXECUTE fails loudly', () => { +test('top-level DO statement with dynamic SQL EXECUTE fails closed', () => { const dir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -1457,13 +1497,13 @@ test('top-level DO statement with dynamic SQL EXECUTE fails loudly', () => { $$;`, }); try { - assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test('tagged top-level DO statement fails loudly', () => { +test('tagged top-level DO statement fails closed', () => { const dir = migrations({ '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, @@ -1471,12 +1511,123 @@ test('tagged top-level DO statement fails loudly', () => { );`, '0002_tagged_do.sql': `DO $migration$ BEGIN - ALTER TABLE studies DROP CONSTRAINT studies_variant_fk; + PERFORM 1; END $migration$;`, }); try { - assert.throws(() => replayStudiesSchema(dir), /contains an unsupported procedural DO block/); + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('Unicode-tagged top-level DO statement fails closed', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0002_unicode_do.sql': `DO $函数$ + BEGIN + PERFORM 1; + END + $函数$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('canonical 0021_engine_bots.sql migration matches allowlist fingerprint and passes replay', () => { + const realFile = join(MIGRATIONS_DIR, '0021_engine_bots.sql'); + const rawSql = readFileSync(realFile, 'utf8'); + const canonicalSql = rawSql.replace(/\r\n/g, '\n'); + const hash = createHash('sha256').update(canonicalSql, 'utf8').digest('hex'); + const entry = KNOWN_HISTORICAL_PROCEDURAL_MIGRATIONS.get('0021_engine_bots.sql'); + assert.ok(entry); + assert.equal(hash, entry.sha256); + assert.equal(entry.expectedDoCount, 1); + + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0021_engine_bots.sql': rawSql, + }); + try { + assert.equal(effectiveStudyVariantForeignKey(dir), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('0021_engine_bots.sql with ONE modified byte is rejected', () => { + const realFile = join(MIGRATIONS_DIR, '0021_engine_bots.sql'); + const rawSql = readFileSync(realFile, 'utf8'); + const modifiedSql = rawSql + ' '; // one changed byte + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0021_engine_bots.sql': modifiedSql, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('0021_engine_bots.sql filename with different file content is rejected', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0021_engine_bots.sql': `DO $$ BEGIN PERFORM 1; END $$;`, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('same safe DO content copied into a different migration filename is rejected', () => { + const realFile = join(MIGRATIONS_DIR, '0021_engine_bots.sql'); + const rawSql = readFileSync(realFile, 'utf8'); + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0030_copied_engine_bots.sql': rawSql, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('new second DO added to allowlisted migration causes rejection via fingerprint and count mismatch', () => { + const realFile = join(MIGRATIONS_DIR, '0021_engine_bots.sql'); + const rawSql = readFileSync(realFile, 'utf8'); + const twoDoSql = rawSql + '\nDO $$ BEGIN PERFORM 1; END $$;'; + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + '0021_engine_bots.sql': twoDoSql, + }); + try { + assert.throws(() => replayStudiesSchema(dir), /contains an unsupported top-level PostgreSQL DO block/); } finally { rmSync(dir, { recursive: true, force: true }); } From 446d3dc3b551fe76fd043770a5b0fca3f4ec819a Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 2 Sep 2026 08:31:08 +0300 Subject: [PATCH 34/39] fix(persistence): close variant catalog domain --- docs/DATABASE.md | 15 +- docs/PROJECT_STATE.md | 26 +- docs/ROADMAP.md | 2 +- .../migrations/0028_studies_variant_fk.sql | 30 +- .../0029_validate_studies_variant_fk.sql | 4 - .../0029_validate_variants_code_check.sql | 4 + .../migrations/0030_studies_variant_fk.sql | 8 + .../0031_validate_studies_variant_fk.sql | 4 + .../test/studies.integration.test.ts | 58 +-- .../variant-migrations.integration.test.ts | 256 +++++++++++++ scripts/check-variant-parity.mjs | 362 +++++++++++++++--- scripts/test/check-variant-parity.test.mjs | 179 ++++++++- 12 files changed, 817 insertions(+), 131 deletions(-) delete mode 100644 packages/persistence/migrations/0029_validate_studies_variant_fk.sql create mode 100644 packages/persistence/migrations/0029_validate_variants_code_check.sql create mode 100644 packages/persistence/migrations/0030_studies_variant_fk.sql create mode 100644 packages/persistence/migrations/0031_validate_studies_variant_fk.sql create mode 100644 packages/persistence/test/variant-migrations.integration.test.ts diff --git a/docs/DATABASE.md b/docs/DATABASE.md index b5401174..9677e1a1 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -118,9 +118,10 @@ constraints**, and *deliberately not* native Postgres `ENUM` types. enum ordering is definition-order, not semantic. For a platform that will add variants and refine terminations over years, this rigidity is a liability. - **Lookup tables** (a `code TEXT PRIMARY KEY` catalog + FK) for vocabularies that - **evolve or carry metadata**: `variants` (add a variant → one seed row, referential - integrity everywhere it's used) and `terminations`. This gives FK enforcement, - a natural place for display names/flags, and trivial extension. + carry metadata: `variants` and `terminations`. The variants catalog also has a + canonical-domain `CHECK`, so adding a variant is a coordinated application + + constraint + seed migration, not an arbitrary catalog insert. This keeps FK + enforcement and display metadata without allowing database/application drift. - **`CHECK` constraints** for **small, fixed, security- or protocol-defined** sets where a whole table is overkort and the set changes only with a code+migration change anyway: `speed`, `result`, `role`, credential `kind`. A `CHECK` is easy to @@ -236,10 +237,14 @@ game was recorded, and keeps upgrades backward-compatible and reversible. ### 4.1 Lookup / catalog tables ```sql -CREATE TABLE variants ( -- evolving vocabulary (add a variant = 1 seed row) +CREATE TABLE variants ( -- closed application vocabulary, migration-evolved code TEXT PRIMARY KEY, -- 'standard','chess960','kingofthehill',... name TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT true + enabled BOOLEAN NOT NULL DEFAULT true, + CONSTRAINT variants_code_check CHECK (code IN ( + 'standard', 'chess960', 'kingofthehill', 'atomic', + 'crazyhouse', 'threecheck', 'horde', 'racingkings' + )) ); CREATE TABLE terminations ( -- evolving/annotated vocabulary code TEXT PRIMARY KEY, -- 'checkmate','resignation','timeout',... diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index ed20bb96..5f6de8dd 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -4,28 +4,34 @@ > to read **only this file** and continue immediately. Updated after every > milestone and every significant architectural step. -_Last updated: 2026-08-31 — M15 Increment 42: Studies variant database integrity (FK conversion)._ +_Last updated: 2026-09-02 — M15 Increment 42: Closed variant catalog and studies FK integrity._ -## M15 Increment 42 — Studies variant database integrity (FK conversion) +## M15 Increment 42 — Closed variant catalog and studies FK integrity -`studies.variant` now derives directly from the canonical `variants` lookup table via a foreign key -constraint `studies_variant_fk` (`REFERENCES variants(code)` added with `NOT VALID` in migration -`0028_studies_variant_fk.sql` and validated in `0029_validate_studies_variant_fk.sql`), replacing the -duplicated inline `CHECK (variant IN (...))` constraint introduced in migration `0022`. +`variants(code)` is now a closed database domain matching the application's eight canonical +variants, and `studies.variant` derives from that catalog via `studies_variant_fk`. Migration `0028` +idempotently restores missing canonical catalog rows and adds `variants_code_check NOT VALID`; +`0029` validates the catalog before `0030` installs the studies FK and removes the duplicated inline +CHECK from migration `0022`; `0031` validates the FK. This completes the database integrity conversion candidate originally deferred in M15 Increment 10 ("Decided and not done: studies.variant stays a CHECK, for now"). Historical entries in earlier increment logs record the pre-migration state when the column was governed by a CHECK constraint. -All database variant columns (`games.variant`, `ratings.variant`, `seeks.variant`, and `studies.variant`) -now share identical relational integrity semantics: +All database variant columns (`games.variant`, `ratings.variant`, `seeks.variant`, and +`studies.variant`) now share identical relational integrity semantics: +- Inserting a noncanonical `variants.code` is rejected with SQLSTATE `23514`; a lookup insert cannot + silently broaden the application's variant domain. - Inserting an unsupported variant code into `studies.variant` is rejected by PostgreSQL with SQLSTATE `23503` (`foreign_key_violation`) referencing `studies_variant_fk`. +- Missing canonical lookup rows are reconstructed without overwriting existing metadata. Existing + noncanonical rows fail catalog validation and are neither deleted nor rewritten. - Existing study rows retain `NOT NULL DEFAULT 'standard'`. - Foreign key semantics use default `NO ACTION` to protect `variants(code)` against accidental deletions while referenced by active studies. -- `scripts/check-variant-parity.mjs` verifies that `studies.variant` derives from `variants(code)` without - maintaining a redundant SQL CHECK mirror, and validates foreign key presence across migrations. +- `scripts/check-variant-parity.mjs` verifies the lookup seed and catalog CHECK against `Variant`, + requires the catalog CHECK and studies FK to be validated in later migrations, rejects unsafe + ordering, and forbids a surviving studies-specific CHECK mirror. ## M15 Increment 41 — Chess960 production integration (ADR-0137) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7aa0c084..edbd130a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,7 +28,7 @@ The correctness-critical foundation everything else depends on. - **Perft suites for each variant (RESOLVED in Increment 32 / ADR-0098 and completed in Increment 42).** All eight variants now have perft verification against published reference vectors or equality/divergence invariants. `horde` and `racingkings` coverage added from official `lichess-org/scalachess` perft resources (ADR-0098), resolving variant-rule defects in Horde rank-1 pawn double pushes and Racing Kings 8th-rank goal turn semantics. - **Chess960 was a label with nothing behind it (RESOLVED in M15 Increment 41 / ADR-0137; rules in ADR-0136).** Bigger than the "castling-by-file" wording suggested, and verified by running the code. (1) `Position.initial('chess960')` returns the standard array on every call, and `packages/game/src/game.ts:92` uses it for any seek without an explicit FEN — so a Chess960 game was ordinary chess. (2) `generateCastles` in `packages/chess-core/src/movegen.ts` pins the king to e1/e8 and looks for rooks at fixed offsets, so castling generates for exactly one of the 960 start positions, and that one is standard chess: king on b1 with rooks a1/h1 produces 0 castling moves, as does king g1 with rooks f1/h1. (3) `packages/chess-core/src/fen.ts` discards file-letter castling rights, so `HAha` on kiwipete gives `perft(1) = 46`, identical to no rights, against 48 for `KQkq`. **Withheld in Increment 33 (ADR-0099):** removed from the lobby's offered variants so nobody receives a mislabelled standard game; still accepted by the API and still a rule set in `chess-core`. **Open:** implementing it — 960-position generation, castling from arbitrary king and rook squares, Shredder/X-FEN in and out, the UCI king-takes-rook encoding, SAN, and perft against published values. **Server contract closed in M15 Increment 14 (ADR-0123):** withholding it in the lobby only protected browser users — `OFFERED_VARIANTS` is a list in the web bundle, and every other client (script, bot, mobile, curl) still reached `Game.create`, which wrote `variant: 'chess960'` beside a standard `initialFen` into an append-only event store. That is a durable falsehood, not a UI wart: afterwards nothing can tell such a row from a real Chess960 game. `Game.create` now refuses the variant outright — the one place every game is born, so seek acceptance, the bot route and the tournament launcher all inherit it — and `CREATABLE_VARIANTS` carries the same rule at the three creation routes so the refusal arrives as the API’s ordinary 422 rather than the 500 an unmapped `GameError` would produce. Seek acceptance re-checks the stored variant (409) because that value comes from a row, not a request. `chess960` remains valid everywhere that reads — the enum, the `variants` table, and every View schema — and only the three Request schemas narrowed. **Rules implemented in ADR-0136:** all 960 arrangements from the Scharnagl numbering, castling from arbitrary king and rook squares, Shredder-FEN in and canonical X-FEN out, the UCI king-takes-rook encoding, SAN unchanged, and perft against all 960 published reference positions — with the refusal deliberately kept, because the engine could now play any arrangement but nothing could yet *tell* it which one. **RESOLVED in M15 Increment 41 (ADR-0137):** `GameCreated` carries an optional `chess960StartId`, and the server draws it — `crypto.randomInt` at seek acceptance and on the bot route, derived from the launch identity for tournaments, so racing replicas agree on the arrangement instead of each drawing their own. Replay validates the stored id against the stored FEN rather than trusting either alone, and a legacy `chess960` event with no id replays from its FEN and reports its start as unknown, never as 518 — the guess that would look plausible. `Game.create` requires the id for the variant and refuses it for every other; `CREATABLE_VARIANTS` and `OFFERED_VARIANTS` admit `chess960`; `openapi.json` is regenerated. The seek-accept 409 is *kept* rather than removed as the checklist said, because `seek.variant` is read from a database column and the type system does not span the SQL (ADR-0137 §6). Move *input* needed no Chess960 logic in the browser — `BoardInteraction` is oracle-driven and the server's legal-move map already spells castling king-takes-rook — but move *projection* did: `applyMove` advances the client's own board between snapshots and recognised castling only at exactly two files, projecting `d1a1` as a king on a1 with the rook deleted. It now treats a king landing on a friendly rook as a castle (ADR-0137 §8). **Nothing left open on the variant.** - **`Position.snapshot()` lost three-check state (RESOLVED in M15 Increment 8).** `snapshot()` in `packages/chess-core/src/position.ts` round-tripped through `parseFen(this.fen(), variant)`, and `toFen` does not serialise `checkCount`, so both counters reset to zero. Found during the Increment 33 audit (ADR-0099 §4) and recorded there as "latent, not live" on the grounds that a repetition key uses only the first four FEN fields. **That assessment was wrong.** `packages/chess-core/src/repetition.ts` had appended the delivered-check counters to the key for `threecheck` since 2026-07-13 — three weeks before the audit — and `packages/game/src/game.ts` builds that key from the lossy snapshot on both the live and replay paths. Every three-check position therefore reported `0+0`, and a board that repeated while the check counts climbed was treated as a repetition: `Re1+ Kf8 Rd1 Ke8 Re1+ Kf8 Rd1 Ke8` was declared a threefold draw with White one check from winning. **Resolved in M15 Increment 8:** `snapshot()` returns `cloneState(this.state)`, the existing authoritative deep copy, so no `PositionState` field is dropped; the line above now continues and White wins `1-0` on the third check. Serialising three-check counters into FEN was deferred to M15 Increment 9 and is **RESOLVED** there (ADR-0120): `toFen` emits the canonical Fairy-Stockfish field — `N+M` remaining, in field five — and `parseFen` accepts that, the trailing `+N+M` delivered form, and the legacy six-field form. The engine defect it was hiding is closed with it: Fairy-Stockfish 14 reads a missing counter field as `1+1`, so every three-check analysis had been scored as though one check won the game. -- **The supported-variant list is written out across mirrors (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, and the `variants` lookup table — originally seven hand-maintained copies (including an inline `CHECK` on `studies.variant`), none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the active mirrors (the four TypeScript sites plus the lookup table) to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Resolved in M15 Increment 42:** `studies.variant` now references `variants(code)` via `FOREIGN KEY` (added with `NOT VALID` in `0028_studies_variant_fk.sql` and validated in `0029_validate_studies_variant_fk.sql`), dropping the duplicated `CHECK` constraint so that all database variant columns derive uniformly from the canonical `variants` lookup table. +- **The supported-variant list is written out across mirrors (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, and the `variants` lookup table — originally seven hand-maintained copies (including an inline `CHECK` on `studies.variant`), none derived from another. The type system does not span the SQL, so `scripts/check-variant-parity.mjs` replays the immutable migration history and compares every active mirror to `Variant`. **Resolved in M15 Increment 42:** migrations `0028`–`0031` restore missing canonical catalog rows, install and validate a closed eight-value CHECK on `variants(code)`, then replace the duplicated studies CHECK with a separately validated FK. Unsupported legacy catalog rows stop validation without being rewritten or deleted; a catalog insert alone can no longer broaden any FK consumer. The guard now verifies the seed, catalog CHECK, safe validation order, final studies FK, and absence of the obsolete studies CHECK. - **CI depends on the Ubuntu package mirror for Stockfish (RESOLVED in M15 Increment 11 / ADR-0121).** The `analysis smoke` job apt-installs Stockfish, and that step has now stalled indefinitely three times: once on PR #140 (cancelled and re-run successfully) and twice post-merge on `cbe6bce` (jobs `96156044656` and `96200357632`, the rerun cancelled after ~34 minutes). Each stall was in the mirror step, before Fairy-Stockfish was installed and before any smoke test ran, so it proves nothing about the code and costs the full job timeout. Fairy-Stockfish in the same job is already a pinned, checksummed release download and has never stalled. **Resolved in M15 Increment 11 (ADR-0121):** Stockfish now comes from release `sf_16`, asset `stockfish-ubuntu-x86-64.tar`, pinned by SHA-256 `efca1c60ec11fd9628425f3ee40644ad1618535ddf881c16385a86f7fc9e0983`, extracted one member by exact path, `chmod`ed only after verification, and asserted to report `id name Stockfish 16` before the suite runs. `sf_16` is the version apt was already serving, so the engine under test is unchanged. `apt-get` no longer appears in any executable line of any workflow, and the job now carries `timeout-minutes: 15` so a future stall is capped rather than inheriting the six-hour default. Production Docker images used apt until **M15 Increment 12**, which closed the last of it. Before that, `release.yml` built `Dockerfile.api` and `Dockerfile.gateway` on a `v*` tag push and those builds ran the apt layers — meaning production shipped Debian bookworm's `stockfish 15.1-4` while CI proved the engine boundary against 16, and a base-image move to trixie would have made it 17 with no commit of ours. Both images now take the binary from a pinned `stockfish` artefact stage using the same release, asset and digest as CI, with the licence and corresponding source copied beside it for GHCR redistribution, and a `docker-images` CI job builds both before merge instead of first exercising them at release time. `scripts/check-engine-pin-parity.mjs` fails if the four copies of the pin ever disagree. - **The web image was published but never built before the tag (RESOLVED in M15 Increment 12).** `release.yml` pushes three images to GHCR on a `v*` tag — `Dockerfile.api`, `Dockerfile.gateway` and `Dockerfile.web` — but the `docker-images` CI job as first written built only the two that carry the pinned engine. `Dockerfile.web` copies `docker/web/nginx.conf.template` into `/etc/nginx/templates/`, where the image entrypoint runs `envsubst` over it at container start, so a broken template is not a build error at all: the image builds clean and the container dies on boot, and nothing before the release tag rendered it. Raised in the Qodo review of PR #143. **Resolved in the same increment:** the job builds all three images and checks the web one for what can actually break in it — the entrypoint renders the template with representative loopback upstreams (`nginx -t` resolves a literal `proxy_pass` host at config-load time, so the compose defaults would fail on a runner for the wrong reason), `nginx -t` must accept the result, both upstreams must appear substituted, and `$http_host` and `$uri` must survive, which is what `NGINX_ENVSUBST_FILTER` exists to guarantee and nothing tested. `docker/` joins the `images` path filter so the filter and the job cover the same set. - **A study movetext walker that never asked which variant it was reading (RESOLVED in M15 Increment 13 / ADR-0122).** `importGame` in `packages/studies/src/import.ts` resolved every SAN through `resolveSan(reader, fen, san)` and `reader.play(fen, san)` with no variant, and `resolveSan` defaults to standard rather than failing — so a Crazyhouse or Three-Check game imported through it would have been validated against standard chess, rejecting legal moves and accepting illegal ones with no error to say why. Latent, not live: a whole-repository search found it referenced only by its own definition and its own test file, and both real import paths (`InMemoryStudiesRepository.buildTreeFromMovetext` via `appendNode`, and `PgStudiesRepository.buildTreeFromMovetextInternal` via a required parameter) already thread the study variant correctly. It was also a third implementation of a descent the two adapters already have, and ADR-0091 §10 records what happened the last time two copies of this walk diverged. **Resolved in M15 Increment 13 (ADR-0122):** deleted, together with the types and the `START_FEN` alias that existed only to serve it; `chapterNameFor` stays, because both adapters import it. `resolveSan`’s standard default is kept and now pinned by a test that states why — `@chess-platform/learning` relies on it and lessons carry no variant of their own. The coverage that was only reachable through the dead function moved onto `resolveSan` itself, and variant propagation through side variations — previously unverified, since the existing three-check test had no variations — is now a mutation-checked regression test. diff --git a/packages/persistence/migrations/0028_studies_variant_fk.sql b/packages/persistence/migrations/0028_studies_variant_fk.sql index 786b0595..c346e554 100644 --- a/packages/persistence/migrations/0028_studies_variant_fk.sql +++ b/packages/persistence/migrations/0028_studies_variant_fk.sql @@ -1,8 +1,26 @@ --- Migration 0028: Replace studies.variant CHECK constraint with FOREIGN KEY referencing variants(code) +-- Migration 0028: Restore the canonical variant catalog and install its closed domain constraint. -ALTER TABLE studies - DROP CONSTRAINT studies_variant_check; +INSERT INTO variants (code, name) VALUES + ('standard', 'Standard'), + ('chess960', 'Chess960'), + ('kingofthehill', 'King of the Hill'), + ('atomic', 'Atomic'), + ('crazyhouse', 'Crazyhouse'), + ('threecheck', 'Three-check'), + ('horde', 'Horde'), + ('racingkings', 'Racing Kings') +ON CONFLICT (code) DO NOTHING; -ALTER TABLE studies - ADD CONSTRAINT studies_variant_fk - FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID; +-- NOT VALID closes the domain for new writes immediately while deferring the legacy-row scan. +ALTER TABLE variants + ADD CONSTRAINT variants_code_check + CHECK (code IN ( + 'standard', + 'chess960', + 'kingofthehill', + 'atomic', + 'crazyhouse', + 'threecheck', + 'horde', + 'racingkings' + )) NOT VALID; diff --git a/packages/persistence/migrations/0029_validate_studies_variant_fk.sql b/packages/persistence/migrations/0029_validate_studies_variant_fk.sql deleted file mode 100644 index 69a1d971..00000000 --- a/packages/persistence/migrations/0029_validate_studies_variant_fk.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Migration 0029: Validate studies_variant_fk constraint without blocking concurrent writes - -ALTER TABLE studies - VALIDATE CONSTRAINT studies_variant_fk; diff --git a/packages/persistence/migrations/0029_validate_variants_code_check.sql b/packages/persistence/migrations/0029_validate_variants_code_check.sql new file mode 100644 index 00000000..7770955d --- /dev/null +++ b/packages/persistence/migrations/0029_validate_variants_code_check.sql @@ -0,0 +1,4 @@ +-- Migration 0029: Reject unsupported legacy variant catalog rows before changing studies. + +ALTER TABLE variants + VALIDATE CONSTRAINT variants_code_check; diff --git a/packages/persistence/migrations/0030_studies_variant_fk.sql b/packages/persistence/migrations/0030_studies_variant_fk.sql new file mode 100644 index 00000000..0456c677 --- /dev/null +++ b/packages/persistence/migrations/0030_studies_variant_fk.sql @@ -0,0 +1,8 @@ +-- Migration 0030: Replace the duplicated studies.variant CHECK with the canonical catalog FK. + +ALTER TABLE studies + ADD CONSTRAINT studies_variant_fk + FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID; + +ALTER TABLE studies + DROP CONSTRAINT studies_variant_check; diff --git a/packages/persistence/migrations/0031_validate_studies_variant_fk.sql b/packages/persistence/migrations/0031_validate_studies_variant_fk.sql new file mode 100644 index 00000000..6ff99dee --- /dev/null +++ b/packages/persistence/migrations/0031_validate_studies_variant_fk.sql @@ -0,0 +1,4 @@ +-- Migration 0031: Validate the studies.variant foreign key after its non-blocking installation. + +ALTER TABLE studies + VALIDATE CONSTRAINT studies_variant_fk; diff --git a/packages/persistence/test/studies.integration.test.ts b/packages/persistence/test/studies.integration.test.ts index a229cc7f..39588f54 100644 --- a/packages/persistence/test/studies.integration.test.ts +++ b/packages/persistence/test/studies.integration.test.ts @@ -33,14 +33,6 @@ interface PgErrorShape { constraint?: string; } -/** - * Type guard verifying whether an unknown error is a PostgreSQL constraint violation matching a code and optional constraint name. - * - * @param err The unknown error caught in an assert.rejects handler. - * @param code The expected 5-character PostgreSQL SQLSTATE error code. - * @param constraint Optional constraint name to match against the error's constraint property. - * @returns boolean indicating if the error matches the expected PostgreSQL constraint violation. - */ function isPgConstraintViolation(err: unknown, code: string, constraint?: string): boolean { if (typeof err !== 'object' || err === null) return false; const pgErr = err as PgErrorShape; @@ -308,7 +300,16 @@ test('pg studies repository integration tests', { skip }, async () => { (err: unknown) => err instanceof StudyRuleError && err.code === 'not_found' ); - // 10. Variant Foreign Key and Integrity (Migration 0028) + // 10. Closed variant catalog and studies FK integrity (migrations 0028-0031) + const domainRes = await pool.query<{ convalidated: boolean }>(` + SELECT convalidated + FROM pg_constraint + WHERE conrelid = 'variants'::regclass + AND contype = 'c' + AND conname = 'variants_code_check' + `); + assert.deepEqual(domainRes.rows, [{ convalidated: true }]); + // 10.1 Metadata verification: FK constraint exists and points to variants(code) const fkRes = await pool.query<{ constraint_name: string; @@ -347,7 +348,14 @@ test('pg studies repository integration tests', { skip }, async () => { `); assert.equal(oldCheckRes.rows.length, 0, 'old CHECK constraint studies_variant_check must no longer exist'); - // 10.3 Invalid variant insertion rejected by FK constraint (23503) + // 10.3 The catalog rejects noncanonical codes before they can broaden FK consumers. + await assert.rejects( + pool.query(`INSERT INTO variants (code, name) VALUES ('noncanonical_variant', 'Invalid')`), + (err: unknown) => isPgConstraintViolation(err, '23514', 'variants_code_check'), + 'inserting a noncanonical catalog code must raise check_violation (23514)' + ); + + // An unsupported study remains rejected by the FK. const badStudyId = uuidv7(); await assert.rejects( async () => @@ -380,36 +388,6 @@ test('pg studies repository integration tests', { skip }, async () => { assert.equal(fetched.variant, v); } - // 10.5 Referencing study specifically protects variants(code) via studies_variant_fk (NO ACTION) - let customVarInserted = false; - let customStudyInserted = false; - const customVarCode = 'test_fk_protection_variant'; - const customVarStudyId = uuidv7(); - try { - await pool.query( - `INSERT INTO variants (code, name, enabled) VALUES ($1, 'Test FK Variant', true)`, - [customVarCode] - ); - customVarInserted = true; - await pool.query( - `INSERT INTO studies (id, owner_id, name, description, visibility, variant, created_at, updated_at) - VALUES ($1, $2, 'Custom Variant Study', '', 'public', $3, NOW(), NOW())`, - [customVarStudyId, alice, customVarCode] - ); - customStudyInserted = true; - await assert.rejects( - async () => pool.query(`DELETE FROM variants WHERE code = $1`, [customVarCode]), - (err: unknown) => isPgConstraintViolation(err, '23503', 'studies_variant_fk'), - 'deleting a variant referenced only by studies must raise foreign_key_violation on studies_variant_fk' - ); - } finally { - if (customStudyInserted) { - await pool.query(`DELETE FROM studies WHERE id = $1`, [customVarStudyId]); - } - if (customVarInserted) { - await pool.query(`DELETE FROM variants WHERE code = $1`, [customVarCode]); - } - } } finally { if (createdUserIds.length > 0) { await pool.query(`DELETE FROM users WHERE id = ANY($1)`, [createdUserIds]); diff --git a/packages/persistence/test/variant-migrations.integration.test.ts b/packages/persistence/test/variant-migrations.integration.test.ts new file mode 100644 index 00000000..dd23f477 --- /dev/null +++ b/packages/persistence/test/variant-migrations.integration.test.ts @@ -0,0 +1,256 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Pool } from 'pg'; +import { createPool } from '../src/pg/pool'; +import { migrate, migrationFiles, migrationsDir } from '../src/pg/migrate'; + +const DATABASE_URL = process.env['DATABASE_URL']; +const skip = DATABASE_URL ? false : 'DATABASE_URL not set'; +const MIGRATIONS_DIR = migrationsDir(); +const CANONICAL_VARIANTS = [ + 'standard', + 'chess960', + 'kingofthehill', + 'atomic', + 'crazyhouse', + 'threecheck', + 'horde', + 'racingkings', +] as const; + +interface PgErrorShape { + readonly code?: string; + readonly constraint?: string; +} + +interface ConstraintRow { + readonly conname: string; + readonly convalidated: boolean; + readonly definition: string; +} + +function isConstraintViolation(error: unknown, code: string, constraint: string): boolean { + if (typeof error !== 'object' || error === null) return false; + const pgError = error as PgErrorShape; + return pgError.code === code && pgError.constraint === constraint; +} + +function databaseUrlFor(database: string): string { + const url = new URL(DATABASE_URL!); + url.pathname = `/${database}`; + return url.toString(); +} + +function migrationsThrough(version: number): { readonly dir: string; cleanup(): void } { + const dir = mkdtempSync(join(tmpdir(), `variant-migrations-${version}-`)); + for (const migration of migrationFiles(MIGRATIONS_DIR)) { + if (migration.version > version) continue; + copyFileSync(join(MIGRATIONS_DIR, migration.file), join(dir, migration.file)); + } + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +async function withDatabase(run: (pool: Pool) => Promise): Promise { + const admin = createPool({ connectionString: DATABASE_URL, max: 2 }); + const database = `variant_migration_${randomUUID().replaceAll('-', '')}`; + await admin.query(`CREATE DATABASE "${database}"`); + const pool = createPool({ connectionString: databaseUrlFor(database), max: 4 }); + try { + await run(pool); + } finally { + await pool.end(); + await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); + await admin.end(); + } +} + +async function constraint( + pool: Pool, + table: 'variants' | 'studies', + name: string, +): Promise { + const found = await pool.query( + `SELECT conname, convalidated, pg_get_constraintdef(oid) AS definition + FROM pg_constraint + WHERE conrelid = $1::regclass AND conname = $2`, + [table, name], + ); + return found.rows[0]; +} + +async function insertUser(pool: Pool): Promise { + const id = randomUUID(); + await pool.query( + `INSERT INTO users (id, handle, email_hash) VALUES ($1, $2, $3)`, + [id, `variant_${id.replaceAll('-', '')}`, Buffer.from(id)], + ); + return id; +} + +async function insertStudy(pool: Pool, ownerId: string, variant: string): Promise { + const id = randomUUID(); + await pool.query( + `INSERT INTO studies + (id, owner_id, name, description, visibility, variant, created_at, updated_at) + VALUES ($1, $2, $3, '', 'public', $4, NOW(), NOW())`, + [id, ownerId, `Study ${variant}`, variant], + ); + return id; +} + +test('fresh install enforces the closed variants domain and validated studies FK', { skip }, async () => { + await withDatabase(async (pool) => { + await migrate(pool, MIGRATIONS_DIR); + + const variants = await pool.query<{ code: string }>('SELECT code FROM variants ORDER BY code'); + assert.deepEqual( + variants.rows.map((row) => row.code), + [...CANONICAL_VARIANTS].sort(), + ); + + await assert.rejects( + pool.query(`INSERT INTO variants (code, name) VALUES ('unsupported', 'Unsupported')`), + (error: unknown) => isConstraintViolation(error, '23514', 'variants_code_check'), + ); + + const domain = await constraint(pool, 'variants', 'variants_code_check'); + assert.equal(domain?.convalidated, true); + assert.match(domain?.definition ?? '', /^CHECK \(\(code = ANY \(ARRAY\[/); + assert.equal(await constraint(pool, 'studies', 'studies_variant_check'), undefined); + + const foreignKey = await constraint(pool, 'studies', 'studies_variant_fk'); + assert.equal(foreignKey?.convalidated, true); + assert.equal(foreignKey?.definition, 'FOREIGN KEY (variant) REFERENCES variants(code)'); + + const ownerId = await insertUser(pool); + for (const variant of CANONICAL_VARIANTS) await insertStudy(pool, ownerId, variant); + + await assert.rejects( + insertStudy(pool, ownerId, 'unsupported'), + (error: unknown) => isConstraintViolation(error, '23503', 'studies_variant_fk'), + ); + await assert.rejects( + pool.query(`DELETE FROM variants WHERE code = 'racingkings'`), + (error: unknown) => isConstraintViolation(error, '23503', 'studies_variant_fk'), + ); + }); +}); + +test('upgrade restores missing canonical rows before replacing the studies CHECK', { skip }, async () => { + const through27 = migrationsThrough(27); + const through28 = migrationsThrough(28); + const through29 = migrationsThrough(29); + const through30 = migrationsThrough(30); + try { + await withDatabase(async (pool) => { + await migrate(pool, through27.dir); + const ownerId = await insertUser(pool); + const atomicStudy = await insertStudy(pool, ownerId, 'atomic'); + const hordeStudy = await insertStudy(pool, ownerId, 'horde'); + await pool.query(`DELETE FROM variants WHERE code IN ('atomic', 'horde')`); + await pool.query( + `UPDATE variants SET name = 'Operator Standard', enabled = false WHERE code = 'standard'`, + ); + + await migrate(pool, through28.dir); + assert.equal((await constraint(pool, 'variants', 'variants_code_check'))?.convalidated, false); + assert.notEqual(await constraint(pool, 'studies', 'studies_variant_check'), undefined); + assert.equal(await constraint(pool, 'studies', 'studies_variant_fk'), undefined); + await assert.rejects( + pool.query(`INSERT INTO variants (code, name) VALUES ('new_rogue', 'New Rogue')`), + (error: unknown) => isConstraintViolation(error, '23514', 'variants_code_check'), + ); + + await migrate(pool, through29.dir); + assert.equal((await constraint(pool, 'variants', 'variants_code_check'))?.convalidated, true); + assert.notEqual(await constraint(pool, 'studies', 'studies_variant_check'), undefined); + + await migrate(pool, through30.dir); + assert.equal(await constraint(pool, 'studies', 'studies_variant_check'), undefined); + assert.equal((await constraint(pool, 'studies', 'studies_variant_fk'))?.convalidated, false); + await assert.rejects( + insertStudy(pool, ownerId, 'new_rogue'), + (error: unknown) => isConstraintViolation(error, '23503', 'studies_variant_fk'), + ); + + await migrate(pool, MIGRATIONS_DIR); + assert.equal((await constraint(pool, 'studies', 'studies_variant_fk'))?.convalidated, true); + const studies = await pool.query<{ id: string; variant: string }>( + 'SELECT id, variant FROM studies WHERE id = ANY($1) ORDER BY variant', + [[atomicStudy, hordeStudy]], + ); + assert.deepEqual(studies.rows.map((row) => row.variant), ['atomic', 'horde']); + const standard = await pool.query<{ name: string; enabled: boolean }>( + `SELECT name, enabled FROM variants WHERE code = 'standard'`, + ); + assert.deepEqual(standard.rows[0], { name: 'Operator Standard', enabled: false }); + }); + } finally { + through27.cleanup(); + through28.cleanup(); + through29.cleanup(); + through30.cleanup(); + } +}); + +test('unsupported legacy catalog data fails loudly, survives rollback, and retries deterministically', { skip }, async () => { + const through27 = migrationsThrough(27); + const through28 = migrationsThrough(28); + const through29 = migrationsThrough(29); + try { + await withDatabase(async (pool) => { + await migrate(pool, through27.dir); + const ownerId = await insertUser(pool); + await pool.query( + `INSERT INTO variants (code, name, enabled) VALUES ('legacy_rogue', 'Legacy Rogue', false)`, + ); + await pool.query( + `INSERT INTO ratings (user_id, variant, rating, rd, vol) + VALUES ($1, 'legacy_rogue', 1400, 100, 0.06)`, + [ownerId], + ); + await pool.query( + `INSERT INTO seeks (id, creator_id, variant, time_control, rated) + VALUES ($1, $2, 'legacy_rogue', '{}', false)`, + [randomUUID(), ownerId], + ); + await pool.query( + `INSERT INTO games (id, variant, rated, speed, result, ply_count, last_seq, started_at) + VALUES ($1, 'legacy_rogue', false, 'rapid', '*', 0, 0, NOW())`, + [randomUUID()], + ); + + await migrate(pool, through28.dir); + assert.equal((await constraint(pool, 'variants', 'variants_code_check'))?.convalidated, false); + + await assert.rejects(migrate(pool, through29.dir), /variants_code_check/); + await assert.rejects(migrate(pool, through29.dir), /variants_code_check/); + const preserved = await pool.query<{ source: string }>(` + SELECT 'variant' AS source FROM variants WHERE code = 'legacy_rogue' + UNION ALL SELECT 'rating' FROM ratings WHERE variant = 'legacy_rogue' + UNION ALL SELECT 'seek' FROM seeks WHERE variant = 'legacy_rogue' + UNION ALL SELECT 'game' FROM games WHERE variant = 'legacy_rogue' + ORDER BY source + `); + assert.deepEqual(preserved.rows.map((row) => row.source), ['game', 'rating', 'seek', 'variant']); + assert.notEqual(await constraint(pool, 'studies', 'studies_variant_check'), undefined); + assert.equal(await constraint(pool, 'studies', 'studies_variant_fk'), undefined); + + await pool.query(`DELETE FROM games WHERE variant = 'legacy_rogue'`); + await pool.query(`DELETE FROM seeks WHERE variant = 'legacy_rogue'`); + await pool.query(`DELETE FROM ratings WHERE variant = 'legacy_rogue'`); + await pool.query(`DELETE FROM variants WHERE code = 'legacy_rogue'`); + await migrate(pool, MIGRATIONS_DIR); + assert.equal((await constraint(pool, 'variants', 'variants_code_check'))?.convalidated, true); + assert.equal((await constraint(pool, 'studies', 'studies_variant_fk'))?.convalidated, true); + }); + } finally { + through27.cleanup(); + through28.cleanup(); + through29.cleanup(); + } +}); diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 8c4ee111..a24689b6 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -2,16 +2,13 @@ /** * Fails when the hand-maintained copies of the supported-variant list stop agreeing. * - * The set of rule sets this platform supports is written out in six places, in two languages, and - * nothing derives from anything else. That is survivable only while they match, and there was no - * check that they do. + * The set of rule sets this platform supports is written out in six active mirrors, in two + * languages. That is survivable only while they match. * - * The database variant columns (games, ratings, seeks, and studies via migrations 0028/0029) are - * `variant TEXT NOT NULL REFERENCES variants(code)`, so once a row exists in the `variants` lookup - * table the database accepts that value uniformly. `studies.variant` was initially governed by an - * inline `CHECK (variant IN (...))` in migration 0022 and converted to `REFERENCES variants(code)` - * in migration 0028 (validated in 0029). The application-level declarations below still need their - * own updates in either case — the lookup row settles what the *database* will store. + * The database variant columns reference `variants(code)`. Migrations 0028/0029 close and validate + * that catalog's canonical domain; migrations 0030/0031 then replace the historical + * `studies.variant` CHECK with a validated FK. The guard replays both the catalog seed and its + * database CHECK, so a lookup row alone cannot widen the domain. * * `chess-core`'s `Variant` is treated as the root: it is the type the engine actually branches on, * so a variant that is not there is not a variant at all. Every other list is compared to it. @@ -487,6 +484,7 @@ export function splitStatements(sql) { */ export function effectiveLookupVariants(dir = MIGRATIONS_DIR) { const codes = []; + const presentCodes = new Set(); for (const file of migrationFiles(dir)) { const sql = stripComments(readFileSync(join(dir, file), 'utf8'), 'sql'); @@ -499,10 +497,16 @@ export function effectiveLookupVariants(dir = MIGRATIONS_DIR) { ); } - for (const insert of sql.matchAll(/INSERT\s+INTO\s+variants\s*\([^)]*\)\s*VALUES([\s\S]*?);/gi)) { + for (const insert of sql.matchAll(/INSERT\s+INTO\s+variants\s*\([^)]*\)\s*VALUES[\s\S]*?;/gi)) { + const idempotentReseed = /\bON\s+CONFLICT\s*\(\s*code\s*\)\s+DO\s+NOTHING\b/i.test(insert[0]); // The first column of each tuple is `code`; the second is a display name that is capitalised // or hyphenated, so taking the leading element of each `(...)` keeps them apart reliably. - for (const tuple of insert[1].matchAll(/\(\s*'([^']+)'/g)) codes.push(tuple[1]); + for (const tuple of insert[0].matchAll(/\(\s*'([^']+)'/g)) { + const code = tuple[1]; + if (idempotentReseed && presentCodes.has(code)) continue; + codes.push(code); + presentCodes.add(code); + } } } if (codes.length === 0) throw new Error(`no INSERT INTO variants found under ${dir}`); @@ -686,31 +690,55 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra * @returns {{ * check: { file: string, name: string, variants: string[] } | null, * checks: Array<{ file: string, name: string, variants: string[] }>, - * hasForeignKey: boolean - * }} Effective check constraint on studies.variant and whether an active foreign key referencing variants(code) exists. + * hasForeignKey: boolean, + * foreignKeys: Array<{ name: string, file: string, validated: boolean, validatedFile: string | null }>, + * variantDomainChecks: Array<{ file: string, name: string, variants: string[], validated: boolean, addedNotValid: boolean, validatedFile: string | null }>, + * unsafeStudyConstraintTransition: boolean + * }} Effective variant-domain and studies constraint state. */ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { /** * @type {Map, * variantConstraints: Set, * activeChecks: Map, - * activeFks: Set + * activeCodeChecks: Map, + * activeFks: Set, + * validatedFks: Set, + * fkAddedFiles: Map, + * fkValidatedFiles: Map * }>} */ const tables = new Map(); + let unsafeStudyConstraintTransition = false; + + function newTableState() { + return { + hasVariantColumn: false, + hasCodeColumn: false, + constraintNamespace: new Set(), + variantConstraints: new Set(), + activeChecks: new Map(), + activeCodeChecks: new Map(), + activeFks: new Set(), + validatedFks: new Set(), + fkAddedFiles: new Map(), + fkValidatedFiles: new Map(), + }; + } + + function hasValidatedVariantDomain() { + const variantsTable = tables.get(VARIANTS_TABLE_KEY); + return variantsTable !== undefined && + Array.from(variantsTable.activeCodeChecks.values()).some((check) => check.validated); + } function getOrCreateTable(key) { let t = tables.get(key); if (!t) { - t = { - hasVariantColumn: false, - constraintNamespace: new Set(), - variantConstraints: new Set(), - activeChecks: new Map(), - activeFks: new Set(), - }; + t = newTableState(); tables.set(key, t); } return t; @@ -773,6 +801,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { tbl.variantConstraints.delete(fkName); } tbl.activeFks.clear(); + tbl.validatedFks.clear(); + tbl.fkAddedFiles.clear(); + tbl.fkValidatedFiles.clear(); } } @@ -806,7 +837,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } const key = tableKey(ref); - if (key !== STUDIES_TABLE_KEY && !tables.has(key)) { + if (key !== STUDIES_TABLE_KEY && key !== VARIANTS_TABLE_KEY && !tables.has(key)) { continue; } @@ -853,20 +884,49 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } if (action[cIdx]?.type === 'word' || action[cIdx]?.type === 'ident') { const name = action[cIdx].value; + if ( + key === STUDIES_TABLE_KEY && + currentTable.activeChecks.has(name) && + !hasValidatedVariantDomain() + ) { + unsafeStudyConstraintTransition = true; + } currentTable.constraintNamespace.delete(name); currentTable.variantConstraints.delete(name); currentTable.activeChecks.delete(name); + currentTable.activeCodeChecks.delete(name); currentTable.activeFks.delete(name); + currentTable.validatedFks.delete(name); + currentTable.fkAddedFiles.delete(name); + currentTable.fkValidatedFiles.delete(name); } continue; } - // Action B: RENAME CONSTRAINT TO + // Action B: VALIDATE CONSTRAINT + if (action[0].value === 'validate' && action[1]?.value === 'constraint') { + const name = action[2]?.value; + const codeCheck = name === undefined ? undefined : currentTable.activeCodeChecks.get(name); + if (codeCheck !== undefined) { + codeCheck.validated = true; + codeCheck.validatedFile = file; + } + if (name !== undefined && currentTable.activeFks.has(name)) { + currentTable.validatedFks.add(name); + currentTable.fkValidatedFiles.set(name, file); + } + continue; + } + + // Action C: RENAME CONSTRAINT TO if (action[0].value === 'rename' && action[1]?.value === 'constraint') { const oldName = action[2]?.value; - if (oldName && currentTable.activeChecks.has(oldName)) { + if ( + oldName && + (currentTable.activeChecks.has(oldName) || currentTable.activeCodeChecks.has(oldName)) + ) { throw new Error( - `${file} renames the constraint governing \`studies.variant\` ` + + `${file} renames a constraint governing the variant domain ` + `(\`${oldName}\`). Teach this guard \`RENAME CONSTRAINT\` rather than leaving it ` + `tracking a name nothing answers to.`, ); @@ -885,11 +945,23 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.activeFks.delete(oldName); currentTable.activeFks.add(newName); } + if (currentTable.validatedFks.has(oldName)) { + currentTable.validatedFks.delete(oldName); + currentTable.validatedFks.add(newName); + } + if (currentTable.fkAddedFiles.has(oldName)) { + currentTable.fkAddedFiles.set(newName, currentTable.fkAddedFiles.get(oldName)); + currentTable.fkAddedFiles.delete(oldName); + } + if (currentTable.fkValidatedFiles.has(oldName)) { + currentTable.fkValidatedFiles.set(newName, currentTable.fkValidatedFiles.get(oldName)); + currentTable.fkValidatedFiles.delete(oldName); + } } continue; } - // Action C: DROP [COLUMN] [IF EXISTS] + // Action D: DROP [COLUMN] [IF EXISTS] if (action[0].value === 'drop') { let cIdx = 1; if (action[cIdx]?.value === 'column') cIdx++; @@ -902,11 +974,21 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.variantConstraints.clear(); currentTable.activeChecks.clear(); currentTable.activeFks.clear(); + currentTable.validatedFks.clear(); + currentTable.fkAddedFiles.clear(); + currentTable.fkValidatedFiles.clear(); + } + if (action[cIdx]?.value === 'code') { + currentTable.hasCodeColumn = false; + for (const name of currentTable.activeCodeChecks.keys()) { + currentTable.constraintNamespace.delete(name); + } + currentTable.activeCodeChecks.clear(); } continue; } - // Action D: RENAME [COLUMN] TO + // Action E: RENAME [COLUMN] TO if (action[0].value === 'rename') { let cIdx = 1; if (action[cIdx]?.value === 'column') cIdx++; @@ -915,11 +997,18 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.variantConstraints.clear(); currentTable.activeChecks.clear(); currentTable.activeFks.clear(); + currentTable.validatedFks.clear(); + currentTable.fkAddedFiles.clear(); + currentTable.fkValidatedFiles.clear(); + } + if (action[cIdx]?.value === 'code' && action[cIdx + 1]?.value === 'to') { + currentTable.hasCodeColumn = false; + currentTable.activeCodeChecks.clear(); } continue; } - // Action E: ADD [COLUMN] [IF NOT EXISTS] variant ... + // Action F: ADD [COLUMN] [IF NOT EXISTS] variant/code ... let colIdx = 0; if (action[colIdx]?.value === 'add') colIdx++; if (action[colIdx]?.value === 'column') colIdx++; @@ -933,11 +1022,26 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { continue; } currentTable.hasVariantColumn = true; + const previousFks = new Set(currentTable.activeFks); scanColumnConstraints(action.slice(colIdx), file, currentTable.constraintNamespace, currentTable.variantConstraints, currentTable.activeChecks, currentTable.activeFks); + for (const name of currentTable.activeFks) { + if (previousFks.has(name)) continue; + currentTable.validatedFks.add(name); + currentTable.fkAddedFiles.set(name, file); + currentTable.fkValidatedFiles.set(name, file); + if (key === STUDIES_TABLE_KEY && !hasValidatedVariantDomain()) { + unsafeStudyConstraintTransition = true; + } + } + continue; + } + if (action[colIdx]?.value === 'code') { + if (currentTable.hasCodeColumn && isColIfNotExists) continue; + currentTable.hasCodeColumn = true; continue; } - // Action F: Table-level ADD [CONSTRAINT ] CHECK (variant IN (...)) or FOREIGN KEY + // Action G: Table-level ADD [CONSTRAINT ] CHECK or FOREIGN KEY let explicitName = null; let aIdx = 0; if (action[aIdx]?.value === 'add') aIdx++; @@ -948,6 +1052,64 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { let handled = false; + // Search for the table-level variants(code) domain CHECK. + if (key === VARIANTS_TABLE_KEY) { + for (let i = 0; i < action.length; i++) { + if (action[i].value !== 'check' || action[i + 1]?.value !== '(') continue; + let depth = 1; + let endIdx = i + 2; + while (endIdx < action.length && depth > 0) { + if (action[endIdx].value === '(') depth++; + else if (action[endIdx].value === ')') depth--; + endIdx++; + } + const checkTokens = action.slice(i + 2, endIdx - 1); + const referencesCode = checkTokens.some( + (token) => + (token.type === 'word' || token.type === 'ident') && token.value === 'code', + ); + if (!referencesCode) continue; + const predicateIndex = i + 2; + if ( + action[predicateIndex]?.value !== 'code' || + action[predicateIndex + 1]?.value !== 'in' || + action[predicateIndex + 2]?.value !== '(' + ) { + throw new Error( + `${file} defines an unsupported CHECK predicate shape on \`variants.code\`. ` + + `Teach this guard that predicate rather than ignoring the domain constraint.`, + ); + } + const parsedIn = parseStrictVariantInList(action, predicateIndex + 3); + if (parsedIn === null || action[parsedIn.nextIndex]?.value !== ')') { + throw new Error( + `${file} defines an unsupported CHECK predicate shape on \`variants.code\`. ` + + `Teach this guard that predicate rather than extracting a partial domain.`, + ); + } + const suffix = action.slice(parsedIn.nextIndex + 1); + const addedNotValid = + suffix.length === 2 && suffix[0]?.value === 'not' && suffix[1]?.value === 'valid'; + if (suffix.length !== 0 && !addedNotValid) { + throw new Error( + `${file} adds an unsupported suffix to the \`variants.code\` CHECK constraint.`, + ); + } + const name = + explicitName ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'variants_code_check'); + currentTable.constraintNamespace.add(name); + currentTable.activeCodeChecks.set(name, { + file, + name, + variants: parsedIn.variants, + validated: !addedNotValid, + addedNotValid, + validatedFile: addedNotValid ? null : file, + }); + handled = true; + } + } + // Search for table-level CHECK (variant ...) for (let i = 0; i < action.length; i++) { if (action[i].value === 'check' && action[i + 1]?.value === '(') { @@ -1017,6 +1179,17 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.constraintNamespace.add(name); currentTable.variantConstraints.add(name); currentTable.activeFks.add(name); + currentTable.fkAddedFiles.set(name, file); + const suffix = action.slice(afterRef + 3); + const addedNotValid = + suffix.length === 2 && suffix[0]?.value === 'not' && suffix[1]?.value === 'valid'; + if (!addedNotValid) { + currentTable.validatedFks.add(name); + currentTable.fkValidatedFiles.set(name, file); + } + if (key === STUDIES_TABLE_KEY && !hasValidatedVariantDomain()) { + unsafeStudyConstraintTransition = true; + } handled = true; } } @@ -1049,7 +1222,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { } const key = tableKey(ref); - if (key !== STUDIES_TABLE_KEY && !tables.has(key)) { + if (key !== STUDIES_TABLE_KEY && key !== VARIANTS_TABLE_KEY && !tables.has(key)) { continue; } @@ -1057,13 +1230,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { continue; } - const currentTable = { - hasVariantColumn: false, - constraintNamespace: new Set(), - variantConstraints: new Set(), - activeChecks: new Map(), - activeFks: new Set(), - }; + const currentTable = newTableState(); tables.set(key, currentTable); const openParen = stmt.findIndex((t) => t.type === 'punct' && t.value === '('); @@ -1081,6 +1248,10 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { scanColumnConstraints(clause, file, currentTable.constraintNamespace, currentTable.variantConstraints, currentTable.activeChecks, currentTable.activeFks); continue; } + if (clause[0]?.value === 'code') { + currentTable.hasCodeColumn = true; + continue; + } let explicitName = null; let cIdx = 0; @@ -1172,16 +1343,43 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.constraintNamespace.add(explicitName); } } + + if (key === STUDIES_TABLE_KEY) { + for (const name of currentTable.activeFks) { + currentTable.validatedFks.add(name); + currentTable.fkAddedFiles.set(name, file); + currentTable.fkValidatedFiles.set(name, file); + } + if (currentTable.activeFks.size > 0 && !hasValidatedVariantDomain()) { + unsafeStudyConstraintTransition = true; + } + } } } } const studiesTable = tables.get(STUDIES_TABLE_KEY); + const variantsTable = tables.get(VARIANTS_TABLE_KEY); + const variantDomainChecks = + variantsTable === undefined ? [] : Array.from(variantsTable.activeCodeChecks.values()); + const foreignKeys = + studiesTable === undefined + ? [] + : Array.from(studiesTable.activeFks).map((name) => ({ + name, + file: studiesTable.fkAddedFiles.get(name) ?? '', + validated: studiesTable.validatedFks.has(name), + validatedFile: studiesTable.fkValidatedFiles.get(name) ?? null, + })); + if (!studiesTable) { return { check: null, checks: [], hasForeignKey: false, + foreignKeys, + variantDomainChecks, + unsafeStudyConstraintTransition, }; } @@ -1194,6 +1392,9 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { check: latestCheck, checks: Array.from(studiesTable.activeChecks.values()), hasForeignKey: studiesTable.activeFks.size > 0, + foreignKeys, + variantDomainChecks, + unsafeStudyConstraintTransition, }; } @@ -1278,14 +1479,24 @@ export function collectMirrors(dir = MIGRATIONS_DIR) { variants: effectiveLookupVariants(dir), }); const replayed = replayStudiesSchema(dir); - for (const checkItem of replayed.checks) { + for (const checkItem of replayed.variantDomainChecks) { mirrors.push({ - label: `\`studies.variant\` CHECK constraint (${checkItem.name}), after all migrations`, + label: `\`variants.code\` CHECK constraint (${checkItem.name}), after all migrations`, file: join(dir, checkItem.file), variants: checkItem.variants, }); } - return { mirrors, studyConstraint: replayed.check, hasStudyVariantFk: replayed.hasForeignKey }; + return { + mirrors, + studyConstraint: replayed.check, + studyChecks: replayed.checks, + hasStudyVariantFk: replayed.hasForeignKey, + studyForeignKeys: replayed.foreignKeys, + variantDomainConstraint: + replayed.variantDomainChecks.length === 1 ? replayed.variantDomainChecks[0] : null, + variantDomainConstraints: replayed.variantDomainChecks, + unsafeStudyConstraintTransition: replayed.unsafeStudyConstraintTransition, + }; } /** @@ -1301,7 +1512,17 @@ export function collectMirrors(dir = MIGRATIONS_DIR) { */ export function evaluateParity(dir = MIGRATIONS_DIR) { const root = extractRegion(ROOT); - const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(dir); + const collected = collectMirrors(dir); + const { + mirrors, + studyConstraint, + studyChecks, + hasStudyVariantFk, + studyForeignKeys, + variantDomainConstraint, + variantDomainConstraints, + unsafeStudyConstraintTransition, + } = collected; const failures = []; for (const mirror of mirrors) { @@ -1311,16 +1532,54 @@ export function evaluateParity(dir = MIGRATIONS_DIR) { } } - if (studyConstraint === null && !hasStudyVariantFk) { - failures.push('`studies.variant` has no CHECK constraint and no foreign key referencing `variants(code)`'); + if (variantDomainConstraints.length === 0) { + failures.push('`variants.code` has no CHECK constraint enforcing the canonical variant domain'); + } else if (variantDomainConstraints.length > 1) { + failures.push('`variants.code` has multiple active domain CHECK constraints'); + } + if (variantDomainConstraint !== null) { + if (!variantDomainConstraint.addedNotValid) { + failures.push('`variants.code` domain CHECK was not added with `NOT VALID`'); + } + if (!variantDomainConstraint.validated) { + failures.push('`variants.code` domain CHECK is not validated'); + } else if (variantDomainConstraint.validatedFile === variantDomainConstraint.file) { + failures.push('`variants.code` domain CHECK must be validated in a later migration'); + } + } + + if (studyChecks.length > 0) { + failures.push( + `\`studies.variant\` retains obsolete CHECK constraint(s): ${studyChecks.map((check) => check.name).join(', ')}`, + ); + } + if (!hasStudyVariantFk) { + failures.push('`studies.variant` has no foreign key referencing `variants(code)`'); + } else if (studyForeignKeys.length > 1) { + failures.push('`studies.variant` has multiple active foreign keys referencing `variants(code)`'); + } else if (studyForeignKeys[0]?.validated !== true) { + failures.push('`studies.variant` foreign key referencing `variants(code)` is not validated'); + } else if (studyForeignKeys[0]?.validatedFile === studyForeignKeys[0]?.file) { + failures.push('`studies.variant` foreign key must be validated in a later migration'); + } + if (unsafeStudyConstraintTransition) { + failures.push( + '`studies.variant` protection changed before the variants domain CHECK was validated', + ); } - return { failures, mirrors, studyConstraint, hasStudyVariantFk }; + return { failures, ...collected }; } function main() { const root = extractRegion(ROOT); - const { failures, mirrors, studyConstraint, hasStudyVariantFk } = evaluateParity(); + const { + failures, + mirrors, + studyConstraint, + hasStudyVariantFk, + variantDomainConstraint, + } = evaluateParity(); console.log(`root: ${root.label} (${root.file})`); console.log(` ${root.variants.join(', ')}\n`); @@ -1334,18 +1593,23 @@ function main() { } } + if (variantDomainConstraint !== null && variantDomainConstraint.validated) { + console.log(' ok `variants.code` domain CHECK is validated'); + } + if (studyConstraint === null) { if (hasStudyVariantFk) { console.log( ' -- `studies.variant` has no CHECK left; it derives from `variants(code)`, nothing to compare', ); - } else { - console.log( - ' FAIL `studies.variant` has no CHECK constraint and no foreign key referencing `variants(code)`', - ); } } + const invariantFailures = failures.filter( + (failure) => !mirrors.some((mirror) => failure.startsWith(`${mirror.label}:`)), + ); + for (const failure of invariantFailures) console.log(` FAIL ${failure}`); + if (failures.length > 0) { console.log( `\nThe supported-variant list disagrees with ${root.label} in ${failures.length} place(s).\n` + diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index cc44ea94..66a0a23d 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -91,6 +91,24 @@ INSERT INTO variants (code, name) VALUES } }); +test('an idempotent canonical re-seed preserves lookup set semantics', () => { + const dir = migrations({ + '0001_init.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY, name TEXT NOT NULL); +INSERT INTO variants (code, name) VALUES + ('standard', 'Standard'), + ('atomic', 'Atomic');`, + '0028_reseed.sql': `INSERT INTO variants (code, name) VALUES + ('standard', 'Standard'), + ('atomic', 'Atomic') +ON CONFLICT (code) DO NOTHING;`, + }); + try { + assert.deepEqual(effectiveLookupVariants(dir), ['standard', 'atomic']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('migrations replay in the order the runner applies them, which is lexicographic', () => { // `pg/migrate.ts` sorts with a plain `.sort()`, so that is the order the database ends up in and // the order this must model. Zero-padded names make lexicographic and numeric order coincide, so @@ -1204,16 +1222,22 @@ test('parity evaluation succeeds when surviving FK provides integrity after CHEC INSERT INTO variants (code) VALUES ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, - '0002_studies.sql': `CREATE TABLE studies ( + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0003_validate_domain.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0004_studies.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL CONSTRAINT chk_v CHECK (variant IN ('standard', 'atomic')) - CONSTRAINT fk_v REFERENCES variants(code) );`, - '0003_drop_chk.sql': `ALTER TABLE studies DROP CONSTRAINT chk_v;`, + '0005_add_fk.sql': `ALTER TABLE studies ADD CONSTRAINT fk_v + FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID;`, + '0006_drop_chk.sql': `ALTER TABLE studies DROP CONSTRAINT chk_v;`, + '0007_validate_fk.sql': `ALTER TABLE studies VALIDATE CONSTRAINT fk_v;`, }); try { - const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(dir); + const { failures, mirrors, studyConstraint, hasStudyVariantFk } = evaluateParity(dir); + assert.deepEqual(failures, []); assert.equal(studyConstraint, null); assert.equal(hasStudyVariantFk, true); const lookupMirror = mirrors.find((m) => m.label.includes('variants')); @@ -1224,12 +1248,15 @@ INSERT INTO variants (code) VALUES } }); -test('parity evaluation succeeds when surviving CHECK provides integrity after FK drop', () => { +test('parity evaluation rejects a surviving studies CHECK even when its values match Variant', () => { const dir = migrations({ '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); INSERT INTO variants (code) VALUES ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), - ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings'); +ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID; +ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, '0002_studies.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL @@ -1239,37 +1266,154 @@ INSERT INTO variants (code) VALUES '0003_drop_fk.sql': `ALTER TABLE studies DROP CONSTRAINT fk_v;`, }); try { - const { mirrors, studyConstraint, hasStudyVariantFk } = collectMirrors(dir); + const { failures, studyConstraint, hasStudyVariantFk } = evaluateParity(dir); assert.notEqual(studyConstraint, null); assert.equal(hasStudyVariantFk, false); - const studyMirror = mirrors.find((m) => m.label.includes('studies.variant') && m.label.includes('CHECK')); - assert.notEqual(studyMirror, undefined); - assert.deepEqual(disagreements(extractRegion(ROOT).variants, studyMirror?.variants ?? []), []); + assert.ok( + failures.some((failure) => /retains obsolete CHECK constraint/.test(failure)), + failures.join('\n'), + ); + assert.ok( + failures.some((failure) => /has no foreign key referencing `variants\(code\)`/.test(failure)), + failures.join('\n'), + ); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test('parity evaluation flags failure when both CHECK and FK are removed from studies.variant', () => { +test('parity evaluation detects seed and domain disagreement independently', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('racingkings');`, + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0003_validate_domain.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0004_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + }); + try { + const { failures } = evaluateParity(dir); + assert.ok( + failures.some((failure) => /variants.*lookup table.*missing `horde`/.test(failure)), + failures.join('\n'), + ); + assert.ok( + !failures.some((failure) => /variants\.code.*missing `horde`/.test(failure)), + failures.join('\n'), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation requires a validated variants domain CHECK matching Variant', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0003_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + }); + try { + const { failures, variantDomainConstraint } = evaluateParity(dir); + assert.equal(variantDomainConstraint?.name, 'variants_code_check'); + assert.equal(variantDomainConstraint?.validated, false); + assert.ok( + failures.some((failure) => /variants\.code.*not validated/.test(failure)), + failures.join('\n'), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation detects missing and unknown values in variants domain CHECK', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'antichess', 'racingkings')) NOT VALID; +ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0003_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + );`, + }); + try { + const { failures } = evaluateParity(dir); + assert.ok( + failures.some((failure) => /variants\.code.*missing `horde`.*unknown `antichess`/.test(failure)), + failures.join('\n'), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation detects studies CHECK removal before variants domain validation', () => { const dir = migrations({ '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); INSERT INTO variants (code) VALUES ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, '0002_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) + );`, + '0003_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0004_unsafe.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_check; +ALTER TABLE studies ADD CONSTRAINT studies_variant_fk FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID;`, + '0005_too_late.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check; +ALTER TABLE studies VALIDATE CONSTRAINT studies_variant_fk;`, + }); + try { + const { failures, unsafeStudyConstraintTransition } = evaluateParity(dir); + assert.equal(unsafeStudyConstraintTransition, true); + assert.ok( + failures.some((failure) => /before the variants domain CHECK was validated/.test(failure)), + failures.join('\n'), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation flags failure when both CHECK and FK are removed from studies.variant', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0003_validate_domain.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0004_studies.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL CONSTRAINT chk_v CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) CONSTRAINT fk_v REFERENCES variants(code) );`, - '0003_drop_both.sql': `ALTER TABLE studies DROP CONSTRAINT chk_v, DROP CONSTRAINT fk_v;`, + '0005_drop_both.sql': `ALTER TABLE studies DROP CONSTRAINT chk_v, DROP CONSTRAINT fk_v;`, }); try { const { failures, studyConstraint, hasStudyVariantFk } = evaluateParity(dir); assert.equal(studyConstraint, null); assert.equal(hasStudyVariantFk, false); assert.equal(failures.length, 1); - assert.match(failures[0], /`studies\.variant` has no CHECK constraint and no foreign key referencing `variants\(code\)`/); + assert.match(failures[0], /`studies\.variant` has no foreign key referencing `variants\(code\)`/); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1757,11 +1901,14 @@ test('end-to-end parity failure when colliding-under-old-model table has variant INSERT INTO variants (code) VALUES ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, - '0002_unconstrained_studies.sql': `CREATE TABLE studies ( + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0003_validate_domain.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0004_unconstrained_studies.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL );`, - '0003_colliding_archive.sql': `CREATE TABLE "public.studies" ( + '0005_colliding_archive.sql': `CREATE TABLE "public.studies" ( id UUID PRIMARY KEY, variant TEXT NOT NULL REFERENCES variants(code) );`, @@ -1771,7 +1918,7 @@ INSERT INTO variants (code) VALUES assert.equal(studyConstraint, null); assert.equal(hasStudyVariantFk, false); assert.equal(failures.length, 1); - assert.match(failures[0], /`studies\.variant` has no CHECK constraint and no foreign key referencing `variants\(code\)`/); + assert.match(failures[0], /`studies\.variant` has no foreign key referencing `variants\(code\)`/); } finally { rmSync(dir, { recursive: true, force: true }); } From 913330a98fef3a225a8a8376390c64fb1301ebe3 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 2 Sep 2026 10:36:37 +0300 Subject: [PATCH 35/39] fix(persistence): harden variant transition review --- .../test/studies.integration.test.ts | 4 ++ .../variant-migrations.integration.test.ts | 29 +++++++-- scripts/check-variant-parity.mjs | 38 ++++++++++-- scripts/test/check-variant-parity.test.mjs | 61 +++++++++++++++++++ 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/packages/persistence/test/studies.integration.test.ts b/packages/persistence/test/studies.integration.test.ts index 39588f54..4c40cf6f 100644 --- a/packages/persistence/test/studies.integration.test.ts +++ b/packages/persistence/test/studies.integration.test.ts @@ -13,10 +13,12 @@ const DATABASE_URL = process.env['DATABASE_URL']; const skip = DATABASE_URL ? false : 'DATABASE_URL not set'; class CorePositionReader implements PositionReader { + /** Returns legal SAN moves for the supplied position and variant. */ legalSans(fen: string, variant: StudyVariant = 'standard'): readonly string[] { const pos = Position.fromFen(fen, variant); return pos.legalMoves().map((m) => pos.toSan(m)); } + /** Applies one legal SAN move and returns the resulting FEN. */ play(fen: string, san: string, variant: StudyVariant = 'standard'): string { const pos = Position.fromFen(fen, variant); const moves = pos.legalMoves(); @@ -33,6 +35,7 @@ interface PgErrorShape { constraint?: string; } +/** Identifies a PostgreSQL constraint violation without assuming every thrown value is an Error. */ function isPgConstraintViolation(err: unknown, code: string, constraint?: string): boolean { if (typeof err !== 'object' || err === null) return false; const pgErr = err as PgErrorShape; @@ -49,6 +52,7 @@ test('pg studies repository integration tests', { skip }, async () => { const reader = new CorePositionReader(); const createdUserIds: string[] = []; + /** Inserts and tracks a disposable integration-test user. */ const createUser = async (name: string): Promise => { const id = uuidv7(); const handle = `usr-${name.toLowerCase()}-${id.slice(0, 8)}`; diff --git a/packages/persistence/test/variant-migrations.integration.test.ts b/packages/persistence/test/variant-migrations.integration.test.ts index dd23f477..daae0865 100644 --- a/packages/persistence/test/variant-migrations.integration.test.ts +++ b/packages/persistence/test/variant-migrations.integration.test.ts @@ -33,18 +33,21 @@ interface ConstraintRow { readonly definition: string; } +/** Returns whether an unknown thrown value is the expected PostgreSQL constraint violation. */ function isConstraintViolation(error: unknown, code: string, constraint: string): boolean { if (typeof error !== 'object' || error === null) return false; const pgError = error as PgErrorShape; return pgError.code === code && pgError.constraint === constraint; } +/** Replaces the database path in the configured PostgreSQL connection URL. */ function databaseUrlFor(database: string): string { const url = new URL(DATABASE_URL!); url.pathname = `/${database}`; return url.toString(); } +/** Copies migrations through the requested version into a disposable directory. */ function migrationsThrough(version: number): { readonly dir: string; cleanup(): void } { const dir = mkdtempSync(join(tmpdir(), `variant-migrations-${version}-`)); for (const migration of migrationFiles(MIGRATIONS_DIR)) { @@ -54,20 +57,32 @@ function migrationsThrough(version: number): { readonly dir: string; cleanup(): return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } +/** Runs a callback in an isolated database and always releases its pools and database. */ async function withDatabase(run: (pool: Pool) => Promise): Promise { const admin = createPool({ connectionString: DATABASE_URL, max: 2 }); const database = `variant_migration_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const pool = createPool({ connectionString: databaseUrlFor(database), max: 4 }); + let databaseCreated = false; try { - await run(pool); + await admin.query(`CREATE DATABASE "${database}"`); + databaseCreated = true; + const pool = createPool({ connectionString: databaseUrlFor(database), max: 4 }); + try { + await run(pool); + } finally { + await pool.end(); + } } finally { - await pool.end(); - await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); - await admin.end(); + try { + if (databaseCreated) { + await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); + } + } finally { + await admin.end(); + } } } +/** Reads one named constraint from PostgreSQL's catalog. */ async function constraint( pool: Pool, table: 'variants' | 'studies', @@ -82,6 +97,7 @@ async function constraint( return found.rows[0]; } +/** Inserts the minimum user row needed to own a study and returns its id. */ async function insertUser(pool: Pool): Promise { const id = randomUUID(); await pool.query( @@ -91,6 +107,7 @@ async function insertUser(pool: Pool): Promise { return id; } +/** Inserts a study with the requested variant and returns its id. */ async function insertStudy(pool: Pool, ownerId: string, variant: string): Promise { const id = randomUUID(); await pool.query( diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index a24689b6..8b4ed8d2 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -697,6 +697,8 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra * }} Effective variant-domain and studies constraint state. */ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { + const canonicalVariants = extractRegion(ROOT).variants; + const canonicalVariantSet = new Set(canonicalVariants); /** * @type {Map check.validated); + Array.from(variantsTable.activeCodeChecks.values()).some( + (check) => check.validated && disagreements(canonicalVariants, check.variants).length === 0, + ); } + /** Whether an active studies CHECK excludes every non-canonical variant. */ + function hasSafeStudyCheck(table) { + return Array.from(table.activeChecks.values()).some( + (check) => + check.variants.length > 0 && check.variants.every((variant) => canonicalVariantSet.has(variant)), + ); + } + + /** Returns existing replay state for a table or creates it on first use. */ function getOrCreateTable(key) { let t = tables.get(key); if (!t) { @@ -1029,7 +1044,11 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.validatedFks.add(name); currentTable.fkAddedFiles.set(name, file); currentTable.fkValidatedFiles.set(name, file); - if (key === STUDIES_TABLE_KEY && !hasValidatedVariantDomain()) { + if ( + key === STUDIES_TABLE_KEY && + !hasValidatedVariantDomain() && + !hasSafeStudyCheck(currentTable) + ) { unsafeStudyConstraintTransition = true; } } @@ -1187,7 +1206,11 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.validatedFks.add(name); currentTable.fkValidatedFiles.set(name, file); } - if (key === STUDIES_TABLE_KEY && !hasValidatedVariantDomain()) { + if ( + key === STUDIES_TABLE_KEY && + !hasValidatedVariantDomain() && + !hasSafeStudyCheck(currentTable) + ) { unsafeStudyConstraintTransition = true; } handled = true; @@ -1350,7 +1373,11 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.fkAddedFiles.set(name, file); currentTable.fkValidatedFiles.set(name, file); } - if (currentTable.activeFks.size > 0 && !hasValidatedVariantDomain()) { + if ( + currentTable.activeFks.size > 0 && + !hasValidatedVariantDomain() && + !hasSafeStudyCheck(currentTable) + ) { unsafeStudyConstraintTransition = true; } } @@ -1571,6 +1598,7 @@ export function evaluateParity(dir = MIGRATIONS_DIR) { return { failures, ...collected }; } +/** Prints the parity report and exits unsuccessfully when any invariant disagrees. */ function main() { const root = extractRegion(ROOT); const { @@ -1606,7 +1634,7 @@ function main() { } const invariantFailures = failures.filter( - (failure) => !mirrors.some((mirror) => failure.startsWith(`${mirror.label}:`)), + (failure) => !mirrors.some((mirror) => failure.startsWith(`${mirror.label} (`)), ); for (const failure of invariantFailures) console.log(` FAIL ${failure}`); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 66a0a23d..0834a9db 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -49,6 +49,7 @@ test('a commented-out variant does not count as present', () => { // The defect this replaced: quoted tokens were matched in raw source, so commenting an entry out // left the guard green while the executable array no longer held it. Raised in the Qodo review of // PR #141. + /** Extracts variants from a synthetic TypeScript declaration body. */ const region = (body) => extractRegion({ label: 'test', @@ -1391,6 +1392,66 @@ ALTER TABLE studies VALIDATE CONSTRAINT studies_variant_fk;`, } }); +test('parity evaluation permits staging the studies FK while its canonical CHECK remains active', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) + );`, + '0003_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0004_stage_fk.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fk + FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID;`, + '0005_validate_domain.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0006_replace_check.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_check;`, + '0007_validate_fk.sql': `ALTER TABLE studies VALIDATE CONSTRAINT studies_variant_fk;`, + }); + try { + const { failures, unsafeStudyConstraintTransition } = evaluateParity(dir); + assert.equal(unsafeStudyConstraintTransition, false); + assert.deepEqual(failures, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation rejects a broad validated domain CHECK as transition authorization', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_broad_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_legacy_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings', 'antichess')) NOT VALID; +ALTER TABLE variants VALIDATE CONSTRAINT variants_code_legacy_check;`, + '0003_canonical_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0004_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) + );`, + '0005_unsafe.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_check; +ALTER TABLE studies ADD CONSTRAINT studies_variant_fk FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID;`, + '0006_too_late.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check; +ALTER TABLE variants DROP CONSTRAINT variants_code_legacy_check; +ALTER TABLE studies VALIDATE CONSTRAINT studies_variant_fk;`, + }); + try { + const { failures, unsafeStudyConstraintTransition } = evaluateParity(dir); + assert.equal(unsafeStudyConstraintTransition, true); + assert.ok( + failures.some((failure) => /before the variants domain CHECK was validated/.test(failure)), + failures.join('\n'), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('parity evaluation flags failure when both CHECK and FK are removed from studies.variant', () => { const dir = migrations({ '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); From 172df30d92532ac8284ae456df4559a3a80911ce Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 2 Sep 2026 12:30:31 +0300 Subject: [PATCH 36/39] fix(scripts): parse variant constraint options --- scripts/check-variant-parity.mjs | 128 +++++++++++++++++++-- scripts/test/check-variant-parity.test.mjs | 96 ++++++++++++++++ 2 files changed, 215 insertions(+), 9 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index 8b4ed8d2..ed57f28f 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -601,6 +601,123 @@ function parseStrictVariantInList(tokens, startIndex) { return null; } +/** + * Parses the ALTER TABLE suffix allowed after a table-level CHECK constraint. + * + * `NO INHERIT` belongs to the CHECK definition, while `NOT VALID` belongs to the ALTER TABLE + * action. `NOT ENFORCED` is deliberately rejected because it would not protect new writes. + * + * @param {SqlToken[]} tokens Tokens following the CHECK expression. + * @param {string} file Current migration filename. + * @param {string} target Human-readable constrained column. + * @returns {boolean} Whether the constraint was added NOT VALID. + */ +function parseCheckAddSuffix(tokens, file, target) { + let idx = 0; + if (tokens[idx]?.value === 'no' && tokens[idx + 1]?.value === 'inherit') idx += 2; + if (tokens[idx]?.value === 'enforced') idx++; + if (tokens[idx]?.value === 'not' && tokens[idx + 1]?.value === 'enforced') { + throw new Error(`${file} adds a NOT ENFORCED constraint on \`${target}\`, which cannot protect writes.`); + } + const addedNotValid = tokens[idx]?.value === 'not' && tokens[idx + 1]?.value === 'valid'; + if (addedNotValid) idx += 2; + if (idx !== tokens.length) { + throw new Error(`${file} adds an unsupported suffix to the \`${target}\` CHECK constraint.`); + } + return addedNotValid; +} + +/** + * Parses PostgreSQL's optional table-level foreign-key attributes and trailing NOT VALID marker. + * + * The parser stays fail-closed: every token must belong to a supported MATCH, referential-action, + * deferrability, timing, enforcement, or validation clause. `NOT ENFORCED` is rejected because the + * parity invariant requires the FK to protect new writes. + * + * @param {SqlToken[]} tokens Tokens following the referenced column list. + * @param {string} file Current migration filename. + * @returns {boolean} Whether the foreign key was added NOT VALID. + */ +function parseForeignKeyAddSuffix(tokens, file) { + let idx = 0; + let addedNotValid = false; + const seen = new Set(); + + while (idx < tokens.length) { + const token = tokens[idx]?.value; + let clause = null; + + if (token === 'match') { + clause = 'match'; + if (!['full', 'partial', 'simple'].includes(tokens[idx + 1]?.value)) break; + idx += 2; + } else if (token === 'on' && ['delete', 'update'].includes(tokens[idx + 1]?.value)) { + clause = `on ${tokens[idx + 1].value}`; + idx += 2; + const action = tokens[idx]?.value; + if (action === 'no' && tokens[idx + 1]?.value === 'action') { + idx += 2; + } else if (action === 'restrict' || action === 'cascade') { + idx++; + } else if (action === 'set' && ['null', 'default'].includes(tokens[idx + 1]?.value)) { + idx += 2; + if (tokens[idx]?.value === '(') { + idx++; + let expectColumn = true; + while (idx < tokens.length && tokens[idx]?.value !== ')') { + const current = tokens[idx]; + if (expectColumn) { + if (current?.type !== 'word' && current?.type !== 'ident') break; + } else if (current?.value !== ',') { + break; + } + expectColumn = !expectColumn; + idx++; + } + if (expectColumn || tokens[idx]?.value !== ')') break; + idx++; + } + } else { + break; + } + } else if (token === 'deferrable') { + clause = 'deferrable'; + idx++; + } else if (token === 'not' && tokens[idx + 1]?.value === 'deferrable') { + clause = 'deferrable'; + idx += 2; + } else if (token === 'initially' && ['deferred', 'immediate'].includes(tokens[idx + 1]?.value)) { + clause = 'initially'; + idx += 2; + } else if (token === 'enforced') { + clause = 'enforced'; + idx++; + } else if (token === 'not' && tokens[idx + 1]?.value === 'enforced') { + throw new Error( + `${file} adds a NOT ENFORCED \`studies.variant\` foreign key, which cannot protect writes.`, + ); + } else if ( + token === 'not' && + tokens[idx + 1]?.value === 'valid' && + idx + 2 === tokens.length + ) { + clause = 'not valid'; + addedNotValid = true; + idx += 2; + } else { + break; + } + + if (seen.has(clause)) break; + seen.add(clause); + } + + if (idx !== tokens.length) { + throw new Error(`${file} adds an unsupported suffix to the \`studies.variant\` foreign key.`); + } + return addedNotValid; +} + /** * Scans a column definition clause for CHECK and REFERENCES constraints on variant. * @@ -1107,13 +1224,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { ); } const suffix = action.slice(parsedIn.nextIndex + 1); - const addedNotValid = - suffix.length === 2 && suffix[0]?.value === 'not' && suffix[1]?.value === 'valid'; - if (suffix.length !== 0 && !addedNotValid) { - throw new Error( - `${file} adds an unsupported suffix to the \`variants.code\` CHECK constraint.`, - ); - } + const addedNotValid = parseCheckAddSuffix(suffix, file, 'variants.code'); const name = explicitName ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'variants_code_check'); currentTable.constraintNamespace.add(name); @@ -1200,8 +1311,7 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { currentTable.activeFks.add(name); currentTable.fkAddedFiles.set(name, file); const suffix = action.slice(afterRef + 3); - const addedNotValid = - suffix.length === 2 && suffix[0]?.value === 'not' && suffix[1]?.value === 'valid'; + const addedNotValid = parseForeignKeyAddSuffix(suffix, file); if (!addedNotValid) { currentTable.validatedFks.add(name); currentTable.fkValidatedFiles.set(name, file); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 0834a9db..854980e9 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1419,6 +1419,102 @@ INSERT INTO variants (code) VALUES } }); +test('parity evaluation recognizes PostgreSQL FK options before trailing NOT VALID', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NOT VALID;`, + '0003_validate_domain.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0004_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) + );`, + '0005_stage_fk.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fk + FOREIGN KEY (variant) REFERENCES variants(code) + MATCH FULL ON DELETE RESTRICT ON UPDATE NO ACTION + DEFERRABLE INITIALLY DEFERRED NOT VALID;`, + '0006_replace_check.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_check;`, + '0007_validate_fk.sql': `ALTER TABLE studies VALIDATE CONSTRAINT studies_variant_fk;`, + }); + try { + const { failures, studyForeignKeys } = evaluateParity(dir); + assert.deepEqual(failures, []); + assert.equal(studyForeignKeys[0]?.file, '0005_stage_fk.sql'); + assert.equal(studyForeignKeys[0]?.validatedFile, '0007_validate_fk.sql'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity evaluation recognizes CHECK NO INHERIT before trailing NOT VALID', () => { + const dir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +INSERT INTO variants (code) VALUES + ('standard'), ('chess960'), ('kingofthehill'), ('atomic'), + ('crazyhouse'), ('threecheck'), ('horde'), ('racingkings');`, + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) NO INHERIT NOT VALID;`, + '0003_validate_domain.sql': `ALTER TABLE variants VALIDATE CONSTRAINT variants_code_check;`, + '0004_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard', 'chess960', 'kingofthehill', 'atomic', 'crazyhouse', 'threecheck', 'horde', 'racingkings')) + );`, + '0005_stage_fk.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fk + FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID;`, + '0006_replace_check.sql': `ALTER TABLE studies DROP CONSTRAINT studies_variant_check;`, + '0007_validate_fk.sql': `ALTER TABLE studies VALIDATE CONSTRAINT studies_variant_fk;`, + }); + try { + const { failures, variantDomainConstraint } = evaluateParity(dir); + assert.deepEqual(failures, []); + assert.equal(variantDomainConstraint?.addedNotValid, true); + assert.equal(variantDomainConstraint?.validatedFile, '0003_validate_domain.sql'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity replay fails closed on an unknown foreign-key suffix', () => { + const dir = migrations({ + '0001_initial.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, + '0002_fk.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fk + FOREIGN KEY (variant) REFERENCES variants(code) MATCH UNKNOWN NOT VALID;`, + }); + try { + assert.throws( + () => replayStudiesSchema(dir), + /unsupported suffix.*studies\.variant.*foreign key/i, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parity replay rejects NOT ENFORCED variant integrity constraints', () => { + const checkDir = migrations({ + '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY);`, + '0002_domain.sql': `ALTER TABLE variants ADD CONSTRAINT variants_code_check + CHECK (code IN ('standard')) NOT ENFORCED NOT VALID;`, + }); + const fkDir = migrations({ + '0001_initial.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); +CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, + '0002_fk.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fk + FOREIGN KEY (variant) REFERENCES variants(code) NOT ENFORCED NOT VALID;`, + }); + try { + assert.throws(() => replayStudiesSchema(checkDir), /NOT ENFORCED.*variants\.code.*protect writes/i); + assert.throws(() => replayStudiesSchema(fkDir), /NOT ENFORCED.*studies\.variant.*protect writes/i); + } finally { + rmSync(checkDir, { recursive: true, force: true }); + rmSync(fkDir, { recursive: true, force: true }); + } +}); + test('parity evaluation rejects a broad validated domain CHECK as transition authorization', () => { const dir = migrations({ '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY); From f4e3c192dc395152bb4e0d63e587e4738a51ecf6 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 2 Sep 2026 12:41:07 +0300 Subject: [PATCH 37/39] fix(scripts): reject unenforced study checks --- scripts/check-variant-parity.mjs | 32 +++++++++++++++++++++ scripts/test/check-variant-parity.test.mjs | 33 ++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index ed57f28f..bf287b17 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -627,6 +627,22 @@ function parseCheckAddSuffix(tokens, file, target) { return addedNotValid; } +/** + * Rejects an inline CHECK that PostgreSQL marks NOT ENFORCED without consuming later constraints. + * + * @param {SqlToken[]} tokens Full column-definition tokens. + * @param {number} startIndex First token after the CHECK expression. + * @param {string} file Current migration filename. + * @param {string} target Human-readable constrained column. + */ +function assertInlineCheckEnforced(tokens, startIndex, file, target) { + let idx = startIndex; + if (tokens[idx]?.value === 'no' && tokens[idx + 1]?.value === 'inherit') idx += 2; + if (tokens[idx]?.value === 'not' && tokens[idx + 1]?.value === 'enforced') { + throw new Error(`${file} adds a NOT ENFORCED constraint on \`${target}\`, which cannot protect writes.`); + } +} + /** * Parses PostgreSQL's optional table-level foreign-key attributes and trailing NOT VALID marker. * @@ -742,6 +758,7 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra const checkTokens = clause.slice(i + 2, endIdx - 1); const referencesVariant = checkTokens.some((t) => (t.type === 'word' || t.type === 'ident') && t.value === 'variant'); if (referencesVariant) { + assertInlineCheckEnforced(clause, endIdx, file, 'studies.variant'); const pIdx = i + 2; if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { let inlineName = null; @@ -1274,6 +1291,11 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { `rather than ignoring suffix expressions.`, ); } + parseCheckAddSuffix( + action.slice(parsedIn.nextIndex + 1), + file, + 'studies.variant', + ); const assignedName = name ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'studies_variant_check'); currentTable.constraintNamespace.add(assignedName); currentTable.variantConstraints.add(assignedName); @@ -1429,6 +1451,16 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { `rather than ignoring suffix expressions.`, ); } + const addedNotValid = parseCheckAddSuffix( + clause.slice(parsedIn.nextIndex + 1), + file, + 'studies.variant', + ); + if (addedNotValid) { + throw new Error( + `${file} adds NOT VALID to a \`studies.variant\` CHECK during CREATE TABLE.`, + ); + } const assignedName = name ?? nextImplicitConstraintName(currentTable.constraintNamespace, 'studies_variant_check'); currentTable.constraintNamespace.add(assignedName); currentTable.variantConstraints.add(assignedName); diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 854980e9..e7ce0f44 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1505,13 +1505,46 @@ test('parity replay rejects NOT ENFORCED variant integrity constraints', () => { CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, '0002_fk.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_fk FOREIGN KEY (variant) REFERENCES variants(code) NOT ENFORCED NOT VALID;`, + }); + const alteredStudyCheckDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, + '0002_check.sql': `ALTER TABLE studies ADD CONSTRAINT studies_variant_check + CHECK (variant IN ('standard')) NOT ENFORCED NOT VALID;`, + }); + const tableStudyCheckDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL, + CONSTRAINT studies_variant_check CHECK (variant IN ('standard')) NOT ENFORCED +);`, + }); + const inlineStudyCheckDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL CHECK (variant IN ('standard')) NOT ENFORCED +);`, }); try { assert.throws(() => replayStudiesSchema(checkDir), /NOT ENFORCED.*variants\.code.*protect writes/i); assert.throws(() => replayStudiesSchema(fkDir), /NOT ENFORCED.*studies\.variant.*protect writes/i); + assert.throws( + () => replayStudiesSchema(alteredStudyCheckDir), + /NOT ENFORCED.*studies\.variant.*protect writes/i, + ); + assert.throws( + () => replayStudiesSchema(tableStudyCheckDir), + /NOT ENFORCED.*studies\.variant.*protect writes/i, + ); + assert.throws( + () => replayStudiesSchema(inlineStudyCheckDir), + /NOT ENFORCED.*studies\.variant.*protect writes/i, + ); } finally { rmSync(checkDir, { recursive: true, force: true }); rmSync(fkDir, { recursive: true, force: true }); + rmSync(alteredStudyCheckDir, { recursive: true, force: true }); + rmSync(tableStudyCheckDir, { recursive: true, force: true }); + rmSync(inlineStudyCheckDir, { recursive: true, force: true }); } }); From ad8089d7d2e31154c81eaf3f1c3aca6eda49df43 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 2 Sep 2026 12:49:43 +0300 Subject: [PATCH 38/39] fix(scripts): bound create table replay --- scripts/check-variant-parity.mjs | 19 ++++++++++++++++++- scripts/test/check-variant-parity.test.mjs | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index bf287b17..b55c61dc 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -1391,7 +1391,24 @@ export function replayStudiesSchema(dir = MIGRATIONS_DIR) { const openParen = stmt.findIndex((t) => t.type === 'punct' && t.value === '('); if (openParen === -1) continue; - const bodyTokens = stmt.slice(openParen + 1); + let closeParen = -1; + let bodyDepth = 0; + for (let i = openParen; i < stmt.length; i++) { + if (stmt[i].type !== 'punct') continue; + if (stmt[i].value === '(') bodyDepth++; + else if (stmt[i].value === ')') { + bodyDepth--; + if (bodyDepth === 0) { + closeParen = i; + break; + } + } + } + if (closeParen === -1) { + throw new Error(`${file} has an unterminated CREATE TABLE column list.`); + } + + const bodyTokens = stmt.slice(openParen + 1, closeParen); const clauses = splitAlterActions(bodyTokens); for (const clause of clauses) { diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index e7ce0f44..49cf5388 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1494,6 +1494,21 @@ CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, } }); +test('CREATE TABLE replay accepts a final table-level studies variant CHECK', () => { + const dir = migrations({ + '0001_studies.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL, + CONSTRAINT studies_variant_check CHECK (variant IN ('standard')) + );`, + }); + try { + assert.deepEqual(replayStudiesSchema(dir).check?.variants, ['standard']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('parity replay rejects NOT ENFORCED variant integrity constraints', () => { const checkDir = migrations({ '0001_variants.sql': `CREATE TABLE variants (code TEXT PRIMARY KEY);`, From 6745f8b7387804296c6abac477137dd1789ee99a Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 2 Sep 2026 13:31:59 +0300 Subject: [PATCH 39/39] fix(scripts): reject unenforced inline foreign keys --- scripts/check-variant-parity.mjs | 34 +++++++++++++++++----- scripts/test/check-variant-parity.test.mjs | 12 ++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/scripts/check-variant-parity.mjs b/scripts/check-variant-parity.mjs index b55c61dc..5b846f42 100644 --- a/scripts/check-variant-parity.mjs +++ b/scripts/check-variant-parity.mjs @@ -627,19 +627,36 @@ function parseCheckAddSuffix(tokens, file, target) { return addedNotValid; } +const INLINE_CONSTRAINT_STARTERS = new Set([ + 'check', + 'constraint', + 'generated', + 'primary', + 'references', + 'unique', +]); + /** - * Rejects an inline CHECK that PostgreSQL marks NOT ENFORCED without consuming later constraints. + * Rejects an inline constraint that PostgreSQL marks NOT ENFORCED without consuming later constraints. * * @param {SqlToken[]} tokens Full column-definition tokens. - * @param {number} startIndex First token after the CHECK expression. + * @param {number} startIndex First token after the inline constraint. * @param {string} file Current migration filename. * @param {string} target Human-readable constrained column. */ -function assertInlineCheckEnforced(tokens, startIndex, file, target) { - let idx = startIndex; - if (tokens[idx]?.value === 'no' && tokens[idx + 1]?.value === 'inherit') idx += 2; - if (tokens[idx]?.value === 'not' && tokens[idx + 1]?.value === 'enforced') { - throw new Error(`${file} adds a NOT ENFORCED constraint on \`${target}\`, which cannot protect writes.`); +function assertInlineConstraintEnforced(tokens, startIndex, file, target) { + for (let idx = startIndex; idx < tokens.length; idx++) { + const token = tokens[idx]?.value; + const next = tokens[idx + 1]?.value; + const previous = tokens[idx - 1]?.value; + if (token === 'not' && next === 'enforced') { + throw new Error(`${file} adds a NOT ENFORCED constraint on \`${target}\`, which cannot protect writes.`); + } + if ( + INLINE_CONSTRAINT_STARTERS.has(token) || + (token === 'not' && next === 'null') || + ((token === 'default' || token === 'null') && previous !== 'set') + ) return; } } @@ -758,7 +775,7 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra const checkTokens = clause.slice(i + 2, endIdx - 1); const referencesVariant = checkTokens.some((t) => (t.type === 'word' || t.type === 'ident') && t.value === 'variant'); if (referencesVariant) { - assertInlineCheckEnforced(clause, endIdx, file, 'studies.variant'); + assertInlineConstraintEnforced(clause, endIdx, file, 'studies.variant'); const pIdx = i + 2; if (clause[pIdx]?.value === 'variant' && clause[pIdx + 1]?.value === 'in' && clause[pIdx + 2]?.value === '(') { let inlineName = null; @@ -800,6 +817,7 @@ function scanColumnConstraints(clause, file, constraintNamespace, variantConstra if (inlineRef && isTableTarget(inlineRef, 'variants')) { const afterRef = inlineRef.nextIndex; if (clause[afterRef]?.value === '(' && clause[afterRef + 1]?.value === 'code' && clause[afterRef + 2]?.value === ')') { + assertInlineConstraintEnforced(clause, afterRef + 3, file, 'studies.variant'); let inlineName = null; if (i >= 2 && clause[i - 2]?.value === 'constraint') { inlineName = clause[i - 1]?.value; diff --git a/scripts/test/check-variant-parity.test.mjs b/scripts/test/check-variant-parity.test.mjs index 49cf5388..4449ac5e 100644 --- a/scripts/test/check-variant-parity.test.mjs +++ b/scripts/test/check-variant-parity.test.mjs @@ -1537,6 +1537,13 @@ CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, '0001_initial.sql': `CREATE TABLE studies ( id UUID PRIMARY KEY, variant TEXT NOT NULL CHECK (variant IN ('standard')) NOT ENFORCED +);`, + }); + const inlineStudyFkDir = migrations({ + '0001_initial.sql': `CREATE TABLE studies ( + id UUID PRIMARY KEY, + variant TEXT NOT NULL REFERENCES variants(code) + ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED NOT ENFORCED );`, }); try { @@ -1554,12 +1561,17 @@ CREATE TABLE studies (id UUID PRIMARY KEY, variant TEXT NOT NULL);`, () => replayStudiesSchema(inlineStudyCheckDir), /NOT ENFORCED.*studies\.variant.*protect writes/i, ); + assert.throws( + () => replayStudiesSchema(inlineStudyFkDir), + /NOT ENFORCED.*studies\.variant.*protect writes/i, + ); } finally { rmSync(checkDir, { recursive: true, force: true }); rmSync(fkDir, { recursive: true, force: true }); rmSync(alteredStudyCheckDir, { recursive: true, force: true }); rmSync(tableStudyCheckDir, { recursive: true, force: true }); rmSync(inlineStudyCheckDir, { recursive: true, force: true }); + rmSync(inlineStudyFkDir, { recursive: true, force: true }); } });