@metaobjectsdev/migrate-ts 0.23.2 → 0.24.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 (59) hide show
  1. package/dist/diff/index.d.ts.map +1 -1
  2. package/dist/diff/index.js +8 -7
  3. package/dist/diff/index.js.map +1 -1
  4. package/dist/drift/drift.d.ts +32 -2
  5. package/dist/drift/drift.d.ts.map +1 -1
  6. package/dist/drift/drift.js +15 -8
  7. package/dist/drift/drift.js.map +1 -1
  8. package/dist/emit/postgres.js +69 -12
  9. package/dist/emit/postgres.js.map +1 -1
  10. package/dist/emit/sqlite.d.ts.map +1 -1
  11. package/dist/emit/sqlite.js +9 -2
  12. package/dist/emit/sqlite.js.map +1 -1
  13. package/dist/expected-schema.d.ts +35 -0
  14. package/dist/expected-schema.d.ts.map +1 -1
  15. package/dist/expected-schema.js +29 -2
  16. package/dist/expected-schema.js.map +1 -1
  17. package/dist/index.d.ts +8 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/qualified-name.d.ts +9 -0
  22. package/dist/qualified-name.d.ts.map +1 -0
  23. package/dist/qualified-name.js +21 -0
  24. package/dist/qualified-name.js.map +1 -0
  25. package/dist/scope.d.ts +121 -0
  26. package/dist/scope.d.ts.map +1 -0
  27. package/dist/scope.js +193 -0
  28. package/dist/scope.js.map +1 -0
  29. package/dist/snapshot/plan.d.ts +23 -1
  30. package/dist/snapshot/plan.d.ts.map +1 -1
  31. package/dist/snapshot/plan.js +17 -9
  32. package/dist/snapshot/plan.js.map +1 -1
  33. package/dist/types.d.ts +17 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/unmanaged.d.ts.map +1 -1
  36. package/dist/unmanaged.js +3 -3
  37. package/dist/unmanaged.js.map +1 -1
  38. package/dist/verify/replay-engine.d.ts +10 -0
  39. package/dist/verify/replay-engine.d.ts.map +1 -0
  40. package/dist/verify/replay-engine.js +161 -0
  41. package/dist/verify/replay-engine.js.map +1 -0
  42. package/dist/verify/replay.d.ts +15 -0
  43. package/dist/verify/replay.d.ts.map +1 -1
  44. package/dist/verify/replay.js +7 -1
  45. package/dist/verify/replay.js.map +1 -1
  46. package/package.json +14 -3
  47. package/src/diff/index.ts +8 -7
  48. package/src/drift/drift.ts +55 -15
  49. package/src/emit/postgres.ts +70 -12
  50. package/src/emit/sqlite.ts +9 -2
  51. package/src/expected-schema.ts +57 -2
  52. package/src/index.ts +18 -2
  53. package/src/qualified-name.ts +22 -0
  54. package/src/scope.ts +261 -0
  55. package/src/snapshot/plan.ts +45 -13
  56. package/src/types.ts +17 -0
  57. package/src/unmanaged.ts +2 -3
  58. package/src/verify/replay-engine.ts +184 -0
  59. package/src/verify/replay.ts +21 -1
@@ -2,6 +2,7 @@ import { applyPending } from "../apply/apply.js";
2
2
  import { MIGRATIONS_TABLE } from "../apply/ledger.js";
3
3
  import { introspect } from "../introspect/index.js";
4
4
  import { driftAgainstSnapshot } from "../drift/classify.js";
