@metaobjectsdev/migrate-ts 0.23.1 → 0.24.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 (59) hide show
  1. package/dist/diff/index.d.ts.map +1 -1
  2. package/dist/diff/index.js +17 -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 +37 -0
  14. package/dist/expected-schema.d.ts.map +1 -1
  15. package/dist/expected-schema.js +124 -16
  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 +17 -9
  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 +155 -15
  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
@@ -0,0 +1,161 @@
1
+ // An empty, throwaway database that lives INSIDE this process.
2
+ //
3
+ // The replay gate has to apply a whole committed chain from nothing. Doing that
4
+ // against the user's server would mean CREATE DATABASE — which needs CREATEDB,
5
+ // breaks behind a connection pooler, is restricted on managed Postgres, collides
6
+ // between parallel CI jobs sharing one server, and puts a DROP DATABASE next to a
7
+ // name derived from a real one (Postgres truncates identifiers at 63 bytes, so a
8
+ // long enough target derives a scratch name that truncates back ONTO the target).
9
+ // None of that is worth it when the engine runs locally and disposably: PGlite is
10
+ // real Postgres compiled to WASM and lives in this process; sqlite is a throwaway
11
+ // file in a private temp directory (see `openMemorySqlite` for why not `:memory:`).
12
+ // Nothing to provision, nothing to name, nothing to drop by mistake.
13
+ //
14
+ // Both drivers are OPTIONAL peers imported lazily. PGlite is ~22 MB of WASM and must
15
+ // not land in the node_modules of every adopter who only ever runs `meta gen`; the
16
+ // install hints mirror `buildKyselyFromUrl`'s. `cli` already depends on
17
+ // `@libsql/kysely-libsql` outright, so only a direct embedder can miss that one.
18
+ import { Kysely } from "kysely";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ /** Open an empty in-process database of the given dialect. */
23
+ export async function openReplayEngine(dialect) {
24
+ return dialect === "postgres" ? openPglite() : openMemorySqlite();
25
+ }
26
+ /**
27
+ * A throwaway sqlite database in a private temp directory, removed on dispose.
28
+ *
29
+ * NOT `:memory:`, and that is the whole point of this comment. Under
30
+ * `@libsql/kysely-libsql`, `:memory:` gives every CONNECTION its own database — so a
31
+ * table created inside a transaction is invisible the moment the transaction's
32
+ * connection is released. `applyPending` runs each migration file in a transaction,
33
+ * which means an in-memory engine would replay a whole chain into a series of
34
+ * throwaway databases, introspect an empty one, and never let migration 2 see
35
+ * migration 1's tables. The gate would pass having proved nothing.
36
+ *
37
+ * `file::memory:?cache=shared` fixes the visibility and breaks isolation instead —
38
+ * two engines in one process land in the SAME database — and libsql rejects the
39
+ * named `?mode=memory&cache=shared` form outright (`URL_PARAM_NOT_SUPPORTED`). A
40
+ * unique temp file is correct on both counts, and it is what the existing
41
+ * `test/integrity/replay.test.ts` has always used.
42
+ */
43
+ async function openMemorySqlite() {
44
+ let LibsqlDialect;
45
+ try {
46
+ const mod = await import("@libsql/kysely-libsql");
47
+ LibsqlDialect = mod.LibsqlDialect;
48
+ }
49
+ catch {
50
+ throw new Error(`the sqlite replay engine requires '@libsql/kysely-libsql'; install it to run 'meta verify --replay'`);
51
+ }
52
+ const dir = mkdtempSync(join(tmpdir(), "meta-replay-"));
53
+ const db = new Kysely({
54
+ dialect: new LibsqlDialect({ url: `file:${join(dir, "replay.db")}` }),
55
+ });
56
+ return disposable(db, async () => {
57
+ rmSync(dir, { recursive: true, force: true });
58
+ });
59
+ }
60
+ async function openPglite() {
61
+ let PGliteCtor;
62
+ try {
63
+ const mod = await import("@electric-sql/pglite");
64
+ PGliteCtor = mod.PGlite;
65
+ }
66
+ catch {
67
+ throw new Error(`the postgres replay engine requires '@electric-sql/pglite' (in-process WASM Postgres); ` +
68
+ `install it to run 'meta verify --replay' against a postgres chain`);
69
+ }
70
+ const { PostgresDialect } = await import("kysely");
71
+ // PGlite is Postgres compiled to WASM, and Emscripten propagates the WASM
72
+ // program's internal exit status into `process.exitCode` — it becomes 99 on the
73
+ // FIRST QUERY (not on teardown) and stays there for the life of the process.
74
+ // Opening an engine must not decide what the HOST process exits with, so the
75
+ // caller's value is captured here and restored on dispose.
76
+ //
77
+ // `bin/meta.ts` ends with `process.exit(code)`, which overrides this, so the
78
+ // shipped CLI never showed it. Anything that does NOT force its own exit did:
79
+ // this package's `bun test` exited 99 on 0 failures, turning two
80
+ // `ci-local.sh --only ts` gates red with no failing test to point at, and an
81
+ // embedder calling `openReplayEngine` directly would exit non-zero on success.
82
+ const hostExitCode = process.exitCode;
83
+ const pg = new PGliteCtor();
84
+ const db = new Kysely({
85
+ dialect: new PostgresDialect({ pool: pgliteAsPool(pg) }),
86
+ });
87
+ // `?? 0` is load-bearing, not defensive: assigning `undefined` to
88
+ // `process.exitCode` is a NO-OP under Bun (measured — set 99, assign
89
+ // `undefined`, it stays 99; assign 0 and it clears). The pristine value IS
90
+ // `undefined`, so restoring it literally runs and changes nothing — which is
91
+ // the shape this bug already took once during the fix.
92
+ //
93
+ // The restore sits in a `finally` because this close is frequently the SECOND:
94
+ // `disposable` runs `db.destroy()` first, which drives the pool's `end()`,
95
+ // which already called `pg.close()`, so this call throws `PGlite is closed`.
96
+ return disposable(db, async () => {
97
+ try {
98
+ await pg.close();
99
+ }
100
+ finally {
101
+ process.exitCode = hostExitCode ?? 0;
102
+ }
103
+ });
104
+ }
105
+ /**
106
+ * Adapt PGlite to the `pg.Pool` shape kysely's `PostgresDialect` expects: `connect()`
107
+ * returning a client with `query()`/`release()`, plus `end()`. PGlite offers only
108
+ * `query`/`close`, so without this the dialect cannot drive it at all.
109
+ *
110
+ * PGlite is a SINGLE session, so every `connect()` hands back the same underlying
111
+ * instance. That is correct here — a replay is strictly sequential — and it is what
112
+ * makes a session advisory lock taken on one kysely connection visible to the next.
113
+ *
114
+ * `command` is read by kysely only to decide whether to report numAffectedRows; the
115
+ * replay path never reads it, so PGlite's `statement` (or a SELECT default) suffices.
116
+ */
117
+ function pgliteAsPool(pg) {
118
+ return {
119
+ async connect() {
120
+ return {
121
+ async query(sqlText, params) {
122
+ if (typeof sqlText !== "string") {
123
+ throw new Error(`the PGlite replay engine does not support cursors`);
124
+ }
125
+ const r = await pg.query(sqlText, params ? [...params] : []);
126
+ return {
127
+ command: r.statement ?? "SELECT",
128
+ rowCount: r.affectedRows ?? r.rows.length,
129
+ rows: r.rows,
130
+ };
131
+ },
132
+ release() { },
133
+ };
134
+ },
135
+ async end() {
136
+ await pg.close();
137
+ },
138
+ };
139
+ }
140
+ function disposable(db, closeEngine) {
141
+ let disposed = false;
142
+ return {
143
+ db,
144
+ dispose: async () => {
145
+ if (disposed)
146
+ return;
147
+ disposed = true;
148
+ // Both swallow: the engine is throwaway, and a teardown error must not mask
149
+ // the replay verdict the caller is about to report.
150
+ try {
151
+ await db.destroy();
152
+ }
153
+ catch { /* ignore */ }
154
+ try {
155
+ await closeEngine();
156
+ }
157
+ catch { /* ignore */ }
158
+ },
159
+ };
160
+ }
161
+ //# sourceMappingURL=replay-engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-engine.js","sourceRoot":"","sources":["../../src/verify/replay-engine.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import type { Kysely } from "kysely";
2
2
  import { type DriftClassification } from "../drift/classify.js";
