@better-schemic/core 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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/lib/authoring.d.ts +114 -0
  4. package/lib/authoring.js +242 -0
  5. package/lib/authoring.js.map +1 -0
  6. package/lib/chunk-26D7WX7Q.js +31 -0
  7. package/lib/chunk-26D7WX7Q.js.map +1 -0
  8. package/lib/chunk-IUPOUD4L.js +330 -0
  9. package/lib/chunk-IUPOUD4L.js.map +1 -0
  10. package/lib/chunk-LC3VHUM2.js +56 -0
  11. package/lib/chunk-LC3VHUM2.js.map +1 -0
  12. package/lib/chunk-RSGP7GVO.js +252 -0
  13. package/lib/chunk-RSGP7GVO.js.map +1 -0
  14. package/lib/client-HZF4ZWGO.js +13 -0
  15. package/lib/client-HZF4ZWGO.js.map +1 -0
  16. package/lib/config-BYh7WA4P.d.ts +259 -0
  17. package/lib/config.d.ts +2 -0
  18. package/lib/config.js +27 -0
  19. package/lib/config.js.map +1 -0
  20. package/lib/driver-LVldBEhS.d.ts +818 -0
  21. package/lib/driver.d.ts +151 -0
  22. package/lib/driver.js +47 -0
  23. package/lib/driver.js.map +1 -0
  24. package/lib/index.d.ts +154 -0
  25. package/lib/index.js +758 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/query.d.ts +81 -0
  28. package/lib/query.js +30 -0
  29. package/lib/query.js.map +1 -0
  30. package/lib/secrets-BETi5p8g.d.ts +26 -0
  31. package/lib/testing.d.ts +99 -0
  32. package/lib/testing.js +212 -0
  33. package/lib/testing.js.map +1 -0
  34. package/package.json +102 -0
  35. package/src/authoring.ts +360 -0
  36. package/src/cli-kit/config.ts +226 -0
  37. package/src/cli-kit/diff.ts +273 -0
  38. package/src/cli-kit/filter.ts +159 -0
  39. package/src/cli-kit/merge.ts +380 -0
  40. package/src/cli-kit/meta.ts +123 -0
  41. package/src/cli-kit/pager.ts +42 -0
  42. package/src/cli-kit/schema.ts +214 -0
  43. package/src/cli-kit/style.ts +24 -0
  44. package/src/client.ts +244 -0
  45. package/src/config.ts +199 -0
  46. package/src/connection.ts +120 -0
  47. package/src/driver/driver.ts +413 -0
  48. package/src/driver/index.ts +31 -0
  49. package/src/driver/portable-ir.ts +51 -0
  50. package/src/driver/portable.ts +124 -0
  51. package/src/driver/sdk.ts +73 -0
  52. package/src/index.ts +185 -0
  53. package/src/kind/index.ts +28 -0
  54. package/src/kind/plan.ts +412 -0
  55. package/src/kind/registry.ts +270 -0
  56. package/src/query/call.ts +21 -0
  57. package/src/query/codec.ts +33 -0
  58. package/src/query/index.ts +22 -0
  59. package/src/query/project.ts +25 -0
  60. package/src/query/ref.ts +32 -0
  61. package/src/query.ts +5 -0
  62. package/src/secrets.ts +61 -0
  63. package/src/seed.ts +14 -0
  64. package/src/testing.ts +402 -0
