@metaobjectsdev/migrate-ts 0.8.1 → 0.9.0-rc.1

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,241 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * Default migration-history ledger table name. A single source of truth tracked
5
+ * across postgres + sqlite so `meta migrate --apply` can skip already-applied
6
+ * files (idempotency from the LEDGER, not from re-diffing).
7
+ */
8
+ export const MIGRATIONS_TABLE = "_metaobjects_migrations";
9
+
10
+ /** Default Postgres schema holding the ledger (and, by default, the lock scope). */
11
+ export const DEFAULT_LEDGER_SCHEMA = "public";
12
+
13
+ /**
14
+ * Safe SQL identifier pattern for caller-supplied `schema` / `table` names. These
15
+ * are interpolated as SQL identifiers (one `sql.ref` per part), so they MUST be a
16
+ * single, unquoted-style identifier — no dots, spaces, or quotes — otherwise
17
+ * Kysely's `sql.ref` would split a value like `"v1.2_migrations"` on every `.`
18
+ * into a wrong multi-part name. Validated at resolve time; a violation throws.
19
+ */
20
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
+
22
+ /**
23
+ * Throw a clear, actionable error if a caller-supplied SQL-identifier option
24
+ * (`schema` / `table`) is not a single safe identifier. Names the offending
25
+ * option + value so the caller can fix the misconfiguration directly.
26
+ */
27
+ function assertSafeIdentifier(option: string, value: string): void {
28
+ if (!SAFE_IDENTIFIER.test(value)) {
29
+ throw new Error(
30
+ `LedgerOptions.${option} must be a single SQL identifier matching ` +
31
+ `${SAFE_IDENTIFIER.source} (letters, digits, underscore; not starting with a digit). ` +
32
+ `Got ${JSON.stringify(value)} — a dotted/quoted/spaced value would be mis-parsed as a ` +
33
+ `multi-part identifier. Use a plain name (e.g. a per-tenant prefix) instead.`,
34
+ );
35
+ }
36
+ }
37
+
38
+ /** The dialect signal threaded through the ledger fns (schema-qualification only applies to pg). */
39
+ export type LedgerDialect = "postgres" | "sqlite";
40
+
41
+ /**
42
+ * Multi-tenant ledger configuration. Generalizes the fixed
43
+ * `public._metaobjects_migrations` ledger so multiple apps/tenants can track
44
+ * independently in one physical database.
45
+ *
46
+ * Defaults preserve the original single-tenant behavior exactly: `schema`
47
+ * defaults to `public`, `table` to `_metaobjects_migrations`. Postgres uses
48
+ * `schema`; SQLite has no schemas and ignores it. `lockName` is consumed by the
49
+ * advisory-lock path (apply/rollback) — see {@link applyPending}.
50
+ */
51
+ export interface LedgerOptions {
52
+ /** Postgres schema holding the ledger table. Default `public`. Ignored on SQLite. */
53
+ schema?: string;
54
+ /** Ledger table name. Default `_metaobjects_migrations`. */
55
+ table?: string;
56
+ /** Advisory-lock name. Default derived from `<schema>.<table>`. Postgres-only. */
57
+ lockName?: string;
58
+ }
59
+
60
+ /** A single ledger row. */
61
+ export interface LedgerRow {
62
+ /** Migration name = the `<timestamp>-<slug>` directory name (sort key + id). */
63
+ name: string;
64
+ /** sha-256 of the up.sql contents at apply time (tamper guard). */
65
+ checksum: string;
66
+ }
67
+
68
+ /** Resolved ledger location: a dialect + a Kysely raw-SQL identifier reference. */
69
+ interface ResolvedLedger {
70
+ dialect: LedgerDialect;
71
+ schema: string;
72
+ table: string;
73
+ /**
74
+ * A qualified-identifier SQL fragment built from SEPARATE `sql.ref` parts —
75
+ * `"<schema>"."<table>"` on pg, the bare `"<table>"` on sqlite. Built part-wise
76
+ * (not from a single dotted string) so a `.` inside a name can never be
77
+ * misread as an identifier separator, independent of the resolve-time
78
+ * validation.
79
+ */
80
+ ref: ReturnType<typeof sql>;
81
+ }
82
+
83
+ /**
84
+ * Resolve a {@link LedgerOptions} (+ dialect) to a concrete ledger location.
85
+ * On Postgres the table is schema-qualified (`"<schema>"."<table>"`); on SQLite
86
+ * (no schema concept) the schema is ignored and the bare `"<table>"` is used.
87
+ *
88
+ * Caller-supplied `schema` / `table` are validated against {@link SAFE_IDENTIFIER}
89
+ * (a violation throws here, naming the option + value), then assembled from two
90
+ * separate `sql.ref` parts so identifier quoting stays dialect-portable AND no
91
+ * `.` can be mis-parsed as a multi-part separator.
92
+ */
93
+ function resolveLedger(
94
+ dialect: LedgerDialect,
95
+ opts: LedgerOptions | undefined,
96
+ ): ResolvedLedger {
97
+ const schema = opts?.schema ?? DEFAULT_LEDGER_SCHEMA;
98
+ const table = opts?.table ?? MIGRATIONS_TABLE;
99
+ assertSafeIdentifier("table", table);
100
+ if (dialect === "postgres") {
101
+ assertSafeIdentifier("schema", schema);
102
+ }
103
+ const ref =
104
+ dialect === "postgres"
105
+ ? sql`${sql.ref(schema)}.${sql.ref(table)}`
106
+ : sql`${sql.ref(table)}`;
107
+ return { dialect, schema, table, ref };
108
+ }
109
+
110
+ /**
111
+ * Create the migration-history table if it does not already exist. Idempotent:
112
+ * re-running is a no-op and preserves existing rows. Dialect-portable DDL
113
+ * (TEXT columns work on both sqlite and postgres; `applied_at` is stored as
114
+ * text so we don't depend on a dialect-specific timestamp type).
115
+ *
116
+ * On Postgres a multi-tenant `schema` is created first (`CREATE SCHEMA IF NOT
117
+ * EXISTS`) so the ledger can live outside `public`.
118
+ */
119
+ export async function ensureLedger(
120
+ db: Kysely<Record<string, unknown>>,
121
+ dialect: LedgerDialect = "sqlite",
122
+ opts?: LedgerOptions,
123
+ ): Promise<void> {
124
+ const ledger = resolveLedger(dialect, opts);
125
+ if (ledger.dialect === "postgres") {
126
+ await sql`CREATE SCHEMA IF NOT EXISTS ${sql.ref(ledger.schema)}`.execute(db);
127
+ }
128
+ await sql`
129
+ CREATE TABLE IF NOT EXISTS ${ledger.ref} (
130
+ name TEXT PRIMARY KEY,
131
+ applied_at TEXT NOT NULL,
132
+ checksum TEXT NOT NULL
133
+ )
134
+ `.execute(db);
135
+ }
136
+
137
+ /**
138
+ * Record a migration as applied. Inserts a row with the current UTC timestamp.
139
+ * Intended to run inside the SAME transaction that applied the migration SQL.
140
+ */
141
+ export async function recordApplied(
142
+ db: Kysely<Record<string, unknown>>,
143
+ name: string,
144
+ checksum: string,
145
+ dialect: LedgerDialect = "sqlite",
146
+ opts?: LedgerOptions,
147
+ ): Promise<void> {
148
+ const ledger = resolveLedger(dialect, opts);
149
+ const appliedAt = new Date().toISOString();
150
+ await sql`
151
+ INSERT INTO ${ledger.ref} (name, applied_at, checksum)
152
+ VALUES (${name}, ${appliedAt}, ${checksum})
153
+ `.execute(db);
154
+ }
155
+
156
+ /** Delete a migration's ledger row (rollback unrecord). */
157
+ export async function deleteApplied(
158
+ db: Kysely<Record<string, unknown>>,
159
+ name: string,
160
+ dialect: LedgerDialect = "sqlite",
161
+ opts?: LedgerOptions,
162
+ ): Promise<void> {
163
+ const ledger = resolveLedger(dialect, opts);
164
+ await sql`
165
+ DELETE FROM ${ledger.ref} WHERE name = ${name}
166
+ `.execute(db);
167
+ }
168
+
169
+ /** Return the set of applied migration names. */
170
+ export async function appliedNames(
171
+ db: Kysely<Record<string, unknown>>,
172
+ dialect: LedgerDialect = "sqlite",
173
+ opts?: LedgerOptions,
174
+ ): Promise<Set<string>> {
175
+ return new Set((await appliedRecords(db, dialect, opts)).keys());
176
+ }
177
+
178
+ /**
179
+ * Return a name→checksum map for all applied migrations (tamper-guard input).
180
+ *
181
+ * The {@link BASELINE_NAME} marker row is excluded at the SQL level: it is a
182
+ * marker, NOT a migration, so no migration-listing consumer (e.g. rollback-all,
183
+ * which derives its work list from these names) should ever see it. The baseline
184
+ * is read independently via {@link baselineRecord}.
185
+ */
186
+ export async function appliedRecords(
187
+ db: Kysely<Record<string, unknown>>,
188
+ dialect: LedgerDialect = "sqlite",
189
+ opts?: LedgerOptions,
190
+ ): Promise<Map<string, string>> {
191
+ const ledger = resolveLedger(dialect, opts);
192
+ // Raw select keeps this dialect-portable and sidesteps typing the dynamic
193
+ // table name against the untyped Kysely<Record<string, unknown>> schema.
194
+ const result = await sql<{ name: string; checksum: string }>`
195
+ SELECT name, checksum FROM ${ledger.ref} WHERE name != ${BASELINE_NAME}
196
+ `.execute(db);
197
+ const map = new Map<string, string>();
198
+ for (const row of result.rows) {
199
+ map.set(row.name, row.checksum);
200
+ }
201
+ return map;
202
+ }
203
+
204
+ /** Reserved ledger name for the baseline marker (sorts before any timestamped migration). */
205
+ export const BASELINE_NAME = "0000-baseline";
206
+
207
+ /**
208
+ * Record (or overwrite) the baseline marker — the snapshot checksum captured when
209
+ * `migrate baseline` seeded the reference snapshot. Lets a later check detect a
210
+ * snapshot that was hand-edited out of sync with the migration chain.
211
+ */
212
+ export async function recordBaseline(
213
+ db: Kysely<Record<string, unknown>>,
214
+ dialect: LedgerDialect,
215
+ checksum: string,
216
+ opts?: LedgerOptions,
217
+ ): Promise<void> {
218
+ const ledger = resolveLedger(dialect, opts);
219
+ await ensureLedger(db, dialect, opts);
220
+ const appliedAt = new Date().toISOString();
221
+ // Upsert: delete any prior baseline, then insert (portable across sqlite/pg).
222
+ await sql`DELETE FROM ${ledger.ref} WHERE name = ${BASELINE_NAME}`.execute(db);
223
+ await sql`
224
+ INSERT INTO ${ledger.ref} (name, applied_at, checksum)
225
+ VALUES (${BASELINE_NAME}, ${appliedAt}, ${checksum})
226
+ `.execute(db);
227
+ }
228
+
229
+ /** Read the baseline marker, or null if none recorded. */
230
+ export async function baselineRecord(
231
+ db: Kysely<Record<string, unknown>>,
232
+ dialect: LedgerDialect = "sqlite",
233
+ opts?: LedgerOptions,
234
+ ): Promise<{ name: string; checksum: string } | null> {
235
+ const ledger = resolveLedger(dialect, opts);
236
+ const result = await sql<{ name: string; checksum: string }>`
237
+ SELECT name, checksum FROM ${ledger.ref} WHERE name = ${BASELINE_NAME}
238
+ `.execute(db);
239
+ const row = result.rows[0];
240
+ return row ? { name: row.name, checksum: row.checksum } : null;
241
+ }
@@ -0,0 +1,49 @@
1
+ // src/check-expr-compare.ts
2
+ //
3
+ // CHECK-expression comparison. Postgres rewrites a stored CHECK body so the raw
4
+ // text we generate and the introspected text differ textually but mean the same
5
+ // thing. This reduces both to ONE canonical form for comparison. Reliable here
6
+ // because every check expression we emit is machine-derived with a simple, known
7
+ // shape (comparison / IN / length / regex) — there is no arbitrary author SQL to
8
+ // mis-normalize. The rewrites PG applies (verified against a live server):
9
+ // - parenthesizes terms: `col >= 0 AND col <= 100` → `(col >= 0) AND (col <= 100)`
10
+ // - rewrites IN-lists: `status IN ('A','B')` → `status = ANY (ARRAY['A'::text, 'B'::text])`
11
+ // - appends type casts: string literals gain `::text`
12
+ // All three are canonicalized below so an enum/range CHECK introspected from PG
13
+ // compares equal to the one we generate (idempotency on the --from-db / verify paths).
14
+
15
+ /** Canonical form: drop casts/brackets/parens, fold `= ANY (ARRAY[…])` back to `IN`, lower-case, collapse whitespace. */
16
+ export function normalizeCheckExpr(expr: string): string {
17
+ const stripped = expr
18
+ .toLowerCase()
19
+ // Drop `::text` / `::"MyType"` type casts PG adds to literals. The lookbehind
20
+ // restricts the strip to a cast that immediately follows a CLOSING single
21
+ // quote (`'open'::text`), so a `::` appearing INSIDE a regex pattern literal
22
+ // (`slug ~ 'a::foo'`) is preserved — otherwise two distinct regex CHECKs would
23
+ // normalize equal and a pattern change would be silently missed.
24
+ .replace(/(?<=')::\s*"?\w+"?/g, "")
25
+ .replace(/[()[\]]/g, " ") // drop parens AND square brackets (ARRAY[…])
26
+ .replace(/\s+/g, " ")
27
+ .trim();
28
+ // PG stores `col IN (…)` as `col = ANY (ARRAY[…])`; after the bracket strip above
29
+ // that reads `col = any array …`. Fold it back to the `col in …` form we emit.
30
+ return stripped.replace(/=\s*any\s+array/g, "in").replace(/\s+/g, " ").trim();
31
+ }
32
+
33
+ /** True when two CHECK expressions are equivalent after normalization. */
34
+ export function checkExprEquals(a: string | undefined, b: string | undefined): boolean {
35
+ if (a === undefined || b === undefined) return false;
36
+ return normalizeCheckExpr(a) === normalizeCheckExpr(b);
37
+ }
38
+
39
+ /**
40
+ * `CHECK (<expr>)` → `<expr>` (balanced outer wrapper); returns input unchanged
41
+ * if there is no CHECK wrapper. Tolerates a trailing constraint modifier suffix
42
+ * (`pg_get_constraintdef` can return `CHECK (<expr>) NOT VALID`) so the wrapper
43
+ * still strips cleanly to the inner expression instead of falling through to the
44
+ * unchanged-input fallback (which would cause spurious drop+add churn).
45
+ */
46
+ export function stripCheckWrapper(def: string): string {
47
+ const m = /^\s*CHECK\s*\((.*)\)(?:\s+NOT\s+VALID)?\s*$/is.exec(def);
48
+ return m ? m[1]!.trim() : def.trim();
49
+ }
package/src/diff/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import type {
2
2
  SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
3
3
  ViewDescriptor,
4
- Change, ChangeStatus, DiffResult, AllowOptions, AmbiguousCallback,
4
+ Change, ChangeStatus, DiffResult, AllowOptions, AmbiguousCallback, Dialect,
5
5
  } from "../types.js";
6
6
  import type { SqlType } from "../sql-type.js";
7
7
  import { sqlTypeEquals } from "../sql-type.js";
8
8
  import { applyStatus } from "./status.js";
9
9
  import { detectColumnRenames, detectTableRenames } from "./rename-heuristic.js";
10
+ import { viewSqlEquals } from "../view-sql-compare.js";
11
+ import { checkExprEquals } from "../check-expr-compare.js";
10
12
  import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
11
13
 
12
14
  export interface DiffArgs {
@@ -25,6 +27,8 @@ export interface DiffArgs {
25
27
  * the default. Pass additional patterns to extend.
26
28
  */
27
29
  ignoreTables?: string[];
30
+ /** Dialect; CHECK-constraint evolution on existing tables is emitted for postgres only. */
31
+ dialect?: Dialect;
28
32
  }
29
33
 
30
34
  const ALLOWED: ChangeStatus = { state: "allowed" };
@@ -128,6 +132,12 @@ export async function diff(
128
132
  fk, status: ALLOWED,
129
133
  });
130
134
  }
135
+ // CHECK constraints are inlined into the CREATE TABLE DDL at emit time
136
+ // (both postgres and sqlite support inline CHECK), so they ride on
137
+ // `create-table.table.checks` rather than as separate add-check changes.
138
+ // For brand-new tables no add-check is emitted here; existing-table CHECK
139
+ // evolution (add/drop on tables present on both sides) is handled by
140
+ // diffTableChecks in Pass 2.
131
141
  }
