@metaobjectsdev/cli 1.0.8 → 1.0.9-rc.2

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 (58) hide show
  1. package/dist/src/commands/eject.d.ts.map +1 -1
  2. package/dist/src/commands/eject.js +22 -4
  3. package/dist/src/commands/eject.js.map +1 -1
  4. package/dist/src/commands/init.d.ts.map +1 -1
  5. package/dist/src/commands/init.js +0 -3
  6. package/dist/src/commands/init.js.map +1 -1
  7. package/dist/src/commands/migrate.d.ts +1 -1
  8. package/dist/src/commands/migrate.d.ts.map +1 -1
  9. package/dist/src/commands/migrate.js +35 -22
  10. package/dist/src/commands/migrate.js.map +1 -1
  11. package/dist/src/commands/verify.d.ts +12 -0
  12. package/dist/src/commands/verify.d.ts.map +1 -1
  13. package/dist/src/commands/verify.js +102 -20
  14. package/dist/src/commands/verify.js.map +1 -1
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +5 -2
  17. package/dist/src/index.js.map +1 -1
  18. package/dist/src/lib/advisory.d.ts +19 -0
  19. package/dist/src/lib/advisory.d.ts.map +1 -1
  20. package/dist/src/lib/advisory.js +27 -0
  21. package/dist/src/lib/advisory.js.map +1 -1
  22. package/dist/src/lib/allow.d.ts +21 -1
  23. package/dist/src/lib/allow.d.ts.map +1 -1
  24. package/dist/src/lib/allow.js +62 -1
  25. package/dist/src/lib/allow.js.map +1 -1
  26. package/dist/src/lib/catalog-listing.d.ts +2 -0
  27. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  28. package/dist/src/lib/catalog-listing.js +7 -0
  29. package/dist/src/lib/catalog-listing.js.map +1 -1
  30. package/dist/src/lib/file-provider.d.ts +12 -0
  31. package/dist/src/lib/file-provider.d.ts.map +1 -1
  32. package/dist/src/lib/file-provider.js +26 -1
  33. package/dist/src/lib/file-provider.js.map +1 -1
  34. package/dist/src/lib/fk-index-advisory.d.ts +17 -0
  35. package/dist/src/lib/fk-index-advisory.d.ts.map +1 -0
  36. package/dist/src/lib/fk-index-advisory.js +77 -0
  37. package/dist/src/lib/fk-index-advisory.js.map +1 -0
  38. package/dist/src/lib/output.d.ts +9 -0
  39. package/dist/src/lib/output.d.ts.map +1 -1
  40. package/dist/src/lib/output.js +17 -3
  41. package/dist/src/lib/output.js.map +1 -1
  42. package/dist/src/lib/referential-action-advisory.d.ts +14 -0
  43. package/dist/src/lib/referential-action-advisory.d.ts.map +1 -0
  44. package/dist/src/lib/referential-action-advisory.js +38 -0
  45. package/dist/src/lib/referential-action-advisory.js.map +1 -0
  46. package/package.json +10 -10
  47. package/src/commands/eject.ts +25 -4
  48. package/src/commands/init.ts +0 -3
  49. package/src/commands/migrate.ts +32 -24
  50. package/src/commands/verify.ts +108 -18
  51. package/src/index.ts +5 -2
  52. package/src/lib/advisory.ts +33 -0
  53. package/src/lib/allow.ts +66 -2
  54. package/src/lib/catalog-listing.ts +7 -0
  55. package/src/lib/file-provider.ts +27 -1
  56. package/src/lib/fk-index-advisory.ts +112 -0
  57. package/src/lib/output.ts +25 -3
  58. package/src/lib/referential-action-advisory.ts +58 -0
package/src/index.ts CHANGED
@@ -142,6 +142,8 @@ MIGRATE FLAGS:
142
142
  drop-column,drop-table,type-change,drop-index,drop-fk,
143
143
  drop-check,drop-view,drop-view-cascade,
144
144
  adopt-view,nullable-to-not-null,drop-identity-default
