@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.
@@ -0,0 +1,234 @@
1
+ // The multi-connection RESOLUTION ENGINE (design: @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 {
14
+ type ConnectionEntry,
15
+ type ConnectionOverrides,
16
+ type Driver,
17
+ driverNames,
18
+ getDriver,
19
+ isConnectionEntry,
20
+ loadProject,
21
+ type ResolveContext,
22
+ type ResolvedConfig,
23
+ type ResolvedConnectionHandle,
24
+ resolveConnectionConfig,
25
+ } from "@schemic/core";
26
+
27
+ /**
28
+ * Dynamically load + register a database driver by name. Drivers are separate packages
29
+ * (`@schemic/<name>`) that self-register with the core registry on import; the CLI itself contains no
30
+ * dialect code and discovers the driver from the project's connection config at runtime. Idempotent.
31
+ */
32
+ export async function ensureDriver(name: string): Promise<void> {
33
+ if (driverNames().includes(name)) return;
34
+ const pkg = `@schemic/${name}`;
35
+ try {
36
+ await import(pkg);
37
+ } catch (e) {
38
+ throw new Error(
39
+ `could not load the "${name}" database driver (package ${pkg}). Install it (e.g. \`bun add ${pkg}\`).\n ${e instanceof Error ? e.message : String(e)}`,
40
+ );
41
+ }
42
+ if (!driverNames().includes(name))
43
+ throw new Error(`package ${pkg} did not register a "${name}" driver.`);
44
+ }
45
+
46
+ /** Addressing + connection overrides every command accepts. */
47
+ export interface ResolveOpts extends ConnectionOverrides {
48
+ config?: string;
49
+ /** Address a single connection: `<name>` (whole connection/collection) or `<name>:<key>` (one element). */
50
+ connection?: string;
51
+ /** Resolve EVERY connection, fanning collections out to all their keyed elements. */
52
+ all?: boolean;
53
+ /** `--arg k=v` (repeatable) → ResolveContext.args, so a resolver can yield a subset. */
54
+ arg?: string[];
55
+ }
56
+
57
+ /** A commander `collect` reducer for repeatable `--arg` flags. */
58
+ export function collectArg(value: string, prev: string[]): string[] {
59
+ return [...prev, value];
60
+ }
61
+
62
+ /** Parse `["k=v", ...]` into `{ k: v }`; rejects an entry without `=`. */
63
+ function parseArgs(arg: string[] | undefined): Record<string, string> {
64
+ const out: Record<string, string> = {};
65
+ for (const a of arg ?? []) {
66
+ const i = a.indexOf("=");
67
+ if (i < 0) throw new Error(`--arg must be key=value (got "${a}").`);
68
+ out[a.slice(0, i)] = a.slice(i + 1);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Split a `<name>` / `<name>:<key>` address on the FIRST colon. */
74
+ function splitAddress(address: string): [name: string, key: string | undefined] {
75
+ const i = address.indexOf(":");
76
+ return i < 0 ? [address, undefined] : [address.slice(0, i), address.slice(i + 1)];
77
+ }
78
+
79
+ /**
80
+ * Resolve the addressed connection(s) to {@link ResolvedConfig}s. Builds the lazy cross-connection
81
+ * proxy (a resolver touching `ctx.connections.<other>` connects `<other>` on demand; cycles error),
82
+ * resolves the target entry/entries (one, a fanned-out collection, or all), then closes any sibling the
83
+ * proxy opened during resolution. Each returned connection's driver package is loaded/registered.
84
+ */
85
+ export async function resolveTargets(
86
+ opts: ResolveOpts,
87
+ ): Promise<ResolvedConfig[]> {
88
+ const { config, root } = await loadProject({ config: opts.config });
89
+ const args = parseArgs(opts.arg);
90
+ const names = Object.keys(config.connections);
91
+
92
+ // Siblings the lazy proxy connected during resolution — closed before we return.
93
+ const opened = new Map<
94
+ string,
95
+ { driver: Driver<unknown>; conn: unknown; driverName: string }
96
+ >();
97
+ const resolving = new Set<string>();
98
+
99
+ const entryOf = (name: string): ConnectionEntry => {
100
+ const entry = config.connections[name];
101
+ if (!isConnectionEntry(entry))
102
+ throw new Error(
103
+ `No connection named "${name}". Known: ${names.join(", ") || "(none)"}.`,
104
+ );
105
+ return entry;
106
+ };
107
+
108
+ // Resolve ONE connection to a single config. A bare collection is ambiguous → require a `:key`.
109
+ const resolveOneConfig = async (
110
+ name: string,
111
+ key?: string,
112
+ ): Promise<ResolvedConfig> => {
113
+ const entry = entryOf(name);
114
+ const list = await entry.resolve(ctx);
115
+ const picked =
116
+ key !== undefined
117
+ ? list.find((c) => c.key === key)
118
+ : list.length === 1
119
+ ? list[0]
120
+ : undefined;
121
+ if (!picked) {
122
+ if (key !== undefined)
123
+ throw new Error(`Connection "${name}" has no element with key "${key}".`);
124
+ throw new Error(
125
+ `Connection "${name}" resolved to ${list.length} connections (a collection); address one with --connection ${name}:<key> or use --all.`,
126
+ );
127
+ }
128
+ return resolveConnectionConfig(config, name, picked, entry.driver, root);
129
+ };
130
+
131
+ // Connect a sibling on demand for a resolver's `ctx.connections.<name>.query(...)`. Cached; cyclic
132
+ // access (A resolves via B resolves via A) throws instead of looping.
133
+ const openConnection = async (name: string) => {
134
+ const cached = opened.get(name);
135
+ if (cached) return cached;
136
+ if (resolving.has(name))
137
+ throw new Error(`Connection cycle detected while resolving "${name}".`);
138
+ resolving.add(name);
139
+ try {
140
+ const resolved = await resolveOneConfig(name);
141
+ await ensureDriver(resolved.driver);
142
+ const driver = getDriver(resolved.driver) as Driver<unknown>;
143
+ const conn = await driver.connect(resolved, opts);
144
+ const handle = { driver, conn, driverName: resolved.driver };
145
+ opened.set(name, handle);
146
+ return handle;
147
+ } finally {
148
+ resolving.delete(name);
149
+ }
150
+ };
151
+
152
+ const connections = new Proxy(
153
+ {} as Record<string, ResolvedConnectionHandle>,
154
+ {
155
+ get(_t, prop): ResolvedConnectionHandle | undefined {
156
+ if (typeof prop !== "string") return undefined;
157
+ return {
158
+ async query(sql, vars) {
159
+ const { driver, conn, driverName } = await openConnection(prop);
160
+ if (!driver.query)
161
+ throw new Error(
162
+ `the "${driverName}" driver has no \`query\` capability (needed by a connection resolver).`,
163
+ );
164
+ return driver.query(conn, sql, vars);
165
+ },
166
+ };
167
+ },
168
+ },
169
+ );
170
+
171
+ const ctx: ResolveContext = { connections, args, env: process.env };
172
+
173
+ // Fan a whole connection (single or collection) out to its config(s).
174
+ const fanOut = async (name: string): Promise<ResolvedConfig[]> => {
175
+ const entry = entryOf(name);
176
+ const list = await entry.resolve(ctx);
177
+ return list.map((conn) =>
178
+ resolveConnectionConfig(config, name, conn, entry.driver, root),
179
+ );
180
+ };
181
+
182
+ try {
183
+ let targets: ResolvedConfig[];
184
+ if (opts.all) {
185
+ targets = [];
186
+ for (const name of names) targets.push(...(await fanOut(name)));
187
+ } else if (opts.connection) {
188
+ const [name, key] = splitAddress(opts.connection);
189
+ targets =
190
+ key !== undefined
191
+ ? [await resolveOneConfig(name, key)]
192
+ : await fanOut(name);
193
+ } else {
194
+ const name =
195
+ config.defaultConnection ?? (names.length === 1 ? names[0] : "default");
196
+ if (!config.connections[name])
197
+ throw new Error(
198
+ `No default connection. Set "defaultConnection" or pass --connection. Known: ${names.join(", ") || "(none)"}.`,
199
+ );
200
+ targets = [await resolveOneConfig(name)];
201
+ }
202
+ if (!targets.length)
203
+ throw new Error("No connections matched — nothing to do.");
204
+ for (const driver of new Set(targets.map((t) => t.driver)))
205
+ await ensureDriver(driver);
206
+ return targets;
207
+ } finally {
208
+ for (const { driver, conn } of opened.values()) {
209
+ try {
210
+ await driver.close(conn);
211
+ } catch {
212
+ // best-effort: a sibling opened only to compute the connection list
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Resolve to EXACTLY ONE connection — for commands that operate on a single connection (`diff`,
220
+ * `gen`, `check`, `new`, `snapshot`, `doctor`). `--connection <name>` picks it; `--all` and a bare
221
+ * collection are rejected with the command-appropriate guidance.
222
+ */
223
+ export async function resolveOne(opts: ResolveOpts): Promise<ResolvedConfig> {
224
+ if (opts.all)
225
+ throw new Error(
226
+ "--all is not supported here — this command operates on a single connection. Use --connection <name>.",
227
+ );
228
+ const targets = await resolveTargets(opts);
229
+ if (targets.length !== 1)
230
+ throw new Error(
231
+ `--connection addressed ${targets.length} connections (a collection) — pin one with --connection <name>:<key>.`,
232
+ );
233
+ return targets[0];
234
+ }