@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,1049 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import type { ResolvedConfig } from "@schemic/core";
4
+ import {
5
+ existingTables,
6
+ type Filter,
7
+ type LocalOnly,
8
+ mergeUnits,
9
+ type PullFilePlan,
10
+ type PullPlan,
11
+ parseFilter,
12
+ type RenderedUnit,
13
+ scanLocalEntities,
14
+ } from "@schemic/core";
15
+ import type { Surreal } from "surrealdb";
16
+ import { formatForAssert } from "../pure";
17
+ import {
18
+ type DbStructured,
19
+ introspectStructured,
20
+ type StructAccess,
21
+ type StructAnalyzer,
22
+ type StructField,
23
+ type StructFunction,
24
+ type StructPerm,
25
+ type StructPermissions,
26
+ type StructTable,
27
+ } from "./structure";
28
+ import { filterStructured } from "./surreal-filter";
29
+
30
+ /** The field clauses pull reverses into `s.*` chains (sourced from `INFO … STRUCTURE`). */
31
+ interface ParsedField {
32
+ type: string;
33
+ default?: string;
34
+ defaultAlways?: boolean;
35
+ value?: string;
36
+ computed?: string;
37
+ assert?: string;
38
+ readonly?: boolean;
39
+ comment?: string;
40
+ flexible?: boolean;
41
+ permissions?: StructPermissions;
42
+ /** `REFERENCE [ON DELETE …]` on a record-link field (mirrors `StructField.reference`). */
43
+ reference?: { on_delete?: string };
44
+ }
45
+
46
+ /** A single permission op as a `.permissions()` argument value (`true`/`false`/a `surql` WHERE). */
47
+ function permValue(v: StructPerm | undefined): string {
48
+ if (v === true) return "true";
49
+ if (v === false || v === undefined) return "false";
50
+ return `surql\`${v}\``;
51
+ }
52
+
53
+ /**
54
+ * The `.permissions(...)` / `.$permissions(...)` argument for a structured permission set, or null
55
+ * to omit it (when it matches the default: FULL for fields, NONE for tables). Collapses all-FULL →
56
+ * `true`, all-NONE → `false`, and one shared `WHERE` across every op → a single `surql` expr.
57
+ */
58
+ function renderPerms(
59
+ perms: StructPermissions | undefined,
60
+ ops: (keyof StructPermissions)[],
61
+ defaultFull: boolean,
62
+ ): string | null {
63
+ if (!perms) return null;
64
+ const vals = ops.map((op) => perms[op]);
65
+ const allTrue = vals.every((v) => v === true);
66
+ const allFalse = vals.every((v) => v === false || v === undefined);
67
+ if (defaultFull ? allTrue : allFalse) return null; // matches the default
68
+ if (allTrue) return "true";
69
+ if (allFalse) return "false";
70
+ if (vals.every((v) => typeof v === "string") && new Set(vals).size === 1) {
71
+ return `surql\`${vals[0]}\``; // one WHERE shared by every op
72
+ }
73
+ return `{ ${ops.map((op) => `${op}: ${permValue(perms[op])}`).join(", ")} }`;
74
+ }
75
+
76
+ /**
77
+ * The bare field path — STRUCTURE backtick-escapes reserved-word segments (`` `value` ``), so we
78
+ * strip them; `ident()` re-quotes only what TS needs, and emit re-escapes for SurrealQL (avoids
79
+ * double-escaping the name).
80
+ */
81
+ function unescapeName(name: string): string {
82
+ return name
83
+ .split(".")
84
+ .map((seg) => seg.replace(/^`([\s\S]*)`$/, "$1"))
85
+ .join(".");
86
+ }
87
+
88
+ /** Map a structured field (from STRUCTURE) to the clause bag `renderField` consumes. */
89
+ function toParsed(f: StructField): ParsedField {
90
+ return {
91
+ type: f.kind,
92
+ default: f.default,
93
+ defaultAlways: f.default_always,
94
+ value: f.value,
95
+ computed: f.computed,
96
+ assert: f.assert,
97
+ readonly: f.readonly,
98
+ comment: f.comment,
99
+ flexible: f.flexible,
100
+ permissions: f.permissions,
101
+ reference: f.reference,
102
+ };
103
+ }
104
+
105
+ // --- Cross-table reference resolution (imports / self-refs / relation endpoints) -------------
106
+
107
+ const pascal = (name: string) =>
108
+ name
109
+ .replace(/(^|[_-])([a-z])/g, (_, __, c) => c.toUpperCase())
110
+ .replace(/[^A-Za-z0-9]/g, "");
111
+
112
+ /** All `record<…>` target table names in a type expression (handles option/array/union nesting). */
113
+ function recordTargets(kind: string): string[] {
114
+ const out: string[] = [];
115
+ const re = /record<([^>]+)>/g;
116
+ let m: RegExpExecArray | null = re.exec(kind);
117
+ while (m) {
118
+ for (const t of m[1].split("|")) out.push(t.trim());
119
+ m = re.exec(kind);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** The pulled tables a table points at: `record<…>` field targets + relation endpoints. */
125
+ function tableRefs(t: StructTable, pulled: Set<string>): Set<string> {
126
+ const out = new Set<string>();
127
+ const add = (n: string) => {
128
+ if (pulled.has(n)) out.add(n);
129
+ };
130
+ for (const f of t.fields) for (const tgt of recordTargets(f.kind)) add(tgt);
131
+ if (t.kind.kind === "RELATION") {
132
+ for (const n of t.kind.in ?? []) add(n);
133
+ for (const n of t.kind.out ?? []) add(n);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /** Everything reachable from `start` in the reference graph (excluding `start` itself). */
139
+ function reachable(
140
+ graph: Map<string, Set<string>>,
141
+ start: string,
142
+ ): Set<string> {
143
+ const seen = new Set<string>();
144
+ const stack = [...(graph.get(start) ?? [])];
145
+ while (stack.length) {
146
+ const n = stack.pop();
147
+ if (n === undefined || seen.has(n)) continue;
148
+ seen.add(n);
149
+ for (const m of graph.get(n) ?? []) stack.push(m);
150
+ }
151
+ return seen;
152
+ }
153
+
154
+ /** How a `record<target>` / endpoint reference should be expressed in the generated code. */
155
+ type RefKind = "self" | "direct" | "string";
156
+
157
+ /** Per-table render context: resolves references and accumulates the imports they need. */
158
+ interface RenderCtx {
159
+ table: string;
160
+ /** Imported table names (→ value imports of their `const`). */
161
+ imports: Set<string>;
162
+ /** Set when a `record<self>` field used the callback `self` parameter. */
163
+ usesSelf: boolean;
164
+ /** Whether the `self` callback is available (only `defineTable`, not `defineRelation`). */
165
+ allowSelf: boolean;
166
+ /** PascalCase const name for a table. */
167
+ constOf: (name: string) => string;
168
+ /** Resolve a reference from this table to `target`. */
169
+ resolve: (target: string) => RefKind;
170
+ }
171
+
172
+ function makeResolver(graph: Map<string, Set<string>>, pulled: Set<string>) {
173
+ const cache = new Map<string, Set<string>>();
174
+ const reach = (n: string) => {
175
+ let r = cache.get(n);
176
+ if (!r) {
177
+ r = reachable(graph, n);
178
+ cache.set(n, r);
179
+ }
180
+ return r;
181
+ };
182
+ return (from: string, target: string): RefKind => {
183
+ if (target === from) return "self";
184
+ if (!pulled.has(target)) return "string"; // not pulled — can't import it
185
+ return reach(target).has(from) ? "string" : "direct"; // cycle → string, else import
186
+ };
187
+ }
188
+
189
+ /** Split a type expression on its top-level `|` (ignoring `|` inside `<…>`). */
190
+ function splitTopUnion(expr: string): string[] {
191
+ const parts: string[] = [];
192
+ let depth = 0;
193
+ let cur = "";
194
+ for (const c of expr) {
195
+ if (c === "<") depth++;
196
+ else if (c === ">") depth--;
197
+ if (c === "|" && depth === 0) {
198
+ parts.push(cur.trim());
199
+ cur = "";
200
+ } else cur += c;
201
+ }
202
+ parts.push(cur.trim());
203
+ return parts;
204
+ }
205
+
206
+ /** Parse a SurrealQL literal token (`'a'`, `"a"`, `42`, `true`) to its JS value, else null. */
207
+ function parseLiteral(s: string): { value: string | number | boolean } | null {
208
+ const t = s.trim();
209
+ const q = /^'((?:\\.|[^'])*)'$/.exec(t) ?? /^"((?:\\.|[^"])*)"$/.exec(t);
210
+ if (q) return { value: q[1] };
211
+ if (/^-?\d+$/.test(t)) return { value: Number.parseInt(t, 10) };
212
+ if (/^-?\d+\.\d+$/.test(t)) return { value: Number.parseFloat(t) };
213
+ if (t === "true" || t === "false") return { value: t === "true" };
214
+ return null;
215
+ }
216
+
217
+ /** Render a `record<…>` reference, using imports / `self` / a string fallback per `ctx`. */
218
+ function renderRecord(targetsRaw: string, ctx?: RenderCtx): string {
219
+ const targets = targetsRaw.split("|").map((s) => s.trim());
220
+ if (ctx && targets.length === 1) {
221
+ const kind = ctx.resolve(targets[0]);
222
+ if (kind === "self" && ctx.allowSelf) {
223
+ ctx.usesSelf = true;
224
+ return "self";
225
+ }
226
+ if (kind === "direct") {
227
+ ctx.imports.add(targets[0]);
228
+ return `${ctx.constOf(targets[0])}.record()`;
229
+ }
230
+ }
231
+ const arg =
232
+ targets.length === 1
233
+ ? JSON.stringify(targets[0])
234
+ : `[${targets.map((t) => JSON.stringify(t)).join(", ")}]`;
235
+ return `s.recordId(${arg})`;
236
+ }
237
+
238
+ /** Map a SurrealQL type to an `s.*` expression (`ctx` resolves `record<…>` references). */
239
+ function szType(type: string, ctx?: RenderCtx): string {
240
+ const t = type.trim();
241
+ // option<X> and the `none | X` form the DB reports.
242
+ const opt = /^option<(.+)>$/.exec(t);
243
+ if (opt) return `${szType(opt[1], ctx)}.optional()`;
244
+ if (/(^|\|)\s*none\s*(\||$)/.test(t)) {
245
+ const inner = t.replace(/\s*\|?\s*none\s*\|?\s*/g, "").trim();
246
+ return `${szType(inner || "any", ctx)}.optional()`;
247
+ }
248
+ const nullable = /^(.+?)\s*\|\s*null$/.exec(t);
249
+ if (nullable) return `${szType(nullable[1], ctx)}.nullable()`;
250
+
251
+ const arr = /^array<(.+)>$/.exec(t);
252
+ if (arr) return `${szType(arr[1], ctx)}.array()`;
253
+ const set = /^set<(.+)>$/.exec(t);
254
+ if (set) return `s.set(${szType(set[1], ctx)})`;
255
+ const rec = /^record<(.+?)>$/.exec(t);
256
+ if (rec) return renderRecord(rec[1], ctx);
257
+
258
+ // Literal unions: `'a' | 'b'` -> s.enum (all strings) or a union of literals; lone -> s.literal.
259
+ const lits = splitTopUnion(t).map(parseLiteral);
260
+ if (lits.length && lits.every((l) => l !== null)) {
261
+ const vals = lits.map(
262
+ (l) => (l as { value: string | number | boolean }).value,
263
+ );
264
+ if (vals.length === 1) return `s.literal(${JSON.stringify(vals[0])})`;
265
+ if (vals.every((v) => typeof v === "string"))
266
+ return `s.enum([${vals.map((v) => JSON.stringify(v)).join(", ")}])`;
267
+ return `s.union([${vals.map((v) => `s.literal(${JSON.stringify(v)})`).join(", ")}])`;
268
+ }
269
+
270
+ // Native types carrying a `<kind>` parameter (e.g. `geometry<point>`).
271
+ const geo = /^geometry(?:<(\w+)>)?$/.exec(t);
272
+ if (geo)
273
+ return geo[1] ? `s.geometry(${JSON.stringify(geo[1])})` : "s.geometry()";
274
+
275
+ switch (t) {
276
+ case "string":
277
+ return "s.string()";
278
+ case "file":
279
+ return "s.file()";
280
+ case "int":
281
+ return "s.int()";
282
+ case "float":
283
+ return "s.float()";
284
+ case "number":
285
+ return "s.number()";
286
+ case "bool":
287
+ return "s.boolean()";
288
+ case "datetime":
289
+ return "s.datetime()";
290
+ case "uuid":
291
+ return "s.uuid()";
292
+ case "decimal":
293
+ return "s.decimal()";
294
+ case "duration":
295
+ return "s.duration()";
296
+ case "bytes":
297
+ return "s.bytes()";
298
+ case "object":
299
+ return "s.object({})";
300
+ case "any":
301
+ return "s.any()";
302
+ default:
303
+ return `s.any() /* ${t} */`;
304
+ }
305
+ }
306
+
307
+ interface FieldNode {
308
+ parsed?: ParsedField;
309
+ children: Map<string, FieldNode>;
310
+ }
311
+
312
+ /** Build a nested tree from dotted field paths (`settings.theme`, `tags.*`). */
313
+ function fieldTree(fields: { name: string; parsed: ParsedField }[]): FieldNode {
314
+ const root: FieldNode = { children: new Map() };
315
+ for (const f of fields) {
316
+ let node = root;
317
+ for (const seg of f.name.split(".")) {
318
+ let child = node.children.get(seg);
319
+ if (!child) {
320
+ child = { children: new Map() };
321
+ node.children.set(seg, child);
322
+ }
323
+ node = child;
324
+ }
325
+ node.parsed = f.parsed;
326
+ }
327
+ return root;
328
+ }
329
+
330
+ /**
331
+ * Strip optionality/nullability wrappers, reporting which were present. Handles both
332
+ * `option<X>` and the `X | none` form SurrealDB's `INFO` reports, plus `X | null`.
333
+ */
334
+ function unwrapType(type: string): {
335
+ base: string;
336
+ optional: boolean;
337
+ nullable: boolean;
338
+ } {
339
+ let t = type.trim();
340
+ let optional = false;
341
+ let nullable = false;
342
+ const opt = /^option<(.+)>$/.exec(t);
343
+ if (opt) {
344
+ optional = true;
345
+ t = opt[1].trim();
346
+ }
347
+ // `X | none` / `none | X` — SurrealDB's normalized form of `option<X>`.
348
+ if (/(^|\|)\s*none\s*(\||$)/.test(t)) {
349
+ optional = true;
350
+ t = t.replace(/\s*\|?\s*none\s*\|?\s*/g, "").trim();
351
+ }
352
+ const nul = /^(.+?)\s*\|\s*null$/.exec(t);
353
+ if (nul) {
354
+ nullable = true;
355
+ t = nul[1].trim();
356
+ }
357
+ return { base: t, optional, nullable };
358
+ }
359
+
360
+ /** Render an `s.*` expression for a field node, recursing into nested objects/array elements. */
361
+ function renderField(node: FieldNode, indent: string, ctx?: RenderCtx): string {
362
+ const p = node.parsed;
363
+ const objChildren = [...node.children].filter(([k]) => k !== "*");
364
+ const star = node.children.get("*");
365
+ const wrap = p ? unwrapType(p.type) : null;
366
+ // A `string` field whose ASSERT is exactly a baked `string::is_<fmt>($value)` round-trips back to
367
+ // the format builder (`s.email()`, …) — the assert is the only signal, and it's dropped below
368
+ // since the builder re-bakes it. Combined/extra asserts don't match, so they stay `string` + assert.
369
+ const fmt = p?.assert !== undefined ? formatForAssert(p.assert) : undefined;
370
+ let expr: string;
371
+ if (p && wrap?.base === "object") {
372
+ // Rebuild s.object from dotted children (empty if none) — even when wrapped in
373
+ // option<…>/| null, so optional/nullable/flexible nested objects keep their shape.
374
+ const inner = objChildren.length
375
+ ? `{\n${objChildren
376
+ .map(
377
+ ([k, c]) =>
378
+ `${indent} ${ident(k)}: ${renderField(c, `${indent} `, ctx)},`,
379
+ )
380
+ .join("\n")}\n${indent}}`
381
+ : "{}";
382
+ expr = `s.object(${inner})`;
383
+ if (p.flexible) expr += ".loose()"; // FLEXIBLE — accepts arbitrary keys
384
+ if (wrap.nullable) expr += ".nullable()";
385
+ if (wrap.optional) expr += ".optional()";
386
+ } else if (p && star && /^(array|set)\b/.test(wrap?.base ?? "")) {
387
+ // Any array/set: the element's full structure (incl. nested sub-fields) lives in the `*`
388
+ // child — fold it into `<elem>.array()` / `s.set(<elem>)`. This beats parsing the element
389
+ // type from the parent kind, which would lose the element's sub-fields.
390
+ const elem = renderField(star, indent, ctx);
391
+ expr = /^set\b/.test(wrap?.base ?? "")
392
+ ? `s.set(${elem})`
393
+ : `${elem}.array()`;
394
+ if (wrap?.nullable) expr += ".nullable()";
395
+ if (wrap?.optional) expr += ".optional()";
396
+ } else if (p && wrap?.base === "string" && fmt) {
397
+ expr = `s.${fmt}()`;
398
+ if (wrap.nullable) expr += ".nullable()";
399
+ if (wrap.optional) expr += ".optional()";
400
+ } else if (!p) {
401
+ expr = "s.any()";
402
+ } else {
403
+ expr = szType(p.type, ctx);
404
+ }
405
+
406
+ if (p) {
407
+ // `REFERENCE [ON DELETE …]` on a record-link field. A bare reference (or the materialized default
408
+ // `IGNORE`) round-trips as `.reference()`; an action keyword as `{ onDelete: '<kw>' }`; anything
409
+ // else (a `surql` expression) as `{ onDelete: surql\`…\` }`. Mirrors `canonicalField` in structure.ts.
410
+ if (p.reference !== undefined) {
411
+ const od = p.reference.on_delete;
412
+ if (!od || od.toUpperCase() === "IGNORE") {
413
+ expr += ".reference()";
414
+ } else if (/^(REJECT|CASCADE|UNSET)$/i.test(od)) {
415
+ expr += `.reference({ onDelete: ${JSON.stringify(od.toLowerCase())} })`;
416
+ } else {
417
+ expr += `.reference({ onDelete: surql\`${od}\` })`;
418
+ }
419
+ }
420
+ if (p.default !== undefined) {
421
+ // A bare literal (false/42/"x") round-trips as a plain JS value the `s` API accepts directly;
422
+ // only non-literal expressions (time::now(), …) need the `surql` tag. Wrapping literals in
423
+ // `surql` would churn hand-authored `.$default(false)` into `.$default(surql\`false\`)`.
424
+ const method = p.defaultAlways ? "$defaultAlways" : "$default";
425
+ const lit = parseLiteral(p.default);
426
+ expr += `.${method}(${lit ? JSON.stringify(lit.value) : `surql\`${p.default}\``})`;
427
+ }
428
+ if (p.value !== undefined) expr += `.$value(surql\`${p.value}\`)`;
429
+ if (p.computed !== undefined) expr += `.$computed(surql\`${p.computed}\`)`;
430
+ // The format builder re-bakes its `string::is_<fmt>` assert, so drop it when we reversed one.
431
+ if (p.assert !== undefined && !fmt)
432
+ expr += `.$assert(surql\`${p.assert}\`)`;
433
+ if (p.readonly) expr += ".$readonly()";
434
+ if (p.comment) expr += `.$comment(${JSON.stringify(p.comment)})`;
435
+ const perm = renderPerms(
436
+ p.permissions,
437
+ ["select", "create", "update"],
438
+ true,
439
+ );
440
+ if (perm) expr += `.$permissions(${perm})`;
441
+ }
442
+ return expr;
443
+ }
444
+
445
+ /** A safe object-key: a bare identifier, or a quoted string. */
446
+ function ident(key: string): string {
447
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
448
+ }
449
+
450
+ /** Resolve a relation endpoint set to `.from`/`.to` args (imported consts) — or null if any can't be. */
451
+ function wireEndpoints(names: string[], ctx: RenderCtx): string | null {
452
+ if (!names.length) return null;
453
+ const resolved = names.map((n) => {
454
+ const kind = ctx.resolve(n);
455
+ if (kind === "direct") {
456
+ ctx.imports.add(n);
457
+ return ctx.constOf(n);
458
+ }
459
+ if (kind === "self") return ctx.constOf(ctx.table);
460
+ return null; // not pulled / cyclic → can't pass a string to .from/.to
461
+ });
462
+ if (resolved.some((r) => r === null)) return null;
463
+ return resolved.length === 1
464
+ ? (resolved[0] as string)
465
+ : `[${resolved.join(", ")}]`;
466
+ }
467
+
468
+ /**
469
+ * A vector/full-text index spec (canonical/minimal form) -> the `.index()` opts fragment, e.g.
470
+ * `HNSW DIMENSION 4 DIST COSINE` -> `hnsw: { dimension: 4, dist: "cosine" }`. Returns null for a
471
+ * plain/UNIQUE/COUNT spec (those are handled separately).
472
+ */
473
+ function indexSpecOpts(spec: string): string | null {
474
+ if (spec.startsWith("HNSW ") || spec.startsWith("DISKANN ")) {
475
+ const algo = spec.startsWith("HNSW") ? "hnsw" : "diskann";
476
+ const toks = spec.split(/\s+/).slice(1);
477
+ const kv: Record<string, string> = {};
478
+ for (let i = 0; i < toks.length; i += 2) kv[toks[i]] = toks[i + 1] ?? "";
479
+ const f: string[] = [`dimension: ${kv.DIMENSION}`];
480
+ if (kv.DIST) f.push(`dist: ${JSON.stringify(kv.DIST.toLowerCase())}`);
481
+ if (kv.TYPE) f.push(`type: ${JSON.stringify(kv.TYPE.toLowerCase())}`);
482
+ if (kv.EFC) f.push(`efc: ${kv.EFC}`);
483
+ if (kv.M) f.push(`m: ${kv.M}`);
484
+ if (kv.DEGREE) f.push(`degree: ${kv.DEGREE}`);
485
+ if (kv.L_BUILD) f.push(`l_build: ${kv.L_BUILD}`);
486
+ if (kv.ALPHA) f.push(`alpha: ${kv.ALPHA}`);
487
+ return `${algo}: { ${f.join(", ")} }`;
488
+ }
489
+ const ft = /^FULLTEXT ANALYZER (\S+)(.*)$/.exec(spec);
490
+ if (ft) {
491
+ const f: string[] = [`analyzer: ${JSON.stringify(ft[1])}`];
492
+ const bm = /BM25\(([^)]*)\)/.exec(ft[2]);
493
+ if (bm) {
494
+ const [k, b] = bm[1].split(",").map((x) => x.trim());
495
+ f.push(`bm25: [${k}, ${b}]`);
496
+ }
497
+ if (/\bHIGHLIGHTS\b/.test(ft[2])) f.push("highlights: true");
498
+ return `fulltext: { ${f.join(", ")} }`;
499
+ }
500
+ return null;
501
+ }
502
+
503
+ /** Render just the `export const … = define…(…);` for one table (no import lines). */
504
+ function renderTableConst(
505
+ t: StructTable,
506
+ ctx: RenderCtx,
507
+ ): { code: string; factory: string } {
508
+ // A pre-computed VIEW: `defineView(name, surql\`SELECT …\`)` — no fields, then the common table
509
+ // config (comment / permissions / changefeed); TYPE ANY + SCHEMALESS are implied by defineView.
510
+ if (t.view !== undefined) {
511
+ let code = `export const ${ctx.constOf(t.name)} = defineView(${JSON.stringify(t.name)}, surql\`${t.view}\`)`;
512
+ if (t.comment) code += `\n .comment(${JSON.stringify(t.comment)})`;
513
+ const vperm = renderPerms(
514
+ t.permissions,
515
+ ["select", "create", "update", "delete"],
516
+ false,
517
+ );
518
+ if (vperm) code += `\n .permissions(${vperm})`;
519
+ if (t.changefeed) {
520
+ const incl = t.changefeed.original ? ", { includeOriginal: true }" : "";
521
+ code += `\n .changefeed(${JSON.stringify(t.changefeed.expiry)}${incl})`;
522
+ }
523
+ return { code: `${code};`, factory: "defineView" };
524
+ }
525
+
526
+ const isRelation = t.kind.kind === "RELATION";
527
+ const fields = t.fields.map((f) => ({
528
+ name: unescapeName(f.name),
529
+ parsed: toParsed(f),
530
+ }));
531
+ const tree = fieldTree(fields);
532
+ const fieldLines = [...tree.children]
533
+ .filter(([k]) => k !== "id" && k !== "in" && k !== "out")
534
+ .map(([k, node]) => ` ${ident(k)}: ${renderField(node, " ", ctx)},`)
535
+ .join("\n");
536
+
537
+ const name = ctx.constOf(t.name);
538
+ const factory = isRelation ? "defineRelation" : "defineTable";
539
+ // A `record<self>` field needs the callback shape so `self` is in scope.
540
+ const head = ctx.usesSelf
541
+ ? `export const ${name} = ${factory}(${JSON.stringify(t.name)}, (self) => ({`
542
+ : `export const ${name} = ${factory}(${JSON.stringify(t.name)}, {`;
543
+ const open = ctx.usesSelf ? "}))" : "})";
544
+
545
+ const body: string[] = [head];
546
+ if (!isRelation) body.push(` id: s.string(),`);
547
+ body.push(fieldLines);
548
+
549
+ let close = open;
550
+ if (isRelation) {
551
+ const from = wireEndpoints(t.kind.in ?? [], ctx);
552
+ const to = wireEndpoints(t.kind.out ?? [], ctx);
553
+ if (from) close += `\n .from(${from})`;
554
+ if (to) close += `\n .to(${to})`;
555
+ if (t.kind.enforced) close += `\n .enforced()`;
556
+ } else if (t.kind.kind === "ANY") {
557
+ close += `\n .typeAny()`;
558
+ }
559
+ // Common table config (applies to tables and relations alike).
560
+ if (!t.schemafull) close += `\n .schemaless()`;
561
+ if (t.drop) close += `\n .drop()`;
562
+ if (t.comment) close += `\n .comment(${JSON.stringify(t.comment)})`;
563
+ const tperm = renderPerms(
564
+ t.permissions,
565
+ ["select", "create", "update", "delete"],
566
+ false,
567
+ );
568
+ if (tperm) close += `\n .permissions(${tperm})`;
569
+ if (t.changefeed) {
570
+ const incl = t.changefeed.original ? ", { includeOriginal: true }" : "";
571
+ close += `\n .changefeed(${JSON.stringify(t.changefeed.expiry)}${incl})`;
572
+ }
573
+ for (const idx of t.indexes) {
574
+ const cols = idx.cols.map((c) => JSON.stringify(c)).join(", ");
575
+ const parts: string[] = [];
576
+ if (idx.index === "UNIQUE") parts.push("unique: true");
577
+ else if (idx.index === "COUNT") parts.push("count: true");
578
+ else {
579
+ const so = indexSpecOpts(idx.index); // HNSW/DISKANN/FULLTEXT -> hnsw/diskann/fulltext opts
580
+ if (so) parts.push(so);
581
+ }
582
+ if (idx.comment !== undefined)
583
+ parts.push(`comment: ${JSON.stringify(idx.comment)}`);
584
+ const opts = parts.length ? `, { ${parts.join(", ")} }` : "";
585
+ close += `\n .index(${JSON.stringify(idx.name)}, [${cols}]${opts})`;
586
+ }
587
+ for (const ev of t.events) {
588
+ // Drop a `WHEN true` (SurrealDB's stored form of an omitted WHEN). Author bodies as `surql\`…\``.
589
+ const when =
590
+ ev.when !== undefined && ev.when !== "true"
591
+ ? `when: surql\`${ev.when}\`, `
592
+ : "";
593
+ const then =
594
+ ev.then.length === 1
595
+ ? `surql\`${ev.then[0]}\``
596
+ : `[${ev.then.map((e) => `surql\`${e}\``).join(", ")}]`;
597
+ close += `\n .event(${JSON.stringify(ev.name)}, { ${when}then: ${then} })`;
598
+ }
599
+ body.push(`${close};`);
600
+
601
+ return { code: body.join("\n"), factory };
602
+ }
603
+
604
+ /** Assemble a single-object module (imports + the const) for the directory layout. */
605
+ function unitModule(u: RenderedUnit): string {
606
+ return `${u.imports.join("\n")}\n\n${u.code}\n`;
607
+ }
608
+
609
+ /** The rendered unit (const statement + the imports it needs) for one table/relation. */
610
+ function tableUnit(t: StructTable, ctx: RenderCtx): RenderedUnit {
611
+ const { code, factory } = renderTableConst(t, ctx);
612
+ // A view's code uses no `s.*` builder — import only the factory then (avoids an unused `s` import).
613
+ const needsS = t.view === undefined && code.includes("s.");
614
+ const imports = [
615
+ `import { ${needsS ? "s, " : ""}${factory} } from "@schemic/surrealdb";`,
616
+ ];
617
+ // Cross-table value imports (one per referenced table, sorted, self excluded).
618
+ for (const dep of [...ctx.imports].filter((d) => d !== t.name).sort()) {
619
+ imports.push(`import { ${ctx.constOf(dep)} } from "./${dep}";`);
620
+ }
621
+ // `surql` lives in surrealdb (where hand-authored files import it from) — a separate line, never
622
+ // folded into the @schemic/surrealdb import (which would reprint/reorder that import on every pull).
623
+ if (code.includes("surql`"))
624
+ imports.push(`import { surql } from "surrealdb";`);
625
+ return {
626
+ kind: "table",
627
+ name: t.name,
628
+ exportName: ctx.constOf(t.name),
629
+ code,
630
+ imports,
631
+ };
632
+ }
633
+
634
+ /** A const name for a function — `fn.name` sanitized to an identifier (`math::add` → `math_add`). */
635
+ function fnConst(name: string): string {
636
+ const id = name.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
637
+ return /^[A-Za-z]/.test(id) ? id : `fn_${id}`;
638
+ }
639
+
640
+ /** Reverse a `StructFunction` into a `defineFunction(name, args).returns(…).body(…)…` const. */
641
+ function renderFunctionConst(fn: StructFunction): string {
642
+ const args = fn.args.map(([n, t]) => `${ident(n)}: ${szType(t)}`).join(", ");
643
+ let code = `export const ${fnConst(fn.name)} = defineFunction(${JSON.stringify(fn.name)}, { ${args} })`;
644
+ if (fn.returns !== undefined) code += `\n .returns(${szType(fn.returns)})`;
645
+ code += `\n .body(surql\`${fn.block}\`)`;
646
+ if (fn.permissions === false) code += `\n .permissions(false)`;
647
+ else if (typeof fn.permissions === "string")
648
+ code += `\n .permissions(surql\`${fn.permissions}\`)`;
649
+ if (fn.comment !== undefined)
650
+ code += `\n .comment(${JSON.stringify(fn.comment)})`;
651
+ return `${code};`;
652
+ }
653
+
654
+ /** Reverse a `StructAccess` into a `defineAccess(name).<type>(…)…` const. Signing keys are NOT recovered. */
655
+ function renderAccessConst(a: StructAccess): string {
656
+ const k = a.kind;
657
+ const v = k.jwt?.verify;
658
+ // A signing key is present (and redacted) for everything except JWT-via-JWKS-URL.
659
+ const hasRedactedKey = !(k.kind === "JWT" && v?.url);
660
+ const lines: string[] = [];
661
+ if (hasRedactedKey)
662
+ lines.push(
663
+ "// NOTE: signing key not pulled (SurrealDB redacts it) — re-applying rotates it.",
664
+ );
665
+ let head = `export const ${fnConst(a.name)} = defineAccess(${JSON.stringify(a.name)})`;
666
+ if (k.kind === "BEARER") {
667
+ head += `\n .bearer({ for: ${JSON.stringify((k.subject ?? "record").toLowerCase())} })`;
668
+ } else if (k.kind === "JWT") {
669
+ head += v?.url
670
+ ? `\n .jwt({ url: ${JSON.stringify(v.url)} })`
671
+ : `\n .jwt({ alg: ${JSON.stringify(v?.alg ?? "HS512")} /* key not pulled */ })`;
672
+ } else {
673
+ head += `\n .record()`;
674
+ }
675
+ lines.push(head);
676
+ if (k.kind === "RECORD") {
677
+ if (k.signup) lines.push(` .signup(surql\`${k.signup}\`)`);
678
+ if (k.signin) lines.push(` .signin(surql\`${k.signin}\`)`);
679
+ if (k.authenticate)
680
+ lines.push(` .authenticate(surql\`${k.authenticate}\`)`);
681
+ }
682
+ const d = a.duration;
683
+ if (d?.grant || d?.token || d?.session) {
684
+ const obj = [
685
+ d.grant && `grant: ${JSON.stringify(d.grant)}`,
686
+ d.token && `token: ${JSON.stringify(d.token)}`,
687
+ d.session && `session: ${JSON.stringify(d.session)}`,
688
+ ]
689
+ .filter(Boolean)
690
+ .join(", ");
691
+ lines.push(` .duration({ ${obj} })`);
692
+ }
693
+ return `${lines.join("\n")};`;
694
+ }
695
+
696
+ /** Topologically sort so a table comes after every same-file table it references (deps first). */
697
+ function topoSort<T extends { name: string; deps: string[] }>(items: T[]): T[] {
698
+ const byName = new Map(items.map((it) => [it.name, it]));
699
+ const out: T[] = [];
700
+ const done = new Set<string>();
701
+ const onStack = new Set<string>();
702
+ const visit = (it: T) => {
703
+ if (done.has(it.name) || onStack.has(it.name)) return;
704
+ onStack.add(it.name);
705
+ for (const dep of it.deps) {
706
+ const d = byName.get(dep);
707
+ if (d) visit(d);
708
+ }
709
+ onStack.delete(it.name);
710
+ done.add(it.name);
711
+ out.push(it);
712
+ };
713
+ for (const it of items) visit(it);
714
+ return out;
715
+ }
716
+
717
+ const EMPTY_LOCAL: LocalOnly = { fields: [], objects: [] };
718
+
719
+ /**
720
+ * Build the per-file pull plan: introspect the live DB (via `INFO … STRUCTURE`) and compute what
721
+ * each schema file would become. Nothing is written — {@link applyPull} does that. Existing files
722
+ * are *merged* (the DB wins per object/field, but unrelated code, comments, and local-only content
723
+ * survive); new files are created. `keepLocal` keeps local-only fields/objects instead of mirroring.
724
+ */
725
+ export async function planPull(
726
+ db: Surreal,
727
+ config: ResolvedConfig,
728
+ opts: { filter?: Filter; keepLocal?: boolean } = {},
729
+ ): Promise<PullPlan> {
730
+ const introspected = await introspectStructured(
731
+ db,
732
+ new Set([config.migrationsTable, `${config.migrationsTable}_lock`]),
733
+ );
734
+ const { tables, functions, accesses, analyzers } = filterStructured(
735
+ introspected,
736
+ opts.filter ?? parseFilter({}),
737
+ );
738
+
739
+ const makeCtx = ctxFactory(tables);
740
+ const keepLocal = opts.keepLocal ?? false;
741
+
742
+ // Single-file layout: one combined module.
743
+ if (config.schemaIsFile) {
744
+ const units = [
745
+ ...tables.map((t) => tableUnit(t, makeCtx(t))),
746
+ ...functions.map(functionUnit),
747
+ ...accesses.map(accessUnit),
748
+ ...analyzers.map(analyzerUnit),
749
+ ];
750
+ return {
751
+ files: [
752
+ planFile(config.schemaPath, units, keepLocal, config, () =>
753
+ assembleCombined({ tables, functions, accesses, analyzers }, makeCtx),
754
+ ),
755
+ ],
756
+ };
757
+ }
758
+
759
+ // Directory layout: one file per object, merged into wherever the object already lives (falling
760
+ // back to its kind folder). A table the user keeps in some other file is updated there, in place.
761
+ const dir = config.schemaPath;
762
+ const tableLoc = await existingTables(dir);
763
+ const groups = new Map<string, RenderedUnit[]>();
764
+ const add = (abs: string, u: RenderedUnit) => {
765
+ const arr = groups.get(abs);
766
+ if (arr) arr.push(u);
767
+ else groups.set(abs, [u]);
768
+ };
769
+ for (const t of tables)
770
+ add(
771
+ tableLoc.get(t.name) ?? join(dir, "tables", `${t.name}.ts`),
772
+ tableUnit(t, makeCtx(t)),
773
+ );
774
+ for (const fn of functions)
775
+ add(join(dir, "functions", `${fn.name}.ts`), functionUnit(fn));
776
+ for (const a of accesses)
777
+ add(join(dir, "access", `${a.name}.ts`), accessUnit(a));
778
+ for (const an of analyzers)
779
+ add(join(dir, "analyzers", `${an.name}.ts`), analyzerUnit(an));
780
+
781
+ const files = [...groups].map(([abs, units]) =>
782
+ planFile(abs, units, keepLocal, config, () =>
783
+ units.length === 1
784
+ ? unitModule(units[0])
785
+ : mergeUnits("", units, {
786
+ keepLocalFields: true,
787
+ keepLocalObjects: true,
788
+ }).content,
789
+ ),
790
+ );
791
+
792
+ // Whole-entity local-only: locally-defined tables/functions/accesses the live DB doesn't have. A
793
+ // file that ALSO holds a DB object is already reconciled above (mergeUnits keeps/drops the
794
+ // local-only entity per `keepLocal`); only files whose entities are ALL local-only are invisible
795
+ // to the DB-driven plan, so surface them here. A file that is PURELY those entities is deletable
796
+ // when mirroring (not --merge); one that mixes them with other code is surfaced but left in place.
797
+ const dbNames = new Set<string>([
798
+ ...tables.map((t) => t.name),
799
+ ...functions.map((f) => f.name),
800
+ ...accesses.map((a) => a.name),
801
+ ...analyzers.map((a) => a.name),
802
+ ]);
803
+ const planned = new Set(files.map((f) => f.abs));
804
+ for (const [file, info] of await scanLocalEntities(dir)) {
805
+ if (planned.has(file)) continue;
806
+ const localOnly = info.entities.filter((e) => !dbNames.has(e.name));
807
+ if (!localOnly.length) continue;
808
+ const before = readFileSync(file, "utf8");
809
+ const deletable =
810
+ !keepLocal &&
811
+ info.pureSchema &&
812
+ localOnly.length === info.entities.length;
813
+ files.push({
814
+ rel: relative(config.root, file),
815
+ abs: file,
816
+ action: deletable ? "delete" : "unchanged",
817
+ before,
818
+ after: deletable ? "" : before,
819
+ localOnly: { fields: [], objects: localOnly.map((e) => e.exportName) },
820
+ });
821
+ }
822
+ files.sort((a, b) => a.rel.localeCompare(b.rel));
823
+ return { files };
824
+ }
825
+
826
+ /** Plan one file: create it from `fresh()` if absent, else merge the units into it. */
827
+ function planFile(
828
+ abs: string,
829
+ units: RenderedUnit[],
830
+ keepLocal: boolean,
831
+ config: ResolvedConfig,
832
+ fresh: () => string,
833
+ ): PullFilePlan {
834
+ const rel = relative(config.root, abs);
835
+ if (!existsSync(abs)) {
836
+ const after = fresh();
837
+ return {
838
+ rel,
839
+ abs,
840
+ action: "create",
841
+ before: "",
842
+ after: after.endsWith("\n") ? after : `${after}\n`,
843
+ localOnly: EMPTY_LOCAL,
844
+ };
845
+ }
846
+ const before = readFileSync(abs, "utf8");
847
+ const { content, localOnly } = mergeUnits(before, units, {
848
+ keepLocalFields: keepLocal,
849
+ keepLocalObjects: keepLocal,
850
+ });
851
+ return {
852
+ rel,
853
+ abs,
854
+ action: content === before ? "unchanged" : "update",
855
+ before,
856
+ after: content,
857
+ localOnly,
858
+ };
859
+ }
860
+
861
+ /** The rendered unit for one db-level function. */
862
+ function functionUnit(fn: StructFunction): RenderedUnit {
863
+ const code = renderFunctionConst(fn);
864
+ const names = ["defineFunction", ...(code.includes("s.") ? ["s"] : [])];
865
+ const imports = [`import { ${names.join(", ")} } from "@schemic/surrealdb";`];
866
+ // `surql` from surrealdb on its own line (see tableUnit) — a function body is always a surql expr.
867
+ if (code.includes("surql`"))
868
+ imports.push(`import { surql } from "surrealdb";`);
869
+ return {
870
+ kind: "function",
871
+ name: fn.name,
872
+ exportName: fnConst(fn.name),
873
+ code,
874
+ imports,
875
+ };
876
+ }
877
+
878
+ /** The rendered unit for one db-level access def. */
879
+ function accessUnit(a: StructAccess): RenderedUnit {
880
+ const code = renderAccessConst(a);
881
+ const imports = [`import { defineAccess } from "@schemic/surrealdb";`];
882
+ if (code.includes("surql`"))
883
+ imports.push(`import { surql } from "surrealdb";`);
884
+ return {
885
+ kind: "access",
886
+ name: a.name,
887
+ exportName: fnConst(a.name),
888
+ code,
889
+ imports,
890
+ };
891
+ }
892
+
893
+ /** Reverse a `StructAnalyzer` into a `defineAnalyzer(name, { tokenizers, filters })` const (lowercased
894
+ * to the authored form; `lowerAnalyzer` re-uppercases for the canonical/diff comparison). */
895
+ function renderAnalyzerConst(a: StructAnalyzer): string {
896
+ const list = (xs: string[]) =>
897
+ `[${xs.map((x) => JSON.stringify(x.toLowerCase())).join(", ")}]`;
898
+ let cfg = `{ tokenizers: ${list(a.tokenizers)}`;
899
+ if (a.filters?.length) cfg += `, filters: ${list(a.filters)}`;
900
+ cfg += " }";
901
+ return `export const ${fnConst(a.name)} = defineAnalyzer(${JSON.stringify(a.name)}, ${cfg});`;
902
+ }
903
+
904
+ function analyzerUnit(a: StructAnalyzer): RenderedUnit {
905
+ return {
906
+ // core `RenderedUnit.kind` lacks "analyzer" yet — group with the other db-level objects for now.
907
+ kind: "access",
908
+ name: a.name,
909
+ exportName: fnConst(a.name),
910
+ code: renderAnalyzerConst(a),
911
+ imports: [`import { defineAnalyzer } from "@schemic/surrealdb";`],
912
+ };
913
+ }
914
+
915
+ /** Build the per-table {@link RenderCtx} factory: cycle-aware ref resolution + import accumulation. */
916
+ function ctxFactory(tables: StructTable[]): (t: StructTable) => RenderCtx {
917
+ // Reference graph (record<…> targets + relation endpoints) → cycle-aware imports / ordering.
918
+ const pulled = new Set(tables.map((t) => t.name));
919
+ const graph = new Map(tables.map((t) => [t.name, tableRefs(t, pulled)]));
920
+ const resolve = makeResolver(graph, pulled);
921
+ const constOf = (n: string) => pascal(n) || n;
922
+ return (t) => ({
923
+ table: t.name,
924
+ imports: new Set(),
925
+ usesSelf: false,
926
+ allowSelf: t.kind.kind !== "RELATION", // only defineTable takes the `self` callback
927
+ constOf,
928
+ resolve: (target) => resolve(t.name, target),
929
+ });
930
+ }
931
+
932
+ /** Render a whole structured schema to one canonical TypeScript module (the source of `diff --ts`). */
933
+ export function renderSchemaToTS(db: DbStructured): string {
934
+ return assembleCombined(db, ctxFactory(db.tables));
935
+ }
936
+
937
+ /** Merge several units' import lines into a deduped block (union of specifiers per source). */
938
+ function mergeImports(units: RenderedUnit[]): string[] {
939
+ const bySource = new Map<string, Set<string>>();
940
+ const order: string[] = [];
941
+ for (const u of units)
942
+ for (const line of u.imports) {
943
+ const m = /import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/.exec(line);
944
+ if (!m) continue;
945
+ let set = bySource.get(m[2]);
946
+ if (!set) {
947
+ set = new Set();
948
+ bySource.set(m[2], set);
949
+ order.push(m[2]);
950
+ }
951
+ for (const s of m[1]
952
+ .split(",")
953
+ .map((x) => x.trim())
954
+ .filter(Boolean))
955
+ set.add(s);
956
+ }
957
+ // @schemic/surrealdb first, then the relative cross-file imports (sorted).
958
+ order.sort((a, b) =>
959
+ a === "@schemic/surrealdb"
960
+ ? -1
961
+ : b === "@schemic/surrealdb"
962
+ ? 1
963
+ : a.localeCompare(b),
964
+ );
965
+ return order.map(
966
+ (src) =>
967
+ `import { ${[...(bySource.get(src) ?? [])].join(", ")} } from "${src}";`,
968
+ );
969
+ }
970
+
971
+ /**
972
+ * Render a structured schema to per-file TypeScript modules keyed by file path — exactly the
973
+ * layout `pull` writes (one file per object, with cross-file imports). `fileFor` maps an object to
974
+ * its file. Used by `diff --ts` so its output matches the user's actual files.
975
+ */
976
+ export function renderPerFile(
977
+ db: DbStructured,
978
+ fileFor: (kind: RenderedUnit["kind"], name: string) => string,
979
+ ): Map<string, string> {
980
+ const makeCtx = ctxFactory(db.tables);
981
+ const byFile = new Map<string, RenderedUnit[]>();
982
+ const add = (file: string, u: RenderedUnit) => {
983
+ const arr = byFile.get(file);
984
+ if (arr) arr.push(u);
985
+ else byFile.set(file, [u]);
986
+ };
987
+ for (const t of db.tables)
988
+ add(fileFor("table", t.name), tableUnit(t, makeCtx(t)));
989
+ for (const fn of db.functions)
990
+ add(fileFor("function", fn.name), functionUnit(fn));
991
+ for (const a of db.accesses) add(fileFor("access", a.name), accessUnit(a));
992
+ for (const an of db.analyzers)
993
+ add(fileFor("access", an.name), analyzerUnit(an));
994
+
995
+ const out = new Map<string, string>();
996
+ for (const [file, units] of byFile)
997
+ out.set(
998
+ file,
999
+ units.length === 1
1000
+ ? unitModule(units[0])
1001
+ : `${mergeImports(units).join("\n")}\n\n${units.map((u) => u.code).join("\n\n")}\n`,
1002
+ );
1003
+ return out;
1004
+ }
1005
+
1006
+ /** Assemble the single-file combined module (tables ordered so same-file refs resolve). */
1007
+ function assembleCombined(
1008
+ { tables, functions, accesses, analyzers }: DbStructured,
1009
+ makeCtx: (t: StructTable) => RenderCtx,
1010
+ ): string {
1011
+ // Render each const (collecting its same-file direct deps via ctx.imports), then order so deps
1012
+ // come first — same-file `Target.record()` refs need `Target` defined above them.
1013
+ const rendered = tables.map((t) => {
1014
+ const ctx = makeCtx(t);
1015
+ const { code, factory } = renderTableConst(t, ctx);
1016
+ return {
1017
+ name: t.name,
1018
+ code,
1019
+ factory,
1020
+ usesSurql: code.includes("surql`"),
1021
+ deps: [...ctx.imports].filter((d) => d !== t.name),
1022
+ };
1023
+ });
1024
+ const ordered = topoSort(rendered);
1025
+ const fnCode = functions.map(renderFunctionConst);
1026
+ const accessCode = accesses.map(renderAccessConst);
1027
+ const analyzerCode = analyzers.map(renderAnalyzerConst);
1028
+ const factories = [...new Set(ordered.map((r) => r.factory))];
1029
+ if (functions.length) factories.push("defineFunction");
1030
+ if (accesses.length) factories.push("defineAccess");
1031
+ if (analyzers.length) factories.push("defineAnalyzer");
1032
+ factories.sort();
1033
+ const usesSurql =
1034
+ functions.length > 0 ||
1035
+ accesses.length > 0 ||
1036
+ ordered.some((r) => r.usesSurql);
1037
+ const names = ["s", ...factories];
1038
+ const imports = [`import { ${names.join(", ")} } from "@schemic/surrealdb";`];
1039
+ // `surql` from surrealdb on its own line (see tableUnit), kept out of the @schemic/surrealdb import.
1040
+ if (usesSurql) imports.push(`import { surql } from "surrealdb";`);
1041
+ // Analyzers first — a FULLTEXT index references its analyzer.
1042
+ const body = [
1043
+ ...analyzerCode,
1044
+ ...ordered.map((r) => r.code),
1045
+ ...fnCode,
1046
+ ...accessCode,
1047
+ ].join("\n\n");
1048
+ return `${imports.join("\n")}\n\n${body}\n`;
1049
+ }