@better-schemic/cli 0.1.0-alpha.1

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