@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
package/src/expected-schema.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
FIELD_SUBTYPE_INET,
|
|
44
44
|
FIELD_SUBTYPE_ENUM,
|
|
45
45
|
FIELD_ATTR_VALUES,
|
|
46
|
+
FIELD_ATTR_INT_VALUE_MAP,
|
|
46
47
|
FIELD_ATTR_OBJECT_REF,
|
|
47
48
|
FIELD_ATTR_STORAGE,
|
|
48
49
|
FIELD_ATTR_DB_COLUMN_TYPE,
|
|
@@ -60,6 +61,7 @@ import type {
|
|
|
60
61
|
Dialect, SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
|
|
61
62
|
CheckDescriptor, ViewDescriptor,
|
|
62
63
|
} from "./types.js";
|
|
64
|
+
import { qualifiedDbName } from "./qualified-name.js";
|
|
63
65
|
import { viewFingerprint } from "./view-fingerprint.js";
|
|
64
66
|
import { resolveViewColumns, type ExpectedViewColumnInput } from "./view-column-types.js";
|
|
65
67
|
import {
|
|
@@ -111,12 +113,55 @@ export interface ExpectedViewInput {
|
|
|
111
113
|
sql?: string;
|
|
112
114
|
dependsOn?: readonly string[];
|
|
113
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;
|
|
125
|
+
}
|
|
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;
|
|
114
140
|
}
|
|
115
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The expected schema as every existing caller wants it. Thin wrapper over
|
|
144
|
+
* {@link buildExpectedSchemaWithProvenance}; byte-identical output.
|
|
145
|
+
*/
|
|
116
146
|
export function buildExpectedSchema(
|
|
117
147
|
root: MetaData,
|
|
118
148
|
opts?: BuildExpectedSchemaOptions,
|
|
119
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 {
|
|
120
165
|
// D1 is SQLite at the SQL level; normalize it so downstream dialect checks
|
|
121
166
|
// don't need to handle "d1" separately.
|
|
122
167
|
const dialect = opts?.dialect === "d1" ? "sqlite" : opts?.dialect;
|
|
@@ -206,6 +251,12 @@ export function buildExpectedSchema(
|
|
|
206
251
|
return byBareHit === AMBIGUOUS ? undefined : byBareHit;
|
|
207
252
|
};
|
|
208
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
|
+
|
|
209
260
|
// Pass 2: build full descriptors with FK resolution.
|
|
210
261
|
// Schema is resolved here (not stored in Pass 1) to avoid exactOptionalPropertyTypes
|
|
211
262
|
// issues with `string | undefined` vs `schema?: string`.
|
|
@@ -213,6 +264,7 @@ export function buildExpectedSchema(
|
|
|
213
264
|
const t = buildTable(entity, tableName, resolveTargetTable, root as MetaRoot, strategy, dialect);
|
|
214
265
|
const schema = resolveTableSchema(entity);
|
|
215
266
|
if (schema !== undefined) t.schema = schema;
|
|
267
|
+
provenance.set(qualifiedDbName(t), entity.resolutionKey());
|
|
216
268
|
return t;
|
|
217
269
|
});
|
|
218
270
|
|
|
@@ -287,13 +339,17 @@ export function buildExpectedSchema(
|
|
|
287
339
|
// whether a view change can use a non-destructive CREATE OR REPLACE.
|
|
288
340
|
const views: ViewDescriptor[] = (opts?.views ?? []).map((v) => {
|
|
289
341
|
const columns = resolveViewColumns(v.columns, tables);
|
|
290
|
-
|
|
342
|
+
const descriptor: ViewDescriptor = {
|
|
291
343
|
name: v.name,
|
|
292
344
|
...(v.schema !== undefined ? { schema: v.schema } : {}),
|
|
293
345
|
...(v.sql !== undefined ? { sql: v.sql, fingerprint: viewFingerprint(v.sql) } : {}),
|
|
294
346
|
...(v.dependsOn !== undefined ? { dependsOn: v.dependsOn } : {}),
|
|
295
347
|
...(columns !== undefined ? { columns } : {}),
|
|
296
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;
|
|
297
353
|
});
|
|
298
354
|
|
|
299
355
|
// Collision guard: two DISTINCT metadata objects that resolve to the same generated
|
|
@@ -323,7 +379,7 @@ export function buildExpectedSchema(
|
|
|
323
379
|
);
|
|
324
380
|
}
|
|
325
381
|
|
|
326
|
-
return { tables, views };
|
|
382
|
+
return { snapshot: { tables, views }, provenance };
|
|
327
383
|
}
|
|
328
384
|
|
|
329
385
|
/**
|
|
@@ -698,12 +754,35 @@ function buildChecks(
|
|
|
698
754
|
if (field.resolvedIsArray()) continue;
|
|
699
755
|
const col = resolveColumnName(field, strategy);
|
|
700
756
|
const qcol = quoteCheckCol(col);
|
|
701
|
-
// Enum membership check.
|
|
757
|
+
// Enum membership check. An INT-BACKED enum (@intValueMap, design D5) stores
|
|
758
|
+
// the mapped integers, so the CHECK lists those integers unquoted rather than
|
|
759
|
+
// the member strings — `IN (0, 5, 9)`, not `IN ('DRAFT', …)`. The members are
|
|
760
|
+
// still the SSOT: the integers are read THROUGH the map, keyed by member, so a
|
|
761
|
+
// member with no mapping cannot silently vanish from the constraint.
|
|
702
762
|
if (field.subType === FIELD_SUBTYPE_ENUM) {
|
|
703
763
|
const raw = field.attr(FIELD_ATTR_VALUES);
|
|
704
764
|
if (Array.isArray(raw) && raw.length > 0) {
|
|
705
765
|
const values = raw.map((v) => String(v));
|
|
706
|
-
const
|
|
766
|
+
const intMap = intValueMapOf(field);
|
|
767
|
+
let expression: string;
|
|
768
|
+
if (intMap !== undefined) {
|
|
769
|
+
// The loader pins key-set-equals-@values (Check 5b) in every port, so every
|
|
770
|
+
// member resolves. Guard anyway: emitting a partial IN list would silently
|
|
771
|
+
// reject rows the model considers valid.
|
|
772
|
+
const ints = values.map((v) => {
|
|
773
|
+
const n = intMap[v];
|
|
774
|
+
if (typeof n !== "number") {
|
|
775
|
+
throw new Error(
|
|
776
|
+
`field.enum '${field.name}' @intValueMap has no integer for member '${v}' — ` +
|
|
777
|
+
`cannot build the CHECK constraint for column '${col}'.`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
return String(n);
|
|
781
|
+
});
|
|
782
|
+
expression = `${qcol} IN (${ints.join(", ")})`;
|
|
783
|
+
} else {
|
|
784
|
+
expression = `${qcol} IN (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")})`;
|
|
785
|
+
}
|
|
707
786
|
checks.push({ name: `${tableName}_${col}_chk`, expression });
|
|
708
787
|
}
|
|
709
788
|
}
|
|
@@ -842,11 +921,27 @@ function buildForeignKeys(
|
|
|
842
921
|
|
|
843
922
|
// Target columns: prefer explicit multi-field dotted form, else delegate
|
|
844
923
|
// to MetaReferenceIdentity.resolvedTargetPkField (single field → target's
|
|
845
|
-
// primary identity → "id" fallback).
|
|
924
|
+
// primary identity → "id" fallback). Each target FIELD name must resolve to
|
|
925
|
+
// its PHYSICAL column via the target entity's own @column override (e.g. a
|
|
926
|
+
// PK field `id` with `@column: "Id"`), exactly like fkCols above — the raw
|
|
927
|
+
// naming strategy alone would emit the logical name and phantom-diff every
|
|
928
|
+
// FK into that table (expected ["id"] vs actual ["Id"]).
|
|
929
|
+
// targetEntity may be package-qualified (FQN); findObject is keyed by bare
|
|
930
|
+
// name — same fallback as resolvedTargetPkField/resolveTargetTable.
|
|
931
|
+
const targetObj = root.findObject(targetEntity)
|
|
932
|
+
?? (targetEntity.includes("::")
|
|
933
|
+
? root.findObject(targetEntity.slice(targetEntity.lastIndexOf("::") + 2))
|
|
934
|
+
: undefined);
|
|
846
935
|
const explicitTargetFields = refChild.targetFields;
|
|
847
|
-
const
|
|
848
|
-
? explicitTargetFields
|
|
849
|
-
: [
|
|
936
|
+
const targetFieldNames = explicitTargetFields.length > 1
|
|
937
|
+
? explicitTargetFields
|
|
938
|
+
: [refChild.resolvedTargetPkField(root) ?? "id"];
|
|
939
|
+
const refColumns = targetFieldNames.map((jsName) => {
|
|
940
|
+
const targetField = targetObj ? findField(targetObj, jsName) : undefined;
|
|
941
|
+
return targetField
|
|
942
|
+
? resolveColumnName(targetField, strategy)
|
|
943
|
+
: applyColumnNamingStrategy(jsName, strategy);
|
|
944
|
+
});
|
|
850
945
|
|
|
851
946
|
const { onDelete, onUpdate } = resolveReferentialActions(entity, refChild);
|
|
852
947
|
// An explicit @constraintName adopts an existing FK name (e.g. a database
|
|
@@ -925,12 +1020,29 @@ function buildColumn(
|
|
|
925
1020
|
};
|
|
926
1021
|
|
|
927
1022
|
if (typeof defaultRaw === "string") {
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
1023
|
+
// An INT-BACKED enum's @default names a MEMBER SYMBOL, but the column holds the
|
|
1024
|
+
// mapped integer — emitting `DEFAULT 'DRAFT'` on an `integer` column is
|
|
1025
|
+
// un-appliable DDL. Lower it through the map. The member is already validated
|
|
1026
|
+
// against @values by the loader (FR-011 Check 5), so a miss here is unreachable;
|
|
1027
|
+
// throw rather than silently emit the symbol, which would fail only at apply time.
|
|
1028
|
+
const enumIntMap = field.subType === FIELD_SUBTYPE_ENUM ? intValueMapOf(field) : undefined;
|
|
1029
|
+
if (enumIntMap !== undefined) {
|
|
1030
|
+
const mapped = enumIntMap[defaultRaw];
|
|
1031
|
+
if (typeof mapped !== "number") {
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
`field.enum '${field.name}' @default '${defaultRaw}' has no entry in @intValueMap — ` +
|
|
1034
|
+
`cannot lower the column default for '${col.name}'.`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
col.default = { kind: "literal", value: String(mapped) };
|
|
1038
|
+
} else {
|
|
1039
|
+
// #235: an EMPTY-string default (`@default: ""`) is a real literal default —
|
|
1040
|
+
// codegen emits `.default("")` and the DB gets `DEFAULT ''`, so dropping it here
|
|
1041
|
+
// (a falsy `.length > 0` check) made the column drift forever on sqlite/d1 and
|
|
1042
|
+
// disagree with codegen. Keep it as a literal; only `undefined` means "no default".
|
|
1043
|
+
const isExpr = defaultRaw.length > 0 && EXPR_DEFAULT_PATTERNS.some((re) => re.test(defaultRaw));
|
|
1044
|
+
col.default = { kind: isExpr ? "expr" : "literal", value: defaultRaw };
|
|
1045
|
+
}
|
|
934
1046
|
} else if (typeof defaultRaw === "boolean" || typeof defaultRaw === "number") {
|
|
935
1047
|
col.default = { kind: "literal", value: String(defaultRaw) };
|
|
936
1048
|
} else {
|
|
@@ -978,8 +1090,10 @@ function buildColumn(
|
|
|
978
1090
|
*/
|
|
979
1091
|
function arrayElementSqlType(field: MetaData): SqlType | undefined {
|
|
980
1092
|
switch (field.subType) {
|
|
1093
|
+
// enum[] stores as text[] — membership is app-level (no CHECK — see buildChecks).
|
|
1094
|
+
// An INT-BACKED enum[] (@intValueMap, design D7) stores as integer[] instead.
|
|
1095
|
+
case FIELD_SUBTYPE_ENUM: return isIntBackedEnum(field) ? { kind: "integer", bits: 32 } : { kind: "text" };
|
|
981
1096
|
case FIELD_SUBTYPE_STRING:
|
|
982
|
-
case FIELD_SUBTYPE_ENUM: // enum[] stores as text[]; membership is app-level (no CHECK — see buildChecks)
|
|
983
1097
|
case FIELD_SUBTYPE_URI: return { kind: "text" };
|
|
984
1098
|
case FIELD_SUBTYPE_UUID: return { kind: "uuid" };
|
|
985
1099
|
case FIELD_SUBTYPE_INT: return { kind: "integer", bits: 32 };
|
|
@@ -1078,7 +1192,33 @@ function subtypeToSqlType(field: MetaData): SqlType {
|
|
|
1078
1192
|
// stores as text (the native inet column would reject a not-strictly-valid
|
|
1079
1193
|
// value at INSERT). ADR-0039: resolving — @lenient may be inherited via extends.
|
|
1080
1194
|
case FIELD_SUBTYPE_INET: return field.attr(FIELD_ATTR_LENIENT) === true ? { kind: "text" } : { kind: "inet" };
|
|
1195
|
+
// A string-backed field.enum is a text column with a membership CHECK; an
|
|
1196
|
+
// INT-BACKED one (@intValueMap, design D5) stores the mapped integer instead.
|
|
1197
|
+
// The TS/wire type is the member string either way — only the column differs.
|
|
1198
|
+
case FIELD_SUBTYPE_ENUM: return isIntBackedEnum(field) ? { kind: "integer", bits: 32 } : { kind: "text" };
|
|
1081
1199
|
default: return { kind: "text" }; // unknown → text fallback
|
|
1082
1200
|
}
|
|
1083
1201
|
}
|
|
1084
1202
|
|
|
1203
|
+
/**
|
|
1204
|
+
* True when this `field.enum` persists as an integer — i.e. it carries an
|
|
1205
|
+
* `@intValueMap` (design D5).
|
|
1206
|
+
*
|
|
1207
|
+
* ADR-0039: RESOLVING (`attr`, not `ownAttr`). Post-#246 a shared (root-level
|
|
1208
|
+
* abstract) enum OWNS the map and consuming fields inherit it — declaring an own
|
|
1209
|
+
* `@intValueMap` against a shared super is `ERR_ENUM_EXTENDS_VALUES_CONFLICT`. So
|
|
1210
|
+
* the inherited case is not an edge case, it is the CANONICAL authoring shape, and
|
|
1211
|
+
* an own-only read here would emit a `text` column for an integer-encoded value on
|
|
1212
|
+
* every consuming field of every shared enum.
|
|
1213
|
+
*/
|
|
1214
|
+
function isIntBackedEnum(field: MetaData): boolean {
|
|
1215
|
+
return intValueMapOf(field) !== undefined;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** The resolved `@intValueMap` as a plain record, or undefined when absent. */
|
|
1219
|
+
export function intValueMapOf(field: MetaData): Record<string, number> | undefined {
|
|
1220
|
+
const raw = field.attr(FIELD_ATTR_INT_VALUE_MAP);
|
|
1221
|
+
if (raw === undefined || raw === null || typeof raw !== "object") return undefined;
|
|
1222
|
+
return raw as Record<string, number>;
|
|
1223
|
+
}
|
|
1224
|
+
|
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
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Per-command scope — narrowing a migrate/verify run to the objects it governs.
|
|
2
|
+
//
|
|
3
|
+
// A consumer sharing a database with another owner declares
|
|
4
|
+
// `"migrate": { "scope": ["acme::platform::**"] }`. Tables and views outside that
|
|
5
|
+
// scope are neither created nor dropped, which takes TWO suppressions:
|
|
6
|
+
//
|
|
7
|
+
// 1. drop them from the EXPECTED side, so nothing is created or altered;
|
|
8
|
+
// 2. suppress the same names on the ACTUAL side (via `diff`'s `unmanagedNames`,
|
|
9
|
+
// the seam `@unmanaged` already uses), so nothing is dropped.
|
|
10
|
+
//
|
|
11
|
+
// Doing only (1) is strictly worse than doing nothing: every out-of-scope table that
|
|
12
|
+
// EXISTS in the database becomes a proposed `DROP TABLE` — the precise hazard this
|
|
13
|
+
// feature exists to remove. `scopeExpectedSchema` therefore returns both halves and
|
|
14
|
+
// callers must thread `outOfScope` into the diff.
|
|
15
|
+
//
|
|
16
|
+
// There is a THIRD half, and it is the one that bites hardest when the scope is
|
|
17
|
+
// wrong. `diff` derives its SCHEMA scope from the schemas the expected side
|
|
18
|
+
// mentions, falling back to "no schema scoping at all" when expected is empty (the
|
|
19
|
+
// legacy whole-DB path for a project with no model). A scope matching NOTHING
|
|
20
|
+
// empties `expected`, reaches that fallback, and every actual table in every schema
|
|
21
|
+
// becomes a drop candidate — another owner's included, which was never in `expected`
|
|
22
|
+
// so it has no provenance and never lands in `outOfScope`. Narrowing must never
|
|
23
|
+
// WIDEN. `declaredSchemas` below reports the UNSCOPED model's schemas so callers can
|
|
24
|
+
// pin `diff`'s `scopeSchemas` to a property of the whole model, which `migrate.scope`
|
|
25
|
+
// then cannot move in either direction.
|
|
26
|
+
//
|
|
27
|
+
// THE RULE THAT FOLLOWS FROM THAT, stated once because it is easy to read the other
|
|
28
|
+
// way: **a scope narrows which OBJECTS the tool governs, never which SCHEMAS it is
|
|
29
|
+
// allowed to see.** Pinning `scopeSchemas` to the unscoped model means a scope that
|
|
30
|
+
// excludes every declared object in schema `X` leaves `X` in scope, so another
|
|
31
|
+
// owner's UNDECLARED table in `X` stays a drop candidate — exactly as it would be on
|
|
32
|
+
// an unscoped run of the same model. That is deliberate: a schema this model
|
|
33
|
+
// declares into is a schema this model manages, and deriving the schema set from the
|
|
34
|
+
// survivors instead is precisely the inversion above. Declaring a scope is not a way
|
|
35
|
+
// to hand a schema over; removing the objects from the model is.
|
|
36
|
+
//
|
|
37
|
+
// `scopedDiffInputs` exists so no caller has to remember any of this: it returns all
|
|
38
|
+
// three obligations as one object, and every scoped `diff` call goes through it.
|
|
39
|
+
|
|
40
|
+
import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
|
|
41
|
+
import type { DiffArgs } from "./diff/index.js";
|
|
42
|
+
import type { ExpectedSchemaWithProvenance } from "./expected-schema.js";
|
|
43
|
+
import { qualifiedDbName } from "./qualified-name.js";
|
|
44
|
+
import type { SchemaSnapshot } from "./types.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decides whether an object's fully-qualified name (`resolutionKey()`) is governed
|
|
48
|
+
* by this run. Supplied by the caller as a PREDICATE so migrate-ts never carries a
|
|
49
|
+
* second implementation of the scope-pattern grammar — `matchesScope` in
|
|
50
|
+
* `@metaobjectsdev/sdk` is the only one, and the CLI adapts a compiled scope to
|
|
51
|
+
* this seam.
|
|
52
|
+
*/
|
|
53
|
+
export type ObjectScopePredicate = (fqn: string) => boolean;
|
|
54
|
+
|
|
55
|
+
export interface ScopedExpectedSchema {
|
|
56
|
+
/** The expected schema narrowed to the governed objects. */
|
|
57
|
+
snapshot: SchemaSnapshot;
|
|
58
|
+
/**
|
|
59
|
+
* Qualified physical names (`<schema>.<name>`) of the tables and views removed
|
|
60
|
+
* above. Reaches `diff`'s `unmanagedNames` (MERGED with `collectUnmanagedNames`,
|
|
61
|
+
* never replacing it) so the actual side is suppressed too — `scopedDiffInputs`
|
|
62
|
+
* does that merge; see the module header for why omitting it inverts the feature.
|
|
63
|
+
*/
|
|
64
|
+
outOfScope: string[];
|
|
65
|
+
/**
|
|
66
|
+
* The database schemas the UNSCOPED model declares, for `diff`'s `scopeSchemas`.
|
|
67
|
+
* `scopedDiffInputs` threads it — see the module header: without it a scope
|
|
68
|
+
* matching nothing hands `diff` an empty expected side, which it reads as "no
|
|
69
|
+
* model, govern the whole database".
|
|
70
|
+
*
|
|
71
|
+
* `undefined` when no predicate was supplied (so `diff` derives its own set from
|
|
72
|
+
* an untouched `expected`, exactly as before — an unscoped project's arguments are
|
|
73
|
+
* unchanged) and also when the unscoped model declares no tables or views at all
|
|
74
|
+
* (nothing to derive from; `diff`'s legacy whole-DB fallback is preserved).
|
|
75
|
+
*/
|
|
76
|
+
declaredSchemas?: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Carry an out-of-scope object forward into the snapshot a run is about to commit.
|
|
81
|
+
*
|
|
82
|
+
* The committed snapshot is built from the metadata-expected schema, which a scoped
|
|
83
|
+
* run has already narrowed — so accepting a scoped run DELETES every out-of-scope
|
|
84
|
+
* entry the previous snapshot held. Widening or removing `migrate.scope` later then
|
|
85
|
+
* proposes `CREATE TABLE` for a table that exists, and the migration fails at apply.
|
|
86
|
+
*
|
|
87
|
+
* `prior` is the snapshot (or introspected schema) the run diffed against, and the
|
|
88
|
+
* entries taken from it are exactly the ones this run excluded — nothing else is
|
|
89
|
+
* carried, so a table the model never declared is unaffected either way. An empty
|
|
90
|
+
* `outOfScope` returns the SAME object, so an unscoped run commits a byte-identical
|
|
91
|
+
* snapshot.
|
|
92
|
+
*/
|
|
93
|
+
export function carryForwardOutOfScope(
|
|
94
|
+
next: SchemaSnapshot,
|
|
95
|
+
prior: SchemaSnapshot,
|
|
96
|
+
outOfScope: readonly string[],
|
|
97
|
+
): SchemaSnapshot {
|
|
98
|
+
if (outOfScope.length === 0) return next;
|
|
99
|
+
const excluded = new Set(outOfScope);
|
|
100
|
+
return {
|
|
101
|
+
...next,
|
|
102
|
+
tables: [...next.tables, ...splitOnName(prior.tables, excluded).named],
|
|
103
|
+
views: [...next.views, ...splitOnName(prior.views, excluded).named],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drop the out-of-scope entries from a COMMITTED SNAPSHOT, producing the same
|
|
109
|
+
* three-part shape `scopeExpectedSchema` produces so the result can go straight
|
|
110
|
+
* through {@link scopedDiffInputs}.
|
|
111
|
+
*
|
|
112
|
+
* `verify`'s committed-snapshot gate (#292) needs this: `unmanagedNames` suppresses
|
|
113
|
+
* only the ACTUAL side, which is right when the expected side is the metadata (it is
|
|
114
|
+
* already scoped) and wrong here, where the expected side IS the snapshot — a
|
|
115
|
+
* snapshot written before the scope was declared still carries the other owner's
|
|
116
|
+
* tables, and leaving them in reports a phantom disagreement about an object this
|
|
117
|
+
* consumer does not manage.
|
|
118
|
+
*
|
|
119
|
+
* `governed` is the scope decision the caller's drift comparison already made — pass
|
|
120
|
+
* the `DriftResult` itself, which satisfies this shape. Taking `declaredSchemas`
|
|
121
|
+
* from there rather than re-deriving it from the snapshot is what closes the last
|
|
122
|
+
* whole-database door: a snapshot that is present but EMPTY (a never-migrated
|
|
123
|
+
* project) declares no schemas at all, so deriving from it hands `diff` nothing and
|
|
124
|
+
* reaches its "no model, govern the whole database" fallback — the very inversion
|
|
125
|
+
* this module exists to prevent, at the one call site that was still re-deriving.
|
|
126
|
+
*
|
|
127
|
+
* An empty `outOfScope` returns the SAME snapshot object with no schema pin, so an
|
|
128
|
+
* unscoped project's `diff` arguments are byte-for-byte what they always were.
|
|
129
|
+
*/
|
|
130
|
+
export function excludeFromSnapshot(
|
|
131
|
+
snapshot: SchemaSnapshot,
|
|
132
|
+
governed: GovernedScope,
|
|
133
|
+
): ScopedExpectedSchema {
|
|
134
|
+
if (governed.outOfScope.length === 0) return { snapshot, outOfScope: [] };
|
|
135
|
+
const excluded = new Set(governed.outOfScope);
|
|
136
|
+
const declared = governed.declaredSchemas ?? declaredSchemasOf(snapshot);
|
|
137
|
+
return {
|
|
138
|
+
snapshot: {
|
|
139
|
+
...snapshot,
|
|
140
|
+
tables: splitOnName(snapshot.tables, excluded).rest,
|
|
141
|
+
views: splitOnName(snapshot.views, excluded).rest,
|
|
142
|
+
},
|
|
143
|
+
outOfScope: [...governed.outOfScope],
|
|
144
|
+
declaredSchemas: [...declared],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The scope decision a run made, as `DriftResult` reports it. */
|
|
149
|
+
export interface GovernedScope {
|
|
150
|
+
/** Qualified physical names (`<schema>.<name>`) the run does not govern. */
|
|
151
|
+
readonly outOfScope: readonly string[];
|
|
152
|
+
/** The schemas the run governs — `ScopedExpectedSchema.declaredSchemas`. */
|
|
153
|
+
readonly declaredSchemas?: readonly string[] | undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Partition `objs` on whether `qualifiedDbName(o)` is in `names`.
|
|
158
|
+
*
|
|
159
|
+
* `carryForwardOutOfScope` wants the `named` half (carry the excluded entries
|
|
160
|
+
* forward) and `excludeFromSnapshot` wants the `rest` half (drop them). They are
|
|
161
|
+
* exact complements over the same key function, so they share one traversal rather
|
|
162
|
+
* than two filters that could come to key differently.
|
|
163
|
+
*/
|
|
164
|
+
function splitOnName<T extends { name: string; schema?: string }>(
|
|
165
|
+
objs: readonly T[],
|
|
166
|
+
names: ReadonlySet<string>,
|
|
167
|
+
): { named: T[]; rest: T[] } {
|
|
168
|
+
const named: T[] = [];
|
|
169
|
+
const rest: T[] = [];
|
|
170
|
+
for (const o of objs) (names.has(qualifiedDbName(o)) ? named : rest).push(o);
|
|
171
|
+
return { named, rest };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The three `diff` arguments a scoped run owes, as ONE value.
|
|
176
|
+
*
|
|
177
|
+
* The module header lists them as three separate obligations, and five call sites
|
|
178
|
+
* re-derived them by hand — one of which had already drifted into its own guard.
|
|
179
|
+
* Every scoped `diff` call is now
|
|
180
|
+
* `diff({ ...scopedDiffInputs(scoped, collectUnmanagedNames(metadata)), actual, ... })`,
|
|
181
|
+
* so the rule is enforced by the type rather than by the comment.
|
|
182
|
+
*
|
|
183
|
+
* `unmanaged` is the `@unmanaged`-declared set (`collectUnmanagedNames`); it is
|
|
184
|
+
* MERGED with `outOfScope`, never replaced by it — both must reach `diff`.
|
|
185
|
+
* `scopeSchemas` is omitted entirely when the run narrowed nothing, so an unscoped
|
|
186
|
+
* project's arguments are unchanged.
|
|
187
|
+
*/
|
|
188
|
+
export function scopedDiffInputs(
|
|
189
|
+
scoped: ScopedExpectedSchema,
|
|
190
|
+
unmanaged: readonly string[],
|
|
191
|
+
): Pick<DiffArgs, "expected" | "unmanagedNames" | "scopeSchemas"> {
|
|
192
|
+
return {
|
|
193
|
+
expected: scoped.snapshot,
|
|
194
|
+
unmanagedNames: [...unmanaged, ...scoped.outOfScope],
|
|
195
|
+
...(scoped.declaredSchemas !== undefined ? { scopeSchemas: scoped.declaredSchemas } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The distinct database schemas a snapshot's tables and views sit in, absent
|
|
201
|
+
* normalized to the Postgres default — the value `diff` derives for itself when no
|
|
202
|
+
* `scopeSchemas` is supplied. The ONE definition: any caller narrowing an expected
|
|
203
|
+
* side must pin `diff`'s schema scope to the UNNARROWED snapshot's schemas, and a
|
|
204
|
+
* second encoding of "absent means public" here would silently disagree with the
|
|
205
|
+
* one inside `diff`.
|
|
206
|
+
*
|
|
207
|
+
* Empty in ⇒ empty out, which callers translate to "pass nothing", preserving
|
|
208
|
+
* `diff`'s legacy whole-database fallback for a genuinely empty model.
|
|
209
|
+
*/
|
|
210
|
+
export function declaredSchemasOf(snapshot: SchemaSnapshot): string[] {
|
|
211
|
+
return [
|
|
212
|
+
...new Set(
|
|
213
|
+
[...snapshot.tables, ...snapshot.views].map(
|
|
214
|
+
(o) => o.schema ?? DEFAULT_DB_SCHEMA_POSTGRES,
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
].sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Narrow an expected schema to the objects inside `inScope`.
|
|
222
|
+
*
|
|
223
|
+
* An undefined predicate returns the input untouched — the SAME snapshot object,
|
|
224
|
+
* not an equal copy — so a project that declares no `migrate.scope` reaches the
|
|
225
|
+
* diff, the emitter and the committed snapshot through an unchanged value.
|
|
226
|
+
*
|
|
227
|
+
* A table or view with NO recorded provenance is KEPT. Scope decides on the
|
|
228
|
+
* declaring object's FQN, and an object whose FQN is unknown was never proven to be
|
|
229
|
+
* anyone else's; dropping it would silently un-manage it (and, worse, suppressing
|
|
230
|
+
* its name on the actual side would hide real drift).
|
|
231
|
+
*/
|
|
232
|
+
export function scopeExpectedSchema(
|
|
233
|
+
built: ExpectedSchemaWithProvenance,
|
|
234
|
+
inScope: ObjectScopePredicate | undefined,
|
|
235
|
+
): ScopedExpectedSchema {
|
|
236
|
+
if (inScope === undefined) return { snapshot: built.snapshot, outOfScope: [] };
|
|
237
|
+
|
|
238
|
+
// Computed from `built.snapshot` — the UNSCOPED side — deliberately, and before
|
|
239
|
+
// the filter below runs. Deriving it from the survivors would reproduce exactly
|
|
240
|
+
// the defect this exists to close.
|
|
241
|
+
const declared = declaredSchemasOf(built.snapshot);
|
|
242
|
+
|
|
243
|
+
const outOfScope: string[] = [];
|
|
244
|
+
const governed = <T extends { name: string; schema?: string }>(obj: T): boolean => {
|
|
245
|
+
const qualified = qualifiedDbName(obj);
|
|
246
|
+
const fqn = built.provenance.get(qualified);
|
|
247
|
+
if (fqn === undefined || inScope(fqn)) return true;
|
|
248
|
+
outOfScope.push(qualified);
|
|
249
|
+
return false;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
snapshot: {
|
|
254
|
+
...built.snapshot,
|
|
255
|
+
tables: built.snapshot.tables.filter(governed),
|
|
256
|
+
views: built.snapshot.views.filter(governed),
|
|
257
|
+
},
|
|
258
|
+
outOfScope,
|
|
259
|
+
...(declared.length > 0 ? { declaredSchemas: declared } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|