@better-schemic/cli 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/lib/cli.js +2014 -0
- package/package.json +50 -0
- package/src/cli/action.ts +40 -0
- package/src/cli/driver-commands.ts +195 -0
- package/src/cli/index.ts +1358 -0
- package/src/cli/init.ts +56 -0
- package/src/cli/inspect.ts +318 -0
- package/src/cli/migrate.ts +606 -0
- package/src/cli/portable-diff.ts +91 -0
- package/src/cli/resolve.ts +333 -0
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import type { Driver } from "@better-schemic/core";
|
|
4
|
+
|
|
5
|
+
export interface InitResult {
|
|
6
|
+
created: string[];
|
|
7
|
+
skipped: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The empty migration snapshot a fresh project starts from. Dialect-NEUTRAL (the KindSnapshot shape is
|
|
12
|
+
* core's), tagged with the driver so the snapshot reader knows who owns its `schema` payload.
|
|
13
|
+
*/
|
|
14
|
+
function initialSnapshot(driver: string): string {
|
|
15
|
+
return `${JSON.stringify(
|
|
16
|
+
{
|
|
17
|
+
version: 3,
|
|
18
|
+
driver,
|
|
19
|
+
schema: { kinds: {} },
|
|
20
|
+
files: {},
|
|
21
|
+
},
|
|
22
|
+
null,
|
|
23
|
+
2,
|
|
24
|
+
)}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scaffold a fresh project for `driver`. The dialect files (config, sample schema, seed, env) come
|
|
29
|
+
* from the driver's {@link Driver.initScaffold}; the CLI adds the neutral migration snapshot. Never
|
|
30
|
+
* overwrites existing files. The CLI itself stays dialect-free — it only knows the file map.
|
|
31
|
+
*/
|
|
32
|
+
export function init(cwd: string, driver: Driver<unknown>): InitResult {
|
|
33
|
+
const scaffold = driver.initScaffold?.();
|
|
34
|
+
if (!scaffold)
|
|
35
|
+
throw new Error(
|
|
36
|
+
`the "${driver.name}" driver does not support \`better-schemic init\` scaffolding.`,
|
|
37
|
+
);
|
|
38
|
+
const files: Record<string, string> = {
|
|
39
|
+
...scaffold,
|
|
40
|
+
"database/migrations/meta/_snapshot.json": initialSnapshot(driver.name),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const created: string[] = [];
|
|
44
|
+
const skipped: string[] = [];
|
|
45
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
46
|
+
const abs = resolve(cwd, rel);
|
|
47
|
+
if (existsSync(abs)) {
|
|
48
|
+
skipped.push(rel);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
52
|
+
writeFileSync(abs, content);
|
|
53
|
+
created.push(rel);
|
|
54
|
+
}
|
|
55
|
+
return { created, skipped };
|
|
56
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// READ-ONLY inspection — `sc <kind> ls` / `sc <kind> info <name>` / `sc ls` (overview). Core-provided
|
|
2
|
+
// for EVERY kind in the neutral kind registry (so every driver gets them for free; no per-driver
|
|
3
|
+
// wiring). A read command shows what you DECLARED by default; drift stays diff/check's lane.
|
|
4
|
+
//
|
|
5
|
+
// BOTH grammars work: noun-first `sc <kind> ls`/`info` (matching the driver-command grammar, so
|
|
6
|
+
// `access` stops being special — it also carries rotate/push/etc.) AND verb-first `sc ls <kind>` /
|
|
7
|
+
// `sc info <kind> <name>`. `sc ls` with no kind is the cross-kind overview.
|
|
8
|
+
//
|
|
9
|
+
// Source (boolean flags, mutually exclusive): DEFAULT the DECLARED schema (the authored `define*` — the
|
|
10
|
+
// diff's "desired" side; always available even pre-`gen`, never stale, fully offline); `--snapshot` the
|
|
11
|
+
// metaDir baseline (last `gen`); `--live` the DB (introspection; matches `diff --live`, so "the DB" is
|
|
12
|
+
// one word across commands).
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type Driver,
|
|
16
|
+
type KindEngine,
|
|
17
|
+
type KindRegistry,
|
|
18
|
+
loadDefs,
|
|
19
|
+
lowerSchema,
|
|
20
|
+
type PortableObject,
|
|
21
|
+
type ResolvedConfig,
|
|
22
|
+
readSnapshot,
|
|
23
|
+
snapshotObjects,
|
|
24
|
+
style,
|
|
25
|
+
} from "@better-schemic/core";
|
|
26
|
+
import type { Command } from "commander";
|
|
27
|
+
import { runAction } from "./action";
|
|
28
|
+
import { type ResolveOpts, resolveOne } from "./resolve";
|
|
29
|
+
|
|
30
|
+
export type Source = "declared" | "snapshot" | "live";
|
|
31
|
+
|
|
32
|
+
/** Resolve the source from the boolean flags — default `declared`; `--snapshot`/`--live` are mutually exclusive. */
|
|
33
|
+
export function pickSource(opts: {
|
|
34
|
+
snapshot?: boolean;
|
|
35
|
+
live?: boolean;
|
|
36
|
+
}): Source {
|
|
37
|
+
if (opts.snapshot && opts.live)
|
|
38
|
+
throw new Error("--snapshot and --live are mutually exclusive");
|
|
39
|
+
if (opts.snapshot) return "snapshot";
|
|
40
|
+
if (opts.live) return "live";
|
|
41
|
+
return "declared";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: the registry erases each engine's A/P at this seam.
|
|
45
|
+
type AnyEngine = KindEngine<any, any>;
|
|
46
|
+
|
|
47
|
+
/** kind -> engine, built once from the neutral registry (the public `entries()` enumeration). */
|
|
48
|
+
function engineMap(registry: KindRegistry): Map<string, AnyEngine> {
|
|
49
|
+
return new Map(registry.entries());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The engine for `kind` (verb-first `sc ls <kind>` / `sc info <kind>`), or a teaching error. */
|
|
53
|
+
export function requireKind(
|
|
54
|
+
registry: KindRegistry,
|
|
55
|
+
engines: Map<string, AnyEngine>,
|
|
56
|
+
kind: string,
|
|
57
|
+
): AnyEngine {
|
|
58
|
+
const engine = engines.get(kind);
|
|
59
|
+
if (!engine)
|
|
60
|
+
throw new Error(
|
|
61
|
+
`unknown kind "${kind}" — expected one of: ${registry.names().join(", ")}`,
|
|
62
|
+
);
|
|
63
|
+
return engine;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The NAME of the structural container a nested kind is addressed under: the `parent` hook (addressing)
|
|
68
|
+
* or, failing that, `owner` (diff clustering) — so a kind that only declares `owner` still addresses
|
|
69
|
+
* dotted, while an owner-declining kind can opt into dotted addressing via `parent`.
|
|
70
|
+
*/
|
|
71
|
+
function parentName(
|
|
72
|
+
engine: AnyEngine | undefined,
|
|
73
|
+
obj: PortableObject,
|
|
74
|
+
): string | undefined {
|
|
75
|
+
return (engine?.parent?.(obj) ?? engine?.owner?.(obj))?.name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The addressable key: `parent.name` for a nested kind, else the bare `name`. */
|
|
79
|
+
export function addressOf(
|
|
80
|
+
engine: AnyEngine | undefined,
|
|
81
|
+
obj: PortableObject,
|
|
82
|
+
): string {
|
|
83
|
+
const parent = parentName(engine, obj);
|
|
84
|
+
return parent ? `${parent}.${obj.name}` : obj.name;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load the portable objects of the chosen source. `declared` explodes + lowers the authored schema (the
|
|
89
|
+
* diff's desired side — all kinds, incl. out-of-band ones like access); `snapshot` reads the metaDir
|
|
90
|
+
* baseline (migration-managed kinds only — access isn't snapshotted); `live` introspects the DB.
|
|
91
|
+
*/
|
|
92
|
+
async function loadObjects(
|
|
93
|
+
source: Source,
|
|
94
|
+
driver: Driver,
|
|
95
|
+
config: ResolvedConfig,
|
|
96
|
+
): Promise<PortableObject[]> {
|
|
97
|
+
if (source === "declared") {
|
|
98
|
+
const { tables, defs } = await loadDefs(config.schemaPath);
|
|
99
|
+
return lowerSchema(driver.registry, driver.explode(tables, defs));
|
|
100
|
+
}
|
|
101
|
+
if (source === "snapshot") {
|
|
102
|
+
try {
|
|
103
|
+
return snapshotObjects(readSnapshot(config.metaDir).schema);
|
|
104
|
+
} catch {
|
|
105
|
+
return []; // no snapshot yet (pre-`gen`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const conn = await driver.connect(config);
|
|
109
|
+
try {
|
|
110
|
+
return await driver.introspectAll(conn);
|
|
111
|
+
} finally {
|
|
112
|
+
await driver.close(conn);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Addresses of one kind's objects, sorted (the inventory of `ls`). */
|
|
117
|
+
export function addressesOfKind(
|
|
118
|
+
engine: AnyEngine,
|
|
119
|
+
kind: string,
|
|
120
|
+
objects: PortableObject[],
|
|
121
|
+
): string[] {
|
|
122
|
+
return objects
|
|
123
|
+
.filter((o) => o.kind === kind)
|
|
124
|
+
.map((o) => addressOf(engine, o))
|
|
125
|
+
.sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `sc <kind> ls` — list entities of one kind from the chosen source. */
|
|
129
|
+
async function lsKind(
|
|
130
|
+
kind: string,
|
|
131
|
+
engine: AnyEngine,
|
|
132
|
+
registry: KindRegistry,
|
|
133
|
+
driver: Driver,
|
|
134
|
+
config: ResolvedConfig,
|
|
135
|
+
source: Source,
|
|
136
|
+
json: boolean,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const objects = await loadObjects(source, driver, config);
|
|
139
|
+
const addresses = addressesOfKind(engine, kind, objects);
|
|
140
|
+
if (json) {
|
|
141
|
+
console.log(JSON.stringify({ kind, source, entities: addresses }, null, 2));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(
|
|
145
|
+
style.bold(
|
|
146
|
+
`${registry.display(kind).plural} (${addresses.length}) — ${source}`,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
if (!addresses.length) console.log(style.dim(" (none)"));
|
|
150
|
+
for (const address of addresses) console.log(` ${address}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `sc <kind> info <address>` — one entity's resolved DDL from the chosen source. */
|
|
154
|
+
async function infoKind(
|
|
155
|
+
kind: string,
|
|
156
|
+
address: string,
|
|
157
|
+
engine: AnyEngine,
|
|
158
|
+
driver: Driver,
|
|
159
|
+
config: ResolvedConfig,
|
|
160
|
+
source: Source,
|
|
161
|
+
json: boolean,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const objects = await loadObjects(source, driver, config);
|
|
164
|
+
const found = objects.find(
|
|
165
|
+
(o) => o.kind === kind && addressOf(engine, o) === address,
|
|
166
|
+
);
|
|
167
|
+
if (!found) throw new Error(`no ${kind} "${address}" in ${source}`);
|
|
168
|
+
if (json) {
|
|
169
|
+
console.log(
|
|
170
|
+
JSON.stringify(
|
|
171
|
+
{ kind, address, source, ddl: engine.emit(found) },
|
|
172
|
+
null,
|
|
173
|
+
2,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
console.log(engine.emit(found).join("\n"));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `sc ls` — cross-kind OVERVIEW: every kind + entity count from the chosen source. */
|
|
182
|
+
async function overview(
|
|
183
|
+
registry: KindRegistry,
|
|
184
|
+
driver: Driver,
|
|
185
|
+
config: ResolvedConfig,
|
|
186
|
+
source: Source,
|
|
187
|
+
json: boolean,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const objects = await loadObjects(source, driver, config);
|
|
190
|
+
const engines = engineMap(registry);
|
|
191
|
+
const rows = registry.names().map((kind) => ({
|
|
192
|
+
kind,
|
|
193
|
+
label: registry.display(kind).plural,
|
|
194
|
+
count: addressesOfKind(engines.get(kind) as AnyEngine, kind, objects)
|
|
195
|
+
.length,
|
|
196
|
+
}));
|
|
197
|
+
if (json) {
|
|
198
|
+
console.log(JSON.stringify({ source, kinds: rows }, null, 2));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
console.log(style.bold(`Schema overview — ${source}`));
|
|
202
|
+
const width = Math.max(0, ...rows.map((r) => r.label.length));
|
|
203
|
+
for (const r of rows) console.log(` ${r.label.padEnd(width)} ${r.count}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Register `sc <kind> ls`/`info` for EVERY registered kind (into the shared kind groups, so they sit
|
|
208
|
+
* beside any driver verbs like `access rotate`), plus the top-level `sc ls` overview. Called from the
|
|
209
|
+
* driver-command registration with the same `groupFor` map + `resolveOpts`.
|
|
210
|
+
*/
|
|
211
|
+
export function registerInspectVerbs(
|
|
212
|
+
program: Command,
|
|
213
|
+
driver: Driver,
|
|
214
|
+
groupFor: (kind: string) => Command,
|
|
215
|
+
resolveOpts: () => ResolveOpts,
|
|
216
|
+
): void {
|
|
217
|
+
const registry = driver.registry;
|
|
218
|
+
const engines = engineMap(registry);
|
|
219
|
+
// The three source flags (default declared); `--live` matches `diff --live` so "the DB" is one word.
|
|
220
|
+
const srcOpts = (c: Command): Command =>
|
|
221
|
+
c
|
|
222
|
+
.option("--snapshot", "inspect the metaDir baseline (last `gen`)")
|
|
223
|
+
.option("--live", "inspect the live database (introspection)")
|
|
224
|
+
.option("--json", "output JSON");
|
|
225
|
+
type Opts = { snapshot?: boolean; live?: boolean; json?: boolean };
|
|
226
|
+
|
|
227
|
+
for (const kind of registry.names()) {
|
|
228
|
+
const engine = engines.get(kind) as AnyEngine;
|
|
229
|
+
const group = groupFor(kind);
|
|
230
|
+
|
|
231
|
+
srcOpts(
|
|
232
|
+
group
|
|
233
|
+
.command("ls")
|
|
234
|
+
.summary(`list ${registry.display(kind).plural.toLowerCase()}`),
|
|
235
|
+
).action((opts: Opts) =>
|
|
236
|
+
runAction(async () => {
|
|
237
|
+
const config = await resolveOne(resolveOpts());
|
|
238
|
+
await lsKind(
|
|
239
|
+
kind,
|
|
240
|
+
engine,
|
|
241
|
+
registry,
|
|
242
|
+
driver,
|
|
243
|
+
config,
|
|
244
|
+
pickSource(opts),
|
|
245
|
+
!!opts.json,
|
|
246
|
+
);
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
srcOpts(
|
|
251
|
+
group
|
|
252
|
+
.command("info <name>")
|
|
253
|
+
.summary(`show one ${kind}'s resolved definition`),
|
|
254
|
+
).action((name: string, opts: Opts) =>
|
|
255
|
+
runAction(async () => {
|
|
256
|
+
const config = await resolveOne(resolveOpts());
|
|
257
|
+
await infoKind(
|
|
258
|
+
kind,
|
|
259
|
+
name,
|
|
260
|
+
engine,
|
|
261
|
+
driver,
|
|
262
|
+
config,
|
|
263
|
+
pickSource(opts),
|
|
264
|
+
!!opts.json,
|
|
265
|
+
);
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Verb-first forms, alongside the noun-first `sc <kind> ls`/`info`: `sc ls [kind]` (a cross-kind
|
|
271
|
+
// overview when omitted, else that kind's entities) and `sc info <kind> <name>`. Same actions, so both
|
|
272
|
+
// grammars behave identically; an unknown kind gets a teaching error.
|
|
273
|
+
srcOpts(
|
|
274
|
+
program
|
|
275
|
+
.command("ls [kind]")
|
|
276
|
+
.summary("overview of all kinds, or list one kind's entities")
|
|
277
|
+
.description(
|
|
278
|
+
"With no KIND: a cross-kind overview (kinds + counts). With a KIND: that kind's entities — the verb-first form of `sc <kind> ls`. `--snapshot`/`--live` switch the source (default: declared).",
|
|
279
|
+
),
|
|
280
|
+
).action((kind: string | undefined, opts: Opts) =>
|
|
281
|
+
runAction(async () => {
|
|
282
|
+
const config = await resolveOne(resolveOpts());
|
|
283
|
+
if (kind)
|
|
284
|
+
await lsKind(
|
|
285
|
+
kind,
|
|
286
|
+
requireKind(registry, engines, kind),
|
|
287
|
+
registry,
|
|
288
|
+
driver,
|
|
289
|
+
config,
|
|
290
|
+
pickSource(opts),
|
|
291
|
+
!!opts.json,
|
|
292
|
+
);
|
|
293
|
+
else
|
|
294
|
+
await overview(registry, driver, config, pickSource(opts), !!opts.json);
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
srcOpts(
|
|
299
|
+
program
|
|
300
|
+
.command("info <kind> <name>")
|
|
301
|
+
.summary(
|
|
302
|
+
"show one entity's resolved definition (verb-first form of `sc <kind> info`)",
|
|
303
|
+
),
|
|
304
|
+
).action((kind: string, name: string, opts: Opts) =>
|
|
305
|
+
runAction(async () => {
|
|
306
|
+
const config = await resolveOne(resolveOpts());
|
|
307
|
+
await infoKind(
|
|
308
|
+
kind,
|
|
309
|
+
name,
|
|
310
|
+
requireKind(registry, engines, kind),
|
|
311
|
+
driver,
|
|
312
|
+
config,
|
|
313
|
+
pickSource(opts),
|
|
314
|
+
!!opts.json,
|
|
315
|
+
);
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
}
|