@metaobjectsdev/cli 0.8.1 → 0.9.0

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 (43) hide show
  1. package/README.md +40 -1
  2. package/dist/src/commands/migrate.d.ts +16 -0
  3. package/dist/src/commands/migrate.d.ts.map +1 -1
  4. package/dist/src/commands/migrate.js +252 -39
  5. package/dist/src/commands/migrate.js.map +1 -1
  6. package/dist/src/commands/verify.d.ts.map +1 -1
  7. package/dist/src/commands/verify.js +162 -53
  8. package/dist/src/commands/verify.js.map +1 -1
  9. package/dist/src/index.d.ts.map +1 -1
  10. package/dist/src/index.js +6 -0
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/lib/allow.d.ts +10 -0
  13. package/dist/src/lib/allow.d.ts.map +1 -0
  14. package/dist/src/lib/allow.js +50 -0
  15. package/dist/src/lib/allow.js.map +1 -0
  16. package/dist/src/lib/args.d.ts +22 -4
  17. package/dist/src/lib/args.d.ts.map +1 -1
  18. package/dist/src/lib/args.js +47 -11
  19. package/dist/src/lib/args.js.map +1 -1
  20. package/dist/src/lib/bun-sqlite-dialect.d.ts +13 -0
  21. package/dist/src/lib/bun-sqlite-dialect.d.ts.map +1 -0
  22. package/dist/src/lib/bun-sqlite-dialect.js +119 -0
  23. package/dist/src/lib/bun-sqlite-dialect.js.map +1 -0
  24. package/dist/src/lib/config.d.ts +11 -0
  25. package/dist/src/lib/config.d.ts.map +1 -1
  26. package/dist/src/lib/config.js +4 -0
  27. package/dist/src/lib/config.js.map +1 -1
  28. package/dist/src/lib/kysely.d.ts.map +1 -1
  29. package/dist/src/lib/kysely.js +21 -7
  30. package/dist/src/lib/kysely.js.map +1 -1
  31. package/dist/src/lib/projection-migrations.d.ts.map +1 -1
  32. package/dist/src/lib/projection-migrations.js +2 -6
  33. package/dist/src/lib/projection-migrations.js.map +1 -1
  34. package/package.json +11 -10
  35. package/src/commands/migrate.ts +277 -42
  36. package/src/commands/verify.ts +172 -61
  37. package/src/index.ts +6 -0
  38. package/src/lib/allow.ts +54 -0
  39. package/src/lib/args.ts +77 -15
  40. package/src/lib/bun-sqlite-dialect.ts +146 -0
  41. package/src/lib/config.ts +15 -0
  42. package/src/lib/kysely.ts +23 -10
  43. package/src/lib/projection-migrations.ts +2 -6
@@ -15,15 +15,21 @@ import {
15
15
  diff,
16
16
  emit,
17
17
  writeMigration,
18
+ baselineFromMetadata,
19
+ planOffline,
20
+ snapshotPath,
21
+ readSnapshot,
22
+ writeSnapshot,
18
23
  BlockedChangesError,
19
24
  renderD1,
20
25
  applyD1SafetyPass,
21
26
  writeMigrationD1,
22
27
  introspectD1,
28
+ applyPending,
29
+ rollbackTo,
23
30
  findWranglerConfig,
24
31
  parseWranglerConfig,
25
32
  resolveD1Binding,
26
- type AllowOptions,
27
33
  type AmbiguousChange,
28
34
  type AmbiguousResolution,
29
35
  type Change,
@@ -40,32 +46,12 @@ import {
40
46
  computeProjectionMigrations,
41
47
  computeProjectionViewDependencies,
42
48
  } from "../lib/projection-migrations.js";
43
-
44
- // Map CLI allow tokens → migrate-ts AllowOptions field names
45
- const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
46
- "drop-column": "dropColumn",
47
- "drop-table": "dropTable",
48
- "type-change": "typeChange",
49
- "drop-index": "dropIndex",
50
- "drop-fk": "dropFk",
51
- "nullable-to-not-null": "nullableToNotNull",
52
- };
49
+ import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
53
50
 
54
51
  function mapOnAmbiguous(v: "abort" | "rename" | "drop-add"): AmbiguousResolution {
55
52
  return v === "drop-add" ? "drop+add" : v;
56
53
  }
