@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.
- package/dist/diff/index.d.ts.map +1 -1
- package/dist/diff/index.js +17 -7
- package/dist/diff/index.js.map +1 -1
- package/dist/drift/drift.d.ts +32 -2
- package/dist/drift/drift.d.ts.map +1 -1
- package/dist/drift/drift.js +15 -8
- package/dist/drift/drift.js.map +1 -1
- package/dist/emit/postgres.js +69 -12
- package/dist/emit/postgres.js.map +1 -1
- package/dist/emit/sqlite.d.ts.map +1 -1
- package/dist/emit/sqlite.js +9 -2
- package/dist/emit/sqlite.js.map +1 -1
- package/dist/expected-schema.d.ts +37 -0
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +124 -16
- package/dist/expected-schema.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/qualified-name.d.ts +9 -0
- package/dist/qualified-name.d.ts.map +1 -0
- package/dist/qualified-name.js +21 -0
- package/dist/qualified-name.js.map +1 -0
- package/dist/scope.d.ts +121 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +193 -0
- package/dist/scope.js.map +1 -0
- package/dist/snapshot/plan.d.ts +23 -1
- package/dist/snapshot/plan.d.ts.map +1 -1
- package/dist/snapshot/plan.js +17 -9
- package/dist/snapshot/plan.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/unmanaged.d.ts.map +1 -1
- package/dist/unmanaged.js +3 -3
- package/dist/unmanaged.js.map +1 -1
- package/dist/verify/replay-engine.d.ts +10 -0
- package/dist/verify/replay-engine.d.ts.map +1 -0
- package/dist/verify/replay-engine.js +161 -0
- package/dist/verify/replay-engine.js.map +1 -0
- package/dist/verify/replay.d.ts +15 -0
- package/dist/verify/replay.d.ts.map +1 -1
- package/dist/verify/replay.js +7 -1
- package/dist/verify/replay.js.map +1 -1
- package/package.json +14 -3
- package/src/diff/index.ts +17 -9
- package/src/drift/drift.ts +55 -15
- package/src/emit/postgres.ts +70 -12
- package/src/emit/sqlite.ts +9 -2
- package/src/expected-schema.ts +155 -15
- package/src/index.ts +18 -2
- package/src/qualified-name.ts +22 -0
- package/src/scope.ts +261 -0
- package/src/snapshot/plan.ts +45 -13
- package/src/types.ts +17 -0
- package/src/unmanaged.ts +2 -3
- package/src/verify/replay-engine.ts +184 -0
- 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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"}
|
package/dist/verify/replay.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/verify/replay.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/drift/drift.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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<
|
|
140
|
+
): Promise<DriftResult> {
|
|
101
141
|
const actual = await introspect(db, dialect);
|
|
102
142
|
return computeDriftFromActual(actual, dialect, metadata, opts);
|
|
103
143
|
}
|
package/src/emit/postgres.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/emit/sqlite.ts
CHANGED
|
@@ -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
|
-
|
|
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":
|