@better-schemic/cli 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }