@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.
- package/README.md +40 -1
- package/dist/src/commands/migrate.d.ts +16 -0
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +252 -39
- 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 +162 -53
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/allow.d.ts +10 -0
- package/dist/src/lib/allow.d.ts.map +1 -0
- package/dist/src/lib/allow.js +50 -0
- package/dist/src/lib/allow.js.map +1 -0
- package/dist/src/lib/args.d.ts +22 -4
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +47 -11
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/bun-sqlite-dialect.d.ts +13 -0
- package/dist/src/lib/bun-sqlite-dialect.d.ts.map +1 -0
- package/dist/src/lib/bun-sqlite-dialect.js +119 -0
- package/dist/src/lib/bun-sqlite-dialect.js.map +1 -0
- package/dist/src/lib/config.d.ts +11 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +4 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/kysely.d.ts.map +1 -1
- package/dist/src/lib/kysely.js +21 -7
- package/dist/src/lib/kysely.js.map +1 -1
- package/dist/src/lib/projection-migrations.d.ts.map +1 -1
- package/dist/src/lib/projection-migrations.js +2 -6
- package/dist/src/lib/projection-migrations.js.map +1 -1
- package/package.json +11 -10
- package/src/commands/migrate.ts +277 -42
- package/src/commands/verify.ts +172 -61
- package/src/index.ts +6 -0
- package/src/lib/allow.ts +54 -0
- package/src/lib/args.ts +77 -15
- package/src/lib/bun-sqlite-dialect.ts +146 -0
- package/src/lib/config.ts +15 -0
- package/src/lib/kysely.ts +23 -10
- package/src/lib/projection-migrations.ts +2 -6
package/src/commands/migrate.ts
CHANGED
|
@@ -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:
|
|
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,
|
package/src/commands/verify.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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}] (
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
164
|
+
return 0;
|
|
147
165
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
)
|
|
151
|
-
|
|
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
|
}
|