@metaobjectsdev/migrate-ts 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -3
  2. package/dist/apply/apply.d.ts +61 -0
  3. package/dist/apply/apply.d.ts.map +1 -0
  4. package/dist/apply/apply.js +241 -0
  5. package/dist/apply/apply.js.map +1 -0
  6. package/dist/apply/ledger.d.ts +78 -0
  7. package/dist/apply/ledger.d.ts.map +1 -0
  8. package/dist/apply/ledger.js +146 -0
  9. package/dist/apply/ledger.js.map +1 -0
  10. package/dist/check-expr-compare.d.ts +13 -0
  11. package/dist/check-expr-compare.d.ts.map +1 -0
  12. package/dist/check-expr-compare.js +48 -0
  13. package/dist/check-expr-compare.js.map +1 -0
  14. package/dist/diff/index.d.ts +3 -1
  15. package/dist/diff/index.d.ts.map +1 -1
  16. package/dist/diff/index.js +57 -14
  17. package/dist/diff/index.js.map +1 -1
  18. package/dist/diff/status.js +3 -0
  19. package/dist/diff/status.js.map +1 -1
  20. package/dist/drift/classify.d.ts +16 -0
  21. package/dist/drift/classify.d.ts.map +1 -0
  22. package/dist/drift/classify.js +44 -0
  23. package/dist/drift/classify.js.map +1 -0
  24. package/dist/drift/drift.d.ts +32 -0
  25. package/dist/drift/drift.d.ts.map +1 -0
  26. package/dist/drift/drift.js +36 -0
  27. package/dist/drift/drift.js.map +1 -0
  28. package/dist/emit/d1-safety-pass.d.ts.map +1 -1
  29. package/dist/emit/d1-safety-pass.js +15 -45
  30. package/dist/emit/d1-safety-pass.js.map +1 -1
  31. package/dist/emit/postgres.d.ts.map +1 -1
  32. package/dist/emit/postgres.js +47 -4
  33. package/dist/emit/postgres.js.map +1 -1
  34. package/dist/emit/sqlite.d.ts.map +1 -1
  35. package/dist/emit/sqlite.js +22 -0
  36. package/dist/emit/sqlite.js.map +1 -1
  37. package/dist/errors.d.ts.map +1 -1
  38. package/dist/errors.js +4 -0
  39. package/dist/errors.js.map +1 -1
  40. package/dist/expected-schema.d.ts.map +1 -1
  41. package/dist/expected-schema.js +114 -5
  42. package/dist/expected-schema.js.map +1 -1
  43. package/dist/index.d.ts +13 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +13 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/introspect/d1.d.ts.map +1 -1
  48. package/dist/introspect/d1.js +1 -0
  49. package/dist/introspect/d1.js.map +1 -1
  50. package/dist/introspect/postgres.d.ts.map +1 -1
  51. package/dist/introspect/postgres.js +38 -2
  52. package/dist/introspect/postgres.js.map +1 -1
  53. package/dist/introspect/sqlite.d.ts.map +1 -1
  54. package/dist/introspect/sqlite.js +13 -2
  55. package/dist/introspect/sqlite.js.map +1 -1
  56. package/dist/snapshot/checksum.d.ts +10 -0
  57. package/dist/snapshot/checksum.d.ts.map +1 -0
  58. package/dist/snapshot/checksum.js +14 -0
  59. package/dist/snapshot/checksum.js.map +1 -0
  60. package/dist/snapshot/plan.d.ts +25 -0
  61. package/dist/snapshot/plan.d.ts.map +1 -0
  62. package/dist/snapshot/plan.js +30 -0
  63. package/dist/snapshot/plan.js.map +1 -0
  64. package/dist/snapshot/serialize.d.ts +10 -0
  65. package/dist/snapshot/serialize.d.ts.map +1 -0
  66. package/dist/snapshot/serialize.js +63 -0
  67. package/dist/snapshot/serialize.js.map +1 -0
  68. package/dist/snapshot/store.d.ts +12 -0
  69. package/dist/snapshot/store.d.ts.map +1 -0
  70. package/dist/snapshot/store.js +32 -0
  71. package/dist/snapshot/store.js.map +1 -0
  72. package/dist/sql/split-statements.d.ts +12 -0
  73. package/dist/sql/split-statements.d.ts.map +1 -0
  74. package/dist/sql/split-statements.js +112 -0
  75. package/dist/sql/split-statements.js.map +1 -0
  76. package/dist/sql-type.d.ts +2 -0
  77. package/dist/sql-type.d.ts.map +1 -1
  78. package/dist/sql-type.js +2 -0
  79. package/dist/sql-type.js.map +1 -1
  80. package/dist/types.d.ts +36 -5
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/verify/replay.d.ts +25 -0
  83. package/dist/verify/replay.d.ts.map +1 -0
  84. package/dist/verify/replay.js +25 -0
  85. package/dist/verify/replay.js.map +1 -0
  86. package/dist/view-sql-compare.d.ts +8 -0
  87. package/dist/view-sql-compare.d.ts.map +1 -0
  88. package/dist/view-sql-compare.js +44 -0
  89. package/dist/view-sql-compare.js.map +1 -0
  90. package/package.json +2 -2
  91. package/src/apply/apply.ts +340 -0
  92. package/src/apply/ledger.ts +241 -0
  93. package/src/check-expr-compare.ts +49 -0
  94. package/src/diff/index.ts +59 -15
  95. package/src/diff/status.ts +3 -0
  96. package/src/drift/classify.ts +56 -0
  97. package/src/drift/drift.ts +66 -0
  98. package/src/emit/d1-safety-pass.ts +16 -45
  99. package/src/emit/postgres.ts +47 -4
  100. package/src/emit/sqlite.ts +22 -0
  101. package/src/errors.ts +4 -0
  102. package/src/expected-schema.ts +124 -4
  103. package/src/index.ts +44 -0
  104. package/src/introspect/d1.ts +1 -0
  105. package/src/introspect/postgres.ts +38 -4
  106. package/src/introspect/sqlite.ts +13 -3
  107. package/src/snapshot/checksum.ts +15 -0
  108. package/src/snapshot/plan.ts +53 -0
  109. package/src/snapshot/serialize.ts +81 -0
  110. package/src/snapshot/store.ts +33 -0
  111. package/src/sql/split-statements.ts +115 -0
  112. package/src/sql-type.ts +3 -0
  113. package/src/types.ts +26 -9
  114. package/src/verify/replay.ts +43 -0
  115. package/src/view-sql-compare.ts +46 -0
