Skip to content

fix(migrate-ts): stop false CHECK drift on Postgres and compare literals as written - #498

Merged
dmealing merged 5 commits into
mainfrom
fm/mo-check-drift-fix
Oct 28, 2026
Merged

dmealing merged 5 commits into
mainfrom
fm/mo-check-drift-fix

Conversation

@dmealing

Copy link
Copy Markdown
Member

What Changed

  • Postgres CHECK comparison folds three shapes back to the spelling migrate emits: a one-element x IN (v) (stored as x = v), a IS NOT DISTINCT FROM b (stored as NOT (a IS DISTINCT FROM b)), and (col)::text on text/varchar columns only. meta verify --db, migrate --from-db and offline migrate --dry-run no longer report false CHECK drift for these shapes.
  • Normalisation runs outside single-quoted literals. Enum member case, regex case and blank runs inside literals now compare as written, so a real change no longer reads as clean. A check stored in a different case than the model declares shows as drift once; meta migrate brings it back. char(n) is no longer treated as text for the (col)::text fold, since its own operator sees the padding. Precedence changes are still not detected.
  • Diff pass threads each table's text-family columns into the rename-pairing and name-only fold checks, so renamed columns and tables compare with the same rules. Tests cover normalisation, offline diff, and a new Postgres round-trip suite that migrates, applies and verifies every generated CHECK shape.
Validation

Risk Assessment

✅ Low: The change is confined to the migrate-ts CHECK-expression comparator and its callers. It meets the intent and the recorded decisions, and its failure modes lean toward reporting drift rather than hiding it.

Testing

Stood up a disposable postgres:16 container and a scratch meta project, built the CLI from dist, and drove meta migrate --apply then meta verify --db end to end. Clean on the generated one-value enum CHECK (st = 'Open') and presentIff CHECK (NOT (kind IS DISTINCT FROM 'principal')). Adversarial: enum case change Open→open and presentIff literal change principal→Principal both report drift; restoring metadata returns to clean. Ran the live-PG suites with MIGRATE_TS_PG_URL: pg-check-shapes-roundtrip, pg-regex-check-cast, lifecycle-pg all pass (15/15), plus the full migrate-ts package (1109 pass, 0 fail). Full server bun test (all packages) did not complete within the 580s foreground limit, so the broad regression run is unfinished; the baseline ts-fast/ts-unit lane was not rerun. Unit-level guards (precedence limit, NOT-distinct nesting, literal-internal IS DISTINCT, bpchar cast) run only inside the migrate-ts unit suite; not driven live through the CLI and recorded as untested.

  • Live validation: ✅ go - 5 of 7 scenarios driven live against the product
Scenario Result Live Evidence
One-value enum CHECK on real PG16 verifies clean (IN ('x') emitted, = 'x' stored) ✅ pass live verify --db schemaDrift changes: [] after migrate --apply of field.enum @values [Open]; stored shape_st_chk CHECK ((st = 'Open'::text))
presentIff CHECK on real PG16 verifies clean (IS NOT DISTINCT FROM emitted, NOT (IS DISTINCT FROM) stored) ✅ pass live verify --db schemaDrift changes: [] ; stored shape_principal_id_presentiff_chk CHECK (((principal_id IS NOT NULL) = (NOT (kind IS DISTINCT FROM 'principal'::text))))
Adversarial: enum member case change Open->open reports drift, not a silent pass ✅ pass live verify --db reports drop-check shape_st_chk ('Open') and add-check ("st" IN ('open')); restored metadata verifies clean again
Adversarial: presentIff literal case change principal->Principal reports drift ✅ pass live verify --db reports drop-check/add-check on shape_principal_id_presentiff_chk with 'Principal'; restored metadata clean
Live PG round-trip suite for all generated CHECK shapes, regex-cast and lifecycle ✅ pass live MIGRATE_TS_PG_URL bun test on pg-check-shapes-roundtrip, pg-regex-check-cast, lifecycle-pg: 15 pass, 0 fail
Unit guards: NOT-distinct fold, nested/literal IS DISTINCT, bpchar cast, precedence limit ⏸️ untested no Only run inside the migrate-ts unit suite (1109 pass, 0 fail with live PG env set); not driven live through the CLI, so no live result is recorded for this scenario.
Full server regression suite ⏸️ untested no bun test in server/typescript exceeded 580s foreground limit, no summary. Run scripts/ci-local.sh or bun test per package to finish.
  • Outcome: ⚠️ 1 warning across 1 run (42m57s)

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

