Skip to content

Commit 80fb676

Browse files
dmealingclaude
andcommitted
fix(migrate-ts): thread dialect into verify/replay/drift diff + harden stripCheckWrapper (NOT VALID)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bfbcbea commit 80fb676

7 files changed

Lines changed: 72 additions & 10 deletions

File tree

server/typescript/packages/migrate-ts/src/check-expr-compare.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,15 @@ export function checkExprEquals(a: string | undefined, b: string | undefined): b
2222
if (a === undefined || b === undefined) return false;
2323
return normalizeCheckExpr(a) === normalizeCheckExpr(b);
2424
}
25+
26+
/**
27+
* `CHECK (<expr>)` → `<expr>` (balanced outer wrapper); returns input unchanged
28+
* if there is no CHECK wrapper. Tolerates a trailing constraint modifier suffix
29+
* (`pg_get_constraintdef` can return `CHECK (<expr>) NOT VALID`) so the wrapper
30+
* still strips cleanly to the inner expression instead of falling through to the
31+
* unchanged-input fallback (which would cause spurious drop+add churn).
32+
*/
33+
export function stripCheckWrapper(def: string): string {
34+
const m = /^\s*CHECK\s*\((.*)\)(?:\s+NOT\s+VALID)?\s*$/is.exec(def);
35+
return m ? m[1]!.trim() : def.trim();
36+
}

server/typescript/packages/migrate-ts/src/drift/classify.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// src/drift/classify.ts
22
import { diff } from "../diff/index.js";
3-
import type { Change, DiffResult, SchemaSnapshot } from "../types.js";
3+
import type { Change, Dialect, DiffResult, SchemaSnapshot } from "../types.js";
44

55
/**
66
* Change kinds that represent an object present in the live DB but absent from
@@ -42,7 +42,12 @@ export function classifyDrift(changes: Change[]): DriftClassification {
4242
export async function driftAgainstSnapshot(
4343
snapshot: SchemaSnapshot,
4444
actual: SchemaSnapshot,
45+
dialect?: Dialect,
4546
): Promise<DriftClassification> {
46-
const result: DiffResult = await diff({ expected: snapshot, actual });
47+
const result: DiffResult = await diff({
48+
expected: snapshot,
49+
actual,
50+
...(dialect !== undefined ? { dialect } : {}),
51+
});
4752
return classifyDrift(result.changes);
4853
}

server/typescript/packages/migrate-ts/src/drift/drift.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export async function computeDrift(
5959
return diff({
6060
expected,
6161
actual,
62+
dialect,
6263
allow: opts?.allow ?? {},
6364
...(opts?.ignoreTables !== undefined ? { ignoreTables: opts.ignoreTables } : {}),
6465
});

server/typescript/packages/migrate-ts/src/introspect/postgres.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { sql } from "kysely";
3131
import type { SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor, CheckDescriptor } from "../types.js";
3232
import type { SqlType } from "../sql-type.js";
3333
import { MIGRATIONS_TABLE } from "../apply/ledger.js";
34+
import { stripCheckWrapper } from "../check-expr-compare.js";
3435

3536
// ---------------------------------------------------------------------------
3637
// Public API
@@ -459,9 +460,3 @@ async function readPgChecks(k: RawKysely, schema: string, table: string): Promis
459460
return []; // pg-mem: pg_constraint unsupported
460461
}
461462
}
462-
463-
/** `CHECK (<expr>)` → `<expr>` (balanced outer wrapper); returns input unchanged if no wrapper. */
464-
function stripCheckWrapper(def: string): string {
465-
const m = /^\s*CHECK\s*\((.*)\)\s*$/is.exec(def);
466-
return m ? m[1]!.trim() : def.trim();
467-
}