@@ -0,0 +1,81 @@
1
+ // src/snapshot/serialize.ts
2
+ import type { SchemaSnapshot } from "../types.js";
3
+
4
+ /**
5
+ * On-disk format version for the committed schema snapshot. Bump when the
6
+ * SchemaSnapshot descriptor gains a field (a DDL-coverage feature); add the
7
+ * matching upgrade branch in parseSnapshot at the same time.
8
+ */
9
+ export const SNAPSHOT_FORMAT_VERSION = 2;
10
+
11
+ interface SnapshotFile {
12
+ formatVersion: number;
13
+ snapshot: SchemaSnapshot;
14
+ }
15
+
16
+ function sortByName<T extends { name: string }>(arr: readonly T[]): T[] {
17
+ return [...arr].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
18
+ }
19
+
20
+ /** Sort arrays by name so serialization is order-independent. */
21
+ function canonicalize(s: SchemaSnapshot): SchemaSnapshot {
22
+ return {
23
+ tables: sortByName(s.tables).map((t) => ({
24
+ ...t,
25
+ columns: sortByName(t.columns),
26
+ indexes: sortByName(t.indexes),
27
+ foreignKeys: sortByName(t.foreignKeys),
28
+ checks: sortByName(t.checks ?? []),
29
+ })),
30
+ views: sortByName(s.views),
31
+ ...(s.meta ? { meta: s.meta } : {}),
32
+ };
33
+ }
34
+
35
+ /** JSON.stringify with object keys sorted recursively (arrays left as-is). */
36
+ function stableStringify(value: unknown): string {
37
+ return JSON.stringify(
38
+ value,
39
+ (_key, v) => {
40
+ if (v && typeof v === "object" && !Array.isArray(v)) {
41
+ const obj = v as Record<string, unknown>;
42
+ return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
43
+ }
44
+ return v;
45
+ },
46
+ 2,
47
+ );
48
+ }
49
+
50
+ export function serializeSnapshot(snapshot: SchemaSnapshot): string {
51
+ const file: SnapshotFile = {
52
+ formatVersion: SNAPSHOT_FORMAT_VERSION,
53
+ snapshot: canonicalize(snapshot),
54
+ };
55
+ return stableStringify(file) + "\n";
56
+ }
57
+
58
+ export function parseSnapshot(text: string): SchemaSnapshot {
59
+ const file = JSON.parse(text) as SnapshotFile;
60
+ if (typeof file.formatVersion !== "number") {
61
+ throw new Error("snapshot file is missing a numeric 'formatVersion'");
62
+ }
63
+ if (file.formatVersion > SNAPSHOT_FORMAT_VERSION) {
64
+ throw new Error(
65
+ `snapshot formatVersion ${file.formatVersion} is newer than supported ` +
66
+ `${SNAPSHOT_FORMAT_VERSION}; upgrade @metaobjectsdev/migrate-ts`,
67
+ );
68
+ }
69
+ if (file.snapshot === null || typeof file.snapshot !== "object") {
70
+ throw new Error("snapshot file is missing a 'snapshot' object");
71
+ }
72
+ if (file.formatVersion < 2) {
73
+ // v1 → v2: the table descriptor gained `checks`; default older snapshots to [].
74
+ for (const t of file.snapshot.tables) {
75
+ if ((t as { checks?: unknown }).checks === undefined) {
76
+ (t as { checks: unknown[] }).checks = [];
77
+ }
78
+ }
79
+ }
80
+ return file.snapshot;
81
+ }
@@ -0,0 +1,33 @@
1
+ // src/snapshot/store.ts
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { Dialect, SchemaSnapshot } from "../types.js";
5
+ import { parseSnapshot, serializeSnapshot } from "./serialize.js";
6
+
7
+ /**
8
+ * Committed reference-snapshot path for a dialect, e.g.
9
+ * `<migrationsDir>/.schema.postgres.json`. d1 shares sqlite's schema, so both
10
+ * map to `.schema.sqlite.json`.
11
+ */
12
+ export function snapshotPath(migrationsDir: string, dialect: Dialect): string {
13
+ const d = dialect === "d1" ? "sqlite" : dialect;
14
+ return join(migrationsDir, `.schema.${d}.json`);
15
+ }
16
+
17
+ /** Read + parse the snapshot, or null if the file is absent. */
18
+ export async function readSnapshot(path: string): Promise<SchemaSnapshot | null> {
19
+ let text: string;
20
+ try {
21
+ text = await readFile(path, "utf8");
22
+ } catch (err) {
23
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
24
+ throw err;
25
+ }
26
+ return parseSnapshot(text);
27
+ }
28
+
29
+ /** Serialize + write the snapshot, creating the parent directory if needed. */
30
+ export async function writeSnapshot(path: string, snapshot: SchemaSnapshot): Promise<void> {
31
+ await mkdir(dirname(path), { recursive: true });
32
+ await writeFile(path, serializeSnapshot(snapshot), "utf8");
33
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Split a SQL script into its top-level statements on `;` boundaries, ignoring
3
+ * separators that fall inside string literals, quoted identifiers, comments, or
4
+ * dollar-quoted blocks. The single source of truth for statement splitting —
5
+ * used both when applying migration files ({@link ../apply/apply.ts}) and when
6
+ * post-processing emitted DDL for the D1 target ({@link ../emit/d1-safety-pass.ts}).
7
+ *
8
+ * Returned statements are trimmed and DO NOT include the trailing `;`
9
+ * separator; callers that need a terminator re-add one.
10
+ */
11
+ export function splitSqlStatements(text: string): string[] {
12
+ const statements: string[] = [];
13
+ let start = 0;
14
+ let i = 0;
15
+ const n = text.length;
16
+
17
+ while (i < n) {
18
+ const ch = text[i];
19
+ const next = text[i + 1];
20
+
21
+ // -- line comment: skip to end of line (or input).
22
+ if (ch === "-" && next === "-") {
23
+ i += 2;
24
+ while (i < n && text[i] !== "\n") i++;
25
+ continue;
26
+ }
27
+
28
+ // /* block comment */: skip to closing delimiter (or input).
29
+ if (ch === "/" && next === "*") {
30
+ i += 2;
31
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
32
+ i += 2; // consume the closing */ (clamped by the while below)
33
+ continue;
34
+ }
35
+
36
+ // Single-quoted literal: consume to the closing quote, treating '' as escape.
37
+ if (ch === "'") {
38
+ i++;
39
+ while (i < n) {
40
+ if (text[i] === "'" && text[i + 1] === "'") {
41
+ i += 2; // embedded ''
42
+ continue;
43
+ }
44
+ if (text[i] === "'") {
45
+ i++;
46
+ break;
47
+ }
48
+ i++;
49
+ }
50
+ continue;
51
+ }
52
+
53
+ // Double-quoted identifier: consume to the closing quote, treating "" as escape.
54
+ if (ch === '"') {
55
+ i++;
56
+ while (i < n) {
57
+ if (text[i] === '"' && text[i + 1] === '"') {
58
+ i += 2; // embedded ""
59
+ continue;
60
+ }
61
+ if (text[i] === '"') {
62
+ i++;
63
+ break;
64
+ }
65
+ i++;
66
+ }
67
+ continue;
68
+ }
69
+
70
+ // Dollar-quoted string: $tag$ … $tag$ with tag = [A-Za-z0-9_]*.
71
+ if (ch === "$") {
72
+ const open = matchDollarTag(text, i);
73
+ if (open !== null) {
74
+ const tag = text.slice(i, open); // includes both $ delimiters
75
+ i = open;
76
+ // Scan for the matching closing tag.
77
+ while (i < n) {
78
+ if (text[i] === "$" && text.startsWith(tag, i)) {
79
+ i += tag.length;
80
+ break;
81
+ }
82
+ i++;
83
+ }
84
+ continue;
85
+ }
86
+ }
87
+
88
+ // Statement separator (only reached when in no special state).
89
+ if (ch === ";") {
90
+ const stmt = text.slice(start, i).trim();
91
+ if (stmt.length > 0) statements.push(stmt);
92
+ i++;
93
+ start = i;
94
+ continue;
95
+ }
96
+
97
+ i++;
98
+ }
99
+
100
+ const tail = text.slice(start).trim();
101
+ if (tail.length > 0) statements.push(tail);
102
+ return statements;
103
+ }
104
+
105
+ /**
106
+ * If a dollar-quote tag opens at `pos` (text[pos] === "$"), return the index
107
+ * just past the opening `$tag$` delimiter; otherwise null. The tag body is
108
+ * `[A-Za-z0-9_]*` and must be terminated by a second `$`.
109
+ */
110
+ function matchDollarTag(text: string, pos: number): number | null {
111
+ let j = pos + 1;
112
+ while (j < text.length && /[A-Za-z0-9_]/.test(text[j] as string)) j++;
113
+ if (text[j] === "$") return j + 1;
114
+ return null;
115
+ }
package/src/sql-type.ts CHANGED
@@ -14,6 +14,7 @@ export type SqlType =
14
14
  | { kind: "boolean" }
15
15
  | { kind: "timestamp"; withTimezone: boolean }
16
16
  | { kind: "date" }
17
+ | { kind: "time" }
17
18
  | { kind: "json" }
18
19
  | { kind: "blob" }
19
20
  | { kind: "uuid" };
@@ -36,6 +37,7 @@ export function sqlTypeEquals(a: SqlType, b: SqlType): boolean {
36
37
  case "real4":
37
38
  case "boolean":
38
39
  case "date":
40
+ case "time":
39
41
  case "json":
40
42
  case "blob":
41
43
  case "uuid":
@@ -85,6 +87,7 @@ export function isWidening(from: SqlType, to: SqlType): boolean {
85
87
  case "real4":
86
88
  case "boolean":
87
89
  case "date":
90
+ case "time":
88
91
  case "json":
89
92
  case "blob":
90
93
  case "uuid":
package/src/types.ts CHANGED
@@ -31,6 +31,7 @@ export interface TableDescriptor {
31
31
  columns: ColumnDescriptor[];
32
32
  indexes: IndexDescriptor[];
33
33
  foreignKeys: FkDescriptor[];
34
+ checks: CheckDescriptor[];
34
35
  primaryKey: string[]; // column names; [] if none
35
36
  /**
36
37
  * Human-readable description threaded from entity `@description`.
@@ -67,6 +68,13 @@ export interface IndexDescriptor {
67
68
  unique: boolean;
68
69
  }
69
70
 
71
+ export interface CheckDescriptor {
72
+ /** Constraint name, e.g. `<table>_<column>_chk`. Diff/identity key. */
73
+ name: string;
74
+ /** The boolean SQL expression, e.g. `status IN ('OPEN','CLOSED')`. */
75
+ expression: string;
76
+ }
77
+
70
78
  export interface FkDescriptor {
71
79
  name: string;
72
80
  columns: string[];
@@ -83,11 +91,17 @@ export interface ViewDescriptor {
83
91
  /** Same semantics as TableDescriptor.schema. */
84
92
  schema?: string;
85
93
  /**
86
- * View body: everything between `CREATE VIEW <name> AS` and the trailing `;`
87
- * (the SELECT clause through the FROM/WHERE/GROUP-BY tail). Populated by
88
- * `buildExpectedSchema` from projection metadata; omitted by introspect
89
- * (body-level comparison isn't implemented yet diff matches by name only,
90
- * so a body change does NOT trigger replace-view today).
94
+ * View definition SQL.
95
+ *
96
+ * On the EXPECTED side (`buildExpectedSchema` / `buildExpectedViews`) this is
97
+ * the view body the SELECT clause through the FROM/WHERE/GROUP-BY tail.
98
+ *
99
+ * On the ACTUAL side (`introspect`) this is whatever the DB catalog stores:
100
+ * sqlite's `sqlite_master.sql` is the full `CREATE VIEW <name> AS <body>`
101
+ * statement, while Postgres' `information_schema.views.view_definition` is the
102
+ * body only. `diff`'s view-body comparator normalizes both sides (strips any
103
+ * leading `CREATE VIEW ... AS`, collapses whitespace) before comparing, so a
104
+ * body change triggers a `replace-view`.
91
105
  */
92
106
  sql?: string;
93
107
  }
@@ -109,10 +123,10 @@ export interface ViewDescriptor {
109
123
  */
110
124
  export type Change =
111
125
  | { kind: "create-table"; table: TableDescriptor; schema?: string; status: ChangeStatus }
112
- | { kind: "drop-table"; table: string; schema?: string; status: ChangeStatus }
126
+ | { kind: "drop-table"; table: string; schema?: string; restore?: TableDescriptor; status: ChangeStatus }
113
127
  | { kind: "rename-table"; from: string; to: string; schema?: string; status: ChangeStatus }
114
128
  | { kind: "add-column"; table: string; schema?: string; column: ColumnDescriptor; status: ChangeStatus }
115
- | { kind: "drop-column"; table: string; schema?: string; column: string; status: ChangeStatus }
129
+ | { kind: "drop-column"; table: string; schema?: string; column: string; restore?: ColumnDescriptor; status: ChangeStatus }
116
130
  | { kind: "rename-column"; table: string; schema?: string; from: string; to: string; status: ChangeStatus }
117
131
  | { kind: "change-column-type"; table: string; schema?: string; column: string;
118
132
  from: SqlType; to: SqlType; status: ChangeStatus }
@@ -121,9 +135,11 @@ export type Change =
121
135
  | { kind: "change-column-default"; table: string; schema?: string; column: string;
122
136
  from?: ColumnDefault; to?: ColumnDefault; status: ChangeStatus }
123
137
  | { kind: "add-index"; table: string; schema?: string; index: IndexDescriptor; status: ChangeStatus }
124
- | { kind: "drop-index"; table: string; schema?: string; index: string; status: ChangeStatus }
138
+ | { kind: "drop-index"; table: string; schema?: string; index: string; restore?: IndexDescriptor; status: ChangeStatus }
125
139
  | { kind: "add-fk"; table: string; schema?: string; fk: FkDescriptor; status: ChangeStatus }
126
- | { kind: "drop-fk"; table: string; schema?: string; fk: string; status: ChangeStatus }
140
+ | { kind: "drop-fk"; table: string; schema?: string; fk: string; restore?: FkDescriptor; status: ChangeStatus }
141
+ | { kind: "add-check"; table: string; schema?: string; check: CheckDescriptor; status: ChangeStatus }
142
+ | { kind: "drop-check"; table: string; schema?: string; check: string; restore?: CheckDescriptor; status: ChangeStatus }
127
143
  // Declared for v0.3, never produced in v0.1:
128
144
  | { kind: "create-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus }
129
145
  | { kind: "drop-view"; view: string; schema?: string; status: ChangeStatus }
@@ -147,6 +163,7 @@ export interface AllowOptions {
147
163
  typeChange?: boolean;
148
164
  dropIndex?: boolean;
149
165
  dropFk?: boolean;
166
+ dropCheck?: boolean;
150
167
  /** Existing data must satisfy NOT NULL; diff cannot verify this. */
151
168
  nullableToNotNull?: boolean;
152
169
  }
@@ -0,0 +1,43 @@
1
+ // src/verify/replay.ts
2
+ import type { Kysely } from "kysely";
3
+ import { applyPending } from "../apply/apply.js";
4
+ import { MIGRATIONS_TABLE } from "../apply/ledger.js";
5
+ import { introspect } from "../introspect/index.js";
6
+ import { driftAgainstSnapshot, type DriftClassification } from "../drift/classify.js";
7
+ import type { Dialect, SchemaSnapshot } from "../types.js";
8
+
9
+ export interface VerifyReplayArgs {
10
+ /** A FRESH, throwaway database. Replay applies every migration into it from empty. */
11
+ db: Kysely<Record<string, unknown>>;
12
+ dialect: Extract<Dialect, "postgres" | "sqlite">;
13
+ /** Directory holding the committed `<timestamp>-<slug>/up.sql` migrations. */
14
+ migrationsDir: string;
15
+ /** The committed snapshot the migrations are expected to reproduce. */
16
+ snapshot: SchemaSnapshot;
17
+ }
18
+
19
+ export interface VerifyReplayResult extends DriftClassification {
20
+ /** True when the replayed schema matches the snapshot (no drift, no unmanaged). */
21
+ ok: boolean;
22
+ }
23
+
24
+ /**
25
+ * Replay all committed migrations into a fresh database, introspect the result,
26
+ * and compare it to the committed snapshot. A non-empty `drift`/`unmanaged` means
27
+ * the migrations-as-applied diverge from the snapshot — e.g. a hand-edited up.sql
28
+ * that changed structure the metadata-derived snapshot doesn't know about. The
29
+ * ledger sidecar table is excluded from the comparison.
30
+ */
31
+ export async function verifyReplay(args: VerifyReplayArgs): Promise<VerifyReplayResult> {
32
+ await applyPending(args.db, args.migrationsDir, { dryRun: false, dialect: args.dialect });
33
+ const introspected = await introspect(args.db, args.dialect);
34
+ const actual: SchemaSnapshot = {
35
+ ...introspected,
36
+ tables: introspected.tables.filter((t) => t.name !== MIGRATIONS_TABLE),
37
+ };
38
+ const classification = await driftAgainstSnapshot(args.snapshot, actual, args.dialect);
39
+ return {
40
+ ...classification,
41
+ ok: classification.drift.length === 0 && classification.unmanaged.length === 0,
42
+ };
43
+ }
@@ -0,0 +1,46 @@
1
+ // view-sql-compare.ts — the single shared comparator for view definition SQL.
2
+ //
3
+ // View definition SQL arrives in two shapes across the pipeline:
4
+ // - EXPECTED (buildExpectedViews): the body only — `SELECT ... FROM ...`.
5
+ // - ACTUAL (introspect): sqlite's sqlite_master.sql is the full
6
+ // `CREATE VIEW <name> AS <body>`; Postgres' view_definition is body-only.
7
+ // - The CLI's readExistingViewSql synthesizes `CREATE VIEW <name> AS <body>`
8
+ // for Postgres so it matches what emitViewDdl produces.
9
+ //
10
+ // normalizeViewSql reduces any of these to a comparable canonical form so the
11
+ // diff (expected vs introspected) and the CLI (emitted DDL vs existing DDL) use
12
+ // ONE comparison. It strips a leading `CREATE [OR REPLACE] VIEW <name> AS`,
13
+ // collapses runs of whitespace to a single space, drops a trailing `;`, and
14
+ // lower-cases — view-body drift should be classified by structure, not by
15
+ // incidental whitespace/case/wrapper differences.
16
+ //
17
+ // CAVEAT (accepted tradeoff): lower-casing makes keyword/identifier comparison
18
+ // case-insensitive but can mask a difference that lives ONLY in a case-sensitive
19
+ // string literal in the body (e.g. `WHERE status = 'Active'` vs `'active'`) —
20
+ // such a change would NOT be flagged as drift. Acceptable for generated
21
+ // aggregate/passthrough projections (no literals); revisit if hand-authored
22
+ // views with case-sensitive literals become a drift concern. The name regex
23
+ // matches one whitespace/`(`-free token, so a quoted view name containing a
24
+ // space would not strip cleanly — also a non-issue for generated identifiers.
25
+
26
+ const CREATE_VIEW_PREFIX =
27
+ /^\s*create\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?view\s+(?:if\s+not\s+exists\s+)?[^\s(]+(?:\s*\([^)]*\))?\s+as\s+/i;
28
+
29
+ /**
30
+ * Collapse a view definition (full `CREATE VIEW ... AS body` OR a bare body)
31
+ * to a canonical, comparable string. Whitespace-, case-, and wrapper-insensitive.
32
+ */
33
+ export function normalizeViewSql(sql: string): string {
34
+ return sql
35
+ .replace(CREATE_VIEW_PREFIX, "")
36
+ .replace(/\s+/g, " ")
37
+ .replace(/;\s*$/, "")
38
+ .trim()
39
+ .toLowerCase();
40
+ }
41
+
42
+ /** True when two view definitions are equivalent after normalization. */
43
+ export function viewSqlEquals(a: string | undefined, b: string | undefined): boolean {
44
+ if (a === undefined || b === undefined) return false;
45
+ return normalizeViewSql(a) === normalizeViewSql(b);
46
+ }