@schemic/cli 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js ADDED
@@ -0,0 +1,1464 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import {
5
+ existsSync as existsSync3,
6
+ watch as fsWatch,
7
+ mkdirSync as mkdirSync3,
8
+ writeFileSync as writeFileSync3
9
+ } from "fs";
10
+ import { dirname as dirname2, join as join2, relative as relative2 } from "path";
11
+ import { createInterface } from "readline/promises";
12
+ import {
13
+ actionLabel,
14
+ applyPull,
15
+ duplicateTables,
16
+ EMPTY_STORED as EMPTY_STORED2,
17
+ existingTables,
18
+ fail,
19
+ formatDiff,
20
+ formatItems as formatItems2,
21
+ formatPatch,
22
+ getDriver as getDriver4,
23
+ isEmptyDiff as isEmptyDiff2,
24
+ kindFlags,
25
+ lineDiff,
26
+ listMigrations as listMigrations2,
27
+ loadDefs as loadDefs3,
28
+ loadSchemas,
29
+ lowerSchema as lowerSchema3,
30
+ ok as ok2,
31
+ parseFilter as parseFilter2,
32
+ pipeThroughPager,
33
+ plural as plural2,
34
+ readSnapshot as readSnapshot2,
35
+ resolvePager,
36
+ snapshotObjects as snapshotObjects2,
37
+ style as style2,
38
+ summarizeKinds,
39
+ unifiedDiff,
40
+ writeSnapshot as writeSnapshot2
41
+ } from "@schemic/core";
42
+ import { Command, Help, Option } from "commander";
43
+
44
+ // src/cli/init.ts
45
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
46
+ import { dirname, resolve } from "path";
47
+ function initialSnapshot(driver) {
48
+ return `${JSON.stringify(
49
+ {
50
+ version: 3,
51
+ driver,
52
+ schema: { kinds: {} },
53
+ files: {}
54
+ },
55
+ null,
56
+ 2
57
+ )}
58
+ `;
59
+ }
60
+ function init(cwd, driver) {
61
+ const scaffold = driver.initScaffold?.();
62
+ if (!scaffold)
63
+ throw new Error(
64
+ `the "${driver.name}" driver does not support \`schemic init\` scaffolding.`
65
+ );
66
+ const files = {
67
+ ...scaffold,
68
+ "database/migrations/meta/_snapshot.json": initialSnapshot(driver.name)
69
+ };
70
+ const created = [];
71
+ const skipped = [];
72
+ for (const [rel, content] of Object.entries(files)) {
73
+ const abs = resolve(cwd, rel);
74
+ if (existsSync(abs)) {
75
+ skipped.push(rel);
76
+ continue;
77
+ }
78
+ mkdirSync(dirname(abs), { recursive: true });
79
+ writeFileSync(abs, content);
80
+ created.push(rel);
81
+ }
82
+ return { created, skipped };
83
+ }
84
+
85
+ // src/cli/migrate.ts
86
+ import {
87
+ existsSync as existsSync2,
88
+ mkdirSync as mkdirSync2,
89
+ readFileSync,
90
+ rmSync,
91
+ writeFileSync as writeFileSync2
92
+ } from "fs";
93
+ import { join, relative, resolve as resolve2 } from "path";
94
+ import {
95
+ buildKindDiff,
96
+ checksum,
97
+ EMPTY_STORED,
98
+ filterKinds,
99
+ getDriver,
100
+ intersectKinds,
101
+ isEmptyDiff,
102
+ listMigrations,
103
+ loadDefs,
104
+ lowerSchema,
105
+ makeJiti,
106
+ mergeStored,
107
+ parseFilter,
108
+ readSnapshot,
109
+ slug,
110
+ snapshotKinds,
111
+ snapshotObjects,
112
+ timestamp,
113
+ writeSnapshot
114
+ } from "@schemic/core";
115
+ function buildStored(driver, tables, defs, opts = {}) {
116
+ const objects = lowerSchema(driver.registry, driver.explode(tables, defs));
117
+ const files = {};
118
+ const rel = (abs) => opts.root ? relative(opts.root, abs) : abs;
119
+ for (const t of tables) {
120
+ const abs = opts.fileOf?.get(t);
121
+ if (abs) files[t.name] = rel(abs);
122
+ }
123
+ for (const d of defs) {
124
+ const abs = opts.fileOf?.get(d);
125
+ const key = d.kind === "event" ? d.table : d.name;
126
+ if (abs && key) files[key] = rel(abs);
127
+ }
128
+ return {
129
+ version: 3,
130
+ driver: driver.name,
131
+ schema: snapshotKinds(objects),
132
+ files
133
+ };
134
+ }
135
+ function migStore(config) {
136
+ const driver = getDriver(config.driver ?? "surrealdb");
137
+ if (!driver.migrations)
138
+ throw new Error(
139
+ `The "${driver.name}" driver does not support running migrations.`
140
+ );
141
+ return driver.migrations;
142
+ }
143
+ var migExt = (config) => getDriver(config.driver ?? "surrealdb").migrations?.extension ?? ".surql";
144
+ function attachFiles(diff, prevFiles, nextFiles) {
145
+ for (const it of diff.items ?? []) {
146
+ const primary = it.op === "remove" ? prevFiles : nextFiles;
147
+ it.file = primary[it.table] ?? nextFiles[it.table] ?? prevFiles[it.table];
148
+ }
149
+ }
150
+ async function planMigration(config, filter = parseFilter({}), opts = {}) {
151
+ const { tables, defs, fileOf } = await loadDefs(config.schemaPath);
152
+ const driver = getDriver(config.driver ?? "surrealdb");
153
+ const reg = driver.registry;
154
+ const next = buildStored(driver, tables, defs, { fileOf, root: config.root });
155
+ const prev = opts.baseline ? EMPTY_STORED : readSnapshot(config.metaDir);
156
+ const diff = buildKindDiff(
157
+ reg,
158
+ filterKinds(reg, snapshotObjects(prev.schema), filter),
159
+ filterKinds(reg, snapshotObjects(next.schema), filter)
160
+ );
161
+ attachFiles(diff, prev.files ?? {}, next.files ?? {});
162
+ return { diff, next: mergeStored(reg, prev, next, filter) };
163
+ }
164
+ function nextTag(migrationsDir, name, ext) {
165
+ const s = slug(name);
166
+ const date = /* @__PURE__ */ new Date();
167
+ let tag = `${timestamp(date)}_${s}`;
168
+ while (existsSync2(join(migrationsDir, `${tag}${ext}`))) {
169
+ date.setUTCSeconds(date.getUTCSeconds() + 1);
170
+ tag = `${timestamp(date)}_${s}`;
171
+ }
172
+ return tag;
173
+ }
174
+ function prepareMigration(config, plan, name) {
175
+ const { diff, next } = plan;
176
+ if (isEmptyDiff(diff)) return null;
177
+ mkdirSync2(config.migrationsDir, { recursive: true });
178
+ const ext = migExt(config);
179
+ const tag = nextTag(config.migrationsDir, name ?? "migration", ext);
180
+ return {
181
+ tag,
182
+ file: `${tag}${ext}`,
183
+ content: migStore(config).render(tag, diff),
184
+ next,
185
+ up: diff.up.length,
186
+ down: diff.down.length
187
+ };
188
+ }
189
+ function commitMigration(config, prepared) {
190
+ mkdirSync2(config.migrationsDir, { recursive: true });
191
+ writeFileSync2(join(config.migrationsDir, prepared.file), prepared.content);
192
+ writeSnapshot(config.metaDir, prepared.next);
193
+ return {
194
+ created: true,
195
+ tag: prepared.tag,
196
+ file: prepared.file,
197
+ up: prepared.up,
198
+ down: prepared.down
199
+ };
200
+ }
201
+ async function recordApplied(db, config, m) {
202
+ await migStore(config).record(db, config.migrationsTable, {
203
+ tag: m.tag,
204
+ file: m.file,
205
+ checksum: checksum(m.content)
206
+ });
207
+ }
208
+ async function baseline(db, config, filter = parseFilter({})) {
209
+ const driver = getDriver(config.driver ?? "surrealdb");
210
+ const reg = driver.registry;
211
+ const live = await driver.introspectAll(
212
+ db,
213
+ /* @__PURE__ */ new Set([config.migrationsTable, `${config.migrationsTable}_lock`])
214
+ );
215
+ const { tables, defs, fileOf } = await loadDefs(config.schemaPath);
216
+ const disk = buildStored(driver, tables, defs, {
217
+ fileOf,
218
+ root: config.root
219
+ });
220
+ const pulledObjects = intersectKinds(
221
+ reg,
222
+ snapshotObjects(disk.schema),
223
+ live,
224
+ filter
225
+ );
226
+ const pulled = {
227
+ version: 3,
228
+ driver: driver.name,
229
+ schema: snapshotKinds(pulledObjects),
230
+ files: disk.files
231
+ };
232
+ const prev = readSnapshot(config.metaDir);
233
+ const diff = buildKindDiff(
234
+ reg,
235
+ filterKinds(reg, snapshotObjects(prev.schema), filter),
236
+ pulledObjects
237
+ );
238
+ attachFiles(diff, prev.files ?? {}, pulled.files ?? {});
239
+ const plan = {
240
+ diff,
241
+ next: mergeStored(reg, prev, pulled, filter)
242
+ };
243
+ const prepared = prepareMigration(config, plan, "baseline");
244
+ if (!prepared) {
245
+ writeSnapshot(config.metaDir, plan.next);
246
+ return { created: false };
247
+ }
248
+ const res = commitMigration(config, prepared);
249
+ await recordApplied(db, config, prepared);
250
+ return res;
251
+ }
252
+ function clearMigrationFiles(config) {
253
+ const migs = listMigrations(config.migrationsDir, migExt(config));
254
+ for (const m of migs)
255
+ rmSync(join(config.migrationsDir, m.file), { force: true });
256
+ return migs.map((m) => m.tag);
257
+ }
258
+ async function reconcileBaseline(db, config, prepared, drift) {
259
+ const mig = migStore(config);
260
+ await mig.ensure(db, config.migrationsTable);
261
+ if (drift) return "pending";
262
+ await mig.clear(db, config.migrationsTable);
263
+ await recordApplied(db, config, prepared);
264
+ return "applied";
265
+ }
266
+ function indexOfTag(migrations, tag) {
267
+ const i = migrations.findIndex((m) => m.tag === tag);
268
+ if (i < 0) throw new Error(`Unknown migration: ${tag}`);
269
+ return i;
270
+ }
271
+ async function unlock(db, config) {
272
+ await migStore(config).unlock(db, config.migrationsTable);
273
+ }
274
+ async function migrate(db, config, opts = {}) {
275
+ const mig = migStore(config);
276
+ const table = config.migrationsTable;
277
+ await mig.ensure(db, table);
278
+ const migrations = listMigrations(config.migrationsDir, migExt(config));
279
+ const applied = await mig.applied(db, table);
280
+ let pending = migrations.filter((m) => !applied.has(m.tag));
281
+ if (opts.to) {
282
+ const targetIdx = indexOfTag(migrations, opts.to);
283
+ const pos = new Map(migrations.map((m, i) => [m.tag, i]));
284
+ pending = pending.filter((m) => (pos.get(m.tag) ?? 0) <= targetIdx);
285
+ }
286
+ if (opts.count !== void 0)
287
+ pending = pending.slice(0, Math.max(0, opts.count));
288
+ await mig.lock(db, table);
289
+ try {
290
+ for (const m of pending) {
291
+ const content = readFileSync(join(config.migrationsDir, m.file), "utf8");
292
+ await mig.apply(db, table, {
293
+ content,
294
+ direction: "up",
295
+ record: { tag: m.tag, file: m.file, checksum: checksum(content) }
296
+ });
297
+ }
298
+ } finally {
299
+ await mig.unlock(db, table);
300
+ }
301
+ return { applied: pending };
302
+ }
303
+ function currentChecksum(config, m) {
304
+ const path = join(config.migrationsDir, m.file);
305
+ if (!existsSync2(path)) return "";
306
+ return checksum(readFileSync(path, "utf8"));
307
+ }
308
+ async function status(db, config) {
309
+ const mig = migStore(config);
310
+ await mig.ensure(db, config.migrationsTable);
311
+ const applied = await mig.applied(db, config.migrationsTable);
312
+ const files = listMigrations(config.migrationsDir, migExt(config));
313
+ const fileTags = new Set(files.map((m) => m.tag));
314
+ const rows = files.map((m) => {
315
+ const appliedSum = applied.get(m.tag);
316
+ return {
317
+ tag: m.tag,
318
+ applied: appliedSum !== void 0,
319
+ drift: appliedSum !== void 0 && appliedSum !== currentChecksum(config, m)
320
+ };
321
+ });
322
+ for (const tag of applied.keys())
323
+ if (!fileTags.has(tag)) rows.push({ tag, applied: true, missing: true });
324
+ return rows.sort((a, b) => a.tag.localeCompare(b.tag));
325
+ }
326
+ async function rollback(db, config, opts = {}) {
327
+ const mig = migStore(config);
328
+ const table = config.migrationsTable;
329
+ await mig.ensure(db, table);
330
+ const migrations = listMigrations(config.migrationsDir, migExt(config));
331
+ const applied = await mig.applied(db, table);
332
+ const appliedMigrations = migrations.filter((m) => applied.has(m.tag));
333
+ let toRevert;
334
+ if (opts.to) {
335
+ const targetIdx = indexOfTag(migrations, opts.to);
336
+ const pos = new Map(migrations.map((m, i) => [m.tag, i]));
337
+ toRevert = appliedMigrations.filter((m) => (pos.get(m.tag) ?? 0) > targetIdx).reverse();
338
+ } else {
339
+ toRevert = appliedMigrations.slice(-(opts.count ?? 1)).reverse();
340
+ }
341
+ await mig.lock(db, table);
342
+ try {
343
+ for (const m of toRevert) {
344
+ const content = readFileSync(join(config.migrationsDir, m.file), "utf8");
345
+ await mig.apply(db, table, {
346
+ content,
347
+ direction: "down",
348
+ record: { tag: m.tag, file: m.file, checksum: checksum(content) }
349
+ });
350
+ }
351
+ } finally {
352
+ await mig.unlock(db, table);
353
+ }
354
+ return toRevert;
355
+ }
356
+ async function seed(db, config) {
357
+ const path = config.seed ? resolve2(config.root, config.seed) : resolve2(config.root, "database/seed.ts");
358
+ if (!existsSync2(path)) throw new Error(`Seed file not found: ${path}`);
359
+ const mod = await makeJiti().import(path);
360
+ const fn = typeof mod.default === "function" ? mod.default : mod.seed;
361
+ if (typeof fn !== "function") {
362
+ throw new Error("Seed file must export a default function `(db) => \u2026`.");
363
+ }
364
+ await fn(db);
365
+ }
366
+
367
+ // src/cli/portable-diff.ts
368
+ import {
369
+ buildKindDiff as buildKindDiff2,
370
+ formatItems,
371
+ getDriver as getDriver2,
372
+ loadDefs as loadDefs2,
373
+ lowerSchema as lowerSchema2,
374
+ ok,
375
+ plural,
376
+ style
377
+ } from "@schemic/core";
378
+ async function portableDiff(config, driverName, opts = {}) {
379
+ const driver = getDriver2(driverName);
380
+ const reg = driver.registry;
381
+ const { tables, defs } = await loadDefs2(config.schemaPath);
382
+ const desired = lowerSchema2(reg, driver.explode(tables, defs));
383
+ const conn = await driver.connect(config);
384
+ let live;
385
+ try {
386
+ live = await driver.introspectAll(
387
+ conn,
388
+ /* @__PURE__ */ new Set([config.migrationsTable, `${config.migrationsTable}_lock`])
389
+ );
390
+ } finally {
391
+ await closeQuietly(conn);
392
+ }
393
+ const diff = buildKindDiff2(reg, live, desired);
394
+ if (opts.json) {
395
+ console.log(
396
+ JSON.stringify({ driver: driverName, up: diff.up, down: diff.down })
397
+ );
398
+ return;
399
+ }
400
+ console.log(style.dim(` driver: ${driverName}`));
401
+ if (!diff.up.length) {
402
+ console.log(ok("Schema is in sync with the target database."));
403
+ return;
404
+ }
405
+ const items = diff.items ?? [];
406
+ console.log(formatItems(items));
407
+ const n = (op) => items.filter((i) => i.op === op).length;
408
+ console.log(
409
+ style.dim(
410
+ `
411
+ ${plural(items.length, "change")} \u2014 ${n("add")} added, ${n("change")} changed, ${n("remove")} removed.`
412
+ )
413
+ );
414
+ }
415
+ async function closeQuietly(conn) {
416
+ const close = conn?.close;
417
+ if (typeof close === "function") {
418
+ try {
419
+ await close.call(conn);
420
+ } catch {
421
+ }
422
+ }
423
+ }
424
+
425
+ // src/cli/resolve.ts
426
+ import {
427
+ driverNames,
428
+ getDriver as getDriver3,
429
+ isConnectionEntry,
430
+ loadProject,
431
+ resolveConnectionConfig
432
+ } from "@schemic/core";
433
+ async function ensureDriver(name) {
434
+ if (driverNames().includes(name)) return;
435
+ const pkg = `@schemic/${name}`;
436
+ try {
437
+ await import(pkg);
438
+ } catch (e) {
439
+ throw new Error(
440
+ `could not load the "${name}" database driver (package ${pkg}). Install it (e.g. \`bun add ${pkg}\`).
441
+ ${e instanceof Error ? e.message : String(e)}`
442
+ );
443
+ }
444
+ if (!driverNames().includes(name))
445
+ throw new Error(`package ${pkg} did not register a "${name}" driver.`);
446
+ }
447
+ function collectArg(value, prev) {
448
+ return [...prev, value];
449
+ }
450
+ function parseArgs(arg) {
451
+ const out = {};
452
+ for (const a of arg ?? []) {
453
+ const i = a.indexOf("=");
454
+ if (i < 0) throw new Error(`--arg must be key=value (got "${a}").`);
455
+ out[a.slice(0, i)] = a.slice(i + 1);
456
+ }
457
+ return out;
458
+ }
459
+ function splitAddress(address) {
460
+ const i = address.indexOf(":");
461
+ return i < 0 ? [address, void 0] : [address.slice(0, i), address.slice(i + 1)];
462
+ }
463
+ async function resolveTargets(opts) {
464
+ const { config, root } = await loadProject({ config: opts.config });
465
+ const args = parseArgs(opts.arg);
466
+ const names = Object.keys(config.connections);
467
+ const opened = /* @__PURE__ */ new Map();
468
+ const resolving = /* @__PURE__ */ new Set();
469
+ const entryOf = (name) => {
470
+ const entry = config.connections[name];
471
+ if (!isConnectionEntry(entry))
472
+ throw new Error(
473
+ `No connection named "${name}". Known: ${names.join(", ") || "(none)"}.`
474
+ );
475
+ return entry;
476
+ };
477
+ const resolveOneConfig = async (name, key) => {
478
+ const entry = entryOf(name);
479
+ const list = await entry.resolve(ctx);
480
+ const picked = key !== void 0 ? list.find((c) => c.key === key) : list.length === 1 ? list[0] : void 0;
481
+ if (!picked) {
482
+ if (key !== void 0)
483
+ throw new Error(`Connection "${name}" has no element with key "${key}".`);
484
+ throw new Error(
485
+ `Connection "${name}" resolved to ${list.length} connections (a collection); address one with --connection ${name}:<key> or use --all.`
486
+ );
487
+ }
488
+ return resolveConnectionConfig(config, name, picked, entry.driver, root);
489
+ };
490
+ const openConnection = async (name) => {
491
+ const cached = opened.get(name);
492
+ if (cached) return cached;
493
+ if (resolving.has(name))
494
+ throw new Error(`Connection cycle detected while resolving "${name}".`);
495
+ resolving.add(name);
496
+ try {
497
+ const resolved = await resolveOneConfig(name);
498
+ await ensureDriver(resolved.driver);
499
+ const driver = getDriver3(resolved.driver);
500
+ const conn = await driver.connect(resolved, opts);
501
+ const handle = { driver, conn, driverName: resolved.driver };
502
+ opened.set(name, handle);
503
+ return handle;
504
+ } finally {
505
+ resolving.delete(name);
506
+ }
507
+ };
508
+ const connections = new Proxy(
509
+ {},
510
+ {
511
+ get(_t, prop) {
512
+ if (typeof prop !== "string") return void 0;
513
+ return {
514
+ async query(sql, vars) {
515
+ const { driver, conn, driverName } = await openConnection(prop);
516
+ if (!driver.query)
517
+ throw new Error(
518
+ `the "${driverName}" driver has no \`query\` capability (needed by a connection resolver).`
519
+ );
520
+ return driver.query(conn, sql, vars);
521
+ }
522
+ };
523
+ }
524
+ }
525
+ );
526
+ const ctx = { connections, args, env: process.env };
527
+ const fanOut = async (name) => {
528
+ const entry = entryOf(name);
529
+ const list = await entry.resolve(ctx);
530
+ return list.map(
531
+ (conn) => resolveConnectionConfig(config, name, conn, entry.driver, root)
532
+ );
533
+ };
534
+ try {
535
+ let targets;
536
+ if (opts.all) {
537
+ targets = [];
538
+ for (const name of names) targets.push(...await fanOut(name));
539
+ } else if (opts.connection) {
540
+ const [name, key] = splitAddress(opts.connection);
541
+ targets = key !== void 0 ? [await resolveOneConfig(name, key)] : await fanOut(name);
542
+ } else {
543
+ const name = config.defaultConnection ?? (names.length === 1 ? names[0] : "default");
544
+ if (!config.connections[name])
545
+ throw new Error(
546
+ `No default connection. Set "defaultConnection" or pass --connection. Known: ${names.join(", ") || "(none)"}.`
547
+ );
548
+ targets = [await resolveOneConfig(name)];
549
+ }
550
+ if (!targets.length)
551
+ throw new Error("No connections matched \u2014 nothing to do.");
552
+ for (const driver of new Set(targets.map((t) => t.driver)))
553
+ await ensureDriver(driver);
554
+ return targets;
555
+ } finally {
556
+ for (const { driver, conn } of opened.values()) {
557
+ try {
558
+ await driver.close(conn);
559
+ } catch {
560
+ }
561
+ }
562
+ }
563
+ }
564
+ async function resolveOne(opts) {
565
+ if (opts.all)
566
+ throw new Error(
567
+ "--all is not supported here \u2014 this command operates on a single connection. Use --connection <name>."
568
+ );
569
+ const targets = await resolveTargets(opts);
570
+ if (targets.length !== 1)
571
+ throw new Error(
572
+ `--connection addressed ${targets.length} connections (a collection) \u2014 pin one with --connection <name>:<key>.`
573
+ );
574
+ return targets[0];
575
+ }
576
+
577
+ // src/cli/index.ts
578
+ var activeDriver = (config) => getDriver4(config.driver);
579
+ async function withDb(opts, fn) {
580
+ const targets = await resolveTargets(opts);
581
+ for (const config of targets) {
582
+ if (targets.length > 1) console.log(style2.bold(`
583
+ [${config.connection}]`));
584
+ const driver = getDriver4(config.driver);
585
+ const db = await driver.connect(config, opts);
586
+ try {
587
+ await fn(db, config);
588
+ } finally {
589
+ await driver.close(db);
590
+ }
591
+ }
592
+ }
593
+ var errMsg = (err) => err instanceof Error ? err.message : String(err);
594
+ function formatDuplicates(dups, root) {
595
+ return [...dups].map(([name, files]) => {
596
+ const counts = /* @__PURE__ */ new Map();
597
+ for (const f of files) counts.set(f, (counts.get(f) ?? 0) + 1);
598
+ const label = [...counts].map(([f, n]) => {
599
+ const rel = relative2(root, f);
600
+ return n > 1 ? `${rel} (\xD7${n})` : rel;
601
+ }).join(", ");
602
+ return `${name} \u2014 ${label}`;
603
+ });
604
+ }
605
+ var duplicateHeader = (n) => `${plural2(n, "table")} defined more than once (last definition silently wins):`;
606
+ function run(action) {
607
+ action().then(
608
+ () => process.exit(process.exitCode ?? 0),
609
+ (err) => {
610
+ console.error(`
611
+ ${fail(errMsg(err))}`);
612
+ process.exit(1);
613
+ }
614
+ );
615
+ }
616
+ async function promptTitle() {
617
+ if (!process.stdin.isTTY) return void 0;
618
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
619
+ try {
620
+ const answer = (await rl.question("Migration title: ")).trim();
621
+ return answer || void 0;
622
+ } finally {
623
+ rl.close();
624
+ }
625
+ }
626
+ async function confirmPrompt(question) {
627
+ if (!process.stdin.isTTY) return false;
628
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
629
+ try {
630
+ const a = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
631
+ return a === "y" || a === "yes";
632
+ } finally {
633
+ rl.close();
634
+ }
635
+ }
636
+ function diffSummary(registry, diff, opts, pending) {
637
+ const summary = [];
638
+ if (!isEmptyDiff2(diff)) {
639
+ const kinds = summarizeKinds(registry, diff.items ?? []);
640
+ summary.push(
641
+ `${plural2(diff.up.length, "change")} ${opts.live ? "vs the live database" : "vs the snapshot"}${kinds ? ` \u2014 ${kinds}` : ""}.`
642
+ );
643
+ }
644
+ if (pending !== void 0)
645
+ summary.push(`${plural2(pending, "migration")} pending.`);
646
+ return summary.length ? `
647
+ ${style2.dim(summary.join("\n"))}` : "";
648
+ }
649
+ function reportDiff(registry, diff, opts, pending) {
650
+ console.log(
651
+ formatDiff(diff, { down: opts.down, full: opts.full, inline: opts.inline })
652
+ );
653
+ const summary = diffSummary(registry, diff, opts, pending);
654
+ if (summary) console.log(summary);
655
+ }
656
+ function watchLoop(config, task, cleanup) {
657
+ return new Promise(() => {
658
+ let timer;
659
+ let running = false;
660
+ let queued = false;
661
+ const fire = async () => {
662
+ if (running) {
663
+ queued = true;
664
+ return;
665
+ }
666
+ running = true;
667
+ console.log(style2.dim(`
668
+ \u2014 ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \u2014`));
669
+ try {
670
+ await task();
671
+ } catch (err) {
672
+ console.error(fail(errMsg(err)));
673
+ }
674
+ running = false;
675
+ if (queued) {
676
+ queued = false;
677
+ void fire();
678
+ }
679
+ };
680
+ const watcher = fsWatch(
681
+ config.schemaPath,
682
+ { recursive: !config.schemaIsFile },
683
+ () => {
684
+ clearTimeout(timer);
685
+ timer = setTimeout(() => void fire(), 150);
686
+ }
687
+ );
688
+ console.log(
689
+ style2.dim(
690
+ `Watching ${relative2(config.root, config.schemaPath)} for changes \u2014 ctrl-c to stop.`
691
+ )
692
+ );
693
+ void fire();
694
+ const stop = () => {
695
+ watcher.close();
696
+ clearTimeout(timer);
697
+ Promise.resolve(cleanup?.()).finally(() => process.exit(0));
698
+ };
699
+ process.once("SIGINT", stop);
700
+ process.once("SIGTERM", stop);
701
+ });
702
+ }
703
+ var configFlag = (cmd) => cmd.option("-c, --config <path>", "path to schemic.config.ts");
704
+ var dbFlags = (cmd) => configFlag(cmd).option(
705
+ "--connection <name>",
706
+ "target a specific connection (or <name>:<key> within a collection)"
707
+ ).option("--all", "run against every connection (collections fanned out)").option(
708
+ "--arg <key=value>",
709
+ "value passed to connection resolvers (repeatable)",
710
+ collectArg,
711
+ []
712
+ ).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(
713
+ new Option("--auth-level <level>", "auth level").choices([
714
+ "root",
715
+ "namespace",
716
+ "database"
717
+ ])
718
+ );
719
+ var program = new Command();
720
+ program.name("schemic").description(
721
+ "Schema-as-code migrations for any database \u2014 generate DDL, diff, and migrate via drivers"
722
+ ).version("0.1.0-alpha.0").showHelpAfterError("(run `schemic --help` for usage)").addHelpText(
723
+ "after",
724
+ `
725
+ Examples:
726
+ $ schemic init scaffold database/ (schemas + migrations) + config
727
+ $ schemic gen add_users create a migration from schema changes
728
+ $ schemic migrate apply pending migrations
729
+ $ schemic push --watch keep the database in sync while you edit
730
+ $ schemic diff --live show how the schema differs from the live database
731
+ `
732
+ );
733
+ program.configureHelp({
734
+ visibleOptions(cmd) {
735
+ const opts = Help.prototype.visibleOptions.call(this, cmd);
736
+ const negated = new Set(
737
+ opts.filter((o) => o.negate).map((o) => o.attributeName())
738
+ );
739
+ this._collapsible = new Set(
740
+ [...negated].filter(
741
+ (n) => opts.some((o) => !o.negate && o.attributeName() === n)
742
+ )
743
+ );
744
+ return opts.filter(
745
+ (o) => !(o.negate && this._collapsible?.has(o.attributeName()))
746
+ );
747
+ },
748
+ optionTerm(option) {
749
+ const term = Help.prototype.optionTerm.call(this, option);
750
+ if (option.negate) return term.replace("--no-", "--[no-]");
751
+ if (this._collapsible?.has(option.attributeName()))
752
+ return term.replace(`--${option.name()}`, `--[no-]${option.name()}`);
753
+ return term;
754
+ }
755
+ });
756
+ program.command("init").description("Scaffold database/ (schemas + migrations) and a config file").option(
757
+ "--driver <name>",
758
+ "database driver to scaffold for (default surrealdb)"
759
+ ).action((opts) => {
760
+ run(async () => {
761
+ const name = opts.driver ?? "surrealdb";
762
+ await ensureDriver(name);
763
+ const { created, skipped } = init(process.cwd(), getDriver4(name));
764
+ for (const f of created) console.log(` ${style2.green("+")} ${f}`);
765
+ for (const f of skipped)
766
+ console.log(style2.dim(` \xB7 ${f} (exists, skipped)`));
767
+ console.log(
768
+ created.length ? `
769
+ ${ok2("Initialized. Edit database/schema, then run `schemic gen`.")}` : "\nNothing to do \u2014 already initialized."
770
+ );
771
+ });
772
+ });
773
+ kindFlags(
774
+ dbFlags(
775
+ program.command("diff").description("Show pending schema changes without writing a migration")
776
+ )
777
+ ).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(
778
+ "-p, --patch",
779
+ "output a unified diff (e.g. to pipe to a diff viewer)"
780
+ ).option(
781
+ "--pager [cmd]",
782
+ "page through your git diff viewer (or <cmd>); off by default"
783
+ ).option(
784
+ "--inline",
785
+ "render changes as an inline word-diff instead of separate -/+ lines"
786
+ ).option("--json", "output the diff as JSON").option(
787
+ "--driver <name>",
788
+ "target database driver (default from config, or 'surreal')"
789
+ ).action(
790
+ (opts) => {
791
+ run(async () => {
792
+ const config = await resolveOne(opts);
793
+ const driverName = opts.driver ?? config.driver ?? "surrealdb";
794
+ await ensureDriver(driverName);
795
+ const driver = getDriver4(driverName);
796
+ const diffLive = driver.diffLive;
797
+ if (!diffLive) {
798
+ await portableDiff(config, driverName, { json: opts.json });
799
+ return;
800
+ }
801
+ const filter = parseFilter2(opts);
802
+ const pager = opts.watch || opts.pager === void 0 || opts.pager === false ? void 0 : typeof opts.pager === "string" ? opts.pager : resolvePager();
803
+ const emit = async (diff, pending) => {
804
+ if (opts.json) {
805
+ console.log(
806
+ JSON.stringify({ up: diff.up, down: diff.down, pending })
807
+ );
808
+ } else if ((opts.patch || pager) && !isEmptyDiff2(diff)) {
809
+ const patch = formatPatch(diff);
810
+ if (pager) await pipeThroughPager(pager, patch);
811
+ else process.stdout.write(patch);
812
+ const summary = diffSummary(driver.registry, diff, opts, pending);
813
+ if (summary) console.log(summary);
814
+ } else {
815
+ reportDiff(driver.registry, diff, opts, pending);
816
+ }
817
+ };
818
+ const persistent = opts.watch && opts.live ? await driver.connect(config, opts) : void 0;
819
+ const once = async () => {
820
+ if (opts.ts) {
821
+ const loc = await existingTables(config.schemaPath);
822
+ const fileFor = (kind, name) => {
823
+ const abs = kind === "table" ? loc.get(name) : void 0;
824
+ return abs ? relative2(config.root, abs) : relative2(
825
+ config.root,
826
+ join2(
827
+ config.schemaPath,
828
+ driver.registry.display(kind).folder,
829
+ `${name}.ts`
830
+ )
831
+ );
832
+ };
833
+ const single = config.schemaIsFile ? relative2(config.root, config.schemaPath) : void 0;
834
+ const showTsDiff = async (cur, des, matchMsg) => {
835
+ if (opts.json) {
836
+ console.log(
837
+ JSON.stringify({
838
+ current: Object.fromEntries(cur),
839
+ desired: Object.fromEntries(des)
840
+ })
841
+ );
842
+ return;
843
+ }
844
+ const files = [.../* @__PURE__ */ new Set([...cur.keys(), ...des.keys()])].sort();
845
+ const changed = files.filter(
846
+ (f) => (cur.get(f) ?? "") !== (des.get(f) ?? "")
847
+ );
848
+ if (!changed.length) {
849
+ console.log(ok2(matchMsg));
850
+ } else if (pager || opts.patch) {
851
+ const patch = changed.map(
852
+ (f) => unifiedDiff(cur.get(f) ?? "", des.get(f) ?? "", f)
853
+ ).join("");
854
+ if (pager) await pipeThroughPager(pager, patch);
855
+ else process.stdout.write(patch);
856
+ } else {
857
+ console.log(
858
+ changed.map(
859
+ (f) => `${style2.bold(f)}
860
+ ${lineDiff(cur.get(f) ?? "", des.get(f) ?? "")}`
861
+ ).join("\n\n")
862
+ );
863
+ }
864
+ };
865
+ if (opts.live) {
866
+ if (!driver.diffTsLive)
867
+ throw new Error(
868
+ `the "${driverName}" driver does not support \`diff --ts --live\`.`
869
+ );
870
+ const db = persistent ?? await driver.connect(config, opts);
871
+ try {
872
+ const { current, desired } = await driver.diffTsLive(
873
+ db,
874
+ config,
875
+ filter,
876
+ fileFor,
877
+ single
878
+ );
879
+ await showTsDiff(
880
+ current,
881
+ desired,
882
+ "Schema matches the live database."
883
+ );
884
+ } finally {
885
+ if (!persistent) await driver.close(db);
886
+ }
887
+ } else {
888
+ if (!driver.renderSchema)
889
+ throw new Error(
890
+ `the "${driverName}" driver does not support \`diff --ts\`.`
891
+ );
892
+ const prev = readSnapshot2(config.metaDir);
893
+ const prevObjects = snapshotObjects2(prev.schema);
894
+ const { tables, defs } = await loadDefs3(config.schemaPath);
895
+ const desiredObjects = lowerSchema3(
896
+ driver.registry,
897
+ driver.explode(tables, defs)
898
+ );
899
+ await showTsDiff(
900
+ driver.renderSchema(prevObjects, filter, fileFor, single),
901
+ driver.renderSchema(desiredObjects, filter, fileFor, single),
902
+ prevObjects.length ? "Schema matches the snapshot." : "No schema to render."
903
+ );
904
+ }
905
+ return;
906
+ }
907
+ if (opts.live) {
908
+ const db = persistent ?? await driver.connect(config, opts);
909
+ try {
910
+ const diff = await diffLive(db, config, filter);
911
+ const pending = (await status(db, config)).filter(
912
+ (r) => !r.applied
913
+ ).length;
914
+ await emit(diff, pending);
915
+ } finally {
916
+ if (!persistent) await driver.close(db);
917
+ }
918
+ } else {
919
+ await emit((await planMigration(config, filter)).diff);
920
+ }
921
+ };
922
+ if (!opts.watch) return once();
923
+ await watchLoop(
924
+ config,
925
+ once,
926
+ persistent ? () => driver.close(persistent) : void 0
927
+ );
928
+ });
929
+ }
930
+ );
931
+ var genAction = (name, opts) => {
932
+ run(async () => {
933
+ const config = await resolveOne(opts);
934
+ const filter = parseFilter2(opts);
935
+ let squashed = null;
936
+ if (opts.baseline) {
937
+ const existing = listMigrations2(
938
+ config.migrationsDir,
939
+ activeDriver(config).migrations?.extension ?? ".surql"
940
+ );
941
+ if (existing.length) {
942
+ const migDir = relative2(config.root, config.migrationsDir);
943
+ const proceed = opts.force || await confirmPrompt(
944
+ `Replace ${plural2(existing.length, "migration")} in ${migDir} with a single baseline?`
945
+ );
946
+ if (!proceed) {
947
+ throw new Error(
948
+ `${plural2(existing.length, "migration")} already exist in ${migDir} \u2014 a baseline would re-define objects they already created.
949
+ Re-run \`schemic gen --baseline --force\` to replace them with one fresh baseline.`
950
+ );
951
+ }
952
+ squashed = clearMigrationFiles(config);
953
+ }
954
+ }
955
+ const plan = await planMigration(config, filter, {
956
+ baseline: opts.baseline
957
+ });
958
+ if (isEmptyDiff2(plan.diff)) {
959
+ console.log(ok2("No schema changes \u2014 nothing to generate."));
960
+ return;
961
+ }
962
+ const kinds = summarizeKinds(
963
+ activeDriver(config).registry,
964
+ plan.diff.items ?? []
965
+ );
966
+ console.log(
967
+ `${plural2(plan.diff.up.length, "change")} to migrate${kinds ? ` \u2014 ${kinds}` : ""}.`
968
+ );
969
+ console.log(formatDiff(plan.diff, {}));
970
+ const title = name ?? (opts.baseline ? "baseline" : opts.yes ? void 0 : await promptTitle());
971
+ const prepared = prepareMigration(config, plan, title);
972
+ if (!prepared) {
973
+ console.log(ok2("No schema changes \u2014 nothing to generate."));
974
+ return;
975
+ }
976
+ const res = commitMigration(config, prepared);
977
+ console.log(
978
+ `${ok2(res.file ?? "migration written")} ${style2.dim(`(+${res.up} up / ${res.down} down)`)}`
979
+ );
980
+ if (squashed) {
981
+ console.log(
982
+ style2.dim(` replaced ${plural2(squashed.length, "migration")}.`)
983
+ );
984
+ try {
985
+ const driver = activeDriver(config);
986
+ const diffLive = driver.diffLive;
987
+ if (!diffLive)
988
+ throw new Error(
989
+ `the "${config.driver ?? "surrealdb"}" driver does not support live reconcile`
990
+ );
991
+ const db = await driver.connect(config, opts);
992
+ try {
993
+ const drift = !isEmptyDiff2(await diffLive(db, config, filter));
994
+ const state = await reconcileBaseline(db, config, prepared, drift);
995
+ console.log(
996
+ style2.dim(
997
+ state === "applied" ? " database matched the schema \u2014 baseline recorded as applied." : " database differs from the schema \u2014 baseline left pending; run `schemic migrate`."
998
+ )
999
+ );
1000
+ } finally {
1001
+ await driver.close(db);
1002
+ }
1003
+ } catch (e) {
1004
+ console.log(
1005
+ style2.dim(
1006
+ ` database not reconciled (${errMsg(e)}) \u2014 baseline is pending; run \`schemic migrate\` to apply it.`
1007
+ )
1008
+ );
1009
+ }
1010
+ }
1011
+ });
1012
+ };
1013
+ var addGenCommand = (cmd) => {
1014
+ kindFlags(dbFlags(cmd)).option("-y, --yes", "use the given/default name without prompting").option(
1015
+ "--baseline",
1016
+ "regenerate one fresh baseline from an empty snapshot (replaces existing migrations)"
1017
+ ).option(
1018
+ "--force",
1019
+ "with --baseline, replace existing migrations without confirmation"
1020
+ ).action(genAction);
1021
+ };
1022
+ addGenCommand(
1023
+ program.command("gen [name]").description("Diff schemas, preview the migration script, and write it")
1024
+ );
1025
+ addGenCommand(program.command("generate [name]", { hidden: true }));
1026
+ var snapshot = program.command("snapshot").description(
1027
+ "Manage the migration snapshot (what `schemic gen`/`schemic diff` compare against)"
1028
+ );
1029
+ configFlag(
1030
+ snapshot.command("reset").description(
1031
+ "Clear the snapshot \u2014 the next `schemic gen` baselines the full schema"
1032
+ )
1033
+ ).action((opts) => {
1034
+ run(async () => {
1035
+ const config = await resolveOne(opts);
1036
+ writeSnapshot2(config.metaDir, EMPTY_STORED2);
1037
+ console.log(ok2("Snapshot cleared."));
1038
+ const existing = listMigrations2(
1039
+ config.migrationsDir,
1040
+ activeDriver(config).migrations?.extension ?? ".surql"
1041
+ );
1042
+ if (existing.length) {
1043
+ console.log(
1044
+ style2.dim(
1045
+ ` ${plural2(existing.length, "migration")} still on disk \u2014 run \`schemic gen --baseline --force\` to replace them with one fresh baseline. (A plain \`schemic gen\` would add a baseline alongside them.)`
1046
+ )
1047
+ );
1048
+ } else {
1049
+ console.log(
1050
+ style2.dim(" The next `schemic gen` will baseline the full schema.")
1051
+ );
1052
+ }
1053
+ });
1054
+ });
1055
+ dbFlags(
1056
+ program.command("migrate [count]").alias("up").description(
1057
+ "Apply pending migrations (all, the next N, or up to --to <tag>)"
1058
+ ).option("--to <tag>", "apply up to and including this migration")
1059
+ ).action((count, opts) => {
1060
+ run(
1061
+ () => withDb(opts, async (db, config) => {
1062
+ const n = count === void 0 ? void 0 : Math.max(1, Number.parseInt(count, 10) || 1);
1063
+ const { applied } = await migrate(db, config, { count: n, to: opts.to });
1064
+ if (!applied.length) {
1065
+ console.log(ok2("Up to date \u2014 no pending migrations."));
1066
+ return;
1067
+ }
1068
+ for (const e of applied) console.log(` ${style2.green("\u2191")} ${e.tag}`);
1069
+ console.log(`
1070
+ ${ok2(`Applied ${plural2(applied.length, "migration")}.`)}`);
1071
+ })
1072
+ );
1073
+ });
1074
+ dbFlags(
1075
+ program.command("status").description("Show applied vs pending migrations")
1076
+ ).option("--json", "output the status as JSON").action((opts) => {
1077
+ run(
1078
+ () => withDb(opts, async (db, config) => {
1079
+ const rows = await status(db, config);
1080
+ if (opts.json) {
1081
+ console.log(JSON.stringify(rows));
1082
+ return;
1083
+ }
1084
+ if (!rows.length) {
1085
+ console.log("No migrations yet. Run `schemic gen`.");
1086
+ return;
1087
+ }
1088
+ for (const r of rows) {
1089
+ if (r.missing) {
1090
+ console.log(
1091
+ ` ${style2.yellow("\u26A0 missing")} ${r.tag} ${style2.dim("(applied in the DB, file deleted)")}`
1092
+ );
1093
+ } else if (r.drift) {
1094
+ console.log(
1095
+ ` ${style2.yellow("\u26A0 drift")} ${r.tag} ${style2.dim("(file changed after apply)")}`
1096
+ );
1097
+ } else if (r.applied) {
1098
+ console.log(` ${style2.green("\u2713 applied")} ${r.tag}`);
1099
+ } else {
1100
+ console.log(style2.dim(` \xB7 pending ${r.tag}`));
1101
+ }
1102
+ }
1103
+ const pending = rows.filter((r) => !r.applied).length;
1104
+ const drifted = rows.filter((r) => r.drift).length;
1105
+ const missing = rows.filter((r) => r.missing).length;
1106
+ const parts = [plural2(rows.length, "migration"), `${pending} pending`];
1107
+ if (drifted) parts.push(`${drifted} drifted`);
1108
+ if (missing) parts.push(`${missing} missing`);
1109
+ console.log(`
1110
+ ${style2.dim(`${parts.join(", ")}.`)}`);
1111
+ if (missing) {
1112
+ console.log(
1113
+ style2.dim(
1114
+ " Missing migrations were applied but their files are gone (e.g. after removing migrations or `snapshot reset`)."
1115
+ )
1116
+ );
1117
+ }
1118
+ })
1119
+ );
1120
+ });
1121
+ dbFlags(
1122
+ program.command("check").description(
1123
+ "Validate schemas, then replay migrations to confirm they reproduce the schema"
1124
+ ).option(
1125
+ "--schema",
1126
+ "validate the schema only \u2014 skip the migration replay (no database)"
1127
+ )
1128
+ ).action((opts) => {
1129
+ run(async () => {
1130
+ const config = await resolveOne(opts);
1131
+ const driver = activeDriver(config);
1132
+ const dups = await duplicateTables(config.schemaPath);
1133
+ if (dups.size) {
1134
+ const lines = formatDuplicates(dups, config.root).map((l) => ` ${l}`);
1135
+ throw new Error(`${duplicateHeader(dups.size)}
1136
+ ${lines.join("\n")}`);
1137
+ }
1138
+ const { tables, defs } = await loadDefs3(config.schemaPath);
1139
+ const kinds = summarizeKinds(
1140
+ driver.registry,
1141
+ lowerSchema3(driver.registry, driver.explode(tables, defs))
1142
+ );
1143
+ console.log(ok2(`Schemas valid${kinds ? ` \u2014 ${kinds}` : " (no objects)"}.`));
1144
+ if (opts.schema) return;
1145
+ if (!driver.checkReplay) {
1146
+ throw new Error(
1147
+ `the "${config.driver ?? "surrealdb"}" driver does not support migration replay \u2014 run \`schemic check --schema\` to validate the schema only.`
1148
+ );
1149
+ }
1150
+ const diff = await driver.checkReplay(
1151
+ config,
1152
+ opts,
1153
+ parseFilter2({}),
1154
+ (m) => console.log(style2.dim(m))
1155
+ );
1156
+ if (isEmptyDiff2(diff)) {
1157
+ console.log(ok2("Migrations reproduce the schema."));
1158
+ return;
1159
+ }
1160
+ console.log(
1161
+ `
1162
+ ${fail("Drift \u2014 migrations do not reproduce the schema:")}
1163
+ `
1164
+ );
1165
+ console.log(formatDiff(diff, {}));
1166
+ console.log(
1167
+ `
1168
+ ${style2.dim(`${summarizeKinds(driver.registry, diff.items ?? [])} differ. \`schemic gen\` writes a migration to reconcile.`)}`
1169
+ );
1170
+ process.exitCode = 1;
1171
+ });
1172
+ });
1173
+ dbFlags(
1174
+ program.command("doctor").description("Print resolved config and test the connection")
1175
+ ).action((opts) => {
1176
+ run(async () => {
1177
+ const config = await resolveOne(opts);
1178
+ const row = (k, v) => console.log(style2.dim(` ${k.padEnd(11)} ${v}`));
1179
+ console.log(style2.bold("Project"));
1180
+ row("root", config.root);
1181
+ row("connection", `${config.connection} (${config.driver})`);
1182
+ row("migrations", relative2(config.root, config.migrationsDir));
1183
+ console.log(style2.bold("\nSchema"));
1184
+ row(
1185
+ "source",
1186
+ `${relative2(config.root, config.schemaPath)} (${config.schemaIsFile ? "file" : "directory"})`
1187
+ );
1188
+ try {
1189
+ const defs = await loadSchemas(config.schemaPath);
1190
+ row(
1191
+ "tables",
1192
+ defs.length ? `${plural2(defs.length, "table")} \u2014 ${defs.map((t) => t.name).join(", ")}` : "(none found)"
1193
+ );
1194
+ const dups = await duplicateTables(config.schemaPath);
1195
+ if (dups.size) {
1196
+ console.log(` ${fail(duplicateHeader(dups.size))}`);
1197
+ for (const line of formatDuplicates(dups, config.root))
1198
+ console.log(style2.dim(` ${line}`));
1199
+ process.exitCode = 1;
1200
+ }
1201
+ } catch (e) {
1202
+ console.log(` ${fail(e instanceof Error ? e.message : String(e))}`);
1203
+ }
1204
+ console.log(style2.bold("\nConnection"));
1205
+ const secret = /pass|secret|token|key/i;
1206
+ const params = Object.entries(config.params);
1207
+ if (params.length) {
1208
+ for (const [k, v] of params)
1209
+ row(k, secret.test(k) ? "***" : String(v ?? ""));
1210
+ } else {
1211
+ row("params", "(none)");
1212
+ }
1213
+ console.log(style2.bold("\nVersions"));
1214
+ row("@schemic/core", program.version() ?? "?");
1215
+ row("node", process.version);
1216
+ console.log(style2.bold("\nStatus"));
1217
+ try {
1218
+ const driver = activeDriver(config);
1219
+ const db = await driver.connect(config, opts);
1220
+ const info = driver.serverInfo ? await driver.serverInfo(db) : config.driver ?? "surrealdb";
1221
+ console.log(` ${ok2(`connected \u2014 ${info}`)}`);
1222
+ await driver.close(db);
1223
+ } catch (e) {
1224
+ console.log(` ${fail(e instanceof Error ? e.message : String(e))}`);
1225
+ process.exitCode = 1;
1226
+ }
1227
+ });
1228
+ });
1229
+ dbFlags(
1230
+ 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")
1231
+ ).action((count, opts) => {
1232
+ run(
1233
+ () => withDb(opts, async (db, config) => {
1234
+ const reverted = await rollback(db, config, {
1235
+ to: opts.to,
1236
+ count: opts.to || count === void 0 ? void 0 : Math.max(1, Number.parseInt(count, 10) || 1)
1237
+ });
1238
+ if (!reverted.length) {
1239
+ console.log(ok2("Nothing to roll back."));
1240
+ return;
1241
+ }
1242
+ for (const e of reverted) console.log(` ${style2.yellow("\u2193")} ${e.tag}`);
1243
+ console.log(
1244
+ `
1245
+ ${ok2(`Rolled back ${plural2(reverted.length, "migration")}.`)}`
1246
+ );
1247
+ })
1248
+ );
1249
+ });
1250
+ configFlag(
1251
+ program.command("new <kind> <name>").description(
1252
+ "Scaffold a new schema file for an entity, e.g. `sc new table user`"
1253
+ )
1254
+ ).action((kind, name, opts) => {
1255
+ run(async () => {
1256
+ const config = await resolveOne(opts);
1257
+ const driver = activeDriver(config);
1258
+ if (!driver.scaffoldEntity)
1259
+ throw new Error(`the "${config.driver}" driver can't scaffold entities.`);
1260
+ if (config.schemaIsFile)
1261
+ throw new Error(
1262
+ "`schemic new` needs a schema directory \u2014 your schema is a single file."
1263
+ );
1264
+ const content = driver.scaffoldEntity(kind, name);
1265
+ const target = join2(
1266
+ config.schemaPath,
1267
+ driver.registry.display(kind).folder,
1268
+ `${name}.ts`
1269
+ );
1270
+ if (existsSync3(target))
1271
+ throw new Error(`${relative2(config.root, target)} already exists.`);
1272
+ mkdirSync3(dirname2(target), { recursive: true });
1273
+ writeFileSync3(target, content);
1274
+ console.log(
1275
+ `${ok2(relative2(config.root, target))} ${style2.dim("\u2014 author its fields, then `schemic gen`")}`
1276
+ );
1277
+ });
1278
+ });
1279
+ dbFlags(
1280
+ program.command("unlock").description("Clear a stale migration lock")
1281
+ ).action((opts) => {
1282
+ run(
1283
+ () => withDb(opts, async (db, config) => {
1284
+ await unlock(db, config);
1285
+ console.log(ok2("Migration lock cleared."));
1286
+ })
1287
+ );
1288
+ });
1289
+ kindFlags(
1290
+ dbFlags(
1291
+ program.command("push").alias("sync").description(
1292
+ "Reconcile the live database with your schema (no migration files)"
1293
+ ).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")
1294
+ )
1295
+ ).action(
1296
+ (opts) => {
1297
+ run(async () => {
1298
+ const config = await resolveOne(opts);
1299
+ const driver = activeDriver(config);
1300
+ const filter = parseFilter2(opts);
1301
+ const diffLive = driver.diffLive;
1302
+ const syncPlan = driver.syncPlan;
1303
+ if (!diffLive || !syncPlan)
1304
+ throw new Error(
1305
+ `the "${config.driver ?? "surrealdb"}" driver does not support \`push\`.`
1306
+ );
1307
+ const once = async (db2) => {
1308
+ const diff = await diffLive(db2, config, filter);
1309
+ const stmts = syncPlan(diff, opts.prune);
1310
+ if (!stmts.length) {
1311
+ console.log(ok2("Database already matches the schema."));
1312
+ return;
1313
+ }
1314
+ const items = (diff.items ?? []).filter(
1315
+ (it) => opts.prune !== false || it.op !== "remove"
1316
+ );
1317
+ console.log(formatItems2(items));
1318
+ const kinds = summarizeKinds(driver.registry, items);
1319
+ if (opts.dryRun) {
1320
+ console.log(
1321
+ `
1322
+ ${style2.dim(`${plural2(stmts.length, "change")}${kinds ? ` \u2014 ${kinds}` : ""} \u2014 run \`schemic push\` to apply.`)}`
1323
+ );
1324
+ return;
1325
+ }
1326
+ await driver.apply(db2, stmts);
1327
+ const pruned = opts.prune === false ? 0 : (diff.items ?? []).filter((it) => it.op === "remove").length;
1328
+ console.log(
1329
+ `
1330
+ ${ok2(`synced ${plural2(stmts.length - pruned, "object")}${pruned ? `, pruned ${pruned}` : ""}${kinds ? ` (${kinds})` : ""}.`)}`
1331
+ );
1332
+ };
1333
+ if (!opts.watch) {
1334
+ await withDb(opts, (db2) => once(db2));
1335
+ return;
1336
+ }
1337
+ const db = await driver.connect(config, opts);
1338
+ await watchLoop(
1339
+ config,
1340
+ () => once(db),
1341
+ () => driver.close(db)
1342
+ );
1343
+ });
1344
+ }
1345
+ );
1346
+ dbFlags(
1347
+ program.command("seed").description("Run the project's seed script")
1348
+ ).action((opts) => {
1349
+ run(
1350
+ () => withDb(opts, async (db, config) => {
1351
+ await seed(db, config);
1352
+ console.log(ok2("Seed complete."));
1353
+ })
1354
+ );
1355
+ });
1356
+ function printPullPlan(plan) {
1357
+ for (const f of plan.files) {
1358
+ if (f.action === "unchanged") continue;
1359
+ console.log(`
1360
+ ${actionLabel(f.action)} ${style2.bold(f.rel)}`);
1361
+ if (f.action === "delete") {
1362
+ console.log(
1363
+ style2.dim(
1364
+ ` whole file removed \u2014 ${f.localOnly.objects.join(", ")} not in the database`
1365
+ )
1366
+ );
1367
+ continue;
1368
+ }
1369
+ console.log(
1370
+ lineDiff(f.before, f.after).split("\n").map((l) => ` ${l}`).join("\n")
1371
+ );
1372
+ }
1373
+ }
1374
+ function printLocalOnly(files) {
1375
+ console.log(`
1376
+ ${style2.yellow("! local-only schema, not in the database:")}`);
1377
+ for (const f of files) {
1378
+ for (const fld of f.localOnly.fields)
1379
+ console.log(
1380
+ style2.dim(` ${f.rel}: ${fld.exportName} \u2192 ${fld.fields.join(", ")}`)
1381
+ );
1382
+ for (const obj of f.localOnly.objects)
1383
+ console.log(style2.dim(` ${f.rel}: ${obj} (whole definition)`));
1384
+ }
1385
+ console.log(style2.dim(" keep with --merge, or drop with --discard."));
1386
+ }
1387
+ kindFlags(
1388
+ dbFlags(
1389
+ program.command("pull").description("Generate/update Zod schema files from the live database").option("--write", "apply the changes (default: preview only)").option(
1390
+ "--merge",
1391
+ "keep local-only fields/objects (default: mirror the DB)"
1392
+ ).option(
1393
+ "--discard",
1394
+ "drop local-only fields/objects to mirror the DB exactly"
1395
+ )
1396
+ )
1397
+ ).action(
1398
+ (opts) => {
1399
+ run(
1400
+ () => withDb(opts, async (db, config) => {
1401
+ const driver = activeDriver(config);
1402
+ if (!driver.planPull)
1403
+ throw new Error(
1404
+ `the "${config.driver ?? "surrealdb"}" driver does not support \`pull\`.`
1405
+ );
1406
+ const plan = await driver.planPull(db, config, {
1407
+ filter: parseFilter2(opts),
1408
+ keepLocal: opts.merge
1409
+ });
1410
+ printPullPlan(plan);
1411
+ const changed = plan.files.filter((f) => f.action !== "unchanged");
1412
+ const atRisk = opts.merge ? [] : plan.files.filter(
1413
+ (f) => f.localOnly.fields.length || f.localOnly.objects.length
1414
+ );
1415
+ if (!changed.length && !atRisk.length) {
1416
+ console.log(ok2("Schema files already match the database."));
1417
+ return;
1418
+ }
1419
+ if (!opts.write) {
1420
+ if (changed.length)
1421
+ console.log(
1422
+ `
1423
+ ${style2.dim(`${plural2(changed.length, "file")} would change \u2014 run \`schemic pull --write\` to apply.`)}`
1424
+ );
1425
+ if (atRisk.length) printLocalOnly(atRisk);
1426
+ return;
1427
+ }
1428
+ if (atRisk.length && !opts.discard) {
1429
+ printLocalOnly(atRisk);
1430
+ throw new Error(
1431
+ "pull would overwrite local-only schema \u2014 re-run with --merge to keep it or --discard to mirror the database."
1432
+ );
1433
+ }
1434
+ const written = applyPull(plan);
1435
+ const base = await baseline(db, config);
1436
+ const removed = plan.files.filter((f) => f.action === "delete").length;
1437
+ const kept = opts.merge ? [] : plan.files.filter(
1438
+ (f) => f.action === "unchanged" && f.localOnly.objects.length
1439
+ );
1440
+ console.log(
1441
+ `
1442
+ ${ok2(`Pulled ${plural2(written.length, "file")} from the database${removed ? ` (${removed} removed)` : ""}.`)}`
1443
+ );
1444
+ if (base.created)
1445
+ console.log(
1446
+ style2.dim(
1447
+ ` baseline ${base.tag} recorded (snapshot synced, marked applied).`
1448
+ )
1449
+ );
1450
+ if (kept.length)
1451
+ console.log(
1452
+ style2.dim(
1453
+ ` ${plural2(kept.length, "file")} with local-only entities mixed with other code left in place \u2014 remove those entities by hand.`
1454
+ )
1455
+ );
1456
+ })
1457
+ );
1458
+ }
1459
+ );
1460
+ if (process.argv.length <= 2) {
1461
+ program.outputHelp();
1462
+ process.exit(0);
1463
+ }
1464
+ program.parse();