145
+ drop-column DELETES the column's data; for a renamed
146
+ column use --rename-column, which keeps it
145
147
  --on-ambiguous abort|rename|drop-add Default abort
146
148
  --rename-table [schema.]old=new Declare a table rename (repeatable)
147
149
  --rename-column [schema.]table.old=new Declare a column rename; keeps its data (repeatable)
@@ -151,8 +153,7 @@ MIGRATE FLAGS:
151
153
  --yes Skip the --remote --apply confirmation pause
152
154
  --dry-run Print SQL to stdout, don't write
153
155
 
154
- Other commands (ingest, mcp, serve, install-hooks, audit, capture, promote)
155
- ship in later sub-projects. See https://metaobjects.com for docs.
156
+ See https://metaobjects.com for docs.
156
157
  `;
157
158
 
158
159
  /** Focused per-subcommand usage slices shown by `<cmd> --help`. */
@@ -323,6 +324,8 @@ FLAGS:
323
324
  A bare 'meta verify' also runs an ADVISORY anti-pattern pass: it scans your authored
324
325
  source for hand-rolled aggregates, money-as-float, and CHECK-IN enums and points you
325
326
  at the construct that models them (origin.aggregate / field.currency / field.enum).
327
+ The same pass names every foreign key with no index on its referencing column(s)
328
+ and the index.lookup that would cover it (migrate never adds one on its own).
326
329
  Warnings only — it never fails the build. Opt out with --no-antipatterns or
327
330
  META_NO_ANTIPATTERNS=1.
328
331
 
@@ -163,6 +163,39 @@ export function libraryPrefixRows(
163
163
  }));
164
164
  }
165
165
 
166
+ /**
167
+ * Unindexed foreign keys in the same row shape. `construct` is `<entity FQN>.<reference>`,
168
+ * the node the author edits to declare the index. There is no line: the finding is about the
169
+ * loaded model, which does not carry source positions.
170
+ */
171
+ export function unindexedFkRows(
172
+ findings: readonly { file: string; construct: string; message: string }[],
173
+ ): AdvisoryFindingRow[] {
174
+ return findings.map((f) => ({
175
+ file: f.file,
176
+ line: 0,
177
+ rule: "unindexed-foreign-key",
178
+ construct: f.construct,
179
+ message: f.message,
180
+ }));
181
+ }
182
+
183
+ /**
184
+ * Relationships on both sides of one FK that disagree on its referential action, in the
185
+ * same row shape. `construct` is `<entity FQN>.<reference>` — the FK, where the fix goes.
186
+ */
187
+ export function referentialActionConflictRows(
188
+ findings: readonly { file: string; construct: string; message: string }[],
189
+ ): AdvisoryFindingRow[] {
190
+ return findings.map((f) => ({
191
+ file: f.file,
192
+ line: 0,
193
+ rule: "overridden-referential-action",
194
+ construct: f.construct,
195
+ message: f.message,
196
+ }));
197
+ }
198
+
166
199
  // ---------------------------------------------------------------------------
167
200
  // text output
168
201
  // ---------------------------------------------------------------------------
package/src/lib/allow.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  // both `meta migrate` and `meta verify --db`. Keeping a single copy avoids the
3
3
  // two commands drifting on which `--allow` tokens exist or how a change reads.
4
4
 
5
- import type { AllowOptions, Change } from "@metaobjectsdev/migrate-ts";
5
+ import { allowOptionFor, suggestColumnRenames } from "@metaobjectsdev/migrate-ts";
6
+ import type { AllowOptions, Change, ColumnRenameSuggestion } from "@metaobjectsdev/migrate-ts";
7
+ import type { BlockedEntry } from "./output.js";
6
8
 
7
9
  // Map CLI allow tokens → migrate-ts AllowOptions field names.
8
10
  // Exported (not just module-local) so allow-tokens-pinned.test.ts can pin its
@@ -77,9 +79,71 @@ export function describeChange(c: Change): string {
77
79
  case "drop-index": return `${c.table} idx ${c.index}`;
78
80
  case "add-fk": return `${c.table} fk ${c.fk.name}`;
79
81
  case "drop-fk": return `${c.table} fk ${c.fk}`;
82
+ case "add-check": return `${c.table} check ${c.check.name} (${c.check.expression})`;
83
+ case "drop-check":
84
+ return c.restore !== undefined
85
+ ? `${c.table} check ${c.check} (${c.restore.expression})`
86
+ : `${c.table} check ${c.check}`;
80
87
  case "create-view": return c.view.name;
81
88
  case "replace-view": return c.view.name;
82
89
  case "drop-view": return c.view;
83
- default: return JSON.stringify(c);
84
90
  }
91
+ // Exhaustive: a new Change kind fails to compile here rather than printing a JSON dump
92
+ // of the change object to a person (which is what a blocked drop-check used to do).
93
+ const unhandled: never = c;
94
+ return unhandled;
95
+ }
96
+
97
+ /** The `--allow` token that unblocks `c`. migrate-ts picks the permission by what blocked the
98
+ * change (a type change can be blocked by the auto-sequence default it carries), so this only
99
+ * maps that permission back to its CLI token. */
100
+ export function allowFlagFor(c: Change): string {
101
+ const option = allowOptionFor(c);
102
+ const token = Object.keys(ALLOW_TOKEN_MAP).find((t) => ALLOW_TOKEN_MAP[t] === option);
103
+ return token ?? c.kind;
104
+ }
105
+
106
+ /** `--rename-column [schema.]table.old=new`, spelled with the pair's real names. */
107
+ export function renameColumnFlag(r: ColumnRenameSuggestion): string {
108
+ return `--rename-column ${r.schema !== undefined ? `${r.schema}.` : ""}${r.table}.${r.from}=${r.to}`;
109
+ }
110
+
111
+ /**
112
+ * One reportable entry per blocked change. `changes` is the whole diff the blocked ones came
113
+ * from: a blocked drop-column that pairs with an add-column in the same table may be a column
114
+ * the author RENAMED, and `--allow drop-column` would delete its data — so that entry carries
115
+ * the declared-rename flag, which every renderer prints FIRST.
116
+ */
117
+ export function blockedEntriesFor(blocked: readonly Change[], changes: readonly Change[]): BlockedEntry[] {
118
+ const renames = suggestColumnRenames(changes);
119
+ return blocked.map((c) => {
120
+ const entry: BlockedEntry = { kind: c.kind, description: describeChange(c), allowFlag: allowFlagFor(c) };
121
+ if (c.kind !== "drop-column") return entry;
122
+ const r = renames.find((x) => x.from === c.column && x.table === c.table && x.schema === c.schema);
123
+ if (r === undefined) return entry;
124
+ return {
125
+ ...entry,
126
+ renameFlag: renameColumnFlag(r),
127
+ renameTo: `${r.table}.${r.to}`,
128
+ ...(r.shapeChange !== undefined ? { shapeChange: r.shapeChange } : {}),
129
+ };
130
+ });
131
+ }
132
+
133
+ /**
134
+ * The hint for one blocked entry, as the lines a renderer prints in order. A rename-pairable
135
+ * drop leads with the declared rename and names `--allow drop-column` as what deletes the data;
136
+ * every other entry is the one-line `--allow` hint it always was.
137
+ */
138
+ export function blockedHintLines(e: BlockedEntry): string[] {
139
+ if (e.renameFlag === undefined) {
140
+ return [`blocked '${e.kind}' on ${e.description} (allow with --allow ${e.allowFlag})`];
141
+ }
142
+ const shape = e.shapeChange !== undefined
143
+ ? ` (its ${e.shapeChange} also changes: rename first with the old shape still declared, then change the shape in a second migration)`
144
+ : "";
145
+ return [
146
+ `${e.description} is dropped and ${e.renameTo ?? "a column"} is added in the same table. If it was renamed, keep its data: re-run with ${e.renameFlag}${shape}`,
147
+ ` (--allow ${e.allowFlag} instead DELETES ${e.description} and all its data)`,
148
+ ];
85
149
  }
@@ -334,6 +334,11 @@ export const REFERENCE_HELPER_NOTE: readonly string[] = [
334
334
  "render) is not a generator and is not listed here.",
335
335
  ];
336
336
 
337
+ /** The footer line explaining the `package-only` row mark. */
338
+ export const PACKAGE_ONLY_NOTE =
339
+ "package-only = no reference template ships for it yet, so `meta eject` cannot copy it; " +
340
+ "import it from its package instead.";
341
+
337
342
  /** The human rendering: grouped by layer, because layer is the axis you select by. */
338
343
  export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
339
344
  const lines: string[] = [];
@@ -352,6 +357,7 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
352
357
  const marks: string[] = [];
353
358
  if (r.framework !== undefined) marks.push(r.framework);
354
359
  if (r.tier === "neutral") marks.push("neutral");
360
+ if (!r.source.ejectable) marks.push("package-only");
355
361
  if (r.project?.wired) marks.push("WIRED");
356
362
  if (r.source.owned) marks.push("owned");
357
363
  if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
@@ -372,6 +378,7 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
372
378
  lines.push("");
373
379
  lines.push("`meta eject <name...>` copies a generator into codegen/generators/ and prints");
374
380
  lines.push("the import to add, the entry to wire, and what to install.");
381
+ if (generators.some((r) => !r.source.ejectable)) lines.push(PACKAGE_ONLY_NOTE);
375
382
  if (!probed) {
376
383
  lines.push("Add --probe to see how many files each would emit for YOUR model.");
377
384
  }
@@ -16,13 +16,23 @@ export class FileProvider implements Provider {
16
16
  constructor(private readonly baseDir: string) {}
17
17
 
18
18
  resolve(ref: string): string | undefined {
19
+ const path = this.pathOf(ref);
20
+ return path === undefined ? undefined : readFileSync(path, "utf8");
21
+ }
22
+
23
+ /**
24
+ * The file `ref` resolves to, or undefined. `resolve` reads exactly this file, so a
25
+ * diagnostic that names it names the body that was checked.
26
+ */
27
+ pathOf(ref: string): string | undefined {
19
28
  // `group/source` → <baseDir>/group/source<ext>; node:path.join normalizes
20
29
  // the embedded "/" separators for the host OS.
21
30
  for (const ext of EXTENSIONS) {
22
31
  const path = join(this.baseDir, ref) + ext;
23
32
  if (existsSync(path)) {
24
33
  try {
25
- return readFileSync(path, "utf8");
34
+ readFileSync(path, "utf8");
35
+ return path;
26
36
  } catch {
27
37
  // unreadable — fall through and try the next candidate
28
38
  }
@@ -31,3 +41,19 @@ export class FileProvider implements Provider {
31
41
  return undefined;
32
42
  }
33
43
  }
44
+
45
+ /**
46
+ * The 1-based line of the first mustache tag naming `path` in `text` — `{{path}}`,
47
+ * `{{{path}}}`, `{{& path}}`, `{{#path}}`, `{{^path}}` — trying the full dotted path and then
48
+ * its head segment (a dotted drift is reported on the path, but the tag may open a section on
49
+ * the head). Undefined when no tag matches, e.g. the variable lives in a partial.
50
+ */
51
+ export function mustacheTagLine(text: string, path: string): number | undefined {
52
+ const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ const head = path.split(".")[0] ?? path;
54
+ for (const name of path === head ? [path] : [path, head]) {
55
+ const m = new RegExp(`\\{\\{\\{?\\s*[#^&]?\\s*${esc(name)}\\s*\\}`).exec(text);
56
+ if (m !== null) return text.slice(0, m.index).split("\n").length;
57
+ }
58
+ return undefined;
59
+ }
@@ -0,0 +1,112 @@
1
+ // Foreign keys with no index covering their columns — an advisory authoring lint.
2
+ //
3
+ // `meta migrate` emits a FOREIGN KEY constraint for every enforced `identity.reference`
4
+ // and no index on its columns: neither Postgres nor SQLite indexes the referencing side
5
+ // of a foreign key. Every join from the parent, and every ON DELETE cascade / set-null /
6
+ // restrict check from it, then scans the child table. Generating the index automatically
7
+ // would propose a migration to every existing adopter the release it shipped, so this is
8
+ // advice, never a gate: it names each such key and says how to declare the index.
9
+ //
10
+ // Coverage is read off the EXPECTED SCHEMA migrate itself builds (primary key, every
11
+ // `identity.secondary`, every `index.lookup`, every `@unique` field), not re-derived from
12
+ // the metadata — so "covered" means covered in the DDL migrate would emit. An index covers
13
+ // a key when its leading columns are exactly the key's columns, in any order. Expression
14
+ // indexes are not counted; partial ones are (bias to under-flagging).
15
+
16
+ import {
17
+ INDEX_SUBTYPE_LOOKUP,
18
+ TYPE_INDEX,
19
+ TYPE_SUBTYPE_SEPARATOR,
20
+ } from "@metaobjectsdev/metadata/constants";
21
+ import {
22
+ isMetaObject,
23
+ resolveColumnName,
24
+ type ColumnNamingStrategy,
25
+ type MetaData,
26
+ } from "@metaobjectsdev/metadata";
27
+ import {
28
+ buildExpectedSchemaWithProvenance,
29
+ qualifiedDbName,
30
+ type Dialect,
31
+ type TableDescriptor,
32
+ } from "@metaobjectsdev/migrate-ts";
33
+
34
+ export interface UnindexedFkFinding {
35
+ /** The metadata file declaring the entity ("" when the loader recorded none). */
36
+ file: string;
37
+ /** `<entity FQN>.<reference name>`. */
38
+ construct: string;
39
+ message: string;
40
+ }
41
+
42
+ export interface UnindexedFkOptions {
43
+ dialect: Dialect;
44
+ columnNamingStrategy: ColumnNamingStrategy;
45
+ /** Skip an entity by FQN — imported (dependency) metadata is not the adopter's to index. */
46
+ skip?: (fqn: string) => boolean;
47
+ }
48
+
49
+ const INDEX_LOOKUP = `${TYPE_INDEX}${TYPE_SUBTYPE_SEPARATOR}${INDEX_SUBTYPE_LOOKUP}`;
50
+
51
+ function covered(table: TableDescriptor, fkColumns: readonly string[]): boolean {
52
+ const want = new Set(fkColumns);
53
+ const prefixes = [
54
+ table.primaryKey,
55
+ ...table.indexes.filter((i) => i.expr === undefined).map((i) => i.columns),
56
+ ];
57
+ return prefixes.some((cols) =>
58
+ cols.length >= want.size && cols.slice(0, want.size).every((c) => want.has(c)));
59
+ }
60
+
61
+ function pascal(s: string): string {
62
+ return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
63
+ }
64
+
65
+ export function scanForUnindexedForeignKeys(root: MetaData, opts: UnindexedFkOptions): UnindexedFkFinding[] {
66
+ const { snapshot, provenance } = buildExpectedSchemaWithProvenance(root, {
67
+ dialect: opts.dialect,
68
+ columnNamingStrategy: opts.columnNamingStrategy,
69
+ });
70
+ // ADR-0039: effective children — resolve rather than rely on root being unextended.
71
+ const objectsByFqn = new Map(
72
+ root.children().filter(isMetaObject).map((o) => [o.resolutionKey(), o] as const),
73
+ );
74
+
75
+ const out: UnindexedFkFinding[] = [];
76
+ for (const table of snapshot.tables) {
77
+ const fqn = provenance.get(qualifiedDbName(table));
78
+ if (fqn === undefined || opts.skip?.(fqn) === true) continue;
79
+ const entity = objectsByFqn.get(fqn);
80
+ if (entity === undefined) continue;
81
+ const files = "files" in entity.source ? entity.source.files : [];
82
+ // A node a shipped library contributed is the library's design, not the adopter's.
83
+ if (files.some((f) => f.startsWith("library:"))) continue;
84
+
85
+ for (const fk of table.foreignKeys) {
86
+ if (covered(table, fk.columns)) continue;
87
+ // Name the key in the author's terms: the reference and its FIELDS, not columns.
88
+ // A TPH subtype's reference folds into its base's table and is not on `entity`;
89
+ // it is then reported by column, which is still enough to act on.
90
+ const ref = entity.referenceIdentities().find((r) => {
91
+ const cols = r.fields.map((f) => {
92
+ const field = entity.fields().find((x) => x.name === f);
93
+ return field !== undefined ? resolveColumnName(field, opts.columnNamingStrategy) : f;
94
+ });
95
+ return cols.length === fk.columns.length && cols.every((c, i) => c === fk.columns[i]);
96
+ });
97
+ const fields = ref !== undefined ? ref.fields : fk.columns;
98
+ const label = ref !== undefined ? `${entity.name}.${ref.name}` : `${entity.name} (${fk.name})`;
99
+ const indexName = `by${fields.map(pascal).join("")}`;
100
+ const fieldList = fields.length === 1 ? fields[0]! : `[${fields.join(", ")}]`;
101
+ out.push({
102
+ file: files[0] ?? "",
103
+ construct: `${fqn}.${ref?.name ?? fk.name}`,
104
+ message:
105
+ `${label}: foreign key (${fields.join(", ")}) → ${fk.refTable} has no index covering it, ` +
106
+ `so joins from ${fk.refTable} and its ON DELETE checks scan ${table.name}. ` +
107
+ `Declare one on ${entity.name}: ${INDEX_LOOKUP}: { name: ${indexName}, fields: ${fieldList} }`,
108
+ });
109
+ }
110
+ }
111
+ return out;
112
+ }
package/src/lib/output.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import type { Dialect } from "./kysely.js";
7
7
  import { toonEncode } from "./format.js";
8
8
  import { skippedSection, type AdvisoryFindingRow, type AdvisorySection } from "./advisory.js";
9
+ import { blockedHintLines } from "./allow.js";
9
10
 
10
11
  export interface FormatOptions {
11
12
  isTTY: boolean;
@@ -154,6 +155,15 @@ export interface BlockedEntry {
154
155
  kind: string;
155
156
  description: string;
156
157
  allowFlag: string;
158
+ /**
159
+ * Set on a blocked drop-column that pairs with an add-column in the same table: the exact
160
+ * `--rename-column` flag that keeps the data. Printed BEFORE `--allow`, which deletes it.
161
+ */
162
+ renameFlag?: string;
163
+ /** `table.column` the dropped column may have been renamed to. */
164
+ renameTo?: string;
165
+ /** The shape difference that forces the rename and the reshape into two migrations. */
166
+ shapeChange?: string;
157
167
  }
158
168
 
159
169
  export interface AmbiguousEntry {
@@ -204,9 +214,14 @@ export function formatMigrateResult(result: MigrateResultShape, _opts: FormatOpt
204
214
  }
205
215
 
206
216
  if (result.blocked.length > 0) {
207
- lines.push(" Blocked (re-run with --allow):");
217
+ const anyRename = result.blocked.some((b) => b.renameFlag !== undefined);
218
+ lines.push(anyRename ? " Blocked (re-run with the flag shown):" : " Blocked (re-run with --allow):");
208
219
  for (const b of result.blocked) {
209
- lines.push(` ${b.kind} ${b.description} (--allow ${b.allowFlag})`);
220
+ if (b.renameFlag === undefined) {
221
+ lines.push(` ${b.kind} ${b.description} (--allow ${b.allowFlag})`);
222
+ } else {
223
+ for (const l of blockedHintLines(b)) lines.push(` ${l}`);
224
+ }
210
225
  }
211
226
  lines.push("");
212
227
  }
@@ -328,8 +343,15 @@ export function migrateResultToData(result: MigrateResultShape): {
328
343
  let help: string[];
329
344
  if (isBlocked) {
330
345
  summary = `${changeSummary}; not applied`;
346
+ // A rename-pairable drop leads: its `--allow` would delete the data the rename keeps.
347
+ const renames = result.blocked.filter((b) => b.renameFlag !== undefined);
348
+ const others = result.blocked.filter((b) => b.renameFlag === undefined);
331
349
  help = [
332
- ...result.blocked.map((b) => `re-run with --allow ${b.allowFlag} to apply: ${b.description}`),
350
+ ...renames.map((b) =>
351
+ `if ${b.description} was renamed to ${b.renameTo ?? "the added column"}, re-run with ${b.renameFlag} to keep its data` +
352
+ `${b.shapeChange !== undefined ? ` (its ${b.shapeChange} also changes: rename first, reshape in a second migration)` : ""}` +
353
+ `; --allow ${b.allowFlag} instead DELETES the column and all its data`),
354
+ ...others.map((b) => `re-run with --allow ${b.allowFlag} to apply: ${b.description}`),
333
355
  ...result.ambiguous.map((a) => `re-run with --on-ambiguous to resolve: ${a.hint}`),
334
356
  ];
335
357
  } else if (result.dryRun) {
@@ -0,0 +1,58 @@
1
+ // Two relationships over one foreign key that disagree on its referential action.
2
+ //
3
+ // A 1:N may be declared on either side — the parent (`Author` composition `books`) or the
4
+ // FK-owning child (`Book` association `author`) — and each side's subtype implies an
5
+ // ON DELETE default (composition → cascade, association → restrict). When BOTH are
6
+ // declared and disagree, the documented precedence (ADR-0047) lets the FK-owning side
7
+ // govern, so the parent's relationship has no effect on the constraint. That is a
8
+ // defensible rule and a silent one: an adopter declared a composition, read "cascade" in
9
+ // the docs, and got ON DELETE RESTRICT and a 409 with nothing saying why.
10
+ //
11
+ // Advisory, never a gate: changing the precedence would change the DDL of every model
12
+ // that relies on it. This names each such key, the action that governs, and the two
13
+ // one-line ways to say which one the author means. Read off the SAME walk that emits the
14
+ // FK (`buildExpectedSchema`'s `onReferentialActionConflict`), so it can only ever name a
15
+ // constraint migrate actually writes. `meta verify` lists them; `meta migrate` warns.
16
+
17
+ import type { MetaData } from "@metaobjectsdev/metadata";
18
+ import {
19
+ buildExpectedSchema,
20
+ describeReferentialActionConflict,
21
+ type ReferentialActionConflict,
22
+ } from "@metaobjectsdev/migrate-ts";
23
+
24
+ export interface ReferentialActionConflictFinding {
25
+ /** The metadata file declaring the FK-owning entity ("" when the loader recorded none). */
26
+ file: string;
27
+ /** `<entity FQN>.<reference name>` — the FK, which is where the fix is declared. */
28
+ construct: string;
29
+ message: string;
30
+ }
31
+
32
+ export interface ReferentialActionConflictOptions {
33
+ /** Skip an entity by FQN — imported (dependency) metadata is not the adopter's to fix. */
34
+ skip?: (fqn: string) => boolean;
35
+ }
36
+
37
+ export function scanForReferentialActionConflicts(
38
+ root: MetaData,
39
+ opts: ReferentialActionConflictOptions = {},
40
+ ): ReferentialActionConflictFinding[] {
41
+ const conflicts: ReferentialActionConflict[] = [];
42
+ buildExpectedSchema(root, { onReferentialActionConflict: (c) => conflicts.push(c) });
43
+
44
+ const out: ReferentialActionConflictFinding[] = [];
45
+ const seen = new Set<string>();
46
+ for (const c of conflicts) {
47
+ const fqn = c.entity.resolutionKey();
48
+ if (opts.skip?.(fqn) === true) continue;
49
+ const construct = `${fqn}.${c.ref.name}`;
50
+ if (seen.has(construct)) continue;
51
+ seen.add(construct);
52
+ const files = "files" in c.entity.source ? c.entity.source.files : [];
53
+ // A node a shipped library contributed is the library's design, not the adopter's.
54
+ if (files.some((f) => f.startsWith("library:"))) continue;
55
+ out.push({ file: files[0] ?? "", construct, message: describeReferentialActionConflict(c) });
56
+ }
57
+ return out;
58
+ }