@schemic/core 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/lib/authoring.d.ts +89 -0
  4. package/lib/authoring.js +187 -0
  5. package/lib/authoring.js.map +1 -0
  6. package/lib/chunk-C4D6JWSE.js +54 -0
  7. package/lib/chunk-C4D6JWSE.js.map +1 -0
  8. package/lib/chunk-T23RNU7G.js +304 -0
  9. package/lib/chunk-T23RNU7G.js.map +1 -0
  10. package/lib/config-TIiKDd9t.d.ts +97 -0
  11. package/lib/config.d.ts +1 -0
  12. package/lib/config.js +8 -0
  13. package/lib/config.js.map +1 -0
  14. package/lib/driver-Dh5hLKHm.d.ts +736 -0
  15. package/lib/driver.d.ts +150 -0
  16. package/lib/driver.js +47 -0
  17. package/lib/driver.js.map +1 -0
  18. package/lib/index.d.ts +84 -0
  19. package/lib/index.js +794 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/testing.d.ts +29 -0
  22. package/lib/testing.js +111 -0
  23. package/lib/testing.js.map +1 -0
  24. package/package.json +93 -0
  25. package/src/authoring.ts +304 -0
  26. package/src/cli-kit/config.ts +179 -0
  27. package/src/cli-kit/diff.ts +230 -0
  28. package/src/cli-kit/filter.ts +159 -0
  29. package/src/cli-kit/merge.ts +380 -0
  30. package/src/cli-kit/meta.ts +123 -0
  31. package/src/cli-kit/pager.ts +42 -0
  32. package/src/cli-kit/schema.ts +186 -0
  33. package/src/cli-kit/style.ts +24 -0
  34. package/src/config.ts +51 -0
  35. package/src/connection.ts +78 -0
  36. package/src/driver/driver.ts +300 -0
  37. package/src/driver/index.ts +31 -0
  38. package/src/driver/portable-ir.ts +51 -0
  39. package/src/driver/portable.ts +124 -0
  40. package/src/driver/sdk.ts +66 -0
  41. package/src/index.ts +145 -0
  42. package/src/kind/index.ts +28 -0
  43. package/src/kind/plan.ts +390 -0
  44. package/src/kind/registry.ts +225 -0
  45. package/src/testing.ts +181 -0
