@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.
Files changed (58) hide show
  1. package/dist/src/commands/eject.d.ts +3 -0
  2. package/dist/src/commands/eject.d.ts.map +1 -1
  3. package/dist/src/commands/eject.js +10 -2
  4. package/dist/src/commands/eject.js.map +1 -1
  5. package/dist/src/commands/gen.d.ts +0 -5
  6. package/dist/src/commands/gen.d.ts.map +1 -1
  7. package/dist/src/commands/gen.js +15 -1
  8. package/dist/src/commands/gen.js.map +1 -1
  9. package/dist/src/commands/migrate.d.ts.map +1 -1
  10. package/dist/src/commands/migrate.js +26 -1
  11. package/dist/src/commands/migrate.js.map +1 -1
  12. package/dist/src/commands/verify.d.ts.map +1 -1
  13. package/dist/src/commands/verify.js +18 -3
  14. package/dist/src/commands/verify.js.map +1 -1
  15. package/dist/src/lib/advisory.d.ts +9 -0
  16. package/dist/src/lib/advisory.d.ts.map +1 -1
  17. package/dist/src/lib/advisory.js +13 -0
  18. package/dist/src/lib/advisory.js.map +1 -1
  19. package/dist/src/lib/allow.d.ts.map +1 -1
  20. package/dist/src/lib/allow.js +9 -1
  21. package/dist/src/lib/allow.js.map +1 -1
  22. package/dist/src/lib/catalog-listing.d.ts +3 -0
  23. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  24. package/dist/src/lib/catalog-listing.js +40 -5
  25. package/dist/src/lib/catalog-listing.js.map +1 -1
  26. package/dist/src/lib/config.d.ts +8 -0
  27. package/dist/src/lib/config.d.ts.map +1 -1
  28. package/dist/src/lib/config.js +11 -0
  29. package/dist/src/lib/config.js.map +1 -1
  30. package/dist/src/lib/gen-migrate-advice.d.ts +17 -0
  31. package/dist/src/lib/gen-migrate-advice.d.ts.map +1 -0
  32. package/dist/src/lib/gen-migrate-advice.js +105 -0
  33. package/dist/src/lib/gen-migrate-advice.js.map +1 -0
  34. package/dist/src/lib/install-set.d.ts +5 -1
  35. package/dist/src/lib/install-set.d.ts.map +1 -1
  36. package/dist/src/lib/install-set.js +9 -1
  37. package/dist/src/lib/install-set.js.map +1 -1
  38. package/dist/src/lib/output.d.ts +5 -0
  39. package/dist/src/lib/output.d.ts.map +1 -1
  40. package/dist/src/lib/output.js +1 -1
  41. package/dist/src/lib/output.js.map +1 -1
  42. package/dist/src/lib/referential-action-advisory.d.ts +14 -0
  43. package/dist/src/lib/referential-action-advisory.d.ts.map +1 -0
  44. package/dist/src/lib/referential-action-advisory.js +38 -0
  45. package/dist/src/lib/referential-action-advisory.js.map +1 -0
  46. package/package.json +10 -10
  47. package/src/commands/eject.ts +12 -2
  48. package/src/commands/gen.ts +17 -1
  49. package/src/commands/migrate.ts +26 -0
  50. package/src/commands/verify.ts +20 -1
  51. package/src/lib/advisory.ts +16 -0
  52. package/src/lib/allow.ts +9 -1
  53. package/src/lib/catalog-listing.ts +46 -7
  54. package/src/lib/config.ts +14 -0
  55. package/src/lib/gen-migrate-advice.ts +129 -0
  56. package/src/lib/install-set.ts +11 -1
  57. package/src/lib/output.ts +6 -1
  58. 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
- try {
186
+ ): Promise<{ count: number; needsConfig?: string[] } | { error: string }> {
187
+ const count = async (config: MetaobjectsGenConfig): Promise<number> => {
185
188
  const result = await runGen({
186
- config: { ...project.config, generators: [entry.factory()] },
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 { count: result.files.length };
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) wouldEmit = outcome.count;
247
- else probeError = outcome.error;
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
- marks.push(`would emit ${r.project.wouldEmit}`);
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
+ }
@@ -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(entries: readonly GeneratorRegistryEntry[]): InstallSet {
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`", "create your database tables with `meta migrate --from-db --db <url> --dialect <sqlite|postgres> --slug init --apply`"]
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
+ }