5
+ import { excludeFromSnapshot } from "../scope.js";
5
6
  /**
6
7
  * Replay all committed migrations into a fresh database, introspect the result,
7
8
  * and compare it to the committed snapshot. A non-empty `drift`/`unmanaged` means
@@ -16,7 +17,12 @@ export async function verifyReplay(args) {
16
17
  ...introspected,
17
18
  tables: introspected.tables.filter((t) => t.name !== MIGRATIONS_TABLE),
18
19
  };
19
- const classification = await driftAgainstSnapshot(args.snapshot, actual, args.dialect);
20
+ // `excludeFromSnapshot` returns a ScopedExpectedSchema, so take `.snapshot`. With an
21
+ // empty `outOfScope` it returns the SAME object, not an equal copy.
22
+ const expected = args.governed !== undefined
23
+ ? excludeFromSnapshot(args.snapshot, args.governed).snapshot
24
+ : args.snapshot;
25
+ const classification = await driftAgainstSnapshot(expected, actual, args.dialect);
20
26
  return {
21
27
  ...classification,
22
28
  ok: classification.drift.length === 0 && classification.unmanaged.length === 0,
@@ -1 +1 @@
1
- {"version":3,"file":"replay.js","sourceRoot":"","sources":["../../src/verify/replay.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,oBAAoB,EAA4B,MAAM,sBAAsB,CAAC;AAkBtF;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAsB;IACvD,MAAM,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,aAAa,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;IAC1F,MAAM,YAAY,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAC7D,MAAM,MAAM,GAAmB;QAC7B,GAAG,YAAY;QACf,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,gBAAgB,CAAC;KACvE,CAAC;IACF,MAAM,cAAc,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACvF,OAAO;QACL,GAAG,cAAc;QACjB,EAAE,EAAE,cAAc,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,cAAc,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;KAC/E,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"replay.js","sourceRoot":"","sources":["../../src/verify/replay.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,oBAAoB,EAA4B,MAAM,sBAAsB,CAAC;AACtF,OAAO,EAAE,mBAAmB,EAAsB,MAAM,aAAa,CAAC;AAgCtE;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAsB;IACvD,MAAM,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,aAAa,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;IAC1F,MAAM,YAAY,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAC7D,MAAM,MAAM,GAAmB;QAC7B,GAAG,YAAY;QACf,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,gBAAgB,CAAC;KACvE,CAAC;IACF,qFAAqF;IACrF,oEAAoE;IACpE,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,KAAK,SAAS;QAC1C,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,QAAQ;QAC5D,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC;IAClB,MAAM,cAAc,GAAG,MAAM,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAClF,OAAO;QACL,GAAG,cAAc;QACjB,EAAE,EAAE,cAAc,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,cAAc,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;KAC/E,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/migrate-ts",
3
- "version": "0.23.2",
3
+ "version": "0.24.1",
4
4
  "description": "Schema migration tooling for MetaObjects: diff metadata vs DB and emit SQL for Postgres and SQLite.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -45,12 +45,23 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@iarna/toml": "^2.2.5",
48
- "@metaobjectsdev/metadata": "0.23.2"
48
+ "@metaobjectsdev/metadata": "0.24.1"
49
49
  },
50
50
  "peerDependencies": {
51
- "kysely": ">=0.27.0 <0.30.0"
51
+ "kysely": ">=0.27.0 <0.30.0",
52
+ "@electric-sql/pglite": ">=0.3.0 <0.6.0",
53
+ "@libsql/kysely-libsql": ">=0.4.0 <0.5.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "@electric-sql/pglite": {
57
+ "optional": true
58
+ },
59
+ "@libsql/kysely-libsql": {
60
+ "optional": true
61
+ }
52
62
  },
53
63
  "devDependencies": {
64
+ "@electric-sql/pglite": "^0.5.0",
54
65
  "@libsql/kysely-libsql": "^0.4.1",
55
66
  "@types/pg": "^8.20.0",
56
67
  "bun-types": "latest",
package/src/diff/index.ts CHANGED
@@ -15,6 +15,7 @@ import { viewReplaceIsLegal } from "../view-column-types.js";
15
15
  import { checkExprEquals, normalizeCheckExpr } from "../check-expr-compare.js";
16
16
  import { isPgAutoSequenceDefault } from "../pg-identity-default.js";
17
17
  import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
18
+ import { qualifiedDbName } from "../qualified-name.js";
18
19
 
19
20
  export interface DiffArgs {
20
21
  expected: SchemaSnapshot;
@@ -90,10 +91,12 @@ const DEFAULT_IGNORE_TABLES: string[] = [
90
91
  *
91
92
  * For SQLite (no schema concept), every table has schema=undefined, so this maps
92
93
  * all tables to the same "public." prefix — harmless and preserves existing behavior.
94
+ *
95
+ * `qualifiedDbName` is THE definition (qualified-name.ts): the act-side exclusion
96
+ * sets — declared-`@unmanaged` and out-of-scope — are matched against these keys, so
97
+ * a second spelling here would silently un-suppress an object and propose its drop.
93
98
  */
94
- function tableIdentity(table: { name: string; schema?: string }): string {
95
- return (table.schema ?? DEFAULT_DB_SCHEMA_POSTGRES) + "." + table.name;
96
- }
99
+ const tableIdentity = qualifiedDbName;
97
100
 
