@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.
- package/dist/diff/index.d.ts.map +1 -1
- package/dist/diff/index.js +8 -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 +35 -0
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +29 -2
- 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 +8 -7
- 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 +57 -2
- 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
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.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.
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
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.
|
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":
|
package/src/expected-schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|