@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,412 @@
1
+ // The GENERIC migration spine over a {@link KindRegistry} — core's kind-blind orchestration. It
2
+ // classifies each portable object as add/change/remove, ORDERS them across kinds by a dependency
3
+ // graph, and emits up/down DDL + the display {@link Diff}. It never names a kind: every kind-specific
4
+ // decision is delegated to that kind's {@link KindEngine}.
5
+ //
6
+ // The spine works on PORTABLE objects (both sides already lowered), exactly like the fixed-slot
7
+ // `Driver.diff(prev, next)`: the stored snapshot IS portable, and the authoring side is lowered once
8
+ // via {@link lowerSchema}. So `prev` is a snapshot, `next` is `lowerSchema(registry, defs)`.
9
+ //
10
+ // Cross-kind ordering is the load-bearing part (docs/kind-registry.md §7.1). THREE layers:
11
+ // 1. dependency GRAPH + topological sort -> CORRECTNESS (an object emits after everything it deps on)
12
+ // 2. kind ORDINAL (registration order) -> stable TIE-BREAK among independent objects (layering)
13
+ // 3. OWNER clustering -> READABILITY (an index right after its table)
14
+ // A per-kind ordinal ALONE is wrong: a table's event can call a function, so the function must emit
15
+ // BEFORE the table — a function-before-table the graph handles and an ordinal cannot. Drops reverse it.
16
+
17
+ // NOTE: `Diff`/`DiffItem` are a type-only import (erased at compile — no runtime cli->kind coupling),
18
+ // the same arrangement as ./driver/portable-diff.ts.
19
+ import type { Diff, DiffItem } from "../cli-kit/diff";
20
+ import type {
21
+ Definable,
22
+ KindEngine,
23
+ KindRegistry,
24
+ PortableObject,
25
+ Ref,
26
+ } from "./registry";
27
+
28
+ const refKey = (r: Ref) => `${r.kind}:${r.name}`;
29
+
30
+ /** A node in the dependency graph: identity + the edges/owner used to order it. */
31
+ export interface OrderNode {
32
+ readonly kind: string;
33
+ readonly name: string;
34
+ /** Objects this node must come AFTER (only intra-set refs constrain; external refs are ignored). */
35
+ readonly deps: Ref[];
36
+ /** Owning object to cluster next to (readability tie-break only; never overrides `deps`). */
37
+ readonly owner?: Ref;
38
+ }
39
+
40
+ /**
41
+ * Kahn's topological sort with two presentation tweaks among the nodes whose deps are all satisfied:
42
+ * prefer one OWNED by the currently-open cluster (so a table's children follow it), then lowest
43
+ * (kind-ordinal, then name). Correctness (deps) always wins — an owned/low-ordinal node can't jump a
44
+ * dependency. A genuine cycle throws (a named error). Refs to nodes outside `nodes` are ignored (an
45
+ * object may depend on something untouched by this diff — it already exists / isn't changing).
46
+ */
47
+ export function orderObjects<T extends OrderNode>(
48
+ nodes: T[],
49
+ ordinalOf: (kind: string) => number,
50
+ ): T[] {
51
+ const byKey = new Map(nodes.map((n) => [refKey(n), n]));
52
+ const indeg = new Map<string, number>(nodes.map((n) => [refKey(n), 0]));
53
+ const dependents = new Map<string, string[]>();
54
+ for (const n of nodes)
55
+ for (const d of n.deps) {
56
+ if (!byKey.has(refKey(d))) continue; // external dep -> not a constraint within this set
57
+ indeg.set(refKey(n), (indeg.get(refKey(n)) ?? 0) + 1);
58
+ const list = dependents.get(refKey(d)) ?? [];
59
+ list.push(refKey(n));
60
+ dependents.set(refKey(d), list);
61
+ }
62
+
63
+ const out: T[] = [];
64
+ const done = new Set<string>();
65
+ let group: string | undefined; // the last unowned node emitted == the open cluster
66
+ while (out.length < nodes.length) {
67
+ const ready = nodes.filter(
68
+ (n) => !done.has(refKey(n)) && indeg.get(refKey(n)) === 0,
69
+ );
70
+ if (ready.length === 0)
71
+ throw new Error(
72
+ `dependency cycle among: ${nodes
73
+ .filter((n) => !done.has(refKey(n)))
74
+ .map(refKey)
75
+ .join(", ")}`,
76
+ );
77
+ ready.sort((a, b) => {
78
+ const ao = a.owner && refKey(a.owner) === group ? 0 : 1; // prefer the open cluster
79
+ const bo = b.owner && refKey(b.owner) === group ? 0 : 1;
80
+ return (
81
+ ao - bo ||
82
+ ordinalOf(a.kind) - ordinalOf(b.kind) ||
83
+ refKey(a).localeCompare(refKey(b))
84
+ );
85
+ });
86
+ const next = ready[0];
87
+ out.push(next);
88
+ done.add(refKey(next));
89
+ if (!next.owner) group = refKey(next); // a top-level object opens a new cluster
90
+ for (const dep of dependents.get(refKey(next)) ?? [])
91
+ indeg.set(dep, (indeg.get(dep) ?? 1) - 1);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // --- lowering + snapshot ------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Author -> portable: lower each definable through its kind's engine (skipping unregistered kinds).
100
+ * The single place authoring becomes portable; everything downstream (diff/emit/snapshot) is portable.
101
+ */
102
+ export function lowerSchema(
103
+ registry: KindRegistry,
104
+ defs: Definable[],
105
+ ): PortableObject[] {
106
+ const out: PortableObject[] = [];
107
+ for (const d of defs) {
108
+ const engine = registry.engine(d.kind);
109
+ if (engine) out.push(engine.lower(d));
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * The registry SNAPSHOT — portable objects grouped by kind. The open, generic replacement for
116
+ * `PortableDb`'s fixed slots; serializes as plain JSON (it is plain data). Pre-launch: the format is
117
+ * free to change, no version migration.
118
+ */
119
+ export interface KindSnapshot {
120
+ kinds: Record<string, PortableObject[]>;
121
+ }
122
+
123
+ /**
124
+ * Group a flat portable schema into a snapshot (by kind). Pass `registry` to DROP kinds/objects
125
+ * marked {@link KindEngine.excludeFromMigrations} (e.g. SurrealDB key-bearing access) so unmanaged,
126
+ * secret-bearing objects never enter a snapshot / migration. Omit it to snapshot every object
127
+ * unchanged.
128
+ */
129
+ export function snapshotKinds(
130
+ schema: PortableObject[],
131
+ registry?: KindRegistry,
132
+ ): KindSnapshot {
133
+ const kinds: Record<string, PortableObject[]> = {};
134
+ for (const o of schema) {
135
+ if (registry?.isExcludedFromMigrations(o)) continue;
136
+ const bucket = kinds[o.kind] ?? [];
137
+ bucket.push(o);
138
+ kinds[o.kind] = bucket;
139
+ }
140
+ return { kinds };
141
+ }
142
+
143
+ /** Flatten a snapshot back into a portable schema (the inverse of {@link snapshotKinds}). */
144
+ export function snapshotObjects(snap: KindSnapshot): PortableObject[] {
145
+ return Object.values(snap.kinds).flat();
146
+ }
147
+
148
+ // --- diff / plan --------------------------------------------------------------------------------
149
+
150
+ /** One classified object change, carrying its ordering metadata + the portable sides for DDL. */
151
+ interface Change extends OrderNode {
152
+ readonly op: "add" | "change" | "remove";
153
+ readonly prev?: PortableObject;
154
+ readonly next?: PortableObject;
155
+ }
156
+
157
+ /** An up/down DDL program (each a list of statements). */
158
+ export interface KindPlan {
159
+ up: string[];
160
+ down: string[];
161
+ }
162
+
163
+ /** The canonical change-detection key for an object — the kind's `canonical`, else its emitted DDL. */
164
+ const canonicalOf = (engine: KindEngine, p: PortableObject): string =>
165
+ engine.canonical?.(p) ?? engine.emit(p).join("\n");
166
+
167
+ const orderNodeOf = (
168
+ engine: KindEngine,
169
+ portable: PortableObject,
170
+ ): OrderNode => ({
171
+ kind: portable.kind,
172
+ name: portable.name,
173
+ deps: engine.deps?.(portable) ?? [],
174
+ owner: engine.owner?.(portable),
175
+ });
176
+
177
+ /** Display identity: `kind:owner:name` (owner blank for a top-level object) + the display owner. */
178
+ const itemKey = (n: OrderNode) => `${n.kind}:${n.owner?.name ?? ""}:${n.name}`;
179
+ const itemTable = (n: OrderNode) => n.owner?.name ?? n.name;
180
+
181
+ const byKey = (schema: PortableObject[]) =>
182
+ new Map(schema.map((o) => [refKey(o), o]));
183
+
184
+ /**
185
+ * Classify both sides into ordered add/change/remove sets — the shared core of plan + diff. A `change`
186
+ * is two objects of the same key whose emitted DDL differs (same test as the fixed-slot engine). Each
187
+ * class is topologically ordered parent-first; the caller reverses one class for drops/inversion.
188
+ */
189
+ function orderedChanges(
190
+ registry: KindRegistry,
191
+ prev: PortableObject[],
192
+ next: PortableObject[],
193
+ ): { nonRemoves: Change[]; removes: Change[] } {
194
+ const prevByKey = byKey(prev);
195
+ const nextByKey = byKey(next);
196
+ const changes: Change[] = [];
197
+ for (const k of new Set([...prevByKey.keys(), ...nextByKey.keys()])) {
198
+ const p = prevByKey.get(k);
199
+ const n = nextByKey.get(k);
200
+ const portable = n ?? p;
201
+ if (!portable) continue;
202
+ // Migration-unmanaged kinds/objects (e.g. key-bearing access) never diff — they're reconciled
203
+ // out-of-band by driver commands, so they must not appear in gen/migrate/diff-live output.
204
+ // Central choke point (a per-object predicate is fed the object that would be emitted).
205
+ if (registry.isExcludedFromMigrations(portable)) continue;
206
+ const engine = registry.engine(portable.kind);
207
+ if (!engine) continue;
208
+ const node = orderNodeOf(engine, portable);
209
+ if (p && !n) changes.push({ op: "remove", prev: p, ...node });
210
+ else if (!p && n) changes.push({ op: "add", next: n, ...node });
211
+ else if (p && n && canonicalOf(engine, p) !== canonicalOf(engine, n))
212
+ changes.push({ op: "change", prev: p, next: n, ...node });
213
+ }
214
+ const ord = (kind: string) => registry.ordinal(kind);
215
+ return {
216
+ nonRemoves: orderObjects(
217
+ changes.filter((c) => c.op !== "remove"),
218
+ ord,
219
+ ),
220
+ removes: orderObjects(
221
+ changes.filter((c) => c.op === "remove"),
222
+ ord,
223
+ ),
224
+ };
225
+ }
226
+
227
+ const overwriteUp = (
228
+ engine: KindEngine,
229
+ a: PortableObject,
230
+ b: PortableObject,
231
+ ): string[] =>
232
+ engine.overwrite?.(a, b) ?? [...engine.remove(a), ...engine.emit(b)];
233
+
234
+ /**
235
+ * Diff two portable schema states into an executable up/down program, generically over the registry.
236
+ *
237
+ * `up` runs creates/changes parent-first (the dependency graph) then drops child-first; `down` is the
238
+ * mirror: recreate drops parent-first, then undo creates/changes child-first. We invert PER OBJECT (not
239
+ * by reversing the flat DDL list) so a kind's multi-line block — a table emitted with its fields —
240
+ * keeps its internal order in both directions.
241
+ */
242
+ export function planKinds(
243
+ registry: KindRegistry,
244
+ prev: PortableObject[],
245
+ next: PortableObject[],
246
+ ): KindPlan {
247
+ const { nonRemoves, removes } = orderedChanges(registry, prev, next);
248
+ const up: string[] = [];
249
+ const down: string[] = [];
250
+ for (const c of nonRemoves) {
251
+ const e = registry.engine(c.kind);
252
+ if (!e) continue;
253
+ if (c.op === "add" && c.next) up.push(...e.emit(c.next));
254
+ else if (c.op === "change" && c.prev && c.next)
255
+ up.push(...overwriteUp(e, c.prev, c.next));
256
+ }
257
+ for (const c of [...removes].reverse()) {
258
+ const e = registry.engine(c.kind); // drops child-first
259
+ if (e && c.prev) up.push(...e.remove(c.prev));
260
+ }
261
+ for (const c of removes) {
262
+ const e = registry.engine(c.kind); // recreate dropped objects parent-first
263
+ if (e && c.prev) down.push(...e.emit(c.prev));
264
+ }
265
+ for (const c of [...nonRemoves].reverse()) {
266
+ const e = registry.engine(c.kind); // undo creates/changes child-first
267
+ if (!e) continue;
268
+ if (c.op === "add" && c.next) down.push(...e.remove(c.next));
269
+ else if (c.op === "change" && c.prev && c.next)
270
+ down.push(...overwriteUp(e, c.next, c.prev));
271
+ }
272
+ return { up, down };
273
+ }
274
+
275
+ /**
276
+ * Display items for a change set, in up order (creates/changes parent-first, drops child-first). A kind
277
+ * with `displayItems` decomposes into FINE-grained sub-items (per-field, each carrying its `table` so
278
+ * the display groups them under it); otherwise it falls back to ONE whole-object item.
279
+ */
280
+ function diffItems(
281
+ registry: KindRegistry,
282
+ nonRemoves: Change[],
283
+ removes: Change[],
284
+ ): DiffItem[] {
285
+ const items: DiffItem[] = [];
286
+ const push = (c: Change) => {
287
+ const e = registry.engine(c.kind);
288
+ if (!e) return;
289
+ if (e.displayItems) {
290
+ items.push(...e.displayItems(c.prev, c.next));
291
+ return;
292
+ }
293
+ const base = { key: itemKey(c), table: itemTable(c), kind: c.kind };
294
+ if (c.op === "add" && c.next)
295
+ items.push({ ...base, op: "add", ddl: e.emit(c.next).join("\n") });
296
+ else if (c.op === "remove" && c.prev)
297
+ items.push({
298
+ ...base,
299
+ op: "remove",
300
+ ddl: e.remove(c.prev).join("\n"),
301
+ old: e.emit(c.prev).join("\n"),
302
+ });
303
+ else if (c.op === "change" && c.prev && c.next)
304
+ items.push({
305
+ ...base,
306
+ op: "change",
307
+ before: e.emit(c.prev).join("\n"),
308
+ after: e.emit(c.next).join("\n"),
309
+ });
310
+ };
311
+ for (const c of nonRemoves) push(c);
312
+ for (const c of [...removes].reverse()) push(c);
313
+ return items;
314
+ }
315
+
316
+ /**
317
+ * The full {@link Diff} the CLI + migration model consume — up/down DDL + per-object display items +
318
+ * the whole desired schema (`full`, for `--full`). This is what a driver's `Driver.diff` returns once
319
+ * its kinds are on the registry (the generic counterpart of the fixed-slot `buildDiff`). Source-file
320
+ * linkage on the items is attached by the caller (the snapshot's `files` map), so `file` is left unset.
321
+ */
322
+ export function buildKindDiff(
323
+ registry: KindRegistry,
324
+ prev: PortableObject[],
325
+ next: PortableObject[],
326
+ ): Diff {
327
+ const { nonRemoves, removes } = orderedChanges(registry, prev, next);
328
+ const { up, down } = planKinds(registry, prev, next);
329
+ // `full` mirrors the items' granularity: a kind with `displayItems` projects its object as per-
330
+ // sub-object adds (displayItems(undefined, portable)); otherwise one whole-object entry.
331
+ const full = orderedSchema(registry, next).flatMap(
332
+ ({ engine, portable, node }) => {
333
+ if (engine.displayItems)
334
+ return engine.displayItems(undefined, portable).map((it) => ({
335
+ key: it.key,
336
+ table: it.table,
337
+ ddl: it.op === "add" ? it.ddl : "",
338
+ }));
339
+ return [
340
+ {
341
+ key: itemKey(node),
342
+ table: itemTable(node),
343
+ ddl: engine.emit(portable).join("\n"),
344
+ },
345
+ ];
346
+ },
347
+ );
348
+ return { up, down, items: diffItems(registry, nonRemoves, removes), full };
349
+ }
350
+
351
+ /** Lower-already portable schema, topologically ordered, paired with each object's engine + node. */
352
+ function orderedSchema(
353
+ registry: KindRegistry,
354
+ schema: PortableObject[],
355
+ ): { engine: KindEngine; portable: PortableObject; node: OrderNode }[] {
356
+ const items = schema.flatMap((portable) => {
357
+ const engine = registry.engine(portable.kind);
358
+ return engine
359
+ ? [{ engine, portable, node: orderNodeOf(engine, portable) }]
360
+ : [];
361
+ });
362
+ const pos = new Map(
363
+ orderObjects(
364
+ items.map((i) => i.node),
365
+ (k) => registry.ordinal(k),
366
+ ).map((n, i) => [itemKey(n), i]),
367
+ );
368
+ return items.sort(
369
+ (a, b) => (pos.get(itemKey(a.node)) ?? 0) - (pos.get(itemKey(b.node)) ?? 0),
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Fresh-apply DDL for a portable schema: every object created, ordered across kinds by the graph.
375
+ * (The `up` of a diff from an empty state.) Lower authoring first via {@link lowerSchema}.
376
+ */
377
+ export function emitKinds(
378
+ registry: KindRegistry,
379
+ schema: PortableObject[],
380
+ ): string[] {
381
+ // Skip migration-unmanaged kinds/objects (e.g. key-bearing access) — they're applied out-of-band
382
+ // by driver commands.
383
+ const managed = schema.filter(
384
+ (o) => !registry.isExcludedFromMigrations(o),
385
+ );
386
+ return orderedSchema(registry, managed).flatMap(({ engine, portable }) =>
387
+ engine.emit(portable),
388
+ );
389
+ }
390
+
391
+ /**
392
+ * Reverse direction, fanned out across kinds: introspect every introspectable kind off one live
393
+ * connection and flatten into portable objects. The RESOLUTION of "per-kind vs one driver read":
394
+ * the contract is per-kind ({@link KindEngine.introspect}), but a driver backs all of its kinds with
395
+ * ONE shared (memoized) read of `conn` and slices out each kind's objects — so the fan-out here costs
396
+ * a single round-trip, not N. A kind without `introspect` contributes nothing (not introspectable).
397
+ */
398
+ export async function introspectKinds(
399
+ registry: KindRegistry,
400
+ conn: unknown,
401
+ ): Promise<PortableObject[]> {
402
+ const out: PortableObject[] = [];
403
+ for (const [kind, engine] of registry.entries()) {
404
+ if (!engine.introspect) continue;
405
+ // Skip STATICALLY migration-unmanaged kinds so the live side never phantom-diffs against a
406
+ // schema that (by design) excludes them. A per-object predicate can't be evaluated without the
407
+ // object — introspection still runs, and the diff choke point filters by object afterwards.
408
+ if (registry.skipsIntrospection(kind)) continue;
409
+ out.push(...(await engine.introspect(conn)));
410
+ }
411
+ return out;
412
+ }
@@ -0,0 +1,270 @@
1
+ // The KIND REGISTRY — core-v2's generic, open replacement for the fixed object-kind slots.
2
+ //
3
+ // Today `PortableDb` hard-codes the object kinds a schema may contain (`tables`/`functions`/
4
+ // `accesses`/`natives`) and the Driver's whole-DB methods switch on those slots. The kind registry
5
+ // turns the slots into a REGISTRY a driver populates: each driver registers KINDS, and every kind
6
+ // brings (a) its OWN authoring builder — any shape/chain it likes, fully typed — and (b) its engine
7
+ // behavior (`lower`/`emit`/`remove`/`overwrite`/`deps`/`owner`/`introspect`) over THAT kind's objects.
8
+ // Core orchestrates generically over the registry (see ./plan.ts) and never names a kind.
9
+ //
10
+ // What stays in core is the field/type VOCABULARY (`SFieldBase`, the Zod-drop-in `s.*`, `PortableType`,
11
+ // codecs) — the substrate every kind builds on. Fields/types are NOT a kind: a table HAS fields, a
12
+ // function's args ARE fields, an index REFERENCES fields. See docs/kind-registry.md.
13
+ //
14
+ // The registry is PER-DRIVER, not a module global: a driver builds one `KindRegistry` and registers
15
+ // its kinds into it.
16
+
17
+ /**
18
+ * An authored definable, tagged with the KIND that owns it. Core dispatches on `kind` alone — every
19
+ * other field is the kind's own business, handed straight to {@link KindEngine.lower}. This is the
20
+ * neutral upper bound for a kind's authoring-object type (a driver's concrete `TableDef`/`FnDef` is a
21
+ * structural subtype).
22
+ */
23
+ export interface Definable {
24
+ readonly kind: string;
25
+ readonly name: string;
26
+ }
27
+
28
+ /**
29
+ * A kind's PORTABLE object — the dialect-independent data shape core stores + diffs. A kind chooses
30
+ * how structured this is: a table's portable form carries fields/indexes (so core can field-level
31
+ * diff it); an opaque kind (function/access) carries a neutral identity + a `native` payload it
32
+ * round-trips. Either way it is tagged with `kind`/`name` for cross-kind dispatch + ordering.
33
+ */
34
+ export interface PortableObject {
35
+ readonly kind: string;
36
+ readonly name: string;
37
+ }
38
+
39
+ /** A reference to another object in the schema graph — the unit of cross-kind dependency ordering. */
40
+ export interface Ref {
41
+ readonly kind: string;
42
+ readonly name: string;
43
+ }
44
+
45
+ // `DiffItem` is a type-only import (erased at compile) — the display contract the `displayItems` hook
46
+ // produces; no runtime cli->kind coupling, same arrangement as ./plan.ts.
47
+ import type { DiffItem } from "../cli-kit/diff";
48
+
49
+ /**
50
+ * What core needs to orchestrate ONE kind generically — it never inspects the specifics. The
51
+ * change-vocabulary (`emit`/`remove`/`overwrite`) mirrors the Driver contract's, so a kind's behavior
52
+ * is parity-checkable against the fixed-slot engine. `A` is the kind's authoring object, `P` its
53
+ * portable object; both are opaque to core beyond the {@link Definable}/{@link PortableObject} bounds.
54
+ */
55
+ export interface KindEngine<
56
+ A extends Definable = Definable,
57
+ P extends PortableObject = PortableObject,
58
+ > {
59
+ /** Authoring object -> this kind's portable object (normalized; both lowerings must converge here). */
60
+ lower(authored: A): P;
61
+ /** CREATE DDL for one portable object (a fresh apply / migration `up` for an added object). */
62
+ emit(portable: P): string[];
63
+ /** DROP DDL for one portable object (`up` for a removed object, `down` for an added one). */
64
+ remove(portable: P): string[];
65
+ /**
66
+ * In-place CHANGE DDL taking `prev` to `next` (the dialect's ALTER/OVERWRITE). The spine calls
67
+ * `overwrite(next, prev)` to roll a change back. A kind with no in-place form recreates: implement
68
+ * as `[...remove(prev), ...emit(next)]`. Default (omitted) = recreate via emit(next).
69
+ */
70
+ overwrite?(prev: P, next: P): string[];
71
+ /**
72
+ * The CANONICAL change-detection key: the spine treats prev/next of the same object as a CHANGE iff
73
+ * their `canonical` differs. Default (omitted) = `emit(portable).join("\n")` — so a kind whose `emit`
74
+ * is already its canonical form needs nothing. Override when `emit` is FAITHFUL but some clauses must
75
+ * be EXCLUDED from equality — because the DB rewrites them on read (a cast suffix like `'x'::text`,
76
+ * added parens like `(a>0)`) or never introspects them (a COMMENT, an index) — so a faithful `emit` would phantom-diff a
77
+ * freshly-applied schema against `introspect`. Return `emit` MINUS those clauses: they stay create-time
78
+ * faithful in `emit`, but don't count as changes. `canonical(a) === canonical(b)` MUST mean "no
79
+ * migration needed". Affects ONLY classification; `emit`/`overwrite` (the DDL) are unaffected.
80
+ */
81
+ canonical?(portable: P): string;
82
+ /**
83
+ * Fine-grained DISPLAY items for a change of this object — so `better-schemic diff` shows per-SUB-OBJECT
84
+ * changes (a table decomposes into per-FIELD items: `field:user:name` changed), each carrying its
85
+ * owner `table` so the display GROUPS them hierarchically under it, instead of one coarse whole-object
86
+ * item. Called `(prev, next)`: a change diffs the two; `(undefined, next)` lists the object's
87
+ * sub-items as adds — the `--full` projection core uses for the full desired-state view. Default
88
+ * (omitted) = ONE whole-object item. DISPLAY ONLY — never affects up/down DDL (that is
89
+ * `emit`/`overwrite`); a structured driver reuses the per-field diff it already computes. Leave
90
+ * `DiffItem.file` unset (the caller attaches source linkage).
91
+ */
92
+ displayItems?(prev: P | undefined, next: P | undefined): DiffItem[];
93
+ /**
94
+ * Objects this one must be emitted AFTER — the cross-kind dependency edges (a field/index -> its
95
+ * table; an edge table -> its in/out tables; an event -> its table + any function it calls). Drives
96
+ * the topological sort in ./plan.ts. Omitted = no dependencies.
97
+ */
98
+ deps?(portable: P): Ref[];
99
+ /**
100
+ * The owning object to CLUSTER next to in the emitted order (an index's table) — readability only,
101
+ * never overrides {@link deps}. Omitted = a top-level object.
102
+ */
103
+ owner?(portable: P): Ref | undefined;
104
+ /**
105
+ * The STRUCTURAL container this object is nested within (an index's/field's table) — used for
106
+ * ADDRESSING and grouping (the CLI's dotted `parent.child`, e.g. `sc index info user.email_idx`) and
107
+ * distinct from {@link owner}, which is a DISPLAY choice (diff clustering). A kind may declare `parent`
108
+ * (it IS nested) while declining `owner` (it doesn't want per-parent diff clustering), or vice versa.
109
+ * The CLI resolves a nesting address as `parent ?? owner` (so a kind that only sets `owner` still
110
+ * addresses dotted). Omitted (and no `owner`) = a top-level object, addressed by its bare name.
111
+ */
112
+ parent?(portable: P): Ref | undefined;
113
+ /**
114
+ * Live connection -> all portable objects of THIS kind (the reverse direction). Introspection is
115
+ * often one `INFO`/catalog read yielding every kind at once; a driver backs all of its kinds'
116
+ * `introspect` with one shared (memoized) read and slices out this kind's objects. Omitted -> this
117
+ * kind isn't introspectable (diff/emit still work from authored state).
118
+ */
119
+ introspect?(conn: unknown): Promise<P[]>;
120
+ /**
121
+ * How this kind is PRESENTED — its human labels and the folder its objects render into. All optional
122
+ * with sensible defaults off the kind name (see {@link KindRegistry.display}), so a kind only declares
123
+ * what the defaults get wrong (e.g. `plural: "Indexes"`, or `folder: "access"`). DISPLAY ONLY.
124
+ */
125
+ display?: KindDisplay;
126
+ /**
127
+ * UNMANAGED by the migration pipeline: when `true`, objects of this kind are EXCLUDED from
128
+ * snapshot / diff / gen AND from the introspect-compare — so they never enter a migration file nor
129
+ * phantom-diff. For a kind whose lifecycle doesn't fit committed migrations: e.g. SurrealDB
130
+ * `DEFINE ACCESS`, which carries a secret the DB redacts on introspection (can't round-trip) and
131
+ * rotates on its own cadence. Such a kind is managed OUT-OF-BAND via the driver's own commands
132
+ * (`sc <kind> …`). `emit`/`lower` still work (a driver command may use them); only the automatic
133
+ * migration lifecycle skips it. Omitted/false = a normal, migration-managed kind.
134
+ *
135
+ * A PREDICATE form (`(portable) => boolean`) decides PER OBJECT — for a kind whose objects are
136
+ * managed only when they round-trip cleanly (e.g. SurrealDB access: key-free defs are manageable,
137
+ * key-bearing/redacted ones are not). The object is REQUIRED by
138
+ * {@link KindRegistry.isExcludedFromMigrations}; only a boolean `true` also skips introspection
139
+ * ({@link KindRegistry.skipsIntrospection}) — a predicate can't be decided there.
140
+ */
141
+ excludeFromMigrations?: boolean | ((portable: PortableObject) => boolean);
142
+ }
143
+
144
+ /** Per-kind presentation metadata (labels + output folder). All optional; core fills defaults. */
145
+ export interface KindDisplay {
146
+ /** Title-Case singular, e.g. `"Table"`, `"Field"`. Default: the kind name, capitalized. */
147
+ label?: string;
148
+ /** Title-Case plural, e.g. `"Tables"`, `"Indexes"`. Default: the English plural of `label`. */
149
+ plural?: string;
150
+ /** The directory this kind's objects render into. Default: the lowercase slug of `plural`. */
151
+ folder?: string;
152
+ }
153
+
154
+ /** A kind's resolved presentation — every field filled (the shape {@link KindRegistry.display} returns). */
155
+ export type ResolvedDisplay = Required<KindDisplay>;
156
+
157
+ /** A kind's full spec: its `name`, its `build` (the driver's authoring entry), and its engine. */
158
+ export type KindSpec<
159
+ Build extends (...args: never[]) => unknown,
160
+ A extends Definable,
161
+ P extends PortableObject,
162
+ > = { name: string; build: Build } & KindEngine<A, P>;
163
+
164
+ /** `"table"` -> `"Table"`. */
165
+ function capitalize(s: string): string {
166
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
167
+ }
168
+
169
+ /** A plain English pluralizer for kind labels: `Index` -> `Indexes`, `Policy` -> `Policies`. */
170
+ function pluralize(s: string): string {
171
+ if (/[^aeiou]y$/i.test(s)) return `${s.slice(0, -1)}ies`;
172
+ if (/(s|x|z|ch|sh)$/i.test(s)) return `${s}es`;
173
+ return `${s}s`;
174
+ }
175
+
176
+ /** `"Tables"` -> `"tables"`; collapses non-alphanumerics to single dashes (a filesystem-safe folder). */
177
+ function slugify(s: string): string {
178
+ return s
179
+ .toLowerCase()
180
+ .replace(/[^a-z0-9]+/g, "-")
181
+ .replace(/^-+|-+$/g, "");
182
+ }
183
+
184
+ /**
185
+ * A driver's set of registered kinds + the generic behavior the spine reads off them. Built once per
186
+ * driver; `define` registers a kind and returns the driver's OWN `build` function UNCHANGED — so the
187
+ * driver writes `export const defineTable = registry.define({ name: "table", build, ...engine })` and
188
+ * keeps full type-safety + DX (TS preserves a generic `build`'s parameters across the passthrough).
189
+ */
190
+ export class KindRegistry {
191
+ // Heterogeneous kinds erase at the engine seam (engine ops are structural); the AUTHORING side
192
+ // keeps full types via `define`'s `Build` passthrough.
193
+ // biome-ignore lint/suspicious/noExplicitAny: the engine seam is intentionally type-erased.
194
+ private readonly kinds = new Map<string, KindEngine<any, any>>();
195
+
196
+ /**
197
+ * Register a KIND. `build` is the driver's own authoring entry — ANY shape/chain — and its type
198
+ * flows through unchanged (type-safety + DX are the driver's to design). The engine fns give core
199
+ * the generic behavior. Registration ORDER is the kind's ordinal (the stable tie-break among
200
+ * independent objects in {@link orderObjects}), so register coarse-to-fine (table before index).
201
+ */
202
+ define<
203
+ Build extends (...args: never[]) => unknown,
204
+ A extends Definable,
205
+ P extends PortableObject,
206
+ >(spec: KindSpec<Build, A, P>): Build {
207
+ this.kinds.set(spec.name, spec);
208
+ return spec.build;
209
+ }
210
+
211
+ /** The engine for `kind`, or undefined if no such kind is registered. */
212
+ // biome-ignore lint/suspicious/noExplicitAny: the engine erases at this seam (see `kinds`).
213
+ engine(kind: string): KindEngine<any, any> | undefined {
214
+ return this.kinds.get(kind);
215
+ }
216
+
217
+ /**
218
+ * Is this PORTABLE OBJECT UNMANAGED by the migration pipeline (its engine set
219
+ * {@link KindEngine.excludeFromMigrations})? The snapshot/diff/emit spine skips such objects — see
220
+ * the flag's docs. The OBJECT is required: a per-object predicate can't be evaluated without it, and
221
+ * defaulting to "managed" would let a key-bearing object slip into a migration. An unregistered kind
222
+ * is treated as managed (false), so a stray object never gets silently dropped by a typo.
223
+ */
224
+ isExcludedFromMigrations(portable: PortableObject): boolean {
225
+ const flag = this.kinds.get(portable.kind)?.excludeFromMigrations;
226
+ return typeof flag === "function" ? flag(portable) : flag === true;
227
+ }
228
+
229
+ /**
230
+ * Whether `kind` is STATICALLY excluded from migrations (`excludeFromMigrations === true`), so even
231
+ * introspection skips it. A per-object PREDICATE can't be decided without an object: introspection
232
+ * runs for the kind, and the diff/snapshot/emit choke points filter by object afterwards.
233
+ */
234
+ skipsIntrospection(kind: string): boolean {
235
+ return this.kinds.get(kind)?.excludeFromMigrations === true;
236
+ }
237
+
238
+ /**
239
+ * A kind's resolved presentation — `label`/`plural`/`folder`, with defaults derived from the kind
240
+ * name for whatever the driver left unset. Works for unregistered display sub-kinds too (e.g. the
241
+ * `"field"` items a table's `displayItems` emits) — they just get the name-derived defaults.
242
+ */
243
+ display(kind: string): ResolvedDisplay {
244
+ const d = this.kinds.get(kind)?.display ?? {};
245
+ const label = d.label ?? capitalize(kind);
246
+ const plural = d.plural ?? pluralize(label);
247
+ return { label, plural, folder: d.folder ?? slugify(plural) };
248
+ }
249
+
250
+ /** Registered kind names, in registration order (== ordinal order). */
251
+ names(): string[] {
252
+ return [...this.kinds.keys()];
253
+ }
254
+
255
+ /**
256
+ * A kind's ORDINAL = its registration index. Used ONLY as a tie-break among objects with no
257
+ * dependency relation, so independent objects come out stably layered (readability); it never
258
+ * overrides the dependency graph. An unknown kind sorts last.
259
+ */
260
+ ordinal(kind: string): number {
261
+ const i = this.names().indexOf(kind);
262
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i;
263
+ }
264
+
265
+ /** [name, engine] pairs in registration order — the spine iterates these. */
266
+ // biome-ignore lint/suspicious/noExplicitAny: the engine erases at this seam (see `kinds`).
267
+ entries(): [string, KindEngine<any, any>][] {
268
+ return [...this.kinds.entries()];
269
+ }
270
+ }