@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.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/lib/authoring.d.ts +89 -0
- package/lib/authoring.js +187 -0
- package/lib/authoring.js.map +1 -0
- package/lib/chunk-C4D6JWSE.js +54 -0
- package/lib/chunk-C4D6JWSE.js.map +1 -0
- package/lib/chunk-T23RNU7G.js +304 -0
- package/lib/chunk-T23RNU7G.js.map +1 -0
- package/lib/config-TIiKDd9t.d.ts +97 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +8 -0
- package/lib/config.js.map +1 -0
- package/lib/driver-Dh5hLKHm.d.ts +736 -0
- package/lib/driver.d.ts +150 -0
- package/lib/driver.js +47 -0
- package/lib/driver.js.map +1 -0
- package/lib/index.d.ts +84 -0
- package/lib/index.js +794 -0
- package/lib/index.js.map +1 -0
- package/lib/testing.d.ts +29 -0
- package/lib/testing.js +111 -0
- package/lib/testing.js.map +1 -0
- package/package.json +93 -0
- package/src/authoring.ts +304 -0
- package/src/cli-kit/config.ts +179 -0
- package/src/cli-kit/diff.ts +230 -0
- package/src/cli-kit/filter.ts +159 -0
- package/src/cli-kit/merge.ts +380 -0
- package/src/cli-kit/meta.ts +123 -0
- package/src/cli-kit/pager.ts +42 -0
- package/src/cli-kit/schema.ts +186 -0
- package/src/cli-kit/style.ts +24 -0
- package/src/config.ts +51 -0
- package/src/connection.ts +78 -0
- package/src/driver/driver.ts +300 -0
- package/src/driver/index.ts +31 -0
- package/src/driver/portable-ir.ts +51 -0
- package/src/driver/portable.ts +124 -0
- package/src/driver/sdk.ts +66 -0
- package/src/index.ts +145 -0
- package/src/kind/index.ts +28 -0
- package/src/kind/plan.ts +390 -0
- package/src/kind/registry.ts +225 -0
- package/src/testing.ts +181 -0
package/src/kind/plan.ts
ADDED
|
@@ -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
|
+
}
|