@schemic/cli 0.1.0-alpha.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.
@@ -0,0 +1,1232 @@
1
+ import {
2
+ existsSync,
3
+ watch as fsWatch,
4
+ mkdirSync,
5
+ writeFileSync,
6
+ } from "node:fs";
7
+ import { dirname, join, relative } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
9
+ import {
10
+ actionLabel,
11
+ applyPull,
12
+ type Diff,
13
+ type DiffItem,
14
+ type Driver,
15
+ duplicateTables,
16
+ EMPTY_STORED,
17
+ emitKinds,
18
+ existingTables,
19
+ type FilterOpts,
20
+ fail,
21
+ formatDiff,
22
+ formatItems,
23
+ formatPatch,
24
+ getDriver,
25
+ isEmptyDiff,
26
+ type KindRegistry,
27
+ kindFlags,
28
+ lineDiff,
29
+ listMigrations,
30
+ loadDefs,
31
+ loadSchemas,
32
+ lowerSchema,
33
+ ok,
34
+ type PullFilePlan,
35
+ type PullPlan,
36
+ parseFilter,
37
+ pipeThroughPager,
38
+ plural,
39
+ type ResolvedConfig,
40
+ readSnapshot,
41
+ resolvePager,
42
+ snapshotObjects,
43
+ style,
44
+ summarizeKinds,
45
+ unifiedDiff,
46
+ writeSnapshot,
47
+ } from "@schemic/core";
48
+ import { Command, Help, Option } from "commander";
49
+ import { init } from "./init";
50
+ import {
51
+ baseline,
52
+ clearMigrationFiles,
53
+ commitMigration,
54
+ migrate,
55
+ planMigration,
56
+ prepareMigration,
57
+ reconcileBaseline,
58
+ rollback,
59
+ seed,
60
+ status,
61
+ unlock,
62
+ } from "./migrate";
63
+ import { portableDiff } from "./portable-diff";
64
+ import {
65
+ collectArg,
66
+ ensureDriver,
67
+ type ResolveOpts,
68
+ resolveOne,
69
+ resolveTargets,
70
+ } from "./resolve";
71
+
72
+ /** The driver a resolved connection uses (its package is loaded by the resolution engine). */
73
+ const activeDriver = (config: ResolvedConfig): Driver<unknown> =>
74
+ getDriver(config.driver);
75
+
76
+ type CommonOpts = ResolveOpts;
77
+
78
+ /**
79
+ * Resolve the addressed connection(s), connect each via its driver, run, and always close. With
80
+ * `--all` (or a `--connection <name>` collection) this fans out over every target, printing a
81
+ * `[connection]` header per run. The connection is OPAQUE here (`db: unknown`) — the orchestration
82
+ * only ever hands it back to the SAME driver, so the CLI body never names a dialect's connection type.
83
+ */
84
+ async function withDb(
85
+ opts: CommonOpts,
86
+ fn: (db: unknown, config: ResolvedConfig) => Promise<void>,
87
+ ): Promise<void> {
88
+ const targets = await resolveTargets(opts);
89
+ for (const config of targets) {
90
+ if (targets.length > 1) console.log(style.bold(`\n[${config.connection}]`));
91
+ const driver = getDriver(config.driver);
92
+ const db = await driver.connect(config, opts);
93
+ try {
94
+ await fn(db, config);
95
+ } finally {
96
+ await driver.close(db);
97
+ }
98
+ }
99
+ }
100
+
101
+ const errMsg = (err: unknown) =>
102
+ err instanceof Error ? err.message : String(err);
103
+
104
+ /**
105
+ * Render duplicate-table conflicts as `name — file, file` lines (files relative to `root`, a file
106
+ * repeated `×N` when it defines the same name more than once). Shared by `check` and `doctor`.
107
+ */
108
+ function formatDuplicates(dups: Map<string, string[]>, root: string): string[] {
109
+ return [...dups].map(([name, files]) => {
110
+ const counts = new Map<string, number>();
111
+ for (const f of files) counts.set(f, (counts.get(f) ?? 0) + 1);
112
+ const label = [...counts]
113
+ .map(([f, n]) => {
114
+ const rel = relative(root, f);
115
+ return n > 1 ? `${rel} (×${n})` : rel;
116
+ })
117
+ .join(", ");
118
+ return `${name} — ${label}`;
119
+ });
120
+ }
121
+
122
+ const duplicateHeader = (n: number) =>
123
+ `${plural(n, "table")} defined more than once (last definition silently wins):`;
124
+
125
+ /**
126
+ * Run a command action, then exit. We force `process.exit` once it settles so a lingering
127
+ * SDK connection handle can't keep the process alive (commands would otherwise hang). Watch
128
+ * commands return a never-settling promise, so they keep running until SIGINT.
129
+ */
130
+ function run(action: () => Promise<void>): void {
131
+ action().then(
132
+ () => process.exit(process.exitCode ?? 0),
133
+ (err: unknown) => {
134
+ console.error(`\n${fail(errMsg(err))}`);
135
+ process.exit(1);
136
+ },
137
+ );
138
+ }
139
+
140
+ /** Prompt for a migration title; returns undefined when non-interactive (uses the default). */
141
+ async function promptTitle(): Promise<string | undefined> {
142
+ if (!process.stdin.isTTY) return undefined;
143
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
144
+ try {
145
+ const answer = (await rl.question("Migration title: ")).trim();
146
+ return answer || undefined;
147
+ } finally {
148
+ rl.close();
149
+ }
150
+ }
151
+
152
+ /** A yes/no prompt; defaults to NO when non-interactive, so scripts must opt in via a flag. */
153
+ async function confirmPrompt(question: string): Promise<boolean> {
154
+ if (!process.stdin.isTTY) return false;
155
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
156
+ try {
157
+ const a = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
158
+ return a === "y" || a === "yes";
159
+ } finally {
160
+ rl.close();
161
+ }
162
+ }
163
+
164
+ /** The short dimmed summary under a diff (per-kind counts + optional pending count). */
165
+ function diffSummary(
166
+ registry: KindRegistry,
167
+ diff: Diff,
168
+ opts: { live?: boolean },
169
+ pending?: number,
170
+ ): string {
171
+ const summary: string[] = [];
172
+ if (!isEmptyDiff(diff)) {
173
+ const kinds = summarizeKinds(registry, diff.items ?? []);
174
+ summary.push(
175
+ `${plural(diff.up.length, "change")} ${opts.live ? "vs the live database" : "vs the snapshot"}${kinds ? ` — ${kinds}` : ""}.`,
176
+ );
177
+ }
178
+ if (pending !== undefined)
179
+ summary.push(`${plural(pending, "migration")} pending.`);
180
+ return summary.length ? `\n${style.dim(summary.join("\n"))}` : "";
181
+ }
182
+
183
+ /** Print a diff (inline word-diff) plus its summary. */
184
+ function reportDiff(
185
+ registry: KindRegistry,
186
+ diff: Diff,
187
+ opts: { down?: boolean; live?: boolean; full?: boolean; inline?: boolean },
188
+ pending?: number,
189
+ ): void {
190
+ console.log(
191
+ formatDiff(diff, { down: opts.down, full: opts.full, inline: opts.inline }),
192
+ );
193
+ const summary = diffSummary(registry, diff, opts, pending);
194
+ if (summary) console.log(summary);
195
+ }
196
+
197
+ /**
198
+ * Watch the schema directory and re-run `task` on each change (debounced, non-overlapping).
199
+ * Runs once immediately, then blocks until SIGINT/SIGTERM, when `cleanup` runs. Never resolves.
200
+ */
201
+ function watchLoop(
202
+ config: ResolvedConfig,
203
+ task: () => Promise<void>,
204
+ cleanup?: () => Promise<unknown>,
205
+ ): Promise<never> {
206
+ return new Promise<never>(() => {
207
+ let timer: ReturnType<typeof setTimeout> | undefined;
208
+ let running = false;
209
+ let queued = false;
210
+ const fire = async () => {
211
+ if (running) {
212
+ queued = true;
213
+ return;
214
+ }
215
+ running = true;
216
+ console.log(style.dim(`\n— ${new Date().toLocaleTimeString()} —`));
217
+ try {
218
+ await task();
219
+ } catch (err) {
220
+ console.error(fail(errMsg(err)));
221
+ }
222
+ running = false;
223
+ if (queued) {
224
+ queued = false;
225
+ void fire();
226
+ }
227
+ };
228
+ const watcher = fsWatch(
229
+ config.schemaPath,
230
+ { recursive: !config.schemaIsFile },
231
+ () => {
232
+ clearTimeout(timer);
233
+ timer = setTimeout(() => void fire(), 150);
234
+ },
235
+ );
236
+ console.log(
237
+ style.dim(
238
+ `Watching ${relative(config.root, config.schemaPath)} for changes — ctrl-c to stop.`,
239
+ ),
240
+ );
241
+ void fire();
242
+ const stop = () => {
243
+ watcher.close();
244
+ clearTimeout(timer);
245
+ Promise.resolve(cleanup?.()).finally(() => process.exit(0));
246
+ };
247
+ process.once("SIGINT", stop);
248
+ process.once("SIGTERM", stop);
249
+ });
250
+ }
251
+
252
+ const configFlag = (cmd: Command): Command =>
253
+ cmd.option("-c, --config <path>", "path to schemic.config.ts");
254
+
255
+ const dbFlags = (cmd: Command): Command =>
256
+ configFlag(cmd)
257
+ .option(
258
+ "--connection <name>",
259
+ "target a specific connection (or <name>:<key> within a collection)",
260
+ )
261
+ .option("--all", "run against every connection (collections fanned out)")
262
+ .option(
263
+ "--arg <key=value>",
264
+ "value passed to connection resolvers (repeatable)",
265
+ collectArg,
266
+ [],
267
+ )
268
+ .option("--url <url>", "override the connection endpoint")
269
+ .option("--namespace <ns>", "override the namespace")
270
+ .option("--database <db>", "override the database")
271
+ .option("--username <user>", "override the auth username")
272
+ .option("--password <pass>", "override the auth password")
273
+ .addOption(
274
+ new Option("--auth-level <level>", "auth level").choices([
275
+ "root",
276
+ "namespace",
277
+ "database",
278
+ ]),
279
+ );
280
+
281
+ const program = new Command();
282
+ program
283
+ .name("schemic")
284
+ .description(
285
+ "Schema-as-code migrations for any database — generate DDL, diff, and migrate via drivers",
286
+ )
287
+ .version("0.1.0-alpha.0")
288
+ .showHelpAfterError("(run `schemic --help` for usage)")
289
+ .addHelpText(
290
+ "after",
291
+ `
292
+ Examples:
293
+ $ schemic init scaffold database/ (schemas + migrations) + config
294
+ $ schemic gen add_users create a migration from schema changes
295
+ $ schemic migrate apply pending migrations
296
+ $ schemic push --watch keep the database in sync while you edit
297
+ $ schemic diff --live show how the schema differs from the live database
298
+ `,
299
+ );
300
+
301
+ // Collapse negatable flags to a single `--[no-]flag` help line: drop the separate `--no-flag` line
302
+ // and prefix its positive (or a lone `--no-flag`) with `[no-]`. Set before any subcommand is added
303
+ // so they inherit it. `_collapsible` (positives that have a `--no-` counterpart) is computed in
304
+ // `visibleOptions` and read in `optionTerm` within the same render pass.
305
+ type CollapsibleHelp = { _collapsible?: Set<string> };
306
+ program.configureHelp({
307
+ visibleOptions(cmd) {
308
+ const opts = Help.prototype.visibleOptions.call(this, cmd);
309
+ // `--tables` and `--no-tables` share an `attributeName()` ("tables"); `name()` does NOT.
310
+ const negated = new Set(
311
+ opts.filter((o) => o.negate).map((o) => o.attributeName()),
312
+ );
313
+ (this as CollapsibleHelp)._collapsible = new Set(
314
+ [...negated].filter((n) =>
315
+ opts.some((o) => !o.negate && o.attributeName() === n),
316
+ ),
317
+ );
318
+ // Drop the `--no-x` rows whose positive `--x` we'll fold the `[no-]` into.
319
+ return opts.filter(
320
+ (o) =>
321
+ !(
322
+ o.negate &&
323
+ (this as CollapsibleHelp)._collapsible?.has(o.attributeName())
324
+ ),
325
+ );
326
+ },
327
+ optionTerm(option) {
328
+ const term = Help.prototype.optionTerm.call(this, option);
329
+ // A lone `--no-x` (no positive counterpart, e.g. `--no-prune`) -> `--[no-]x`.
330
+ if (option.negate) return term.replace("--no-", "--[no-]");
331
+ // A positive `--x` that has a `--no-x` counterpart -> `--[no-]x [...]`.
332
+ if ((this as CollapsibleHelp)._collapsible?.has(option.attributeName()))
333
+ return term.replace(`--${option.name()}`, `--[no-]${option.name()}`);
334
+ return term;
335
+ },
336
+ });
337
+
338
+ program
339
+ .command("init")
340
+ .description("Scaffold database/ (schemas + migrations) and a config file")
341
+ .option(
342
+ "--driver <name>",
343
+ "database driver to scaffold for (default surrealdb)",
344
+ )
345
+ .action((opts: { driver?: string }) => {
346
+ run(async () => {
347
+ const name = opts.driver ?? "surrealdb";
348
+ await ensureDriver(name);
349
+ const { created, skipped } = init(process.cwd(), getDriver(name));
350
+ for (const f of created) console.log(` ${style.green("+")} ${f}`);
351
+ for (const f of skipped)
352
+ console.log(style.dim(` · ${f} (exists, skipped)`));
353
+ console.log(
354
+ created.length
355
+ ? `\n${ok("Initialized. Edit database/schema, then run `schemic gen`.")}`
356
+ : "\nNothing to do — already initialized.",
357
+ );
358
+ });
359
+ });
360
+
361
+ kindFlags(
362
+ dbFlags(
363
+ program
364
+ .command("diff")
365
+ .description("Show pending schema changes without writing a migration"),
366
+ ),
367
+ )
368
+ .option("--down", "also show the rollback (down) statements")
369
+ .option("--live", "diff against the live database instead of the snapshot")
370
+ .option("--ts", "show the change as TypeScript schema instead of DDL")
371
+ .option("--watch", "re-run on schema changes")
372
+ .option("--full", "show the full schema SQL, not just the changed parts")
373
+ .option(
374
+ "-p, --patch",
375
+ "output a unified diff (e.g. to pipe to a diff viewer)",
376
+ )
377
+ .option(
378
+ "--pager [cmd]",
379
+ "page through your git diff viewer (or <cmd>); off by default",
380
+ )
381
+ .option(
382
+ "--inline",
383
+ "render changes as an inline word-diff instead of separate -/+ lines",
384
+ )
385
+ .option("--json", "output the diff as JSON")
386
+ .option(
387
+ "--driver <name>",
388
+ "target database driver (default from config, or 'surreal')",
389
+ )
390
+ .action(
391
+ (
392
+ opts: CommonOpts &
393
+ FilterOpts & {
394
+ down?: boolean;
395
+ live?: boolean;
396
+ ts?: boolean;
397
+ watch?: boolean;
398
+ full?: boolean;
399
+ patch?: boolean;
400
+ pager?: string | boolean;
401
+ inline?: boolean;
402
+ json?: boolean;
403
+ driver?: string;
404
+ },
405
+ ) => {
406
+ run(async () => {
407
+ const config = await resolveOne(opts);
408
+ const driverName = opts.driver ?? config.driver ?? "surrealdb";
409
+ await ensureDriver(driverName);
410
+ const driver = getDriver(driverName);
411
+ // A driver without the rich live/snapshot diff capability routes through the portable-IR
412
+ // diff path (introspect + structural compare); the snapshot/`--ts`/`--live` pipeline below
413
+ // needs it. The CLI gates on the CAPABILITY, never on the driver name.
414
+ const diffLive = driver.diffLive;
415
+ if (!diffLive) {
416
+ await portableDiff(config, driverName, { json: opts.json });
417
+ return;
418
+ }
419
+ const filter = parseFilter(opts);
420
+ // External pager only when explicitly requested via `--pager` (the default renders inline).
421
+ // `--pager <cmd>` uses that command; bare `--pager` resolves the user's git diff viewer
422
+ // (`pager.diff`/`core.pager`/$GIT_PAGER/$PAGER). `--patch` forces the unified-diff format
423
+ // (to the pager, or to stdout when piped). Paging is incompatible with `--watch`.
424
+ const pager =
425
+ opts.watch || opts.pager === undefined || opts.pager === false
426
+ ? undefined
427
+ : typeof opts.pager === "string"
428
+ ? opts.pager
429
+ : resolvePager();
430
+ const emit = async (diff: Diff, pending?: number) => {
431
+ if (opts.json) {
432
+ console.log(
433
+ JSON.stringify({ up: diff.up, down: diff.down, pending }),
434
+ );
435
+ } else if ((opts.patch || pager) && !isEmptyDiff(diff)) {
436
+ const patch = formatPatch(diff);
437
+ if (pager) await pipeThroughPager(pager, patch);
438
+ else process.stdout.write(patch);
439
+ const summary = diffSummary(driver.registry, diff, opts, pending);
440
+ if (summary) console.log(summary);
441
+ } else {
442
+ reportDiff(driver.registry, diff, opts, pending);
443
+ }
444
+ };
445
+ // Reuse one connection across watch runs for --live; otherwise connect per run.
446
+ const persistent =
447
+ opts.watch && opts.live
448
+ ? await driver.connect(config, opts)
449
+ : undefined;
450
+ const once = async () => {
451
+ // TypeScript view: render both sides PER FILE (matching `pull`'s layout) and diff each.
452
+ if (opts.ts) {
453
+ // Map each object to its source file (where it lives in the schema, else its kind folder
454
+ // — the driver names the folder per kind via the registry's display metadata).
455
+ const loc = await existingTables(config.schemaPath);
456
+ const fileFor = (kind: string, name: string): string => {
457
+ const abs = kind === "table" ? loc.get(name) : undefined;
458
+ return abs
459
+ ? relative(config.root, abs)
460
+ : relative(
461
+ config.root,
462
+ join(
463
+ config.schemaPath,
464
+ driver.registry.display(kind).folder,
465
+ `${name}.ts`,
466
+ ),
467
+ );
468
+ };
469
+ // Single-file layout → one combined module key; directory layout → one file per object.
470
+ const single = config.schemaIsFile
471
+ ? relative(config.root, config.schemaPath)
472
+ : undefined;
473
+
474
+ // cur = the baseline (live DB or snapshot) rendered to source, des = the declared schema.
475
+ const showTsDiff = async (
476
+ cur: Map<string, string>,
477
+ des: Map<string, string>,
478
+ matchMsg: string,
479
+ ) => {
480
+ if (opts.json) {
481
+ console.log(
482
+ JSON.stringify({
483
+ current: Object.fromEntries(cur),
484
+ desired: Object.fromEntries(des),
485
+ }),
486
+ );
487
+ return;
488
+ }
489
+ const files = [...new Set([...cur.keys(), ...des.keys()])].sort();
490
+ const changed = files.filter(
491
+ (f) => (cur.get(f) ?? "") !== (des.get(f) ?? ""),
492
+ );
493
+ if (!changed.length) {
494
+ console.log(ok(matchMsg));
495
+ } else if (pager || opts.patch) {
496
+ // A git-style unified patch, one section per changed file.
497
+ const patch = changed
498
+ .map((f) =>
499
+ unifiedDiff(cur.get(f) ?? "", des.get(f) ?? "", f),
500
+ )
501
+ .join("");
502
+ if (pager) await pipeThroughPager(pager, patch);
503
+ else process.stdout.write(patch);
504
+ } else {
505
+ // Colored, one git-style section per changed file (path header + line diff).
506
+ console.log(
507
+ changed
508
+ .map(
509
+ (f) =>
510
+ `${style.bold(f)}\n${lineDiff(cur.get(f) ?? "", des.get(f) ?? "")}`,
511
+ )
512
+ .join("\n\n"),
513
+ );
514
+ }
515
+ };
516
+
517
+ if (opts.live) {
518
+ if (!driver.diffTsLive)
519
+ throw new Error(
520
+ `the "${driverName}" driver does not support \`diff --ts --live\`.`,
521
+ );
522
+ const db = persistent ?? (await driver.connect(config, opts));
523
+ try {
524
+ const { current, desired } = await driver.diffTsLive(
525
+ db,
526
+ config,
527
+ filter,
528
+ fileFor,
529
+ single,
530
+ );
531
+ await showTsDiff(
532
+ current,
533
+ desired,
534
+ "Schema matches the live database.",
535
+ );
536
+ } finally {
537
+ if (!persistent) await driver.close(db);
538
+ }
539
+ } else {
540
+ if (!driver.renderSchema)
541
+ throw new Error(
542
+ `the "${driverName}" driver does not support \`diff --ts\`.`,
543
+ );
544
+ // Offline: render the snapshot's recorded schema and the declared schema to source,
545
+ // then diff per file.
546
+ const prev = readSnapshot(config.metaDir);
547
+ const prevObjects = snapshotObjects(prev.schema);
548
+ const { tables, defs } = await loadDefs(config.schemaPath);
549
+ const desiredObjects = lowerSchema(
550
+ driver.registry,
551
+ driver.explode(tables, defs),
552
+ );
553
+ // No snapshot? Render against an empty current side — the whole schema shows as added
554
+ // TS, the same as plain `diff` does against an empty snapshot.
555
+ await showTsDiff(
556
+ driver.renderSchema(prevObjects, filter, fileFor, single),
557
+ driver.renderSchema(desiredObjects, filter, fileFor, single),
558
+ prevObjects.length
559
+ ? "Schema matches the snapshot."
560
+ : "No schema to render.",
561
+ );
562
+ }
563
+ return;
564
+ }
565
+ if (opts.live) {
566
+ const db = persistent ?? (await driver.connect(config, opts));
567
+ try {
568
+ const diff = await diffLive(db, config, filter);
569
+ const pending = (await status(db, config)).filter(
570
+ (r) => !r.applied,
571
+ ).length;
572
+ await emit(diff, pending);
573
+ } finally {
574
+ if (!persistent) await driver.close(db);
575
+ }
576
+ } else {
577
+ await emit((await planMigration(config, filter)).diff);
578
+ }
579
+ };
580
+ if (!opts.watch) return once();
581
+ await watchLoop(
582
+ config,
583
+ once,
584
+ persistent ? () => driver.close(persistent) : undefined,
585
+ );
586
+ });
587
+ },
588
+ );
589
+
590
+ // `gen` is the primary command; `generate` is a hidden, undocumented alias (a separate hidden
591
+ // command so help shows only `gen`, not `gen|generate`). Both share one action.
592
+ const genAction = (
593
+ name: string | undefined,
594
+ opts: CommonOpts &
595
+ FilterOpts & { yes?: boolean; baseline?: boolean; force?: boolean },
596
+ ) => {
597
+ run(async () => {
598
+ const config = await resolveOne(opts);
599
+ const filter = parseFilter(opts);
600
+ // A baseline regenerates the WHOLE schema from an empty snapshot; existing migrations would
601
+ // clash (they already created those objects), so a baseline must REPLACE them. With --force (or
602
+ // an interactive yes) we squash them into one fresh baseline; otherwise stop with the exact
603
+ // command to run.
604
+ let squashed: string[] | null = null;
605
+ if (opts.baseline) {
606
+ const existing = listMigrations(
607
+ config.migrationsDir,
608
+ activeDriver(config).migrations?.extension ?? ".surql",
609
+ );
610
+ if (existing.length) {
611
+ const migDir = relative(config.root, config.migrationsDir);
612
+ const proceed =
613
+ opts.force ||
614
+ (await confirmPrompt(
615
+ `Replace ${plural(existing.length, "migration")} in ${migDir} with a single baseline?`,
616
+ ));
617
+ if (!proceed) {
618
+ throw new Error(
619
+ `${plural(existing.length, "migration")} already exist in ${migDir} — a baseline would re-define objects they already created.\n Re-run \`schemic gen --baseline --force\` to replace them with one fresh baseline.`,
620
+ );
621
+ }
622
+ squashed = clearMigrationFiles(config);
623
+ }
624
+ }
625
+ const plan = await planMigration(config, filter, {
626
+ baseline: opts.baseline,
627
+ });
628
+ if (isEmptyDiff(plan.diff)) {
629
+ console.log(ok("No schema changes — nothing to generate."));
630
+ return;
631
+ }
632
+ const kinds = summarizeKinds(
633
+ activeDriver(config).registry,
634
+ plan.diff.items ?? [],
635
+ );
636
+ console.log(
637
+ `${plural(plan.diff.up.length, "change")} to migrate${kinds ? ` — ${kinds}` : ""}.`,
638
+ );
639
+ // Preview the changes BEFORE prompting for a name, so you see what you're naming.
640
+ console.log(formatDiff(plan.diff, {}));
641
+ const title =
642
+ name ??
643
+ (opts.baseline ? "baseline" : opts.yes ? undefined : await promptTitle());
644
+ const prepared = prepareMigration(config, plan, title);
645
+ if (!prepared) {
646
+ console.log(ok("No schema changes — nothing to generate."));
647
+ return;
648
+ }
649
+ const res = commitMigration(config, prepared);
650
+ console.log(
651
+ `${ok(res.file ?? "migration written")} ${style.dim(`(+${res.up} up / ${res.down} down)`)}`,
652
+ );
653
+ // After a squash, reconcile the live DB's migration history (best-effort): when the DB already
654
+ // matches the schema, record the baseline as applied so its DDL isn't re-run and `schemic status`
655
+ // stays clean. Unreachable / drifted → leave it pending and say so.
656
+ if (squashed) {
657
+ console.log(
658
+ style.dim(` replaced ${plural(squashed.length, "migration")}.`),
659
+ );
660
+ try {
661
+ const driver = activeDriver(config);
662
+ const diffLive = driver.diffLive;
663
+ if (!diffLive)
664
+ throw new Error(
665
+ `the "${config.driver ?? "surrealdb"}" driver does not support live reconcile`,
666
+ );
667
+ const db = await driver.connect(config, opts);
668
+ try {
669
+ const drift = !isEmptyDiff(await diffLive(db, config, filter));
670
+ const state = await reconcileBaseline(db, config, prepared, drift);
671
+ console.log(
672
+ style.dim(
673
+ state === "applied"
674
+ ? " database matched the schema — baseline recorded as applied."
675
+ : " database differs from the schema — baseline left pending; run `schemic migrate`.",
676
+ ),
677
+ );
678
+ } finally {
679
+ await driver.close(db);
680
+ }
681
+ } catch (e) {
682
+ console.log(
683
+ style.dim(
684
+ ` database not reconciled (${errMsg(e)}) — baseline is pending; run \`schemic migrate\` to apply it.`,
685
+ ),
686
+ );
687
+ }
688
+ }
689
+ });
690
+ };
691
+ const addGenCommand = (cmd: Command): void => {
692
+ kindFlags(dbFlags(cmd))
693
+ .option("-y, --yes", "use the given/default name without prompting")
694
+ .option(
695
+ "--baseline",
696
+ "regenerate one fresh baseline from an empty snapshot (replaces existing migrations)",
697
+ )
698
+ .option(
699
+ "--force",
700
+ "with --baseline, replace existing migrations without confirmation",
701
+ )
702
+ .action(genAction);
703
+ };
704
+ addGenCommand(
705
+ program
706
+ .command("gen [name]")
707
+ .description("Diff schemas, preview the migration script, and write it"),
708
+ );
709
+ addGenCommand(program.command("generate [name]", { hidden: true }));
710
+
711
+ // `snapshot` groups operations on the migration snapshot (the state `schemic gen`/`schemic diff` compare
712
+ // against). `reset` clears it so the next `schemic gen` baselines the full schema.
713
+ const snapshot = program
714
+ .command("snapshot")
715
+ .description(
716
+ "Manage the migration snapshot (what `schemic gen`/`schemic diff` compare against)",
717
+ );
718
+ configFlag(
719
+ snapshot
720
+ .command("reset")
721
+ .description(
722
+ "Clear the snapshot — the next `schemic gen` baselines the full schema",
723
+ ),
724
+ ).action((opts: CommonOpts) => {
725
+ run(async () => {
726
+ const config = await resolveOne(opts);
727
+ writeSnapshot(config.metaDir, EMPTY_STORED);
728
+ console.log(ok("Snapshot cleared."));
729
+ const existing = listMigrations(
730
+ config.migrationsDir,
731
+ activeDriver(config).migrations?.extension ?? ".surql",
732
+ );
733
+ if (existing.length) {
734
+ console.log(
735
+ style.dim(
736
+ ` ${plural(existing.length, "migration")} still on disk — run \`schemic gen --baseline --force\` to replace them with one fresh baseline. (A plain \`schemic gen\` would add a baseline alongside them.)`,
737
+ ),
738
+ );
739
+ } else {
740
+ console.log(
741
+ style.dim(" The next `schemic gen` will baseline the full schema."),
742
+ );
743
+ }
744
+ });
745
+ });
746
+
747
+ dbFlags(
748
+ program
749
+ .command("migrate [count]")
750
+ .alias("up")
751
+ .description(
752
+ "Apply pending migrations (all, the next N, or up to --to <tag>)",
753
+ )
754
+ .option("--to <tag>", "apply up to and including this migration"),
755
+ ).action((count: string | undefined, opts: CommonOpts & { to?: string }) => {
756
+ run(() =>
757
+ withDb(opts, async (db, config) => {
758
+ const n =
759
+ count === undefined
760
+ ? undefined
761
+ : Math.max(1, Number.parseInt(count, 10) || 1);
762
+ const { applied } = await migrate(db, config, { count: n, to: opts.to });
763
+ if (!applied.length) {
764
+ console.log(ok("Up to date — no pending migrations."));
765
+ return;
766
+ }
767
+ for (const e of applied) console.log(` ${style.green("↑")} ${e.tag}`);
768
+ console.log(`\n${ok(`Applied ${plural(applied.length, "migration")}.`)}`);
769
+ }),
770
+ );
771
+ });
772
+
773
+ dbFlags(
774
+ program.command("status").description("Show applied vs pending migrations"),
775
+ )
776
+ .option("--json", "output the status as JSON")
777
+ .action((opts: CommonOpts & { json?: boolean }) => {
778
+ run(() =>
779
+ withDb(opts, async (db, config) => {
780
+ const rows = await status(db, config);
781
+ if (opts.json) {
782
+ console.log(JSON.stringify(rows));
783
+ return;
784
+ }
785
+ if (!rows.length) {
786
+ console.log("No migrations yet. Run `schemic gen`.");
787
+ return;
788
+ }
789
+ for (const r of rows) {
790
+ if (r.missing) {
791
+ console.log(
792
+ ` ${style.yellow("⚠ missing")} ${r.tag} ${style.dim("(applied in the DB, file deleted)")}`,
793
+ );
794
+ } else if (r.drift) {
795
+ console.log(
796
+ ` ${style.yellow("⚠ drift")} ${r.tag} ${style.dim("(file changed after apply)")}`,
797
+ );
798
+ } else if (r.applied) {
799
+ console.log(` ${style.green("✓ applied")} ${r.tag}`);
800
+ } else {
801
+ console.log(style.dim(` · pending ${r.tag}`));
802
+ }
803
+ }
804
+ const pending = rows.filter((r) => !r.applied).length;
805
+ const drifted = rows.filter((r) => r.drift).length;
806
+ const missing = rows.filter((r) => r.missing).length;
807
+ const parts = [plural(rows.length, "migration"), `${pending} pending`];
808
+ if (drifted) parts.push(`${drifted} drifted`);
809
+ if (missing) parts.push(`${missing} missing`);
810
+ console.log(`\n${style.dim(`${parts.join(", ")}.`)}`);
811
+ if (missing) {
812
+ console.log(
813
+ style.dim(
814
+ " Missing migrations were applied but their files are gone (e.g. after removing migrations or `snapshot reset`).",
815
+ ),
816
+ );
817
+ }
818
+ }),
819
+ );
820
+ });
821
+
822
+ dbFlags(
823
+ program
824
+ .command("check")
825
+ .description(
826
+ "Validate schemas, then replay migrations to confirm they reproduce the schema",
827
+ )
828
+ .option(
829
+ "--schema",
830
+ "validate the schema only — skip the migration replay (no database)",
831
+ ),
832
+ ).action((opts: CommonOpts & { schema?: boolean }) => {
833
+ run(async () => {
834
+ const config = await resolveOne(opts);
835
+ const driver = activeDriver(config);
836
+
837
+ // 1. Static validation (no connection): no duplicate tables, schemas parse.
838
+ const dups = await duplicateTables(config.schemaPath);
839
+ if (dups.size) {
840
+ const lines = formatDuplicates(dups, config.root).map((l) => ` ${l}`);
841
+ throw new Error(`${duplicateHeader(dups.size)}\n${lines.join("\n")}`);
842
+ }
843
+ const { tables, defs } = await loadDefs(config.schemaPath);
844
+ const kinds = summarizeKinds(
845
+ driver.registry,
846
+ lowerSchema(driver.registry, driver.explode(tables, defs)),
847
+ );
848
+ console.log(ok(`Schemas valid${kinds ? ` — ${kinds}` : " (no objects)"}.`));
849
+ if (opts.schema) return;
850
+
851
+ // 2. Deep check: replay every migration into a throwaway engine and confirm the result matches
852
+ // the schema. The driver owns the replay (engine selection + apply); it NEVER touches the
853
+ // real database. A driver without the capability can only `check --schema`.
854
+ if (!driver.checkReplay) {
855
+ throw new Error(
856
+ `the "${config.driver ?? "surrealdb"}" driver does not support migration replay — run \`schemic check --schema\` to validate the schema only.`,
857
+ );
858
+ }
859
+ const diff = await driver.checkReplay(config, opts, parseFilter({}), (m) =>
860
+ console.log(style.dim(m)),
861
+ );
862
+ if (isEmptyDiff(diff)) {
863
+ console.log(ok("Migrations reproduce the schema."));
864
+ return;
865
+ }
866
+ console.log(
867
+ `\n${fail("Drift — migrations do not reproduce the schema:")}\n`,
868
+ );
869
+ console.log(formatDiff(diff, {}));
870
+ console.log(
871
+ `\n${style.dim(`${summarizeKinds(driver.registry, diff.items ?? [])} differ. \`schemic gen\` writes a migration to reconcile.`)}`,
872
+ );
873
+ process.exitCode = 1;
874
+ });
875
+ });
876
+
877
+ dbFlags(
878
+ program
879
+ .command("doctor")
880
+ .description("Print resolved config and test the connection"),
881
+ ).action((opts: CommonOpts) => {
882
+ run(async () => {
883
+ const config = await resolveOne(opts);
884
+ const row = (k: string, v: string) =>
885
+ console.log(style.dim(` ${k.padEnd(11)} ${v}`));
886
+ console.log(style.bold("Project"));
887
+ row("root", config.root);
888
+ row("connection", `${config.connection} (${config.driver})`);
889
+ row("migrations", relative(config.root, config.migrationsDir));
890
+ console.log(style.bold("\nSchema"));
891
+ row(
892
+ "source",
893
+ `${relative(config.root, config.schemaPath)} (${config.schemaIsFile ? "file" : "directory"})`,
894
+ );
895
+ try {
896
+ const defs = await loadSchemas(config.schemaPath);
897
+ row(
898
+ "tables",
899
+ defs.length
900
+ ? `${plural(defs.length, "table")} — ${defs.map((t) => t.name).join(", ")}`
901
+ : "(none found)",
902
+ );
903
+ const dups = await duplicateTables(config.schemaPath);
904
+ if (dups.size) {
905
+ console.log(` ${fail(duplicateHeader(dups.size))}`);
906
+ for (const line of formatDuplicates(dups, config.root))
907
+ console.log(style.dim(` ${line}`));
908
+ process.exitCode = 1;
909
+ }
910
+ } catch (e) {
911
+ console.log(` ${fail(e instanceof Error ? e.message : String(e))}`);
912
+ }
913
+ // The connection params are driver-specific + opaque to the CLI — print them generically,
914
+ // redacting anything secret-looking (password/secret/token/key). The driver names the params.
915
+ console.log(style.bold("\nConnection"));
916
+ const secret = /pass|secret|token|key/i;
917
+ const params = Object.entries(config.params);
918
+ if (params.length) {
919
+ for (const [k, v] of params)
920
+ row(k, secret.test(k) ? "***" : String(v ?? ""));
921
+ } else {
922
+ row("params", "(none)");
923
+ }
924
+ console.log(style.bold("\nVersions"));
925
+ row("@schemic/core", program.version() ?? "?");
926
+ row("node", process.version);
927
+ console.log(style.bold("\nStatus"));
928
+ try {
929
+ const driver = activeDriver(config);
930
+ const db = await driver.connect(config, opts);
931
+ const info = driver.serverInfo
932
+ ? await driver.serverInfo(db)
933
+ : (config.driver ?? "surrealdb");
934
+ console.log(` ${ok(`connected — ${info}`)}`);
935
+ await driver.close(db);
936
+ } catch (e) {
937
+ console.log(` ${fail(e instanceof Error ? e.message : String(e))}`);
938
+ process.exitCode = 1;
939
+ }
940
+ });
941
+ });
942
+
943
+ dbFlags(
944
+ program
945
+ .command("rollback [count]")
946
+ .alias("down")
947
+ .description("Roll back applied migrations (last N, or back to --to <tag>)")
948
+ .option("--to <tag>", "roll back everything applied after this migration"),
949
+ ).action((count: string | undefined, opts: CommonOpts & { to?: string }) => {
950
+ run(() =>
951
+ withDb(opts, async (db, config) => {
952
+ const reverted = await rollback(db, config, {
953
+ to: opts.to,
954
+ count:
955
+ opts.to || count === undefined
956
+ ? undefined
957
+ : Math.max(1, Number.parseInt(count, 10) || 1),
958
+ });
959
+ if (!reverted.length) {
960
+ console.log(ok("Nothing to roll back."));
961
+ return;
962
+ }
963
+ for (const e of reverted) console.log(` ${style.yellow("↓")} ${e.tag}`);
964
+ console.log(
965
+ `\n${ok(`Rolled back ${plural(reverted.length, "migration")}.`)}`,
966
+ );
967
+ }),
968
+ );
969
+ });
970
+
971
+ configFlag(
972
+ program
973
+ .command("new <kind> <name>")
974
+ .description(
975
+ "Scaffold a new schema file for an entity, e.g. `sc new table user`",
976
+ ),
977
+ ).action((kind: string, name: string, opts: CommonOpts) => {
978
+ run(async () => {
979
+ const config = await resolveOne(opts);
980
+ const driver = activeDriver(config);
981
+ if (!driver.scaffoldEntity)
982
+ throw new Error(`the "${config.driver}" driver can't scaffold entities.`);
983
+ if (config.schemaIsFile)
984
+ throw new Error(
985
+ "`schemic new` needs a schema directory — your schema is a single file.",
986
+ );
987
+ // The driver authors the file (throws for a kind it can't); it lands under the kind's folder.
988
+ const content = driver.scaffoldEntity(kind, name);
989
+ const target = join(
990
+ config.schemaPath,
991
+ driver.registry.display(kind).folder,
992
+ `${name}.ts`,
993
+ );
994
+ if (existsSync(target))
995
+ throw new Error(`${relative(config.root, target)} already exists.`);
996
+ mkdirSync(dirname(target), { recursive: true });
997
+ writeFileSync(target, content);
998
+ console.log(
999
+ `${ok(relative(config.root, target))} ${style.dim("— author its fields, then `schemic gen`")}`,
1000
+ );
1001
+ });
1002
+ });
1003
+
1004
+ dbFlags(
1005
+ program.command("unlock").description("Clear a stale migration lock"),
1006
+ ).action((opts: CommonOpts) => {
1007
+ run(() =>
1008
+ withDb(opts, async (db, config) => {
1009
+ await unlock(db, config);
1010
+ console.log(ok("Migration lock cleared."));
1011
+ }),
1012
+ );
1013
+ });
1014
+
1015
+ kindFlags(
1016
+ dbFlags(
1017
+ program
1018
+ .command("push")
1019
+ .alias("sync")
1020
+ .description(
1021
+ "Reconcile the live database with your schema (no migration files)",
1022
+ )
1023
+ .option("--no-prune", "keep objects that were removed from the schema")
1024
+ .option("--dry-run", "preview the changes without applying them")
1025
+ .option("--watch", "re-sync on schema changes"),
1026
+ ),
1027
+ ).action(
1028
+ (
1029
+ opts: CommonOpts &
1030
+ FilterOpts & { prune?: boolean; dryRun?: boolean; watch?: boolean },
1031
+ ) => {
1032
+ run(async () => {
1033
+ const config = await resolveOne(opts);
1034
+ const driver = activeDriver(config);
1035
+ const filter = parseFilter(opts);
1036
+ const diffLive = driver.diffLive;
1037
+ const syncPlan = driver.syncPlan;
1038
+ if (!diffLive || !syncPlan)
1039
+ throw new Error(
1040
+ `the "${config.driver ?? "surrealdb"}" driver does not support \`push\`.`,
1041
+ );
1042
+ const once = async (db: unknown) => {
1043
+ const diff = await diffLive(db, config, filter);
1044
+ const stmts = syncPlan(diff, opts.prune);
1045
+ if (!stmts.length) {
1046
+ console.log(ok("Database already matches the schema."));
1047
+ return;
1048
+ }
1049
+ // With --no-prune, drops are kept in the DB — hide the remove items from the preview too.
1050
+ const items = (diff.items ?? []).filter(
1051
+ (it: DiffItem) => opts.prune !== false || it.op !== "remove",
1052
+ );
1053
+ console.log(formatItems(items));
1054
+ const kinds = summarizeKinds(driver.registry, items);
1055
+ if (opts.dryRun) {
1056
+ console.log(
1057
+ `\n${style.dim(`${plural(stmts.length, "change")}${kinds ? ` — ${kinds}` : ""} — run \`schemic push\` to apply.`)}`,
1058
+ );
1059
+ return;
1060
+ }
1061
+ await driver.apply(db, stmts);
1062
+ const pruned =
1063
+ opts.prune === false
1064
+ ? 0
1065
+ : (diff.items ?? []).filter((it) => it.op === "remove").length;
1066
+ console.log(
1067
+ `\n${ok(`synced ${plural(stmts.length - pruned, "object")}${pruned ? `, pruned ${pruned}` : ""}${kinds ? ` (${kinds})` : ""}.`)}`,
1068
+ );
1069
+ };
1070
+ if (!opts.watch) {
1071
+ await withDb(opts, (db) => once(db));
1072
+ return;
1073
+ }
1074
+ const db = await driver.connect(config, opts);
1075
+ await watchLoop(
1076
+ config,
1077
+ () => once(db),
1078
+ () => driver.close(db),
1079
+ );
1080
+ });
1081
+ },
1082
+ );
1083
+
1084
+ dbFlags(
1085
+ program.command("seed").description("Run the project's seed script"),
1086
+ ).action((opts: CommonOpts) => {
1087
+ run(() =>
1088
+ withDb(opts, async (db, config) => {
1089
+ await seed(db, config);
1090
+ console.log(ok("Seed complete."));
1091
+ }),
1092
+ );
1093
+ });
1094
+
1095
+ /** Print the per-file create/update diffs of a pull plan (unchanged files are omitted). */
1096
+ function printPullPlan(plan: PullPlan): void {
1097
+ for (const f of plan.files) {
1098
+ if (f.action === "unchanged") continue;
1099
+ console.log(`\n${actionLabel(f.action)} ${style.bold(f.rel)}`);
1100
+ if (f.action === "delete") {
1101
+ console.log(
1102
+ style.dim(
1103
+ ` whole file removed — ${f.localOnly.objects.join(", ")} not in the database`,
1104
+ ),
1105
+ );
1106
+ continue;
1107
+ }
1108
+ console.log(
1109
+ lineDiff(f.before, f.after)
1110
+ .split("\n")
1111
+ .map((l) => ` ${l}`)
1112
+ .join("\n"),
1113
+ );
1114
+ }
1115
+ }
1116
+
1117
+ /** List the local-only fields/objects a mirror pull would drop. */
1118
+ function printLocalOnly(files: PullFilePlan[]): void {
1119
+ console.log(`\n${style.yellow("! local-only schema, not in the database:")}`);
1120
+ for (const f of files) {
1121
+ for (const fld of f.localOnly.fields)
1122
+ console.log(
1123
+ style.dim(` ${f.rel}: ${fld.exportName} → ${fld.fields.join(", ")}`),
1124
+ );
1125
+ for (const obj of f.localOnly.objects)
1126
+ console.log(style.dim(` ${f.rel}: ${obj} (whole definition)`));
1127
+ }
1128
+ console.log(style.dim(" keep with --merge, or drop with --discard."));
1129
+ }
1130
+
1131
+ kindFlags(
1132
+ dbFlags(
1133
+ program
1134
+ .command("pull")
1135
+ .description("Generate/update Zod schema files from the live database")
1136
+ .option("--write", "apply the changes (default: preview only)")
1137
+ .option(
1138
+ "--merge",
1139
+ "keep local-only fields/objects (default: mirror the DB)",
1140
+ )
1141
+ .option(
1142
+ "--discard",
1143
+ "drop local-only fields/objects to mirror the DB exactly",
1144
+ ),
1145
+ ),
1146
+ ).action(
1147
+ (
1148
+ opts: CommonOpts &
1149
+ FilterOpts & { write?: boolean; merge?: boolean; discard?: boolean },
1150
+ ) => {
1151
+ run(() =>
1152
+ withDb(opts, async (db, config) => {
1153
+ const driver = activeDriver(config);
1154
+ if (!driver.planPull)
1155
+ throw new Error(
1156
+ `the "${config.driver ?? "surrealdb"}" driver does not support \`pull\`.`,
1157
+ );
1158
+ const plan = await driver.planPull(db, config, {
1159
+ filter: parseFilter(opts),
1160
+ keepLocal: opts.merge,
1161
+ });
1162
+ printPullPlan(plan);
1163
+
1164
+ const changed = plan.files.filter((f) => f.action !== "unchanged");
1165
+ // Local-only content is only "at risk" when we're not keeping it (--merge keeps it).
1166
+ const atRisk = opts.merge
1167
+ ? []
1168
+ : plan.files.filter(
1169
+ (f) => f.localOnly.fields.length || f.localOnly.objects.length,
1170
+ );
1171
+
1172
+ // "Already match" only when nothing would change AND there's no at-risk local-only schema
1173
+ // (a whole local-only entity is neither changed nor — under --merge — at risk, but it must
1174
+ // still be surfaced rather than silently reported as a match).
1175
+ if (!changed.length && !atRisk.length) {
1176
+ console.log(ok("Schema files already match the database."));
1177
+ return;
1178
+ }
1179
+
1180
+ if (!opts.write) {
1181
+ if (changed.length)
1182
+ console.log(
1183
+ `\n${style.dim(`${plural(changed.length, "file")} would change — run \`schemic pull --write\` to apply.`)}`,
1184
+ );
1185
+ if (atRisk.length) printLocalOnly(atRisk);
1186
+ return;
1187
+ }
1188
+
1189
+ // Don't silently destroy local-only schema (the git "commit or stash" guard).
1190
+ if (atRisk.length && !opts.discard) {
1191
+ printLocalOnly(atRisk);
1192
+ throw new Error(
1193
+ "pull would overwrite local-only schema — re-run with --merge to keep it or --discard to mirror the database.",
1194
+ );
1195
+ }
1196
+
1197
+ const written = applyPull(plan);
1198
+ // Baseline: sync the snapshot and record the pulled state as an already-applied migration, so
1199
+ // the schema matches the DB and `schemic diff` doesn't report the freshly-pulled objects as pending.
1200
+ const base = await baseline(db, config);
1201
+ const removed = plan.files.filter((f) => f.action === "delete").length;
1202
+ // Files we surfaced but couldn't safely delete (a local-only entity mixed with other code).
1203
+ const kept = opts.merge
1204
+ ? []
1205
+ : plan.files.filter(
1206
+ (f) => f.action === "unchanged" && f.localOnly.objects.length,
1207
+ );
1208
+ console.log(
1209
+ `\n${ok(`Pulled ${plural(written.length, "file")} from the database${removed ? ` (${removed} removed)` : ""}.`)}`,
1210
+ );
1211
+ if (base.created)
1212
+ console.log(
1213
+ style.dim(
1214
+ ` baseline ${base.tag} recorded (snapshot synced, marked applied).`,
1215
+ ),
1216
+ );
1217
+ if (kept.length)
1218
+ console.log(
1219
+ style.dim(
1220
+ ` ${plural(kept.length, "file")} with local-only entities mixed with other code left in place — remove those entities by hand.`,
1221
+ ),
1222
+ );
1223
+ }),
1224
+ );
1225
+ },
1226
+ );
1227
+
1228
+ if (process.argv.length <= 2) {
1229
+ program.outputHelp();
1230
+ process.exit(0);
1231
+ }
1232
+ program.parse();