⚠️ **Test** - 1 warning
  • ⚠️ server/typescript - Full server bun test did not complete within the foreground time limit, so the complete regression suite has no result.
  • Live validation: ✅ go - 5 of 7 scenarios driven live against the product
Scenario Result Live Evidence
One-value enum CHECK on real PG16 verifies clean (IN ('x') emitted, = 'x' stored) ✅ pass live verify --db schemaDrift changes: [] after migrate --apply of field.enum @values [Open]; stored shape_st_chk CHECK ((st = 'Open'::text))
presentIff CHECK on real PG16 verifies clean (IS NOT DISTINCT FROM emitted, NOT (IS DISTINCT FROM) stored) ✅ pass live verify --db schemaDrift changes: [] ; stored shape_principal_id_presentiff_chk CHECK (((principal_id IS NOT NULL) = (NOT (kind IS DISTINCT FROM 'principal'::text))))
Adversarial: enum member case change Open->open reports drift, not a silent pass ✅ pass live verify --db reports drop-check shape_st_chk ('Open') and add-check ("st" IN ('open')); restored metadata verifies clean again
Adversarial: presentIff literal case change principal->Principal reports drift ✅ pass live verify --db reports drop-check/add-check on shape_principal_id_presentiff_chk with 'Principal'; restored metadata clean
Live PG round-trip suite for all generated CHECK shapes, regex-cast and lifecycle ✅ pass live MIGRATE_TS_PG_URL bun test on pg-check-shapes-roundtrip, pg-regex-check-cast, lifecycle-pg: 15 pass, 0 fail
Unit guards: NOT-distinct fold, nested/literal IS DISTINCT, bpchar cast, precedence limit ⏸️ untested no Only run inside the migrate-ts unit suite (1109 pass, 0 fail with live PG env set); not driven live through the CLI, so no live result is recorded for this scenario.
Full server regression suite ⏸️ untested no bun test in server/typescript exceeded 580s foreground limit, no summary. Run scripts/ci-local.sh or bun test per package to finish.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • bun cli/dist/bin/meta.js init (scratch project)
  • bun cli/dist/bin/meta.js migrate --slug init --db <pg> --apply
  • bun cli/dist/bin/meta.js verify --db <pg> (clean, enum + presentIff)
  • adversarial: sed enum @values Open->open then verify --db (drift reported)
  • adversarial: sed presentIff @equals principal->Principal then verify --db (drift reported)
  • MIGRATE_TS_PG_URL=<pg> bun test packages/migrate-ts/test/integration/pg-check-shapes-roundtrip.test.ts pg-regex-check-cast.test.ts lifecycle-pg.test.ts (15 pass)
  • MIGRATE_TS_PG_URL=<pg> bun test packages/migrate-ts (1109 pass, 0 fail)
  • MIGRATE_TS_PG_URL=<pg> bun test (server, full) — timed out at 580s, not completed
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…als as written

A one-value enum, presentIff and validators on varchar(n) columns reported drift
after migrate had just built them, because Postgres stores IN (x) as = x,
IS NOT DISTINCT FROM as NOT (IS DISTINCT FROM) and casts varchar columns to text.
The normaliser also lower-cased and bracket-stripped quoted literals, so an enum
member or regex case change read as clean. char(n) is no longer a text-family
column for the (col)::text fold. Adds a Postgres round-trip over every generated
CHECK shape.
… drift

Covers the non-regex bpchar case on real Postgres and offline.
@dmealing
dmealing merged commit 4565725 into main Oct 28, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-check-drift-fix branch October 28, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant