@metaobjectsdev/cli 0.5.1 → 0.6.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.
- package/README.md +20 -1
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +34 -5
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/migrate.d.ts +4 -1
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +233 -5
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/prompt-snapshot.d.ts +2 -0
- package/dist/src/commands/prompt-snapshot.d.ts.map +1 -0
- package/dist/src/commands/prompt-snapshot.js +125 -0
- package/dist/src/commands/prompt-snapshot.js.map +1 -0
- package/dist/src/commands/verify.d.ts +2 -0
- package/dist/src/commands/verify.d.ts.map +1 -0
- package/dist/src/commands/verify.js +93 -0
- package/dist/src/commands/verify.js.map +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +22 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/args.d.ts +18 -1
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +39 -1
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/config.d.ts +11 -1
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +10 -1
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/file-provider.d.ts +7 -0
- package/dist/src/lib/file-provider.d.ts.map +1 -0
- package/dist/src/lib/file-provider.js +33 -0
- package/dist/src/lib/file-provider.js.map +1 -0
- package/dist/src/lib/kysely.d.ts +1 -1
- package/dist/src/lib/kysely.d.ts.map +1 -1
- package/dist/src/lib/kysely.js +3 -0
- package/dist/src/lib/kysely.js.map +1 -1
- package/dist/src/lib/output.d.ts +3 -2
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/payload-field-tree.d.ts +9 -0
- package/dist/src/lib/payload-field-tree.d.ts.map +1 -0
- package/dist/src/lib/payload-field-tree.js +36 -0
- package/dist/src/lib/payload-field-tree.js.map +1 -0
- package/dist/src/lib/projection-migrations.d.ts +2 -1
- package/dist/src/lib/projection-migrations.d.ts.map +1 -1
- package/dist/src/lib/projection-migrations.js +4 -2
- package/dist/src/lib/projection-migrations.js.map +1 -1
- package/dist/src/lib/snapshot.d.ts +11 -0
- package/dist/src/lib/snapshot.d.ts.map +1 -0
- package/dist/src/lib/snapshot.js +36 -0
- package/dist/src/lib/snapshot.js.map +1 -0
- package/dist/src/lib/wrangler.d.ts +18 -0
- package/dist/src/lib/wrangler.d.ts.map +1 -0
- package/dist/src/lib/wrangler.js +30 -0
- package/dist/src/lib/wrangler.js.map +1 -0
- package/package.json +8 -7
- package/src/commands/init.ts +35 -5
- package/src/commands/migrate.ts +287 -5
- package/src/commands/prompt-snapshot.ts +142 -0
- package/src/commands/verify.ts +111 -0
- package/src/index.ts +22 -1
- package/src/lib/args.ts +67 -1
- package/src/lib/config.ts +23 -3
- package/src/lib/file-provider.ts +33 -0
- package/src/lib/kysely.ts +7 -1
- package/src/lib/output.ts +4 -2
- package/src/lib/payload-field-tree.ts +47 -0
- package/src/lib/projection-migrations.ts +6 -3
- package/src/lib/snapshot.ts +50 -0
- package/src/lib/wrangler.ts +45 -0
package/src/commands/migrate.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { resolve as resolvePath } from "node:path";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
3
4
|
import { parseMigrateArgs } from "../lib/args.js";
|
|
4
|
-
import { resolveMigrateConfig } from "../lib/config.js";
|
|
5
|
+
import { resolveMigrateConfig, MIGRATE_DEFAULT_OUT_DIR } from "../lib/config.js";
|
|
6
|
+
import type { ResolvedMigrateConfig } from "../lib/config.js";
|
|
5
7
|
import { formatMigrateResult, type BlockedEntry, type AmbiguousEntry } from "../lib/output.js";
|
|
6
8
|
import { buildKyselyFromUrl } from "../lib/kysely.js";
|
|
7
9
|
import { log } from "../lib/log.js";
|
|
@@ -14,12 +16,26 @@ import {
|
|
|
14
16
|
emit,
|
|
15
17
|
writeMigration,
|
|
16
18
|
BlockedChangesError,
|
|
19
|
+
renderD1,
|
|
20
|
+
applyD1SafetyPass,
|
|
21
|
+
writeMigrationD1,
|
|
22
|
+
introspectD1,
|
|
23
|
+
findWranglerConfig,
|
|
24
|
+
parseWranglerConfig,
|
|
25
|
+
resolveD1Binding,
|
|
17
26
|
type AllowOptions,
|
|
18
27
|
type AmbiguousChange,
|
|
19
28
|
type AmbiguousResolution,
|
|
20
29
|
type Change,
|
|
30
|
+
type D1Binding,
|
|
21
31
|
type EmitResult,
|
|
32
|
+
type D1Runner,
|
|
22
33
|
} from "@metaobjectsdev/migrate-ts";
|
|
34
|
+
import {
|
|
35
|
+
buildWranglerExecuteArgs,
|
|
36
|
+
defaultWranglerRunner,
|
|
37
|
+
type WranglerRunner,
|
|
38
|
+
} from "../lib/wrangler.js";
|
|
23
39
|
import {
|
|
24
40
|
computeProjectionMigrations,
|
|
25
41
|
computeProjectionViewDependencies,
|
|
@@ -114,7 +130,12 @@ function ambiguousToEntries(amb: AmbiguousChange[]): AmbiguousEntry[] {
|
|
|
114
130
|
});
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
export async function migrateCommand(
|
|
133
|
+
export async function migrateCommand(
|
|
134
|
+
args: string[],
|
|
135
|
+
cwd: string,
|
|
136
|
+
/** Injectable wrangler runner — tests pass a mock; production uses the default. */
|
|
137
|
+
wranglerRunner?: WranglerRunner,
|
|
138
|
+
): Promise<number> {
|
|
118
139
|
let flags;
|
|
119
140
|
try {
|
|
120
141
|
flags = parseMigrateArgs(args);
|
|
@@ -126,6 +147,14 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
|
|
|
126
147
|
const metaRoot = cwd;
|
|
127
148
|
const config = await resolveMigrateConfig(flags, metaRoot);
|
|
128
149
|
|
|
150
|
+
if (config.dialect === "d1") {
|
|
151
|
+
if (config.databaseUrl !== undefined) {
|
|
152
|
+
log.error(`migrate: --db / DATABASE_URL is not used for dialect 'd1' — wrangler.toml owns connection`);
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner);
|
|
156
|
+
}
|
|
157
|
+
|
|
129
158
|
if (config.databaseUrl === undefined) {
|
|
130
159
|
log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
|
|
131
160
|
return 2;
|
|
@@ -221,7 +250,8 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
|
|
|
221
250
|
|
|
222
251
|
// Pull existing view CREATE SQL from the DB so unchanged views can be
|
|
223
252
|
// skipped (no DROP+CREATE noise when the body hasn't changed).
|
|
224
|
-
|
|
253
|
+
// kysely.dialect is always "sqlite" | "postgres" here — d1 exits above.
|
|
254
|
+
const existingViewSql = await readExistingViewSql(kysely.db, kysely.dialect as "sqlite" | "postgres");
|
|
225
255
|
|
|
226
256
|
// Compute view migrations (projections) independently of table changes.
|
|
227
257
|
const viewResult = computeProjectionMigrations({
|
|
@@ -317,7 +347,7 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
|
|
|
317
347
|
if (config.dryRun) {
|
|
318
348
|
log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
|
|
319
349
|
} else {
|
|
320
|
-
const outDir =
|
|
350
|
+
const outDir = resolvePath(metaRoot, config.outDir);
|
|
321
351
|
await mkdir(outDir, { recursive: true });
|
|
322
352
|
const res = await writeMigration(
|
|
323
353
|
{ up: combinedUp, down: combinedDown },
|
|
@@ -349,6 +379,258 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
|
|
|
349
379
|
return exitCode;
|
|
350
380
|
}
|
|
351
381
|
|
|
382
|
+
async function runD1Migrate(
|
|
383
|
+
config: ResolvedMigrateConfig,
|
|
384
|
+
metaRoot: string,
|
|
385
|
+
runner: WranglerRunner,
|
|
386
|
+
): Promise<number> {
|
|
387
|
+
// 1. Resolve wrangler.toml + binding.
|
|
388
|
+
const wranglerConfigPath = config.d1.wranglerConfigPath
|
|
389
|
+
? resolvePath(metaRoot, config.d1.wranglerConfigPath)
|
|
390
|
+
: findWranglerConfig(metaRoot);
|
|
391
|
+
|
|
392
|
+
if (wranglerConfigPath === undefined && config.d1.binding === undefined) {
|
|
393
|
+
log.error(`migrate: no wrangler.toml found in ${metaRoot} or parents; pass --d1 <binding> to bypass`);
|
|
394
|
+
return 2;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let binding: D1Binding;
|
|
398
|
+
if (wranglerConfigPath !== undefined) {
|
|
399
|
+
const parsed = parseWranglerConfig(wranglerConfigPath);
|
|
400
|
+
try {
|
|
401
|
+
binding = resolveD1Binding(parsed.d1Bindings, config.d1.binding);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
log.error(`migrate: ${(err as Error).message}`);
|
|
404
|
+
return 2;
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
// No wrangler config but explicit binding — let wrangler discover the DB itself.
|
|
408
|
+
binding = { binding: config.d1.binding!, database_name: "", database_id: "", migrations_dir: undefined };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 2. Build a D1Runner closure over the wrangler runner.
|
|
412
|
+
const d1Runner: D1Runner = async (sql) => {
|
|
413
|
+
const args = buildWranglerExecuteArgs({
|
|
414
|
+
binding: binding.binding,
|
|
415
|
+
remote: config.d1.remote,
|
|
416
|
+
command: sql,
|
|
417
|
+
configPath: wranglerConfigPath,
|
|
418
|
+
});
|
|
419
|
+
const { stdout } = await runner(args, metaRoot);
|
|
420
|
+
return stdout;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// 3. Load metadata.
|
|
424
|
+
let metadata;
|
|
425
|
+
try {
|
|
426
|
+
metadata = await loadMemory(metaRoot);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
const msg = (err as Error).message;
|
|
429
|
+
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
|
|
430
|
+
log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
|
|
431
|
+
} else {
|
|
432
|
+
log.error(`migrate: failed to load metadata: ${msg}`);
|
|
433
|
+
}
|
|
434
|
+
return 2;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 4. Build expected schema + introspect actual.
|
|
438
|
+
const expected = buildExpectedSchema(metadata, { dialect: "d1" });
|
|
439
|
+
let actual;
|
|
440
|
+
try {
|
|
441
|
+
actual = await introspectD1({
|
|
442
|
+
runner: d1Runner,
|
|
443
|
+
binding: binding.binding,
|
|
444
|
+
remote: config.d1.remote,
|
|
445
|
+
configPath: wranglerConfigPath,
|
|
446
|
+
});
|
|
447
|
+
} catch (err) {
|
|
448
|
+
log.error(`migrate: failed to introspect D1: ${(err as Error).message}`);
|
|
449
|
+
return 2;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 5. Diff.
|
|
453
|
+
const collectedAmbiguous: AmbiguousChange[] = [];
|
|
454
|
+
const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
|
|
455
|
+
let diffResult;
|
|
456
|
+
try {
|
|
457
|
+
diffResult = await diff({
|
|
458
|
+
expected,
|
|
459
|
+
actual,
|
|
460
|
+
allow: tokensToAllowOptions(config.allow),
|
|
461
|
+
onAmbiguous: async (a) => {
|
|
462
|
+
collectedAmbiguous.push(a);
|
|
463
|
+
return onAmbiguousResolution;
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if ((err as Error).message.includes("aborted by onAmbiguous")) {
|
|
468
|
+
const entries = ambiguousToEntries(collectedAmbiguous);
|
|
469
|
+
for (const e of entries) {
|
|
470
|
+
log.error(` ambiguous ${e.kind}: ${e.description}${e.hint ? ` [${e.hint}]` : ""}`);
|
|
471
|
+
}
|
|
472
|
+
log.error(`migrate: aborted on ambiguous change (re-run with --on-ambiguous rename|drop-add)`);
|
|
473
|
+
return 1;
|
|
474
|
+
}
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const changeCounts = summarizeChanges(diffResult.changes);
|
|
479
|
+
|
|
480
|
+
// Load metaobjects config to pick up columnNamingStrategy for view DDL emit.
|
|
481
|
+
// Defensive try/catch: if metaobjects.config.ts is absent, fall back to snake_case.
|
|
482
|
+
let columnNamingStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
|
|
483
|
+
try {
|
|
484
|
+
const forgeConfig = await loadMetaobjectsConfig(metaRoot);
|
|
485
|
+
if (forgeConfig.columnNamingStrategy) {
|
|
486
|
+
columnNamingStrategy = forgeConfig.columnNamingStrategy;
|
|
487
|
+
}
|
|
488
|
+
} catch {
|
|
489
|
+
// metaobjects.config.ts absent or invalid — use default snake_case
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 5b. Compute view migrations (projections). D1 reuses the sqlite emitter.
|
|
493
|
+
// We intentionally skip readExistingViewSql for D1 in v1 — D1 has no
|
|
494
|
+
// introspection path for view bodies, so we accept over-eager DROP+CREATE.
|
|
495
|
+
const viewResult = computeProjectionMigrations({
|
|
496
|
+
metadata,
|
|
497
|
+
dialect: "d1",
|
|
498
|
+
allowBreaking: false,
|
|
499
|
+
columnNamingStrategy,
|
|
500
|
+
});
|
|
501
|
+
if (viewResult.errors.length > 0) {
|
|
502
|
+
for (const err of viewResult.errors) log.error(err);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const hasTableChanges = diffResult.changes.length > 0;
|
|
507
|
+
const hasViewChanges = viewResult.migrations.length > 0;
|
|
508
|
+
|
|
509
|
+
if (!hasTableChanges && !hasViewChanges) {
|
|
510
|
+
log.info(`migrate: no schema changes for d1 binding '${binding.binding}'`);
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (config.slug === undefined) {
|
|
515
|
+
log.error(`migrate: --slug <name> required when there are changes`);
|
|
516
|
+
return 2;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 6. Emit (with D1 safety pass) + write Wrangler migration files.
|
|
520
|
+
let emitResult;
|
|
521
|
+
try {
|
|
522
|
+
emitResult = renderD1(diffResult.changes, expected, actual.meta);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (err instanceof BlockedChangesError) {
|
|
525
|
+
const entries = blockedToEntries(err);
|
|
526
|
+
for (const e of entries) {
|
|
527
|
+
log.error(`migrate: blocked '${e.kind}' on ${e.description} (allow with --allow ${e.allowFlag})`);
|
|
528
|
+
}
|
|
529
|
+
return 1;
|
|
530
|
+
}
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Combine view SQL with table SQL.
|
|
535
|
+
// Extract view names from CREATE VIEW statements for pre-drop + down SQL.
|
|
536
|
+
const viewNames = viewResult.migrations
|
|
537
|
+
.map((s) => {
|
|
538
|
+
const m = /CREATE(?:\s+OR\s+REPLACE)?\s+VIEW\s+(\S+)/i.exec(s);
|
|
539
|
+
return m ? m[1] : undefined;
|
|
540
|
+
})
|
|
541
|
+
.filter((n): n is string => Boolean(n));
|
|
542
|
+
|
|
543
|
+
// Pre-drop views whose source tables are being recreated (same logic as kysely path).
|
|
544
|
+
const recreatedTables = emitResult.recreatedTables ?? new Set<string>();
|
|
545
|
+
const viewPreDropSql = recreatedTables.size > 0 && viewNames.length > 0
|
|
546
|
+
? (() => {
|
|
547
|
+
const deps = computeProjectionViewDependencies({ metadata, columnNamingStrategy });
|
|
548
|
+
const affected = viewNames.filter((n) => {
|
|
549
|
+
const sources = deps.get(n);
|
|
550
|
+
if (!sources) return false;
|
|
551
|
+
for (const t of sources) if (recreatedTables.has(t)) return true;
|
|
552
|
+
return false;
|
|
553
|
+
});
|
|
554
|
+
return affected.length > 0
|
|
555
|
+
? affected.map((n) => `DROP VIEW IF EXISTS ${n};`).join("\n")
|
|
556
|
+
: "";
|
|
557
|
+
})()
|
|
558
|
+
: "";
|
|
559
|
+
|
|
560
|
+
const viewUpSql = viewResult.migrations.join("\n\n");
|
|
561
|
+
const viewDownSql = viewNames.map((n) => `DROP VIEW IF EXISTS ${n};`).join("\n");
|
|
562
|
+
|
|
563
|
+
// Combine and apply safety pass to the full combined SQL so view DDL
|
|
564
|
+
// (which uses BEGIN/COMMIT from emitSqliteViewMigration) is stripped too.
|
|
565
|
+
const rawUp = [viewPreDropSql, emitResult.up, viewUpSql].filter(Boolean).join("\n\n");
|
|
566
|
+
const rawDown = [viewDownSql, emitResult.down].filter(Boolean).join("\n\n");
|
|
567
|
+
const combinedUp = applyD1SafetyPass(rawUp);
|
|
568
|
+
const combinedDown = applyD1SafetyPass(rawDown);
|
|
569
|
+
|
|
570
|
+
// Migration dir resolution: --out-dir > wrangler.toml's migrations_dir > "migrations".
|
|
571
|
+
// The default outDir (./.metaobjects/migrations) is the Kysely-path default; for D1
|
|
572
|
+
// we fall back to wrangler conventions when the caller hasn't overridden it.
|
|
573
|
+
const isDefaultOutDir = config.outDir === MIGRATE_DEFAULT_OUT_DIR;
|
|
574
|
+
const migrationsDir = resolvePath(
|
|
575
|
+
metaRoot,
|
|
576
|
+
isDefaultOutDir ? (binding.migrations_dir ?? "migrations") : config.outDir,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
if (config.dryRun) {
|
|
580
|
+
log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const writeResult = await writeMigrationD1(
|
|
585
|
+
{ up: combinedUp, down: combinedDown },
|
|
586
|
+
{ dir: migrationsDir, slug: config.slug },
|
|
587
|
+
);
|
|
588
|
+
log.info(`migrate: wrote ${writeResult.upPath}`);
|
|
589
|
+
log.info(`migrate: wrote ${writeResult.downPath}`);
|
|
590
|
+
for (const [kind, count] of Object.entries(changeCounts)) {
|
|
591
|
+
log.info(` ${kind}: ${count}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 7. Optional --apply: run `wrangler d1 migrations apply`.
|
|
595
|
+
if (config.d1.autoApply) {
|
|
596
|
+
return await runWranglerApply(
|
|
597
|
+
binding.binding,
|
|
598
|
+
binding.database_name,
|
|
599
|
+
config.d1.remote,
|
|
600
|
+
wranglerConfigPath,
|
|
601
|
+
config.yes,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function runWranglerApply(
|
|
609
|
+
bindingName: string,
|
|
610
|
+
databaseName: string,
|
|
611
|
+
remote: boolean,
|
|
612
|
+
wranglerConfigPath: string | undefined,
|
|
613
|
+
yes: boolean,
|
|
614
|
+
): Promise<number> {
|
|
615
|
+
if (remote && !yes) {
|
|
616
|
+
log.info(
|
|
617
|
+
`Applying to remote D1 '${databaseName}' (binding=${bindingName}) in 2s — Ctrl+C to abort or pass --yes to skip this pause.`,
|
|
618
|
+
);
|
|
619
|
+
await new Promise<void>((r) => setTimeout(r, 2000));
|
|
620
|
+
}
|
|
621
|
+
const applyArgs = ["d1", "migrations", "apply", bindingName, remote ? "--remote" : "--local"];
|
|
622
|
+
if (wranglerConfigPath !== undefined) applyArgs.push("--config", wranglerConfigPath);
|
|
623
|
+
|
|
624
|
+
return await new Promise<number>((resolve) => {
|
|
625
|
+
const child = spawn("wrangler", applyArgs, { stdio: "inherit" });
|
|
626
|
+
child.on("error", (err) => {
|
|
627
|
+
log.error(`migrate: failed to run wrangler: ${(err as Error).message}`);
|
|
628
|
+
resolve(2);
|
|
629
|
+
});
|
|
630
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
352
634
|
/**
|
|
353
635
|
* Read existing view CREATE SQL from the DB. Returns an empty map on any
|
|
354
636
|
* introspection failure — the worst case is over-eager DROP+CREATE
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// `meta prompt-snapshot` — deterministic rendered-prompt goldens (FR-004 #4).
|
|
2
|
+
//
|
|
3
|
+
// For each template.* node with a committed fixture payload, render its @textRef
|
|
4
|
+
// text against that payload (same engine, provider, and @format escaping prod
|
|
5
|
+
// uses) and snapshot the byte-exact output under .metaobjects/snapshots/<name>/.
|
|
6
|
+
// Write mode (default) overwrites output.snap; --check compares against the
|
|
7
|
+
// committed golden and exits 1 on drift (never writes). Closes the gap the
|
|
8
|
+
// template's own git history misses: a shared partial or payload-shape change
|
|
9
|
+
// that silently alters the rendered prompt.
|
|
10
|
+
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { parsePromptSnapshotArgs } from "../lib/args.js";
|
|
14
|
+
import { log } from "../lib/log.js";
|
|
15
|
+
import { FileProvider } from "../lib/file-provider.js";
|
|
16
|
+
import { snapshotPaths, unifiedDiff } from "../lib/snapshot.js";
|
|
17
|
+
import { loadMemory } from "@metaobjectsdev/sdk";
|
|
18
|
+
import { TYPE_TEMPLATE, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT } from "@metaobjectsdev/metadata";
|
|
19
|
+
import { render, ESCAPERS, type RenderFormat } from "@metaobjectsdev/render";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PROMPTS_DIR = "prompts";
|
|
22
|
+
|
|
23
|
+
export async function promptSnapshotCommand(args: string[], cwd: string): Promise<number> {
|
|
24
|
+
let flags;
|
|
25
|
+
try {
|
|
26
|
+
flags = parsePromptSnapshotArgs(args);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
log.error((err as Error).message);
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let root;
|
|
33
|
+
try {
|
|
34
|
+
root = await loadMemory(cwd);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
const msg = (err as Error).message;
|
|
37
|
+
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
|
|
38
|
+
log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
log.error(`failed to load metadata: ${msg}`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
|
|
46
|
+
const provider = new FileProvider(promptsDir);
|
|
47
|
+
|
|
48
|
+
const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
|
|
49
|
+
if (templates.length === 0) {
|
|
50
|
+
log.info("meta prompt-snapshot — no template.* nodes found; nothing to snapshot.");
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let errorCount = 0;
|
|
55
|
+
let driftCount = 0;
|
|
56
|
+
let wrote = 0;
|
|
57
|
+
let checked = 0;
|
|
58
|
+
let skipped = 0;
|
|
59
|
+
|
|
60
|
+
for (const tmpl of templates) {
|
|
61
|
+
const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
|
|
62
|
+
// Absent/typeless required attrs are a loader-schema concern, not ours.
|
|
63
|
+
if (typeof textRef !== "string") continue;
|
|
64
|
+
|
|
65
|
+
const { dir, payloadPath, snapPath } = snapshotPaths(cwd, tmpl.name);
|
|
66
|
+
if (!existsSync(payloadPath)) {
|
|
67
|
+
log.info(`[${tmpl.name}] skipped — no payload at ${payloadPath}`);
|
|
68
|
+
skipped++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let payload: unknown;
|
|
73
|
+
try {
|
|
74
|
+
payload = JSON.parse(readFileSync(payloadPath, "utf8"));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log.error(`[${tmpl.name}] invalid payload.json: ${(err as Error).message}`);
|
|
77
|
+
errorCount++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// @format is loader-validated against the template format vocabulary; narrow
|
|
82
|
+
// against the render engine's own escaper registry so the RenderFormat cast is
|
|
83
|
+
// a checked narrowing (never a TypeError on an unknown format), and omit the
|
|
84
|
+
// key entirely when absent (exactOptionalPropertyTypes forbids `format: undefined`).
|
|
85
|
+
const fmtAttr = tmpl.ownAttr(TEMPLATE_ATTR_FORMAT);
|
|
86
|
+
const format =
|
|
87
|
+
typeof fmtAttr === "string" && fmtAttr in ESCAPERS ? (fmtAttr as RenderFormat) : undefined;
|
|
88
|
+
|
|
89
|
+
let rendered: string;
|
|
90
|
+
try {
|
|
91
|
+
rendered = render({ ref: textRef, payload, provider, ...(format ? { format } : {}) });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.error(`[${tmpl.name}] render failed: ${(err as Error).message}`);
|
|
94
|
+
errorCount++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (flags.check) {
|
|
99
|
+
checked++;
|
|
100
|
+
if (!existsSync(snapPath)) {
|
|
101
|
+
log.error(
|
|
102
|
+
`[${tmpl.name}] no committed snapshot at ${snapPath}; run 'meta prompt-snapshot' to create it`,
|
|
103
|
+
);
|
|
104
|
+
driftCount++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const golden = readFileSync(snapPath, "utf8");
|
|
108
|
+
if (golden !== rendered) {
|
|
109
|
+
log.error(`[${tmpl.name}] snapshot drift:\n${unifiedDiff(golden, rendered)}`);
|
|
110
|
+
log.error(`[${tmpl.name}] run 'meta prompt-snapshot' to accept the change`);
|
|
111
|
+
driftCount++;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
mkdirSync(dir, { recursive: true });
|
|
115
|
+
writeFileSync(snapPath, rendered, "utf8");
|
|
116
|
+
log.info(`[${tmpl.name}] wrote ${snapPath}`);
|
|
117
|
+
wrote++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (flags.check) {
|
|
122
|
+
if (errorCount > 0 || driftCount > 0) {
|
|
123
|
+
log.error(
|
|
124
|
+
`meta prompt-snapshot --check — ${driftCount} drifted, ${errorCount} error(s) across ${checked} checked.`,
|
|
125
|
+
);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
log.info(
|
|
129
|
+
`meta prompt-snapshot --check — ${checked} snapshot(s) clean${skipped > 0 ? `, ${skipped} skipped` : ""}.`,
|
|
130
|
+
);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (errorCount > 0) {
|
|
135
|
+
log.error(`meta prompt-snapshot — ${errorCount} error(s); ${wrote} snapshot(s) written.`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
log.info(
|
|
139
|
+
`meta prompt-snapshot — ${wrote} snapshot(s) written${skipped > 0 ? `, ${skipped} skipped` : ""}.`,
|
|
140
|
+
);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// `meta verify` — the build-time drift gate (FR-004 Plan #3, T6).
|
|
2
|
+
//
|
|
3
|
+
// Loads metadata + a filesystem provider; for each template.* node resolves its
|
|
4
|
+
// @textRef text, derives its @payloadRef view-object field tree, and runs the
|
|
5
|
+
// render engine's `verify` (template variable ↔ payload field drift). Exits
|
|
6
|
+
// non-zero on any drift error so CI fails loud — the "a renamed field can't
|
|
7
|
+
// silently break a prompt" guarantee, enforced at the last fixed point before
|
|
8
|
+
// the text ships. Required-slot misses are warnings (don't fail the build).
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { parseVerifyArgs } from "../lib/args.js";
|
|
12
|
+
import { log } from "../lib/log.js";
|
|
13
|
+
import { FileProvider } from "../lib/file-provider.js";
|
|
14
|
+
import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
|
|
15
|
+
import { loadMemory } from "@metaobjectsdev/sdk";
|
|
16
|
+
import {
|
|
17
|
+
TYPE_TEMPLATE,
|
|
18
|
+
TEMPLATE_ATTR_PAYLOAD_REF,
|
|
19
|
+
TEMPLATE_ATTR_TEXT_REF,
|
|
20
|
+
TEMPLATE_ATTR_REQUIRED_SLOTS,
|
|
21
|
+
TEMPLATE_ATTR_REQUIRED_TAGS,
|
|
22
|
+
} from "@metaobjectsdev/metadata";
|
|
23
|
+
import { verify, ERR_REQUIRED_SLOT_UNUSED, ERR_PARTIAL_UNRESOLVED } from "@metaobjectsdev/render";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_PROMPTS_DIR = "prompts";
|
|
26
|
+
|
|
27
|
+
/** Coerce a string-array attr (array, or a single string) into a string[]. */
|
|
28
|
+
function attrAsStringArray(attr: unknown): string[] {
|
|
29
|
+
if (Array.isArray(attr)) return attr.filter((s): s is string => typeof s === "string");
|
|
30
|
+
if (typeof attr === "string") return [attr];
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function verifyCommand(args: string[], cwd: string): Promise<number> {
|
|
35
|
+
let flags;
|
|
36
|
+
try {
|
|
37
|
+
flags = parseVerifyArgs(args);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log.error((err as Error).message);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let root;
|
|
44
|
+
try {
|
|
45
|
+
root = await loadMemory(cwd);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const msg = (err as Error).message;
|
|
48
|
+
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
|
|
49
|
+
log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
log.error(`failed to load metadata: ${msg}`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
|
|
57
|
+
const provider = new FileProvider(promptsDir);
|
|
58
|
+
|
|
59
|
+
const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
|
|
60
|
+
if (templates.length === 0) {
|
|
61
|
+
log.info("meta verify — no template.* nodes found; nothing to check.");
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let errorCount = 0;
|
|
66
|
+
let warnCount = 0;
|
|
67
|
+
let checked = 0;
|
|
68
|
+
|
|
69
|
+
for (const tmpl of templates) {
|
|
70
|
+
const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
|
|
71
|
+
const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
|
|
72
|
+
// Absent/typeless required attrs are a loader-schema concern, not verify's.
|
|
73
|
+
if (typeof textRef !== "string" || typeof payloadRef !== "string") continue;
|
|
74
|
+
|
|
75
|
+
const text = provider.resolve(textRef);
|
|
76
|
+
if (text === undefined) {
|
|
77
|
+
log.error(
|
|
78
|
+
`[${tmpl.name}] ${ERR_PARTIAL_UNRESOLVED}: @textRef "${textRef}" did not resolve under ${promptsDir}`,
|
|
79
|
+
);
|
|
80
|
+
errorCount++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fieldTree = derivePayloadFieldTree(root, payloadRef);
|
|
85
|
+
const requiredSlots = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_SLOTS));
|
|
86
|
+
const requiredTags = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_TAGS));
|
|
87
|
+
|
|
88
|
+
const drift = verify(text, fieldTree, { provider, requiredSlots, requiredTags });
|
|
89
|
+
checked++;
|
|
90
|
+
for (const e of drift) {
|
|
91
|
+
if (e.code === ERR_REQUIRED_SLOT_UNUSED) {
|
|
92
|
+
log.warn(`[${tmpl.name}] ${e.code}: ${e.path}`);
|
|
93
|
+
warnCount++;
|
|
94
|
+
} else {
|
|
95
|
+
log.error(`[${tmpl.name}] ${e.code}: ${e.path}`);
|
|
96
|
+
errorCount++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (errorCount > 0) {
|
|
102
|
+
log.error(
|
|
103
|
+
`meta verify — ${errorCount} drift error(s) across ${templates.length} template(s).`,
|
|
104
|
+
);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
log.info(
|
|
108
|
+
`meta verify — ${checked} template(s) clean${warnCount > 0 ? ` (${warnCount} warning(s))` : ""}.`,
|
|
109
|
+
);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,8 @@ COMMANDS:
|
|
|
39
39
|
init --refresh-docs Refresh .metaobjects/AGENTS.md + CLAUDE.md after CLI upgrades
|
|
40
40
|
gen [<entity>...] Codegen TS targets from metaobjects/ entities
|
|
41
41
|
export Flatten loaded metadata to one canonical JSON artifact
|
|
42
|
+
verify Check template.* text against its payload (drift gate)
|
|
43
|
+
prompt-snapshot Snapshot rendered template.* output; --check gates drift
|
|
42
44
|
migrate Diff metadata vs live DB; emit migration SQL files
|
|
43
45
|
--version, -v Print version
|
|
44
46
|
--help, -h Print this help
|
|
@@ -54,15 +56,26 @@ GEN FLAGS:
|
|
|
54
56
|
EXPORT FLAGS:
|
|
55
57
|
--out <file> Write output to a file (default: stdout)
|
|
56
58
|
|
|
59
|
+
VERIFY FLAGS:
|
|
60
|
+
--prompts <dir> Directory of provider-resolved template text (default: prompts)
|
|
61
|
+
|
|
62
|
+
PROMPT-SNAPSHOT FLAGS:
|
|
63
|
+
--check Compare against committed snapshots; exit 1 on drift (CI gate)
|
|
64
|
+
--prompts <dir> Directory of provider-resolved template text (default: prompts)
|
|
65
|
+
|
|
57
66
|
MIGRATE FLAGS:
|
|
58
67
|
--db <url> DB connection URL (required, or set DATABASE_URL or config)
|
|
59
68
|
Supports: file:, libsql:, postgres:, postgresql:
|
|
60
|
-
--dialect sqlite|postgres Optional override (auto-detected from URL scheme)
|
|
69
|
+
--dialect sqlite|postgres|d1 Optional override (auto-detected from URL scheme)
|
|
61
70
|
--out-dir <path> Migration directory (default: ./.metaobjects/migrations)
|
|
62
71
|
--slug <name> Required when changes are present (e.g., add-user-shipping)
|
|
63
72
|
--allow <csv> Comma-separated destructive-change permissions:
|
|
64
73
|
drop-column,drop-table,type-change,drop-index,drop-fk,nullable-to-not-null
|
|
65
74
|
--on-ambiguous abort|rename|drop-add Default abort
|
|
75
|
+
--d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
|
|
76
|
+
--remote Target remote D1 instead of local (only with --dialect d1)
|
|
77
|
+
--apply Run 'wrangler d1 migrations apply' after writing files
|
|
78
|
+
--yes Skip the --remote --apply confirmation pause
|
|
66
79
|
--dry-run Print SQL to stdout, don't write
|
|
67
80
|
|
|
68
81
|
Other commands (ingest, mcp, serve, install-hooks, audit, capture, promote)
|
|
@@ -121,6 +134,14 @@ export async function run(argv: string[]): Promise<number> {
|
|
|
121
134
|
const { exportCommand } = await import("./commands/export.js");
|
|
122
135
|
return exportCommand(rest, cwd);
|
|
123
136
|
}
|
|
137
|
+
case "verify": {
|
|
138
|
+
const { verifyCommand } = await import("./commands/verify.js");
|
|
139
|
+
return verifyCommand(rest, cwd);
|
|
140
|
+
}
|
|
141
|
+
case "prompt-snapshot": {
|
|
142
|
+
const { promptSnapshotCommand } = await import("./commands/prompt-snapshot.js");
|
|
143
|
+
return promptSnapshotCommand(rest, cwd);
|
|
144
|
+
}
|
|
124
145
|
case "migrate": {
|
|
125
146
|
const { migrateCommand } = await import("./commands/migrate.js");
|
|
126
147
|
return migrateCommand(rest, cwd);
|