@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
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// The multi-connection RESOLUTION ENGINE (design: @better-schemic/core docs/MULTI-CONNECTION.md). A project's
|
|
2
|
+
// config maps names to CONNECTIONS; this layer turns a CLI invocation + addressing flags into the
|
|
3
|
+
// concrete {@link ResolvedConfig}(s) the commands run against:
|
|
4
|
+
// - `--connection <name>` a single connection (or a whole collection, fanned out)
|
|
5
|
+
// - `--connection <name>:<key>` one element of a collection
|
|
6
|
+
// - `--all` every connection (collections fanned out to all their elements)
|
|
7
|
+
// - default `defaultConnection`, or the sole connection, else `"default"`
|
|
8
|
+
// - `--arg k=v` (repeatable) fed to resolvers via ResolveContext.args
|
|
9
|
+
// A resolver may reach SIBLING connections through `ctx.connections.<name>.query(...)`; that proxy
|
|
10
|
+
// connects the sibling on demand (the dependency graph falls out of access; cycles error) and we close
|
|
11
|
+
// anything it opened once resolution settles. The returned configs are connected FRESH by each command.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
type AnyConnectionEntry,
|
|
19
|
+
type ConnectionOverrides,
|
|
20
|
+
type Driver,
|
|
21
|
+
driverNames,
|
|
22
|
+
getDriver,
|
|
23
|
+
isConnectionEntry,
|
|
24
|
+
loadProject,
|
|
25
|
+
type ResolveContext,
|
|
26
|
+
type ResolvedConfig,
|
|
27
|
+
type ResolvedConnectionHandle,
|
|
28
|
+
resolveConnectionConfig,
|
|
29
|
+
} from "@better-schemic/core";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dynamically load + register a database driver by name. Drivers are separate packages
|
|
33
|
+
* (`@better-schemic/<name>`) that self-register with the core registry on import; the CLI itself contains no
|
|
34
|
+
* dialect code and discovers the driver from the project's connection config at runtime. Idempotent.
|
|
35
|
+
*/
|
|
36
|
+
/** Pick a package's COMPILED entry from its exports, deliberately skipping the `bun` condition. */
|
|
37
|
+
function pickCompiled(node: unknown): string | undefined {
|
|
38
|
+
if (typeof node === "string") return node;
|
|
39
|
+
if (node && typeof node === "object") {
|
|
40
|
+
const o = node as Record<string, unknown>;
|
|
41
|
+
// import/default/require are the published (compiled) conditions; never `bun` (raw src/*.ts).
|
|
42
|
+
return (
|
|
43
|
+
pickCompiled(o.import) ??
|
|
44
|
+
pickCompiled(o.default) ??
|
|
45
|
+
pickCompiled(o.require)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a driver package's COMPILED entry file for the given `subpath` export, from `base`'s module
|
|
53
|
+
* scope. We read the manifest and pick the `import`/`default` export rather than letting the runtime
|
|
54
|
+
* choose the `bun` condition (raw `src/*.ts` for monorepo dev) — loading a PUBLISHED package's source is
|
|
55
|
+
* fragile. `subpath` is an exports key (`"."` for the index, `"./driver"` for the engine entry); a
|
|
56
|
+
* non-index subpath that the package doesn't declare returns `null` (so the caller can fall back).
|
|
57
|
+
*/
|
|
58
|
+
function compiledEntry(
|
|
59
|
+
pkg: string,
|
|
60
|
+
base: string,
|
|
61
|
+
subpath: string,
|
|
62
|
+
): string | null {
|
|
63
|
+
try {
|
|
64
|
+
const manifestPath = createRequire(base).resolve(`${pkg}/package.json`);
|
|
65
|
+
const m = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
|
66
|
+
exports?: Record<string, unknown>;
|
|
67
|
+
module?: string;
|
|
68
|
+
main?: string;
|
|
69
|
+
};
|
|
70
|
+
const exp = pickCompiled(m.exports?.[subpath]);
|
|
71
|
+
// The index falls back to module/main; a missing non-index subpath is simply absent.
|
|
72
|
+
const rel =
|
|
73
|
+
subpath === "." ? (exp ?? m.module ?? m.main ?? "index.js") : exp;
|
|
74
|
+
return rel ? join(dirname(manifestPath), rel) : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function ensureDriver(name: string): Promise<void> {
|
|
81
|
+
if (driverNames().includes(name)) return;
|
|
82
|
+
// Canonical scope first; the legacy `@schemic/<name>` scope still loads (pre-rename installs).
|
|
83
|
+
const pkgs = [`@better-schemic/${name}`, `@schemic/${name}`];
|
|
84
|
+
const pkg = pkgs[0];
|
|
85
|
+
// Try the USER's project (cwd) first, then the CLI's own module scope — the CLI is often run via
|
|
86
|
+
// `bunx`/`npx` from a temp dir, so a driver installed in the user's project must be found by cwd.
|
|
87
|
+
let lastErr: unknown;
|
|
88
|
+
let loaded = false;
|
|
89
|
+
// A driver registers via its `/driver` engine entry (the authoring index is side-effect-free, so
|
|
90
|
+
// importing it never registers). Load `/driver` from cwd's scope first (the CLI is often run via
|
|
91
|
+
// bunx/npx from a temp dir, so a driver in the user's project must be found by cwd), then the CLI's own.
|
|
92
|
+
for (const candidate of pkgs) {
|
|
93
|
+
for (const base of [join(process.cwd(), "noop.js"), import.meta.url]) {
|
|
94
|
+
const entry = compiledEntry(candidate, base, "./driver");
|
|
95
|
+
if (!entry) continue;
|
|
96
|
+
try {
|
|
97
|
+
await import(pathToFileURL(entry).href);
|
|
98
|
+
loaded = true;
|
|
99
|
+
break;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
lastErr = e;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (loaded) break;
|
|
105
|
+
// Plain specifier covers an unbuilt monorepo checkout (no lib; resolves the `/driver` bun -> src export).
|
|
106
|
+
try {
|
|
107
|
+
await import(`${candidate}/driver`);
|
|
108
|
+
loaded = true;
|
|
109
|
+
break;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
lastErr = e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!loaded)
|
|
115
|
+
throw new Error(
|
|
116
|
+
`could not load the "${name}" database driver from ${pkg}/driver. ` +
|
|
117
|
+
`Install it (and ensure it's >= 0.1.0-alpha.1, which exposes the /driver entry):\n bun add ${pkg}\n (${
|
|
118
|
+
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
119
|
+
})`,
|
|
120
|
+
);
|
|
121
|
+
if (!driverNames().includes(name))
|
|
122
|
+
throw new Error(
|
|
123
|
+
`package ${pkg}/driver did not register a "${name}" driver.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Addressing + connection overrides every command accepts. */
|
|
128
|
+
export interface ResolveOpts extends ConnectionOverrides {
|
|
129
|
+
config?: string;
|
|
130
|
+
/** Address a single connection: `<name>` (whole connection/collection) or `<name>:<key>` (one element). */
|
|
131
|
+
connection?: string;
|
|
132
|
+
/** Resolve EVERY connection, fanning collections out to all their keyed elements. */
|
|
133
|
+
all?: boolean;
|
|
134
|
+
/** `--arg k=v` (repeatable) sugar — merged into the resolver args. */
|
|
135
|
+
arg?: string[];
|
|
136
|
+
/** `--args <json>` — the resolver's typed args as one JSON object (k=v sugar merges over it). */
|
|
137
|
+
args?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A commander `collect` reducer for repeatable `--arg` flags. */
|
|
141
|
+
export function collectArg(value: string, prev: string[]): string[] {
|
|
142
|
+
return [...prev, value];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parse `["k=v", ...]` into `{ k: v }`; rejects an entry without `=`. */
|
|
146
|
+
function parseArgs(
|
|
147
|
+
arg: string[] | undefined,
|
|
148
|
+
argsJson?: string,
|
|
149
|
+
): Record<string, unknown> {
|
|
150
|
+
const out: Record<string, unknown> = {};
|
|
151
|
+
if (argsJson) {
|
|
152
|
+
try {
|
|
153
|
+
Object.assign(out, JSON.parse(argsJson) as Record<string, unknown>);
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error(`--args must be a JSON object (got "${argsJson}").`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const a of arg ?? []) {
|
|
159
|
+
const i = a.indexOf("=");
|
|
160
|
+
if (i < 0) throw new Error(`--arg must be key=value (got "${a}").`);
|
|
161
|
+
out[a.slice(0, i)] = a.slice(i + 1);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Split a `<name>` / `<name>:<key>` address on the FIRST colon. */
|
|
167
|
+
function splitAddress(
|
|
168
|
+
address: string,
|
|
169
|
+
): [name: string, key: string | undefined] {
|
|
170
|
+
const i = address.indexOf(":");
|
|
171
|
+
return i < 0
|
|
172
|
+
? [address, undefined]
|
|
173
|
+
: [address.slice(0, i), address.slice(i + 1)];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the addressed connection(s) to {@link ResolvedConfig}s. Builds the lazy cross-connection
|
|
178
|
+
* proxy (a resolver touching `ctx.connections.<other>` connects `<other>` on demand; cycles error),
|
|
179
|
+
* resolves the target entry/entries (one, a fanned-out collection, or all), then closes any sibling the
|
|
180
|
+
* proxy opened during resolution. Each returned connection's driver package is loaded/registered.
|
|
181
|
+
*/
|
|
182
|
+
export async function resolveTargets(
|
|
183
|
+
opts: ResolveOpts,
|
|
184
|
+
): Promise<ResolvedConfig[]> {
|
|
185
|
+
const { config, root } = await loadProject({ config: opts.config });
|
|
186
|
+
const args = parseArgs(opts.arg, opts.args);
|
|
187
|
+
const names = Object.keys(config.connections);
|
|
188
|
+
|
|
189
|
+
// Siblings the lazy proxy connected during resolution — closed before we return.
|
|
190
|
+
const opened = new Map<
|
|
191
|
+
string,
|
|
192
|
+
{ driver: Driver<unknown>; conn: unknown; driverName: string }
|
|
193
|
+
>();
|
|
194
|
+
const resolving = new Set<string>();
|
|
195
|
+
|
|
196
|
+
const entryOf = (name: string): AnyConnectionEntry => {
|
|
197
|
+
const entry = config.connections[name];
|
|
198
|
+
if (!isConnectionEntry(entry))
|
|
199
|
+
throw new Error(
|
|
200
|
+
`No connection named "${name}". Known: ${names.join(", ") || "(none)"}.`,
|
|
201
|
+
);
|
|
202
|
+
return entry;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Resolve ONE connection to a single config. A bare collection is ambiguous → require a `:key`.
|
|
206
|
+
const resolveOneConfig = async (
|
|
207
|
+
name: string,
|
|
208
|
+
key?: string,
|
|
209
|
+
): Promise<ResolvedConfig> => {
|
|
210
|
+
const entry = entryOf(name);
|
|
211
|
+
const list = await entry.resolve(ctx, args);
|
|
212
|
+
const picked =
|
|
213
|
+
key !== undefined
|
|
214
|
+
? list.find((c) => c.key === key)
|
|
215
|
+
: list.length === 1
|
|
216
|
+
? list[0]
|
|
217
|
+
: undefined;
|
|
218
|
+
if (!picked) {
|
|
219
|
+
if (key !== undefined)
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Connection "${name}" has no element with key "${key}".`,
|
|
222
|
+
);
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Connection "${name}" resolved to ${list.length} connections (a collection); address one with --connection ${name}:<key> or use --all.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return resolveConnectionConfig(config, name, picked, entry.driver, root);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Connect a sibling on demand for a resolver's `ctx.connections.<name>.query(...)`. Cached; cyclic
|
|
231
|
+
// access (A resolves via B resolves via A) throws instead of looping.
|
|
232
|
+
const openConnection = async (name: string) => {
|
|
233
|
+
const cached = opened.get(name);
|
|
234
|
+
if (cached) return cached;
|
|
235
|
+
if (resolving.has(name))
|
|
236
|
+
throw new Error(`Connection cycle detected while resolving "${name}".`);
|
|
237
|
+
resolving.add(name);
|
|
238
|
+
try {
|
|
239
|
+
const resolved = await resolveOneConfig(name);
|
|
240
|
+
await ensureDriver(resolved.driver);
|
|
241
|
+
const driver = getDriver(resolved.driver) as Driver<unknown>;
|
|
242
|
+
const conn = await driver.connect(resolved, opts);
|
|
243
|
+
const handle = { driver, conn, driverName: resolved.driver };
|
|
244
|
+
opened.set(name, handle);
|
|
245
|
+
return handle;
|
|
246
|
+
} finally {
|
|
247
|
+
resolving.delete(name);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const connections = new Proxy(
|
|
252
|
+
{} as Record<string, ResolvedConnectionHandle>,
|
|
253
|
+
{
|
|
254
|
+
get(_t, prop): ResolvedConnectionHandle | undefined {
|
|
255
|
+
if (typeof prop !== "string") return undefined;
|
|
256
|
+
return {
|
|
257
|
+
async query(sql, vars) {
|
|
258
|
+
const { driver, conn, driverName } = await openConnection(prop);
|
|
259
|
+
if (!driver.query)
|
|
260
|
+
throw new Error(
|
|
261
|
+
`the "${driverName}" driver has no \`query\` capability (needed by a connection resolver).`,
|
|
262
|
+
);
|
|
263
|
+
return driver.query(conn, sql, vars);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const ctx: ResolveContext = { connections, env: process.env };
|
|
271
|
+
|
|
272
|
+
// Fan a whole connection (single or collection) out to its config(s).
|
|
273
|
+
const fanOut = async (name: string): Promise<ResolvedConfig[]> => {
|
|
274
|
+
const entry = entryOf(name);
|
|
275
|
+
const list = await entry.resolve(ctx, args);
|
|
276
|
+
return list.map((conn) =>
|
|
277
|
+
resolveConnectionConfig(config, name, conn, entry.driver, root),
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
let targets: ResolvedConfig[];
|
|
283
|
+
if (opts.all) {
|
|
284
|
+
targets = [];
|
|
285
|
+
for (const name of names) targets.push(...(await fanOut(name)));
|
|
286
|
+
} else if (opts.connection) {
|
|
287
|
+
const [name, key] = splitAddress(opts.connection);
|
|
288
|
+
targets =
|
|
289
|
+
key !== undefined
|
|
290
|
+
? [await resolveOneConfig(name, key)]
|
|
291
|
+
: await fanOut(name);
|
|
292
|
+
} else {
|
|
293
|
+
const name =
|
|
294
|
+
config.defaultConnection ?? (names.length === 1 ? names[0] : "default");
|
|
295
|
+
if (!config.connections[name])
|
|
296
|
+
throw new Error(
|
|
297
|
+
`No default connection. Set "defaultConnection" or pass --connection. Known: ${names.join(", ") || "(none)"}.`,
|
|
298
|
+
);
|
|
299
|
+
targets = [await resolveOneConfig(name)];
|
|
300
|
+
}
|
|
301
|
+
if (!targets.length)
|
|
302
|
+
throw new Error("No connections matched — nothing to do.");
|
|
303
|
+
for (const driver of new Set(targets.map((t) => t.driver)))
|
|
304
|
+
await ensureDriver(driver);
|
|
305
|
+
return targets;
|
|
306
|
+
} finally {
|
|
307
|
+
for (const { driver, conn } of opened.values()) {
|
|
308
|
+
try {
|
|
309
|
+
await driver.close(conn);
|
|
310
|
+
} catch {
|
|
311
|
+
// best-effort: a sibling opened only to compute the connection list
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Resolve to EXACTLY ONE connection — for commands that operate on a single connection (`diff`,
|
|
319
|
+
* `gen`, `check`, `new`, `snapshot`, `doctor`). `--connection <name>` picks it; `--all` and a bare
|
|
320
|
+
* collection are rejected with the command-appropriate guidance.
|
|
321
|
+
*/
|
|
322
|
+
export async function resolveOne(opts: ResolveOpts): Promise<ResolvedConfig> {
|
|
323
|
+
if (opts.all)
|
|
324
|
+
throw new Error(
|
|
325
|
+
"--all is not supported here — this command operates on a single connection. Use --connection <name>.",
|
|
326
|
+
);
|
|
327
|
+
const targets = await resolveTargets(opts);
|
|
328
|
+
if (targets.length !== 1)
|
|
329
|
+
throw new Error(
|
|
330
|
+
`--connection addressed ${targets.length} connections (a collection) — pin one with --connection <name>:<key>.`,
|
|
331
|
+
);
|
|
332
|
+
return targets[0];
|
|
333
|
+
}
|