@schemic/surrealdb 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.
@@ -0,0 +1,696 @@
1
+ import { escapeIdent, type Surreal } from "surrealdb";
2
+ import type { DefineStatement } from "../ddl";
3
+
4
+ /** A snapshot statement: the emitted DDL plus the source file it came from (for `diff` annotations). */
5
+ export type SnapshotStatement = DefineStatement & {
6
+ /** Project-root-relative source file (absent for objects introspected from a live DB). */
7
+ file?: string;
8
+ };
9
+
10
+ /**
11
+ * The legacy STATEMENT snapshot — canonical SurrealQL DDL keyed by `kind:table:name`, + the
12
+ * optional normalized Struct (for `diff --ts`). This is the Surreal driver's INTERNAL diff data
13
+ * model (`buildSnapshot`/`diffSnapshots`/`structuredSnapshot`), derived on demand from the portable
14
+ * IR. The NEUTRAL stored snapshot is `StoredSnapshot` in cli/meta.ts.
15
+ */
16
+ export interface Snapshot {
17
+ version: 1;
18
+ statements: Record<string, SnapshotStatement>;
19
+ struct?: DbStructured;
20
+ }
21
+
22
+ /** The empty STATEMENT snapshot — the Surreal engine's "nothing yet" sentinel (e.g. baseline diff). */
23
+ export const EMPTY_SNAPSHOT: Snapshot = { version: 1, statements: {} };
24
+
25
+ /**
26
+ * Typed views of `INFO FOR … STRUCTURE` (SurrealDB 3.x). Unlike plain `INFO FOR …` (which
27
+ * returns DDL strings that have to be regex-parsed), STRUCTURE returns the schema as data — so
28
+ * names, clauses, flags, and permissions arrive pre-separated. Only `kind` stays a type
29
+ * expression (`object | none`, `array<record<user>>`, `'a' | 'b'`), parsed by `szType`.
30
+ */
31
+
32
+ /** A permission op's rule: `true` (FULL) / `false` (NONE) / a WHERE expression string. */
33
+ export type StructPerm = boolean | string;
34
+
35
+ export interface StructPermissions {
36
+ select?: StructPerm;
37
+ create?: StructPerm;
38
+ update?: StructPerm;
39
+ delete?: StructPerm;
40
+ }
41
+
42
+ export interface StructField {
43
+ /** Field path, e.g. `email`, `address.city`, `tags.*` — bare (no backtick escaping). */
44
+ name: string;
45
+ /** The SurrealQL type expression (kind), e.g. `string`, `option<int>`, `array<string>`. */
46
+ kind: string;
47
+ flexible?: boolean;
48
+ readonly?: boolean;
49
+ default?: string;
50
+ default_always?: boolean;
51
+ value?: string;
52
+ /** `COMPUTED <expr>` — a derived, read-only column. */
53
+ computed?: string;
54
+ assert?: string;
55
+ comment?: string;
56
+ /** `REFERENCE [ON DELETE …]` on a record-link field. `on_delete` mirrors `INFO … STRUCTURE`
57
+ * (snake_case) and the offline lowering (`lowerReference`): an action keyword (`CASCADE`/…) or a
58
+ * `surql` expression. Absent = no reference; present-without-`on_delete` = a bare `REFERENCE`. */
59
+ reference?: { on_delete?: string };
60
+ permissions?: StructPermissions;
61
+ table: string;
62
+ }
63
+
64
+ export interface StructIndex {
65
+ name: string;
66
+ /** Indexed columns/fields. */
67
+ cols: string[];
68
+ /** `"UNIQUE"`, `""` (plain), `"COUNT"`, or a `FULLTEXT …`/`HNSW …`/`DISKANN …` spec (canonicalized). */
69
+ index: string;
70
+ /** `COMMENT <string>` on the index. */
71
+ comment?: string;
72
+ }
73
+
74
+ export interface StructEvent {
75
+ name: string;
76
+ /** Owning table (STRUCTURE calls it `what`). */
77
+ what: string;
78
+ /** The `WHEN` condition. SurrealDB stores an omitted `WHEN` as the literal `"true"`. */
79
+ when?: string;
80
+ /** One or more `THEN` expressions (parens/`;` already stripped). */
81
+ then: string[];
82
+ }
83
+
84
+ export interface StructFunction {
85
+ name: string;
86
+ /** Ordered `[argName, surqlType]` pairs. */
87
+ args: [string, string][];
88
+ /** The body block, e.g. `{ RETURN $a + $b }`. */
89
+ block: string;
90
+ /** Declared return type, if any. */
91
+ returns?: string;
92
+ /** Execute permission: `true` (FULL, the default) / `false` (NONE) / a WHERE expression. */
93
+ permissions?: boolean | string;
94
+ comment?: string;
95
+ }
96
+
97
+ export interface StructAccess {
98
+ name: string;
99
+ kind: {
100
+ kind: string; // "RECORD" | "JWT" | "BEARER"
101
+ /** BEARER: `"RECORD"` | `"USER"`. */
102
+ subject?: string;
103
+ /** RECORD bodies. */
104
+ signup?: string;
105
+ signin?: string;
106
+ authenticate?: string;
107
+ /** JWT/BEARER token config. The `key` is REDACTED by SurrealDB; `alg`/`url` are not. */
108
+ jwt?: {
109
+ issuer?: { alg?: string; key?: string };
110
+ verify?: { alg?: string; key?: string; url?: string };
111
+ };
112
+ };
113
+ duration?: { grant?: string; token?: string; session?: string };
114
+ }
115
+
116
+ /** A text-search `DEFINE ANALYZER` — `INFO … STRUCTURE` returns uppercase tokenizer/filter lists. */
117
+ export interface StructAnalyzer {
118
+ name: string;
119
+ tokenizers: string[];
120
+ filters?: string[];
121
+ }
122
+
123
+ export interface StructTableKind {
124
+ kind: "NORMAL" | "ANY" | "RELATION";
125
+ in?: string[];
126
+ out?: string[];
127
+ enforced?: boolean;
128
+ }
129
+
130
+ export interface StructTable {
131
+ name: string;
132
+ kind: StructTableKind;
133
+ schemafull: boolean;
134
+ drop?: boolean;
135
+ comment?: string;
136
+ /** `CHANGEFEED <expiry> [INCLUDE ORIGINAL]`. */
137
+ changefeed?: { expiry: string; original: boolean };
138
+ permissions?: StructPermissions;
139
+ /** A pre-computed VIEW's `SELECT …` (the `AS ` keyword stripped; canonical re-adds it). */
140
+ view?: string;
141
+ fields: StructField[];
142
+ indexes: StructIndex[];
143
+ events: StructEvent[];
144
+ }
145
+
146
+ interface DbStructure {
147
+ tables?: (Omit<StructTable, "fields" | "indexes" | "kind"> & {
148
+ kind: {
149
+ kind: StructTableKind["kind"];
150
+ in?: unknown[];
151
+ out?: unknown[];
152
+ enforced?: boolean;
153
+ };
154
+ id?: number;
155
+ })[];
156
+ functions?: StructFunction[];
157
+ accesses?: StructAccess[];
158
+ analyzers?: StructAnalyzer[];
159
+ }
160
+
161
+ /** The structured database: tables (with their fields/indexes/events) and db-level functions/access/analyzers. */
162
+ export interface DbStructured {
163
+ tables: StructTable[];
164
+ functions: StructFunction[];
165
+ accesses: StructAccess[];
166
+ analyzers: StructAnalyzer[];
167
+ }
168
+ interface TableStructure {
169
+ fields?: StructField[];
170
+ indexes?: StructIndex[];
171
+ events?: StructEvent[];
172
+ }
173
+
174
+ /** The unescaped name of a relation endpoint — the SDK deserializes `in`/`out` as `Table` objects. */
175
+ function endpointName(v: unknown): string {
176
+ if (typeof v === "string") return v;
177
+ const name = (v as { name?: unknown })?.name;
178
+ return typeof name === "string" ? name : String(v);
179
+ }
180
+
181
+ // --- Canonical DDL ----------------------------------------------------------------------------
182
+ // Build a deterministic DDL string per object from the structured data, so two semantically-equal
183
+ // schemas compare equal regardless of how SurrealDB happened to format/order them. Both sides of
184
+ // `diff --live` (the live DB and the shadow-applied schema) go through this same builder.
185
+
186
+ /** Split a type expression on its top-level `|` (ignoring `|` inside `<…>`). */
187
+ function splitTopUnion(expr: string): string[] {
188
+ const parts: string[] = [];
189
+ let depth = 0;
190
+ let cur = "";
191
+ for (const c of expr) {
192
+ if (c === "<") depth++;
193
+ else if (c === ">") depth--;
194
+ if (c === "|" && depth === 0) {
195
+ parts.push(cur.trim());
196
+ cur = "";
197
+ } else cur += c;
198
+ }
199
+ parts.push(cur.trim());
200
+ return parts;
201
+ }
202
+
203
+ /**
204
+ * Canonical form of a type `kind`: fold a top-level `none` member into `option<…>` and sort the
205
+ * remaining union members, so `object | none` / `none | object` / `option<object>` all collapse
206
+ * to the same string (the union-ordering false-diff fix, done structurally).
207
+ */
208
+ function canonicalKind(kind: string): string {
209
+ const parts = splitTopUnion(kind).map((p) => p.trim());
210
+ if (parts.length <= 1) return kind.trim();
211
+ const hasNone = parts.includes("none");
212
+ const rest = parts.filter((p) => p !== "none");
213
+ if (hasNone) {
214
+ if (!rest.length) return "none";
215
+ const inner = rest.length === 1 ? rest[0] : [...rest].sort().join(" | ");
216
+ return `option<${inner}>`;
217
+ }
218
+ return [...parts].sort().join(" | ");
219
+ }
220
+
221
+ /**
222
+ * Canonical `PERMISSIONS …` clause from structured perms (`""` when it matches the kind default).
223
+ * `defaultFull` is the kind's default: FULL for fields/functions, NONE for tables. INFO materializes
224
+ * the default explicitly; the generator omits it — so we drop the clause when all ops are the
225
+ * default, making an unspecified `PERMISSIONS` compare equal across the two emitters.
226
+ */
227
+ function canonicalPerms(
228
+ perms: StructPermissions | undefined,
229
+ ops: (keyof StructPermissions)[],
230
+ defaultFull: boolean,
231
+ ): string {
232
+ if (!perms) return "";
233
+ const vals = ops.map((op) => perms[op]);
234
+ const allFull = vals.every((v) => v === true);
235
+ const allNone = vals.every((v) => v === false || v === undefined);
236
+ if (defaultFull ? allFull : allNone) return "";
237
+ if (allFull) return "PERMISSIONS FULL";
238
+ if (allNone) return "PERMISSIONS NONE";
239
+ // Mixed: emit only the ops that differ from the kind default (the generator omits default ops).
240
+ const isDefault = (v: StructPermissions[keyof StructPermissions]) =>
241
+ defaultFull ? v === true : v === false || v === undefined;
242
+ const clauses = ops
243
+ .filter((op) => !isDefault(perms[op]))
244
+ .map((op) => {
245
+ const v = perms[op];
246
+ if (v === true) return `FOR ${op} FULL`;
247
+ if (v === false || v === undefined) return `FOR ${op} NONE`;
248
+ return `FOR ${op} WHERE ${v}`;
249
+ });
250
+ return clauses.length ? `PERMISSIONS ${clauses.join(" ")}` : "";
251
+ }
252
+
253
+ /**
254
+ * Field CLAUSES keyed by clause name (insertion order == DDL order), each value already in the
255
+ * `ALTER FIELD … <set>` form — so the migration engine can diff clauses structurally without
256
+ * re-parsing the DDL (matches the authoring `emit` in ddl.ts). Keys are drawn from
257
+ * `FIELD_CLAUSE_ORDER` — including REFERENCE (emitted below + reversed by `pull`'s `renderField`).
258
+ */
259
+ function fieldClauses(f: StructField): Record<string, string> {
260
+ const clauses: Record<string, string> = {
261
+ TYPE: `TYPE ${canonicalKind(f.kind)}`,
262
+ };
263
+ if (f.flexible) clauses.FLEXIBLE = "FLEXIBLE";
264
+ // REFERENCE. SurrealDB defaults a bare `REFERENCE` to `ON DELETE IGNORE` and MATERIALIZES that in
265
+ // `INFO … STRUCTURE` (reference: { on_delete: 'IGNORE' }), whereas the offline lowering leaves a
266
+ // bare reference's on_delete absent — so IGNORE (and absent) both canonicalize to a bare `REFERENCE`
267
+ // (else every bare reference phantom-diffs against a live DB). A non-default action keyword renders
268
+ // `ON DELETE <ACTION>`; anything else (a surql expression) renders `ON DELETE THEN <expr>`. Inserted
269
+ // after FLEXIBLE to match FIELD_CLAUSE_ORDER, so a reference change diffs as ALTER FIELD, not OVERWRITE.
270
+ if (f.reference) {
271
+ const od = f.reference.on_delete;
272
+ clauses.REFERENCE =
273
+ !od || od.toUpperCase() === "IGNORE"
274
+ ? "REFERENCE"
275
+ : `REFERENCE ON DELETE ${/^(REJECT|CASCADE|UNSET)$/i.test(od) ? od : `THEN ${od}`}`;
276
+ }
277
+ if (f.default !== undefined)
278
+ clauses.DEFAULT = `DEFAULT ${f.default_always ? "ALWAYS " : ""}${f.default}`;
279
+ if (f.value !== undefined) clauses.VALUE = `VALUE ${f.value}`;
280
+ if (f.computed !== undefined) clauses.COMPUTED = `COMPUTED ${f.computed}`;
281
+ if (f.assert !== undefined) clauses.ASSERT = `ASSERT ${f.assert}`;
282
+ if (f.readonly) clauses.READONLY = "READONLY";
283
+ if (f.comment !== undefined)
284
+ clauses.COMMENT = `COMMENT ${JSON.stringify(f.comment)}`;
285
+ const perms = canonicalPerms(
286
+ f.permissions,
287
+ ["select", "create", "update"],
288
+ true,
289
+ );
290
+ if (perms) clauses.PERMISSIONS = perms;
291
+ return clauses;
292
+ }
293
+
294
+ /** Canonical `DEFINE FIELD …`. The name is taken as-is (STRUCTURE already escapes reserved words). */
295
+ function canonicalField(f: StructField): string {
296
+ const { TYPE, ...rest } = fieldClauses(f);
297
+ const head = `DEFINE FIELD ${f.name} ON TABLE ${f.table} ${TYPE}`;
298
+ const tail = Object.values(rest).join(" ");
299
+ return tail ? `${head} ${tail};` : `${head};`;
300
+ }
301
+
302
+ /**
303
+ * Table-head CLAUSES keyed by clause name (insertion order == DDL order), in the `ALTER TABLE …
304
+ * <set>` form so the migration engine can diff them structurally. Keys are drawn from
305
+ * `TABLE_CLAUSE_ORDER` (the head's own clauses; fields/indexes/events are their own statements).
306
+ */
307
+ function tableHeadClauses(t: StructTable): Record<string, string> {
308
+ const k = t.kind;
309
+ let type: string;
310
+ if (k.kind === "RELATION") {
311
+ // Endpoints optional — omit FROM/TO when unrestricted (matches `emit`). INFO stores them as
312
+ // IN/OUT; the generator (and our canonical form) use the FROM/TO alias.
313
+ type = "RELATION";
314
+ if (k.in?.length) type += ` FROM ${k.in.join(" | ")}`;
315
+ if (k.out?.length) type += ` TO ${k.out.join(" | ")}`;
316
+ if (k.enforced) type += " ENFORCED";
317
+ } else {
318
+ type = k.kind;
319
+ }
320
+ const clauses: Record<string, string> = { TYPE: `TYPE ${type}` };
321
+ clauses.SCHEMA = t.schemafull ? "SCHEMAFULL" : "SCHEMALESS";
322
+ if (t.view !== undefined) clauses.AS = `AS ${t.view}`;
323
+ if (t.drop) clauses.DROP = "DROP";
324
+ if (t.changefeed)
325
+ clauses.CHANGEFEED = `CHANGEFEED ${t.changefeed.expiry}${t.changefeed.original ? " INCLUDE ORIGINAL" : ""}`;
326
+ if (t.comment !== undefined)
327
+ clauses.COMMENT = `COMMENT ${JSON.stringify(t.comment)}`;
328
+ const perms = canonicalPerms(
329
+ t.permissions,
330
+ ["select", "create", "update", "delete"],
331
+ false,
332
+ );
333
+ if (perms) clauses.PERMISSIONS = perms;
334
+ return clauses;
335
+ }
336
+
337
+ /** Canonical `DEFINE TABLE …` head. */
338
+ function canonicalTableHead(t: StructTable): string {
339
+ const { TYPE, ...rest } = tableHeadClauses(t);
340
+ const head = `DEFINE TABLE ${t.name} ${TYPE}`;
341
+ const tail = Object.values(rest).join(" ");
342
+ return tail ? `${head} ${tail};` : `${head};`;
343
+ }
344
+
345
+ // SurrealDB MATERIALIZES default vector/fulltext index clauses on apply — e.g. `HNSW DIMENSION 4` is
346
+ // stored as `HNSW DIMENSION 4 DIST EUCLIDEAN TYPE F32 EFC 150 M 12 M0 24 LM <1/ln(M)>f`, and `FULLTEXT
347
+ // ANALYZER a` always carries `BM25(1.2,0.75)`. So the CANONICAL form strips default-valued + derived
348
+ // clauses and normalizes numbers (drops the SurrealQL `f` float suffix), making an authored MINIMAL
349
+ // spec compare equal to the introspected FULL one. Non-default values are kept.
350
+
351
+ /** Normalize a SurrealQL number token: drop a trailing `f`, collapse float noise (`1.20` -> `1.2`). */
352
+ function normNum(v: string): string {
353
+ const n = Number(v.replace(/f$/i, ""));
354
+ return Number.isFinite(n) ? String(n) : v;
355
+ }
356
+
357
+ /** Default-valued clauses SurrealDB fills in (stripped from canonical when they match). */
358
+ const VECTOR_DEFAULTS: Record<string, Record<string, string>> = {
359
+ HNSW: { DIST: "EUCLIDEAN", TYPE: "F32", EFC: "150", M: "12" },
360
+ DISKANN: {
361
+ DIST: "EUCLIDEAN",
362
+ TYPE: "F32",
363
+ DEGREE: "64",
364
+ L_BUILD: "100",
365
+ ALPHA: "1.2",
366
+ },
367
+ };
368
+ /** Clauses fully DERIVED from others (never authored, always stripped): HNSW `M0`=2·M, `LM`=1/ln(M). */
369
+ const VECTOR_DERIVED: Record<string, Set<string>> = {
370
+ HNSW: new Set(["M0", "LM"]),
371
+ DISKANN: new Set(),
372
+ };
373
+ /** Canonical clause order (matches the authoring emit). */
374
+ const VECTOR_ORDER: Record<string, string[]> = {
375
+ HNSW: ["DIMENSION", "DIST", "TYPE", "EFC", "M"],
376
+ DISKANN: ["DIMENSION", "DIST", "TYPE", "DEGREE", "L_BUILD", "ALPHA"],
377
+ };
378
+
379
+ /** Canonicalize an `HNSW …`/`DISKANN …` spec: drop derived + default clauses, normalize numbers. */
380
+ function normVector(algo: "HNSW" | "DISKANN", spec: string): string {
381
+ const toks = spec.slice(algo.length).trim().split(/\s+/).filter(Boolean);
382
+ const kept = new Map<string, string>();
383
+ for (let i = 0; i < toks.length; i += 2) {
384
+ const key = toks[i];
385
+ let val = toks[i + 1] ?? "";
386
+ if (VECTOR_DERIVED[algo].has(key)) continue;
387
+ if (/^[\d.]/.test(val) || /f$/i.test(val)) val = normNum(val);
388
+ if (VECTOR_DEFAULTS[algo][key] === val) continue;
389
+ kept.set(key, val);
390
+ }
391
+ const parts: string[] = [algo];
392
+ for (const k of VECTOR_ORDER[algo]) {
393
+ const v = kept.get(k);
394
+ if (v !== undefined) parts.push(k, v);
395
+ }
396
+ return parts.join(" ");
397
+ }
398
+
399
+ /** Canonicalize a `FULLTEXT ANALYZER … [BM25[(k,b)]] [HIGHLIGHTS]` spec: drop the default BM25(1.2,0.75). */
400
+ function normFulltext(spec: string): string {
401
+ const m = /^FULLTEXT ANALYZER (\S+)(.*)$/.exec(spec);
402
+ if (!m) return spec;
403
+ let out = `FULLTEXT ANALYZER ${m[1]}`;
404
+ const bm = /BM25(?:\(([^)]*)\))?/.exec(m[2]);
405
+ if (bm?.[1]) {
406
+ const args = bm[1].split(",").map((a) => normNum(a.trim()));
407
+ if (!(args.length === 2 && args[0] === "1.2" && args[1] === "0.75"))
408
+ out += ` BM25(${args.join(",")})`; // a non-default BM25 is kept
409
+ }
410
+ if (/\bHIGHLIGHTS\b/.test(m[2])) out += " HIGHLIGHTS";
411
+ return out;
412
+ }
413
+
414
+ /** Strip SurrealDB's materialized defaults from a vector/fulltext index spec (passthrough otherwise). */
415
+ function normalizeIndexSpec(spec: string): string {
416
+ if (spec.startsWith("HNSW")) return normVector("HNSW", spec);
417
+ if (spec.startsWith("DISKANN")) return normVector("DISKANN", spec);
418
+ if (spec.startsWith("FULLTEXT")) return normFulltext(spec);
419
+ return spec; // "", UNIQUE, COUNT (or a legacy SEARCH/MTREE spec) — left as-is
420
+ }
421
+
422
+ /** Canonical `DEFINE INDEX …`. `index` is `"UNIQUE"`/`""`/`"COUNT"`, or a FULLTEXT/HNSW/DISKANN spec —
423
+ * the latter normalized (materialized defaults stripped) so authored and introspected sides match. */
424
+ function canonicalIndex(t: StructTable, idx: StructIndex): string {
425
+ // A COUNT index has no columns → no `FIELDS` clause (`idx.index` carries `COUNT`).
426
+ const fields = idx.cols.length ? ` FIELDS ${idx.cols.join(", ")}` : "";
427
+ const norm = normalizeIndexSpec(idx.index);
428
+ const spec = norm ? ` ${norm}` : "";
429
+ const comment =
430
+ idx.comment !== undefined ? ` COMMENT ${JSON.stringify(idx.comment)}` : "";
431
+ return `DEFINE INDEX ${idx.name} ON TABLE ${t.name}${fields}${spec}${comment};`;
432
+ }
433
+
434
+ /**
435
+ * Canonical `DEFINE EVENT …`. An omitted `WHEN` (stored by SurrealDB as `"true"`) is dropped, and
436
+ * the `then` expressions are comma-joined — so an event authored with/without a `WHEN true` and any
437
+ * `THEN` formatting compares equal across the live DB and the shadow-applied schema.
438
+ */
439
+ function canonicalEvent(t: StructTable, ev: StructEvent): string {
440
+ const parts = [`DEFINE EVENT ${ev.name} ON TABLE ${t.name}`];
441
+ if (ev.when !== undefined && ev.when !== "true")
442
+ parts.push(`WHEN ${ev.when}`);
443
+ parts.push(`THEN ${ev.then.join(", ")}`);
444
+ return `${parts.join(" ")};`;
445
+ }
446
+
447
+ /**
448
+ * Canonical `DEFINE FUNCTION …`. The `block` is taken verbatim (already `{ … }`); a `true`
449
+ * (FULL — SurrealDB's default) permission is dropped so an unspecified `PERMISSIONS` compares equal
450
+ * across the live DB and the shadow-applied schema.
451
+ */
452
+ function canonicalFunction(fn: StructFunction): string {
453
+ const args = fn.args.map(([n, t]) => `$${n}: ${t}`).join(", ");
454
+ const parts = [`DEFINE FUNCTION fn::${fn.name}(${args})`];
455
+ if (fn.returns !== undefined) parts.push(`-> ${fn.returns}`);
456
+ parts.push(fn.block);
457
+ if (fn.permissions === false) parts.push("PERMISSIONS NONE");
458
+ else if (typeof fn.permissions === "string")
459
+ parts.push(`PERMISSIONS ${fn.permissions}`);
460
+ if (fn.comment !== undefined)
461
+ parts.push(`COMMENT ${JSON.stringify(fn.comment)}`);
462
+ return `${parts.join(" ")};`;
463
+ }
464
+
465
+ /**
466
+ * Canonical `DEFINE ACCESS …`. The signing KEY is INTENTIONALLY ignored — SurrealDB redacts it
467
+ * identically on both diff sides, so comparing it is meaningless (key changes go undetected by
468
+ * `diff --live`; documented). The JWT `alg`/`url` are kept (not redacted). Always `ON DATABASE`.
469
+ */
470
+ function canonicalAccess(a: StructAccess): string {
471
+ const k = a.kind;
472
+ let typeClause: string;
473
+ if (k.kind === "BEARER") {
474
+ typeClause = `TYPE BEARER FOR ${k.subject}`;
475
+ } else if (k.kind === "JWT") {
476
+ const v = k.jwt?.verify;
477
+ typeClause = v?.url
478
+ ? `TYPE JWT URL ${JSON.stringify(v.url)}`
479
+ : `TYPE JWT ALGORITHM ${v?.alg ?? ""}`; // KEY omitted (redacted)
480
+ } else {
481
+ typeClause = "TYPE RECORD";
482
+ }
483
+ const parts = [`DEFINE ACCESS ${a.name} ON DATABASE ${typeClause}`];
484
+ if (k.kind === "RECORD") {
485
+ if (k.signup) parts.push(`SIGNUP ${k.signup}`);
486
+ if (k.signin) parts.push(`SIGNIN ${k.signin}`);
487
+ if (k.authenticate) parts.push(`AUTHENTICATE ${k.authenticate}`);
488
+ }
489
+ const d = a.duration;
490
+ if (d?.grant || d?.token || d?.session) {
491
+ const fors: string[] = [];
492
+ if (d.grant) fors.push(`FOR GRANT ${d.grant}`);
493
+ if (d.token) fors.push(`FOR TOKEN ${d.token}`);
494
+ if (d.session) fors.push(`FOR SESSION ${d.session}`);
495
+ parts.push(`DURATION ${fors.join(", ")}`);
496
+ }
497
+ return `${parts.join(" ")};`;
498
+ }
499
+
500
+ /** Canonical `DEFINE ANALYZER …` — tokenizer/filter lists joined `, ` (uppercase, as INFO returns them). */
501
+ function canonicalAnalyzer(a: StructAnalyzer): string {
502
+ let s = `DEFINE ANALYZER ${a.name} TOKENIZERS ${a.tokenizers.join(", ")}`;
503
+ if (a.filters?.length) s += ` FILTERS ${a.filters.join(", ")}`;
504
+ return `${s};`;
505
+ }
506
+
507
+ /**
508
+ * Fold an array element type into a BARE `array`/`set` kind, so a field stored as `array` with an
509
+ * `array.* TYPE object` element compares equal to `array<object>` (the typed form @schemic/core
510
+ * emits). Typed kinds (`array<X>`) and the `.*` itself are left alone — the element is in the type.
511
+ */
512
+ function foldArrayElement(kind: string, elementKind: string): string {
513
+ const elem = canonicalKind(elementKind);
514
+ // Replace a bare `array`/`set` (not already followed by `<…>`) — handles `option<array>` too.
515
+ return kind.replace(/\b(array|set)\b(?!<)/, (kw) => `${kw}<${elem}>`);
516
+ }
517
+
518
+ /** True if every listed permission op is FULL (`true`) or unset — i.e. the default. */
519
+ function isFullPerms(
520
+ perms: StructPermissions | undefined,
521
+ ops: (keyof StructPermissions)[],
522
+ ): boolean {
523
+ if (!perms) return true;
524
+ return ops.every((op) => perms[op] === undefined || perms[op] === true);
525
+ }
526
+
527
+ /**
528
+ * An array element (`x.*`) is "trivial" when it's exactly the form SurrealDB auto-creates from
529
+ * `array<…>` — a plain element with default permissions and no other clause. Trivial elements are
530
+ * folded into the parent type and not emitted; a CUSTOMIZED element (FLEXIBLE / permissions /
531
+ * readonly / default / value / assert / comment) is kept so its config isn't silently lost.
532
+ */
533
+ function isTrivialElement(f: StructField): boolean {
534
+ return (
535
+ !f.flexible &&
536
+ !f.readonly &&
537
+ f.default === undefined &&
538
+ f.value === undefined &&
539
+ f.assert === undefined &&
540
+ f.comment === undefined &&
541
+ isFullPerms(f.permissions, ["select", "create", "update"])
542
+ );
543
+ }
544
+
545
+ const keyOf = (s: Pick<DefineStatement, "kind" | "name" | "table">) =>
546
+ `${s.kind}:${s.table ?? ""}:${s.name}`;
547
+
548
+ /**
549
+ * Build a canonical-DDL {@link Snapshot} from the structured database (tables + db-level functions).
550
+ * Skips implicit `id` (and `in`/`out` on relations) and childless `*` array-element fields —
551
+ * @schemic/core's emit doesn't produce them, so the snapshot must not either (else diff would
552
+ * try to drop/add them).
553
+ */
554
+ export function structuredSnapshot({
555
+ tables,
556
+ functions,
557
+ accesses,
558
+ analyzers,
559
+ }: DbStructured): Snapshot {
560
+ const statements: Record<string, DefineStatement> = {};
561
+ for (const t of tables) {
562
+ const tableStmt: DefineStatement = {
563
+ kind: "table",
564
+ name: t.name,
565
+ ddl: canonicalTableHead(t),
566
+ clauses: tableHeadClauses(t),
567
+ };
568
+ statements[keyOf(tableStmt)] = tableStmt;
569
+
570
+ const implicit =
571
+ t.kind.kind === "RELATION"
572
+ ? new Set(["id", "in", "out"])
573
+ : new Set(["id"]);
574
+ // A trivial array element (`x.*`) is folded into the parent's `array<…>` type and not emitted
575
+ // (so bare `array` + `x.* object` == typed `array<object>`). A CUSTOMIZED element (FLEXIBLE /
576
+ // permissions / …) is kept so its config isn't lost. Map/record `.*` values are kept too.
577
+ const byName = new Map(t.fields.map((f) => [f.name, f]));
578
+ const elementOf = new Map<string, StructField>();
579
+ for (const f of t.fields)
580
+ if (f.name.endsWith(".*")) elementOf.set(f.name.slice(0, -2), f);
581
+
582
+ for (const f of t.fields) {
583
+ if (implicit.has(f.name)) continue;
584
+ if (f.name.endsWith(".*")) {
585
+ const parent = byName.get(f.name.slice(0, -2));
586
+ const parentIsArray = parent
587
+ ? /\b(?:array|set)\b/.test(parent.kind)
588
+ : false;
589
+ if (parentIsArray && isTrivialElement(f)) continue; // auto-created form → folded
590
+ }
591
+ const elem = elementOf.get(f.name);
592
+ const field = elem
593
+ ? { ...f, kind: foldArrayElement(f.kind, elem.kind) }
594
+ : f;
595
+ const s: DefineStatement = {
596
+ kind: "field",
597
+ name: f.name,
598
+ table: t.name,
599
+ ddl: canonicalField(field),
600
+ clauses: fieldClauses(field),
601
+ };
602
+ statements[keyOf(s)] = s;
603
+ }
604
+ for (const idx of t.indexes) {
605
+ const s: DefineStatement = {
606
+ kind: "index",
607
+ name: idx.name,
608
+ table: t.name,
609
+ ddl: canonicalIndex(t, idx),
610
+ };
611
+ statements[keyOf(s)] = s;
612
+ }
613
+ for (const ev of t.events) {
614
+ const s: DefineStatement = {
615
+ kind: "event",
616
+ name: ev.name,
617
+ table: t.name,
618
+ ddl: canonicalEvent(t, ev),
619
+ };
620
+ statements[keyOf(s)] = s;
621
+ }
622
+ }
623
+ for (const fn of functions) {
624
+ const s: DefineStatement = {
625
+ kind: "function",
626
+ name: fn.name,
627
+ ddl: canonicalFunction(fn),
628
+ };
629
+ statements[keyOf(s)] = s;
630
+ }
631
+ for (const a of accesses) {
632
+ const s: DefineStatement = {
633
+ kind: "access",
634
+ name: a.name,
635
+ ddl: canonicalAccess(a),
636
+ };
637
+ statements[keyOf(s)] = s;
638
+ }
639
+ for (const an of analyzers) {
640
+ const s: DefineStatement = {
641
+ kind: "analyzer",
642
+ name: an.name,
643
+ ddl: canonicalAnalyzer(an),
644
+ };
645
+ statements[keyOf(s)] = s;
646
+ }
647
+ return { version: 1, statements };
648
+ }
649
+
650
+ /**
651
+ * Read the live database structure via `INFO FOR DB STRUCTURE` (table heads + db-level functions)
652
+ * plus one `INFO FOR TABLE … STRUCTURE` per table (its fields/indexes/events), skipping `exclude`d
653
+ * tables. Functions are db-level and not excludable.
654
+ */
655
+ export async function introspectStructured(
656
+ db: Surreal,
657
+ exclude: Set<string> = new Set(),
658
+ ): Promise<DbStructured> {
659
+ const [dbInfo] = await db.query<[DbStructure]>("INFO FOR DB STRUCTURE");
660
+ const tables: StructTable[] = [];
661
+ for (const t of dbInfo.tables ?? []) {
662
+ if (exclude.has(t.name)) continue;
663
+ const [tinfo] = await db.query<[TableStructure]>(
664
+ `INFO FOR TABLE ${escapeIdent(t.name)} STRUCTURE`,
665
+ );
666
+ tables.push({
667
+ name: t.name,
668
+ kind: {
669
+ kind: t.kind.kind,
670
+ in: t.kind.in?.map(endpointName),
671
+ out: t.kind.out?.map(endpointName),
672
+ enforced: t.kind.enforced,
673
+ },
674
+ schemafull: t.schemafull,
675
+ drop: t.drop,
676
+ comment: t.comment,
677
+ changefeed: t.changefeed,
678
+ permissions: t.permissions,
679
+ // INFO STRUCTURE stores a view as `AS SELECT …`; strip the keyword to match the lowered form.
680
+ view: t.view?.replace(/^AS\s+/i, ""),
681
+ fields: tinfo.fields ?? [],
682
+ // Strip SurrealDB's materialized vector/fulltext defaults so the spec matches the authored form.
683
+ indexes: (tinfo.indexes ?? []).map((i) => ({
684
+ ...i,
685
+ index: normalizeIndexSpec(i.index),
686
+ })),
687
+ events: tinfo.events ?? [],
688
+ });
689
+ }
690
+ return {
691
+ tables,
692
+ functions: dbInfo.functions ?? [],
693
+ accesses: dbInfo.accesses ?? [],
694
+ analyzers: dbInfo.analyzers ?? [],
695
+ };
696
+ }