@metaobjectsdev/cli 1.0.8-rc.1 → 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.
- package/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +22 -4
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +0 -3
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/migrate.d.ts +1 -1
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +14 -22
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/verify.d.ts +12 -0
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +85 -18
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/advisory.d.ts +10 -0
- package/dist/src/lib/advisory.d.ts.map +1 -1
- package/dist/src/lib/advisory.js +14 -0
- package/dist/src/lib/advisory.js.map +1 -1
- package/dist/src/lib/allow.d.ts +21 -1
- package/dist/src/lib/allow.d.ts.map +1 -1
- package/dist/src/lib/allow.js +53 -0
- package/dist/src/lib/allow.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +2 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +7 -0
- package/dist/src/lib/catalog-listing.js.map +1 -1
- package/dist/src/lib/file-provider.d.ts +12 -0
- package/dist/src/lib/file-provider.d.ts.map +1 -1
- package/dist/src/lib/file-provider.js +26 -1
- package/dist/src/lib/file-provider.js.map +1 -1
- package/dist/src/lib/fk-index-advisory.d.ts +17 -0
- package/dist/src/lib/fk-index-advisory.d.ts.map +1 -0
- package/dist/src/lib/fk-index-advisory.js +77 -0
- package/dist/src/lib/fk-index-advisory.js.map +1 -0
- package/dist/src/lib/output.d.ts +9 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +17 -3
- package/dist/src/lib/output.js.map +1 -1
- package/package.json +10 -10
- package/src/commands/eject.ts +25 -4
- package/src/commands/init.ts +0 -3
- package/src/commands/migrate.ts +11 -24
- package/src/commands/verify.ts +88 -17
- package/src/index.ts +5 -2
- package/src/lib/advisory.ts +17 -0
- package/src/lib/allow.ts +57 -1
- package/src/lib/catalog-listing.ts +7 -0
- package/src/lib/file-provider.ts +27 -1
- package/src/lib/fk-index-advisory.ts +112 -0
- package/src/lib/output.ts +25 -3
package/src/commands/migrate.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/commands/verify.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/lib/advisory.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/lib/file-provider.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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) {
|