3
+ import { type GovernedScope } from "../scope.js";
3
4
  import type { Dialect, SchemaSnapshot } from "../types.js";
4
5
  export interface VerifyReplayArgs {
5
6
  /** A FRESH, throwaway database. Replay applies every migration into it from empty. */
@@ -9,6 +10,20 @@ export interface VerifyReplayArgs {
9
10
  migrationsDir: string;
10
11
  /** The committed snapshot the migrations are expected to reproduce. */
11
12
  snapshot: SchemaSnapshot;
13
+ /**
14
+ * The scope decision the run made, as `scopeExpectedSchema` reports it.
15
+ *
16
+ * A project declaring `migrate.scope` carries the OTHER owner's tables into its
17
+ * committed snapshot on purpose (`carryForwardOutOfScope`), and its chain — also on
18
+ * purpose — never creates them. Without this they read as missing on every replay,
19
+ * so a scoped project could never use this check at all.
20
+ *
21
+ * Excluded from the SNAPSHOT side only: the replayed database never had them
22
+ * either, so there is nothing to suppress on the actual side. Omitted ⇒ the
23
+ * comparison is byte-for-byte what it was, which is what every unscoped project
24
+ * gets.
25
+ */
26
+ governed?: GovernedScope;
12
27
  }
13
28
  export interface VerifyReplayResult extends DriftClassification {
14
29
  /** True when the replayed schema matches the snapshot (no drift, no unmanaged). */
@@ -1 +1 @@
1
- {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/verify/replay.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAIrC,OAAO,EAAwB,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AACtF,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE3D,MAAM,WAAW,gBAAgB;IAC/B,sFAAsF;IACtF,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACpC,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,QAAQ,CAAC,CAAC;IACjD,8EAA8E;IAC9E,aAAa,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,QAAQ,EAAE,cAAc,CAAC;CAC1B;AAED,MAAM,WAAW,kBAAmB,SAAQ,mBAAmB;IAC7D,mFAAmF;IACnF,EAAE,EAAE,OAAO,CAAC;CACb;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAYtF"}
1
+ {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/verify/replay.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAIrC,OAAO,EAAwB,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AACtF,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE3D,MAAM,WAAW,gBAAgB;IAC/B,sFAAsF;IACtF,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACpC,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,QAAQ,CAAC,CAAC;IACjD,8EAA8E;IAC9E,aAAa,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,QAAQ,EAAE,cAAc,CAAC;IACzB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED,MAAM,WAAW,kBAAmB,SAAQ,mBAAmB;IAC7D,mFAAmF;IACnF,EAAE,EAAE,OAAO,CAAC;CACb;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAiBtF"}
@@ -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.1",
3
+ "version": "0.24.0",
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.1"
48
+ "@metaobjectsdev/metadata": "0.24.0"
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.
@@ -148,9 +151,16 @@ export async function diff(
148
151
  // schemas it mentions), else null = no scoping (empty model → prior whole-DB
149
152
  // behavior). A table outside the scope is excluded from both sides, so a
150
153
  // co-located schema owned by another app is neither dropped nor reported.
151
- const declaredSchemas = new Set(
152
- args.expected.tables.map((t) => t.schema ?? DEFAULT_DB_SCHEMA_POSTGRES),
153
- );
154
+ const declaredSchemas = new Set([
155
+ ...args.expected.tables.map((t) => t.schema ?? DEFAULT_DB_SCHEMA_POSTGRES),
156
+ // A model that declares views in a schema with no table of its own (e.g. an
157
+ // API/read-model schema like `p3_api` sitting alongside an all-`public`
158
+ // entity model) must still bring that schema into scope — otherwise its
159
+ // views are silently excluded from BOTH sides of the diff (never compared,
160
+ // so real drift in an opaque @sql body or a genuine missing/extra view goes
161
+ // undetected) rather than gated on it as an owned schema.
162
+ ...args.expected.views.map((v) => v.schema ?? DEFAULT_DB_SCHEMA_POSTGRES),
163
+ ]);
154
164
  const scopeSchemas: Set<string> | null =
155
165
  args.scopeSchemas !== undefined
156
166
  ? new Set(args.scopeSchemas)
@@ -583,9 +593,7 @@ function diffTableChecks(
583
593
  }
584
594
  }
585
595
 
586
- function viewIdentity(v: { name: string; schema?: string }): string {
587
- return (v.schema ?? DEFAULT_DB_SCHEMA_POSTGRES) + "." + v.name;
588
- }
596
+ const viewIdentity = qualifiedDbName;
589
597
 
590
598
  /**
591
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":