@@ -0,0 +1,214 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Authored, AuthoredDef } from "@better-schemic/core";
4
+ import { makeJiti } from "./config";
5
+
6
+ /**
7
+ * The NEUTRAL view of a loaded table the engine reads — just `name` plus the `config.relation` flag
8
+ * used for ordering. A driver casts this to its own concrete table builder in `lower`. (The runtime
9
+ * object is the driver's real `TableDef`; the engine never names that type.)
10
+ */
11
+
12
+ /** Import one schema module, wrapping a crash with the FAILING FILE path (original as `cause`). */
13
+ async function importSchemaModule(
14
+ jiti: ReturnType<typeof makeJiti>,
15
+ file: string,
16
+ ): Promise<unknown> {
17
+ try {
18
+ return await jiti.import(file);
19
+ } catch (err) {
20
+ throw new Error(
21
+ `failed to load schema module ${file}: ${err instanceof Error ? err.message : String(err)}`,
22
+ { cause: err },
23
+ );
24
+ }
25
+ }
26
+
27
+ export interface AnyTable extends Authored {
28
+ readonly config: { readonly relation?: unknown };
29
+ }
30
+
31
+ /**
32
+ * Duck-typed `TableDef` check. We avoid `instanceof` on purpose: the user's schema and the
33
+ * CLI may end up with different module instances of `@better-schemic/core`, so we recognize a table
34
+ * by shape instead. (Structural access into `emitStatements` works regardless.)
35
+ */
36
+ function isTableDef(v: unknown): v is AnyTable {
37
+ if (!v || typeof v !== "object") return false;
38
+ const t = v as Record<string, unknown>;
39
+ return (
40
+ typeof t.name === "string" &&
41
+ typeof t.fields === "object" &&
42
+ t.fields !== null &&
43
+ typeof t.config === "object" &&
44
+ t.config !== null &&
45
+ typeof t.record === "function"
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Duck-typed standalone-def check — DRIVER-AGNOSTIC: any non-table object carrying a string `kind`
51
+ * and `name` is a standalone definable (event/function/access/enum/view/sequence/…). Core must not
52
+ * hardcode a dialect's kinds — the driver's `registry`/`explode` owns which kinds are valid. Tables
53
+ * are matched by `isTableDef` first, so they never reach here. See `isTableDef` on why not `instanceof`.
54
+ */
55
+ function isStandaloneDef(v: unknown): v is AuthoredDef {
56
+ if (!v || typeof v !== "object") return false;
57
+ const d = v as Record<string, unknown>;
58
+ return (
59
+ typeof d.kind === "string" &&
60
+ d.kind.length > 0 &&
61
+ typeof d.name === "string"
62
+ );
63
+ }
64
+
65
+ function tsFiles(dir: string): string[] {
66
+ const out: string[] = [];
67
+ for (const entry of readdirSync(dir).sort()) {
68
+ const p = join(dir, entry);
69
+ if (statSync(p).isDirectory()) out.push(...tsFiles(p));
70
+ else if (/\.(ts|mts|js|mjs)$/.test(entry) && !entry.endsWith(".d.ts"))
71
+ out.push(p);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** The schema module file(s) for a path: the file itself, or every module under the directory. */
77
+ function schemaFiles(path: string): string[] {
78
+ return statSync(path).isFile() ? [path] : tsFiles(path);
79
+ }
80
+
81
+ /** Import a schema module file and yield its exported tables/relations, paired with the file. */
82
+ async function* tablesIn(
83
+ jiti: ReturnType<typeof makeJiti>,
84
+ file: string,
85
+ ): AsyncGenerator<AnyTable> {
86
+ const mod = (await importSchemaModule(jiti, file)) as Record<
87
+ string,
88
+ unknown
89
+ >;
90
+ for (const value of Object.values(mod)) if (isTableDef(value)) yield value;
91
+ }
92
+
93
+ /**
94
+ * Load every schema object from `schemaPath` (a single `.ts` module, or a directory of them): the
95
+ * tables/relations (ordered normal-before-relation, then by name, for stable DDL) and the standalone
96
+ * defs (any non-table `{ kind, name }` definable — the driver's registry owns the kinds). One pass.
97
+ */
98
+ export async function loadDefs(schemaPath: string): Promise<{
99
+ tables: AnyTable[];
100
+ defs: AuthoredDef[];
101
+ /** Absolute source file each table/def was loaded from (for `diff`'s file annotations). */
102
+ fileOf: Map<AnyTable | AuthoredDef, string>;
103
+ }> {
104
+ if (!existsSync(schemaPath)) {
105
+ throw new Error(`Schema path not found: ${schemaPath}`);
106
+ }
107
+ const jiti = makeJiti();
108
+ const tables = new Map<string, AnyTable>();
109
+ const defs: AuthoredDef[] = [];
110
+ const fileOf = new Map<AnyTable | AuthoredDef, string>();
111
+ for (const file of schemaFiles(schemaPath)) {
112
+ const mod = (await importSchemaModule(jiti, file)) as Record<
113
+ string,
114
+ unknown
115
+ >;
116
+ for (const value of Object.values(mod)) {
117
+ if (isTableDef(value)) {
118
+ tables.set(value.name, value); // last def of a name wins
119
+ fileOf.set(value, file);
120
+ } else if (isStandaloneDef(value)) {
121
+ defs.push(value);
122
+ fileOf.set(value, file);
123
+ }
124
+ }
125
+ }
126
+ const rank = (t: AnyTable) => (t.config.relation ? 1 : 0);
127
+ const sorted = [...tables.values()].sort(
128
+ (a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name),
129
+ );
130
+ return { tables: sorted, defs, fileOf };
131
+ }
132
+
133
+ /** The tables/relations from `schemaPath` (standalone events excluded — see {@link loadDefs}). */
134
+ export async function loadSchemas(schemaPath: string): Promise<AnyTable[]> {
135
+ return (await loadDefs(schemaPath)).tables;
136
+ }
137
+
138
+ /** A schema file's exported entities (tables/functions/accesses) and whether it holds ONLY those. */
139
+ export interface LocalFileEntities {
140
+ /** Each schema entity by its export-const identifier + its DB name (table/function/access name). */
141
+ entities: { exportName: string; name: string; kind: "table" | "def" }[];
142
+ /** True when EVERY runtime export of the file is a schema entity (no helpers / other exports). */
143
+ pureSchema: boolean;
144
+ }
145
+
146
+ /**
147
+ * Scan each schema file for the tables/functions/accesses it exports (by export-const name), and
148
+ * whether the file is purely schema. `pull` uses this to find whole-entity local-only schema
149
+ * (entities the live DB doesn't have) and to decide whether a file is safe to delete when mirroring
150
+ * the DB. Standalone events are not whole entities (they attach to a table), so they don't count as
151
+ * entities — a file exporting one is therefore not `pureSchema` and won't be auto-deleted.
152
+ */
153
+ export async function scanLocalEntities(
154
+ schemaPath: string,
155
+ ): Promise<Map<string, LocalFileEntities>> {
156
+ if (!existsSync(schemaPath)) return new Map();
157
+ const jiti = makeJiti();
158
+ const out = new Map<string, LocalFileEntities>();
159
+ for (const file of schemaFiles(schemaPath)) {
160
+ const exports = Object.entries(
161
+ (await importSchemaModule(jiti, file)) as Record<string, unknown>,
162
+ );
163
+ const entities: LocalFileEntities["entities"] = [];
164
+ for (const [exportName, value] of exports) {
165
+ if (isTableDef(value))
166
+ entities.push({ exportName, name: value.name, kind: "table" });
167
+ else if (
168
+ isStandaloneDef(value) &&
169
+ (value.kind === "function" || value.kind === "access")
170
+ )
171
+ entities.push({ exportName, name: value.name, kind: "def" });
172
+ }
173
+ if (entities.length)
174
+ out.set(file, {
175
+ entities,
176
+ pureSchema: entities.length === exports.length,
177
+ });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /** Map of table name → the file that defines it (for `pull`'s duplicate-definition check). */
183
+ export async function existingTables(
184
+ schemaPath: string,
185
+ ): Promise<Map<string, string>> {
186
+ if (!existsSync(schemaPath)) return new Map();
187
+ const jiti = makeJiti();
188
+ const out = new Map<string, string>();
189
+ for (const file of schemaFiles(schemaPath)) {
190
+ for await (const t of tablesIn(jiti, file)) out.set(t.name, file);
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * Names defined in more than one place, mapped to the files that define them (a file repeats if it
197
+ * defines the same name twice). `loadSchemas` silently lets the last definition win, so this is how
198
+ * `doctor` surfaces the otherwise-invisible conflict.
199
+ */
200
+ export async function duplicateTables(
201
+ schemaPath: string,
202
+ ): Promise<Map<string, string[]>> {
203
+ if (!existsSync(schemaPath)) return new Map();
204
+ const jiti = makeJiti();
205
+ const seen = new Map<string, string[]>();
206
+ for (const file of schemaFiles(schemaPath)) {
207
+ for await (const t of tablesIn(jiti, file)) {
208
+ const files = seen.get(t.name);
209
+ if (files) files.push(file);
210
+ else seen.set(t.name, [file]);
211
+ }
212
+ }
213
+ return new Map([...seen].filter(([, files]) => files.length > 1));
214
+ }
@@ -0,0 +1,24 @@
1
+ // Tiny ANSI styling, gated on a TTY and honoring NO_COLOR. No dependency.
2
+ /** Whether colored output is enabled (a TTY and `NO_COLOR` unset). */
3
+ export const colorEnabled = () =>
4
+ Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
5
+ const paint = (code: number, s: string) =>
6
+ colorEnabled() ? `\x1b[${code}m${s}\x1b[0m` : s;
7
+
8
+ export const style = {
9
+ green: (s: string) => paint(32, s),
10
+ red: (s: string) => paint(31, s),
11
+ yellow: (s: string) => paint(33, s),
12
+ cyan: (s: string) => paint(36, s),
13
+ dim: (s: string) => paint(90, s),
14
+ bold: (s: string) => paint(1, s),
15
+ };
16
+
17
+ /** A green ✓ success line. */
18
+ export const ok = (s: string) => `${style.green("✓")} ${s}`;
19
+ /** A red ✗ failure line. */
20
+ export const fail = (s: string) => `${style.red("✗")} ${s}`;
21
+
22
+ /** Pluralize `n thing` / `n things`. */
23
+ export const plural = (n: number, word: string) =>
24
+ `${n} ${word}${n === 1 ? "" : "s"}`;
package/src/client.ts ADDED
@@ -0,0 +1,244 @@
1
+ // The neutral foundation for the bound ORM CLIENT (P1) — the runtime read/write handle each driver
2
+ // builds on. Core owns only the dialect-agnostic parts: the disposable lifecycle contract and the
3
+ // managed-connection resolution. A driver's client (`@better-schemic/<driver>/client`) extends `OrmClientBase`,
4
+ // binds its native connection, and adds its TYPED `select`/`call` (+ writes in P2). See
5
+ // docs/proposals/managed-connections-and-orm-client.md.
6
+
7
+ import {
8
+ loadProject,
9
+ type ResolvedConfig,
10
+ resolveConnectionConfig,
11
+ } from "./cli-kit/config";
12
+ import type { BetterSchemicConfig } from "./config";
13
+ import type { AnyConnectionEntry, ResolveContext } from "./connection";
14
+
15
+ /**
16
+ * The neutral bound-client contract. A driver's client extends this and adds its typed query surface.
17
+ * It is an `AsyncDisposable`, so `await using db = await connect()` closes it at block exit.
18
+ *
19
+ * DISPOSE RULE (hard): a MANAGED client (opened by {@link resolveConnection} + the driver's `connect`)
20
+ * closes the connection it opened; a BYO client (wrapping the user's own pool) MUST make `close` a
21
+ * NO-OP — we never close a connection the user owns. The driver enforces this at construction.
22
+ */
23
+ export interface OrmClientBase extends AsyncDisposable {
24
+ /** Close the underlying connection (managed only; a BYO client is a no-op — see the dispose rule). */
25
+ close(): Promise<void>;
26
+ }
27
+
28
+ /** Mixin the default `[Symbol.asyncDispose]` (= `close`) onto a client class's prototype. */
29
+ export function asyncDisposable<T extends { close(): Promise<void> }>(
30
+ proto: T,
31
+ ): void {
32
+ (proto as { [Symbol.asyncDispose]?: () => Promise<void> })[
33
+ Symbol.asyncDispose
34
+ ] = function (this: T) {
35
+ return this.close();
36
+ };
37
+ }
38
+
39
+ /** Options for {@link resolveConnection}: which connection, and where the config lives. */
40
+ export interface ResolveConnectionOptions {
41
+ /** Connection name; defaults to `defaultConnection`, else the sole connection, else `"default"`. */
42
+ name?: string;
43
+ /** Path to `better-schemic.config.ts` (else auto-discovered from `cwd`). */
44
+ config?: string;
45
+ /** Working directory to discover the config + resolve relative paths from. */
46
+ cwd?: string;
47
+ /** The resolver's typed args (its declared 2nd param) for a PARAMETERIZED connection. */
48
+ args?: unknown;
49
+ }
50
+
51
+ /**
52
+ * A live resolution context for RUNTIME connects: `ctx.connections.<name>` lazily opens the sibling
53
+ * via ITS entry's embedded client opener (so a resolver can query another connection to enumerate a
54
+ * fleet), with CYCLE detection; everything opened during resolution is closed when it settles.
55
+ */
56
+ function makeRuntimeContext(config: BetterSchemicConfig, root: string) {
57
+ const opened = new Map<string, Promise<unknown>>();
58
+ const resolving = new Set<string>();
59
+
60
+ const open = async (name: string): Promise<unknown> => {
61
+ if (resolving.has(name)) {
62
+ throw new Error(
63
+ `cyclic connection resolution: "${name}" is already resolving (a resolver reached back into itself via ctx.connections)`,
64
+ );
65
+ }
66
+ const entry = config.connections[name];
67
+ if (!entry) throw new Error(`ctx.connections.${name}: no such connection`);
68
+ if (!entry.client) {
69
+ throw new Error(
70
+ `ctx.connections.${name}: the "${entry.driver}" connection factory predates runtime cross-connection access — update @better-schemic/${entry.driver}`,
71
+ );
72
+ }
73
+ resolving.add(name);
74
+ try {
75
+ const bases = await entry.resolve(context, undefined);
76
+ if (bases.length !== 1) {
77
+ throw new Error(
78
+ `ctx.connections.${name}: resolved to ${bases.length} configs — a sibling reached via ctx.connections must resolve to exactly one`,
79
+ );
80
+ }
81
+ const rc = resolveConnectionConfig(
82
+ config,
83
+ name,
84
+ bases[0],
85
+ entry.driver,
86
+ root,
87
+ );
88
+ return await entry.client(rc);
89
+ } finally {
90
+ resolving.delete(name);
91
+ }
92
+ };
93
+
94
+ const context: ResolveContext = {
95
+ env: process.env,
96
+ connections: new Proxy(
97
+ {},
98
+ {
99
+ get(_t, name: string) {
100
+ const openOnce = () => {
101
+ let client = opened.get(name);
102
+ if (!client) {
103
+ client = open(name);
104
+ opened.set(name, client);
105
+ }
106
+ return client;
107
+ };
108
+ // The handle is THENABLE to the sibling's FULL ORM client (typed in the chained form:
109
+ // `const main = await ctx.connections.main; main.select(...)`) and keeps a direct
110
+ // `.query` for the neutral/literal form. Do NOT stash the client past resolution — it is
111
+ // closed when resolution settles.
112
+ return {
113
+ query: async (sql: string, vars?: Record<string, unknown>) => {
114
+ const db = (await openOnce()) as {
115
+ query(sql: string, vars?: unknown): Promise<unknown>;
116
+ };
117
+ return db.query(sql, vars);
118
+ },
119
+ then: (
120
+ onOk?: (v: unknown) => unknown,
121
+ onErr?: (e: unknown) => unknown,
122
+ ) => openOnce().then(onOk, onErr),
123
+ };
124
+ },
125
+ },
126
+ ) as ResolveContext["connections"],
127
+ };
128
+
129
+ const closeOpened = async () => {
130
+ for (const c of opened.values()) {
131
+ try {
132
+ await ((await c) as { close?: () => Promise<void> }).close?.();
133
+ } catch {
134
+ // best-effort cleanup of resolution-time siblings
135
+ }
136
+ }
137
+ opened.clear();
138
+ };
139
+
140
+ return { context, closeOpened, resolving };
141
+ }
142
+
143
+ /**
144
+ * Resolve ONE named connection from the disk-discovered project config — the MANAGED path a driver's
145
+ * standalone `connect(name?)` uses before `driver.connect(config)`. Single-config or a teaching
146
+ * error (a bulk resolution must be arg-selected).
147
+ */
148
+ export async function resolveConnection(
149
+ opts: ResolveConnectionOptions = {},
150
+ ): Promise<ResolvedConfig> {
151
+ const { config, root } = await loadProject({
152
+ config: opts.config,
153
+ cwd: opts.cwd,
154
+ });
155
+ return exactlyOne(
156
+ await resolveFromConfig(config, root, { name: opts.name, args: opts.args }),
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The shared single-name resolution over an IN-MEMORY config: pick the entry (named /
162
+ * defaultConnection / sole / `"default"`), run the resolver with its typed `args`, and build one
163
+ * {@link ResolvedConfig} per returned config, each with a display label (config `key` > the entry's
164
+ * `label` hook > positional `name[i]`). Resolvers may query siblings via `ctx.connections` (lazy,
165
+ * cycle-checked; opened siblings are closed when resolution settles). Used by both
166
+ * {@link resolveConnection} (disk-discovered config) and {@link connectFromConfig} (`config.connect`).
167
+ */
168
+ export async function resolveFromConfig(
169
+ config: BetterSchemicConfig,
170
+ root: string,
171
+ opts: { name?: string; args?: unknown } = {},
172
+ ): Promise<{
173
+ entry: AnyConnectionEntry;
174
+ name: string;
175
+ resolved: ResolvedConfig[];
176
+ labels: string[];
177
+ }> {
178
+ const names = Object.keys(config.connections);
179
+ const name =
180
+ opts.name ??
181
+ config.defaultConnection ??
182
+ (names.length === 1 ? names[0] : "default");
183
+ const entry = config.connections[name];
184
+ if (!entry) {
185
+ throw new Error(
186
+ `connection "${name}" is not defined in the config (have: ${names.join(", ") || "none"})`,
187
+ );
188
+ }
189
+
190
+ const rt = makeRuntimeContext(config, root);
191
+ rt.resolving.add(name); // the target itself is mid-resolution — a self-reference is a cycle
192
+ try {
193
+ const bases = await entry.resolve(rt.context, opts.args);
194
+ if (!bases.length) {
195
+ throw new Error(`connection "${name}" resolved to no config`);
196
+ }
197
+ const resolved = bases.map((b) =>
198
+ resolveConnectionConfig(config, name, b, entry.driver, root),
199
+ );
200
+ const labels = resolved.map(
201
+ (rc, i) => bases[i].key ?? entry.label?.(rc) ?? `${name}[${i}]`,
202
+ );
203
+ return { entry, name, resolved, labels };
204
+ } finally {
205
+ rt.resolving.delete(name);
206
+ await rt.closeOpened();
207
+ }
208
+ }
209
+
210
+ /** Single-config resolution or a TEACHING error — bulk (array) resolutions must be arg-selected. */
211
+ function exactlyOne(r: {
212
+ name: string;
213
+ resolved: ResolvedConfig[];
214
+ labels: string[];
215
+ }): ResolvedConfig {
216
+ if (r.resolved.length !== 1) {
217
+ throw new Error(
218
+ `connection "${r.name}" resolved to ${r.resolved.length} configs (${r.labels.join(", ")}) — ` +
219
+ `a parameterized/bulk connection. Pass args selecting exactly one, e.g. betterSchemic.connect("${r.name}", { ... }).`,
220
+ );
221
+ }
222
+ return r.resolved[0];
223
+ }
224
+
225
+ /**
226
+ * The runtime behind `config.connect(name, args?)` (see `defineConfig`): resolve the entry from the
227
+ * in-memory config and open its bound ORM client via the factory-embedded
228
+ * {@link AnyConnectionEntry.client} opener. The static return type is the entry's own client type
229
+ * (inferred per entry in `BetterSchemicProject`).
230
+ */
231
+ export async function connectFromConfig(
232
+ config: BetterSchemicConfig,
233
+ name?: string,
234
+ args?: unknown,
235
+ ): Promise<unknown> {
236
+ const r = await resolveFromConfig(config, process.cwd(), { name, args });
237
+ const resolved = exactlyOne(r);
238
+ if (!r.entry.client) {
239
+ throw new Error(
240
+ `the "${r.entry.driver}" connection factory predates config.connect() — update @better-schemic/${r.entry.driver} to a version whose ${r.entry.driver}Connection embeds a client opener`,
241
+ );
242
+ }
243
+ return r.entry.client(resolved);
244
+ }
package/src/config.ts ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Configuration for the `better-schemic` CLI — author it in `better-schemic.config.ts`
3
+ * (`schemic.config.ts` still loads as a legacy alias).
4
+ *
5
+ * A project declares one or more named CONNECTIONS, each built by a per-driver factory
6
+ * (`<driver>Connection(...)` exported from `@better-schemic/<driver>/connection`). Connection values are EXPLICIT —
7
+ * there is no env-var magic; read env yourself where you want it (`url: process.env.MY_URL`).
8
+ * See `@better-schemic/core` docs/MULTI-CONNECTION.md.
9
+ *
10
+ * ```ts
11
+ * import { defineConfig } from "@better-schemic/core/config";
12
+ * import { surrealConnection } from "@better-schemic/surrealdb/connection";
13
+ *
14
+ * export default defineConfig({
15
+ * connections: {
16
+ * default: surrealConnection({
17
+ * schema: "./database/schema",
18
+ * url: "ws://localhost:8000",
19
+ * namespace: "app",
20
+ * database: "app",
21
+ * }),
22
+ * },
23
+ * });
24
+ * ```
25
+ *
26
+ * For MULTIPLE databases (multi-tenant / heterogeneous / DB-per-user), add more named connections;
27
+ * a connection may be a resolver (incl. an array → a collection). See docs/MULTI-CONNECTION.md.
28
+ *
29
+ * NOTE: this file is dialect-NEUTRAL. Driver-specific connection shapes (SurrealDB's
30
+ * url/namespace/authLevel, its check-engine options, …) live in the driver package's
31
+ * `<driver>Connection` factory, not here.
32
+ */
33
+ import type {
34
+ AnyConnectionEntry,
35
+ ConnectionConfigBase,
36
+ ConnectionEntry,
37
+ ConnectionInput,
38
+ ResolveContext,
39
+ ResolvedConnectionHandle,
40
+ } from "./connection";
41
+
42
+ export interface BetterSchemicConfig {
43
+ /** Named database connections — each produced by a per-driver `<driver>Connection(...)` factory. */
44
+ connections: Record<string, AnyConnectionEntry>;
45
+ /**
46
+ * With more than one connection, the connection a bare command targets (must name a single static
47
+ * connection). Absent + ambiguous → a live command errors asking for `--connection`.
48
+ */
49
+ defaultConnection?: string;
50
+ /** Table that records applied migrations (per connection). Default `_migrations`. */
51
+ migrationsTable?: string;
52
+ /** Optional seed script run by `better-schemic seed`. */
53
+ seed?: string;
54
+ }
55
+
56
+ /**
57
+ * Legacy alias — prefer {@link BetterSchemicConfig}.
58
+ */
59
+ export type SchemicConfig = BetterSchemicConfig;
60
+
61
+ /** The bound ORM client type a {@link ConnectionEntry} opens (inferred from the driver factory). */
62
+ // biome-ignore lint/suspicious/noExplicitAny: matching the erased Args slot.
63
+ export type EntryClient<E> =
64
+ E extends ConnectionEntry<infer Client, any> ? Client : never;
65
+ /** The typed resolver `args` a {@link ConnectionEntry} accepts (from its `args` schema). */
66
+ // biome-ignore lint/suspicious/noExplicitAny: matching the erased Client slot.
67
+ export type EntryArgs<E> =
68
+ E extends ConnectionEntry<any, infer Args> ? Args : never;
69
+
70
+ /**
71
+ * What {@link defineConfig} ADDS to your config: the config IS the app's typed entry point to its
72
+ * databases. `connect(name, args?)` autocompletes your connection names, types `args` per connection
73
+ * (the resolver's declared 2nd param — absent for a static/argless connection), and returns that
74
+ * entry's own client type (a heterogeneous-driver project types per-connection). A PARAMETERIZED
75
+ * connection whose resolver returns an ARRAY is bulk-only: `connect` throws a teaching error — pass
76
+ * `args` selecting ONE config. The client is disposable: `await using db = await betterSchemic.connect()`.
77
+ */
78
+ export interface BetterSchemicProject<
79
+ Conns extends Record<string, AnyConnectionEntry>,
80
+ > {
81
+ connect<N extends keyof Conns & string>(
82
+ name?: N,
83
+ args?: EntryArgs<Conns[N]>,
84
+ ): Promise<EntryClient<Conns[N]>>;
85
+ }
86
+
87
+ /**
88
+ * Type + enrich a Better-schemic config: returns the config with a typed `connect()` attached — the config
89
+ * itself is the factory. The loader accepts a `default` export OR the NAMED `betterSchemic` export
90
+ * (`schemic` still loads as a legacy alias); the scaffolded form is the named one
91
+ * (deterministic auto-import, no file rename needed):
92
+ *
93
+ * ```ts
94
+ * // better-schemic.config.ts (better-schemic.ts, schemic.config.ts, and schemic.ts also load)
95
+ * export const betterSchemic = defineConfig({ connections: { ... } });
96
+ * // app code: import { betterSchemic } from "./better-schemic.config"; → await using db = await betterSchemic.connect();
97
+ * ```
98
+ */
99
+ /**
100
+ * A resolver's `ctx` in the CHAINED form: `connections` is typed with the ACCUMULATED prior
101
+ * connections — each handle is thenable to that entry's FULL ORM client (`const main = await
102
+ * ctx.connections.main; main.select(...)`) and keeps a direct `.query`. Order = visibility: a
103
+ * resolver only sees connections declared BEFORE it (structural cycle prevention). Do not stash a
104
+ * sibling client — it is closed when resolution settles.
105
+ */
106
+ export type ChainCtx<Conns extends Record<string, AnyConnectionEntry>> = Omit<
107
+ ResolveContext,
108
+ "connections"
109
+ > & {
110
+ connections: {
111
+ [K in keyof Conns]: PromiseLike<EntryClient<Conns[K]>> &
112
+ ResolvedConnectionHandle;
113
+ };
114
+ };
115
+
116
+ /** The shape a driver connection factory must have to be used as the `.connection()` driver marker. */
117
+ export interface ChainableDriverFactory<
118
+ C extends ConnectionConfigBase,
119
+ Client,
120
+ > {
121
+ // biome-ignore lint/suspicious/noExplicitAny: the chain re-types input/args itself (variance cast).
122
+ (input: ConnectionInput<C, any>): ConnectionEntry<Client, any>;
123
+ }
124
+
125
+ /**
126
+ * The CHAINED config builder (`defineConfig().connection(...)`): each `.connection(name, factory,
127
+ * input)` uses the driver FACTORY ITSELF as the driver marker and contextually types the resolver's
128
+ * `ctx.connections` with everything declared so far. The literal `defineConfig({ connections })`
129
+ * form remains for static maps.
130
+ */
131
+ export interface ChainedConfig<Conns extends Record<string, AnyConnectionEntry>>
132
+ extends BetterSchemicProject<Conns> {
133
+ connections: Conns;
134
+ defaultConnection?: string;
135
+ migrationsTable?: string;
136
+ seed?: string;
137
+ connection<
138
+ N extends string,
139
+ C extends ConnectionConfigBase,
140
+ Client,
141
+ Args = undefined,
142
+ >(
143
+ name: N,
144
+ factory: ChainableDriverFactory<C, Client>,
145
+ input:
146
+ | C
147
+ | ((ctx: ChainCtx<Conns>, args: Args) => C | C[] | Promise<C | C[]>),
148
+ ): ChainedConfig<Conns & { [K in N]: ConnectionEntry<Client, Args> }>;
149
+ }
150
+
151
+ /** Start a CHAINED config: `defineConfig().connection("main", surrealConnection, {...})`. */
152
+ export function defineConfig(
153
+ base?: Omit<BetterSchemicConfig, "connections">,
154
+ ): ChainedConfig<Record<never, never>>;
155
+ /** Type + enrich a literal config — returns it with the typed `connect()` attached. */
156
+ export function defineConfig<const C extends BetterSchemicConfig>(
157
+ config: C,
158
+ ): C & BetterSchemicProject<C["connections"]>;
159
+ export function defineConfig(
160
+ config?: BetterSchemicConfig | Omit<BetterSchemicConfig, "connections">,
161
+ ): unknown {
162
+ const isLiteral = !!config && "connections" in config;
163
+ const base: BetterSchemicConfig = isLiteral
164
+ ? (config as BetterSchemicConfig)
165
+ : {
166
+ ...(config as Omit<BetterSchemicConfig, "connections"> | undefined),
167
+ connections: {},
168
+ };
169
+
170
+ const withApi = (cfg: BetterSchemicConfig): unknown => ({
171
+ ...cfg,
172
+ async connect(name?: string, args?: unknown) {
173
+ // Lazy: authoring/loading a config stays light; the client machinery loads only when used.
174
+ const { connectFromConfig } = await import("./client");
175
+ return connectFromConfig(cfg, name, args);
176
+ },
177
+ connection(
178
+ name: string,
179
+ factory: (input: unknown) => AnyConnectionEntry,
180
+ input: unknown,
181
+ ) {
182
+ // The factory IS the driver marker: it stamps the driver tag, config type, and client opener.
183
+ // The chain re-types ctx/args itself, so the factory is called through a variance cast.
184
+ const entry = factory(input as never);
185
+ return withApi({
186
+ ...cfg,
187
+ connections: { ...cfg.connections, [name]: entry },
188
+ });
189
+ },
190
+ });
191
+
192
+ return withApi(base);
193
+ }
194
+
195
+ /**
196
+ * Legacy alias — prefer {@link BetterSchemicProject}.
197
+ */
198
+ export type SchemicProject<Conns extends Record<string, AnyConnectionEntry>> =
199
+ BetterSchemicProject<Conns>;