@@ -0,0 +1,390 @@
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
+ /** Group a flat portable schema into a snapshot (by kind). */
124
+ export function snapshotKinds(schema: PortableObject[]): KindSnapshot {
125
+ const kinds: Record<string, PortableObject[]> = {};
126
+ for (const o of schema) {
127
+ const bucket = kinds[o.kind] ?? [];
128
+ bucket.push(o);
129
+ kinds[o.kind] = bucket;
130
+ }
131
+ return { kinds };
132
+ }
133
+
134
+ /** Flatten a snapshot back into a portable schema (the inverse of {@link snapshotKinds}). */
135
+ export function snapshotObjects(snap: KindSnapshot): PortableObject[] {
136
+ return Object.values(snap.kinds).flat();
137
+ }
138
+
139
+ // --- diff / plan --------------------------------------------------------------------------------
140
+
141
+ /** One classified object change, carrying its ordering metadata + the portable sides for DDL. */
142
+ interface Change extends OrderNode {
143
+ readonly op: "add" | "change" | "remove";
144
+ readonly prev?: PortableObject;
145
+ readonly next?: PortableObject;
146
+ }
147
+
148
+ /** An up/down DDL program (each a list of statements). */
149
+ export interface KindPlan {
150
+ up: string[];
151
+ down: string[];
152
+ }
153
+
154
+ /** The canonical change-detection key for an object — the kind's `canonical`, else its emitted DDL. */
155
+ const canonicalOf = (engine: KindEngine, p: PortableObject): string =>
156
+ engine.canonical?.(p) ?? engine.emit(p).join("\n");
157
+
158
+ const orderNodeOf = (
159
+ engine: KindEngine,
160
+ portable: PortableObject,
161
+ ): OrderNode => ({
162
+ kind: portable.kind,
163
+ name: portable.name,
164
+ deps: engine.deps?.(portable) ?? [],
165
+ owner: engine.owner?.(portable),
166
+ });
167
+
168
+ /** Display identity: `kind:owner:name` (owner blank for a top-level object) + the display owner. */
169
+ const itemKey = (n: OrderNode) => `${n.kind}:${n.owner?.name ?? ""}:${n.name}`;
170
+ const itemTable = (n: OrderNode) => n.owner?.name ?? n.name;
171
+
172
+ const byKey = (schema: PortableObject[]) =>
173
+ new Map(schema.map((o) => [refKey(o), o]));
174
+
175
+ /**
176
+ * Classify both sides into ordered add/change/remove sets — the shared core of plan + diff. A `change`
177
+ * is two objects of the same key whose emitted DDL differs (same test as the fixed-slot engine). Each
178
+ * class is topologically ordered parent-first; the caller reverses one class for drops/inversion.
179
+ */
180
+ function orderedChanges(
181
+ registry: KindRegistry,
182
+ prev: PortableObject[],
183
+ next: PortableObject[],
184
+ ): { nonRemoves: Change[]; removes: Change[] } {
185
+ const prevByKey = byKey(prev);
186
+ const nextByKey = byKey(next);
187
+ const changes: Change[] = [];
188
+ for (const k of new Set([...prevByKey.keys(), ...nextByKey.keys()])) {
189
+ const p = prevByKey.get(k);
190
+ const n = nextByKey.get(k);
191
+ const portable = n ?? p;
192
+ if (!portable) continue;
193
+ const engine = registry.engine(portable.kind);
194
+ if (!engine) continue;
195
+ const node = orderNodeOf(engine, portable);
196
+ if (p && !n) changes.push({ op: "remove", prev: p, ...node });
197
+ else if (!p && n) changes.push({ op: "add", next: n, ...node });
198
+ else if (p && n && canonicalOf(engine, p) !== canonicalOf(engine, n))
199
+ changes.push({ op: "change", prev: p, next: n, ...node });
200
+ }
201
+ const ord = (kind: string) => registry.ordinal(kind);
202
+ return {
203
+ nonRemoves: orderObjects(
204
+ changes.filter((c) => c.op !== "remove"),
205
+ ord,
206
+ ),
207
+ removes: orderObjects(
208
+ changes.filter((c) => c.op === "remove"),
209
+ ord,
210
+ ),
211
+ };
212
+ }
213
+
214
+ const overwriteUp = (
215
+ engine: KindEngine,
216
+ a: PortableObject,
217
+ b: PortableObject,
218
+ ): string[] =>
219
+ engine.overwrite?.(a, b) ?? [...engine.remove(a), ...engine.emit(b)];
220
+
221
+ /**
222
+ * Diff two portable schema states into an executable up/down program, generically over the registry.
223
+ *
224
+ * `up` runs creates/changes parent-first (the dependency graph) then drops child-first; `down` is the
225
+ * mirror: recreate drops parent-first, then undo creates/changes child-first. We invert PER OBJECT (not
226
+ * by reversing the flat DDL list) so a kind's multi-line block — a table emitted with its fields —
227
+ * keeps its internal order in both directions.
228
+ */
229
+ export function planKinds(
230
+ registry: KindRegistry,
231
+ prev: PortableObject[],
232
+ next: PortableObject[],
233
+ ): KindPlan {
234
+ const { nonRemoves, removes } = orderedChanges(registry, prev, next);
235
+ const up: string[] = [];
236
+ const down: string[] = [];
237
+ for (const c of nonRemoves) {
238
+ const e = registry.engine(c.kind);
239
+ if (!e) continue;
240
+ if (c.op === "add" && c.next) up.push(...e.emit(c.next));
241
+ else if (c.op === "change" && c.prev && c.next)
242
+ up.push(...overwriteUp(e, c.prev, c.next));
243
+ }
244
+ for (const c of [...removes].reverse()) {
245
+ const e = registry.engine(c.kind); // drops child-first
246
+ if (e && c.prev) up.push(...e.remove(c.prev));
247
+ }
248
+ for (const c of removes) {
249
+ const e = registry.engine(c.kind); // recreate dropped objects parent-first
250
+ if (e && c.prev) down.push(...e.emit(c.prev));
251
+ }
252
+ for (const c of [...nonRemoves].reverse()) {
253
+ const e = registry.engine(c.kind); // undo creates/changes child-first
254
+ if (!e) continue;
255
+ if (c.op === "add" && c.next) down.push(...e.remove(c.next));
256
+ else if (c.op === "change" && c.prev && c.next)
257
+ down.push(...overwriteUp(e, c.next, c.prev));
258
+ }
259
+ return { up, down };
260
+ }
261
+
262
+ /**
263
+ * Display items for a change set, in up order (creates/changes parent-first, drops child-first). A kind
264
+ * with `displayItems` decomposes into FINE-grained sub-items (per-field, each carrying its `table` so
265
+ * the display groups them under it); otherwise it falls back to ONE whole-object item.
266
+ */
267
+ function diffItems(
268
+ registry: KindRegistry,
269
+ nonRemoves: Change[],
270
+ removes: Change[],
271
+ ): DiffItem[] {
272
+ const items: DiffItem[] = [];
273
+ const push = (c: Change) => {
274
+ const e = registry.engine(c.kind);
275
+ if (!e) return;
276
+ if (e.displayItems) {
277
+ items.push(...e.displayItems(c.prev, c.next));
278
+ return;
279
+ }
280
+ const base = { key: itemKey(c), table: itemTable(c), kind: c.kind };
281
+ if (c.op === "add" && c.next)
282
+ items.push({ ...base, op: "add", ddl: e.emit(c.next).join("\n") });
283
+ else if (c.op === "remove" && c.prev)
284
+ items.push({
285
+ ...base,
286
+ op: "remove",
287
+ ddl: e.remove(c.prev).join("\n"),
288
+ old: e.emit(c.prev).join("\n"),
289
+ });
290
+ else if (c.op === "change" && c.prev && c.next)
291
+ items.push({
292
+ ...base,
293
+ op: "change",
294
+ before: e.emit(c.prev).join("\n"),
295
+ after: e.emit(c.next).join("\n"),
296
+ });
297
+ };
298
+ for (const c of nonRemoves) push(c);
299
+ for (const c of [...removes].reverse()) push(c);
300
+ return items;
301
+ }
302
+
303
+ /**
304
+ * The full {@link Diff} the CLI + migration model consume — up/down DDL + per-object display items +
305
+ * the whole desired schema (`full`, for `--full`). This is what a driver's `Driver.diff` returns once
306
+ * its kinds are on the registry (the generic counterpart of the fixed-slot `buildDiff`). Source-file
307
+ * linkage on the items is attached by the caller (the snapshot's `files` map), so `file` is left unset.
308
+ */
309
+ export function buildKindDiff(
310
+ registry: KindRegistry,
311
+ prev: PortableObject[],
312
+ next: PortableObject[],
313
+ ): Diff {
314
+ const { nonRemoves, removes } = orderedChanges(registry, prev, next);
315
+ const { up, down } = planKinds(registry, prev, next);
316
+ // `full` mirrors the items' granularity: a kind with `displayItems` projects its object as per-
317
+ // sub-object adds (displayItems(undefined, portable)); otherwise one whole-object entry.
318
+ const full = orderedSchema(registry, next).flatMap(
319
+ ({ engine, portable, node }) => {
320
+ if (engine.displayItems)
321
+ return engine.displayItems(undefined, portable).map((it) => ({
322
+ key: it.key,
323
+ table: it.table,
324
+ ddl: it.op === "add" ? it.ddl : "",
325
+ }));
326
+ return [
327
+ {
328
+ key: itemKey(node),
329
+ table: itemTable(node),
330
+ ddl: engine.emit(portable).join("\n"),
331
+ },
332
+ ];
333
+ },
334
+ );
335
+ return { up, down, items: diffItems(registry, nonRemoves, removes), full };
336
+ }
337
+
338
+ /** Lower-already portable schema, topologically ordered, paired with each object's engine + node. */
339
+ function orderedSchema(
340
+ registry: KindRegistry,
341
+ schema: PortableObject[],
342
+ ): { engine: KindEngine; portable: PortableObject; node: OrderNode }[] {
343
+ const items = schema.flatMap((portable) => {
344
+ const engine = registry.engine(portable.kind);
345
+ return engine
346
+ ? [{ engine, portable, node: orderNodeOf(engine, portable) }]
347
+ : [];
348
+ });
349
+ const pos = new Map(
350
+ orderObjects(
351
+ items.map((i) => i.node),
352
+ (k) => registry.ordinal(k),
353
+ ).map((n, i) => [itemKey(n), i]),
354
+ );
355
+ return items.sort(
356
+ (a, b) => (pos.get(itemKey(a.node)) ?? 0) - (pos.get(itemKey(b.node)) ?? 0),
357
+ );
358
+ }
359
+
360
+ /**
361
+ * Fresh-apply DDL for a portable schema: every object created, ordered across kinds by the graph.
362
+ * (The `up` of a diff from an empty state.) Lower authoring first via {@link lowerSchema}.
363
+ */
364
+ export function emitKinds(
365
+ registry: KindRegistry,
366
+ schema: PortableObject[],
367
+ ): string[] {
368
+ return orderedSchema(registry, schema).flatMap(({ engine, portable }) =>
369
+ engine.emit(portable),
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Reverse direction, fanned out across kinds: introspect every introspectable kind off one live
375
+ * connection and flatten into portable objects. The RESOLUTION of "per-kind vs one driver read":
376
+ * the contract is per-kind ({@link KindEngine.introspect}), but a driver backs all of its kinds with
377
+ * ONE shared (memoized) read of `conn` and slices out each kind's objects — so the fan-out here costs
378
+ * a single round-trip, not N. A kind without `introspect` contributes nothing (not introspectable).
379
+ */
380
+ export async function introspectKinds(
381
+ registry: KindRegistry,
382
+ conn: unknown,
383
+ ): Promise<PortableObject[]> {
384
+ const out: PortableObject[] = [];
385
+ for (const [, engine] of registry.entries()) {
386
+ if (!engine.introspect) continue;
387
+ out.push(...(await engine.introspect(conn)));
388
+ }
389
+ return out;
390
+ }
@@ -0,0 +1,225 @@
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: multiple drivers (`@schemic/surrealdb`,
15
+ // `@schemic/postgres`) are registered at once and each defines its own `"table"`/`"function"`, so a
16
+ // shared global map would collide. A driver builds one `KindRegistry` and registers its kinds into it.
17
+
18
+ /**
19
+ * An authored definable, tagged with the KIND that owns it. Core dispatches on `kind` alone — every
20
+ * other field is the kind's own business, handed straight to {@link KindEngine.lower}. This is the
21
+ * neutral upper bound for a kind's authoring-object type (a driver's concrete `TableDef`/`FnDef` is a
22
+ * structural subtype).
23
+ */
24
+ export interface Definable {
25
+ readonly kind: string;
26
+ readonly name: string;
27
+ }
28
+
29
+ /**
30
+ * A kind's PORTABLE object — the dialect-independent data shape core stores + diffs. A kind chooses
31
+ * how structured this is: a table's portable form carries fields/indexes (so core can field-level
32
+ * diff it); an opaque kind (function/access) carries a neutral identity + a `native` payload it
33
+ * round-trips. Either way it is tagged with `kind`/`name` for cross-kind dispatch + ordering.
34
+ */
35
+ export interface PortableObject {
36
+ readonly kind: string;
37
+ readonly name: string;
38
+ }
39
+
40
+ /** A reference to another object in the schema graph — the unit of cross-kind dependency ordering. */
41
+ export interface Ref {
42
+ readonly kind: string;
43
+ readonly name: string;
44
+ }
45
+
46
+ // `DiffItem` is a type-only import (erased at compile) — the display contract the `displayItems` hook
47
+ // produces; no runtime cli->kind coupling, same arrangement as ./plan.ts.
48
+ import type { DiffItem } from "../cli-kit/diff";
49
+
50
+ /**
51
+ * What core needs to orchestrate ONE kind generically — it never inspects the specifics. The
52
+ * change-vocabulary (`emit`/`remove`/`overwrite`) mirrors the Driver contract's, so a kind's behavior
53
+ * is parity-checkable against the fixed-slot engine. `A` is the kind's authoring object, `P` its
54
+ * portable object; both are opaque to core beyond the {@link Definable}/{@link PortableObject} bounds.
55
+ */
56
+ export interface KindEngine<
57
+ A extends Definable = Definable,
58
+ P extends PortableObject = PortableObject,
59
+ > {
60
+ /** Authoring object -> this kind's portable object (normalized; both lowerings must converge here). */
61
+ lower(authored: A): P;
62
+ /** CREATE DDL for one portable object (a fresh apply / migration `up` for an added object). */
63
+ emit(portable: P): string[];
64
+ /** DROP DDL for one portable object (`up` for a removed object, `down` for an added one). */
65
+ remove(portable: P): string[];
66
+ /**
67
+ * In-place CHANGE DDL taking `prev` to `next` (the dialect's ALTER/OVERWRITE). The spine calls
68
+ * `overwrite(next, prev)` to roll a change back. A kind with no in-place form recreates: implement
69
+ * as `[...remove(prev), ...emit(next)]`. Default (omitted) = recreate via emit(next).
70
+ */
71
+ overwrite?(prev: P, next: P): string[];
72
+ /**
73
+ * The CANONICAL change-detection key: the spine treats prev/next of the same object as a CHANGE iff
74
+ * their `canonical` differs. Default (omitted) = `emit(portable).join("\n")` — so a kind whose `emit`
75
+ * is already its canonical form needs nothing. Override when `emit` is FAITHFUL but some clauses must
76
+ * be EXCLUDED from equality — because the DB rewrites them on read (PG `'x'` -> `'x'::text`, `a>0` ->
77
+ * `(a>0)`) or never introspects them (a COMMENT, an index) — so a faithful `emit` would phantom-diff a
78
+ * freshly-applied schema against `introspect`. Return `emit` MINUS those clauses: they stay create-time
79
+ * faithful in `emit`, but don't count as changes. `canonical(a) === canonical(b)` MUST mean "no
80
+ * migration needed". Affects ONLY classification; `emit`/`overwrite` (the DDL) are unaffected.
81
+ */
82
+ canonical?(portable: P): string;
83
+ /**
84
+ * Fine-grained DISPLAY items for a change of this object — so `schemic diff` shows per-SUB-OBJECT
85
+ * changes (a table decomposes into per-FIELD items: `field:user:name` changed), each carrying its
86
+ * owner `table` so the display GROUPS them hierarchically under it, instead of one coarse whole-object
87
+ * item. Called `(prev, next)`: a change diffs the two; `(undefined, next)` lists the object's
88
+ * sub-items as adds — the `--full` projection core uses for the full desired-state view. Default
89
+ * (omitted) = ONE whole-object item. DISPLAY ONLY — never affects up/down DDL (that is
90
+ * `emit`/`overwrite`); a structured driver reuses the per-field diff it already computes. Leave
91
+ * `DiffItem.file` unset (the caller attaches source linkage).
92
+ */
93
+ displayItems?(prev: P | undefined, next: P | undefined): DiffItem[];
94
+ /**
95
+ * Objects this one must be emitted AFTER — the cross-kind dependency edges (a field/index -> its
96
+ * table; an edge table -> its in/out tables; an event -> its table + any function it calls). Drives
97
+ * the topological sort in ./plan.ts. Omitted = no dependencies.
98
+ */
99
+ deps?(portable: P): Ref[];
100
+ /**
101
+ * The owning object to CLUSTER next to in the emitted order (an index's table) — readability only,
102
+ * never overrides {@link deps}. Omitted = a top-level object.
103
+ */
104
+ owner?(portable: P): Ref | undefined;
105
+ /**
106
+ * Live connection -> all portable objects of THIS kind (the reverse direction). Introspection is
107
+ * often one `INFO`/`pg_catalog` read yielding every kind at once; a driver backs all of its kinds'
108
+ * `introspect` with one shared (memoized) read and slices out this kind's objects. Omitted -> this
109
+ * kind isn't introspectable (diff/emit still work from authored state).
110
+ */
111
+ introspect?(conn: unknown): Promise<P[]>;
112
+ /**
113
+ * How this kind is PRESENTED — its human labels and the folder its objects render into. All optional
114
+ * with sensible defaults off the kind name (see {@link KindRegistry.display}), so a kind only declares
115
+ * what the defaults get wrong (e.g. `plural: "Indexes"`, or `folder: "access"`). DISPLAY ONLY.
116
+ */
117
+ display?: KindDisplay;
118
+ }
119
+
120
+ /** Per-kind presentation metadata (labels + output folder). All optional; core fills defaults. */
121
+ export interface KindDisplay {
122
+ /** Title-Case singular, e.g. `"Table"`, `"Field"`. Default: the kind name, capitalized. */
123
+ label?: string;
124
+ /** Title-Case plural, e.g. `"Tables"`, `"Indexes"`. Default: the English plural of `label`. */
125
+ plural?: string;
126
+ /** The directory this kind's objects render into. Default: the lowercase slug of `plural`. */
127
+ folder?: string;
128
+ }
129
+
130
+ /** A kind's resolved presentation — every field filled (the shape {@link KindRegistry.display} returns). */
131
+ export type ResolvedDisplay = Required<KindDisplay>;
132
+
133
+ /** A kind's full spec: its `name`, its `build` (the driver's authoring entry), and its engine. */
134
+ export type KindSpec<
135
+ Build extends (...args: never[]) => unknown,
136
+ A extends Definable,
137
+ P extends PortableObject,
138
+ > = { name: string; build: Build } & KindEngine<A, P>;
139
+
140
+ /** `"table"` -> `"Table"`. */
141
+ function capitalize(s: string): string {
142
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
143
+ }
144
+
145
+ /** A plain English pluralizer for kind labels: `Index` -> `Indexes`, `Policy` -> `Policies`. */
146
+ function pluralize(s: string): string {
147
+ if (/[^aeiou]y$/i.test(s)) return `${s.slice(0, -1)}ies`;
148
+ if (/(s|x|z|ch|sh)$/i.test(s)) return `${s}es`;
149
+ return `${s}s`;
150
+ }
151
+
152
+ /** `"Tables"` -> `"tables"`; collapses non-alphanumerics to single dashes (a filesystem-safe folder). */
153
+ function slugify(s: string): string {
154
+ return s
155
+ .toLowerCase()
156
+ .replace(/[^a-z0-9]+/g, "-")
157
+ .replace(/^-+|-+$/g, "");
158
+ }
159
+
160
+ /**
161
+ * A driver's set of registered kinds + the generic behavior the spine reads off them. Built once per
162
+ * driver; `define` registers a kind and returns the driver's OWN `build` function UNCHANGED — so the
163
+ * driver writes `export const defineTable = registry.define({ name: "table", build, ...engine })` and
164
+ * keeps full type-safety + DX (TS preserves a generic `build`'s parameters across the passthrough).
165
+ */
166
+ export class KindRegistry {
167
+ // Heterogeneous kinds erase at the engine seam (engine ops are structural); the AUTHORING side
168
+ // keeps full types via `define`'s `Build` passthrough.
169
+ // biome-ignore lint/suspicious/noExplicitAny: the engine seam is intentionally type-erased.
170
+ private readonly kinds = new Map<string, KindEngine<any, any>>();
171
+
172
+ /**
173
+ * Register a KIND. `build` is the driver's own authoring entry — ANY shape/chain — and its type
174
+ * flows through unchanged (type-safety + DX are the driver's to design). The engine fns give core
175
+ * the generic behavior. Registration ORDER is the kind's ordinal (the stable tie-break among
176
+ * independent objects in {@link orderObjects}), so register coarse-to-fine (table before index).
177
+ */
178
+ define<
179
+ Build extends (...args: never[]) => unknown,
180
+ A extends Definable,
181
+ P extends PortableObject,
182
+ >(spec: KindSpec<Build, A, P>): Build {
183
+ this.kinds.set(spec.name, spec);
184
+ return spec.build;
185
+ }
186
+
187
+ /** The engine for `kind`, or undefined if no such kind is registered. */
188
+ // biome-ignore lint/suspicious/noExplicitAny: the engine erases at this seam (see `kinds`).
189
+ engine(kind: string): KindEngine<any, any> | undefined {
190
+ return this.kinds.get(kind);
191
+ }
192
+
193
+ /**
194
+ * A kind's resolved presentation — `label`/`plural`/`folder`, with defaults derived from the kind
195
+ * name for whatever the driver left unset. Works for unregistered display sub-kinds too (e.g. the
196
+ * `"field"` items a table's `displayItems` emits) — they just get the name-derived defaults.
197
+ */
198
+ display(kind: string): ResolvedDisplay {
199
+ const d = this.kinds.get(kind)?.display ?? {};
200
+ const label = d.label ?? capitalize(kind);
201
+ const plural = d.plural ?? pluralize(label);
202
+ return { label, plural, folder: d.folder ?? slugify(plural) };
203
+ }
204
+
205
+ /** Registered kind names, in registration order (== ordinal order). */
206
+ names(): string[] {
207
+ return [...this.kinds.keys()];
208
+ }
209
+
210
+ /**
211
+ * A kind's ORDINAL = its registration index. Used ONLY as a tie-break among objects with no
212
+ * dependency relation, so independent objects come out stably layered (readability); it never
213
+ * overrides the dependency graph. An unknown kind sorts last.
214
+ */
215
+ ordinal(kind: string): number {
216
+ const i = this.names().indexOf(kind);
217
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i;
218
+ }
219
+
220
+ /** [name, engine] pairs in registration order — the spine iterates these. */
221
+ // biome-ignore lint/suspicious/noExplicitAny: the engine erases at this seam (see `kinds`).
222
+ entries(): [string, KindEngine<any, any>][] {
223
+ return [...this.kinds.entries()];
224
+ }
225
+ }