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