@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,230 @@
1
+ // The DIALECT-FREE diff DISPLAY + shared types. The SurrealDB statement-diff engine (buildSnapshot/
2
+ // diffSnapshots/renderMigration) lives in `./surreal-diff` and is invoked through the driver; this
3
+ // module is what the CLI shell uses to RENDER any driver's Diff (git-style file groups, word-diff,
4
+ // unified patch, kind summaries). Dialect-free: `kind` is an opaque string, not a Surreal kind union.
5
+ import type { KindRegistry } from "../kind";
6
+ import { colorEnabled, style } from "./style";
7
+
8
+ /**
9
+ * One object's change, for display. `kind` is the object kind, `table` its owner (a table name,
10
+ * or the object's own name for db-level objects). `add` carries the new DDL; `remove` carries the
11
+ * `REMOVE` statement (`ddl`) plus the dropped object's prior DDL (`old`, for the unified patch);
12
+ * `change` pairs old↔new.
13
+ */
14
+ export type DiffItem = {
15
+ key: string;
16
+ table: string;
17
+ /** Dialect-defined object kind (e.g. "table"/"field"/"index") — opaque to the display. */
18
+ kind: string;
19
+ /** The source file this object lives in (or lived in, for a removal). Absent if unknown. */
20
+ file?: string;
21
+ } & (
22
+ | { op: "add"; ddl: string }
23
+ | { op: "remove"; ddl: string; old: string }
24
+ | { op: "change"; before: string; after: string }
25
+ );
26
+
27
+ export interface Diff {
28
+ up: string[];
29
+ down: string[];
30
+ /** Structured per-object changes for the human display (word-level diff). */
31
+ items?: DiffItem[];
32
+ /** Every desired statement (the `next` schema), for the `--full` context view. */
33
+ full?: { key: string; table: string; ddl: string }[];
34
+ }
35
+
36
+ /** `true` if the two snapshots define the same objects with identical DDL. */
37
+ export function isEmptyDiff(diff: Diff): boolean {
38
+ return diff.up.length === 0;
39
+ }
40
+
41
+ /**
42
+ * Inline word-level diff of two statements: shared tokens dim, removed tokens red, added tokens
43
+ * green (LCS over space-separated tokens). So a changed field shows the whole statement with only
44
+ * the changed words highlighted.
45
+ */
46
+ export function tokenDiff(before: string, after: string): string {
47
+ const a = before.split(" ");
48
+ const b = after.split(" ");
49
+ const m = a.length;
50
+ const n = b.length;
51
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
52
+ new Array(n + 1).fill(0),
53
+ );
54
+ for (let i = m - 1; i >= 0; i--) {
55
+ for (let j = n - 1; j >= 0; j--) {
56
+ dp[i][j] =
57
+ a[i] === b[j]
58
+ ? dp[i + 1][j + 1] + 1
59
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
60
+ }
61
+ }
62
+ // With color: red/green/dim. Without (pipe / CI / NO_COLOR): git `--word-diff=plain` markers
63
+ // `[-removed-]`/`{+added+}` so removed-vs-added is unambiguous and assertable.
64
+ const colored = colorEnabled();
65
+ const del = (t: string) => (colored ? style.red(t) : `[-${t}-]`);
66
+ const ins = (t: string) => (colored ? style.green(t) : `{+${t}+}`);
67
+ const eq = (t: string) => (colored ? style.dim(t) : t);
68
+ const out: string[] = [];
69
+ let i = 0;
70
+ let j = 0;
71
+ while (i < m && j < n) {
72
+ if (a[i] === b[j]) {
73
+ out.push(eq(a[i]));
74
+ i++;
75
+ j++;
76
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
77
+ out.push(del(a[i++]));
78
+ } else {
79
+ out.push(ins(b[j++]));
80
+ }
81
+ }
82
+ while (i < m) out.push(del(a[i++]));
83
+ while (j < n) out.push(ins(b[j++]));
84
+ return out.join(" ");
85
+ }
86
+
87
+ /**
88
+ * Render one display item: `+`/`-` line for add/remove. A change renders as separate red `-` /
89
+ * green `+` lines by default, or as a single inline word-diff when `inline` is set (the A/B toggle).
90
+ */
91
+ function renderItem(it: DiffItem, inline = false): string {
92
+ if (it.op === "add") return style.green(` + ${it.ddl}`);
93
+ if (it.op === "remove") return style.red(` - ${it.ddl}`);
94
+ if (inline) return ` ${tokenDiff(it.before, it.after)}`;
95
+ return `${style.red(` - ${it.before}`)}\n${style.green(` + ${it.after}`)}`;
96
+ }
97
+
98
+ /**
99
+ * Group diff items by their source file (git-style), so the display reads like a file diff. Items
100
+ * with no file (an older snapshot, or the live DB) fall back to a per-object group headed by the
101
+ * object's bare name. Returns groups in first-seen order, each with its header.
102
+ */
103
+ function groupByFile(
104
+ items: DiffItem[],
105
+ ): { header: string; items: DiffItem[] }[] {
106
+ const order: string[] = [];
107
+ const byKey = new Map<string, DiffItem[]>();
108
+ for (const it of items) {
109
+ const key = it.file ?? `\0${it.table}`; // \0 can't collide with a real path
110
+ let group = byKey.get(key);
111
+ if (!group) {
112
+ group = [];
113
+ byKey.set(key, group);
114
+ order.push(key);
115
+ }
116
+ group.push(it);
117
+ }
118
+ return order.map((key) => {
119
+ const group = byKey.get(key) ?? [];
120
+ return {
121
+ header: group.find((i) => i.file)?.file ?? group[0].table,
122
+ items: group,
123
+ };
124
+ });
125
+ }
126
+
127
+ /** Render display items as a git-style file diff: each group headed by its source file path. */
128
+ export function formatItems(items: DiffItem[], inline = false): string {
129
+ return groupByFile(items)
130
+ .map((g) =>
131
+ [
132
+ style.bold(g.header),
133
+ ...g.items.map((it) => renderItem(it, inline)),
134
+ ].join("\n"),
135
+ )
136
+ .join("\n\n");
137
+ }
138
+
139
+ /**
140
+ * A standard **unified diff** of the change, grouped one section per source file (git-style) — for
141
+ * piping through a diff viewer (git's pager / delta). Objects with no file fall back to a section
142
+ * headed by the object's bare name. Each object is a single-line DDL statement, so hunks are
143
+ * line-for-line.
144
+ */
145
+ export function formatPatch(diff: Diff): string {
146
+ const items = diff.items ?? [];
147
+ if (!items.length) return "";
148
+ const out: string[] = [];
149
+ for (const { header, items: group } of groupByFile(items)) {
150
+ const lines: string[] = [];
151
+ let dels = 0;
152
+ let adds = 0;
153
+ for (const it of group) {
154
+ if (it.op === "add") {
155
+ lines.push(`+${it.ddl}`);
156
+ adds++;
157
+ } else if (it.op === "remove") {
158
+ lines.push(`-${it.old}`);
159
+ dels++;
160
+ } else {
161
+ lines.push(`-${it.before}`, `+${it.after}`);
162
+ dels++;
163
+ adds++;
164
+ }
165
+ }
166
+ out.push(
167
+ `diff --git a/${header} b/${header}`,
168
+ `--- a/${header}`,
169
+ `+++ b/${header}`,
170
+ `@@ -${dels ? 1 : 0},${dels} +${adds ? 1 : 0},${adds} @@`,
171
+ ...lines,
172
+ );
173
+ }
174
+ return `${out.join("\n")}\n`;
175
+ }
176
+
177
+ /** `--full`: the whole desired schema — unchanged dim, additions green, changes word-diffed. */
178
+ function formatFull(diff: Diff, inline = false): string {
179
+ const byKey = new Map((diff.items ?? []).map((it) => [it.key, it]));
180
+ const out: string[] = [];
181
+ let prev: string | undefined;
182
+ for (const f of diff.full ?? []) {
183
+ if (prev !== undefined && f.table !== prev) out.push("");
184
+ const it = byKey.get(f.key);
185
+ if (it?.op === "change") out.push(renderItem(it, inline));
186
+ else if (it?.op === "add") out.push(style.green(` + ${f.ddl}`));
187
+ else out.push(style.dim(` ${f.ddl}`));
188
+ prev = f.table;
189
+ }
190
+ const removed = (diff.items ?? []).filter((it) => it.op === "remove");
191
+ if (removed.length) {
192
+ out.push("");
193
+ for (const it of removed) out.push(renderItem(it, inline));
194
+ }
195
+ return out.join("\n");
196
+ }
197
+
198
+ /** A human-readable view of a diff's forward (and optionally reverse) changes. */
199
+ export function formatDiff(
200
+ diff: Diff,
201
+ opts: { down?: boolean; full?: boolean; inline?: boolean } = {},
202
+ ): string {
203
+ if (!diff.up.length) return "No changes.";
204
+ let out = opts.full
205
+ ? formatFull(diff, opts.inline)
206
+ : formatItems(diff.items ?? [], opts.inline);
207
+ if (opts.down) {
208
+ out += `\n\n${style.dim(" rollback (down):")}\n${diff.down.map((s) => style.dim(` ${s}`)).join("\n")}`;
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * A per-kind breakdown of a set of changes, e.g. `1 Table, 2 Fields`. Counts each item by its
215
+ * structured `kind` and labels it from the registry's per-kind {@link KindRegistry.display} (singular
216
+ * when the count is one), so the summary is correct for every dialect — no DDL parsing.
217
+ */
218
+ export function summarizeKinds(
219
+ registry: KindRegistry,
220
+ items: readonly { kind: string }[],
221
+ ): string {
222
+ const counts = new Map<string, number>();
223
+ for (const it of items) counts.set(it.kind, (counts.get(it.kind) ?? 0) + 1);
224
+ const parts: string[] = [];
225
+ for (const [kind, n] of counts) {
226
+ const d = registry.display(kind);
227
+ parts.push(`${n} ${n === 1 ? d.label : d.plural}`);
228
+ }
229
+ return parts.join(", ");
230
+ }
@@ -0,0 +1,159 @@
1
+ import type { Command } from "commander";
2
+ import type { KindRegistry, PortableObject } from "../kind";
3
+ import { snapshotKinds, snapshotObjects } from "../kind";
4
+ import type { StoredSnapshot } from "./meta";
5
+
6
+ /**
7
+ * Per-kind object filter for `pull`/`diff`/`sync`/`generate`. Each kind is independently
8
+ * included (optionally name-restricted). `DEFINE ACCESS` is OPT-IN everywhere — excluded
9
+ * unless `--access` is given — so an introspection (which redacts access signing keys) can't
10
+ * silently rotate them. Table-scoped objects (fields/indexes/events) follow their table.
11
+ */
12
+ interface Cat {
13
+ on: boolean;
14
+ /** When set, only these names of the kind are included. */
15
+ names?: Set<string>;
16
+ }
17
+
18
+ export interface Filter {
19
+ tables: Cat;
20
+ functions: Cat;
21
+ events: Cat;
22
+ access: Cat;
23
+ }
24
+
25
+ /** Commander's parse of one `--kind [names]` / `--no-kind` flag: `undefined`/`true`/string/`false`. */
26
+ type FlagValue = string | boolean | undefined;
27
+
28
+ export interface FilterOpts {
29
+ tables?: FlagValue;
30
+ functions?: FlagValue;
31
+ events?: FlagValue;
32
+ access?: FlagValue;
33
+ }
34
+
35
+ function cat(v: FlagValue, defaultOn: boolean): Cat {
36
+ if (v === undefined) return { on: defaultOn };
37
+ if (v === false) return { on: false };
38
+ if (v === true) return { on: true };
39
+ const names = new Set(
40
+ v
41
+ .split(",")
42
+ .map((s) => s.trim())
43
+ .filter(Boolean),
44
+ );
45
+ return names.size ? { on: true, names } : { on: true };
46
+ }
47
+
48
+ /** Build a {@link Filter} from CLI flags. Access is opt-in (`--access`); the rest default to on. */
49
+ export function parseFilter(o: FilterOpts): Filter {
50
+ return {
51
+ tables: cat(o.tables, true),
52
+ functions: cat(o.functions, true),
53
+ events: cat(o.events, true),
54
+ access: cat(o.access, false), // DEFINE ACCESS is explicit everywhere — see module note.
55
+ };
56
+ }
57
+
58
+ /** Attach the per-kind `--tables/--functions/--events/--access [names]` (+ `--no-*`) options. */
59
+ export function kindFlags(cmd: Command): Command {
60
+ return cmd
61
+ .option("--tables [names]", "only these tables (comma-separated)")
62
+ .option("--no-tables", "exclude all tables")
63
+ .option("--functions [names]", "only these functions")
64
+ .option("--no-functions", "exclude all functions")
65
+ .option("--events [names]", "only these events")
66
+ .option("--no-events", "exclude all events")
67
+ .option(
68
+ "--access [names]",
69
+ "include access (off by default; key not pulled)",
70
+ )
71
+ .option("--no-access", "exclude all access (the default)");
72
+ }
73
+
74
+ /** Whether a name passes a category gate (kind on + name allowed). Shared with the surreal filters. */
75
+ export const inCat = (c: Cat, name: string): boolean =>
76
+ c.on && (!c.names || c.names.has(name));
77
+
78
+ // The SurrealDB statement/struct filters (`included`/`filterSnapshot`/`mergeSnapshot`/
79
+ // `filterStructured`) live in `./surreal-filter`. Below are the dialect-free kind-registry filters.
80
+
81
+ // --- kind-registry filters (the stored-snapshot path) -------------------------------------------
82
+
83
+ const objKey = (o: PortableObject) => `${o.kind}:${o.name}`;
84
+
85
+ /** Which Filter category gates a TOP-LEVEL kind (and whose name-set its name is matched against). */
86
+ function category(kind: string): keyof Filter {
87
+ if (kind === "function") return "functions";
88
+ if (kind === "access") return "access";
89
+ return "tables"; // a table (and any future top-level structural kind)
90
+ }
91
+
92
+ /**
93
+ * Whether a portable object passes the filter. A TOP-LEVEL object (no owner) is gated by its kind's
94
+ * category + name. An OWNED object (owner set — an index/event/constraint) FOLLOWS its owner table's
95
+ * inclusion; an `event` is ADDITIONALLY gated by the `events` category. (Fields are substrate nested
96
+ * in their table object, never standalone here.)
97
+ */
98
+ export function passesFilter(
99
+ registry: KindRegistry,
100
+ o: PortableObject,
101
+ f: Filter,
102
+ ): boolean {
103
+ const owner = registry.engine(o.kind)?.owner?.(o);
104
+ if (owner) {
105
+ if (!inCat(f.tables, owner.name)) return false;
106
+ return o.kind === "event" ? inCat(f.events, o.name) : true;
107
+ }
108
+ return inCat(f[category(o.kind)], o.name);
109
+ }
110
+
111
+ /** Keep only the portable objects that pass the filter (the {@link filterStructured} analog). */
112
+ export function filterKinds(
113
+ registry: KindRegistry,
114
+ objects: PortableObject[],
115
+ f: Filter,
116
+ ): PortableObject[] {
117
+ return objects.filter((o) => passesFilter(registry, o, f));
118
+ }
119
+
120
+ /**
121
+ * The stored snapshot to persist after a filtered `generate`: INCLUDED objects take their new state
122
+ * from `next`, EXCLUDED objects keep `prev`'s. Dedup by `kind:name` (an included `next` object wins).
123
+ * `files` overlays next on prev.
124
+ */
125
+ export function mergeStored(
126
+ registry: KindRegistry,
127
+ prev: StoredSnapshot,
128
+ next: StoredSnapshot,
129
+ f: Filter,
130
+ ): StoredSnapshot {
131
+ const merged = new Map<string, PortableObject>();
132
+ for (const o of snapshotObjects(prev.schema))
133
+ if (!passesFilter(registry, o, f)) merged.set(objKey(o), o);
134
+ for (const o of snapshotObjects(next.schema))
135
+ if (passesFilter(registry, o, f)) merged.set(objKey(o), o);
136
+ return {
137
+ version: 3,
138
+ driver: next.driver,
139
+ schema: snapshotKinds([...merged.values()]),
140
+ files: { ...(prev.files ?? {}), ...(next.files ?? {}) },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Restrict the `disk` objects to those that ALSO exist `live` (intersect by `kind:name`) AND pass the
146
+ * filter — for `baseline`. Hand-written schema not yet in the DB stays pending; what's really there is
147
+ * captured. Each field/index/event/constraint is its own object, so intersect-by-key handles them.
148
+ */
149
+ export function intersectKinds(
150
+ registry: KindRegistry,
151
+ disk: PortableObject[],
152
+ live: PortableObject[],
153
+ f: Filter,
154
+ ): PortableObject[] {
155
+ const liveKeys = new Set(live.map(objKey));
156
+ return disk.filter(
157
+ (o) => liveKeys.has(objKey(o)) && passesFilter(registry, o, f),
158
+ );
159
+ }