@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.
package/src/ddl.ts ADDED
@@ -0,0 +1,931 @@
1
+ import { BoundQuery, escapeIdent, toSurqlString } from "surrealdb";
2
+ import type { z } from "zod";
3
+ import {
4
+ type AccessDef,
5
+ type AnalyzerDef,
6
+ type Expr,
7
+ type FieldPermissions,
8
+ type FunctionDef,
9
+ objectFieldsRegistry,
10
+ type PermOp,
11
+ type SField,
12
+ type Shape,
13
+ type StandaloneDef,
14
+ type SurrealMeta,
15
+ surrealTypeRegistry,
16
+ type TableDef,
17
+ type TableEvent,
18
+ type TablePermissions,
19
+ } from "./pure";
20
+
21
+ /** Inline a BoundQuery's bindings into a literal SurrealQL string for DDL use. Exported so the
22
+ * Struct-IR lowering (`fromTableDef`) renders DEFAULT/VALUE/COMPUTED/permission exprs identically. */
23
+ export function inline(query: BoundQuery): string {
24
+ let out = query.query;
25
+ for (const [name, value] of Object.entries(query.bindings ?? {})) {
26
+ out = out.replaceAll(`$${name}`, toSurqlString(value));
27
+ }
28
+ return out.trim();
29
+ }
30
+
31
+ /**
32
+ * The bare AND-joined `ASSERT` expression (no `ASSERT ` keyword): inline any `BoundQuery` entries
33
+ * (custom `surql` asserts), keep strings (computed checks) as-is, dedupe while preserving order,
34
+ * and AND-join. Each fragment is already a complete boolean expr. Returns "" when there are none.
35
+ * Exported so the Struct-IR lowering (`fromTableDef`) can populate `StructField.assert` (the bare
36
+ * expr) while the DDL emitter prepends the `ASSERT ` keyword via {@link renderAsserts}.
37
+ */
38
+ export function assertExpr(asserts: SurrealMeta["asserts"]): string {
39
+ if (!asserts?.length) return "";
40
+ const frags: string[] = [];
41
+ for (const a of asserts) {
42
+ const frag = a instanceof BoundQuery ? inline(a) : a;
43
+ if (frag && !frags.includes(frag)) frags.push(frag);
44
+ }
45
+ return frags.join(" AND ");
46
+ }
47
+
48
+ /** The full `ASSERT <expr>` clause for the DDL emitter (or "" when there are no fragments). */
49
+ function renderAsserts(asserts: SurrealMeta["asserts"]): string {
50
+ const expr = assertExpr(asserts);
51
+ return expr ? `ASSERT ${expr}` : "";
52
+ }
53
+
54
+ /** Read a Zod schema's internal def with a loose type for traversal. */
55
+ function zdef(schema: z.ZodType): { type: string; [k: string]: unknown } {
56
+ return schema._zod.def as unknown as { type: string; [k: string]: unknown };
57
+ }
58
+
59
+ /** Format a literal value as a SurrealQL literal type (e.g. `'admin'`, `42`). */
60
+ function surqlLiteral(value: unknown): string {
61
+ if (typeof value === "string") return JSON.stringify(value);
62
+ if (
63
+ typeof value === "number" ||
64
+ typeof value === "boolean" ||
65
+ typeof value === "bigint"
66
+ ) {
67
+ return String(value);
68
+ }
69
+ return toSurqlString(value).replace(/^s"/, '"');
70
+ }
71
+
72
+ /**
73
+ * The SurrealQL type of a field plus any nested fields it expands into:
74
+ * object subfields (`path.key`) and array/record element fields (`path.*`).
75
+ * Exported (with {@link inferField}) so the Struct-IR lowering walks the SAME child tree the
76
+ * emitter does — so the two can't disagree on type strings or dotted field paths.
77
+ */
78
+ export interface FieldInfo {
79
+ type: string;
80
+ flexible: boolean;
81
+ children: { suffix: string; info: FieldInfo; surreal?: SurrealMeta }[];
82
+ }
83
+ const leaf = (type: string): FieldInfo => ({
84
+ type,
85
+ flexible: false,
86
+ children: [],
87
+ });
88
+
89
+ /** Infer a field's SurrealQL type + nested structure from a Zod schema. Exported so the Struct-IR
90
+ * lowering (`fromTableDef`) and the emitter share one source of truth for type strings + paths. */
91
+ export function inferField(
92
+ schema: z.ZodType,
93
+ seen: Set<z.ZodType> = new Set(),
94
+ ): FieldInfo {
95
+ // Surreal-native schemas (datetime, recordId) carry their type explicitly.
96
+ const explicit = surrealTypeRegistry.get(schema);
97
+ if (explicit) return leaf(explicit);
98
+
99
+ const def = zdef(schema);
100
+ switch (def.type) {
101
+ case "string":
102
+ return leaf("string");
103
+ case "number": {
104
+ // z.int/int32/uint32/float64 share def.type "number"; the format discriminates.
105
+ const fmt = def.format as string | undefined;
106
+ if (fmt?.includes("float")) return leaf("float");
107
+ if (fmt?.includes("int")) return leaf("int");
108
+ return leaf("number");
109
+ }
110
+ case "bigint":
111
+ return leaf("int");
112
+ case "boolean":
113
+ return leaf("bool");
114
+ case "date":
115
+ return leaf("datetime");
116
+ case "any":
117
+ case "unknown":
118
+ return leaf("any");
119
+ case "null":
120
+ return leaf("null");
121
+
122
+ // No SurrealQL mapping — these exist on `s.*` only for drop-in `z.*` parity, and are
123
+ // rejected when used as a table field. (Registered native types — datetime/uuid/record/…
124
+ // — are caught by the `surrealTypeRegistry` check at the top, so they never reach here.)
125
+ case "symbol":
126
+ case "undefined":
127
+ case "void":
128
+ case "never":
129
+ case "nan":
130
+ case "function":
131
+ case "promise":
132
+ case "custom":
133
+ throw new Error(
134
+ `s.${def.type}() has no SurrealQL type and can't be used as a table field. ` +
135
+ `Use a Surreal-native builder (e.g. s.string / s.int / s.datetime / s.uuid / ` +
136
+ `s.recordId) instead, or keep this schema out of your table definitions.`,
137
+ );
138
+
139
+ case "optional":
140
+ case "default":
141
+ case "prefault": {
142
+ const inner = inferField(def.innerType as z.ZodType, seen);
143
+ // `any` already admits NONE/NULL, so `option<any>` is invalid SurrealQL — leave it as `any`.
144
+ if (inner.type === "any") return inner;
145
+ return { ...inner, type: `option<${inner.type}>` };
146
+ }
147
+ case "nullable": {
148
+ const inner = inferField(def.innerType as z.ZodType, seen);
149
+ if (inner.type === "any") return inner; // `any` already includes null
150
+ // Fold null INTO an existing option<X> so .optional().nullable() matches
151
+ // .nullish()/.nullable().optional(): option<X> | null -> option<X | null>.
152
+ if (inner.type.startsWith("option<") && inner.type.endsWith(">")) {
153
+ const x = inner.type.slice("option<".length, -1);
154
+ return { ...inner, type: `option<${x} | null>` };
155
+ }
156
+ return { ...inner, type: `${inner.type} | null` };
157
+ }
158
+ case "readonly":
159
+ case "catch": // app-side error recovery — the stored type is the inner type
160
+ return inferField(def.innerType as z.ZodType, seen);
161
+ case "pipe": // a codec with no explicit type — use its encoded (wire) side
162
+ return inferField(def.in as z.ZodType, seen);
163
+
164
+ case "lazy": {
165
+ // Track the lazy schema itself: its getter returns a fresh instance each call,
166
+ // but the recursive reference reuses the same lazy node.
167
+ if (seen.has(schema)) return leaf("any");
168
+ seen.add(schema);
169
+ const info = inferField((def.getter as () => z.ZodType)(), seen);
170
+ seen.delete(schema);
171
+ return info;
172
+ }
173
+
174
+ case "object": {
175
+ const shape = def.shape as Record<string, z.ZodType>;
176
+ const fields = objectFieldsRegistry.get(schema); // SField shape if built via s.object
177
+ const catchall = def.catchall as z.ZodType | undefined;
178
+ const flexible = !!catchall && zdef(catchall).type === "unknown";
179
+ const children = Object.entries(shape).map(([key, value]) => ({
180
+ suffix: `.${escapeIdent(key)}`,
181
+ info: inferField(value, seen),
182
+ surreal: fields?.[key]?.surreal,
183
+ }));
184
+ return { type: "object", flexible, children };
185
+ }
186
+
187
+ case "intersection": {
188
+ const left = inferField(def.left as z.ZodType, seen);
189
+ const right = inferField(def.right as z.ZodType, seen);
190
+ if (left.type === "object" && right.type === "object") {
191
+ const merged = new Map(left.children.map((c) => [c.suffix, c]));
192
+ for (const c of right.children) merged.set(c.suffix, c); // right wins on overlap
193
+ return {
194
+ type: "object",
195
+ flexible: left.flexible || right.flexible,
196
+ children: [...merged.values()],
197
+ };
198
+ }
199
+ return leaf("any");
200
+ }
201
+
202
+ case "array":
203
+ case "set": {
204
+ const elem = inferField(
205
+ (def.element ?? def.valueType) as z.ZodType,
206
+ seen,
207
+ );
208
+ // Element subfields live under `path.*`, but only when the element is structured.
209
+ const children =
210
+ elem.children.length > 0 || elem.type === "object"
211
+ ? [{ suffix: ".*", info: elem }]
212
+ : [];
213
+ // `set<T>` is distinct from `array<T>` in SurrealDB (dedup) and round-trips — preserve it.
214
+ const kw = def.type === "set" ? "set" : "array";
215
+ // `array<T, N>` / `set<T, N>` — N is a MAX size from a Zod `.max()` check
216
+ // (`max_length` on arrays, `max_size` on sets). No min in the SurrealQL form.
217
+ const checks =
218
+ (
219
+ def as {
220
+ checks?: {
221
+ _zod?: { def?: { check?: string; maximum?: number } };
222
+ }[];
223
+ }
224
+ ).checks ?? [];
225
+ const maximum = checks
226
+ .map((c) => c._zod?.def)
227
+ .find(
228
+ (d) => d?.check === "max_length" || d?.check === "max_size",
229
+ )?.maximum;
230
+ const size = typeof maximum === "number" ? `, ${maximum}` : "";
231
+ return { type: `${kw}<${elem.type}${size}>`, flexible: false, children };
232
+ }
233
+
234
+ case "record":
235
+ case "map": {
236
+ const value = inferField(def.valueType as z.ZodType, seen);
237
+ return {
238
+ type: "object",
239
+ flexible: false,
240
+ children: [{ suffix: ".*", info: value }],
241
+ };
242
+ }
243
+
244
+ case "union": {
245
+ const opts = (def.options ?? []) as z.ZodType[];
246
+ // A `none`-ish member (z.undefined()/z.void()) makes the union optional: `T | none` -> `option<T>`.
247
+ const noneish = (o: z.ZodType) => {
248
+ const t = zdef(o).type;
249
+ return t === "undefined" || t === "void";
250
+ };
251
+ const hasNone = opts.some(noneish);
252
+ const types = [
253
+ ...new Set(
254
+ opts.filter((o) => !noneish(o)).map((o) => inferField(o, seen).type),
255
+ ),
256
+ ];
257
+ // `any` absorbs every other member (including none) — `any | string` is invalid → `any`.
258
+ if (types.includes("any")) return leaf("any");
259
+ const joined = types.join(" | ") || "any";
260
+ if (hasNone) return leaf(joined === "any" ? "any" : `option<${joined}>`);
261
+ return leaf(joined);
262
+ }
263
+ case "enum": {
264
+ const entries = (def.entries ?? {}) as Record<string, string | number>;
265
+ // Drop TS numeric-enum reverse mappings (name->number); keep the real values.
266
+ const values = Object.values(entries).filter(
267
+ (v) => typeof entries[v as string] !== "number",
268
+ );
269
+ const types = [...new Set(values.map(surqlLiteral))];
270
+ return leaf(types.join(" | ") || "any");
271
+ }
272
+ case "literal": {
273
+ const values = (def.values ?? []) as unknown[];
274
+ const types = [...new Set(values.map(surqlLiteral))];
275
+ return leaf(types.join(" | ") || "any");
276
+ }
277
+ case "tuple": {
278
+ if (def.rest) return leaf("array"); // variadic tuple -> generic array
279
+ const items = (def.items ?? []) as z.ZodType[];
280
+ return leaf(`[${items.map((i) => inferField(i, seen).type).join(", ")}]`);
281
+ }
282
+
283
+ default:
284
+ return leaf("any");
285
+ }
286
+ }
287
+
288
+ /** DDL generation options. `exists: "overwrite"` -> OVERWRITE; "ignore" -> IF NOT EXISTS. */
289
+ export type DefineOptions = { exists?: "overwrite" | "ignore" };
290
+
291
+ function existsPrefix(opts?: DefineOptions): string {
292
+ return opts?.exists === "overwrite"
293
+ ? "OVERWRITE "
294
+ : opts?.exists === "ignore"
295
+ ? "IF NOT EXISTS "
296
+ : "";
297
+ }
298
+
299
+ /**
300
+ * Render a `PERMISSIONS …` clause for a table or field from a permissions spec. `ops` is
301
+ * the canonical op set: `["select","create","update","delete"]` for tables,
302
+ * `["select","create","update"]` for fields (fields have no `delete`).
303
+ *
304
+ * - `true` -> `PERMISSIONS FULL`
305
+ * - `false` -> `PERMISSIONS NONE`
306
+ * - a `BoundQuery` -> every op shares it: `PERMISSIONS FOR <all ops> WHERE <expr>`
307
+ * - an object -> 1. resolve each present op to a concrete rule (`boolean | BoundQuery`);
308
+ * a `` `same as X` `` reuses X's resolved rule (errors if X is absent or on a cycle).
309
+ * 2. merge ops whose resolved rule is identical (booleans by `===`, BoundQuery by its
310
+ * `inline()`-ed string) into one `FOR a, b … <rule>` clause, in canonical op order.
311
+ *
312
+ * Omitted ops emit nothing — and the SurrealDB defaults for an omitted op are intentionally
313
+ * ASYMMETRIC: a TABLE defaults it to NONE (deny), a FIELD defaults it to FULL (the table is
314
+ * the gate). So to lock a field op you must set it `false` explicitly.
315
+ */
316
+ export function renderPermissions(
317
+ spec: TablePermissions | FieldPermissions,
318
+ ops: readonly PermOp[],
319
+ ): string {
320
+ if (spec === true) return "PERMISSIONS FULL";
321
+ if (spec === false) return "PERMISSIONS NONE";
322
+ if (spec instanceof BoundQuery)
323
+ return `PERMISSIONS FOR ${ops.join(", ")} WHERE ${inline(spec)}`;
324
+
325
+ const rules = spec as Partial<Record<PermOp, boolean | BoundQuery | string>>;
326
+ const present = ops.filter((op) => rules[op] !== undefined);
327
+ const resolved = new Map<PermOp, boolean | BoundQuery>();
328
+
329
+ // Resolve an op's rule, following `same as X` references; `chain` detects cycles.
330
+ const resolve = (op: PermOp, chain: PermOp[]): boolean | BoundQuery => {
331
+ const cached = resolved.get(op);
332
+ if (cached !== undefined) return cached;
333
+ const rule = rules[op];
334
+ if (rule === undefined) {
335
+ throw new Error(
336
+ `PERMISSIONS: "same as ${op}" references op "${op}", which is not in the spec`,
337
+ );
338
+ }
339
+ if (chain.includes(op)) {
340
+ throw new Error(
341
+ `PERMISSIONS: "same as" reference cycle: ${[...chain, op].join(" -> ")}`,
342
+ );
343
+ }
344
+ const value =
345
+ typeof rule === "string"
346
+ ? resolve(rule.slice("same as ".length).trim() as PermOp, [
347
+ ...chain,
348
+ op,
349
+ ])
350
+ : rule;
351
+ resolved.set(op, value);
352
+ return value;
353
+ };
354
+
355
+ // Group present ops by their resolved rule's clause body (canonical order preserved).
356
+ const groups = new Map<string, PermOp[]>();
357
+ for (const op of present) {
358
+ const rule = resolve(op, []);
359
+ const body =
360
+ rule === true
361
+ ? "FULL"
362
+ : rule === false
363
+ ? "NONE"
364
+ : `WHERE ${inline(rule)}`;
365
+ const group = groups.get(body);
366
+ if (group) group.push(op);
367
+ else groups.set(body, [op]);
368
+ }
369
+ const clauses = [...groups].map(
370
+ ([body, group]) => `FOR ${group.join(", ")} ${body}`,
371
+ );
372
+ return clauses.length ? `PERMISSIONS ${clauses.join(" ")}` : "";
373
+ }
374
+
375
+ /**
376
+ * One generated DDL statement, tied to the schema object it defines. `kind` is the object
377
+ * kind; `name` identifies it within its scope (a table name, or a field path like
378
+ * `settings.theme` / `tags.*`, already escaped as it appears in the DDL); `table` is the
379
+ * owning table for fields. Used by the CLI's migration diff to add/change/remove objects
380
+ * individually. `emitTable`/`emitField` are the string-joined views of these.
381
+ */
382
+ export interface DefineStatement {
383
+ kind:
384
+ | "table"
385
+ | "field"
386
+ | "index"
387
+ | "event"
388
+ | "function"
389
+ | "access"
390
+ | "analyzer";
391
+ name: string;
392
+ table?: string;
393
+ ddl: string;
394
+ /**
395
+ * Rendered clause fragments keyed by clause name (`TYPE`, `DEFAULT`, `ASSERT`, …) — only on
396
+ * `field`/`table` statements. Each value is the exact fragment used in the DDL, which is also
397
+ * the `ALTER … <set>` form, so the migration engine can compute a clause-level delta without
398
+ * parsing SurrealQL. Absent on older snapshots (those changes fall back to `OVERWRITE`).
399
+ */
400
+ clauses?: Record<string, string>;
401
+ }
402
+
403
+ /** The SurrealQL type of a field schema (e.g. `string`, `option<int>`, `record<user>`). */
404
+ export function fieldType(field: SField): string {
405
+ return inferField(field.schema).type;
406
+ }
407
+
408
+ /** Inline a single event clause (`when`/one `then`): a `BoundQuery` is inlined, a string passes
409
+ * through. Exported so the Struct-IR lowering renders event/permission exprs identically. */
410
+ export function eventClause(e: Expr): string {
411
+ return e instanceof BoundQuery ? inline(e) : e;
412
+ }
413
+
414
+ /** A `{ … }` block body — wraps a bare statement list in braces; a `surql\`{ … }\`` passes through.
415
+ * Exported so the Struct-IR lowering renders function/access blocks to match INFO's `{ … }` form. */
416
+ export function braceBody(e: Expr): string {
417
+ const s = eventClause(e).trim();
418
+ return s.startsWith("{") ? s : `{ ${s} }`;
419
+ }
420
+
421
+ /** `DEFINE EVENT <name> ON TABLE <table> [WHEN <when>] THEN <then>`. Multiple `then`s run in order. */
422
+ function emitEvent(
423
+ table: string,
424
+ ev: TableEvent,
425
+ opts?: DefineOptions,
426
+ ): string {
427
+ const parts = [
428
+ `DEFINE EVENT ${existsPrefix(opts)}${escapeIdent(ev.name)} ON TABLE ${escapeIdent(table)}`,
429
+ ];
430
+ if (ev.when !== undefined) parts.push(`WHEN ${eventClause(ev.when)}`);
431
+ const thens = (Array.isArray(ev.then) ? ev.then : [ev.then]).map(eventClause);
432
+ // One `THEN` rides bare; several are parenthesized so the comma list parses unambiguously.
433
+ parts.push(
434
+ `THEN ${thens.length === 1 ? thens[0] : thens.map((t) => `(${t})`).join(", ")}`,
435
+ );
436
+ return `${parts.join(" ")};`;
437
+ }
438
+
439
+ /** `DEFINE FUNCTION fn::<name>(<args>) [-> <returns>] { <body> } [PERMISSIONS …] [COMMENT …]`. */
440
+ function emitFunction(fn: FunctionDef, opts?: DefineOptions): string {
441
+ if (fn.config.body === undefined) {
442
+ throw new Error(
443
+ `function fn::${fn.name} has no body — call .body(surql\`…\`)`,
444
+ );
445
+ }
446
+ const args = Object.entries(fn.args)
447
+ .map(([n, f]) => `$${n}: ${fieldType(f)}`)
448
+ .join(", ");
449
+ const parts = [
450
+ `DEFINE FUNCTION ${existsPrefix(opts)}fn::${escapeIdent(fn.name)}(${args})`,
451
+ ];
452
+ if (fn.config.returns) parts.push(`-> ${fieldType(fn.config.returns)}`);
453
+ parts.push(braceBody(fn.config.body));
454
+ const p = fn.config.permissions;
455
+ if (p !== undefined) {
456
+ parts.push(
457
+ p === true
458
+ ? "PERMISSIONS FULL"
459
+ : p === false
460
+ ? "PERMISSIONS NONE"
461
+ : `PERMISSIONS ${eventClause(p)}`,
462
+ );
463
+ }
464
+ if (fn.config.comment)
465
+ parts.push(`COMMENT ${JSON.stringify(fn.config.comment)}`);
466
+ return `${parts.join(" ")};`;
467
+ }
468
+
469
+ /** `DEFINE ACCESS <name> ON <DATABASE|NAMESPACE> TYPE <RECORD|JWT|BEARER> … [DURATION …]`. */
470
+ function emitAccess(a: AccessDef, opts?: DefineOptions): string {
471
+ const on = a.config.on === "namespace" ? "NAMESPACE" : "DATABASE";
472
+ const k = a.config.kind;
473
+ let typeClause: string;
474
+ if (k.type === "bearer") {
475
+ typeClause = `TYPE BEARER FOR ${k.subject === "user" ? "USER" : "RECORD"}`;
476
+ } else if (k.type === "jwt") {
477
+ typeClause = k.url
478
+ ? `TYPE JWT URL ${JSON.stringify(k.url)}`
479
+ : `TYPE JWT ALGORITHM ${k.alg ?? "HS512"} KEY ${JSON.stringify(k.key ?? "")}`;
480
+ } else {
481
+ typeClause = "TYPE RECORD";
482
+ }
483
+ const parts = [
484
+ `DEFINE ACCESS ${existsPrefix(opts)}${escapeIdent(a.name)} ON ${on} ${typeClause}`,
485
+ ];
486
+ // SIGNUP/SIGNIN/AUTHENTICATE only apply to RECORD access.
487
+ if (k.type === "record") {
488
+ if (a.config.signup) parts.push(`SIGNUP ${braceBody(a.config.signup)}`);
489
+ if (a.config.signin) parts.push(`SIGNIN ${braceBody(a.config.signin)}`);
490
+ if (a.config.authenticate)
491
+ parts.push(`AUTHENTICATE ${braceBody(a.config.authenticate)}`);
492
+ }
493
+ const d = a.config.duration;
494
+ if (d?.grant || d?.token || d?.session) {
495
+ const fors: string[] = [];
496
+ if (d.grant) fors.push(`FOR GRANT ${d.grant}`);
497
+ if (d.token) fors.push(`FOR TOKEN ${d.token}`);
498
+ if (d.session) fors.push(`FOR SESSION ${d.session}`);
499
+ parts.push(`DURATION ${fors.join(", ")}`);
500
+ }
501
+ return `${parts.join(" ")};`;
502
+ }
503
+
504
+ /** `DEFINE ANALYZER <name> TOKENIZERS … [FILTERS …]`. Tokenizers/filters are uppercased to match
505
+ * `INFO … STRUCTURE`, so an authored analyzer compares equal to the introspected one. */
506
+ function emitAnalyzer(a: AnalyzerDef, opts?: DefineOptions): string {
507
+ const toks = a.config.tokenizers.map((t) => t.toUpperCase()).join(", ");
508
+ let s = `DEFINE ANALYZER ${existsPrefix(opts)}${escapeIdent(a.name)} TOKENIZERS ${toks}`;
509
+ if (a.config.filters?.length)
510
+ s += ` FILTERS ${a.config.filters.map((f) => f.toUpperCase()).join(", ")}`;
511
+ return `${s};`;
512
+ }
513
+
514
+ /** The `DefineStatement` for a standalone def — `defineEvent`/`defineFunction`/`defineAccess`/`defineAnalyzer`. */
515
+ export function emitDefStatement(
516
+ def: StandaloneDef,
517
+ opts?: DefineOptions,
518
+ ): DefineStatement {
519
+ if (def.kind === "event") {
520
+ return {
521
+ kind: "event",
522
+ name: def.name,
523
+ table: def.table,
524
+ ddl: emitEvent(def.table, def, opts),
525
+ };
526
+ }
527
+ if (def.kind === "access") {
528
+ return { kind: "access", name: def.name, ddl: emitAccess(def, opts) };
529
+ }
530
+ if (def.kind === "analyzer") {
531
+ return { kind: "analyzer", name: def.name, ddl: emitAnalyzer(def, opts) };
532
+ }
533
+ return { kind: "function", name: def.name, ddl: emitFunction(def, opts) };
534
+ }
535
+
536
+ /** Emit `DEFINE FIELD path ...` for a node, then recurse into its children. */
537
+ /** A trivial array element is the plain auto-created form — no FLEXIBLE and no `$`-clauses. */
538
+ function isTrivialElement(info: FieldInfo, surreal?: SurrealMeta): boolean {
539
+ if (info.flexible) return false;
540
+ if (!surreal) return true;
541
+ return (
542
+ !surreal.permissions &&
543
+ !surreal.readonly &&
544
+ !surreal.default &&
545
+ !surreal.value &&
546
+ !surreal.asserts?.length &&
547
+ !surreal.comment &&
548
+ !surreal.internal &&
549
+ !surreal.index
550
+ );
551
+ }
552
+
553
+ function emit(
554
+ path: string,
555
+ table: string,
556
+ info: FieldInfo,
557
+ surreal: SurrealMeta | undefined,
558
+ opts: DefineOptions | undefined,
559
+ out: DefineStatement[],
560
+ forceOverwrite = false,
561
+ ): void {
562
+ let type = info.type;
563
+ // A DB-side DEFAULT/VALUE/COMPUTED means the column is always populated -> drop a leading option<>.
564
+ if (
565
+ (surreal?.default || surreal?.value || surreal?.computed) &&
566
+ type.startsWith("option<")
567
+ ) {
568
+ type = type.slice("option<".length, -1);
569
+ }
570
+ // An array element is auto-created by SurrealDB, so a (kept) element DEFINE must OVERWRITE it.
571
+ const prefix = forceOverwrite ? "OVERWRITE " : existsPrefix(opts);
572
+ // Clause fragments keyed by clause name (insertion order == DDL order). Each fragment is also
573
+ // the `ALTER FIELD … <set>` form, so the migration engine diffs clauses without parsing.
574
+ const clauses: Record<string, string> = { TYPE: `TYPE ${type}` };
575
+ if (info.flexible) clauses.FLEXIBLE = "FLEXIBLE";
576
+ if (surreal?.reference) {
577
+ let ref = "REFERENCE";
578
+ const onDelete =
579
+ surreal.reference === true ? undefined : surreal.reference.onDelete;
580
+ if (onDelete !== undefined) {
581
+ ref +=
582
+ onDelete instanceof BoundQuery
583
+ ? ` ON DELETE THEN ${inline(onDelete)}`
584
+ : ` ON DELETE ${onDelete.toUpperCase()}`;
585
+ }
586
+ clauses.REFERENCE = ref;
587
+ }
588
+ if (surreal?.default) {
589
+ clauses.DEFAULT = `DEFAULT ${surreal.defaultAlways ? "ALWAYS " : ""}${inline(surreal.default)}`;
590
+ }
591
+ if (surreal?.value) clauses.VALUE = `VALUE ${inline(surreal.value)}`;
592
+ if (surreal?.computed)
593
+ clauses.COMPUTED = `COMPUTED ${inline(surreal.computed)}`;
594
+ const assertClause = renderAsserts(surreal?.asserts);
595
+ if (assertClause) clauses.ASSERT = assertClause;
596
+ if (surreal?.readonly) clauses.READONLY = "READONLY";
597
+ if (surreal?.comment)
598
+ clauses.COMMENT = `COMMENT ${JSON.stringify(surreal.comment)}`;
599
+ // Internal fields still exist on the table (so SCHEMAFULL writes succeed) but grant
600
+ // no record-user access — internal wins over any `$permissions` on the same field.
601
+ if (surreal?.internal) {
602
+ clauses.PERMISSIONS = "PERMISSIONS NONE";
603
+ } else if (surreal?.permissions !== undefined) {
604
+ const clause = renderPermissions(surreal.permissions, [
605
+ "select",
606
+ "create",
607
+ "update",
608
+ ]);
609
+ if (clause) clauses.PERMISSIONS = clause;
610
+ }
611
+ const ddl = `DEFINE FIELD ${prefix}${path} ON TABLE ${escapeIdent(table)} ${Object.values(clauses).join(" ")};`;
612
+ out.push({ kind: "field", name: path, table, ddl, clauses });
613
+
614
+ // A single-field index defined via `.index()` / `.unique()`.
615
+ if (surreal?.index) {
616
+ const idxName = `${table}_${path.replace(/[`]/g, "").replace(/[^a-zA-Z0-9]+/g, "_")}_idx`;
617
+ const unique = surreal.index.unique ? " UNIQUE" : "";
618
+ out.push({
619
+ kind: "index",
620
+ name: idxName,
621
+ table,
622
+ ddl: `DEFINE INDEX ${existsPrefix(opts)}${escapeIdent(idxName)} ON TABLE ${escapeIdent(table)} FIELDS ${path}${unique};`,
623
+ });
624
+ }
625
+
626
+ // SurrealDB auto-creates an array's `.*` element from the `array<…>` type. A TRIVIAL element is
627
+ // that exact form, so we skip its DEFINE (a plain one errors "already exists") and emit only its
628
+ // sub-fields. A CUSTOMIZED element (FLEXIBLE / permissions / …) is emitted with OVERWRITE. Other
629
+ // parents (an `object` map's `.*` value) are emitted normally.
630
+ const isArray = /^(?:array|set)\b/.test(info.type);
631
+ for (const child of info.children) {
632
+ const childPath = `${path}${child.suffix}`;
633
+ if (isArray && child.suffix === ".*") {
634
+ if (isTrivialElement(child.info, child.surreal)) {
635
+ for (const sub of child.info.children) {
636
+ emit(
637
+ `${childPath}${sub.suffix}`,
638
+ table,
639
+ sub.info,
640
+ sub.surreal,
641
+ opts,
642
+ out,
643
+ );
644
+ }
645
+ } else {
646
+ emit(childPath, table, child.info, child.surreal, opts, out, true);
647
+ }
648
+ } else {
649
+ emit(childPath, table, child.info, child.surreal, opts, out);
650
+ }
651
+ }
652
+ }
653
+
654
+ /** Structured `DEFINE FIELD` statements for a field (and its nested subfields). */
655
+ export function emitFieldStatements(
656
+ name: string,
657
+ table: string,
658
+ field: SField,
659
+ opts?: DefineOptions,
660
+ ): DefineStatement[] {
661
+ const out: DefineStatement[] = [];
662
+ let info: FieldInfo;
663
+ try {
664
+ info = inferField(field.schema);
665
+ } catch (e) {
666
+ // inferField only sees the schema; pin the failure to the field + table for the user.
667
+ const msg = e instanceof Error ? e.message : String(e);
668
+ throw new Error(`${msg} (field "${name}" on table "${table}")`);
669
+ }
670
+ emit(escapeIdent(name), table, info, field.surreal, opts, out);
671
+ return out;
672
+ }
673
+
674
+ /** `DEFINE FIELD ...` for a field (and any nested object/array/record subfields). */
675
+ export function emitField(
676
+ name: string,
677
+ table: string,
678
+ field: SField,
679
+ opts?: DefineOptions,
680
+ ): string {
681
+ return emitFieldStatements(name, table, field, opts)
682
+ .map((s) => s.ddl)
683
+ .join("\n");
684
+ }
685
+
686
+ /** Structured statements for a table: its `DEFINE TABLE` head, then one per (nested) field. */
687
+ export function emitStatements(
688
+ t: TableDef<string, Shape>,
689
+ opts?: DefineOptions,
690
+ ): DefineStatement[] {
691
+ const rel = t.config.relation;
692
+ // Surreal manages id (and in/out for relations) implicitly.
693
+ const implicit = rel ? new Set(["id", "in", "out"]) : new Set(["id"]);
694
+ let type: string;
695
+ if (rel) {
696
+ // Endpoints are optional: omit FROM/TO when unrestricted (`TYPE RELATION`).
697
+ type = "RELATION";
698
+ if (rel.from.length)
699
+ type += ` FROM ${rel.from.map(escapeIdent).join(" | ")}`;
700
+ if (rel.to.length) type += ` TO ${rel.to.map(escapeIdent).join(" | ")}`;
701
+ if (rel.enforced) type += " ENFORCED";
702
+ } else {
703
+ type = t.config.type === "any" ? "ANY" : "NORMAL";
704
+ }
705
+
706
+ // Build clauses keyed by name (insertion order == DDL order) so the migration engine can diff
707
+ // table-level changes into the `ALTER TABLE … <set>` form without parsing SurrealQL — the
708
+ // stored fragment IS the ALTER set form, exactly as for fields.
709
+ const clauses: Record<string, string> = { TYPE: `TYPE ${type}` };
710
+ if (t.config.drop) clauses.DROP = "DROP";
711
+ clauses.SCHEMA = t.config.schemafull ? "SCHEMAFULL" : "SCHEMALESS";
712
+ // A pre-computed VIEW: `AS <SELECT …>` (the query is inlined like any other expression).
713
+ if (t.config.view !== undefined)
714
+ clauses.AS = `AS ${eventClause(t.config.view)}`;
715
+ if (t.config.changefeed) {
716
+ clauses.CHANGEFEED = `CHANGEFEED ${t.config.changefeed.expiry}${
717
+ t.config.changefeed.includeOriginal ? " INCLUDE ORIGINAL" : ""
718
+ }`;
719
+ }
720
+ if (t.config.comment)
721
+ clauses.COMMENT = `COMMENT ${JSON.stringify(t.config.comment)}`;
722
+ // Fold permissions into the single DEFINE TABLE head (no separate OVERWRITE … PERMISSIONS).
723
+ if (t.config.permissions !== undefined) {
724
+ const clause = renderPermissions(t.config.permissions, [
725
+ "select",
726
+ "create",
727
+ "update",
728
+ "delete",
729
+ ]);
730
+ if (clause) clauses.PERMISSIONS = clause;
731
+ }
732
+
733
+ const out: DefineStatement[] = [
734
+ {
735
+ kind: "table",
736
+ name: t.name,
737
+ ddl: `DEFINE TABLE ${existsPrefix(opts)}${escapeIdent(t.name)} ${Object.values(clauses).join(" ")};`,
738
+ clauses,
739
+ },
740
+ ];
741
+ // A view's rows are computed from its query — it has no DEFINE FIELD statements.
742
+ if (!t.config.view)
743
+ for (const [name, field] of Object.entries(t.fields)) {
744
+ if (implicit.has(name)) continue;
745
+ out.push(...emitFieldStatements(name, t.name, field as SField, opts));
746
+ }
747
+ // Composite indexes declared via `.index(name, fields, …)`. A `count` index has no FIELDS.
748
+ for (const idx of t.config.indexes ?? []) {
749
+ let spec: string;
750
+ if (idx.count) {
751
+ spec = "COUNT";
752
+ } else {
753
+ spec = `FIELDS ${idx.fields.map(escapeIdent).join(", ")}`;
754
+ if (idx.unique) spec += " UNIQUE";
755
+ if (idx.spec) spec += ` ${idx.spec}`; // HNSW/DISKANN/FULLTEXT
756
+ }
757
+ const comment = idx.comment
758
+ ? ` COMMENT ${JSON.stringify(idx.comment)}`
759
+ : "";
760
+ out.push({
761
+ kind: "index",
762
+ name: idx.name,
763
+ table: t.name,
764
+ ddl: `DEFINE INDEX ${existsPrefix(opts)}${escapeIdent(idx.name)} ON TABLE ${escapeIdent(t.name)} ${spec}${comment};`,
765
+ });
766
+ }
767
+ // Row-change events declared via `.event(name, { when?, then })`.
768
+ for (const ev of t.config.events ?? []) {
769
+ out.push({
770
+ kind: "event",
771
+ name: ev.name,
772
+ table: t.name,
773
+ ddl: emitEvent(t.name, ev, opts),
774
+ });
775
+ }
776
+ return out;
777
+ }
778
+
779
+ /** `DEFINE TABLE ...` plus a `DEFINE FIELD` per field. */
780
+ export function emitTable(
781
+ t: TableDef<string, Shape>,
782
+ opts?: DefineOptions,
783
+ ): string {
784
+ return emitStatements(t, opts)
785
+ .map((s) => s.ddl)
786
+ .join("\n");
787
+ }
788
+
789
+ /** The `REMOVE ...` statement that drops the object a `DefineStatement` defines. */
790
+ export function removeStatement(
791
+ s: Pick<DefineStatement, "kind" | "name" | "table">,
792
+ ): string {
793
+ if (s.kind === "table")
794
+ return `REMOVE TABLE IF EXISTS ${escapeIdent(s.name)};`;
795
+ if (s.kind === "index") {
796
+ return `REMOVE INDEX IF EXISTS ${escapeIdent(s.name)} ON TABLE ${escapeIdent(s.table ?? "")};`;
797
+ }
798
+ if (s.kind === "event") {
799
+ return `REMOVE EVENT IF EXISTS ${escapeIdent(s.name)} ON TABLE ${escapeIdent(s.table ?? "")};`;
800
+ }
801
+ if (s.kind === "function") {
802
+ return `REMOVE FUNCTION IF EXISTS fn::${escapeIdent(s.name)};`;
803
+ }
804
+ if (s.kind === "access") {
805
+ return `REMOVE ACCESS IF EXISTS ${escapeIdent(s.name)} ON DATABASE;`;
806
+ }
807
+ if (s.kind === "analyzer") {
808
+ return `REMOVE ANALYZER IF EXISTS ${escapeIdent(s.name)};`;
809
+ }
810
+ return `REMOVE FIELD IF EXISTS ${s.name} ON TABLE ${escapeIdent(s.table ?? "")};`;
811
+ }
812
+
813
+ /** Inject `OVERWRITE` into a plain `DEFINE <kind> …` statement (idempotent re-definition). */
814
+ export function overwriteStatement(ddl: string): string {
815
+ return ddl.replace(
816
+ /^DEFINE (TABLE|FIELD|INDEX|EVENT|ANALYZER|ACCESS|PARAM|FUNCTION) (?!OVERWRITE\b)/,
817
+ "DEFINE $1 OVERWRITE ",
818
+ );
819
+ }
820
+
821
+ /** Canonical clause order for a deterministic `ALTER FIELD` body (matches the DDL order). */
822
+ const FIELD_CLAUSE_ORDER = [
823
+ "TYPE",
824
+ "FLEXIBLE",
825
+ "REFERENCE",
826
+ "DEFAULT",
827
+ "VALUE",
828
+ "COMPUTED",
829
+ "ASSERT",
830
+ "READONLY",
831
+ "COMMENT",
832
+ "PERMISSIONS",
833
+ ] as const;
834
+ /** Clauses `ALTER FIELD` can `DROP` (remove). `PERMISSIONS` has no DROP (reset to FULL);
835
+ * `COMPUTED` has no ALTER form at all (forces an OVERWRITE fallback). */
836
+ const FIELD_DROPPABLE = new Set([
837
+ "FLEXIBLE",
838
+ "READONLY",
839
+ "VALUE",
840
+ "ASSERT",
841
+ "DEFAULT",
842
+ "COMMENT",
843
+ "REFERENCE",
844
+ ]);
845
+
846
+ /**
847
+ * Emit an `ALTER FIELD` that turns the `prev` clause set into `next` — re-set changed/added
848
+ * clauses, `DROP` removed ones (a true delta). Returns `null` (the caller should fall back to
849
+ * `DEFINE … OVERWRITE`) when the delta touches a clause `ALTER FIELD` can't express (`COMPUTED`),
850
+ * or when clause data is unavailable (e.g. an older snapshot without `clauses`).
851
+ */
852
+ export function alterField(
853
+ table: string,
854
+ path: string,
855
+ prev: Record<string, string> | undefined,
856
+ next: Record<string, string> | undefined,
857
+ ): string | null {
858
+ if (!prev || !next) return null;
859
+ const sets: string[] = [];
860
+ for (const k of FIELD_CLAUSE_ORDER) {
861
+ const before = prev[k];
862
+ const after = next[k];
863
+ if (before === after) continue;
864
+ if (k === "COMPUTED") return null; // no `ALTER FIELD … COMPUTED` form
865
+ if (after !== undefined) {
866
+ sets.push(after); // added or changed -> re-set (the fragment IS the ALTER set form)
867
+ } else if (k === "PERMISSIONS") {
868
+ sets.push("PERMISSIONS FULL"); // no `DROP PERMISSIONS`; reset to the field default
869
+ } else if (k === "TYPE") {
870
+ return null; // a field always has a TYPE; "removing" it is meaningless -> OVERWRITE
871
+ } else if (FIELD_DROPPABLE.has(k)) {
872
+ sets.push(`DROP ${k}`);
873
+ } else {
874
+ return null;
875
+ }
876
+ }
877
+ if (!sets.length) return null;
878
+ return `ALTER FIELD ${path} ON TABLE ${escapeIdent(table)} ${sets.join(" ")};`;
879
+ }
880
+
881
+ /** Canonical clause order for a deterministic `ALTER TABLE` body (matches the DDL order). */
882
+ const TABLE_CLAUSE_ORDER = [
883
+ "TYPE",
884
+ "DROP",
885
+ "SCHEMA",
886
+ "AS",
887
+ "CHANGEFEED",
888
+ "COMMENT",
889
+ "PERMISSIONS",
890
+ ] as const;
891
+ /** Clauses `ALTER TABLE` can express. `TYPE`/`DROP` have no ALTER form (force an OVERWRITE
892
+ * fallback); `SCHEMA` is always SCHEMAFULL|SCHEMALESS (re-set, never dropped); `CHANGEFEED`
893
+ * and `COMMENT` have `DROP` forms; `PERMISSIONS` resets to the table default (NONE). */
894
+ const TABLE_ALTERABLE = new Set([
895
+ "SCHEMA",
896
+ "CHANGEFEED",
897
+ "COMMENT",
898
+ "PERMISSIONS",
899
+ ]);
900
+ const TABLE_DROPPABLE = new Set(["CHANGEFEED", "COMMENT"]);
901
+
902
+ /**
903
+ * Emit an `ALTER TABLE` that turns the `prev` clause set into `next`. Returns `null` (caller falls
904
+ * back to `DEFINE … OVERWRITE`) when the delta touches a clause `ALTER TABLE` can't express
905
+ * (`TYPE` NORMAL/RELATION/ANY, or the `DROP` flag), or when clause data is unavailable.
906
+ */
907
+ export function alterTable(
908
+ name: string,
909
+ prev: Record<string, string> | undefined,
910
+ next: Record<string, string> | undefined,
911
+ ): string | null {
912
+ if (!prev || !next) return null;
913
+ const sets: string[] = [];
914
+ for (const k of TABLE_CLAUSE_ORDER) {
915
+ const before = prev[k];
916
+ const after = next[k];
917
+ if (before === after) continue;
918
+ if (!TABLE_ALTERABLE.has(k)) return null; // TYPE / DROP changed -> OVERWRITE
919
+ if (after !== undefined) {
920
+ sets.push(after); // added or changed -> re-set
921
+ } else if (k === "PERMISSIONS") {
922
+ sets.push("PERMISSIONS NONE"); // no `DROP PERMISSIONS`; reset to the table default
923
+ } else if (TABLE_DROPPABLE.has(k)) {
924
+ sets.push(`DROP ${k}`);
925
+ } else {
926
+ return null; // SCHEMA is never absent; anything else unexpected -> OVERWRITE
927
+ }
928
+ }
929
+ if (!sets.length) return null;
930
+ return `ALTER TABLE ${escapeIdent(name)} ${sets.join(" ")};`;
931
+ }