98
101
  /**
99
102
  * Build the optional-schema spread used when constructing Change records.
@@ -151,7 +154,7 @@ export async function diff(
151
154
  const declaredSchemas = new Set([
152
155
  ...args.expected.tables.map((t) => t.schema ?? DEFAULT_DB_SCHEMA_POSTGRES),
153
156
  // A model that declares views in a schema with no table of its own (e.g. an
154
- // API/read-model schema like `p3_api` sitting alongside an all-`public`
157
+ // API/read-model schema like `acme_api` sitting alongside an all-`public`
155
158
  // entity model) must still bring that schema into scope — otherwise its
156
159
  // views are silently excluded from BOTH sides of the diff (never compared,
157
160
  // so real drift in an opaque @sql body or a genuine missing/extra view goes
@@ -590,9 +593,7 @@ function diffTableChecks(
590
593
  }
591
594
  }
592
595
 
593
- function viewIdentity(v: { name: string; schema?: string }): string {
594
- return (v.schema ?? DEFAULT_DB_SCHEMA_POSTGRES) + "." + v.name;
595
- }
596
+ const viewIdentity = qualifiedDbName;
596
597
 
597
598
  /**
598
599
  * Decide, per view, whether the DB matches the model.
@@ -16,10 +16,11 @@
16
16
  import type { Kysely } from "kysely";
17
17
  import type { MetaRoot } from "@metaobjectsdev/metadata";
18
18
  import type { ColumnNamingStrategy } from "@metaobjectsdev/metadata";
19
- import { buildExpectedSchema } from "../expected-schema.js";
19
+ import { buildExpectedSchemaWithProvenance } from "../expected-schema.js";
20
20
  import { introspect } from "../introspect/index.js";
21
21
  import { diff } from "../diff/index.js";
22
22
  import { collectUnmanagedNames } from "../unmanaged.js";
23
+ import { scopeExpectedSchema, scopedDiffInputs, type ObjectScopePredicate } from "../scope.js";
23
24
  import type { AllowOptions, Dialect, DiffResult, SchemaSnapshot } from "../types.js";
24
25
 
25
26
  export interface ComputeDriftOptions {
@@ -46,6 +47,36 @@ export interface ComputeDriftOptions {
46
47
  * itself; pass these so view drift is detected. Defaults to none.
47
48
  */
48
49
  views?: readonly import("../expected-schema.js").ExpectedViewInput[];
50
+ /**
51
+ * Per-command scope (`migrate.scope`): objects whose declaring FQN this predicate
52
+ * rejects are governed by somebody else. They leave the expected side AND are
53
+ * suppressed on the actual side, so their divergence is neither drift nor a
54
+ * proposed drop — `verify` reports them as out-of-scope instead (see
55
+ * `DriftResult.outOfScope`). Omit to govern everything loaded (unchanged behavior).
56
+ *
57
+ * `verify --db` and `migrate` deliberately share ONE declaration: a drift gate
58
+ * failing on tables migrate does not own is incoherent.
59
+ */
60
+ inScope?: ObjectScopePredicate;
61
+ }
62
+
63
+ export interface DriftResult extends DiffResult {
64
+ /**
65
+ * Qualified physical names excluded by `inScope` — empty when no scope was
66
+ * given. The caller REPORTS these: an object silently dropped from the
67
+ * comparison is indistinguishable from one that was checked and found clean.
68
+ */
69
+ outOfScope: readonly string[];
70
+ /**
71
+ * The schemas this comparison governed (`ScopedExpectedSchema.declaredSchemas`),
72
+ * `undefined` when no scope was given and `diff` derived its own.
73
+ *
74
+ * Reported so a SECOND comparison over the same run — `verify`'s committed-snapshot
75
+ * gate — can govern exactly the same schemas instead of re-deriving them from a
76
+ * different expected side. Together with `outOfScope` this pair is a
77
+ * `GovernedScope`, which is what `excludeFromSnapshot` takes.
78
+ */
79
+ declaredSchemas: readonly string[] | undefined;
49
80
  }
50
81
 