57
54
 
58
- function tokensToAllowOptions(tokens: string[]): AllowOptions {
59
- const opts: AllowOptions = {};
60
- for (const tok of tokens) {
61
- const field = ALLOW_TOKEN_MAP[tok];
62
- if (field !== undefined) {
63
- opts[field] = true;
64
- }
65
- }
66
- return opts;
67
- }
68
-
69
55
  function summarizeChanges(changes: Change[]): Record<string, number> {
70
56
  const counts: Record<string, number> = {};
71
57
  for (const c of changes) {
@@ -74,25 +60,6 @@ function summarizeChanges(changes: Change[]): Record<string, number> {
74
60
  return counts;
75
61
  }
76
62
 
77
- function describeChangeForOutput(c: Change): string {
78
- switch (c.kind) {
79
- case "create-table": return c.table.name;
80
- case "drop-table": return c.table;
81
- case "rename-table": return `${c.from} → ${c.to}`;
82
- case "add-column": return `${c.table}.${c.column.name}`;
83
- case "drop-column": return `${c.table}.${c.column}`;
84
- case "rename-column": return `${c.table}.${c.from} → ${c.table}.${c.to}`;
85
- case "change-column-type": return `${c.table}.${c.column} (${c.from.kind} → ${c.to.kind})`;
86
- case "change-column-nullable": return `${c.table}.${c.column} (${c.from ? "NULL" : "NOT NULL"} → ${c.to ? "NULL" : "NOT NULL"})`;
87
- case "change-column-default": return `${c.table}.${c.column}`;
88
- case "add-index": return `${c.table} idx ${c.index.name}`;
89
- case "drop-index": return `${c.table} idx ${c.index}`;
90
- case "add-fk": return `${c.table} fk ${c.fk.name}`;
91
- case "drop-fk": return `${c.table} fk ${c.fk}`;
92
- default: return JSON.stringify(c);
93
- }
94
- }
95
-
96
63
  function allowFlagFor(kind: string): string {
97
64
  switch (kind) {
98
65
  case "drop-column": return "drop-column";
@@ -108,7 +75,7 @@ function allowFlagFor(kind: string): string {
108
75
  function blockedToEntries(err: BlockedChangesError): BlockedEntry[] {
109
76
  return err.blocked.map((c) => ({
110
77
  kind: c.kind,
111
- description: describeChangeForOutput(c),
78
+ description: describeChange(c),
112
79
  allowFlag: allowFlagFor(c.kind),
113
80
  }));
114
81
  }
@@ -148,18 +115,45 @@ export async function migrateCommand(
148
115
  const config = await resolveMigrateConfig(flags, metaRoot);
149
116
 
150
117
  if (config.dialect === "d1") {
118
+ if (config.baseline) {
119
+ log.error(`migrate baseline is not supported for dialect 'd1' (snapshots are a postgres/sqlite concept)`);
120
+ return 2;
121
+ }
151
122
  if (config.databaseUrl !== undefined) {
152
123
  log.error(`migrate: --db / DATABASE_URL is not used for dialect 'd1' — wrangler.toml owns connection`);
153
124
  return 2;
154
125
  }
126
+ if (config.rollback !== undefined) {
127
+ log.error(`migrate: --rollback is not supported for dialect 'd1' (use 'wrangler d1 migrations' tooling)`);
128
+ return 2;
129
+ }
155
130
  return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner);
156
131
  }
157
132
 
133
+ // `migrate baseline` — seed the committed reference snapshot, emit no migration.
134
+ if (config.baseline) {
135
+ return await runBaseline(config, metaRoot);
136
+ }
137
+
138
+ // Default = offline snapshot generation. The live-introspection path runs only
139
+ // when explicitly requested via --from-db, when --apply needs a connection, or
140
+ // for --rollback (which runs hand-authored down.sql against the live DB).
141
+ if (!config.fromDb && !config.apply && config.rollback === undefined) {
142
+ return await runOfflineGenerate(config, metaRoot);
143
+ }
144
+
158
145
  if (config.databaseUrl === undefined) {
159
146
  log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
160
147
  return 2;
161
148
  }
162
149
 
150
+ // --rollback short-circuits the diff/emit pipeline: it runs the down.sql of
151
+ // every applied migration NEWER than <target> (target retained), in reverse
152
+ // order, ledger-tracked + advisory-locked. postgres/sqlite only.
153
+ if (config.rollback !== undefined) {
154
+ return await runRollback(config, metaRoot);
155
+ }
156
+
163
157
  // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
164
158
  // providers. migrate's postgres/sqlite path also reads the config later
165
159
  // for columnNamingStrategy; we load it once here and reuse below.
@@ -196,6 +190,7 @@ export async function migrateCommand(
196
190
 
197
191
  let exitCode = 0;
198
192
  let writtenPaths: string[] = [];
193
+ let appliedNames: string[] = [];
199
194
  let blocked: BlockedEntry[] = [];
200
195
  let ambiguous: AmbiguousEntry[] = [];
201
196
  let changeCounts: Record<string, number> = {};
@@ -219,6 +214,7 @@ export async function migrateCommand(
219
214
  diffResult = await diff({
220
215
  expected,
221
216
  actual,
217
+ dialect: kysely.dialect,
222
218
  allow: tokensToAllowOptions(config.allow),
223
219
  onAmbiguous: async (a) => {
224
220
  collectedAmbiguous.push(a);
@@ -367,9 +363,34 @@ export async function migrateCommand(
367
363
  { dir: outDir, slug: config.slug },
368
364
  );
369
365
  writtenPaths = [res.upPath, res.downPath];
366
+ if (config.fromDb) {
367
+ log.info(`migrate: --from-db did not advance the committed snapshot; run 'meta migrate baseline --from-db' to re-sync`);
368
+ }
370
369
  }
371
370
  }
372
371
  }
372
+
373
+ // --apply: run pending committed migration files against the DB, tracked by
374
+ // the migration-history ledger, transactionally. Idempotency comes from the
375
+ // ledger (skip already-applied), NOT from re-diffing — so this also applies
376
+ // any previously-written-but-unapplied files in this run. Skipped on dry-run
377
+ // and when a prior step set a non-zero exit (e.g. blocked changes).
378
+ if (config.apply && exitCode === 0 && !config.dryRun) {
379
+ const outDir = resolvePath(metaRoot, config.outDir);
380
+ try {
381
+ // applyPending calls ensureLedger internally (idempotent), so no need
382
+ // to ensure it here. Pass the dialect so postgres gets schema-qualified
383
+ // ledger DDL + the session advisory lock (sqlite is a no-op there).
384
+ const result = await applyPending(kysely.db, outDir, {
385
+ dryRun: false,
386
+ dialect: kysely.dialect as "sqlite" | "postgres",
387
+ });
388
+ appliedNames = [...result.applied];
389
+ } catch (err) {
390
+ log.error(`migrate: apply failed: ${(err as Error).message}`);
391
+ exitCode = 1;
392
+ }
393
+ }
373
394
  } finally {
374
395
  try {
375
396
  await kysely.close();
@@ -389,9 +410,223 @@ export async function migrateCommand(
389
410
  }, { isTTY: !!process.stdout.isTTY });
390
411
 
391
412
  log.info(output);
413
+ if (config.apply && exitCode === 0) {
414
+ if (appliedNames.length > 0) {
415
+ log.info(`migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
416
+ } else {
417
+ log.info(`migrate: no pending migrations to apply`);
418
+ }
419
+ }
392
420
  return exitCode;
393
421
  }
394
422
 
423
+ /**
424
+ * `meta migrate baseline [--from-db]` — seed the committed reference snapshot.
425
+ * `--from-metadata` (default) derives it from metadata; `--from-db` introspects
426
+ * an existing database once. Emits no migration.
427
+ */
428
+ export async function runBaseline(
429
+ config: ResolvedMigrateConfig,
430
+ metaRoot: string,
431
+ ): Promise<number> {
432
+ if (config.dialect === undefined) {
433
+ log.error(`migrate baseline: --dialect required (or set migrate.dialect in .metaobjects/config.json)`);
434
+ return 2;
435
+ }
436
+ const outDir = resolvePath(metaRoot, config.outDir);
437
+ const path = snapshotPath(outDir, config.dialect);
438
+
439
+ let snapshot;
440
+ if (config.fromDb) {
441
+ if (config.databaseUrl === undefined) {
442
+ log.error(`migrate baseline --from-db: --db <url> required`);
443
+ return 2;
444
+ }
445
+ let kysely;
446
+ try {
447
+ kysely = await buildKyselyFromUrl(config.databaseUrl, config.dialect);
448
+ } catch (err) {
449
+ log.error(`migrate baseline: ${(err as Error).message}`);
450
+ return 2;
451
+ }
452
+ try {
453
+ snapshot = await introspect(kysely.db, kysely.dialect);
454
+ } finally {
455
+ await kysely.close();
456
+ }
457
+ } else {
458
+ let metadata;
459
+ try {
460
+ metadata = await loadMemory(metaRoot);
461
+ } catch (err) {
462
+ log.error(`migrate baseline: failed to load metadata: ${(err as Error).message}`);
463
+ return 2;
464
+ }
465
+ snapshot = baselineFromMetadata(metadata, config.dialect);
466
+ }
467
+
468
+ if (config.dryRun) {
469
+ log.info(`migrate baseline (dry-run): would write schema snapshot ${path}`);
470
+ return 0;
471
+ }
472
+
473
+ await writeSnapshot(path, snapshot);
474
+ log.info(`migrate: wrote schema snapshot ${path}`);
475
+ return 0;
476
+ }
477
+
478
+ /**
479
+ * Default `meta migrate` generate path — fully offline. Diffs metadata against
480
+ * the committed snapshot (no DB), writes up/down.sql, and advances the snapshot.
481
+ * The live-introspection path is used only with --from-db or --apply.
482
+ *
483
+ * Scope: table/column/index/FK changes. Projection-view migrations stay on the
484
+ * introspection path (offline-view parity is a follow-up).
485
+ */
486
+ export async function runOfflineGenerate(
487
+ config: ResolvedMigrateConfig,
488
+ metaRoot: string,
489
+ ): Promise<number> {
490
+ if (config.dialect === undefined) {
491
+ log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
492
+ return 2;
493
+ }
494
+ let metadata;
495
+ try {
496
+ metadata = await loadMemory(metaRoot);
497
+ } catch (err) {
498
+ log.error(`migrate: failed to load metadata: ${(err as Error).message}`);
499
+ return 2;
500
+ }
501
+
502
+ const outDir = resolvePath(metaRoot, config.outDir);
503
+ const path = snapshotPath(outDir, config.dialect);
504
+ let snapshot;
505
+ try {
506
+ snapshot = await readSnapshot(path);
507
+ } catch (err) {
508
+ log.error(`migrate: cannot read schema snapshot at ${path}: ${(err as Error).message}`);
509
+ return 2;
510
+ }
511
+ if (snapshot === null) {
512
+ log.error(`migrate: no schema snapshot at ${path}; run 'meta migrate baseline' first`);
513
+ return 2;
514
+ }
515
+
516
+ const collectedAmbiguous: AmbiguousChange[] = [];
517
+ const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
518
+
519
+ let plan;
520
+ try {
521
+ plan = await planOffline({
522
+ metadata,
523
+ dialect: config.dialect,
524
+ snapshot,
525
+ allow: tokensToAllowOptions(config.allow),
526
+ onAmbiguous: async (a) => {
527
+ collectedAmbiguous.push(a);
528
+ return onAmbiguousResolution;
529
+ },
530
+ });
531
+ } catch (err) {
532
+ if ((err as Error).message.includes("aborted by onAmbiguous")) {
533
+ log.error(`migrate: ambiguous rename/drop detected; re-run with --on-ambiguous rename|drop-add`);
534
+ return 1;
535
+ }
536
+ throw err;
537
+ }
538
+
539
+ const { diff: diffResult, nextSnapshot } = plan;
540
+
541
+ if (diffResult.blocked.length > 0) {
542
+ log.error(`migrate: ${diffResult.blocked.length} destructive change(s) blocked; re-run with --allow <tokens>`);
543
+ return 1;
544
+ }
545
+ if (diffResult.changes.length === 0) {
546
+ log.info(`migrate: no changes`);
547
+ return 0;
548
+ }
549
+ if (config.slug === undefined) {
550
+ log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
551
+ return 2;
552
+ }
553
+
554
+ const emitResult = emit(diffResult.changes, {
555
+ dialect: config.dialect,
556
+ expectedSchema: nextSnapshot,
557
+ ...(snapshot.meta ? { actualMeta: snapshot.meta } : {}),
558
+ });
559
+
560
+ if (config.dryRun) {
561
+ log.info(`-- UP --\n${emitResult.up}\n\n-- DOWN --\n${emitResult.down}`);
562
+ return 0;
563
+ }
564
+
565
+ await mkdir(outDir, { recursive: true });
566
+ const res = await writeMigration(
567
+ { up: emitResult.up, down: emitResult.down },
568
+ { dir: outDir, slug: config.slug },
569
+ );
570
+ await writeSnapshot(path, nextSnapshot);
571
+ log.info(`migrate: wrote ${res.upPath}`);
572
+ return 0;
573
+ }
574
+
575
+ /**
576
+ * `meta migrate --rollback <target>` — run the down.sql of every applied
577
+ * migration newer than <target> (target retained) in reverse order, against the
578
+ * live DB, ledger-tracked + advisory-locked. postgres/sqlite only.
579
+ *
580
+ * Pass `--rollback ""` (empty target) is treated as null → roll back everything.
581
+ */
582
+ async function runRollback(
583
+ config: ResolvedMigrateConfig,
584
+ metaRoot: string,
585
+ ): Promise<number> {
586
+ // databaseUrl is guaranteed defined by the caller's guard above.
587
+ const databaseUrl = config.databaseUrl as string;
588
+
589
+ // Rollback is destructive and runs hand-authored down.sql; there is no
590
+ // meaningful dry-run plan (no diff to preview), so reject the combination
591
+ // rather than silently executing.
592
+ if (config.dryRun) {
593
+ log.error(`migrate: --dry-run is not supported with --rollback`);
594
+ return 2;
595
+ }
596
+
597
+ let kysely;
598
+ try {
599
+ kysely = await buildKyselyFromUrl(databaseUrl, config.dialect);
600
+ } catch (err) {
601
+ log.error(`migrate: ${(err as Error).message}`);
602
+ return 2;
603
+ }
604
+ // kysely.dialect is "sqlite" | "postgres" here — d1 is rejected upstream.
605
+ const dialect = kysely.dialect as "sqlite" | "postgres";
606
+ const outDir = resolvePath(metaRoot, config.outDir);
607
+ // An empty --rollback string means "roll back everything".
608
+ const target = config.rollback === "" ? null : (config.rollback ?? null);
609
+
610
+ try {
611
+ const result = await rollbackTo(kysely.db, outDir, target, { dialect });
612
+ if (result.rolledBack.length > 0) {
613
+ log.info(`migrate: rolled back ${result.rolledBack.length} migration(s): ${result.rolledBack.join(", ")}`);
614
+ } else {
615
+ log.info(`migrate: nothing to roll back${target ? ` newer than '${target}'` : ""}`);
616
+ }
617
+ return 0;
618
+ } catch (err) {
619
+ log.error(`migrate: rollback failed: ${(err as Error).message}`);
620
+ return 1;
621
+ } finally {
622
+ try {
623
+ await kysely.close();
624
+ } catch (err) {
625
+ log.warn(`migrate: failed to close DB cleanly: ${(err as Error).message}`);
626
+ }
627
+ }
628
+ }
629
+
395
630
  async function runD1Migrate(
396
631
  config: ResolvedMigrateConfig,
397
632
  metaRoot: string,
@@ -13,6 +13,9 @@ import { log } from "../lib/log.js";
13
13
  import { FileProvider } from "../lib/file-provider.js";
14
14
  import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
15
15
  import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
16
+ import { buildKyselyFromUrl, type Dialect } from "../lib/kysely.js";
17
+ import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
18
+ import { computeDrift, type Change } from "@metaobjectsdev/migrate-ts";
16
19
  import { loadMemory } from "@metaobjectsdev/sdk";
17
20
  import {
18
21
  TYPE_TEMPLATE,
@@ -35,7 +38,7 @@ function attrAsStringArray(attr: unknown): string[] {
35
38
  }
36
39
 
37
40
  export async function verifyCommand(args: string[], cwd: string): Promise<number> {
38
- let flags;
41
+ let flags: ReturnType<typeof parseVerifyArgs>;
39
42
  try {
40
43
  flags = parseVerifyArgs(args);
41
44
  } catch (err) {
@@ -56,7 +59,7 @@ export async function verifyCommand(args: string[], cwd: string): Promise<number
56
59
  configProviders = undefined;
57
60
  }
58
61
 
59
- let root;
62
+ let root: Awaited<ReturnType<typeof loadMemory>>;
60
63
  try {
61
64
  root = await loadMemory(cwd, {
62
65
  ...(configProviders !== undefined ? { providers: configProviders } : {}),
@@ -74,79 +77,187 @@ export async function verifyCommand(args: string[], cwd: string): Promise<number
74
77
  const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
75
78
  const provider = new FileProvider(promptsDir);
76
79
 
77
- const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
78
- if (templates.length === 0) {
79
- log.info("meta verify no template.* nodes found; nothing to check.");
80
- return 0;
81
- }
80
+ // Exit-code composition: the overall result is max(templateExit, schemaExit)
81
+ // so either kind of drift fails CI. The schema path only runs when --db is
82
+ // present (and not --skip-schema); with no --db it is skipped entirely and
83
+ // the exit reflects the template path alone (unchanged behavior).
84
+ const templateExit = runTemplateVerify();
85
+ const schemaExit = await runSchemaVerify();
86
+ return Math.max(templateExit, schemaExit);
82
87
 
83
- let errorCount = 0;
84
- let warnCount = 0;
85
- let checked = 0;
86
-
87
- for (const tmpl of templates) {
88
- const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
89
- const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
90
- // Absent/typeless required attrs are a loader-schema concern, not verify's.
91
- if (typeof textRef !== "string" || typeof payloadRef !== "string") continue;
92
-
93
- // Both subtypes verify that @payloadRef resolves to a loaded object.value.
94
- // The render-engine `verify()` would also throw on missing refs, but the
95
- // output branch doesn't call it — explicit check keeps the error symmetric.
96
- const fieldTree = derivePayloadFieldTree(root, payloadRef);
97
- if (fieldTree.length === 0) {
98
- log.error(
99
- `[${tmpl.name}] (${tmpl.subType}) ${ERR_PARTIAL_UNRESOLVED}: ` +
100
- `@payloadRef "${payloadRef}" did not resolve to a loaded object.value`,
101
- );
102
- errorCount++;
103
- continue;
88
+ // -- template (prompt / output) drift --------------------------------------
89
+ function runTemplateVerify(): number {
90
+ const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
91
+ if (templates.length === 0) {
92
+ log.info("meta verify no template.* nodes found; nothing to check.");
93
+ return 0;
104
94
  }
105
95
 
106
- if (tmpl.subType === TEMPLATE_SUBTYPE_PROMPT) {
107
- // Render-engine drift check: template variables ↔ payload field names.
108
- const text = provider.resolve(textRef);
109
- if (text === undefined) {
96
+ let errorCount = 0;
97
+ let warnCount = 0;
98
+ let checked = 0;
99
+
100
+ for (const tmpl of templates) {
101
+ const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
102
+ const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
103
+ // Absent/typeless required attrs are a loader-schema concern, not verify's.
104
+ if (typeof textRef !== "string" || typeof payloadRef !== "string") continue;
105
+
106
+ // Both subtypes verify that @payloadRef resolves to a loaded object.value.
107
+ // The render-engine `verify()` would also throw on missing refs, but the
108
+ // output branch doesn't call it — explicit check keeps the error symmetric.
109
+ const fieldTree = derivePayloadFieldTree(root, payloadRef);
110
+ if (fieldTree.length === 0) {
110
111
  log.error(
111
- `[${tmpl.name}] (prompt) ${ERR_PARTIAL_UNRESOLVED}: @textRef "${textRef}" did not resolve under ${promptsDir}`,
112
+ `[${tmpl.name}] (${tmpl.subType}) ${ERR_PARTIAL_UNRESOLVED}: ` +
113
+ `@payloadRef "${payloadRef}" did not resolve to a loaded object.value`,
112
114
  );
113
115
  errorCount++;
114
116
  continue;
115
117
  }
116
118
 
117
- const requiredSlots = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_SLOTS));
118
- const requiredTags = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_TAGS));
119
-
120
- const drift = verify(text, fieldTree, { provider, requiredSlots, requiredTags });
121
- checked++;
122
- for (const e of drift) {
123
- if (e.code === ERR_REQUIRED_SLOT_UNUSED) {
124
- log.warn(`[${tmpl.name}] (prompt) ${e.code}: ${e.path}`);
125
- warnCount++;
126
- } else {
127
- log.error(`[${tmpl.name}] (prompt) ${e.code}: ${e.path}`);
119
+ if (tmpl.subType === TEMPLATE_SUBTYPE_PROMPT) {
120
+ // Render-engine drift check: template variables ↔ payload field names.
121
+ const text = provider.resolve(textRef);
122
+ if (text === undefined) {
123
+ log.error(
124
+ `[${tmpl.name}] (prompt) ${ERR_PARTIAL_UNRESOLVED}: @textRef "${textRef}" did not resolve under ${promptsDir}`,
125
+ );
128
126
  errorCount++;
127
+ continue;
128
+ }
129
+
130
+ const requiredSlots = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_SLOTS));
131
+ const requiredTags = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_TAGS));
132
+
133
+ const drift = verify(text, fieldTree, { provider, requiredSlots, requiredTags });
134
+ checked++;
135
+ for (const e of drift) {
136
+ if (e.code === ERR_REQUIRED_SLOT_UNUSED) {
137
+ log.warn(`[${tmpl.name}] (prompt) ${e.code}: ${e.path}`);
138
+ warnCount++;
139
+ } else {
140
+ log.error(`[${tmpl.name}] (prompt) ${e.code}: ${e.path}`);
141
+ errorCount++;
142
+ }
129
143
  }
144
+ } else if (tmpl.subType === TEMPLATE_SUBTYPE_OUTPUT) {
145
+ // Output drift check: re-derive the payload field tree (already done above);
146
+ // if it resolved, the parser codegen can produce a schema. Field-type
147
+ // unsupported-by-Zod issues are caught by the codegen itself if/when
148
+ // `meta gen` runs; verify confines itself to ref-resolution checks.
149
+ checked++;
150
+ } else {
151
+ // Unknown subtype — ignore (loader-schema concern).
130
152
  }
131
- } else if (tmpl.subType === TEMPLATE_SUBTYPE_OUTPUT) {
132
- // Output drift check: re-derive the payload field tree (already done above);
133
- // if it resolved, the parser codegen can produce a schema. Field-type
134
- // unsupported-by-Zod issues are caught by the codegen itself if/when
135
- // `meta gen` runs; verify confines itself to ref-resolution checks.
136
- checked++;
137
- } else {
138
- // Unknown subtype — ignore (loader-schema concern).
139
153
  }
140
- }
141
154
 
142
- if (errorCount > 0) {
143
- log.error(
144
- `meta verify — ${errorCount} drift error(s) across ${templates.length} template(s).`,
155
+ if (errorCount > 0) {
156
+ log.error(
157
+ `meta verify — ${errorCount} drift error(s) across ${templates.length} template(s).`,
158
+ );
159
+ return 1;
160
+ }
161
+ log.info(
162
+ `meta verify — ${checked} template(s) clean${warnCount > 0 ? ` (${warnCount} warning(s))` : ""}.`,
145
163
  );
146
- return 1;
164
+ return 0;
147
165
  }
148
- log.info(
149
- `meta verify ${checked} template(s) clean${warnCount > 0 ? ` (${warnCount} warning(s))` : ""}.`,
150
- );
151
- return 0;
166
+
167
+ // -- schema drift (live DB) ------------------------------------------------
168
+ // Gated on --db. With no --db (or --skip-schema), this is a no-op returning 0
169
+ // — the DB-free default behavior is unchanged.
170
+ async function runSchemaVerify(): Promise<number> {
171
+ if (flags.db === undefined || flags.skipSchema) return 0;
172
+
173
+ // d1 has no Kysely-driver introspection path, so the schema-drift gate
174
+ // can't support it. `buildKyselyFromUrl` already throws for the d1 dialect
175
+ // ("does not use a URL connection") — the catch below surfaces that as a
176
+ // clear exit 1, so no separate d1 guard is needed here.
177
+ let kysely;
178
+ try {
179
+ kysely = await buildKyselyFromUrl(flags.db, flags.dialect as Dialect | undefined);
180
+ } catch (err) {
181
+ log.error(`verify: ${(err as Error).message}`);
182
+ return 1;
183
+ }
184
+
185
+ try {
186
+ const allow = tokensToAllowOptions(flags.allow);
187
+ let driftResult;
188
+ try {
189
+ driftResult = await computeDrift(kysely.db, kysely.dialect, root, { allow });
190
+ } catch (err) {
191
+ log.error(`verify: failed to introspect ${kysely.displayUrl}: ${(err as Error).message}`);
192
+ return 1;
193
+ }
194
+
195
+ // TODO(Unit 3 — migration-history ledger): when the ledger table exists,
196
+ // a migration that is recorded-as-pending-but-unapplied must also count as
197
+ // drift here. Until Unit 3 ships the ledger, this MUST no-op — do not query
198
+ // a table that doesn't exist. `reconcileLedger` returns no extra drift today.
199
+ const ledgerDrift = await reconcileLedger();
200
+
201
+ const changes = driftResult.changes;
202
+ if (changes.length === 0 && ledgerDrift.length === 0) {
203
+ log.info(`meta verify — schema in sync with ${kysely.displayUrl}.`);
204
+ return 0;
205
+ }
206
+
207
+ log.error(`meta verify — schema drift vs ${kysely.displayUrl} (${changes.length} change(s)):`);
208
+ for (const line of summarizeDrift(changes)) log.error(` ${line}`);
209
+ for (const line of ledgerDrift) log.error(` ${line}`);
210
+ return 1;
211
+ } finally {
212
+ try {
213
+ await kysely.close();
214
+ } catch (err) {
215
+ log.warn(`verify: failed to close DB cleanly: ${(err as Error).message}`);
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Migration-history ledger reconciliation hook (Unit 3 — not yet built).
223
+ *
224
+ * When the ledger lands, this will read it and report any recorded-but-unapplied
225
+ * migration as drift. Until then it is a deliberate no-op: returning an empty
226
+ * array means "no ledger-derived drift", and crucially it does NOT touch any
227
+ * ledger table (which doesn't exist yet) so a fresh DB never trips on it.
228
+ */
229
+ async function reconcileLedger(): Promise<string[]> {
230
+ return [];
231
+ }
232
+
233
+ // Per-kind glyph (+ add / - drop / ~ change) and noun for the drift summary.
234
+ // The detail string itself comes from the shared `describeChange`.
235
+ const DRIFT_PRESENTATION: Record<Change["kind"], { glyph: string; noun: string }> = {
236
+ "create-table": { glyph: "+", noun: "table" },
237
+ "drop-table": { glyph: "-", noun: "table" },
238
+ "rename-table": { glyph: "~", noun: "table" },
239
+ "add-column": { glyph: "+", noun: "column" },
240
+ "drop-column": { glyph: "-", noun: "column" },
241
+ "rename-column": { glyph: "~", noun: "column" },
242
+ "change-column-type": { glyph: "~", noun: "column" },
243
+ "change-column-nullable": { glyph: "~", noun: "column" },
244
+ "change-column-default": { glyph: "~", noun: "column" },
245
+ "add-index": { glyph: "+", noun: "index" },
246
+ "drop-index": { glyph: "-", noun: "index" },
247
+ "add-fk": { glyph: "+", noun: "fk" },
248
+ "drop-fk": { glyph: "-", noun: "fk" },
249
+ "add-check": { glyph: "+", noun: "check" },
250
+ "drop-check": { glyph: "-", noun: "check" },
251
+ "create-view": { glyph: "+", noun: "view" },
252
+ "replace-view": { glyph: "~", noun: "view" },
253
+ "drop-view": { glyph: "-", noun: "view" },
254
+ };
255
+
256
+ /** Human-readable one-line-per-change drift summary (table/column/index/fk/view). */
257
+ function summarizeDrift(changes: Change[]): string[] {
258
+ return changes.map((c) => {
259
+ const p = DRIFT_PRESENTATION[c.kind];
260
+ if (p === undefined) return JSON.stringify(c);
261
+ return `${p.glyph} ${p.noun} ${describeChange(c)}`;
262
+ });
152
263
  }