@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/LICENSE +21 -0
- package/README.md +103 -0
- package/lib/index.d.ts +1231 -0
- package/lib/index.js +5019 -0
- package/lib/index.js.map +1 -0
- package/package.json +68 -0
- package/src/cli/engine.ts +189 -0
- package/src/cli/introspect.ts +275 -0
- package/src/cli/lower.ts +370 -0
- package/src/cli/pull.ts +1049 -0
- package/src/cli/scaffold.ts +167 -0
- package/src/cli/struct.ts +0 -0
- package/src/cli/structure.ts +696 -0
- package/src/cli/surreal-connect.ts +112 -0
- package/src/cli/surreal-diff.ts +321 -0
- package/src/cli/surreal-filter.ts +67 -0
- package/src/config.ts +94 -0
- package/src/connection.ts +51 -0
- package/src/ddl.ts +931 -0
- package/src/driver/surql-type.ts +191 -0
- package/src/driver/surreal.ts +364 -0
- package/src/index.ts +99 -0
- package/src/kinds/explode.ts +201 -0
- package/src/kinds/portable.ts +116 -0
- package/src/kinds/registry.ts +177 -0
- package/src/pure.ts +2671 -0
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
|
+
}
|