51
82
  /**
@@ -65,24 +96,33 @@ export async function computeDriftFromActual(
65
96
  dialect: Dialect,
66
97
  metadata: MetaRoot,
67
98
  opts?: ComputeDriftOptions,
68
- ): Promise<DiffResult> {
69
- const expected = buildExpectedSchema(metadata, {
70
- dialect,
71
- ...(opts?.columnNamingStrategy !== undefined
72
- ? { columnNamingStrategy: opts.columnNamingStrategy }
73
- : {}),
74
- ...(opts?.views !== undefined ? { views: opts.views } : {}),
75
- });
76
- return diff({
77
- expected,
99
+ ): Promise<DriftResult> {
100
+ const scoped = scopeExpectedSchema(
101
+ buildExpectedSchemaWithProvenance(metadata, {
102
+ dialect,
103
+ ...(opts?.columnNamingStrategy !== undefined
104
+ ? { columnNamingStrategy: opts.columnNamingStrategy }
105
+ : {}),
106
+ ...(opts?.views !== undefined ? { views: opts.views } : {}),
107
+ }),
108
+ opts?.inScope,
109
+ );
110
+ const result = await diff({
111
+ // The three scoped-diff obligations as one value (see scope.ts's header):
112
+ // the narrowed expected side, `unmanagedNames` merging @unmanaged with the
113
+ // out-of-scope names so neither is proposed for drop, and the schema scope
114
+ // pinned to the UNSCOPED model so a narrow scope can never widen the run.
115
+ ...scopedDiffInputs(scoped, collectUnmanagedNames(metadata)),
78
116
  actual,
79
117
  dialect,
80
118
  allow: opts?.allow ?? {},
81
- // #208 §7 — a declared-@unmanaged object is external, so it is not drift: exclude it
82
- // from the actual side (same as `meta migrate`) rather than surface a false drop-*.
83
- unmanagedNames: collectUnmanagedNames(metadata),
84
119
  ...(opts?.ignoreTables !== undefined ? { ignoreTables: opts.ignoreTables } : {}),
85
120
  });
121
+ return {
122
+ ...result,
123
+ outOfScope: scoped.outOfScope,
124
+ declaredSchemas: scoped.declaredSchemas,
125
+ };
86
126
  }
87
127
 
88
128
  /**
@@ -97,7 +137,7 @@ export async function computeDrift(
97
137
  dialect: Dialect,
98
138
  metadata: MetaRoot,
99
139
  opts?: ComputeDriftOptions,
100
- ): Promise<DiffResult> {
140
+ ): Promise<DriftResult> {
101
141
  const actual = await introspect(db, dialect);
102
142
  return computeDriftFromActual(actual, dialect, metadata, opts);
103
143
  }
@@ -54,16 +54,53 @@ export function renderPostgres(changes: Change[]): EmitResult {
54
54
  }
55
55
  // Down runs in reverse order (so creates undo correctly w.r.t. FKs).
56
56
  return {
57
- up: upStmts.join("\n\n"),
57
+ up: [...createSchemaStmts(sorted), ...upStmts].join("\n\n"),
58
58
  down: [...downStmts].reverse().join("\n\n"),
59
59
  recreatedTables: new Set(), // postgres alters in place; no recreate-and-copy
60
60
  };
61
61
  }
62
62
 
63
+ /**
64
+ * `CREATE SCHEMA IF NOT EXISTS` for every non-default schema this migration creates
65
+ * an object in, ahead of everything else it emits.
66
+ *
67
+ * A chain must be appliable to a VIRGIN database (#313), and `CREATE TABLE "s"."x"`
68
+ * fails there unless `s` exists — yet `CREATE SCHEMA` was emitted nowhere in either
69
+ * emitter, only by the ledger's own setup. So an `@schema` project's chain could
70
+ * never be replayed, and the first `apply-pending` against a fresh CI database died.
71
+ *
72
+ * VIEWS count, not only tables: a first migration that creates only a view in a
73
+ * non-default schema fails identically. A `create-view` carries the schema in two
74
+ * places and the change's own key wins, matching `renderCreateView(c.view, c.schema)`.
75
+ *
76
+ * `IF NOT EXISTS` because a later migration in the same chain, or an operator, may
77
+ * have created it already. Sorted so output is deterministic — the committed snapshot
78
+ * and the golden tests depend on that. Deliberately NOT dropped in `down`: the schema
79
+ * may hold objects this tool does not own and cannot restore.
80
+ */
81
+ function createSchemaStmts(sorted: readonly Change[]): string[] {
82
+ const schemas = new Set<string>();
83
+ for (const c of sorted) {
84
+ const s =
85
+ c.kind === "create-table" ? c.table.schema
86
+ : c.kind === "create-view" ? (c.schema ?? c.view.schema)
87
+ : undefined;
88
+ if (s !== undefined && s !== DEFAULT_DB_SCHEMA_POSTGRES) schemas.add(s);
89
+ }
90
+ return [...schemas].sort().map((s) => `CREATE SCHEMA IF NOT EXISTS ${quote(s)};`);
91
+ }
92
+
63
93
  function renderUp(c: Change): string {
64
94
  switch (c.kind) {
65
95
  case "create-table": return renderCreateTable(c.table);
66
- case "drop-table": return `DROP TABLE ${quoteQualified(c.table, c.schema)};`;
96
+ // #313 — every FORWARD drop is `IF EXISTS`. A committed chain must apply to a
97
+ // VIRGIN database, and the diff legitimately proposes dropping an object that
98
+ // exists in the live DB but was never created by any migration in the chain (a
99
+ // table another tool owns, say). Bare, that statement kills the replay with
100
+ // `table "x" does not exist`. The DOWN renderer below is deliberately NOT
101
+ // guarded: `rollbackTo` runs down.sql and the ledger delete in ONE transaction,
102
+ // so a no-op down would still record the rollback as done.
103
+ case "drop-table": return `DROP TABLE IF EXISTS ${quoteQualified(c.table, c.schema)};`;
67
104
  case "rename-table": return `ALTER TABLE ${quoteQualified(c.from, c.schema)} RENAME TO ${quote(c.to)};`;
68
105
  case "add-column": {
69
106
  const base = `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD COLUMN ${renderColumn(c.column)};`;
@@ -90,18 +127,37 @@ function renderUp(c: Change): string {
90
127
  // descriptor (both diff producers populate it), which is where the marker lives.
91
128
  // Matters broadly, not marginally: Drizzle's `unique()` emits constraints, so every
92
129
  // schema adopted from Drizzle has constraint-backed unique indexes.
130
+ // Both arms carry the #313 `IF EXISTS`: they are two renderings of the SAME
131
+ // `drop-index` change, and guarding one would leave the change kind half-covered.
132
+ // The constraint-backed arm ALSO guards the enclosing `ALTER TABLE` (not just
133
+ // the constraint name) — see the `drop-fk`/`drop-check` comment below for why.
93
134
  case "drop-index":
94
135
  return c.restore?.constraint !== undefined
95
- ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.index)};`
96
- : `DROP INDEX ${quoteIndexQualified(c.index, c.schema)};`;
136
+ ? `ALTER TABLE IF EXISTS ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT IF EXISTS ${quote(c.index)};`
137
+ : `DROP INDEX IF EXISTS ${quoteIndexQualified(c.index, c.schema)};`;
97
138
  case "add-fk": return renderAddFk(c.table, c.schema, c.fk);
98
- case "drop-fk": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.fk)};`;
99
- // add-check / drop-check are declared but NOT yet produced by the diff
100
- // checks are create-time-only (inlined in CREATE TABLE via renderCreateTable).
101
- // These arms exist for future existing-table CHECK evolution support, mirroring
102
- // the create-view/drop-view "declared, not yet produced" pattern.
139
+ // #313 (constraint-level): `DROP CONSTRAINT IF EXISTS` alone only guards the
140
+ // constraint NAME Postgres still requires the TABLE to exist to parse an
141
+ // `ALTER TABLE` at all, so a table another tool owns (never created by any
142
+ // migration in this chain) still killed the replay with `relation "x" does
143
+ // not exist`. Postgres supports `ALTER TABLE IF EXISTS` directly; using it
144
+ // closes the gap the same way `DROP TABLE IF EXISTS` above already does.
145
+ case "drop-fk": return `ALTER TABLE IF EXISTS ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT IF EXISTS ${quote(c.fk)};`;
146
+ // `drop-check` IS produced by the diff — diff/index.ts:579 and :592 both push it,
147
+ // and an evolved `field.enum @values` is a live producer. (A comment here used to
148
+ // claim these arms were unreachable "declared, not yet produced" stubs; that was
149
+ // false, and two tests already asserted the emitted statement.) `add-check` is the
150
+ // paired ADD and rides the same passes.
151
+ //
152
+ // `drop-fk`/`drop-check` are guarded on Postgres ONLY, and that is not a dialect
153
+ // split: SQLite emits no standalone statement for either kind — `renderUpNative`
154
+ // throws, because SQLite constraints are create-time-only and inline, so the change
155
+ // folds into a table recreate that rebuilds from the EXPECTED descriptor and never
156
+ // references the dropped constraint. SQLite is already replay-safe by construction;
157
+ // guarding Postgres makes the two dialects agree.
103
158
  case "add-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD CONSTRAINT ${quote(c.check.name)} CHECK (${c.check.expression});`;
104
- case "drop-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.check)};`;
159
+ // Same `ALTER TABLE IF EXISTS` gap as `drop-fk` above.
160
+ case "drop-check": return `ALTER TABLE IF EXISTS ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT IF EXISTS ${quote(c.check)};`;
105
161
  case "create-view": return renderCreateView(c.view, c.schema, /* orReplace */ false);
106
162
  case "drop-view": return renderDropView(c);
107
163
  case "replace-view": return renderCreateView(c.view, c.schema, /* orReplace */ true);
@@ -372,7 +428,9 @@ function renderViewComment(qualifiedView: string, comment: string | null): strin
372
428
  function renderDropView(c: Extract<Change, { kind: "drop-view" }>): string {
373
429
  const qualified = quoteQualifiedView(c.view, c.schema);
374
430
  const dependents = c.dependents ?? [];
375
- if (dependents.length === 0) return `DROP VIEW ${qualified};`;
431
+ // #313 `IF EXISTS` on both forms — this is the FORWARD renderer. `renderRestoreView`
432
+ // below stays bare: it is reached only from `renderDown`.
433
+ if (dependents.length === 0) return `DROP VIEW IF EXISTS ${qualified};`;
376
434
 
377
435
  const listed = dependents
378
436
  .map((d) => `-- ${d.schema}.${d.name} (${d.relkind === "m" ? "materialized view" : "view"})`)
@@ -385,7 +443,7 @@ function renderDropView(c: Extract<Change, { kind: "drop-view" }>): string {
385
443
  "-- restore them:",
386
444
  listed,
387
445
  rule,
388
- `DROP VIEW ${qualified} CASCADE;`,
446
+ `DROP VIEW IF EXISTS ${qualified} CASCADE;`,
389
447
  ].join("\n");
390
448
  }
391
449
 
@@ -216,13 +216,20 @@ function renderRecreate(
216
216
  function renderUpNative(c: Change): string {
217
217
  switch (c.kind) {
218
218
  case "create-table": return renderCreateTable(c.table);
219
- case "drop-table": return `DROP TABLE ${quote(c.table)};`;
219
+ // #313 — FORWARD drops are `IF EXISTS` so a committed chain applies to a VIRGIN
220
+ // database: the diff legitimately proposes dropping an object present in the live
221
+ // DB that no migration in the chain ever created. `renderDownNative` stays bare
222
+ // (a no-op rollback would still be recorded as done), and so does the
223
+ // recreate-and-copy rebuild's DROP above — that one drops a table the same recipe
224
+ // just INSERT…SELECTed from, where IF EXISTS turns a caught corruption into a
225
+ // silent one.
226
+ case "drop-table": return `DROP TABLE IF EXISTS ${quote(c.table)};`;
220
227
  case "rename-table": return `ALTER TABLE ${quote(c.from)} RENAME TO ${quote(c.to)};`;
221
228
  case "add-column": return `ALTER TABLE ${quote(c.table)} ADD COLUMN ${renderColumnInline(c.column)};`;
222
229
  case "drop-column": return `ALTER TABLE ${quote(c.table)} DROP COLUMN ${quote(c.column)};`;
223
230
  case "rename-column": return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.from)} TO ${quote(c.to)};`;
224
231
  case "add-index": return renderCreateIndex(c.table, c.index);
225
- case "drop-index": return `DROP INDEX ${quote(c.index)};`;
232
+ case "drop-index": return `DROP INDEX IF EXISTS ${quote(c.index)};`;
226
233
  case "add-check":
227
234
  case "drop-check":
228
235
  case "change-column-type":
@@ -61,6 +61,7 @@ import type {
61
61
  Dialect, SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
62
62
  CheckDescriptor, ViewDescriptor,
63
63
  } from "./types.js";
64
+ import { qualifiedDbName } from "./qualified-name.js";
64
65
  import { viewFingerprint } from "./view-fingerprint.js";
65
66
  import { resolveViewColumns, type ExpectedViewColumnInput } from "./view-column-types.js";
66
67
  import {
@@ -112,12 +113,55 @@ export interface ExpectedViewInput {
112
113
  sql?: string;
113
114
  dependsOn?: readonly string[];
114
115
  columns?: readonly ExpectedViewColumnInput[];
116
+ /**
117
+ * `resolutionKey()` of the object that declared this view — its PROVENANCE.
118
+ * Recorded in the provenance map and deliberately NEVER copied onto the
119
+ * `ViewDescriptor`: descriptors are serialized into the committed snapshot, and
120
+ * a descriptor that gains a field owes a `SNAPSHOT_FORMAT_VERSION` bump, which
121
+ * hard-fails every older reader. Optional — a caller that supplies no FQN gets a
122
+ * view with no provenance, which `scopeExpectedSchema` keeps (never guesses).
123
+ */
124
+ fqn?: string;
115
125
  }
116
126
 
127
+ /**
128
+ * Qualified physical name (`qualifiedDbName`) → the `resolutionKey()` of the
129
+ * metadata object that declared it. The ONLY sound basis for a per-command scope
130
+ * decision: a SQL name cannot be reversed into an FQN (naming strategies, `@table`
131
+ * overrides and TPH folding are all lossy), and a second metadata walk would have
132
+ * to re-implement Pass 1's skip rules — abstract, TPH subtype, no writable source,
133
+ * `@unmanaged` — and would drift from them.
134
+ */
135
+ export type SchemaProvenance = ReadonlyMap<string, string>;
136
+
137
+ export interface ExpectedSchemaWithProvenance {
138
+ snapshot: SchemaSnapshot;
139
+ provenance: SchemaProvenance;
140
+ }
141
+
142
+ /**
143
+ * The expected schema as every existing caller wants it. Thin wrapper over
144
+ * {@link buildExpectedSchemaWithProvenance}; byte-identical output.
145
+ */
117
146
  export function buildExpectedSchema(
118
147
  root: MetaData,
119
148
  opts?: BuildExpectedSchemaOptions,
120
149
  ): SchemaSnapshot {
150
+ return buildExpectedSchemaWithProvenance(root, opts).snapshot;
151
+ }
152
+
153
+ /**
154
+ * The expected schema PLUS the declaring FQN of every table and view in it.
155
+ *
156
+ * Provenance is threaded out of the passes that already hold the declaring node —
157
+ * Pass 2 has each table's entity, Pass 4 each view's input — so there is exactly
158
+ * one walk and one set of skip rules. Callers that filter by scope
159
+ * (`scopeExpectedSchema`) consume it; callers that don't use the wrapper above.
160
+ */
161
+ export function buildExpectedSchemaWithProvenance(
162
+ root: MetaData,
163
+ opts?: BuildExpectedSchemaOptions,
164
+ ): ExpectedSchemaWithProvenance {
121
165
  // D1 is SQLite at the SQL level; normalize it so downstream dialect checks
122
166
  // don't need to handle "d1" separately.
123
167
  const dialect = opts?.dialect === "d1" ? "sqlite" : opts?.dialect;
@@ -207,6 +251,12 @@ export function buildExpectedSchema(
207
251
  return byBareHit === AMBIGUOUS ? undefined : byBareHit;
208
252
  };
209
253
 
254
+ // Provenance: qualified physical name → declaring object's FQN. Recorded as the
255
+ // descriptors are built, never re-derived from a SQL name (lossy) and never by a
256
+ // second walk (it would have to duplicate Pass 1's skip rules and would drift from
257
+ // them — a TPH subtype, for one, shares its base's table and declares none of its own).
258
+ const provenance = new Map<string, string>();
259
+
210
260
  // Pass 2: build full descriptors with FK resolution.
211
261
  // Schema is resolved here (not stored in Pass 1) to avoid exactOptionalPropertyTypes
212
262
  // issues with `string | undefined` vs `schema?: string`.
@@ -214,6 +264,7 @@ export function buildExpectedSchema(
214
264
  const t = buildTable(entity, tableName, resolveTargetTable, root as MetaRoot, strategy, dialect);
215
265
  const schema = resolveTableSchema(entity);
216
266
  if (schema !== undefined) t.schema = schema;
267
+ provenance.set(qualifiedDbName(t), entity.resolutionKey());
217
268
  return t;
218
269
  });
219
270
 
@@ -288,13 +339,17 @@ export function buildExpectedSchema(
288
339
  // whether a view change can use a non-destructive CREATE OR REPLACE.
289
340
  const views: ViewDescriptor[] = (opts?.views ?? []).map((v) => {
290
341
  const columns = resolveViewColumns(v.columns, tables);
291
- return {
342
+ const descriptor: ViewDescriptor = {
292
343
  name: v.name,
293
344
  ...(v.schema !== undefined ? { schema: v.schema } : {}),
294
345
  ...(v.sql !== undefined ? { sql: v.sql, fingerprint: viewFingerprint(v.sql) } : {}),
295
346
  ...(v.dependsOn !== undefined ? { dependsOn: v.dependsOn } : {}),
296
347
  ...(columns !== undefined ? { columns } : {}),
297
348
  };
349
+ // The declaring FQN goes to the provenance map ONLY — never onto the descriptor,
350
+ // which is what the committed snapshot serializes (see ExpectedViewInput.fqn).
351
+ if (v.fqn !== undefined) provenance.set(qualifiedDbName(descriptor), v.fqn);
352
+ return descriptor;
298
353
  });
299
354
 
300
355
  // Collision guard: two DISTINCT metadata objects that resolve to the same generated
@@ -324,7 +379,7 @@ export function buildExpectedSchema(
324
379
  );
325
380
  }
326
381
 
327
- return { tables, views };
382
+ return { snapshot: { tables, views }, provenance };
328
383
  }
329
384
 
330
385
  /**
package/src/index.ts CHANGED
@@ -8,11 +8,23 @@
8
8
  // See docs/specs/2026-05-11-v0.2-sp4-migrate-ts-design.md.
9
9
 
10
10
  // Pipeline functions
11
- export { buildExpectedSchema } from "./expected-schema.js";
11
+ export { buildExpectedSchema, buildExpectedSchemaWithProvenance } from "./expected-schema.js";
12
+ export type { ExpectedSchemaWithProvenance, SchemaProvenance } from "./expected-schema.js";
12
13
  export { introspect, introspectPostgres, introspectSqlite } from "./introspect/index.js";
13
14
  export { diff } from "./diff/index.js";
14
15
  export { collectUnmanagedNames } from "./unmanaged.js";
15
- export { computeDrift, computeDriftFromActual, type ComputeDriftOptions } from "./drift/drift.js";
16
+ // Per-command scope (`migrate.scope`) see scope.ts for why the suppression is
17
+ // two-sided and why the pattern engine stays in @metaobjectsdev/sdk.
18
+ export {
19
+ scopeExpectedSchema,
20
+ declaredSchemasOf,
21
+ carryForwardOutOfScope,
22
+ excludeFromSnapshot,
23
+ scopedDiffInputs,
24
+ } from "./scope.js";
25
+ export type { ObjectScopePredicate, ScopedExpectedSchema, GovernedScope } from "./scope.js";
26
+ export { qualifiedDbName } from "./qualified-name.js";
27
+ export { computeDrift, computeDriftFromActual, type ComputeDriftOptions, type DriftResult } from "./drift/drift.js";
16
28
  export { classifyDrift, driftAgainstSnapshot } from "./drift/classify.js";
17
29
  export type { DriftClassification } from "./drift/classify.js";
18
30
  export { emit } from "./emit/index.js";
@@ -100,6 +112,10 @@ export {
100
112
  export { verifyReplay } from "./verify/replay.js";
101
113
  export type { VerifyReplayArgs, VerifyReplayResult } from "./verify/replay.js";
102
114
 
115
+ // An empty in-process database to replay a committed chain into (#313).
116
+ export { openReplayEngine } from "./verify/replay-engine.js";
117
+ export type { ReplayEngine } from "./verify/replay-engine.js";
118
+
103
119
  // Wrangler config helpers
104
120
  export {
105
121
  findWranglerConfig,
@@ -0,0 +1,22 @@
1
+ // The ONE qualified-physical-name form: `<schema>.<name>`, with an absent schema
2
+ // normalized to the Postgres default.
3
+ //
4
+ // Three things must key DB objects identically or the diff silently disagrees with
5
+ // itself: `diff`'s table/view identity maps, the declared-`@unmanaged` exclusion set
6
+ // (`collectUnmanagedNames`), and the out-of-scope exclusion set (`scopeExpectedSchema`).
7
+ // The last two are ACT-side suppressions matched against the first, so a name built a
8
+ // second way — a different default schema, a different separator — reads as "not
9
+ // suppressed" and the object it names comes back as a proposed DROP. One function.
10
+ //
11
+ // SQLite has no schema concept, so every SQLite object normalizes to the same prefix.
12
+ // That is harmless: it is a constant, and the un-prefixed names were already unique.
13
+
14
+ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
15
+
16
+ /** `<schema>.<name>`; an absent schema is the Postgres default (`public`). The
17
+ * parameter accepts an EXPLICIT `undefined` schema (not only an omitted key) so a
18
+ * caller holding a `string | undefined` can pass it straight through under
19
+ * `exactOptionalPropertyTypes` — the two spell the same thing here. */
20
+ export function qualifiedDbName(obj: { name: string; schema?: string | undefined }): string {
21
+ return `${obj.schema ?? DEFAULT_DB_SCHEMA_POSTGRES}.${obj.name}`;
22
+ }