@metaobjectsdev/cli 1.0.9-rc.1 → 1.0.9-rc.3
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 +3 -0
- package/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +10 -2
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/gen.d.ts +0 -5
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +15 -1
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +26 -1
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +18 -3
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/lib/advisory.d.ts +9 -0
- package/dist/src/lib/advisory.d.ts.map +1 -1
- package/dist/src/lib/advisory.js +13 -0
- package/dist/src/lib/advisory.js.map +1 -1
- package/dist/src/lib/allow.d.ts.map +1 -1
- package/dist/src/lib/allow.js +9 -1
- package/dist/src/lib/allow.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +3 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +40 -5
- package/dist/src/lib/catalog-listing.js.map +1 -1
- package/dist/src/lib/config.d.ts +8 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +11 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/gen-migrate-advice.d.ts +17 -0
- package/dist/src/lib/gen-migrate-advice.d.ts.map +1 -0
- package/dist/src/lib/gen-migrate-advice.js +105 -0
- package/dist/src/lib/gen-migrate-advice.js.map +1 -0
- package/dist/src/lib/install-set.d.ts +5 -1
- package/dist/src/lib/install-set.d.ts.map +1 -1
- package/dist/src/lib/install-set.js +9 -1
- package/dist/src/lib/install-set.js.map +1 -1
- package/dist/src/lib/output.d.ts +5 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +1 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/referential-action-advisory.d.ts +14 -0
- package/dist/src/lib/referential-action-advisory.d.ts.map +1 -0
- package/dist/src/lib/referential-action-advisory.js +38 -0
- package/dist/src/lib/referential-action-advisory.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/eject.ts +12 -2
- package/src/commands/gen.ts +17 -1
- package/src/commands/migrate.ts +26 -0
- package/src/commands/verify.ts +20 -1
- package/src/lib/advisory.ts +16 -0
- package/src/lib/allow.ts +9 -1
- package/src/lib/catalog-listing.ts +46 -7
- package/src/lib/config.ts +14 -0
- package/src/lib/gen-migrate-advice.ts +129 -0
- package/src/lib/install-set.ts +11 -1
- package/src/lib/output.ts +6 -1
- package/src/lib/referential-action-advisory.ts +58 -0
package/src/lib/allow.ts
CHANGED
|
@@ -79,11 +79,19 @@ export function describeChange(c: Change): string {
|
|
|
79
79
|
case "drop-index": return `${c.table} idx ${c.index}`;
|
|
80
80
|
case "add-fk": return `${c.table} fk ${c.fk.name}`;
|
|
81
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}`;
|
|
82
87
|
case "create-view": return c.view.name;
|
|
83
88
|
case "replace-view": return c.view.name;
|
|
84
89
|
case "drop-view": return c.view;
|
|
85
|
-
default: return JSON.stringify(c);
|
|
86
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;
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/** The `--allow` token that unblocks `c`. migrate-ts picks the permission by what blocked the
|
|
@@ -77,6 +77,9 @@ export interface GeneratorCatalogRow {
|
|
|
77
77
|
wouldEmit: number | null;
|
|
78
78
|
/** Why `wouldEmit` is null despite --probe. Absent when it is a number. */
|
|
79
79
|
probeError?: string;
|
|
80
|
+
/** Config keys this project leaves unset that the generator needs before `meta gen`
|
|
81
|
+
* can run it. `wouldEmit` was counted with a placeholder for each. Absent when none. */
|
|
82
|
+
needsConfig?: string[];
|
|
80
83
|
};
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -180,21 +183,44 @@ async function probeOne(
|
|
|
180
183
|
project: CatalogProject,
|
|
181
184
|
metadata: MetaData,
|
|
182
185
|
scope: ((fqn: string) => boolean) | undefined,
|
|
183
|
-
): Promise<{ count: number } | { error: string }> {
|
|
184
|
-
|
|
186
|
+
): Promise<{ count: number; needsConfig?: string[] } | { error: string }> {
|
|
187
|
+
const count = async (config: MetaobjectsGenConfig): Promise<number> => {
|
|
185
188
|
const result = await runGen({
|
|
186
|
-
config: { ...
|
|
189
|
+
config: { ...config, generators: [entry.factory()] },
|
|
187
190
|
metadata,
|
|
188
191
|
projectRoot: project.projectRoot,
|
|
189
192
|
dryRun: true,
|
|
190
193
|
...(scope !== undefined ? { scope } : {}),
|
|
191
194
|
});
|
|
192
|
-
return
|
|
195
|
+
return result.files.length;
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
return { count: await count(project.config) };
|
|
193
199
|
} catch (err) {
|
|
200
|
+
// A generator that emits `import { db } from …` (the Fastify `routes`, `queries`)
|
|
201
|
+
// refuses to run while `dbImport` is unset — which it is in a freshly initialised
|
|
202
|
+
// project, exactly when `--probe` is being asked "what would this emit?". The path
|
|
203
|
+
// changes one import line, never the file count, so count with a placeholder and
|
|
204
|
+
// report the key as needed rather than dropping the count: the listing used to show
|
|
205
|
+
// `routes … [fastify]` beside `routes-hono … [hono, would emit 3]` with no reason.
|
|
206
|
+
// Only a retry that SUCCEEDS with the key supplied attributes the failure to it.
|
|
207
|
+
if (project.config.dbImport === undefined) {
|
|
208
|
+
try {
|
|
209
|
+
return {
|
|
210
|
+
count: await count({ ...project.config, dbImport: PROBE_DB_IMPORT_PLACEHOLDER }),
|
|
211
|
+
needsConfig: ["dbImport"],
|
|
212
|
+
};
|
|
213
|
+
} catch {
|
|
214
|
+
// Fall through: the original error is the one worth reporting.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
194
217
|
return { error: (err as Error).message };
|
|
195
218
|
}
|
|
196
219
|
}
|
|
197
220
|
|
|
221
|
+
/** Stand-in `dbImport` for a probe. Never written anywhere — `dryRun` touches no file. */
|
|
222
|
+
const PROBE_DB_IMPORT_PLACEHOLDER = "./db";
|
|
223
|
+
|
|
198
224
|
// ---------------------------------------------------------------------------
|
|
199
225
|
// the listing
|
|
200
226
|
// ---------------------------------------------------------------------------
|
|
@@ -241,10 +267,13 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
|
|
|
241
267
|
|
|
242
268
|
let wouldEmit: number | null = null;
|
|
243
269
|
let probeError: string | undefined;
|
|
270
|
+
let needsConfig: string[] | undefined;
|
|
244
271
|
if (opts.probe !== undefined) {
|
|
245
272
|
const outcome = await probeOne(entry, project, opts.probe.metadata, opts.probe.scope);
|
|
246
|
-
if ("count" in outcome)
|
|
247
|
-
|
|
273
|
+
if ("count" in outcome) {
|
|
274
|
+
wouldEmit = outcome.count;
|
|
275
|
+
needsConfig = outcome.needsConfig;
|
|
276
|
+
} else probeError = outcome.error;
|
|
248
277
|
}
|
|
249
278
|
|
|
250
279
|
row.project = {
|
|
@@ -252,6 +281,7 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
|
|
|
252
281
|
frameworkDetected,
|
|
253
282
|
wouldEmit,
|
|
254
283
|
...(probeError !== undefined ? { probeError } : {}),
|
|
284
|
+
...(needsConfig !== undefined ? { needsConfig } : {}),
|
|
255
285
|
};
|
|
256
286
|
}
|
|
257
287
|
|
|
@@ -361,7 +391,16 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
|
|
|
361
391
|
if (r.project?.wired) marks.push("WIRED");
|
|
362
392
|
if (r.source.owned) marks.push("owned");
|
|
363
393
|
if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
|
|
364
|
-
|
|
394
|
+
const needs = r.project.needsConfig;
|
|
395
|
+
marks.push(
|
|
396
|
+
needs !== undefined && needs.length > 0
|
|
397
|
+
? `would emit ${r.project.wouldEmit} once ${needs.join(", ")} is set`
|
|
398
|
+
: `would emit ${r.project.wouldEmit}`,
|
|
399
|
+
);
|
|
400
|
+
} else if (probed && r.project?.probeError !== undefined) {
|
|
401
|
+
// Say that the probe could not count it, not nothing: a missing count read as
|
|
402
|
+
// "emits nothing" beside a sibling that showed one.
|
|
403
|
+
marks.push("probe failed — see --format json");
|
|
365
404
|
}
|
|
366
405
|
const suffix = marks.length > 0 ? ` [${marks.join(", ")}]` : "";
|
|
367
406
|
lines.push(` ${r.name.padEnd(width)} — ${r.description}${suffix}`);
|
package/src/lib/config.ts
CHANGED
|
@@ -82,6 +82,20 @@ async function tryLoadConfig(metaRoot: string): Promise<Config | undefined> {
|
|
|
82
82
|
// Public resolvers
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The migrations directory and dialect `meta migrate` would use with no flags — what
|
|
87
|
+
* `meta gen` needs to word its "now migrate" next step and find the committed snapshot.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveMigrateDefaults(
|
|
90
|
+
metaRoot: string,
|
|
91
|
+
): Promise<{ outDir: string; dialect: Dialect | undefined }> {
|
|
92
|
+
const config = await tryLoadConfig(metaRoot);
|
|
93
|
+
return {
|
|
94
|
+
outDir: config?.migrate?.outDir ?? MIGRATE_DEFAULTS.outDir,
|
|
95
|
+
dialect: config?.migrate?.dialect ?? MIGRATE_DEFAULTS.dialect,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
export function resolveGenConfig(flags: GenFlags): ResolvedGenConfig {
|
|
86
100
|
return { dryRun: flags.dryRun, entities: flags.entities };
|
|
87
101
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The "now migrate" next step `meta gen` prints — WHEN a schema change is plausible,
|
|
2
|
+
// and in the project's own dialect.
|
|
3
|
+
//
|
|
4
|
+
// It used to be one hardcoded line on every run that wrote a file:
|
|
5
|
+
// `meta migrate --from-db --db <url> --dialect <sqlite|postgres> --slug init --apply`.
|
|
6
|
+
// That was wrong twice over for a D1 project (whose migrate takes `--dialect d1 --d1
|
|
7
|
+
// <binding>` and applies through wrangler), and it was noise after any change that
|
|
8
|
+
// cannot reach the database — a value object or a prompt payload has no table.
|
|
9
|
+
//
|
|
10
|
+
// The decision, most precise evidence first:
|
|
11
|
+
// 1. No table-backed object in the model → no advice (nothing to migrate, ever).
|
|
12
|
+
// 2. Nothing written this run → no advice.
|
|
13
|
+
// 3. A committed schema snapshot exists → advise exactly when the expected schema's
|
|
14
|
+
// TABLES differ from it (the same diff `meta migrate` runs offline).
|
|
15
|
+
// 4. No snapshot (a D1 project, or before the first migrate) → advise when a file
|
|
16
|
+
// generated for a table-backed object, or the shared enums module (enum members
|
|
17
|
+
// are CHECK constraints), changed this run. Files of value objects, prompts and
|
|
18
|
+
// other sourceless declarations do not count.
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { basename, resolve as resolvePath } from "node:path";
|
|
21
|
+
import type { MetaRoot } from "@metaobjectsdev/metadata";
|
|
22
|
+
import {
|
|
23
|
+
buildExpectedSchemaWithProvenance,
|
|
24
|
+
diff,
|
|
25
|
+
findWranglerConfig,
|
|
26
|
+
parseWranglerConfig,
|
|
27
|
+
readSnapshot,
|
|
28
|
+
snapshotPath,
|
|
29
|
+
type Change,
|
|
30
|
+
type D1Binding,
|
|
31
|
+
} from "@metaobjectsdev/migrate-ts";
|
|
32
|
+
|
|
33
|
+
/** The FQN separator; an object's short name is the segment after the last one. */
|
|
34
|
+
const FQN_SEPARATOR = "::";
|
|
35
|
+
/** The shared enums module `entityFile()` emits (its members back CHECK constraints). */
|
|
36
|
+
const SHARED_ENUMS_BASENAME = "enums.ts";
|
|
37
|
+
|
|
38
|
+
const VIEW_CHANGE_KINDS: ReadonlySet<Change["kind"]> = new Set(["create-view", "drop-view", "replace-view"]);
|
|
39
|
+
|
|
40
|
+
export interface GenMigrateAdviceInput {
|
|
41
|
+
metadata: MetaRoot;
|
|
42
|
+
/** `metaobjects.config.ts` `dialect` — undefined when no DB code is generated. */
|
|
43
|
+
dialect: "sqlite" | "postgres" | undefined;
|
|
44
|
+
/** `.metaobjects/config.json` `migrate.dialect`, when set (may say `d1`). */
|
|
45
|
+
migrateDialect: "sqlite" | "postgres" | "d1" | undefined;
|
|
46
|
+
columnNamingStrategy: "snake_case" | "literal" | "kebab-case" | undefined;
|
|
47
|
+
projectRoot: string;
|
|
48
|
+
/** The migrations directory `meta migrate` uses (holds the committed snapshot). */
|
|
49
|
+
migrateOutDir: string;
|
|
50
|
+
/** Paths (any form) of the files this run wrote, merged or removed. */
|
|
51
|
+
changedFiles: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The next-step line, or undefined when this run cannot have changed the schema. */
|
|
55
|
+
export async function genMigrateAdvice(input: GenMigrateAdviceInput): Promise<string | undefined> {
|
|
56
|
+
if (input.dialect === undefined || input.changedFiles.length === 0) return undefined;
|
|
57
|
+
|
|
58
|
+
const d1 = d1BindingFor(input);
|
|
59
|
+
const schemaDialect = d1 !== undefined ? "d1" : input.dialect;
|
|
60
|
+
let built;
|
|
61
|
+
try {
|
|
62
|
+
built = buildExpectedSchemaWithProvenance(input.metadata, {
|
|
63
|
+
dialect: schemaDialect,
|
|
64
|
+
...(input.columnNamingStrategy !== undefined ? { columnNamingStrategy: input.columnNamingStrategy } : {}),
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// A model `meta migrate` would refuse (it says why); gen is not the place to report it.
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const expected = { tables: built.snapshot.tables, views: [] };
|
|
71
|
+
if (expected.tables.length === 0) return undefined;
|
|
72
|
+
|
|
73
|
+
const snapPath = snapshotPath(resolvePath(input.projectRoot, input.migrateOutDir), schemaDialect);
|
|
74
|
+
const snapshot = existsSync(snapPath) ? await readSnapshot(snapPath) : null;
|
|
75
|
+
if (snapshot !== null) {
|
|
76
|
+
const actual = { ...snapshot, views: [] };
|
|
77
|
+
try {
|
|
78
|
+
const d = await diff({ expected, actual, dialect: schemaDialect });
|
|
79
|
+
if (d.changes.every((c) => VIEW_CHANGE_KINDS.has(c.kind))) return undefined;
|
|
80
|
+
} catch {
|
|
81
|
+
// A diff the engine refuses (a primary-key move, say) is still a schema change.
|
|
82
|
+
}
|
|
83
|
+
} else if (!touchesTableBackedObject(input.changedFiles, built.provenance)) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (d1 !== undefined) {
|
|
88
|
+
const db = d1.database_name.length > 0 ? d1.database_name : "<database>";
|
|
89
|
+
return `write the schema change as a D1 migration with \`meta migrate --dialect d1 --d1 ${d1.binding} ` +
|
|
90
|
+
`--slug <name>\`, then apply it with \`wrangler d1 migrations apply ${db} --local\``;
|
|
91
|
+
}
|
|
92
|
+
return snapshot === null
|
|
93
|
+
? `create your database tables with \`meta migrate --from-db --db <url> --dialect ${input.dialect} --slug init --apply\``
|
|
94
|
+
: `migrate the schema change with \`meta migrate --db <url> --dialect ${input.dialect} --slug <name> --apply\``;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The project's D1 binding when it migrates through D1, else undefined. */
|
|
98
|
+
function d1BindingFor(input: GenMigrateAdviceInput): D1Binding | undefined {
|
|
99
|
+
if (input.dialect !== "sqlite" && input.migrateDialect !== "d1") return undefined;
|
|
100
|
+
const path = findWranglerConfig(input.projectRoot);
|
|
101
|
+
if (path === undefined) return undefined;
|
|
102
|
+
try {
|
|
103
|
+
return parseWranglerConfig(path).d1Bindings[0];
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether a changed file was generated for a table-backed object: its basename starts
|
|
111
|
+
* with that object's short name followed by a `.` (`Pet.ts`, `Pet.queries.ts`), or it
|
|
112
|
+
* is the shared enums module.
|
|
113
|
+
*/
|
|
114
|
+
function touchesTableBackedObject(
|
|
115
|
+
changedFiles: readonly string[],
|
|
116
|
+
provenance: ReadonlyMap<string, string>,
|
|
117
|
+
): boolean {
|
|
118
|
+
const shortName = (fqn: string): string => {
|
|
119
|
+
const at = fqn.lastIndexOf(FQN_SEPARATOR);
|
|
120
|
+
return at === -1 ? fqn : fqn.slice(at + FQN_SEPARATOR.length);
|
|
121
|
+
};
|
|
122
|
+
const names = new Set([...provenance.values()].map(shortName));
|
|
123
|
+
return changedFiles.some((f) => {
|
|
124
|
+
const base = basename(f);
|
|
125
|
+
if (base === SHARED_ENUMS_BASENAME) return true;
|
|
126
|
+
const dot = base.indexOf(".");
|
|
127
|
+
return dot > 0 && names.has(base.slice(0, dot));
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/lib/install-set.ts
CHANGED
|
@@ -108,7 +108,13 @@ function specs(from: Map<string, string | undefined>): string[] {
|
|
|
108
108
|
* `@metaobjectsdev/codegen-ts-tanstack` ONCE, and an adopter handed the same package
|
|
109
109
|
* twice reasonably wonders which one to run.
|
|
110
110
|
*/
|
|
111
|
-
export function installSetFor(
|
|
111
|
+
export function installSetFor(
|
|
112
|
+
entries: readonly GeneratorRegistryEntry[],
|
|
113
|
+
/** `@metaobjectsdev/*` packages an ejected generator FILE imports at gen time (the
|
|
114
|
+
* catalog does not know them). Build-time, so dev — unless the runtime set already
|
|
115
|
+
* installs the package, which satisfies the build too. */
|
|
116
|
+
templatePackages: readonly string[] = [],
|
|
117
|
+
): InstallSet {
|
|
112
118
|
const version = cliVersion();
|
|
113
119
|
const dev = new Map<string, string | undefined>();
|
|
114
120
|
const runtime = new Map<string, string | undefined>();
|
|
@@ -131,6 +137,10 @@ export function installSetFor(entries: readonly GeneratorRegistryEntry[]): Insta
|
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
for (const p of templatePackages) {
|
|
141
|
+
if (!runtime.has(p)) addPackage(dev, p, `^${version}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
134
144
|
const devList = specs(dev);
|
|
135
145
|
const runtimeList = specs(runtime);
|
|
136
146
|
const parts: string[] = [];
|
package/src/lib/output.ts
CHANGED
|
@@ -70,6 +70,11 @@ export interface GenResultShape {
|
|
|
70
70
|
* already correct. The output's own first line (`gen: []`) already knew.
|
|
71
71
|
*/
|
|
72
72
|
generatorCount: number;
|
|
73
|
+
/**
|
|
74
|
+
* The "now migrate" next step, worded for this project's dialect — or absent when
|
|
75
|
+
* this run cannot have changed the database schema (see gen-migrate-advice.ts).
|
|
76
|
+
*/
|
|
77
|
+
migrateAdvice?: string;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
const GEN_GLYPHS: Record<GenFileStatus, string> = {
|
|
@@ -283,7 +288,7 @@ export function genResultToData(result: GenResultShape): {
|
|
|
283
288
|
? "no generators are wired in metaobjects.config.ts, so nothing was generated"
|
|
284
289
|
: `no entities to generate in ${result.outDir}`;
|
|
285
290
|
const help = result.files.length !== 0
|
|
286
|
-
? ["typecheck the generated code with `npx tsc`",
|
|
291
|
+
? ["typecheck the generated code with `npx tsc`", ...(result.migrateAdvice !== undefined ? [result.migrateAdvice] : [])]
|
|
287
292
|
: noGenerators
|
|
288
293
|
? ["add generators to the `generators: []` array in metaobjects.config.ts — `meta eject --list` names every generator you can own, and `meta init` scaffolds the usual set"]
|
|
289
294
|
: ["author entities in this project's metadata sources then re-run `meta gen`"];
|
|
@@ -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
|
+
}
|