@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.
package/lib/cli.js ADDED
@@ -0,0 +1,2014 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { spawnSync } from "child_process";
5
+ import {
6
+ existsSync as existsSync3,
7
+ watch as fsWatch,
8
+ mkdirSync as mkdirSync3,
9
+ writeFileSync as writeFileSync3
10
+ } from "fs";
11
+ import { dirname as dirname4, join as join3, relative as relative2 } from "path";
12
+ import { createInterface as createInterface2 } from "readline/promises";
13
+ import {
14
+ actionLabel,
15
+ applyPull,
16
+ duplicateTables,
17
+ EMPTY_STORED as EMPTY_STORED2,
18
+ existingTables,
19
+ fail as fail3,
20
+ formatDiff,
21
+ formatItems as formatItems2,
22
+ formatPatch,
23
+ getDriver as getDriver5,
24
+ isEmptyDiff as isEmptyDiff2,
25
+ kindFlags,
26
+ lineDiff,
27
+ listMigrations as listMigrations2,
28
+ loadDefs as loadDefs4,
29
+ loadSchemas,
30
+ lowerSchema as lowerSchema4,
31
+ ok as ok3,
32
+ parseFilter as parseFilter2,
33
+ pipeThroughPager,
34
+ plural as plural2,
35
+ readSnapshot as readSnapshot3,
36
+ resolvePager,
37
+ snapshotObjects as snapshotObjects3,
38
+ style as style6,
39
+ summarizeKinds,
40
+ unifiedDiff,
41
+ writeSnapshot as writeSnapshot2
42
+ } from "@better-schemic/core";
43
+ import { Command, Help, Option } from "commander";
44
+
45
+ // package.json
46
+ var version = "0.1.0-alpha.1";
47
+
48
+ // src/cli/action.ts
49
+ import { fail, style } from "@better-schemic/core";
50
+ var debugEnabled = () => !!process.env.BETTER_SCHEMIC_DEBUG || !!process.env.SCHEMIC_DEBUG;
51
+ var errMsg = (err) => err instanceof Error ? err.message : String(err);
52
+ function runAction(action) {
53
+ action().then(
54
+ () => process.exit(process.exitCode ?? 0),
55
+ (err) => {
56
+ console.error(`
57
+ ${fail(errMsg(err))}`);
58
+ if (debugEnabled() || process.argv.includes("--stack")) {
59
+ for (let e = err, depth = 0; e && depth < 8; depth++) {
60
+ console.error(
61
+ style.dim(e instanceof Error ? e.stack ?? String(e) : String(e))
62
+ );
63
+ e = e instanceof Error ? e.cause : void 0;
64
+ if (e) console.error(style.dim("caused by:"));
65
+ }
66
+ } else {
67
+ console.error(
68
+ style.dim(
69
+ "(re-run with BETTER_SCHEMIC_DEBUG=1 or --stack for the stack trace)"
70
+ )
71
+ );
72
+ }
73
+ process.exit(1);
74
+ }
75
+ );
76
+ }
77
+
78
+ // src/cli/driver-commands.ts
79
+ import { createInterface } from "readline/promises";
80
+ import {
81
+ envSecretProvider,
82
+ fail as fail2,
83
+ getDriver as getDriver2,
84
+ loadProject as loadProject2,
85
+ ok,
86
+ style as style3
87
+ } from "@better-schemic/core";
88
+
89
+ // src/cli/inspect.ts
90
+ import {
91
+ loadDefs,
92
+ lowerSchema,
93
+ readSnapshot,
94
+ snapshotObjects,
95
+ style as style2
96
+ } from "@better-schemic/core";
97
+
98
+ // src/cli/resolve.ts
99
+ import { readFileSync } from "fs";
100
+ import { createRequire } from "module";
101
+ import { dirname, join } from "path";
102
+ import { pathToFileURL } from "url";
103
+ import {
104
+ driverNames,
105
+ getDriver,
106
+ isConnectionEntry,
107
+ loadProject,
108
+ resolveConnectionConfig
109
+ } from "@better-schemic/core";
110
+ function pickCompiled(node) {
111
+ if (typeof node === "string") return node;
112
+ if (node && typeof node === "object") {
113
+ const o = node;
114
+ return pickCompiled(o.import) ?? pickCompiled(o.default) ?? pickCompiled(o.require);
115
+ }
116
+ return void 0;
117
+ }
118
+ function compiledEntry(pkg, base, subpath) {
119
+ try {
120
+ const manifestPath = createRequire(base).resolve(`${pkg}/package.json`);
121
+ const m = JSON.parse(readFileSync(manifestPath, "utf8"));
122
+ const exp = pickCompiled(m.exports?.[subpath]);
123
+ const rel = subpath === "." ? exp ?? m.module ?? m.main ?? "index.js" : exp;
124
+ return rel ? join(dirname(manifestPath), rel) : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+ async function ensureDriver(name) {
130
+ if (driverNames().includes(name)) return;
131
+ const pkgs = [`@better-schemic/${name}`, `@schemic/${name}`];
132
+ const pkg = pkgs[0];
133
+ let lastErr;
134
+ let loaded = false;
135
+ for (const candidate of pkgs) {
136
+ for (const base of [join(process.cwd(), "noop.js"), import.meta.url]) {
137
+ const entry = compiledEntry(candidate, base, "./driver");
138
+ if (!entry) continue;
139
+ try {
140
+ await import(pathToFileURL(entry).href);
141
+ loaded = true;
142
+ break;
143
+ } catch (e) {
144
+ lastErr = e;
145
+ }
146
+ }
147
+ if (loaded) break;
148
+ try {
149
+ await import(`${candidate}/driver`);
150
+ loaded = true;
151
+ break;
152
+ } catch (e) {
153
+ lastErr = e;
154
+ }
155
+ }
156
+ if (!loaded)
157
+ throw new Error(
158
+ `could not load the "${name}" database driver from ${pkg}/driver. Install it (and ensure it's >= 0.1.0-alpha.1, which exposes the /driver entry):
159
+ bun add ${pkg}
160
+ (${lastErr instanceof Error ? lastErr.message : String(lastErr)})`
161
+ );
162
+ if (!driverNames().includes(name))
163
+ throw new Error(
164
+ `package ${pkg}/driver did not register a "${name}" driver.`
165
+ );
166
+ }
167
+ function collectArg(value, prev) {
168
+ return [...prev, value];
169
+ }
170
+ function parseArgs(arg, argsJson) {
171
+ const out = {};
172
+ if (argsJson) {
173
+ try {
174
+ Object.assign(out, JSON.parse(argsJson));
175
+ } catch {
176
+ throw new Error(`--args must be a JSON object (got "${argsJson}").`);
177
+ }
178
+ }
179
+ for (const a of arg ?? []) {
180
+ const i = a.indexOf("=");
181
+ if (i < 0) throw new Error(`--arg must be key=value (got "${a}").`);
182
+ out[a.slice(0, i)] = a.slice(i + 1);
183
+ }
184
+ return out;
185
+ }
186
+ function splitAddress(address) {
187
+ const i = address.indexOf(":");
188
+ return i < 0 ? [address, void 0] : [address.slice(0, i), address.slice(i + 1)];
189
+ }
190
+ async function resolveTargets(opts) {
191
+ const { config, root } = await loadProject({ config: opts.config });
192
+ const args = parseArgs(opts.arg, opts.args);
193
+ const names = Object.keys(config.connections);
194
+ const opened = /* @__PURE__ */ new Map();
195
+ const resolving = /* @__PURE__ */ new Set();
196
+ const entryOf = (name) => {
197
+ const entry = config.connections[name];
198
+ if (!isConnectionEntry(entry))
199
+ throw new Error(
200
+ `No connection named "${name}". Known: ${names.join(", ") || "(none)"}.`
201
+ );
202
+ return entry;
203
+ };
204
+ const resolveOneConfig = async (name, key) => {
205
+ const entry = entryOf(name);
206
+ const list = await entry.resolve(ctx, args);
207
+ const picked = key !== void 0 ? list.find((c) => c.key === key) : list.length === 1 ? list[0] : void 0;
208
+ if (!picked) {
209
+ if (key !== void 0)
210
+ throw new Error(
211
+ `Connection "${name}" has no element with key "${key}".`
212
+ );
213
+ throw new Error(
214
+ `Connection "${name}" resolved to ${list.length} connections (a collection); address one with --connection ${name}:<key> or use --all.`
215
+ );
216
+ }
217
+ return resolveConnectionConfig(config, name, picked, entry.driver, root);
218
+ };
219
+ const openConnection = async (name) => {
220
+ const cached = opened.get(name);
221
+ if (cached) return cached;
222
+ if (resolving.has(name))
223
+ throw new Error(`Connection cycle detected while resolving "${name}".`);
224
+ resolving.add(name);
225
+ try {
226
+ const resolved = await resolveOneConfig(name);
227
+ await ensureDriver(resolved.driver);
228
+ const driver = getDriver(resolved.driver);
229
+ const conn = await driver.connect(resolved, opts);
230
+ const handle = { driver, conn, driverName: resolved.driver };
231
+ opened.set(name, handle);
232
+ return handle;
233
+ } finally {
234
+ resolving.delete(name);
235
+ }
236
+ };
237
+ const connections = new Proxy(
238
+ {},
239
+ {
240
+ get(_t, prop) {
241
+ if (typeof prop !== "string") return void 0;
242
+ return {
243
+ async query(sql, vars) {
244
+ const { driver, conn, driverName } = await openConnection(prop);
245
+ if (!driver.query)
246
+ throw new Error(
247
+ `the "${driverName}" driver has no \`query\` capability (needed by a connection resolver).`
248
+ );
249
+ return driver.query(conn, sql, vars);
250
+ }
251
+ };
252
+ }
253
+ }
254
+ );
255
+ const ctx = { connections, env: process.env };
256
+ const fanOut = async (name) => {
257
+ const entry = entryOf(name);
258
+ const list = await entry.resolve(ctx, args);
259
+ return list.map(
260
+ (conn) => resolveConnectionConfig(config, name, conn, entry.driver, root)
261
+ );
262
+ };
263
+ try {
264
+ let targets;
265
+ if (opts.all) {
266
+ targets = [];
267
+ for (const name of names) targets.push(...await fanOut(name));
268
+ } else if (opts.connection) {
269
+ const [name, key] = splitAddress(opts.connection);
270
+ targets = key !== void 0 ? [await resolveOneConfig(name, key)] : await fanOut(name);
271
+ } else {
272
+ const name = config.defaultConnection ?? (names.length === 1 ? names[0] : "default");
273
+ if (!config.connections[name])
274
+ throw new Error(
275
+ `No default connection. Set "defaultConnection" or pass --connection. Known: ${names.join(", ") || "(none)"}.`
276
+ );
277
+ targets = [await resolveOneConfig(name)];
278
+ }
279
+ if (!targets.length)
280
+ throw new Error("No connections matched \u2014 nothing to do.");
281
+ for (const driver of new Set(targets.map((t) => t.driver)))
282
+ await ensureDriver(driver);
283
+ return targets;
284
+ } finally {
285
+ for (const { driver, conn } of opened.values()) {
286
+ try {
287
+ await driver.close(conn);
288
+ } catch {
289
+ }
290
+ }
291
+ }
292
+ }
293
+ async function resolveOne(opts) {
294
+ if (opts.all)
295
+ throw new Error(
296
+ "--all is not supported here \u2014 this command operates on a single connection. Use --connection <name>."
297
+ );
298
+ const targets = await resolveTargets(opts);
299
+ if (targets.length !== 1)
300
+ throw new Error(
301
+ `--connection addressed ${targets.length} connections (a collection) \u2014 pin one with --connection <name>:<key>.`
302
+ );
303
+ return targets[0];
304
+ }
305
+
306
+ // src/cli/inspect.ts
307
+ function pickSource(opts) {
308
+ if (opts.snapshot && opts.live)
309
+ throw new Error("--snapshot and --live are mutually exclusive");
310
+ if (opts.snapshot) return "snapshot";
311
+ if (opts.live) return "live";
312
+ return "declared";
313
+ }
314
+ function engineMap(registry) {
315
+ return new Map(registry.entries());
316
+ }
317
+ function requireKind(registry, engines, kind) {
318
+ const engine = engines.get(kind);
319
+ if (!engine)
320
+ throw new Error(
321
+ `unknown kind "${kind}" \u2014 expected one of: ${registry.names().join(", ")}`
322
+ );
323
+ return engine;
324
+ }
325
+ function parentName(engine, obj) {
326
+ return (engine?.parent?.(obj) ?? engine?.owner?.(obj))?.name;
327
+ }
328
+ function addressOf(engine, obj) {
329
+ const parent = parentName(engine, obj);
330
+ return parent ? `${parent}.${obj.name}` : obj.name;
331
+ }
332
+ async function loadObjects(source, driver, config) {
333
+ if (source === "declared") {
334
+ const { tables, defs } = await loadDefs(config.schemaPath);
335
+ return lowerSchema(driver.registry, driver.explode(tables, defs));
336
+ }
337
+ if (source === "snapshot") {
338
+ try {
339
+ return snapshotObjects(readSnapshot(config.metaDir).schema);
340
+ } catch {
341
+ return [];
342
+ }
343
+ }
344
+ const conn = await driver.connect(config);
345
+ try {
346
+ return await driver.introspectAll(conn);
347
+ } finally {
348
+ await driver.close(conn);
349
+ }
350
+ }
351
+ function addressesOfKind(engine, kind, objects) {
352
+ return objects.filter((o) => o.kind === kind).map((o) => addressOf(engine, o)).sort();
353
+ }
354
+ async function lsKind(kind, engine, registry, driver, config, source, json) {
355
+ const objects = await loadObjects(source, driver, config);
356
+ const addresses = addressesOfKind(engine, kind, objects);
357
+ if (json) {
358
+ console.log(JSON.stringify({ kind, source, entities: addresses }, null, 2));
359
+ return;
360
+ }
361
+ console.log(
362
+ style2.bold(
363
+ `${registry.display(kind).plural} (${addresses.length}) \u2014 ${source}`
364
+ )
365
+ );
366
+ if (!addresses.length) console.log(style2.dim(" (none)"));
367
+ for (const address of addresses) console.log(` ${address}`);
368
+ }
369
+ async function infoKind(kind, address, engine, driver, config, source, json) {
370
+ const objects = await loadObjects(source, driver, config);
371
+ const found = objects.find(
372
+ (o) => o.kind === kind && addressOf(engine, o) === address
373
+ );
374
+ if (!found) throw new Error(`no ${kind} "${address}" in ${source}`);
375
+ if (json) {
376
+ console.log(
377
+ JSON.stringify(
378
+ { kind, address, source, ddl: engine.emit(found) },
379
+ null,
380
+ 2
381
+ )
382
+ );
383
+ return;
384
+ }
385
+ console.log(engine.emit(found).join("\n"));
386
+ }
387
+ async function overview(registry, driver, config, source, json) {
388
+ const objects = await loadObjects(source, driver, config);
389
+ const engines = engineMap(registry);
390
+ const rows = registry.names().map((kind) => ({
391
+ kind,
392
+ label: registry.display(kind).plural,
393
+ count: addressesOfKind(engines.get(kind), kind, objects).length
394
+ }));
395
+ if (json) {
396
+ console.log(JSON.stringify({ source, kinds: rows }, null, 2));
397
+ return;
398
+ }
399
+ console.log(style2.bold(`Schema overview \u2014 ${source}`));
400
+ const width = Math.max(0, ...rows.map((r) => r.label.length));
401
+ for (const r of rows) console.log(` ${r.label.padEnd(width)} ${r.count}`);
402
+ }
403
+ function registerInspectVerbs(program2, driver, groupFor, resolveOpts) {
404
+ const registry = driver.registry;
405
+ const engines = engineMap(registry);
406
+ const srcOpts = (c) => c.option("--snapshot", "inspect the metaDir baseline (last `gen`)").option("--live", "inspect the live database (introspection)").option("--json", "output JSON");
407
+ for (const kind of registry.names()) {
408
+ const engine = engines.get(kind);
409
+ const group = groupFor(kind);
410
+ srcOpts(
411
+ group.command("ls").summary(`list ${registry.display(kind).plural.toLowerCase()}`)
412
+ ).action(
413
+ (opts) => runAction(async () => {
414
+ const config = await resolveOne(resolveOpts());
415
+ await lsKind(
416
+ kind,
417
+ engine,
418
+ registry,
419
+ driver,
420
+ config,
421
+ pickSource(opts),
422
+ !!opts.json
423
+ );
424
+ })
425
+ );
426
+ srcOpts(
427
+ group.command("info <name>").summary(`show one ${kind}'s resolved definition`)
428
+ ).action(
429
+ (name, opts) => runAction(async () => {
430
+ const config = await resolveOne(resolveOpts());
431
+ await infoKind(
432
+ kind,
433
+ name,
434
+ engine,
435
+ driver,
436
+ config,
437
+ pickSource(opts),
438
+ !!opts.json
439
+ );
440
+ })
441
+ );
442
+ }
443
+ srcOpts(
444
+ program2.command("ls [kind]").summary("overview of all kinds, or list one kind's entities").description(
445
+ "With no KIND: a cross-kind overview (kinds + counts). With a KIND: that kind's entities \u2014 the verb-first form of `sc <kind> ls`. `--snapshot`/`--live` switch the source (default: declared)."
446
+ )
447
+ ).action(
448
+ (kind, opts) => runAction(async () => {
449
+ const config = await resolveOne(resolveOpts());
450
+ if (kind)
451
+ await lsKind(
452
+ kind,
453
+ requireKind(registry, engines, kind),
454
+ registry,
455
+ driver,
456
+ config,
457
+ pickSource(opts),
458
+ !!opts.json
459
+ );
460
+ else
461
+ await overview(registry, driver, config, pickSource(opts), !!opts.json);
462
+ })
463
+ );
464
+ srcOpts(
465
+ program2.command("info <kind> <name>").summary(
466
+ "show one entity's resolved definition (verb-first form of `sc <kind> info`)"
467
+ )
468
+ ).action(
469
+ (kind, name, opts) => runAction(async () => {
470
+ const config = await resolveOne(resolveOpts());
471
+ await infoKind(
472
+ kind,
473
+ name,
474
+ requireKind(registry, engines, kind),
475
+ driver,
476
+ config,
477
+ pickSource(opts),
478
+ !!opts.json
479
+ );
480
+ })
481
+ );
482
+ }
483
+
484
+ // src/cli/driver-commands.ts
485
+ var io = {
486
+ ok: (m) => console.log(ok(m)),
487
+ fail: (m) => console.error(fail2(m)),
488
+ info: (m) => console.log(m),
489
+ async prompt(question, opts) {
490
+ if (!process.stdin.isTTY)
491
+ throw new Error(
492
+ `"${question}" needs an interactive terminal (no value was provided non-interactively)`
493
+ );
494
+ const rl = createInterface({
495
+ input: process.stdin,
496
+ output: process.stdout
497
+ });
498
+ try {
499
+ if (opts?.hidden) {
500
+ const out = process.stdout;
501
+ const onData = () => out.write("\x1B[2K\r");
502
+ process.stdin.on("data", onData);
503
+ try {
504
+ return (await rl.question(`${question} `)).trim();
505
+ } finally {
506
+ process.stdin.off("data", onData);
507
+ }
508
+ }
509
+ return (await rl.question(`${question} `)).trim();
510
+ } finally {
511
+ rl.close();
512
+ }
513
+ }
514
+ };
515
+ function configFromArgv(argv) {
516
+ for (let i = 0; i < argv.length; i++) {
517
+ if (argv[i] === "-c" || argv[i] === "--config") return argv[i + 1];
518
+ if (argv[i].startsWith("--config="))
519
+ return argv[i].slice("--config=".length);
520
+ }
521
+ return void 0;
522
+ }
523
+ function toParsedArgs(cmd, positionals, opts) {
524
+ const flags = {};
525
+ for (const f of cmd.args?.flags ?? []) {
526
+ const v = opts[f.name];
527
+ flags[f.name] = f.value ? v : v === true;
528
+ if (f.required && (v === void 0 || v === false))
529
+ throw new Error(`\`sc ${cmd.kind} ${cmd.verb}\` requires --${f.name}`);
530
+ }
531
+ return { positionals, flags };
532
+ }
533
+ function addCommand(kindGroup, cmd, resolveOpts) {
534
+ const usage = (cmd.args?.positionals ?? []).map((p) => p.required ? `<${p.name}>` : `[${p.name}]`).join(" ");
535
+ const verb = kindGroup.command(`${cmd.verb} [args...]`).summary(cmd.summary).description(usage ? `${cmd.summary}
536
+
537
+ Arguments: ${usage}` : cmd.summary);
538
+ for (const f of cmd.args?.flags ?? []) {
539
+ const flag = f.value ? `--${f.name} <value>` : `--${f.name}`;
540
+ verb.option(flag, f.help ?? "");
541
+ }
542
+ verb.action(
543
+ (args, opts) => runAction(async () => {
544
+ const parsed = toParsedArgs(cmd, args, opts);
545
+ const config = await resolveOne(resolveOpts());
546
+ const driver = getDriver2(config.driver);
547
+ const conn = await driver.connect(config);
548
+ const ctx = {
549
+ conn,
550
+ config,
551
+ io,
552
+ secrets: envSecretProvider
553
+ };
554
+ try {
555
+ await cmd.run(ctx, parsed);
556
+ } finally {
557
+ await driver.close(conn);
558
+ }
559
+ })
560
+ );
561
+ }
562
+ async function registerDriverCommands(program2) {
563
+ let driverName;
564
+ const configPath = configFromArgv(process.argv.slice(2));
565
+ try {
566
+ const { config } = await loadProject2({ config: configPath });
567
+ const entry = Object.values(config.connections)[0];
568
+ if (!entry?.driver) return;
569
+ driverName = entry.driver;
570
+ } catch {
571
+ return;
572
+ }
573
+ try {
574
+ await ensureDriver(driverName);
575
+ } catch {
576
+ return;
577
+ }
578
+ const driver = getDriver2(driverName);
579
+ const groups = /* @__PURE__ */ new Map();
580
+ const groupFor = (kind) => {
581
+ let g = groups.get(kind);
582
+ if (!g) {
583
+ g = program2.command(kind).summary(`inspect + manage ${style3.bold(kind)} resources`).description(
584
+ `\`${kind}\` commands: the universal \`ls\`/\`info\` (any kind) plus any driver verbs from @better-schemic/${driverName}.`
585
+ );
586
+ groups.set(kind, g);
587
+ }
588
+ return g;
589
+ };
590
+ const resolveOpts = () => {
591
+ const opts = program2.opts();
592
+ return { connection: opts.connection, config: opts.config ?? configPath };
593
+ };
594
+ registerInspectVerbs(program2, driver, groupFor, resolveOpts);
595
+ for (const cmd of driver.commands ?? []) {
596
+ addCommand(groupFor(cmd.kind), cmd, resolveOpts);
597
+ }
598
+ }
599
+
600
+ // src/cli/init.ts
601
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
602
+ import { dirname as dirname2, resolve } from "path";
603
+ function initialSnapshot(driver) {
604
+ return `${JSON.stringify(
605
+ {
606
+ version: 3,
607
+ driver,
608
+ schema: { kinds: {} },
609
+ files: {}
610
+ },
611
+ null,
612
+ 2
613
+ )}
614
+ `;
615
+ }
616
+ function init(cwd, driver) {
617
+ const scaffold = driver.initScaffold?.();
618
+ if (!scaffold)
619
+ throw new Error(
620
+ `the "${driver.name}" driver does not support \`better-schemic init\` scaffolding.`
621
+ );
622
+ const files = {
623
+ ...scaffold,
624
+ "database/migrations/meta/_snapshot.json": initialSnapshot(driver.name)
625
+ };
626
+ const created = [];
627
+ const skipped = [];
628
+ for (const [rel, content] of Object.entries(files)) {
629
+ const abs = resolve(cwd, rel);
630
+ if (existsSync(abs)) {
631
+ skipped.push(rel);
632
+ continue;
633
+ }
634
+ mkdirSync(dirname2(abs), { recursive: true });
635
+ writeFileSync(abs, content);
636
+ created.push(rel);
637
+ }
638
+ return { created, skipped };
639
+ }
640
+
641
+ // src/cli/migrate.ts
642
+ import {
643
+ existsSync as existsSync2,
644
+ mkdirSync as mkdirSync2,
645
+ readdirSync,
646
+ readFileSync as readFileSync2,
647
+ rmSync,
648
+ statSync,
649
+ writeFileSync as writeFileSync2
650
+ } from "fs";
651
+ import { dirname as dirname3, join as join2, relative, resolve as resolve2 } from "path";
652
+ import {
653
+ buildKindDiff,
654
+ checksum,
655
+ EMPTY_STORED,
656
+ filterKinds,
657
+ getDriver as getDriver3,
658
+ intersectKinds,
659
+ isEmptyDiff,
660
+ listMigrations,
661
+ loadDefs as loadDefs2,
662
+ lowerSchema as lowerSchema2,
663
+ makeJiti,
664
+ mergeStored,
665
+ parseFilter,
666
+ readSnapshot as readSnapshot2,
667
+ slug,
668
+ snapshotKinds,
669
+ snapshotObjects as snapshotObjects2,
670
+ style as style4,
671
+ timestamp,
672
+ writeSnapshot
673
+ } from "@better-schemic/core";
674
+ function buildStored(driver, tables, defs, opts = {}) {
675
+ const objects = lowerSchema2(driver.registry, driver.explode(tables, defs));
676
+ const files = {};
677
+ const rel = (abs) => opts.root ? relative(opts.root, abs) : abs;
678
+ for (const t of tables) {
679
+ const abs = opts.fileOf?.get(t);
680
+ if (abs) files[t.name] = rel(abs);
681
+ }
682
+ for (const d of defs) {
683
+ const abs = opts.fileOf?.get(d);
684
+ const key = d.kind === "event" ? d.table : d.name;
685
+ if (abs && key) files[key] = rel(abs);
686
+ }
687
+ return {
688
+ version: 3,
689
+ driver: driver.name,
690
+ schema: snapshotKinds(objects, driver.registry),
691
+ files
692
+ };
693
+ }
694
+ function migStore(config) {
695
+ const driver = getDriver3(config.driver ?? "surrealdb");
696
+ if (!driver.migrations)
697
+ throw new Error(
698
+ `The "${driver.name}" driver does not support running migrations.`
699
+ );
700
+ return driver.migrations;
701
+ }
702
+ var migExt = (config) => getDriver3(config.driver ?? "surrealdb").migrations?.extension ?? ".surql";
703
+ function attachFiles(diff, prevFiles, nextFiles) {
704
+ for (const it of diff.items ?? []) {
705
+ const primary = it.op === "remove" ? prevFiles : nextFiles;
706
+ it.file = primary[it.table] ?? nextFiles[it.table] ?? prevFiles[it.table];
707
+ }
708
+ }
709
+ async function planMigration(config, filter = parseFilter({}), opts = {}) {
710
+ const { tables, defs, fileOf } = await loadDefs2(config.schemaPath);
711
+ const driver = getDriver3(config.driver ?? "surrealdb");
712
+ const reg = driver.registry;
713
+ const next = buildStored(driver, tables, defs, { fileOf, root: config.root });
714
+ const prev = opts.baseline ? EMPTY_STORED : readSnapshot2(config.metaDir);
715
+ const diff = buildKindDiff(
716
+ reg,
717
+ filterKinds(reg, snapshotObjects2(prev.schema), filter),
718
+ filterKinds(reg, snapshotObjects2(next.schema), filter)
719
+ );
720
+ attachFiles(diff, prev.files ?? {}, next.files ?? {});
721
+ return { diff, next: mergeStored(reg, prev, next, filter) };
722
+ }
723
+ function nextTag(migrationsDir, name, ext) {
724
+ const s = slug(name);
725
+ const date = /* @__PURE__ */ new Date();
726
+ let tag = `${timestamp(date)}_${s}`;
727
+ while (existsSync2(join2(migrationsDir, `${tag}${ext}`))) {
728
+ date.setUTCSeconds(date.getUTCSeconds() + 1);
729
+ tag = `${timestamp(date)}_${s}`;
730
+ }
731
+ return tag;
732
+ }
733
+ function prepareMigration(config, plan, name) {
734
+ const { diff, next } = plan;
735
+ if (isEmptyDiff(diff)) return null;
736
+ mkdirSync2(config.migrationsDir, { recursive: true });
737
+ const ext = migExt(config);
738
+ const tag = nextTag(config.migrationsDir, name ?? "migration", ext);
739
+ return {
740
+ tag,
741
+ file: `${tag}${ext}`,
742
+ content: migStore(config).render(tag, diff),
743
+ next,
744
+ up: diff.up.length,
745
+ down: diff.down.length
746
+ };
747
+ }
748
+ function renderMigrationPreview(config, diff) {
749
+ return migStore(config).render("(unnamed)", diff);
750
+ }
751
+ function commitMigration(config, prepared) {
752
+ mkdirSync2(config.migrationsDir, { recursive: true });
753
+ writeFileSync2(join2(config.migrationsDir, prepared.file), prepared.content);
754
+ writeSnapshot(config.metaDir, prepared.next);
755
+ return {
756
+ created: true,
757
+ tag: prepared.tag,
758
+ file: prepared.file,
759
+ up: prepared.up,
760
+ down: prepared.down
761
+ };
762
+ }
763
+ async function recordApplied(db, config, m) {
764
+ await migStore(config).record(db, config.migrationsTable, {
765
+ tag: m.tag,
766
+ file: m.file,
767
+ checksum: checksum(m.content)
768
+ });
769
+ }
770
+ async function baseline(db, config, filter = parseFilter({})) {
771
+ const driver = getDriver3(config.driver ?? "surrealdb");
772
+ const reg = driver.registry;
773
+ const live = await driver.introspectAll(
774
+ db,
775
+ /* @__PURE__ */ new Set([config.migrationsTable, `${config.migrationsTable}_lock`])
776
+ );
777
+ const { tables, defs, fileOf } = await loadDefs2(config.schemaPath);
778
+ const disk = buildStored(driver, tables, defs, {
779
+ fileOf,
780
+ root: config.root
781
+ });
782
+ const pulledObjects = intersectKinds(
783
+ reg,
784
+ snapshotObjects2(disk.schema),
785
+ live,
786
+ filter
787
+ );
788
+ const pulled = {
789
+ version: 3,
790
+ driver: driver.name,
791
+ schema: snapshotKinds(pulledObjects, driver.registry),
792
+ files: disk.files
793
+ };
794
+ const prev = readSnapshot2(config.metaDir);
795
+ const diff = buildKindDiff(
796
+ reg,
797
+ filterKinds(reg, snapshotObjects2(prev.schema), filter),
798
+ pulledObjects
799
+ );
800
+ attachFiles(diff, prev.files ?? {}, pulled.files ?? {});
801
+ const plan = {
802
+ diff,
803
+ next: mergeStored(reg, prev, pulled, filter)
804
+ };
805
+ const prepared = prepareMigration(config, plan, "baseline");
806
+ if (!prepared) {
807
+ writeSnapshot(config.metaDir, plan.next);
808
+ return { created: false };
809
+ }
810
+ const res = commitMigration(config, prepared);
811
+ await recordApplied(db, config, prepared);
812
+ return res;
813
+ }
814
+ function clearMigrationFiles(config) {
815
+ const migs = listMigrations(config.migrationsDir, migExt(config));
816
+ for (const m of migs)
817
+ rmSync(join2(config.migrationsDir, m.file), { force: true });
818
+ return migs.map((m) => m.tag);
819
+ }
820
+ async function reconcileBaseline(db, config, prepared, drift) {
821
+ const mig = migStore(config);
822
+ await mig.ensure(db, config.migrationsTable);
823
+ if (drift) return "pending";
824
+ await mig.clear(db, config.migrationsTable);
825
+ await recordApplied(db, config, prepared);
826
+ return "applied";
827
+ }
828
+ function indexOfTag(migrations, tag) {
829
+ const i = migrations.findIndex((m) => m.tag === tag);
830
+ if (i < 0) throw new Error(`Unknown migration: ${tag}`);
831
+ return i;
832
+ }
833
+ async function unlock(db, config) {
834
+ await migStore(config).unlock(db, config.migrationsTable);
835
+ }
836
+ async function migrate(db, config, opts = {}) {
837
+ const mig = migStore(config);
838
+ const table = config.migrationsTable;
839
+ await mig.ensure(db, table);
840
+ const migrations = listMigrations(config.migrationsDir, migExt(config));
841
+ const applied = await mig.applied(db, table);
842
+ let pending = migrations.filter((m) => !applied.has(m.tag));
843
+ if (opts.to) {
844
+ const targetIdx = indexOfTag(migrations, opts.to);
845
+ const pos = new Map(migrations.map((m, i) => [m.tag, i]));
846
+ pending = pending.filter((m) => (pos.get(m.tag) ?? 0) <= targetIdx);
847
+ }
848
+ if (opts.count !== void 0)
849
+ pending = pending.slice(0, Math.max(0, opts.count));
850
+ await mig.lock(db, table);
851
+ try {
852
+ for (const m of pending) {
853
+ const content = readFileSync2(join2(config.migrationsDir, m.file), "utf8");
854
+ await mig.apply(db, table, {
855
+ content,
856
+ direction: "up",
857
+ record: { tag: m.tag, file: m.file, checksum: checksum(content) }
858
+ });
859
+ }
860
+ } finally {
861
+ await mig.unlock(db, table);
862
+ }
863
+ return { applied: pending };
864
+ }
865
+ function currentChecksum(config, m) {
866
+ const path = join2(config.migrationsDir, m.file);
867
+ if (!existsSync2(path)) return "";
868
+ return checksum(readFileSync2(path, "utf8"));
869
+ }
870
+ async function status(db, config) {
871
+ const mig = migStore(config);
872
+ await mig.ensure(db, config.migrationsTable);
873
+ const applied = await mig.applied(db, config.migrationsTable);
874
+ const files = listMigrations(config.migrationsDir, migExt(config));
875
+ const fileTags = new Set(files.map((m) => m.tag));
876
+ const rows = files.map((m) => {
877
+ const appliedSum = applied.get(m.tag);
878
+ return {
879
+ tag: m.tag,
880
+ applied: appliedSum !== void 0,
881
+ drift: appliedSum !== void 0 && appliedSum !== currentChecksum(config, m)
882
+ };
883
+ });
884
+ for (const tag of applied.keys())
885
+ if (!fileTags.has(tag)) rows.push({ tag, applied: true, missing: true });
886
+ return rows.sort((a, b) => a.tag.localeCompare(b.tag));
887
+ }
888
+ async function rollback(db, config, opts = {}) {
889
+ const mig = migStore(config);
890
+ const table = config.migrationsTable;
891
+ await mig.ensure(db, table);
892
+ const migrations = listMigrations(config.migrationsDir, migExt(config));
893
+ const applied = await mig.applied(db, table);
894
+ const appliedMigrations = migrations.filter((m) => applied.has(m.tag));
895
+ let toRevert;
896
+ if (opts.to) {
897
+ const targetIdx = indexOfTag(migrations, opts.to);
898
+ const pos = new Map(migrations.map((m, i) => [m.tag, i]));
899
+ toRevert = appliedMigrations.filter((m) => (pos.get(m.tag) ?? 0) > targetIdx).reverse();
900
+ } else {
901
+ toRevert = appliedMigrations.slice(-(opts.count ?? 1)).reverse();
902
+ }
903
+ await mig.lock(db, table);
904
+ try {
905
+ for (const m of toRevert) {
906
+ const content = readFileSync2(join2(config.migrationsDir, m.file), "utf8");
907
+ await mig.apply(db, table, {
908
+ content,
909
+ direction: "down",
910
+ record: { tag: m.tag, file: m.file, checksum: checksum(content) }
911
+ });
912
+ }
913
+ } finally {
914
+ await mig.unlock(db, table);
915
+ }
916
+ return toRevert;
917
+ }
918
+ var SEED_EXT = /\.(ts|mts|cts|js|mjs|cjs)$/;
919
+ var SEED_ORDER_PREFIX = /^\d+[-_]/;
920
+ function seedName(file) {
921
+ return file.replace(SEED_EXT, "").replace(SEED_ORDER_PREFIX, "");
922
+ }
923
+ function locateSeeds(config) {
924
+ const base = resolve2(config.root, config.seed ?? "database/seed");
925
+ if (existsSync2(base) && statSync(base).isDirectory()) {
926
+ let index;
927
+ const scripts = [];
928
+ for (const f of readdirSync(base).sort()) {
929
+ if (f.startsWith("_") || f.endsWith(".d.ts") || !SEED_EXT.test(f))
930
+ continue;
931
+ const path = join2(base, f);
932
+ if (statSync(path).isDirectory()) continue;
933
+ if (seedName(f) === "index") index = path;
934
+ else scripts.push({ name: seedName(f), path });
935
+ }
936
+ return { dir: { base, scripts, index } };
937
+ }
938
+ const file = SEED_EXT.test(base) ? base : `${base}.ts`;
939
+ return { file: existsSync2(file) ? file : base };
940
+ }
941
+ async function runSeedFile(db, path) {
942
+ if (!existsSync2(path)) throw new Error(`Seed file not found: ${path}`);
943
+ const mod = await makeJiti().import(path);
944
+ const fn = typeof mod.default === "function" ? mod.default : mod.seed;
945
+ if (typeof fn !== "function")
946
+ throw new Error(
947
+ `Seed "${relative(process.cwd(), path)}" must export a default function \`(db, ctx) => \u2026\`.`
948
+ );
949
+ const dir = dirname3(path);
950
+ const ctx = {
951
+ dir,
952
+ file: (name) => readFileSync2(resolve2(dir, name), "utf8")
953
+ };
954
+ await fn(db, ctx);
955
+ }
956
+ async function seed(db, config, opts = {}) {
957
+ const { dir, file } = locateSeeds(config);
958
+ if (!dir) {
959
+ if (opts.name)
960
+ throw new Error(
961
+ `--name needs a seed folder (database/seed/); found a single seed file instead.`
962
+ );
963
+ await runSeedFile(db, file);
964
+ return;
965
+ }
966
+ const { base, scripts, index } = dir;
967
+ const run2 = async (s) => {
968
+ console.log(style4.dim(` \u2192 ${s.name}`));
969
+ await runSeedFile(db, s.path);
970
+ };
971
+ if (opts.name) {
972
+ const match = scripts.find((s) => s.name === opts.name);
973
+ if (!match)
974
+ throw new Error(
975
+ `No seed named "${opts.name}". Available: ${scripts.map((s) => s.name).join(", ") || "(none)"}.`
976
+ );
977
+ await run2(match);
978
+ return;
979
+ }
980
+ if (index && !opts.all) {
981
+ await runSeedFile(db, index);
982
+ return;
983
+ }
984
+ if (!scripts.length)
985
+ throw new Error(`No seeds found in ${relative(process.cwd(), base)}/.`);
986
+ for (const s of scripts) await run2(s);
987
+ }
988
+
989
+ // src/cli/portable-diff.ts
990
+ import {
991
+ buildKindDiff as buildKindDiff2,
992
+ formatItems,
993
+ getDriver as getDriver4,
994
+ loadDefs as loadDefs3,
995
+ lowerSchema as lowerSchema3,
996
+ ok as ok2,
997
+ plural,
998
+ style as style5
999
+ } from "@better-schemic/core";
1000
+ async function portableDiff(config, driverName, opts = {}) {
1001
+ const driver = getDriver4(driverName);
1002
+ const reg = driver.registry;
1003
+ const { tables, defs } = await loadDefs3(config.schemaPath);
1004
+ const desired = lowerSchema3(
1005
+ reg,
1006
+ driver.explode(tables, defs)
1007
+ );
1008
+ const conn = await driver.connect(config);
1009
+ let live;
1010
+ try {
1011
+ live = await driver.introspectAll(
1012
+ conn,
1013
+ /* @__PURE__ */ new Set([config.migrationsTable, `${config.migrationsTable}_lock`])
1014
+ );
1015
+ } finally {
1016
+ await closeQuietly(conn);
1017
+ }
1018
+ const diff = buildKindDiff2(reg, live, desired);
1019
+ if (opts.json) {
1020
+ console.log(
1021
+ JSON.stringify({ driver: driverName, up: diff.up, down: diff.down })
1022
+ );
1023
+ return;
1024
+ }
1025
+ console.log(style5.dim(` driver: ${driverName}`));
1026
+ if (!diff.up.length) {
1027
+ console.log(ok2("Schema is in sync with the target database."));
1028
+ return;
1029
+ }
1030
+ const items = diff.items ?? [];
1031
+ console.log(formatItems(items));
1032
+ const n = (op) => items.filter((i) => i.op === op).length;
1033
+ console.log(
1034
+ style5.dim(
1035
+ `
1036
+ ${plural(items.length, "change")} \u2014 ${n("add")} added, ${n("change")} changed, ${n("remove")} removed.`
1037
+ )
1038
+ );
1039
+ }
1040
+ async function closeQuietly(conn) {
1041
+ const close = conn?.close;
1042
+ if (typeof close === "function") {
1043
+ try {
1044
+ await close.call(conn);
1045
+ } catch {
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ // src/cli/index.ts
1051
+ var activeDriver = (config) => getDriver5(config.driver);
1052
+ async function withDb(opts, fn) {
1053
+ const targets = await resolveTargets(opts);
1054
+ for (const config of targets) {
1055
+ if (targets.length > 1) console.log(style6.bold(`
1056
+ [${config.connection}]`));
1057
+ const driver = getDriver5(config.driver);
1058
+ const db = await driver.connect(config, opts);
1059
+ try {
1060
+ await fn(db, config);
1061
+ } finally {
1062
+ await driver.close(db);
1063
+ }
1064
+ }
1065
+ }
1066
+ var errMsg2 = (err) => err instanceof Error ? err.message : String(err);
1067
+ function formatDuplicates(dups, root) {
1068
+ return [...dups].map(([name, files]) => {
1069
+ const counts = /* @__PURE__ */ new Map();
1070
+ for (const f of files) counts.set(f, (counts.get(f) ?? 0) + 1);
1071
+ const label = [...counts].map(([f, n]) => {
1072
+ const rel = relative2(root, f);
1073
+ return n > 1 ? `${rel} (\xD7${n})` : rel;
1074
+ }).join(", ");
1075
+ return `${name} \u2014 ${label}`;
1076
+ });
1077
+ }
1078
+ var duplicateHeader = (n) => `${plural2(n, "table")} defined more than once (last definition silently wins):`;
1079
+ function run(action) {
1080
+ runAction(action);
1081
+ }
1082
+ async function promptTitle() {
1083
+ if (!process.stdin.isTTY) return void 0;
1084
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
1085
+ try {
1086
+ const answer = (await rl.question("Migration title: ")).trim();
1087
+ return answer || void 0;
1088
+ } finally {
1089
+ rl.close();
1090
+ }
1091
+ }
1092
+ async function confirmPrompt(question) {
1093
+ if (!process.stdin.isTTY) return false;
1094
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
1095
+ try {
1096
+ const a = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
1097
+ return a === "y" || a === "yes";
1098
+ } finally {
1099
+ rl.close();
1100
+ }
1101
+ }
1102
+ function diffSummary(registry, diff, opts, pending) {
1103
+ const summary = [];
1104
+ if (!isEmptyDiff2(diff)) {
1105
+ const kinds = summarizeKinds(registry, diff.items ?? []);
1106
+ summary.push(
1107
+ `${plural2(diff.up.length, "change")} ${opts.live ? "vs the live database" : "vs the snapshot"}${kinds ? ` \u2014 ${kinds}` : ""}.`
1108
+ );
1109
+ }
1110
+ if (pending !== void 0)
1111
+ summary.push(`${plural2(pending, "migration")} pending.`);
1112
+ return summary.length ? `
1113
+ ${style6.dim(summary.join("\n"))}` : "";
1114
+ }
1115
+ function reportDiff(registry, diff, opts, pending) {
1116
+ console.log(
1117
+ formatDiff(diff, { down: opts.down, full: opts.full, inline: opts.inline })
1118
+ );
1119
+ const summary = diffSummary(registry, diff, opts, pending);
1120
+ if (summary) console.log(summary);
1121
+ }
1122
+ function watchLoop(config, task, cleanup) {
1123
+ return new Promise(() => {
1124
+ let timer;
1125
+ let running = false;
1126
+ let queued = false;
1127
+ const fire = async () => {
1128
+ if (running) {
1129
+ queued = true;
1130
+ return;
1131
+ }
1132
+ running = true;
1133
+ console.log(style6.dim(`
1134
+ \u2014 ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \u2014`));
1135
+ try {
1136
+ await task();
1137
+ } catch (err) {
1138
+ console.error(fail3(errMsg2(err)));
1139
+ }
1140
+ running = false;
1141
+ if (queued) {
1142
+ queued = false;
1143
+ void fire();
1144
+ }
1145
+ };
1146
+ const watcher = fsWatch(
1147
+ config.schemaPath,
1148
+ { recursive: !config.schemaIsFile },
1149
+ () => {
1150
+ clearTimeout(timer);
1151
+ timer = setTimeout(() => void fire(), 150);
1152
+ }
1153
+ );
1154
+ console.log(
1155
+ style6.dim(
1156
+ `Watching ${relative2(config.root, config.schemaPath)} for changes \u2014 ctrl-c to stop.`
1157
+ )
1158
+ );
1159
+ void fire();
1160
+ const stop = () => {
1161
+ watcher.close();
1162
+ clearTimeout(timer);
1163
+ Promise.resolve(cleanup?.()).finally(() => process.exit(0));
1164
+ };
1165
+ process.once("SIGINT", stop);
1166
+ process.once("SIGTERM", stop);
1167
+ });
1168
+ }
1169
+ var configFlag = (cmd) => cmd.option("-c, --config <path>", "path to better-schemic.config.ts");
1170
+ var dbFlags = (cmd) => configFlag(cmd).option(
1171
+ "--connection <name>",
1172
+ "target a specific connection (or <name>:<key> within a collection)"
1173
+ ).option("--all", "run against every connection (collections fanned out)").option(
1174
+ "--arg <key=value>",
1175
+ "value passed to connection resolvers (repeatable)",
1176
+ collectArg,
1177
+ []
1178
+ ).option(
1179
+ "--args <json>",
1180
+ "resolver args as one JSON object (--arg k=v merges over it)"
1181
+ ).option("--url <url>", "override the connection endpoint").option("--namespace <ns>", "override the namespace").option("--database <db>", "override the database").option("--username <user>", "override the auth username").option("--password <pass>", "override the auth password").addOption(
1182
+ new Option("--auth-level <level>", "auth level").choices([
1183
+ "root",
1184
+ "namespace",
1185
+ "database"
1186
+ ])
1187
+ );
1188
+ var program = new Command();
1189
+ program.name("better-schemic").description(
1190
+ "Schema-as-code migrations for any database \u2014 generate DDL, diff, and migrate via drivers"
1191
+ ).version(version).showHelpAfterError("(run `better-schemic --help` for usage)").addHelpText(
1192
+ "after",
1193
+ `
1194
+ Examples:
1195
+ $ better-schemic init scaffold database/ (schemas + migrations) + config
1196
+ $ better-schemic gen add_users create a migration from schema changes
1197
+ $ better-schemic migrate apply pending migrations
1198
+ $ better-schemic push --watch keep the database in sync while you edit
1199
+ $ better-schemic diff --live show how the schema differs from the live database
1200
+ `
1201
+ );
1202
+ program.configureHelp({
1203
+ visibleOptions(cmd) {
1204
+ const opts = Help.prototype.visibleOptions.call(this, cmd);
1205
+ const negated = new Set(
1206
+ opts.filter((o) => o.negate).map((o) => o.attributeName())
1207
+ );
1208
+ this._collapsible = new Set(
1209
+ [...negated].filter(
1210
+ (n) => opts.some((o) => !o.negate && o.attributeName() === n)
1211
+ )
1212
+ );
1213
+ return opts.filter(
1214
+ (o) => !(o.negate && this._collapsible?.has(o.attributeName()))
1215
+ );
1216
+ },
1217
+ optionTerm(option) {
1218
+ const term = Help.prototype.optionTerm.call(this, option);
1219
+ if (option.negate) return term.replace("--no-", "--[no-]");
1220
+ if (this._collapsible?.has(option.attributeName()))
1221
+ return term.replace(`--${option.name()}`, `--[no-]${option.name()}`);
1222
+ return term;
1223
+ }
1224
+ });
1225
+ program.command("init").description("Scaffold database/ (schemas + migrations) and a config file").option(
1226
+ "--driver <name>",
1227
+ "database driver to scaffold for (default surrealdb)"
1228
+ ).action((opts) => {
1229
+ run(async () => {
1230
+ const name = opts.driver ?? "surrealdb";
1231
+ try {
1232
+ await ensureDriver(name);
1233
+ } catch (e) {
1234
+ if (process.env.BETTER_SCHEMIC_NO_BOOTSTRAP ?? process.env.SCHEMIC_NO_BOOTSTRAP)
1235
+ throw e;
1236
+ console.log(
1237
+ style6.dim(
1238
+ "This project isn't set up for Better-schemic yet \u2014 bootstrapping with create-better-schemic\u2026\n"
1239
+ )
1240
+ );
1241
+ const runner = process.versions.bun ? ["bun", "x"] : ["npx", "-y"];
1242
+ const r = spawnSync(
1243
+ runner[0],
1244
+ [...runner.slice(1), "create-better-schemic", ".", "--driver", name],
1245
+ { stdio: "inherit", cwd: process.cwd() }
1246
+ );
1247
+ process.exit(r.status ?? 1);
1248
+ }
1249
+ const { created, skipped } = init(process.cwd(), getDriver5(name));
1250
+ for (const f of created) console.log(` ${style6.green("+")} ${f}`);
1251
+ for (const f of skipped)
1252
+ console.log(style6.dim(` \xB7 ${f} (exists, skipped)`));
1253
+ console.log(
1254
+ created.length ? `
1255
+ ${ok3("Initialized. Edit database/schema, then run `better-schemic gen`.")}` : "\nNothing to do \u2014 already initialized."
1256
+ );
1257
+ });
1258
+ });
1259
+ kindFlags(
1260
+ dbFlags(
1261
+ program.command("diff").description("Show pending schema changes without writing a migration")
1262
+ )
1263
+ ).option("--down", "also show the rollback (down) statements").option("--live", "diff against the live database instead of the snapshot").option("--ts", "show the change as TypeScript schema instead of DDL").option("--watch", "re-run on schema changes").option("--full", "show the full schema SQL, not just the changed parts").option(
1264
+ "-p, --patch",
1265
+ "output a unified diff (e.g. to pipe to a diff viewer)"
1266
+ ).option(
1267
+ "--pager [cmd]",
1268
+ "page through your git diff viewer (or <cmd>); off by default"
1269
+ ).option(
1270
+ "--inline",
1271
+ "render changes as an inline word-diff instead of separate -/+ lines"
1272
+ ).option("--json", "output the diff as JSON").option(
1273
+ "--driver <name>",
1274
+ "target database driver (default from config, or 'surreal')"
1275
+ ).action(
1276
+ (opts) => {
1277
+ run(async () => {
1278
+ const config = await resolveOne(opts);
1279
+ const driverName = opts.driver ?? config.driver ?? "surrealdb";
1280
+ await ensureDriver(driverName);
1281
+ const driver = getDriver5(driverName);
1282
+ const diffLive = driver.diffLive;
1283
+ if (!diffLive) {
1284
+ await portableDiff(config, driverName, { json: opts.json });
1285
+ return;
1286
+ }
1287
+ const filter = parseFilter2(opts);
1288
+ const pager = opts.watch || opts.pager === void 0 || opts.pager === false ? void 0 : typeof opts.pager === "string" ? opts.pager : resolvePager();
1289
+ const emit = async (diff, pending) => {
1290
+ if (opts.json) {
1291
+ console.log(
1292
+ JSON.stringify({ up: diff.up, down: diff.down, pending })
1293
+ );
1294
+ } else if ((opts.patch || pager) && !isEmptyDiff2(diff)) {
1295
+ const patch = formatPatch(diff);
1296
+ if (pager) await pipeThroughPager(pager, patch);
1297
+ else process.stdout.write(patch);
1298
+ const summary = diffSummary(driver.registry, diff, opts, pending);
1299
+ if (summary) console.log(summary);
1300
+ } else {
1301
+ reportDiff(driver.registry, diff, opts, pending);
1302
+ }
1303
+ };
1304
+ const persistent = opts.watch && opts.live ? await driver.connect(config, opts) : void 0;
1305
+ const once = async () => {
1306
+ if (opts.ts) {
1307
+ const loc = await existingTables(config.schemaPath);
1308
+ const fileFor = (kind, name) => {
1309
+ const abs = kind === "table" ? loc.get(name) : void 0;
1310
+ return abs ? relative2(config.root, abs) : relative2(
1311
+ config.root,
1312
+ join3(
1313
+ config.schemaPath,
1314
+ driver.registry.display(kind).folder,
1315
+ `${name}.ts`
1316
+ )
1317
+ );
1318
+ };
1319
+ const single = config.schemaIsFile ? relative2(config.root, config.schemaPath) : void 0;
1320
+ const showTsDiff = async (cur, des, matchMsg) => {
1321
+ if (opts.json) {
1322
+ console.log(
1323
+ JSON.stringify({
1324
+ current: Object.fromEntries(cur),
1325
+ desired: Object.fromEntries(des)
1326
+ })
1327
+ );
1328
+ return;
1329
+ }
1330
+ const files = [.../* @__PURE__ */ new Set([...cur.keys(), ...des.keys()])].sort();
1331
+ const changed = files.filter(
1332
+ (f) => (cur.get(f) ?? "") !== (des.get(f) ?? "")
1333
+ );
1334
+ if (!changed.length) {
1335
+ console.log(ok3(matchMsg));
1336
+ } else if (pager || opts.patch) {
1337
+ const patch = changed.map(
1338
+ (f) => unifiedDiff(cur.get(f) ?? "", des.get(f) ?? "", f)
1339
+ ).join("");
1340
+ if (pager) await pipeThroughPager(pager, patch);
1341
+ else process.stdout.write(patch);
1342
+ } else {
1343
+ console.log(
1344
+ changed.map(
1345
+ (f) => `${style6.bold(f)}
1346
+ ${lineDiff(cur.get(f) ?? "", des.get(f) ?? "")}`
1347
+ ).join("\n\n")
1348
+ );
1349
+ }
1350
+ };
1351
+ if (opts.live) {
1352
+ if (!driver.diffTsLive)
1353
+ throw new Error(
1354
+ `the "${driverName}" driver does not support \`diff --ts --live\`.`
1355
+ );
1356
+ const db = persistent ?? await driver.connect(config, opts);
1357
+ try {
1358
+ const { current, desired } = await driver.diffTsLive(
1359
+ db,
1360
+ config,
1361
+ filter,
1362
+ fileFor,
1363
+ single
1364
+ );
1365
+ await showTsDiff(
1366
+ current,
1367
+ desired,
1368
+ "Schema matches the live database."
1369
+ );
1370
+ } finally {
1371
+ if (!persistent) await driver.close(db);
1372
+ }
1373
+ } else {
1374
+ if (!driver.renderSchema)
1375
+ throw new Error(
1376
+ `the "${driverName}" driver does not support \`diff --ts\`.`
1377
+ );
1378
+ const prev = readSnapshot3(config.metaDir);
1379
+ const prevObjects = snapshotObjects3(prev.schema);
1380
+ const { tables, defs } = await loadDefs4(config.schemaPath);
1381
+ const desiredObjects = lowerSchema4(
1382
+ driver.registry,
1383
+ driver.explode(tables, defs)
1384
+ );
1385
+ await showTsDiff(
1386
+ driver.renderSchema(prevObjects, filter, fileFor, single),
1387
+ driver.renderSchema(desiredObjects, filter, fileFor, single),
1388
+ prevObjects.length ? "Schema matches the snapshot." : "No schema to render."
1389
+ );
1390
+ }
1391
+ return;
1392
+ }
1393
+ if (opts.live) {
1394
+ const db = persistent ?? await driver.connect(config, opts);
1395
+ try {
1396
+ const diff = await diffLive(db, config, filter);
1397
+ const pending = (await status(db, config)).filter(
1398
+ (r) => !r.applied
1399
+ ).length;
1400
+ await emit(diff, pending);
1401
+ } finally {
1402
+ if (!persistent) await driver.close(db);
1403
+ }
1404
+ } else {
1405
+ await emit((await planMigration(config, filter)).diff);
1406
+ }
1407
+ };
1408
+ if (!opts.watch) return once();
1409
+ await watchLoop(
1410
+ config,
1411
+ once,
1412
+ persistent ? () => driver.close(persistent) : void 0
1413
+ );
1414
+ });
1415
+ }
1416
+ );
1417
+ var genAction = (name, opts) => {
1418
+ run(async () => {
1419
+ const config = await resolveOne(opts);
1420
+ const filter = parseFilter2(opts);
1421
+ let squashed = null;
1422
+ if (opts.baseline) {
1423
+ const existing = listMigrations2(
1424
+ config.migrationsDir,
1425
+ activeDriver(config).migrations?.extension ?? ".surql"
1426
+ );
1427
+ if (existing.length) {
1428
+ const migDir = relative2(config.root, config.migrationsDir);
1429
+ const proceed = opts.force || await confirmPrompt(
1430
+ `Replace ${plural2(existing.length, "migration")} in ${migDir} with a single baseline?`
1431
+ );
1432
+ if (!proceed) {
1433
+ throw new Error(
1434
+ `${plural2(existing.length, "migration")} already exist in ${migDir} \u2014 a baseline would re-define objects they already created.
1435
+ Re-run \`better-schemic gen --baseline --force\` to replace them with one fresh baseline.`
1436
+ );
1437
+ }
1438
+ squashed = clearMigrationFiles(config);
1439
+ }
1440
+ }
1441
+ const plan = await planMigration(config, filter, {
1442
+ baseline: opts.baseline
1443
+ });
1444
+ if (isEmptyDiff2(plan.diff)) {
1445
+ console.log(ok3("No schema changes \u2014 nothing to generate."));
1446
+ return;
1447
+ }
1448
+ const kinds = summarizeKinds(
1449
+ activeDriver(config).registry,
1450
+ plan.diff.items ?? []
1451
+ );
1452
+ console.log(
1453
+ `${plural2(plan.diff.up.length, "change")}${kinds ? ` \u2014 ${kinds}` : ""}.`
1454
+ );
1455
+ const body = renderMigrationPreview(config, plan.diff).replace(/\n+$/, "").split("\n").map((line) => ` ${line}`).join("\n");
1456
+ console.log(`
1457
+ ${style6.dim(body)}
1458
+ `);
1459
+ const title = name ?? (opts.baseline ? "baseline" : opts.yes ? void 0 : await promptTitle());
1460
+ const prepared = prepareMigration(config, plan, title);
1461
+ if (!prepared) {
1462
+ console.log(ok3("No schema changes \u2014 nothing to generate."));
1463
+ return;
1464
+ }
1465
+ const res = commitMigration(config, prepared);
1466
+ console.log(
1467
+ `${ok3(res.file ?? "migration written")} ${style6.dim(`(+${res.up} up / ${res.down} down)`)}`
1468
+ );
1469
+ if (squashed) {
1470
+ console.log(
1471
+ style6.dim(` replaced ${plural2(squashed.length, "migration")}.`)
1472
+ );
1473
+ try {
1474
+ const driver = activeDriver(config);
1475
+ const diffLive = driver.diffLive;
1476
+ if (!diffLive)
1477
+ throw new Error(
1478
+ `the "${config.driver ?? "surrealdb"}" driver does not support live reconcile`
1479
+ );
1480
+ const db = await driver.connect(config, opts);
1481
+ try {
1482
+ const drift = !isEmptyDiff2(await diffLive(db, config, filter));
1483
+ const state = await reconcileBaseline(db, config, prepared, drift);
1484
+ console.log(
1485
+ style6.dim(
1486
+ state === "applied" ? " database matched the schema \u2014 baseline recorded as applied." : " database differs from the schema \u2014 baseline left pending; run `better-schemic migrate`."
1487
+ )
1488
+ );
1489
+ } finally {
1490
+ await driver.close(db);
1491
+ }
1492
+ } catch (e) {
1493
+ console.log(
1494
+ style6.dim(
1495
+ ` database not reconciled (${errMsg2(e)}) \u2014 baseline is pending; run \`better-schemic migrate\` to apply it.`
1496
+ )
1497
+ );
1498
+ }
1499
+ }
1500
+ });
1501
+ };
1502
+ var addGenCommand = (cmd) => {
1503
+ kindFlags(dbFlags(cmd)).option("-y, --yes", "use the given/default name without prompting").option(
1504
+ "--baseline",
1505
+ "regenerate one fresh baseline from an empty snapshot (replaces existing migrations)"
1506
+ ).option(
1507
+ "--force",
1508
+ "with --baseline, replace existing migrations without confirmation"
1509
+ ).action(genAction);
1510
+ };
1511
+ addGenCommand(
1512
+ program.command("gen [name]").description("Diff schemas, preview the migration script, and write it")
1513
+ );
1514
+ addGenCommand(program.command("generate [name]", { hidden: true }));
1515
+ var snapshot = program.command("snapshot").description(
1516
+ "Manage the migration snapshot (what `better-schemic gen`/`better-schemic diff` compare against)"
1517
+ );
1518
+ configFlag(
1519
+ snapshot.command("reset").description(
1520
+ "Clear the snapshot \u2014 the next `better-schemic gen` baselines the full schema"
1521
+ )
1522
+ ).action((opts) => {
1523
+ run(async () => {
1524
+ const config = await resolveOne(opts);
1525
+ writeSnapshot2(config.metaDir, EMPTY_STORED2);
1526
+ console.log(ok3("Snapshot cleared."));
1527
+ const existing = listMigrations2(
1528
+ config.migrationsDir,
1529
+ activeDriver(config).migrations?.extension ?? ".surql"
1530
+ );
1531
+ if (existing.length) {
1532
+ console.log(
1533
+ style6.dim(
1534
+ ` ${plural2(existing.length, "migration")} still on disk \u2014 run \`better-schemic gen --baseline --force\` to replace them with one fresh baseline. (A plain \`better-schemic gen\` would add a baseline alongside them.)`
1535
+ )
1536
+ );
1537
+ } else {
1538
+ console.log(
1539
+ style6.dim(
1540
+ " The next `better-schemic gen` will baseline the full schema."
1541
+ )
1542
+ );
1543
+ }
1544
+ });
1545
+ });
1546
+ dbFlags(
1547
+ program.command("migrate [count]").alias("up").description(
1548
+ "Apply pending migrations (all, the next N, or up to --to <tag>)"
1549
+ ).option("--to <tag>", "apply up to and including this migration")
1550
+ ).action((count, opts) => {
1551
+ run(
1552
+ () => withDb(opts, async (db, config) => {
1553
+ const n = count === void 0 ? void 0 : Math.max(1, Number.parseInt(count, 10) || 1);
1554
+ const { applied } = await migrate(db, config, { count: n, to: opts.to });
1555
+ if (!applied.length) {
1556
+ console.log(ok3("Up to date \u2014 no pending migrations."));
1557
+ return;
1558
+ }
1559
+ for (const e of applied) console.log(` ${style6.green("\u2191")} ${e.tag}`);
1560
+ console.log(`
1561
+ ${ok3(`Applied ${plural2(applied.length, "migration")}.`)}`);
1562
+ })
1563
+ );
1564
+ });
1565
+ dbFlags(
1566
+ program.command("status").description("Show applied vs pending migrations")
1567
+ ).option("--json", "output the status as JSON").action((opts) => {
1568
+ run(
1569
+ () => withDb(opts, async (db, config) => {
1570
+ const rows = await status(db, config);
1571
+ if (opts.json) {
1572
+ console.log(JSON.stringify(rows));
1573
+ return;
1574
+ }
1575
+ if (!rows.length) {
1576
+ console.log("No migrations yet. Run `better-schemic gen`.");
1577
+ return;
1578
+ }
1579
+ for (const r of rows) {
1580
+ if (r.missing) {
1581
+ console.log(
1582
+ ` ${style6.yellow("\u26A0 missing")} ${r.tag} ${style6.dim("(applied in the DB, file deleted)")}`
1583
+ );
1584
+ } else if (r.drift) {
1585
+ console.log(
1586
+ ` ${style6.yellow("\u26A0 drift")} ${r.tag} ${style6.dim("(file changed after apply)")}`
1587
+ );
1588
+ } else if (r.applied) {
1589
+ console.log(` ${style6.green("\u2713 applied")} ${r.tag}`);
1590
+ } else {
1591
+ console.log(style6.dim(` \xB7 pending ${r.tag}`));
1592
+ }
1593
+ }
1594
+ const pending = rows.filter((r) => !r.applied).length;
1595
+ const drifted = rows.filter((r) => r.drift).length;
1596
+ const missing = rows.filter((r) => r.missing).length;
1597
+ const parts = [plural2(rows.length, "migration"), `${pending} pending`];
1598
+ if (drifted) parts.push(`${drifted} drifted`);
1599
+ if (missing) parts.push(`${missing} missing`);
1600
+ console.log(`
1601
+ ${style6.dim(`${parts.join(", ")}.`)}`);
1602
+ if (missing) {
1603
+ console.log(
1604
+ style6.dim(
1605
+ " Missing migrations were applied but their files are gone (e.g. after removing migrations or `snapshot reset`)."
1606
+ )
1607
+ );
1608
+ }
1609
+ })
1610
+ );
1611
+ });
1612
+ dbFlags(
1613
+ program.command("check").description(
1614
+ "Validate schemas, then replay migrations to confirm they reproduce the schema"
1615
+ ).option(
1616
+ "--schema",
1617
+ "validate the schema only \u2014 skip the migration replay (no database)"
1618
+ )
1619
+ ).action((opts) => {
1620
+ run(async () => {
1621
+ const config = await resolveOne(opts);
1622
+ const driver = activeDriver(config);
1623
+ const dups = await duplicateTables(config.schemaPath);
1624
+ if (dups.size) {
1625
+ const lines = formatDuplicates(dups, config.root).map((l) => ` ${l}`);
1626
+ throw new Error(`${duplicateHeader(dups.size)}
1627
+ ${lines.join("\n")}`);
1628
+ }
1629
+ const { tables, defs } = await loadDefs4(config.schemaPath);
1630
+ const kinds = summarizeKinds(
1631
+ driver.registry,
1632
+ lowerSchema4(driver.registry, driver.explode(tables, defs))
1633
+ );
1634
+ console.log(ok3(`Schemas valid${kinds ? ` \u2014 ${kinds}` : " (no objects)"}.`));
1635
+ if (opts.schema) return;
1636
+ if (!driver.checkReplay) {
1637
+ throw new Error(
1638
+ `the "${config.driver ?? "surrealdb"}" driver does not support migration replay \u2014 run \`better-schemic check --schema\` to validate the schema only.`
1639
+ );
1640
+ }
1641
+ const diff = await driver.checkReplay(
1642
+ config,
1643
+ opts,
1644
+ parseFilter2({}),
1645
+ (m) => console.log(style6.dim(m))
1646
+ );
1647
+ if (isEmptyDiff2(diff)) {
1648
+ console.log(ok3("Migrations reproduce the schema."));
1649
+ return;
1650
+ }
1651
+ console.log(
1652
+ `
1653
+ ${fail3("Drift \u2014 migrations do not reproduce the schema:")}
1654
+ `
1655
+ );
1656
+ console.log(formatDiff(diff, {}));
1657
+ console.log(
1658
+ `
1659
+ ${style6.dim(`${summarizeKinds(driver.registry, diff.items ?? [])} differ. \`better-schemic gen\` writes a migration to reconcile.`)}`
1660
+ );
1661
+ process.exitCode = 1;
1662
+ });
1663
+ });
1664
+ dbFlags(
1665
+ program.command("doctor").description("Print resolved config and test the connection")
1666
+ ).action((opts) => {
1667
+ run(async () => {
1668
+ const config = await resolveOne(opts);
1669
+ const row = (k, v) => console.log(style6.dim(` ${k.padEnd(11)} ${v}`));
1670
+ console.log(style6.bold("Project"));
1671
+ row("root", config.root);
1672
+ row("connection", `${config.connection} (${config.driver})`);
1673
+ row("migrations", relative2(config.root, config.migrationsDir));
1674
+ console.log(style6.bold("\nSchema"));
1675
+ row(
1676
+ "source",
1677
+ `${relative2(config.root, config.schemaPath)} (${config.schemaIsFile ? "file" : "directory"})`
1678
+ );
1679
+ try {
1680
+ const defs = await loadSchemas(config.schemaPath);
1681
+ row(
1682
+ "tables",
1683
+ defs.length ? `${plural2(defs.length, "table")} \u2014 ${defs.map((t) => t.name).join(", ")}` : "(none found)"
1684
+ );
1685
+ const dups = await duplicateTables(config.schemaPath);
1686
+ if (dups.size) {
1687
+ console.log(` ${fail3(duplicateHeader(dups.size))}`);
1688
+ for (const line of formatDuplicates(dups, config.root))
1689
+ console.log(style6.dim(` ${line}`));
1690
+ process.exitCode = 1;
1691
+ }
1692
+ } catch (e) {
1693
+ console.log(` ${fail3(e instanceof Error ? e.message : String(e))}`);
1694
+ }
1695
+ console.log(style6.bold("\nConnection"));
1696
+ const secret = /pass|secret|token|key/i;
1697
+ const params = Object.entries(config.params);
1698
+ if (params.length) {
1699
+ for (const [k, v] of params)
1700
+ row(k, secret.test(k) ? "***" : String(v ?? ""));
1701
+ } else {
1702
+ row("params", "(none)");
1703
+ }
1704
+ console.log(style6.bold("\nVersions"));
1705
+ row("@better-schemic/core", program.version() ?? "?");
1706
+ row("node", process.version);
1707
+ console.log(style6.bold("\nStatus"));
1708
+ try {
1709
+ const driver = activeDriver(config);
1710
+ const db = await driver.connect(config, opts);
1711
+ const info = driver.serverInfo ? await driver.serverInfo(db) : config.driver ?? "surrealdb";
1712
+ console.log(` ${ok3(`connected \u2014 ${info}`)}`);
1713
+ await driver.close(db);
1714
+ } catch (e) {
1715
+ console.log(` ${fail3(e instanceof Error ? e.message : String(e))}`);
1716
+ process.exitCode = 1;
1717
+ }
1718
+ });
1719
+ });
1720
+ dbFlags(
1721
+ program.command("rollback [count]").alias("down").description("Roll back applied migrations (last N, or back to --to <tag>)").option("--to <tag>", "roll back everything applied after this migration")
1722
+ ).action((count, opts) => {
1723
+ run(
1724
+ () => withDb(opts, async (db, config) => {
1725
+ const reverted = await rollback(db, config, {
1726
+ to: opts.to,
1727
+ count: opts.to || count === void 0 ? void 0 : Math.max(1, Number.parseInt(count, 10) || 1)
1728
+ });
1729
+ if (!reverted.length) {
1730
+ console.log(ok3("Nothing to roll back."));
1731
+ return;
1732
+ }
1733
+ for (const e of reverted) console.log(` ${style6.yellow("\u2193")} ${e.tag}`);
1734
+ console.log(
1735
+ `
1736
+ ${ok3(`Rolled back ${plural2(reverted.length, "migration")}.`)}`
1737
+ );
1738
+ })
1739
+ );
1740
+ });
1741
+ configFlag(
1742
+ program.command("new <kind> <name>").description(
1743
+ "Scaffold a new schema file for an entity, e.g. `sc new table user`"
1744
+ )
1745
+ ).action((kind, name, opts) => {
1746
+ run(async () => {
1747
+ const config = await resolveOne(opts);
1748
+ const driver = activeDriver(config);
1749
+ if (!driver.scaffoldEntity)
1750
+ throw new Error(`the "${config.driver}" driver can't scaffold entities.`);
1751
+ if (config.schemaIsFile)
1752
+ throw new Error(
1753
+ "`better-schemic new` needs a schema directory \u2014 your schema is a single file."
1754
+ );
1755
+ const content = driver.scaffoldEntity(kind, name);
1756
+ const target = join3(
1757
+ config.schemaPath,
1758
+ driver.registry.display(kind).folder,
1759
+ `${name}.ts`
1760
+ );
1761
+ if (existsSync3(target))
1762
+ throw new Error(`${relative2(config.root, target)} already exists.`);
1763
+ mkdirSync3(dirname4(target), { recursive: true });
1764
+ writeFileSync3(target, content);
1765
+ console.log(
1766
+ `${ok3(relative2(config.root, target))} ${style6.dim("\u2014 author its fields, then `better-schemic gen`")}`
1767
+ );
1768
+ });
1769
+ });
1770
+ dbFlags(
1771
+ program.command("unlock").description("Clear a stale migration lock")
1772
+ ).action((opts) => {
1773
+ run(
1774
+ () => withDb(opts, async (db, config) => {
1775
+ await unlock(db, config);
1776
+ console.log(ok3("Migration lock cleared."));
1777
+ })
1778
+ );
1779
+ });
1780
+ kindFlags(
1781
+ dbFlags(
1782
+ program.command("push").alias("sync").description(
1783
+ "Reconcile the live database with your schema (no migration files)"
1784
+ ).option("--no-prune", "keep objects that were removed from the schema").option("--dry-run", "preview the changes without applying them").option("--watch", "re-sync on schema changes")
1785
+ )
1786
+ ).action(
1787
+ (opts) => {
1788
+ run(async () => {
1789
+ const config = await resolveOne(opts);
1790
+ const driver = activeDriver(config);
1791
+ const filter = parseFilter2(opts);
1792
+ const diffLive = driver.diffLive;
1793
+ const syncPlan = driver.syncPlan;
1794
+ if (!diffLive || !syncPlan)
1795
+ throw new Error(
1796
+ `the "${config.driver ?? "surrealdb"}" driver does not support \`push\`.`
1797
+ );
1798
+ const once = async (db2) => {
1799
+ const diff = await diffLive(db2, config, filter);
1800
+ const stmts = syncPlan(diff, opts.prune);
1801
+ if (!stmts.length) {
1802
+ console.log(ok3("Database already matches the schema."));
1803
+ return;
1804
+ }
1805
+ const items = (diff.items ?? []).filter(
1806
+ (it) => opts.prune !== false || it.op !== "remove"
1807
+ );
1808
+ console.log(formatItems2(items));
1809
+ const kinds = summarizeKinds(driver.registry, items);
1810
+ if (opts.dryRun) {
1811
+ console.log(
1812
+ `
1813
+ ${style6.dim(`${plural2(stmts.length, "change")}${kinds ? ` \u2014 ${kinds}` : ""} \u2014 run \`better-schemic push\` to apply.`)}`
1814
+ );
1815
+ return;
1816
+ }
1817
+ await driver.apply(db2, stmts);
1818
+ const pruned = opts.prune === false ? 0 : (diff.items ?? []).filter((it) => it.op === "remove").length;
1819
+ console.log(
1820
+ `
1821
+ ${ok3(`synced ${plural2(stmts.length - pruned, "object")}${pruned ? `, pruned ${pruned}` : ""}${kinds ? ` (${kinds})` : ""}.`)}`
1822
+ );
1823
+ };
1824
+ if (!opts.watch) {
1825
+ await withDb(opts, (db2) => once(db2));
1826
+ return;
1827
+ }
1828
+ const db = await driver.connect(config, opts);
1829
+ await watchLoop(
1830
+ config,
1831
+ () => once(db),
1832
+ () => driver.close(db)
1833
+ );
1834
+ });
1835
+ }
1836
+ );
1837
+ dbFlags(
1838
+ program.command("seed [name]").description(
1839
+ "Run the project's seed(s): a named seed, --all (every seed), or (no arg) index.ts / every seed"
1840
+ )
1841
+ // NOTE: `--all` comes from dbFlags (every connection) and doubles as "every seed" here — do NOT add
1842
+ // a second `--all` option (commander throws a conflicting-flag error at construction).
1843
+ ).action((name, opts) => {
1844
+ run(
1845
+ () => withDb(opts, async (db, config) => {
1846
+ await seed(db, config, { name, all: opts.all });
1847
+ console.log(ok3("Seed complete."));
1848
+ })
1849
+ );
1850
+ });
1851
+ function printPullPlan(plan) {
1852
+ for (const f of plan.files) {
1853
+ if (f.action === "unchanged") continue;
1854
+ console.log(`
1855
+ ${actionLabel(f.action)} ${style6.bold(f.rel)}`);
1856
+ if (f.action === "delete") {
1857
+ console.log(
1858
+ style6.dim(
1859
+ ` whole file removed \u2014 ${f.localOnly.objects.join(", ")} not in the database`
1860
+ )
1861
+ );
1862
+ continue;
1863
+ }
1864
+ console.log(
1865
+ lineDiff(f.before, f.after).split("\n").map((l) => ` ${l}`).join("\n")
1866
+ );
1867
+ }
1868
+ }
1869
+ function printLocalOnly(files) {
1870
+ console.log(`
1871
+ ${style6.yellow("! local-only schema, not in the database:")}`);
1872
+ for (const f of files) {
1873
+ for (const fld of f.localOnly.fields)
1874
+ console.log(
1875
+ style6.dim(` ${f.rel}: ${fld.exportName} \u2192 ${fld.fields.join(", ")}`)
1876
+ );
1877
+ for (const obj of f.localOnly.objects)
1878
+ console.log(style6.dim(` ${f.rel}: ${obj} (whole definition)`));
1879
+ }
1880
+ console.log(style6.dim(" keep with --merge, or drop with --discard."));
1881
+ }
1882
+ async function pullPass(db, config, opts, watch) {
1883
+ const driver = activeDriver(config);
1884
+ if (!driver.planPull)
1885
+ throw new Error(
1886
+ `the "${config.driver ?? "surrealdb"}" driver does not support \`pull\`.`
1887
+ );
1888
+ const plan = await driver.planPull(db, config, {
1889
+ filter: parseFilter2(opts),
1890
+ keepLocal: opts.merge
1891
+ });
1892
+ const changed = plan.files.filter((f) => f.action !== "unchanged");
1893
+ const atRisk = opts.merge ? [] : plan.files.filter(
1894
+ (f) => f.localOnly.fields.length || f.localOnly.objects.length
1895
+ );
1896
+ if (!changed.length && !atRisk.length) return false;
1897
+ printPullPlan(plan);
1898
+ if (!opts.write) {
1899
+ if (changed.length)
1900
+ console.log(
1901
+ `
1902
+ ${style6.dim(`${plural2(changed.length, "file")} would change \u2014 run \`better-schemic pull --write\` to apply.`)}`
1903
+ );
1904
+ if (atRisk.length) printLocalOnly(atRisk);
1905
+ return true;
1906
+ }
1907
+ if (atRisk.length && !opts.discard) {
1908
+ printLocalOnly(atRisk);
1909
+ const msg = "pull would overwrite local-only schema \u2014 re-run with --merge to keep it or --discard to mirror the database.";
1910
+ if (!watch) throw new Error(msg);
1911
+ console.error(fail3(msg));
1912
+ return true;
1913
+ }
1914
+ const written = applyPull(plan);
1915
+ const base = await baseline(db, config);
1916
+ const removed = plan.files.filter((f) => f.action === "delete").length;
1917
+ const kept = opts.merge ? [] : plan.files.filter(
1918
+ (f) => f.action === "unchanged" && f.localOnly.objects.length
1919
+ );
1920
+ console.log(
1921
+ `
1922
+ ${ok3(`Pulled ${plural2(written.length, "file")} from the database${removed ? ` (${removed} removed)` : ""}.`)}`
1923
+ );
1924
+ if (base.created)
1925
+ console.log(
1926
+ style6.dim(
1927
+ ` baseline ${base.tag} recorded (snapshot synced, marked applied).`
1928
+ )
1929
+ );
1930
+ if (kept.length)
1931
+ console.log(
1932
+ style6.dim(
1933
+ ` ${plural2(kept.length, "file")} with local-only entities mixed with other code left in place \u2014 remove those entities by hand.`
1934
+ )
1935
+ );
1936
+ return true;
1937
+ }
1938
+ function pullPollLoop(config, db, intervalMs, opts) {
1939
+ return new Promise(() => {
1940
+ const driver = activeDriver(config);
1941
+ console.log(
1942
+ style6.dim(
1943
+ `Polling ${config.connection ?? "the database"} every ${intervalMs / 1e3}s \u2014 ctrl-c to stop.`
1944
+ )
1945
+ );
1946
+ let stopped = false;
1947
+ const stop = () => {
1948
+ stopped = true;
1949
+ Promise.resolve(driver.close(db)).finally(() => process.exit(0));
1950
+ };
1951
+ process.once("SIGINT", stop);
1952
+ process.once("SIGTERM", stop);
1953
+ const tick = async () => {
1954
+ if (stopped) return;
1955
+ try {
1956
+ const printed = await pullPass(db, config, opts, true);
1957
+ if (!printed)
1958
+ process.stdout.write(
1959
+ style6.dim(`\r\xB7 ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} in sync `)
1960
+ );
1961
+ } catch (err) {
1962
+ console.error(`
1963
+ ${fail3(errMsg2(err))}`);
1964
+ }
1965
+ if (!stopped) setTimeout(() => void tick(), intervalMs);
1966
+ };
1967
+ void tick();
1968
+ });
1969
+ }
1970
+ kindFlags(
1971
+ dbFlags(
1972
+ program.command("pull").description("Generate/update Zod schema files from the live database").option("--write", "apply the changes (default: preview only)").option(
1973
+ "--merge",
1974
+ "keep local-only fields/objects (default: mirror the DB)"
1975
+ ).option(
1976
+ "--discard",
1977
+ "drop local-only fields/objects to mirror the DB exactly"
1978
+ ).option("--watch", "poll the live DB and re-pull as it changes").option(
1979
+ "--interval <seconds>",
1980
+ "poll interval in seconds for --watch (default: 2)"
1981
+ )
1982
+ )
1983
+ ).action(
1984
+ (opts) => {
1985
+ run(async () => {
1986
+ if (opts.watch) {
1987
+ const config = await resolveOne(opts);
1988
+ const driver = activeDriver(config);
1989
+ if (!driver.planPull)
1990
+ throw new Error(
1991
+ `the "${config.driver ?? "surrealdb"}" driver does not support \`pull\`.`
1992
+ );
1993
+ const intervalMs = Math.max(500, (Number(opts.interval) || 2) * 1e3);
1994
+ const db = await driver.connect(config, opts);
1995
+ await pullPollLoop(config, db, intervalMs, opts);
1996
+ return;
1997
+ }
1998
+ await withDb(opts, async (db, config) => {
1999
+ if (!await pullPass(db, config, opts, false))
2000
+ console.log(ok3("Schema files already match the database."));
2001
+ });
2002
+ });
2003
+ }
2004
+ );
2005
+ async function bootstrap() {
2006
+ await registerDriverCommands(program).catch(() => {
2007
+ });
2008
+ if (process.argv.length <= 2) {
2009
+ program.outputHelp();
2010
+ process.exit(0);
2011
+ }
2012
+ await program.parseAsync();
2013
+ }
2014
+ bootstrap();