@metaobjectsdev/migrate-ts 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/diff/index.d.ts.map +1 -1
  2. package/dist/diff/index.js +17 -7
  3. package/dist/diff/index.js.map +1 -1
  4. package/dist/drift/drift.d.ts +32 -2
  5. package/dist/drift/drift.d.ts.map +1 -1
  6. package/dist/drift/drift.js +15 -8
  7. package/dist/drift/drift.js.map +1 -1
  8. package/dist/emit/postgres.js +69 -12
  9. package/dist/emit/postgres.js.map +1 -1
  10. package/dist/emit/sqlite.d.ts.map +1 -1
  11. package/dist/emit/sqlite.js +9 -2
  12. package/dist/emit/sqlite.js.map +1 -1
  13. package/dist/expected-schema.d.ts +37 -0
  14. package/dist/expected-schema.d.ts.map +1 -1
  15. package/dist/expected-schema.js +124 -16
  16. package/dist/expected-schema.js.map +1 -1
  17. package/dist/index.d.ts +8 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/qualified-name.d.ts +9 -0
  22. package/dist/qualified-name.d.ts.map +1 -0
  23. package/dist/qualified-name.js +21 -0
  24. package/dist/qualified-name.js.map +1 -0
  25. package/dist/scope.d.ts +121 -0
  26. package/dist/scope.d.ts.map +1 -0
  27. package/dist/scope.js +193 -0
  28. package/dist/scope.js.map +1 -0
  29. package/dist/snapshot/plan.d.ts +23 -1
  30. package/dist/snapshot/plan.d.ts.map +1 -1
  31. package/dist/snapshot/plan.js +17 -9
  32. package/dist/snapshot/plan.js.map +1 -1
  33. package/dist/types.d.ts +17 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/unmanaged.d.ts.map +1 -1
  36. package/dist/unmanaged.js +3 -3
  37. package/dist/unmanaged.js.map +1 -1
  38. package/dist/verify/replay-engine.d.ts +10 -0
  39. package/dist/verify/replay-engine.d.ts.map +1 -0
  40. package/dist/verify/replay-engine.js +161 -0
  41. package/dist/verify/replay-engine.js.map +1 -0
  42. package/dist/verify/replay.d.ts +15 -0
  43. package/dist/verify/replay.d.ts.map +1 -1
  44. package/dist/verify/replay.js +7 -1
  45. package/dist/verify/replay.js.map +1 -1
  46. package/package.json +14 -3
  47. package/src/diff/index.ts +17 -9
  48. package/src/drift/drift.ts +55 -15
  49. package/src/emit/postgres.ts +70 -12
  50. package/src/emit/sqlite.ts +9 -2
  51. package/src/expected-schema.ts +155 -15
  52. package/src/index.ts +18 -2
  53. package/src/qualified-name.ts +22 -0
  54. package/src/scope.ts +261 -0
  55. package/src/snapshot/plan.ts +45 -13
  56. package/src/types.ts +17 -0
  57. package/src/unmanaged.ts +2 -3
  58. package/src/verify/replay-engine.ts +184 -0
  59. package/src/verify/replay.ts +21 -1
@@ -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
- return {
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 expression = `${qcol} IN (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")})`;
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 refColumns = explicitTargetFields.length > 1
848
- ? explicitTargetFields.map((n) => applyColumnNamingStrategy(n, strategy))
849
- : [applyColumnNamingStrategy(refChild.resolvedTargetPkField(root) ?? "id", strategy)];
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
- // #235: an EMPTY-string default (`@default: ""`) is a real literal default
929
- // codegen emits `.default("")` and the DB gets `DEFAULT ''`, so dropping it here
930
- // (a falsy `.length > 0` check) made the column drift forever on sqlite/d1 and
931
- // disagree with codegen. Keep it as a literal; only `undefined` means "no default".
932
- const isExpr = defaultRaw.length > 0 && EXPR_DEFAULT_PATTERNS.some((re) => re.test(defaultRaw));
933
- col.default = { kind: isExpr ? "expr" : "literal", value: defaultRaw };
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
- export { computeDrift, computeDriftFromActual, type ComputeDriftOptions } from "./drift/drift.js";
16
+ // Per-command scope (`migrate.scope`) see scope.ts for why the suppression is
17
+ // two-sided and why the pattern engine stays in @metaobjectsdev/sdk.
18
+ export {
19
+ scopeExpectedSchema,
20
+ declaredSchemasOf,
21
+ carryForwardOutOfScope,
22
+ excludeFromSnapshot,
23
+ scopedDiffInputs,
24
+ } from "./scope.js";
25
+ export type { ObjectScopePredicate, ScopedExpectedSchema, GovernedScope } from "./scope.js";
26
+ export { qualifiedDbName } from "./qualified-name.js";
27
+ export { computeDrift, computeDriftFromActual, type ComputeDriftOptions, type DriftResult } from "./drift/drift.js";
16
28
  export { classifyDrift, driftAgainstSnapshot } from "./drift/classify.js";
17
29
  export type { DriftClassification } from "./drift/classify.js";
18
30
  export { emit } from "./emit/index.js";
@@ -100,6 +112,10 @@ export {
100
112
  export { verifyReplay } from "./verify/replay.js";
101
113
  export type { VerifyReplayArgs, VerifyReplayResult } from "./verify/replay.js";
102
114
 
115
+ // An empty in-process database to replay a committed chain into (#313).
116
+ export { openReplayEngine } from "./verify/replay-engine.js";
117
+ export type { ReplayEngine } from "./verify/replay-engine.js";
118
+
103
119
  // Wrangler config helpers
104
120
  export {
105
121
  findWranglerConfig,
@@ -0,0 +1,22 @@
1
+ // The ONE qualified-physical-name form: `<schema>.<name>`, with an absent schema
2
+ // normalized to the Postgres default.
3
+ //
4
+ // Three things must key DB objects identically or the diff silently disagrees with
5
+ // itself: `diff`'s table/view identity maps, the declared-`@unmanaged` exclusion set
6
+ // (`collectUnmanagedNames`), and the out-of-scope exclusion set (`scopeExpectedSchema`).
7
+ // The last two are ACT-side suppressions matched against the first, so a name built a
8
+ // second way — a different default schema, a different separator — reads as "not
9
+ // suppressed" and the object it names comes back as a proposed DROP. One function.
10
+ //
11
+ // SQLite has no schema concept, so every SQLite object normalizes to the same prefix.
12
+ // That is harmless: it is a constant, and the un-prefixed names were already unique.
13
+
14
+ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
15
+
16
+ /** `<schema>.<name>`; an absent schema is the Postgres default (`public`). The
17
+ * parameter accepts an EXPLICIT `undefined` schema (not only an omitted key) so a
18
+ * caller holding a `string | undefined` can pass it straight through under
19
+ * `exactOptionalPropertyTypes` — the two spell the same thing here. */
20
+ export function qualifiedDbName(obj: { name: string; schema?: string | undefined }): string {
21
+ return `${obj.schema ?? DEFAULT_DB_SCHEMA_POSTGRES}.${obj.name}`;
22
+ }
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
+ }