server/typescript/packages/migrate-ts/src/verify/replay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export async function verifyReplay(args: VerifyReplayArgs): Promise<VerifyReplay
3535
...introspected,
3636
tables: introspected.tables.filter((t) => t.name !== MIGRATIONS_TABLE),
3737
};
38-
const classification = await driftAgainstSnapshot(args.snapshot, actual);
38+
const classification = await driftAgainstSnapshot(args.snapshot, actual, args.dialect);
3939
return {
4040
...classification,
4141
ok: classification.drift.length === 0 && classification.unmanaged.length === 0,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, test, expect } from "bun:test";
2+
import type { SchemaSnapshot, TableDescriptor, CheckDescriptor } from "../../src/types.js";
3+
import { driftAgainstSnapshot } from "../../src/drift/classify.js";
4+
5+
const CHK = (name: string, expression: string): CheckDescriptor => ({ name, expression });
6+
function tbl(checks: CheckDescriptor[]): TableDescriptor {
7+
return {
8+
name: "orders",
9+
columns: [{ name: "qty", sqlType: { kind: "integer", bits: 32 }, nullable: false }],
10+
indexes: [], foreignKeys: [], checks, primaryKey: ["qty"],
11+
};
12+
}
13+
const snap = (checks: CheckDescriptor[]): SchemaSnapshot => ({ tables: [tbl(checks)], views: [] });
14+
const C0 = CHK("orders_qty_numeric_chk", "qty >= 1");
15+
const C1 = CHK("orders_qty_max_chk", "qty <= 100");
16+
17+
describe("driftAgainstSnapshot — dialect-threaded CHECK evolution", () => {
18+
test("with dialect=postgres: snapshot has 2 checks, DB has 1 → reports check drift", async () => {
19+
// expected (snapshot) = 2 checks; actual (DB) = 1 check → the missing modeled
20+
// check surfaces as add-check drift (postgres-gated diffTableChecks).
21+
const r = await driftAgainstSnapshot(snap([C0, C1]), snap([C0]), "postgres");
22+
const checkChanges = [...r.drift, ...r.unmanaged].filter((c) => c.kind.endsWith("-check"));
23+
expect(checkChanges.length).toBeGreaterThan(0);
24+
});
25+
26+
test("without dialect (back-compat): same snapshots → no check drift", async () => {
27+
const r = await driftAgainstSnapshot(snap([C0, C1]), snap([C0]));
28+
const checkChanges = [...r.drift, ...r.unmanaged].filter((c) => c.kind.endsWith("-check"));
29+
expect(checkChanges.length).toBe(0);
30+
});
31+
});

server/typescript/packages/migrate-ts/test/check-evolution/normalize.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from "bun:test";
2-
import { normalizeCheckExpr, checkExprEquals } from "../../src/check-expr-compare.js";
2+
import { normalizeCheckExpr, checkExprEquals, stripCheckWrapper } from "../../src/check-expr-compare.js";
33

44
describe("normalizeCheckExpr", () => {
55
test("strips parens, collapses whitespace, lowercases", () => {
@@ -18,3 +18,21 @@ describe("normalizeCheckExpr", () => {
1818
expect(checkExprEquals(undefined, "x")).toBe(false);
1919
});
2020
});
21+
22+
describe("stripCheckWrapper", () => {
23+
test("strips the CHECK(...) wrapper", () => {
24+
expect(stripCheckWrapper("CHECK (qty >= 1)")).toBe("qty >= 1");
25+
});
26+
test("preserves inner expression with multiple terms", () => {
27+
expect(stripCheckWrapper("CHECK (qty >= 1 AND qty <= 100)")).toBe("qty >= 1 AND qty <= 100");
28+
});
29+
test("tolerates a trailing NOT VALID modifier", () => {
30+
expect(stripCheckWrapper("CHECK (qty >= 1) NOT VALID")).toBe("qty >= 1");
31+
});
32+
test("preserves a regex expression with parens", () => {
33+
expect(stripCheckWrapper("CHECK (slug ~ '^(a|b)$')")).toBe("slug ~ '^(a|b)$'");
34+
});
35+
test("returns a non-CHECK string unchanged", () => {
36+
expect(stripCheckWrapper("qty >= 1")).toBe("qty >= 1");
37+
});
38+
});

0 commit comments

Comments
 (0)