@metaobjectsdev/cli 1.0.8 → 1.0.9-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) 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 +14 -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 +85 -18
  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 +10 -0
  19. package/dist/src/lib/advisory.d.ts.map +1 -1
  20. package/dist/src/lib/advisory.js +14 -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 +53 -0
  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/package.json +10 -10
  43. package/src/commands/eject.ts +25 -4
  44. package/src/commands/init.ts +0 -3
  45. package/src/commands/migrate.ts +11 -24
  46. package/src/commands/verify.ts +88 -17
  47. package/src/index.ts +5 -2
  48. package/src/lib/advisory.ts +17 -0
  49. package/src/lib/allow.ts +57 -1
  50. package/src/lib/catalog-listing.ts +7 -0
  51. package/src/lib/file-provider.ts +27 -1
  52. package/src/lib/fk-index-advisory.ts +112 -0
  53. package/src/lib/output.ts +25 -3
@@ -16,7 +16,6 @@ import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } fro
16
16
  import { collectionLoadOptions } from "../lib/collection-load-options.js";
17
17
  import { exclusionNotes, importedOption, migrateScopeMismatch } from "../lib/migrate-scope.js";
18
18
  import {
19
- allowOptionFor,
20
19
  isBlockedChangesError,
21
20
  isPrimaryKeyChangeError,
22
21
  isDeclaredRenameError,
@@ -35,7 +34,6 @@ import {
35
34
  readSnapshot,
36
35
  writeSnapshot,
37
36
  qualifiedDbName,
38
- type BlockedChangesError,
39
37
  renderD1,
40
38
  writeMigrationD1,
41
39
  writeMigrationFlyway,
@@ -62,7 +60,7 @@ import {
62
60
  type WranglerRunner,
63
61
  } from "../lib/wrangler.js";
64
62
  import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
65
- import { ALLOW_TOKEN_MAP, tokensToAllowOptions, describeChange } from "../lib/allow.js";
63
+ import { tokensToAllowOptions, blockedEntriesFor, blockedHintLines } from "../lib/allow.js";
66
64
  import { reportLoadError } from "../lib/load-error.js";
67
65
 
68
66
  /**
@@ -108,6 +106,9 @@ MIGRATE FLAGS:
108
106
  drop-check,drop-view,drop-view-cascade,
109
107
  adopt-view,nullable-to-not-null,drop-identity-default,
110
108
  drop-unmanaged
109
+ drop-column DELETES the column and every value in it. If
110
+ the column was RENAMED, use --rename-column instead: it
111
+ keeps the data.
111
112
  drop-unmanaged permits dropping a table/view the committed
112
113
  snapshot never contained — one this toolchain never managed.
113
114
  Without it that drop is refused, because the migration it
@@ -436,21 +437,10 @@ async function snapshotAbsentDrops(changes: Change[], snapPath: string): Promise
436
437
  return absent;
437
438
  }
438
439
 
439
- /** The `--allow` token that unblocks `c`. migrate-ts picks the permission by what blocked the
440
- * change (a type change can be blocked by the auto-sequence default it carries), so this only
441
- * maps that permission back to its CLI token. */
442
- function allowFlagFor(c: Change): string {
443
- const option = allowOptionFor(c);
444
- const token = Object.keys(ALLOW_TOKEN_MAP).find((t) => ALLOW_TOKEN_MAP[t] === option);
445
- return token ?? c.kind;
446
- }
447
-
448
- function blockedToEntries(err: BlockedChangesError): BlockedEntry[] {
449
- return err.blocked.map((c) => ({
450
- kind: c.kind,
451
- description: describeChange(c),
452
- allowFlag: allowFlagFor(c),
453
- }));
440
+ /** Report blocked changes on stderr, one hint per change, rename-first (see `blockedHintLines`). */
441
+ function logBlocked(entries: readonly BlockedEntry[]): void {
442
+ log.error(`migrate: ${entries.length} destructive change(s) blocked; nothing written`);
443
+ for (const e of entries) for (const l of blockedHintLines(e)) log.error(`migrate: ${l}`);
454
444
  }
455
445
 
456
446
  function ambiguousToEntries(amb: AmbiguousChange[]): AmbiguousEntry[] {
@@ -873,7 +863,7 @@ export async function migrateCommand(
873
863
  });
874
864
  } catch (err) {
875
865
  if (isBlockedChangesError(err)) {
876
- blocked = blockedToEntries(err);
866
+ blocked = blockedEntriesFor(err.blocked, diffResult.changes);
877
867
  exitCode = 1;
878
868
  } else {
879
869
  throw err;
@@ -1351,7 +1341,7 @@ export async function runOfflineGenerate(
1351
1341
  logOutOfScope(plan.outOfScope, plan.importedOutOfScope ?? [], fmt);
1352
1342
 
1353
1343
  if (diffResult.blocked.length > 0) {
1354
- log.error(`migrate: ${diffResult.blocked.length} destructive change(s) blocked; re-run with --allow <tokens>`);
1344
+ logBlocked(blockedEntriesFor(diffResult.blocked, diffResult.changes));
1355
1345
  return 1;
1356
1346
  }
1357
1347
  if (diffResult.changes.length === 0) {
@@ -1631,10 +1621,7 @@ async function runD1Migrate(
1631
1621
  emitResult = renderD1(diffResult.changes, expected, actual.meta, actual);
1632
1622
  } catch (err) {
1633
1623
  if (isBlockedChangesError(err)) {
1634
- const entries = blockedToEntries(err);
1635
- for (const e of entries) {
1636
- log.error(`migrate: blocked '${e.kind}' on ${e.description} (allow with --allow ${e.allowFlag})`);
1637
- }
1624
+ logBlocked(blockedEntriesFor(err.blocked, diffResult.changes));
1638
1625
  return 1;
1639
1626
  }
1640
1627
  throw err;
@@ -7,7 +7,7 @@
7
7
  // silently break a prompt" guarantee, enforced at the last fixed point before
8
8
  // the text ships. Required-slot misses are warnings (don't fail the build).
9
9
 
10
- import { join, resolve as resolvePath } from "node:path";
10
+ import { join, relative, resolve as resolvePath } from "node:path";
11
11
  import { parseVerifyArgs, type MigrateFlags } from "../lib/args.js";
12
12
  import { log } from "../lib/log.js";
13
13
  import { emitStructured, type OutputFormat } from "../lib/format.js";
@@ -15,15 +15,17 @@ import {
15
15
  antiPatternRows, missingBaseUrlRows, removedPropRows, ranSection, skippedSection, warnCapped,
16
16
  type AdvisoryDiagnosticRow, type AdvisoryFindingRow, type AdvisorySection,
17
17
  libraryPrefixRows,
18
+ unindexedFkRows,
18
19
  } from "../lib/advisory.js";
19
20
  import { scanForUnprovenancedLibraryPrefix } from "../lib/library-prefix-advisory.js";
21
+ import { scanForUnindexedForeignKeys, type UnindexedFkFinding } from "../lib/fk-index-advisory.js";
20
22
  import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
21
23
  import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
22
24
  import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
23
25
  import { scanForMissingBaseUrl, type BaseUrlFinding } from "../lib/base-url-advisory.js";
24
26
  import { scanForRemovedProps, type RemovedPropFinding } from "../lib/removed-prop-advisory.js";
25
27
  import { replayRemedy } from "../lib/replay-remedy.js";
26
- import { FileProvider } from "../lib/file-provider.js";
28
+ import { FileProvider, mustacheTagLine } from "../lib/file-provider.js";
27
29
  import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
28
30
  import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
29
31
  import { collectionLoadOptions } from "../lib/collection-load-options.js";
@@ -396,22 +398,33 @@ export async function verifyCommand(
396
398
  replayExit,
397
399
  );
398
400
 
401
+ const gates: VerifyGateRow[] = [
402
+ { gate: "templates", ran: runTemplates, ok: templateExit === 0 },
403
+ // The schema gate decides internally whether it is selected (--db, or
404
+ // --dialect d1), so "ran" is read off the same signals rather than
405
+ // restated: a payload that claims a gate ran when it did not is the
406
+ // failure mode this whole change exists to remove.
407
+ { gate: "schema", ran: ranSchemaGate, ok: schemaExit === 0 },
408
+ { gate: "codegen", ran: runCodegen, ok: codegenExit === 0 },
409
+ { gate: "docs", ran: runDocs, ok: docsExit === 0 },
410
+ { gate: "deps", ran: runDeps, ok: depsExit === 0 },
411
+ { gate: "requirements", ran: true, ok: requirementExit === 0 },
412
+ { gate: "replay", ran: flags.replay || flags.replaySnapshot, ok: replayExit === 0 },
413
+ ];
414
+
415
+ // Naming a gate runs ONLY the gates named (ADR-0021 D2), so `verify --codegen --db <url>`
416
+ // exits 0 without ever looking at the prompt templates. A green partial run must not read
417
+ // as a full one: say which gates did not run and how to select each. A bare `verify` has
418
+ // already said so in its default note above.
419
+ if (flags.anyExplicit) {
420
+ const note = notRunNote(gates);
421
+ if (note !== undefined) say(note);
422
+ }
423
+
399
424
  if (structured) {
400
425
  emitStructured(
401
426
  buildVerifyPayload({
402
- gates: [
403
- { gate: "templates", ran: runTemplates, ok: templateExit === 0 },
404
- // The schema gate decides internally whether it is selected (--db, or
405
- // --dialect d1), so "ran" is read off the same signals rather than
406
- // restated: a payload that claims a gate ran when it did not is the
407
- // failure mode this whole change exists to remove.
408
- { gate: "schema", ran: ranSchemaGate, ok: schemaExit === 0 },
409
- { gate: "codegen", ran: runCodegen, ok: codegenExit === 0 },
410
- { gate: "docs", ran: runDocs, ok: docsExit === 0 },
411
- { gate: "deps", ran: runDeps, ok: depsExit === 0 },
412
- { gate: "requirements", ran: true, ok: requirementExit === 0 },
413
- { gate: "replay", ran: flags.replay || flags.replaySnapshot, ok: replayExit === 0 },
414
- ],
427
+ gates,
415
428
  exitCode,
416
429
  requirements: requirementSection,
417
430
  requirementCounts,
@@ -839,11 +852,28 @@ export async function verifyCommand(
839
852
  // Same discipline as its siblings: an advisory scan never breaks verify.
840
853
  }
841
854
 
855
+ // Foreign keys migrate emits with no index on the referencing columns. Advisory: an
856
+ // automatic index would propose a migration to every existing adopter, so this names
857
+ // each one and says how to declare it (`index.lookup`) instead. Read off the SAME
858
+ // expected schema `meta migrate` builds, so "covered" means covered in its DDL.
859
+ let unindexedFks: UnindexedFkFinding[] = [];
860
+ try {
861
+ unindexedFks = scanForUnindexedForeignKeys(root, {
862
+ dialect: flags.dialect ?? forgeConfig?.dialect ?? "postgres",
863
+ columnNamingStrategy: forgeConfig?.columnNamingStrategy ?? "snake_case",
864
+ skip: collection.imported,
865
+ });
866
+ } catch {
867
+ // Same discipline as its siblings: an advisory scan never breaks verify. A model the
868
+ // schema builder refuses is migrate's to report, with its own message.
869
+ }
870
+
842
871
  antiPatternSection = ranSection([
843
872
  ...antiPatternRows(findings),
844
873
  ...missingBaseUrlRows(baseUrl),
845
874
  ...removedPropRows(removedProps),
846
875
  ...libraryPrefixRows(libraryPrefix),
876
+ ...unindexedFkRows(unindexedFks),
847
877
  ]);
848
878
  if (findings.length > 0) {
849
879
  log.warn(
@@ -866,6 +896,13 @@ export async function verifyCommand(
866
896
  );
867
897
  warnCapped(removedProps.map((f) => ` ${f.message}`), flags.limit, { structured });
868
898
  }
899
+ if (unindexedFks.length > 0) {
900
+ log.warn(
901
+ `meta verify — ${unindexedFks.length} foreign key(s) with no index on the referencing ` +
902
+ `column(s) (advisory — does not fail the build):`,
903
+ );
904
+ warnCapped(unindexedFks.map((f) => ` ${f.message}`), flags.limit, { structured });
905
+ }
869
906
  if (libraryPrefix.length > 0) {
870
907
  log.warn(
871
908
  `meta verify — ${libraryPrefix.length} node(s) declared under "metaobjects::" with ` +
@@ -987,12 +1024,22 @@ export async function verifyCommand(
987
1024
  }
988
1025
  const drift = verify(text, fieldTree, { provider, requiredSlots, requiredTags });
989
1026
  anyBodyChecked = true;
1027
+ // WHERE to look. The {code, path} diagnostic is pinned by the cross-port verify
1028
+ // corpus, so the location is added here, in the display only: the body's file
1029
+ // (project-relative) and the line the variable's tag first appears on, when the
1030
+ // tag is in this body rather than a partial.
1031
+ const bodyFile = provider.pathOf(ref);
1032
+ const where = (varPath: string): string => {
1033
+ if (bodyFile === undefined) return "";
1034
+ const line = mustacheTagLine(text, varPath);
1035
+ return ` (${relative(projectRoot, bodyFile)}${line !== undefined ? `:${line}` : ""})`;
1036
+ };
990
1037
  for (const e of drift) {
991
1038
  if (e.code === ERR_REQUIRED_SLOT_UNUSED) {
992
- log.warn(`[${tmpl.name}] (${label}) ${e.code}: ${e.path}`);
1039
+ log.warn(`[${tmpl.name}] (${label}) ${e.code}: ${e.path}${where(e.path)}`);
993
1040
  warnCount++;
994
1041
  } else {
995
- log.error(`[${tmpl.name}] (${label}) ${e.code}: ${e.path}`);
1042
+ log.error(`[${tmpl.name}] (${label}) ${e.code}: ${e.path}${where(e.path)}`);
996
1043
  errorCount++;
997
1044
  anyErrorHere = true;
998
1045
  }
@@ -1617,6 +1664,30 @@ interface VerifyGateRow {
1617
1664
  ok: boolean;
1618
1665
  }
1619
1666
 
1667
+ /** The flag that selects each gate, for the "not run" note. `requirements` always runs. */
1668
+ const GATE_SELECTOR: Record<string, string> = {
1669
+ templates: "--templates",
1670
+ schema: "--db <url>",
1671
+ codegen: "--codegen",
1672
+ docs: "--docs",
1673
+ deps: "--deps",
1674
+ replay: "--replay",
1675
+ };
1676
+
1677
+ /**
1678
+ * One line naming every gate this run did NOT run, each with the flag that selects it —
1679
+ * undefined when every gate ran. Exported for its test.
1680
+ */
1681
+ export function notRunNote(gates: readonly VerifyGateRow[]): string | undefined {
1682
+ const skipped = gates.filter((g) => !g.ran);
1683
+ if (skipped.length === 0) return undefined;
1684
+ const named = skipped.map((g) => {
1685
+ const flag = GATE_SELECTOR[g.gate];
1686
+ return flag !== undefined ? `${g.gate} (${flag})` : g.gate;
1687
+ });
1688
+ return `meta verify — not run: ${named.join(", ")}`;
1689
+ }
1690
+
1620
1691
  /**
1621
1692
  * Format one `Diagnostic` as a printed TEXT line. Named `formatDiagnostic`, not
1622
1693
  * `fmt`: `fmt` is this command's OUTPUT FORMAT parameter, and a shadow of it
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,23 @@ 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
+
166
183
  // ---------------------------------------------------------------------------
167
184
  // text output
168
185
  // ---------------------------------------------------------------------------
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
@@ -83,3 +85,57 @@ export function describeChange(c: Change): string {
83
85
  default: return JSON.stringify(c);
84
86
  }
85
87
  }
88
+
89
+ /** The `--allow` token that unblocks `c`. migrate-ts picks the permission by what blocked the
90
+ * change (a type change can be blocked by the auto-sequence default it carries), so this only
91
+ * maps that permission back to its CLI token. */
92
+ export function allowFlagFor(c: Change): string {
93
+ const option = allowOptionFor(c);
94
+ const token = Object.keys(ALLOW_TOKEN_MAP).find((t) => ALLOW_TOKEN_MAP[t] === option);
95
+ return token ?? c.kind;
96
+ }
97
+
98
+ /** `--rename-column [schema.]table.old=new`, spelled with the pair's real names. */
99
+ export function renameColumnFlag(r: ColumnRenameSuggestion): string {
100
+ return `--rename-column ${r.schema !== undefined ? `${r.schema}.` : ""}${r.table}.${r.from}=${r.to}`;
101
+ }
102
+
103
+ /**
104
+ * One reportable entry per blocked change. `changes` is the whole diff the blocked ones came
105
+ * from: a blocked drop-column that pairs with an add-column in the same table may be a column
106
+ * the author RENAMED, and `--allow drop-column` would delete its data — so that entry carries
107
+ * the declared-rename flag, which every renderer prints FIRST.
108
+ */
109
+ export function blockedEntriesFor(blocked: readonly Change[], changes: readonly Change[]): BlockedEntry[] {
110
+ const renames = suggestColumnRenames(changes);
111
+ return blocked.map((c) => {
112
+ const entry: BlockedEntry = { kind: c.kind, description: describeChange(c), allowFlag: allowFlagFor(c) };
113
+ if (c.kind !== "drop-column") return entry;
114
+ const r = renames.find((x) => x.from === c.column && x.table === c.table && x.schema === c.schema);
115
+ if (r === undefined) return entry;
116
+ return {
117
+ ...entry,
118
+ renameFlag: renameColumnFlag(r),
119
+ renameTo: `${r.table}.${r.to}`,
120
+ ...(r.shapeChange !== undefined ? { shapeChange: r.shapeChange } : {}),
121
+ };
122
+ });
123
+ }
124
+
125
+ /**
126
+ * The hint for one blocked entry, as the lines a renderer prints in order. A rename-pairable
127
+ * drop leads with the declared rename and names `--allow drop-column` as what deletes the data;
128
+ * every other entry is the one-line `--allow` hint it always was.
129
+ */
130
+ export function blockedHintLines(e: BlockedEntry): string[] {
131
+ if (e.renameFlag === undefined) {
132
+ return [`blocked '${e.kind}' on ${e.description} (allow with --allow ${e.allowFlag})`];
133
+ }
134
+ const shape = e.shapeChange !== undefined
135
+ ? ` (its ${e.shapeChange} also changes: rename first with the old shape still declared, then change the shape in a second migration)`
136
+ : "";
137
+ return [
138
+ `${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}`,
139
+ ` (--allow ${e.allowFlag} instead DELETES ${e.description} and all its data)`,
140
+ ];
141
+ }
@@ -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) {