132
142
  }
133
143
  // Pass 1b: tables present in actual but not expected → drop-table
@@ -135,7 +145,7 @@ export async function diff(
135
145
  for (const [id, t] of actualTables) {
136
146
  if (!expectedTables.has(id)) {
137
147
  const dropChange: Change & { _columns?: ColumnDescriptor[] } = {
138
- kind: "drop-table", table: t.name, ...schemaSpread(t.schema), status: ALLOWED,
148
+ kind: "drop-table", table: t.name, ...schemaSpread(t.schema), restore: t, status: ALLOWED,
139
149
  };
140
150
  dropChange._columns = t.columns;
141
151
  changes.push(dropChange);
@@ -149,13 +159,15 @@ export async function diff(
149
159
  diffTableColumns(expectedTable, actualTable, changes);
150
160
  diffTableIndexes(expectedTable, actualTable, changes);
151
161
  diffTableForeignKeys(expectedTable, actualTable, changes);
162
+ // CHECK constraints on existing tables are evolved for postgres only (SQLite
163
+ // evolves checks via table recreate, not ALTER). Gated on `actual.checks`
164
+ // being populated — by the snapshot offline, or pg_constraint introspection.
165
+ if (args.dialect === "postgres") diffTableChecks(expectedTable, actualTable, changes);
152
166
  }
153
167
 
154
- // Pass 2b: views. Identity is (schema, name). Body comparison isn't
155
- // implemented introspect doesn't read view bodies today, so a name match
156
- // is no-change regardless of how the user's projection metadata has evolved.
157
- // Users who change a view body without renaming need to manually drop+recreate
158
- // or do a full bootstrap until replace-view-from-body-diff lands.
168
+ // Pass 2b: views. Identity is (schema, name). A name present on both sides
169
+ // with a divergent body (whitespace-/wrapper-normalized) emits replace-view;
170
+ // introspect now reads the actual body so view-body drift is visible.
159
171
  diffViews(args.expected.views, args.actual.views, changes);
160
172
 
161
173
  // Pass 3: detect table renames BEFORE column renames — so a renamed table's
@@ -221,7 +233,7 @@ function diffTableColumns(
221
233
  for (const [name, ac] of actualCols) {
222
234
  if (!expectedCols.has(name)) {
223
235
  const dropChange: Change & { _sqlType?: SqlType; _nullable?: boolean } = {
224
- kind: "drop-column", table, ...sx, column: name, status: ALLOWED,
236
+ kind: "drop-column", table, ...sx, column: name, restore: ac, status: ALLOWED,
225
237
  };
226
238
  dropChange._sqlType = ac.sqlType;
227
239
  dropChange._nullable = ac.nullable;
@@ -245,13 +257,14 @@ function diffTableIndexes(
245
257
  changes.push({ kind: "add-index", table, ...sx, index: ix, status: ALLOWED });
246
258
  } else if (!indexEquals(ix, a)) {
247
259
  // Index shape changed: drop + add (atomic from caller's perspective).
248
- changes.push({ kind: "drop-index", table, ...sx, index: name, status: ALLOWED });
260
+ // restore = the ACTUAL shape so the down re-creates the original index.
261
+ changes.push({ kind: "drop-index", table, ...sx, index: name, restore: a, status: ALLOWED });
249
262
  changes.push({ kind: "add-index", table, ...sx, index: ix, status: ALLOWED });
250
263
  }
251
264
  }
252
- for (const [name] of actualIdx) {
265
+ for (const [name, ai] of actualIdx) {
253
266
  if (!expectedIdx.has(name)) {
254
- changes.push({ kind: "drop-index", table, ...sx, index: name, status: ALLOWED });
267
+ changes.push({ kind: "drop-index", table, ...sx, index: name, restore: ai, status: ALLOWED });
255
268
  }
256
269
  }
257
270
  }
@@ -270,13 +283,35 @@ function diffTableForeignKeys(
270
283
  if (!a) {
271
284
  changes.push({ kind: "add-fk", table, ...sx, fk, status: ALLOWED });
272
285
  } else if (!fkEquals(fk, a)) {
273
- changes.push({ kind: "drop-fk", table, ...sx, fk: name, status: ALLOWED });
286
+ // FK shape changed: drop + add. restore = the ACTUAL shape so the down
287
+ // re-creates the original FK.
288
+ changes.push({ kind: "drop-fk", table, ...sx, fk: name, restore: a, status: ALLOWED });
274
289
  changes.push({ kind: "add-fk", table, ...sx, fk, status: ALLOWED });
275
290
  }
276
291
  }
277
- for (const [name] of actualFk) {
292
+ for (const [name, af] of actualFk) {
278
293
  if (!expectedFk.has(name)) {
279
- changes.push({ kind: "drop-fk", table, ...sx, fk: name, status: ALLOWED });
294
+ changes.push({ kind: "drop-fk", table, ...sx, fk: name, restore: af, status: ALLOWED });
295
+ }
296
+ }
297
+ }
298
+
299
+ function diffTableChecks(expected: TableDescriptor, actual: TableDescriptor, changes: Change[]): void {
300
+ const sx = schemaSpread(expected.schema);
301
+ const expectedChk = new Map(expected.checks.map((c) => [c.name, c]));
302
+ const actualChk = new Map(actual.checks.map((c) => [c.name, c]));
303
+ for (const [name, ec] of expectedChk) {
304
+ const ac = actualChk.get(name);
305
+ if (!ac) {
306
+ changes.push({ kind: "add-check", table: expected.name, ...sx, check: ec, status: ALLOWED });
307
+ } else if (!checkExprEquals(ec.expression, ac.expression)) {
308
+ changes.push({ kind: "drop-check", table: expected.name, ...sx, check: name, restore: ac, status: ALLOWED });
309
+ changes.push({ kind: "add-check", table: expected.name, ...sx, check: ec, status: ALLOWED });
310
+ }
311
+ }
312
+ for (const [name, ac] of actualChk) {
313
+ if (!expectedChk.has(name)) {
314
+ changes.push({ kind: "drop-check", table: expected.name, ...sx, check: name, restore: ac, status: ALLOWED });
280
315
  }
281
316
  }
282
317
  }
@@ -291,8 +326,17 @@ function diffViews(
291
326
  const exp = new Map(expected.map((v) => [viewIdentity(v), v] as const));
292
327
  const act = new Map(actual.map((v) => [viewIdentity(v), v] as const));
293
328
  for (const [id, v] of exp) {
294
- if (!act.has(id)) {
329
+ const a = act.get(id);
330
+ if (a === undefined) {
295
331
  changes.push({ kind: "create-view", view: v, ...schemaSpread(v.schema), status: ALLOWED });
332
+ } else if (
333
+ // Both bodies known and divergent → replace-view. When either body is
334
+ // absent (e.g. expected projection unresolvable, or an introspector that
335
+ // couldn't read the body) we cannot prove a change, so we leave it alone
336
+ // rather than emit a spurious replace.
337
+ v.sql !== undefined && a.sql !== undefined && !viewSqlEquals(v.sql, a.sql)
338
+ ) {
339
+ changes.push({ kind: "replace-view", view: v, ...schemaSpread(v.schema), status: ALLOWED });
296
340
  }
297
341
  }
298
342
  for (const [id, v] of act) {
@@ -27,6 +27,8 @@ function blockedReasonFor(c: Change, allow: AllowOptions): string | null {
27
27
  return allow.dropIndex ? null : "destructive: drop-index not allowed (pass allow.dropIndex)";
28
28
  case "drop-fk":
29
29
  return allow.dropFk ? null : "destructive: drop-fk not allowed (pass allow.dropFk)";
30
+ case "drop-check":
31
+ return allow.dropCheck ? null : "destructive: drop-check not allowed (pass allow.dropCheck)";
30
32
 
31
33
  case "change-column-type":
32
34
  if (isWidening(c.from, c.to)) return null; // widening always allowed
@@ -47,6 +49,7 @@ function blockedReasonFor(c: Change, allow: AllowOptions): string | null {
47
49
  case "change-column-default":
48
50
  case "add-index":
49
51
  case "add-fk":
52
+ case "add-check":
50
53
  case "create-view":
51
54
  case "drop-view":
52
55
  case "replace-view":
@@ -0,0 +1,56 @@
1
+ // src/drift/classify.ts
2
+ import { diff } from "../diff/index.js";
3
+ import type { Change, Dialect, DiffResult, SchemaSnapshot } from "../types.js";
4
+
5
+ /**
6
+ * Change kinds that represent an object present in the live DB but absent from
7
+ * the snapshot. When the snapshot is the `expected` side of a diff, these are
8
+ * the DB's *unmanaged* objects — hand-authored, not modeled — and must never be
9
+ * treated as actionable drift or auto-dropped.
10
+ */
11
+ const UNMANAGED_KINDS = new Set<string>([
12
+ "drop-table",
13
+ "drop-column",
14
+ "drop-index",
15
+ "drop-fk",
16
+ "drop-view",
17
+ // A CHECK present in the DB but not the snapshot is a DB-only object, same as
18
+ // a hand-authored index/fk/view — never actionable drift, never auto-dropped.
19
+ "drop-check",
20
+ ]);
21
+
22
+ export interface DriftClassification {
23
+ /** Modeled objects the DB is missing or has differently — actionable; fails the gate. */
24
+ drift: Change[];
25
+ /** Objects present in the DB but not the snapshot — informational; never dropped. */
26
+ unmanaged: Change[];
27
+ }
28
+
29
+ export function classifyDrift(changes: Change[]): DriftClassification {
30
+ const drift: Change[] = [];
31
+ const unmanaged: Change[] = [];
32
+ for (const c of changes) {
33
+ if (UNMANAGED_KINDS.has(c.kind)) unmanaged.push(c);
34
+ else drift.push(c);
35
+ }
36
+ return { drift, unmanaged };
37
+ }
38
+
39
+ /**
40
+ * Drift of a live DB (introspected into `actual`) against the committed snapshot.
41
+ * Diffs with `expected = snapshot` so that objects only in the DB surface as
42
+ * `drop-*` → classified `unmanaged`; objects the snapshot has but the DB lacks or
43
+ * differs surface as create/add/change → classified `drift`.
44
+ */
45
+ export async function driftAgainstSnapshot(
46
+ snapshot: SchemaSnapshot,
47
+ actual: SchemaSnapshot,
48
+ dialect?: Dialect,
49
+ ): Promise<DriftClassification> {
50
+ const result: DiffResult = await diff({
51
+ expected: snapshot,
52
+ actual,
53
+ ...(dialect !== undefined ? { dialect } : {}),
54
+ });
55
+ return classifyDrift(result.changes);
56
+ }
@@ -0,0 +1,66 @@
1
+ // computeDrift — structural + view-body drift of a live DB vs the metadata-
2
+ // declared schema. Thin composition over the existing pipeline:
3
+ // buildExpectedSchema(metadata) + introspect(db, dialect) → diff(...)
4
+ //
5
+ // Used by the `meta verify --db` schema-drift gate: a non-empty `changes` list
6
+ // means the live DB has diverged from what the metadata describes (a missing
7
+ // column, a changed view body, an extra table, etc.). Unlike `meta migrate`,
8
+ // drift detection never emits SQL or asks about ambiguous renames — it just
9
+ // reports the divergence so CI can fail loud.
10
+
11
+ import type { Kysely } from "kysely";
12
+ import type { MetaRoot } from "@metaobjectsdev/metadata";
13
+ import type { ColumnNamingStrategy } from "@metaobjectsdev/metadata";
14
+ import { buildExpectedSchema } from "../expected-schema.js";
15
+ import { introspect } from "../introspect/index.js";
16
+ import { diff } from "../diff/index.js";
17
+ import type { AllowOptions, Dialect, DiffResult } from "../types.js";
18
+
19
+ export interface ComputeDriftOptions {
20
+ /**
21
+ * Destructive-change permissions. Mirrors `diff`'s `allow` — a drift that is
22
+ * "allowed" still appears in `changes` (it's still drift), so the gate fails
23
+ * on it. `allow` only affects `blocked`. Defaults to `{}`.
24
+ */
25
+ allow?: AllowOptions;
26
+ /**
27
+ * Column-naming strategy for fields with no `@column` override. Must match
28
+ * the runtime's strategy or the expected schema's columns won't line up with
29
+ * what introspection sees. Defaults to `buildExpectedSchema`'s default.
30
+ */
31
+ columnNamingStrategy?: ColumnNamingStrategy;
32
+ /**
33
+ * Table-name patterns to ignore on both sides (passed through to `diff`).
34
+ * Omit to keep `diff`'s default (migration-tracking sidecar tables).
35
+ */
36
+ ignoreTables?: string[];
37
+ }
38
+
39
+ /**
40
+ * Compute the drift between a live DB and the metadata-declared schema.
41
+ *
42
+ * Returns a `DiffResult` whose `changes` is empty iff the DB matches the
43
+ * metadata. The caller decides exit behavior (the schema-drift gate fails when
44
+ * `changes` is non-empty).
45
+ */
46
+ export async function computeDrift(
47
+ db: Kysely<Record<string, unknown>>,
48
+ dialect: Dialect,
49
+ metadata: MetaRoot,
50
+ opts?: ComputeDriftOptions,
51
+ ): Promise<DiffResult> {
52
+ const expected = buildExpectedSchema(metadata, {
53
+ dialect,
54
+ ...(opts?.columnNamingStrategy !== undefined
55
+ ? { columnNamingStrategy: opts.columnNamingStrategy }
56
+ : {}),
57
+ });
58
+ const actual = await introspect(db, dialect);
59
+ return diff({
60
+ expected,
61
+ actual,
62
+ dialect,
63
+ allow: opts?.allow ?? {},
64
+ ...(opts?.ignoreTables !== undefined ? { ignoreTables: opts.ignoreTables } : {}),
65
+ });
66
+ }
@@ -1,3 +1,5 @@
1
+ import { splitSqlStatements } from "../sql/split-statements.js";
2
+
1
3
  const MAX_STATEMENT_BYTES = 1 * 1024 * 1024; // 1 MB — D1 batch API per-statement limit (path used by `wrangler d1 migrations apply --file`).
2
4
 
3
5
  export class D1UnsupportedStatementError extends Error {
@@ -22,73 +24,42 @@ export function applyD1SafetyPass(sql: string, opts?: { collectWarnings?: boolea
22
24
  return collect ? { sql: "", warnings } : "";
23
25
  }
24
26
 
25
- const statements = splitStatements(sql);
27
+ // splitSqlStatements already returns trimmed, non-empty statements.
28
+ const statements = splitSqlStatements(sql);
26
29
  const kept: string[] = [];
27
30
 
28
31
  for (const stmt of statements) {
29
- const trimmed = stmt.trim();
30
- if (trimmed.length === 0) continue;
31
-
32
32
  // Reject hard failures up front.
33
- if (/^\s*(ATTACH|DETACH)\b/i.test(trimmed)) {
34
- throw new D1UnsupportedStatementError(trimmed, "ATTACH/DETACH DATABASE");
33
+ if (/^\s*(ATTACH|DETACH)\b/i.test(stmt)) {
34
+ throw new D1UnsupportedStatementError(stmt, "ATTACH/DETACH DATABASE");
35
35
  }
36
- if (/^\s*VACUUM\b/i.test(trimmed)) {
37
- throw new D1UnsupportedStatementError(trimmed, "VACUUM");
36
+ if (/^\s*VACUUM\b/i.test(stmt)) {
37
+ throw new D1UnsupportedStatementError(stmt, "VACUUM");
38
38
  }
39
39
 
40
40
  // Strip explicit transaction control + savepoints.
41
- if (/^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(trimmed)) {
41
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(stmt)) {
42
42
  continue;
43
43
  }
44
44
 
45
- const byteLen = byteLength(trimmed);
45
+ const byteLen = byteLength(stmt);
46
46
  if (byteLen > MAX_STATEMENT_BYTES) {
47
47
  warnings.push(
48
48
  `statement exceeds D1's 1 MB per-statement limit (${byteLen} bytes); ` +
49
- `may be rejected by D1 at apply time: ${trimmed.slice(0, 80)}...`,
49
+ `may be rejected by D1 at apply time: ${stmt.slice(0, 80)}...`,
50
50
  );
51
51
  }
52
52
 
53
- kept.push(trimmed);
53
+ kept.push(stmt);
54
54
  }
55
55
 
56
- // Re-join: each statement on its own line, blank line between top-level
57
- // DDL statements (matches sqlite emit's output style).
58
- const out = kept.join("\n\n");
56
+ // Re-join: each statement on its own line, blank line between top-level DDL
57
+ // statements (matches sqlite emit's output style). splitSqlStatements strips
58
+ // the `;` separators, so re-add exactly one terminator per kept statement.
59
+ const out = kept.map((s) => `${s};`).join("\n\n");
59
60
  return collect ? { sql: out, warnings } : out;
60
61
  }
61
62
 
62
- /**
63
- * Split SQL on `;` boundaries, respecting single-quoted strings (SQL uses
64
- * '' to escape a single quote inside a literal — two consecutive quotes toggle
65
- * inString twice, net zero, which is exactly what we want).
66
- * Sufficient for our DDL output; we don't generate dollar-quoted blocks or
67
- * other exotic SQLite literals.
68
- */
69
- function splitStatements(sql: string): string[] {
70
- const out: string[] = [];
71
- let buf = "";
72
- let inString = false;
73
- for (let i = 0; i < sql.length; i++) {
74
- const c = sql[i]!;
75
- if (c === "'") {
76
- buf += c;
77
- inString = !inString;
78
- continue;
79
- }
80
- if (c === ";" && !inString) {
81
- buf += ";";
82
- out.push(buf);
83
- buf = "";
84
- continue;
85
- }
86
- buf += c;
87
- }
88
- if (buf.trim().length > 0) out.push(buf);
89
- return out;
90
- }
91
-
92
63
  function byteLength(s: string): number {
93
64
  return new TextEncoder().encode(s).length;
94
65
  }