@typicalday/firegraph 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +62 -20
  2. package/dist/backend-CE3pM9-T.d.ts +167 -0
  3. package/dist/{backend-DuvHGgK1.d.cts → backend-DNzv8KSR.d.cts} +34 -20
  4. package/dist/{backend-DuvHGgK1.d.ts → backend-DNzv8KSR.d.ts} +34 -20
  5. package/dist/backend-EjFfw9yO.d.cts +167 -0
  6. package/dist/backend.cjs.map +1 -1
  7. package/dist/backend.d.cts +2 -2
  8. package/dist/backend.d.ts +2 -2
  9. package/dist/backend.js +1 -1
  10. package/dist/chunk-5JBNLH5W.js +732 -0
  11. package/dist/chunk-5JBNLH5W.js.map +1 -0
  12. package/dist/{chunk-3AHHXMWX.js → chunk-6IO74NKD.js} +23 -44
  13. package/dist/chunk-6IO74NKD.js.map +1 -0
  14. package/dist/{chunk-DJI3VXXA.js → chunk-7IEZ6IYY.js} +2 -2
  15. package/dist/chunk-7IEZ6IYY.js.map +1 -0
  16. package/dist/chunk-NGAJCALM.js +34 -0
  17. package/dist/chunk-NGAJCALM.js.map +1 -0
  18. package/dist/chunk-NZVSLWNY.js +867 -0
  19. package/dist/chunk-NZVSLWNY.js.map +1 -0
  20. package/dist/{chunk-N5HFDWQX.js → chunk-PWIO46RT.js} +1 -1
  21. package/dist/{chunk-N5HFDWQX.js.map → chunk-PWIO46RT.js.map} +1 -1
  22. package/dist/{client-BKi3vk0Q.d.ts → client-CNAwJayO.d.ts} +1 -1
  23. package/dist/{client-BrsaXtDV.d.cts → client-CaXH5D5C.d.cts} +1 -1
  24. package/dist/{client-Bk2Cm6xv.d.cts → client-DoyEdJ5w.d.cts} +1 -1
  25. package/dist/{client-Bk2Cm6xv.d.ts → client-DoyEdJ5w.d.ts} +1 -1
  26. package/dist/cloudflare/index.cjs +159 -167
  27. package/dist/cloudflare/index.cjs.map +1 -1
  28. package/dist/cloudflare/index.d.cts +73 -70
  29. package/dist/cloudflare/index.d.ts +73 -70
  30. package/dist/cloudflare/index.js +54 -589
  31. package/dist/cloudflare/index.js.map +1 -1
  32. package/dist/codegen/index.d.cts +1 -1
  33. package/dist/codegen/index.d.ts +1 -1
  34. package/dist/firestore-enterprise/index.cjs +11 -9
  35. package/dist/firestore-enterprise/index.cjs.map +1 -1
  36. package/dist/firestore-enterprise/index.d.cts +3 -3
  37. package/dist/firestore-enterprise/index.d.ts +3 -3
  38. package/dist/firestore-enterprise/index.js +6 -4
  39. package/dist/firestore-enterprise/index.js.map +1 -1
  40. package/dist/firestore-standard/index.cjs +11 -9
  41. package/dist/firestore-standard/index.cjs.map +1 -1
  42. package/dist/firestore-standard/index.d.cts +3 -3
  43. package/dist/firestore-standard/index.d.ts +3 -3
  44. package/dist/firestore-standard/index.js +4 -3
  45. package/dist/firestore-standard/index.js.map +1 -1
  46. package/dist/index.cjs +11 -9
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.cts +5 -5
  49. package/dist/index.d.ts +5 -5
  50. package/dist/index.js +6 -4
  51. package/dist/index.js.map +1 -1
  52. package/dist/query-client/index.d.cts +2 -2
  53. package/dist/query-client/index.d.ts +2 -2
  54. package/dist/{registry-C2KUPVZj.d.ts → registry-By1i-zge.d.ts} +2 -2
  55. package/dist/{registry-Bc7h6WTM.d.cts → registry-CNToyEra.d.cts} +2 -2
  56. package/dist/sqlite/index.cjs +599 -380
  57. package/dist/sqlite/index.cjs.map +1 -1
  58. package/dist/sqlite/index.d.cts +4 -110
  59. package/dist/sqlite/index.d.ts +4 -110
  60. package/dist/sqlite/index.js +7 -1144
  61. package/dist/sqlite/index.js.map +1 -1
  62. package/dist/sqlite/local.cjs +2262 -0
  63. package/dist/sqlite/local.cjs.map +1 -0
  64. package/dist/sqlite/local.d.cts +109 -0
  65. package/dist/sqlite/local.d.ts +109 -0
  66. package/dist/sqlite/local.js +546 -0
  67. package/dist/sqlite/local.js.map +1 -0
  68. package/package.json +15 -1
  69. package/dist/chunk-3AHHXMWX.js.map +0 -1
  70. package/dist/chunk-DJI3VXXA.js.map +0 -1
  71. package/dist/chunk-NNBSUOOF.js +0 -289
  72. package/dist/chunk-NNBSUOOF.js.map +0 -1
@@ -0,0 +1,2262 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/sqlite/local.ts
31
+ var local_exports = {};
32
+ __export(local_exports, {
33
+ createBetterSqliteExecutor: () => createBetterSqliteExecutor,
34
+ createLocalSqliteBackend: () => createLocalSqliteBackend
35
+ });
36
+ module.exports = __toCommonJS(local_exports);
37
+
38
+ // src/errors.ts
39
+ var FiregraphError = class extends Error {
40
+ constructor(message, code) {
41
+ super(message);
42
+ this.code = code;
43
+ this.name = "FiregraphError";
44
+ }
45
+ };
46
+
47
+ // src/internal/backend.ts
48
+ function createCapabilities(caps) {
49
+ return {
50
+ has: (capability) => caps.has(capability),
51
+ values: () => caps.values()
52
+ };
53
+ }
54
+
55
+ // src/default-indexes.ts
56
+ var DEFAULT_CORE_INDEXES = Object.freeze([
57
+ { fields: ["aUid"] },
58
+ { fields: ["bUid"] },
59
+ { fields: ["aType"] },
60
+ { fields: ["bType"] },
61
+ { fields: ["aUid", "axbType"] },
62
+ { fields: ["axbType", "bUid"] },
63
+ { fields: ["aType", "axbType"] },
64
+ { fields: ["axbType", "bType"] }
65
+ ]);
66
+
67
+ // src/internal/sqlite-index-ddl.ts
68
+ var IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
69
+ var JSON_PATH_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
70
+ function quoteIdent(name) {
71
+ if (!IDENT_RE.test(name)) {
72
+ throw new FiregraphError(
73
+ `Invalid SQL identifier in index DDL: ${name}. Must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,
74
+ "INVALID_INDEX"
75
+ );
76
+ }
77
+ return `"${name}"`;
78
+ }
79
+ function fnv1a32(str) {
80
+ let h = 2166136261;
81
+ for (let i = 0; i < str.length; i++) {
82
+ h ^= str.charCodeAt(i);
83
+ h = Math.imul(h, 16777619);
84
+ }
85
+ return (h >>> 0).toString(16).padStart(8, "0");
86
+ }
87
+ function normalizeFields(fields) {
88
+ return fields.map((f) => {
89
+ if (typeof f === "string") return { path: f, desc: false };
90
+ if (!f.path || typeof f.path !== "string") {
91
+ throw new FiregraphError(
92
+ `IndexSpec field must be a string or { path: string, desc?: boolean }; got ${JSON.stringify(f)}`,
93
+ "INVALID_INDEX"
94
+ );
95
+ }
96
+ return { path: f.path, desc: !!f.desc };
97
+ });
98
+ }
99
+ function specFingerprint(spec) {
100
+ const normalized = {
101
+ lead: [],
102
+ fields: normalizeFields(spec.fields),
103
+ where: spec.where ?? ""
104
+ };
105
+ return fnv1a32(JSON.stringify(normalized));
106
+ }
107
+ function compileFieldExpr(path, fieldToColumn) {
108
+ const col = fieldToColumn[path];
109
+ if (col) return quoteIdent(col);
110
+ if (path === "data") {
111
+ return `json_extract("data", '$')`;
112
+ }
113
+ if (path.startsWith("data.")) {
114
+ const suffix = path.slice(5);
115
+ const parts = suffix.split(".");
116
+ for (const part of parts) {
117
+ if (!JSON_PATH_KEY_RE.test(part)) {
118
+ throw new FiregraphError(
119
+ `IndexSpec data path "${path}" has invalid component "${part}". Each component must match /^[A-Za-z_][A-Za-z0-9_-]*$/.`,
120
+ "INVALID_INDEX"
121
+ );
122
+ }
123
+ }
124
+ return `json_extract("data", '$.${suffix}')`;
125
+ }
126
+ throw new FiregraphError(
127
+ `IndexSpec field "${path}" is not a known firegraph field. Use a top-level field (aType, aUid, axbType, bType, bUid, createdAt, updatedAt, v) or a dotted data path like 'data.status'.`,
128
+ "INVALID_INDEX"
129
+ );
130
+ }
131
+ function buildIndexDDL(spec, options) {
132
+ const { table, fieldToColumn } = options;
133
+ if (!spec.fields || spec.fields.length === 0) {
134
+ throw new FiregraphError("IndexSpec.fields must be a non-empty array", "INVALID_INDEX");
135
+ }
136
+ const normalized = normalizeFields(spec.fields);
137
+ const hash = specFingerprint(spec);
138
+ const indexName = `${table}_idx_${hash}`;
139
+ const cols = [];
140
+ for (const f of normalized) {
141
+ const expr = compileFieldExpr(f.path, fieldToColumn);
142
+ cols.push(f.desc ? `${expr} DESC` : expr);
143
+ }
144
+ let ddl = `CREATE INDEX IF NOT EXISTS ${quoteIdent(indexName)} ON ${quoteIdent(table)}(${cols.join(", ")})`;
145
+ if (spec.where) {
146
+ ddl += ` WHERE ${spec.where}`;
147
+ }
148
+ return ddl;
149
+ }
150
+ function dedupeIndexSpecs(specs) {
151
+ const seen = /* @__PURE__ */ new Set();
152
+ const out = [];
153
+ for (const spec of specs) {
154
+ const fp = specFingerprint(spec);
155
+ if (seen.has(fp)) continue;
156
+ seen.add(fp);
157
+ out.push(spec);
158
+ }
159
+ return out;
160
+ }
161
+
162
+ // src/internal/sqlite-schema.ts
163
+ var FIELD_TO_COLUMN = {
164
+ aType: "a_type",
165
+ aUid: "a_uid",
166
+ axbType: "axb_type",
167
+ bType: "b_type",
168
+ bUid: "b_uid",
169
+ v: "v",
170
+ createdAt: "created_at",
171
+ updatedAt: "updated_at"
172
+ };
173
+ function buildSchemaStatements(table, options = {}) {
174
+ const t = quoteIdent2(table);
175
+ const statements = [
176
+ `CREATE TABLE IF NOT EXISTS ${t} (
177
+ doc_id TEXT NOT NULL PRIMARY KEY,
178
+ a_type TEXT NOT NULL,
179
+ a_uid TEXT NOT NULL,
180
+ axb_type TEXT NOT NULL,
181
+ b_type TEXT NOT NULL,
182
+ b_uid TEXT NOT NULL,
183
+ data TEXT NOT NULL,
184
+ v INTEGER,
185
+ created_at INTEGER NOT NULL,
186
+ updated_at INTEGER NOT NULL
187
+ )`
188
+ ];
189
+ const core = options.coreIndexes ?? [...DEFAULT_CORE_INDEXES];
190
+ const fromRegistry = options.registry?.entries().flatMap((e) => e.indexes ?? []) ?? [];
191
+ const deduped = dedupeIndexSpecs([...core, ...fromRegistry]);
192
+ for (const spec of deduped) {
193
+ statements.push(buildIndexDDL(spec, { table, fieldToColumn: FIELD_TO_COLUMN }));
194
+ }
195
+ return statements;
196
+ }
197
+ function quoteIdent2(name) {
198
+ validateTableName(name);
199
+ return `"${name}"`;
200
+ }
201
+ function validateTableName(name) {
202
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
203
+ throw new Error(`Invalid SQL identifier: ${name}. Must match /^[A-Za-z_][A-Za-z0-9_]*$/.`);
204
+ }
205
+ }
206
+ function quoteColumnAlias(label) {
207
+ return `"${label.replace(/"/g, '""')}"`;
208
+ }
209
+
210
+ // src/internal/sqlite-data-ops.ts
211
+ var FIRESTORE_TYPE_NAMES = /* @__PURE__ */ new Set([
212
+ "Timestamp",
213
+ "GeoPoint",
214
+ "VectorValue",
215
+ "DocumentReference",
216
+ "FieldValue"
217
+ ]);
218
+ function isFirestoreSpecialType(value) {
219
+ const ctorName = value.constructor?.name;
220
+ if (ctorName && FIRESTORE_TYPE_NAMES.has(ctorName)) return ctorName;
221
+ return null;
222
+ }
223
+ var JSON_PATH_KEY_RE2 = /^[A-Za-z_][A-Za-z0-9_-]*$/;
224
+ function validateJsonPathKey(key, backendLabel) {
225
+ if (key.length === 0) {
226
+ throw new FiregraphError(
227
+ `${backendLabel}: empty JSON path component is not allowed`,
228
+ "INVALID_QUERY"
229
+ );
230
+ }
231
+ if (!JSON_PATH_KEY_RE2.test(key)) {
232
+ throw new FiregraphError(
233
+ `${backendLabel}: data field path component "${key}" is not a safe JSON-path identifier. Allowed pattern: /^[A-Za-z_][A-Za-z0-9_-]*$/. Use replaceNode/replaceEdge (full-data overwrite) for keys with reserved characters (whitespace, dots, brackets, quotes, etc.).`,
234
+ "INVALID_QUERY"
235
+ );
236
+ }
237
+ }
238
+ function buildJsonPath(segments) {
239
+ return "$" + segments.map((seg) => "." + JSON.stringify(seg)).join("");
240
+ }
241
+ function jsonBind(value, backendLabel) {
242
+ if (value === void 0) return "null";
243
+ if (value !== null && typeof value === "object") {
244
+ const firestoreType = isFirestoreSpecialType(value);
245
+ if (firestoreType) {
246
+ throw new FiregraphError(
247
+ `${backendLabel} cannot persist a Firestore ${firestoreType} value. Convert to a primitive before writing (e.g. \`ts.toMillis()\` for Timestamp).`,
248
+ "INVALID_ARGUMENT"
249
+ );
250
+ }
251
+ }
252
+ return JSON.stringify(value);
253
+ }
254
+ function compileDataOpsExpr(ops, base, params, backendLabel) {
255
+ if (ops.length === 0) return null;
256
+ const deletes = [];
257
+ const sets = [];
258
+ for (const op of ops) (op.delete ? deletes : sets).push(op);
259
+ let expr = base;
260
+ if (deletes.length > 0) {
261
+ const placeholders = deletes.map(() => "?").join(", ");
262
+ expr = `json_remove(${expr}, ${placeholders})`;
263
+ for (const op of deletes) {
264
+ params.push(buildJsonPath(op.path));
265
+ }
266
+ }
267
+ if (sets.length > 0) {
268
+ const pieces = sets.map(() => "?, json(?)").join(", ");
269
+ expr = `json_set(${expr}, ${pieces})`;
270
+ for (const op of sets) {
271
+ params.push(buildJsonPath(op.path));
272
+ params.push(jsonBind(op.value, backendLabel));
273
+ }
274
+ }
275
+ return expr;
276
+ }
277
+
278
+ // src/timestamp.ts
279
+ var GraphTimestampImpl = class _GraphTimestampImpl {
280
+ constructor(seconds, nanoseconds) {
281
+ this.seconds = seconds;
282
+ this.nanoseconds = nanoseconds;
283
+ }
284
+ toDate() {
285
+ return new Date(this.toMillis());
286
+ }
287
+ toMillis() {
288
+ return this.seconds * 1e3 + Math.floor(this.nanoseconds / 1e6);
289
+ }
290
+ toJSON() {
291
+ return { seconds: this.seconds, nanoseconds: this.nanoseconds };
292
+ }
293
+ static fromMillis(ms) {
294
+ const seconds = Math.floor(ms / 1e3);
295
+ const nanoseconds = (ms - seconds * 1e3) * 1e6;
296
+ return new _GraphTimestampImpl(seconds, nanoseconds);
297
+ }
298
+ static now() {
299
+ return _GraphTimestampImpl.fromMillis(Date.now());
300
+ }
301
+ };
302
+
303
+ // src/internal/constants.ts
304
+ var NODE_RELATION = "is";
305
+ var SHARD_SEPARATOR = ":";
306
+
307
+ // src/internal/serialization-tag.ts
308
+ var SERIALIZATION_TAG = "__firegraph_ser__";
309
+ var KNOWN_TYPES = /* @__PURE__ */ new Set(["Timestamp", "GeoPoint", "VectorValue", "DocumentReference"]);
310
+ function isTaggedValue(value) {
311
+ if (value === null || typeof value !== "object") return false;
312
+ const tag = value[SERIALIZATION_TAG];
313
+ return typeof tag === "string" && KNOWN_TYPES.has(tag);
314
+ }
315
+
316
+ // src/internal/write-plan.ts
317
+ var DELETE_FIELD = /* @__PURE__ */ Symbol.for("firegraph.deleteField");
318
+ function isDeleteSentinel(value) {
319
+ return value === DELETE_FIELD;
320
+ }
321
+ var FIRESTORE_TERMINAL_CTOR = /* @__PURE__ */ new Set([
322
+ "Timestamp",
323
+ "GeoPoint",
324
+ "VectorValue",
325
+ "DocumentReference",
326
+ "FieldValue",
327
+ "NumericIncrementTransform",
328
+ "ArrayUnionTransform",
329
+ "ArrayRemoveTransform",
330
+ "ServerTimestampTransform",
331
+ "DeleteTransform"
332
+ ]);
333
+ function isTerminalValue(value) {
334
+ if (value === null) return true;
335
+ const t = typeof value;
336
+ if (t !== "object") return true;
337
+ if (Array.isArray(value)) return true;
338
+ if (isTaggedValue(value)) return true;
339
+ const proto = Object.getPrototypeOf(value);
340
+ if (proto === null || proto === Object.prototype) return false;
341
+ const ctor = value.constructor;
342
+ if (ctor && typeof ctor.name === "string" && FIRESTORE_TERMINAL_CTOR.has(ctor.name)) return true;
343
+ return true;
344
+ }
345
+ function assertUpdatePayloadExclusive(update) {
346
+ if (update.replaceData !== void 0 && update.dataOps !== void 0) {
347
+ throw new Error(
348
+ "firegraph: UpdatePayload cannot specify both `replaceData` and `dataOps`. Use one or the other \u2014 `replaceData` is the migration-write-back form, `dataOps` is the standard partial-update form."
349
+ );
350
+ }
351
+ }
352
+ function walkForDeleteSentinels(node, path, parent, visit) {
353
+ if (node === null || node === void 0) return;
354
+ if (isDeleteSentinel(node)) {
355
+ visit({ path, parent });
356
+ return;
357
+ }
358
+ if (typeof node !== "object") return;
359
+ if (isTaggedValue(node)) return;
360
+ if (Array.isArray(node)) {
361
+ for (let i = 0; i < node.length; i++) {
362
+ walkForDeleteSentinels(node[i], [...path, String(i)], { kind: "array", index: i }, visit);
363
+ }
364
+ return;
365
+ }
366
+ const proto = Object.getPrototypeOf(node);
367
+ if (proto !== null && proto !== Object.prototype) return;
368
+ const obj = node;
369
+ for (const key of Object.keys(obj)) {
370
+ walkForDeleteSentinels(obj[key], [...path, key], { kind: "object" }, visit);
371
+ }
372
+ }
373
+ function assertSafePath(path) {
374
+ for (const seg of path) {
375
+ if (seg === "") {
376
+ throw new Error(
377
+ `firegraph: empty object key at path ${path.map((p) => JSON.stringify(p)).join(" > ")}. Object keys in update payloads must be non-empty.`
378
+ );
379
+ }
380
+ }
381
+ }
382
+ function flattenPatch(data) {
383
+ const ops = [];
384
+ walk(data, [], ops);
385
+ return ops;
386
+ }
387
+ function assertNoDeleteSentinelsInArrayValue(arr, arrayPath) {
388
+ walkForDeleteSentinels(arr, arrayPath, { kind: "root" }, ({ parent }) => {
389
+ const arrayPathStr = arrayPath.length === 0 ? "<root>" : arrayPath.map((p) => JSON.stringify(p)).join(" > ");
390
+ if (parent.kind === "array") {
391
+ throw new Error(
392
+ `firegraph: deleteField() sentinel at index ${parent.index} inside an array at path ${arrayPathStr}. Arrays are terminal in update payloads (replaced as a unit), so the sentinel would be silently dropped by JSON serialization. To remove the field entirely, pass deleteField() in place of the whole array.`
393
+ );
394
+ }
395
+ throw new Error(
396
+ `firegraph: deleteField() sentinel inside an array element at path ${arrayPathStr}. Arrays are terminal in update payloads \u2014 the sentinel would be silently dropped by JSON serialization.`
397
+ );
398
+ });
399
+ }
400
+ function walk(node, path, out) {
401
+ if (node === void 0) return;
402
+ if (isDeleteSentinel(node)) {
403
+ if (path.length === 0) {
404
+ throw new Error("firegraph: deleteField() cannot be the entire update payload.");
405
+ }
406
+ assertSafePath(path);
407
+ out.push({ path: [...path], value: void 0, delete: true });
408
+ return;
409
+ }
410
+ if (isTerminalValue(node)) {
411
+ if (path.length === 0) {
412
+ throw new Error(
413
+ "firegraph: update payload must be a plain object. Got " + (node === null ? "null" : Array.isArray(node) ? "array" : typeof node) + "."
414
+ );
415
+ }
416
+ if (Array.isArray(node)) {
417
+ assertNoDeleteSentinelsInArrayValue(node, path);
418
+ }
419
+ assertSafePath(path);
420
+ out.push({ path: [...path], value: node, delete: false });
421
+ return;
422
+ }
423
+ const obj = node;
424
+ const keys = Object.keys(obj);
425
+ if (keys.length === 0) {
426
+ if (path.length > 0) {
427
+ assertSafePath(path);
428
+ out.push({ path: [...path], value: {}, delete: false });
429
+ }
430
+ return;
431
+ }
432
+ for (const key of keys) {
433
+ if (key === SERIALIZATION_TAG) {
434
+ const where = path.length === 0 ? "<root>" : path.map((p) => JSON.stringify(p)).join(" > ");
435
+ throw new Error(
436
+ `firegraph: update payload contains a literal \`${SERIALIZATION_TAG}\` key at ${where}. That key is reserved for firegraph's serialization envelope and cannot appear on a plain object in user data. Use a different field name, or pass a recognized tagged value through replaceNode/replaceEdge instead.`
437
+ );
438
+ }
439
+ walk(obj[key], [...path, key], out);
440
+ }
441
+ }
442
+
443
+ // src/internal/sqlite-payload-guard.ts
444
+ var FIRESTORE_TYPE_NAMES2 = /* @__PURE__ */ new Set([
445
+ "Timestamp",
446
+ "GeoPoint",
447
+ "VectorValue",
448
+ "DocumentReference",
449
+ "FieldValue"
450
+ ]);
451
+ function assertJsonSafePayload(data, label) {
452
+ walk2(data, [], label);
453
+ }
454
+ function walk2(node, path, label) {
455
+ if (node === null || node === void 0) return;
456
+ if (isDeleteSentinel(node)) {
457
+ throw new FiregraphError(
458
+ `${label} backend cannot persist a deleteField() sentinel inside a full-data payload (replaceNode/replaceEdge or first-insert). The sentinel is only valid inside an updateNode/updateEdge dataOps patch. Path: ${formatPath(path)}.`,
459
+ "INVALID_ARGUMENT"
460
+ );
461
+ }
462
+ const t = typeof node;
463
+ if (t === "symbol" || t === "function") {
464
+ throw new FiregraphError(
465
+ `${label} backend cannot persist a value of type ${t}. JSON.stringify drops it silently. Path: ${formatPath(path)}.`,
466
+ "INVALID_ARGUMENT"
467
+ );
468
+ }
469
+ if (t === "bigint") {
470
+ throw new FiregraphError(
471
+ `${label} backend cannot persist a value of type bigint. JSON.stringify cannot serialize this type (throws TypeError). Path: ${formatPath(path)}.`,
472
+ "INVALID_ARGUMENT"
473
+ );
474
+ }
475
+ if (t !== "object") return;
476
+ if (Array.isArray(node)) {
477
+ for (let i = 0; i < node.length; i++) {
478
+ walk2(node[i], [...path, String(i)], label);
479
+ }
480
+ return;
481
+ }
482
+ const obj = node;
483
+ if (Object.prototype.hasOwnProperty.call(obj, SERIALIZATION_TAG)) {
484
+ const tagValue = obj[SERIALIZATION_TAG];
485
+ throw new FiregraphError(
486
+ `${label} backend cannot persist an object with a \`${SERIALIZATION_TAG}\` key (value: ${formatTagValue(tagValue)}). Recognised tags are valid only on the Firestore backend (migration-sandbox output); a literal \`${SERIALIZATION_TAG}\` field in user data is reserved and not allowed. Path: ${formatPath(path)}.`,
487
+ "INVALID_ARGUMENT"
488
+ );
489
+ }
490
+ const proto = Object.getPrototypeOf(node);
491
+ if (proto !== null && proto !== Object.prototype) {
492
+ const ctor = node.constructor;
493
+ const ctorName = ctor && typeof ctor.name === "string" ? ctor.name : "<anonymous>";
494
+ if (FIRESTORE_TYPE_NAMES2.has(ctorName)) {
495
+ throw new FiregraphError(
496
+ `${label} backend cannot persist a Firestore ${ctorName} value. Convert to a primitive before writing (e.g. \`ts.toMillis()\` for Timestamp, \`{lat,lng}\` for GeoPoint). Path: ${formatPath(path)}.`,
497
+ "INVALID_ARGUMENT"
498
+ );
499
+ }
500
+ if (node instanceof Date) return;
501
+ throw new FiregraphError(
502
+ `${label} backend cannot persist a class instance of type ${ctorName}. Only plain objects, arrays, and primitives round-trip safely through JSON storage. Path: ${formatPath(path)}.`,
503
+ "INVALID_ARGUMENT"
504
+ );
505
+ }
506
+ for (const key of Object.keys(obj)) {
507
+ walk2(obj[key], [...path, key], label);
508
+ }
509
+ }
510
+ function formatPath(path) {
511
+ return path.length === 0 ? "<root>" : path.map((p) => JSON.stringify(p)).join(" > ");
512
+ }
513
+ function formatTagValue(value) {
514
+ if (value === null) return "null";
515
+ if (value === void 0) return "undefined";
516
+ if (typeof value === "string") return JSON.stringify(value);
517
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
518
+ return String(value);
519
+ }
520
+ return typeof value;
521
+ }
522
+
523
+ // src/internal/sqlite-sql.ts
524
+ var BACKEND_LABEL = "SQLite";
525
+ var BACKEND_ERR_LABEL = "SQLite backend";
526
+ function compileFieldRef(field) {
527
+ const column = FIELD_TO_COLUMN[field];
528
+ if (column) {
529
+ return { expr: quoteIdent2(column) };
530
+ }
531
+ if (field.startsWith("data.")) {
532
+ const suffix = field.slice(5);
533
+ for (const part of suffix.split(".")) {
534
+ validateJsonPathKey(part, BACKEND_ERR_LABEL);
535
+ }
536
+ return { expr: `json_extract("data", '$.${suffix}')` };
537
+ }
538
+ if (field === "data") {
539
+ return { expr: `json_extract("data", '$')` };
540
+ }
541
+ throw new FiregraphError(`SQLite backend cannot resolve filter field: ${field}`, "INVALID_QUERY");
542
+ }
543
+ function bindValue(value) {
544
+ if (value === null || value === void 0) return null;
545
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
546
+ return value;
547
+ }
548
+ if (value instanceof Date) return value.getTime();
549
+ if (typeof value === "object") {
550
+ const firestoreType = isFirestoreSpecialType(value);
551
+ if (firestoreType) {
552
+ throw new FiregraphError(
553
+ `SQLite backend cannot bind a Firestore ${firestoreType} value \u2014 JSON serialization would silently drop fields and the resulting bind would never match a stored row. Convert to a primitive (e.g. \`ts.toMillis()\` for Timestamp) before filtering or updating.`,
554
+ "INVALID_QUERY"
555
+ );
556
+ }
557
+ return JSON.stringify(value);
558
+ }
559
+ return String(value);
560
+ }
561
+ function compileFilter(filter, params) {
562
+ const { expr } = compileFieldRef(filter.field);
563
+ switch (filter.op) {
564
+ case "==":
565
+ params.push(bindValue(filter.value));
566
+ return `${expr} = ?`;
567
+ case "!=":
568
+ params.push(bindValue(filter.value));
569
+ return `${expr} != ?`;
570
+ case "<":
571
+ params.push(bindValue(filter.value));
572
+ return `${expr} < ?`;
573
+ case "<=":
574
+ params.push(bindValue(filter.value));
575
+ return `${expr} <= ?`;
576
+ case ">":
577
+ params.push(bindValue(filter.value));
578
+ return `${expr} > ?`;
579
+ case ">=":
580
+ params.push(bindValue(filter.value));
581
+ return `${expr} >= ?`;
582
+ case "in": {
583
+ const values = asArray(filter.value, "in");
584
+ const placeholders = values.map(() => "?").join(", ");
585
+ for (const v of values) params.push(bindValue(v));
586
+ return `${expr} IN (${placeholders})`;
587
+ }
588
+ case "not-in": {
589
+ const values = asArray(filter.value, "not-in");
590
+ const placeholders = values.map(() => "?").join(", ");
591
+ for (const v of values) params.push(bindValue(v));
592
+ return `${expr} NOT IN (${placeholders})`;
593
+ }
594
+ case "array-contains": {
595
+ params.push(bindValue(filter.value));
596
+ return `EXISTS (SELECT 1 FROM json_each(${expr}) WHERE value = ?)`;
597
+ }
598
+ case "array-contains-any": {
599
+ const values = asArray(filter.value, "array-contains-any");
600
+ const placeholders = values.map(() => "?").join(", ");
601
+ for (const v of values) params.push(bindValue(v));
602
+ return `EXISTS (SELECT 1 FROM json_each(${expr}) WHERE value IN (${placeholders}))`;
603
+ }
604
+ default:
605
+ throw new FiregraphError(
606
+ `SQLite backend does not support filter operator: ${String(filter.op)}`,
607
+ "INVALID_QUERY"
608
+ );
609
+ }
610
+ }
611
+ function compileFilterConditions(filters, params) {
612
+ return filters.map((f) => compileFilter(f, params));
613
+ }
614
+ function asArray(value, op) {
615
+ if (!Array.isArray(value) || value.length === 0) {
616
+ throw new FiregraphError(`Operator "${op}" requires a non-empty array value`, "INVALID_QUERY");
617
+ }
618
+ return value;
619
+ }
620
+ function compileOrderBy(options, _params) {
621
+ if (!options?.orderBy) return "";
622
+ const { field, direction } = options.orderBy;
623
+ const { expr } = compileFieldRef(field);
624
+ const dir = direction === "desc" ? "DESC" : "ASC";
625
+ return ` ORDER BY ${expr} ${dir}`;
626
+ }
627
+ function compileLimit(options, params) {
628
+ if (options?.limit === void 0) return "";
629
+ params.push(options.limit);
630
+ return ` LIMIT ?`;
631
+ }
632
+ function compileSelect(table, filters, options) {
633
+ const params = [];
634
+ const conditions = [];
635
+ for (const f of filters) {
636
+ conditions.push(compileFilter(f, params));
637
+ }
638
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
639
+ let sql = `SELECT * FROM ${quoteIdent2(table)}${where}`;
640
+ sql += compileOrderBy(options, params);
641
+ sql += compileLimit(options, params);
642
+ return { sql, params };
643
+ }
644
+ function compileExpand(table, params) {
645
+ if (params.sources.length === 0) {
646
+ throw new FiregraphError(
647
+ "compileExpand requires a non-empty sources list \u2014 empty IN () is invalid SQL.",
648
+ "INVALID_QUERY"
649
+ );
650
+ }
651
+ const direction = params.direction ?? "forward";
652
+ const aUidCol = compileFieldRef("aUid").expr;
653
+ const bUidCol = compileFieldRef("bUid").expr;
654
+ const aTypeCol = compileFieldRef("aType").expr;
655
+ const bTypeCol = compileFieldRef("bType").expr;
656
+ const axbTypeCol = compileFieldRef("axbType").expr;
657
+ const sourceColumn = direction === "forward" ? aUidCol : bUidCol;
658
+ const sqlParams = [params.axbType];
659
+ const conditions = [`${axbTypeCol} = ?`];
660
+ const placeholders = params.sources.map(() => "?").join(", ");
661
+ conditions.push(`${sourceColumn} IN (${placeholders})`);
662
+ for (const uid of params.sources) sqlParams.push(uid);
663
+ if (params.aType !== void 0) {
664
+ conditions.push(`${aTypeCol} = ?`);
665
+ sqlParams.push(params.aType);
666
+ }
667
+ if (params.bType !== void 0) {
668
+ conditions.push(`${bTypeCol} = ?`);
669
+ sqlParams.push(params.bType);
670
+ }
671
+ if (params.axbType === NODE_RELATION) {
672
+ conditions.push(`${aUidCol} != ${bUidCol}`);
673
+ }
674
+ let sql = `SELECT * FROM ${quoteIdent2(table)} WHERE ${conditions.join(" AND ")}`;
675
+ if (params.orderBy) {
676
+ sql += compileOrderBy({ orderBy: params.orderBy }, sqlParams);
677
+ }
678
+ if (params.limitPerSource !== void 0) {
679
+ const totalLimit = params.sources.length * params.limitPerSource;
680
+ sql += ` LIMIT ?`;
681
+ sqlParams.push(totalLimit);
682
+ }
683
+ return { sql, params: sqlParams };
684
+ }
685
+ function compileExpandHydrate(table, targetUids) {
686
+ if (targetUids.length === 0) {
687
+ throw new FiregraphError(
688
+ "compileExpandHydrate requires a non-empty target list \u2014 empty IN () is invalid SQL.",
689
+ "INVALID_QUERY"
690
+ );
691
+ }
692
+ const placeholders = targetUids.map(() => "?").join(", ");
693
+ const sqlParams = [NODE_RELATION];
694
+ for (const uid of targetUids) sqlParams.push(uid);
695
+ const aUidCol = compileFieldRef("aUid").expr;
696
+ const bUidCol = compileFieldRef("bUid").expr;
697
+ const axbTypeCol = compileFieldRef("axbType").expr;
698
+ return {
699
+ sql: `SELECT * FROM ${quoteIdent2(table)} WHERE ${axbTypeCol} = ? AND ${aUidCol} = ${bUidCol} AND ${bUidCol} IN (${placeholders})`,
700
+ params: sqlParams
701
+ };
702
+ }
703
+ function compileSelectByDocId(table, docId) {
704
+ return {
705
+ sql: `SELECT * FROM ${quoteIdent2(table)} WHERE "doc_id" = ? LIMIT 1`,
706
+ params: [docId]
707
+ };
708
+ }
709
+ function normalizeProjectionField(field) {
710
+ if (field in FIELD_TO_COLUMN) return field;
711
+ if (field === "data" || field.startsWith("data.")) return field;
712
+ return `data.${field}`;
713
+ }
714
+ function compileFindEdgesProjected(table, select, filters, options) {
715
+ if (select.length === 0) {
716
+ throw new FiregraphError(
717
+ "compileFindEdgesProjected requires a non-empty select list \u2014 an empty projection has no SQL representation distinct from `findEdges`.",
718
+ "INVALID_QUERY"
719
+ );
720
+ }
721
+ const seen = /* @__PURE__ */ new Set();
722
+ const uniqueFields = [];
723
+ for (const f of select) {
724
+ if (!seen.has(f)) {
725
+ seen.add(f);
726
+ uniqueFields.push(f);
727
+ }
728
+ }
729
+ const projections = [];
730
+ const columns = [];
731
+ for (let idx = 0; idx < uniqueFields.length; idx++) {
732
+ const field = uniqueFields[idx];
733
+ const canonical = normalizeProjectionField(field);
734
+ const { expr } = compileFieldRef(canonical);
735
+ const alias = quoteColumnAlias(field);
736
+ projections.push(`${expr} AS ${alias}`);
737
+ let kind;
738
+ let typeAliasName;
739
+ if (canonical === "data") {
740
+ kind = "data";
741
+ } else if (canonical.startsWith("data.")) {
742
+ kind = "json";
743
+ typeAliasName = `__fg_t_${idx}`;
744
+ const typeAlias = quoteColumnAlias(typeAliasName);
745
+ projections.push(`json_type("data", '$.${canonical.slice(5)}') AS ${typeAlias}`);
746
+ } else {
747
+ if (canonical === "v") kind = "builtin-int";
748
+ else if (canonical === "createdAt" || canonical === "updatedAt") kind = "builtin-timestamp";
749
+ else kind = "builtin-text";
750
+ }
751
+ columns.push({ field, kind, typeAlias: typeAliasName });
752
+ }
753
+ const params = [];
754
+ const conditions = [];
755
+ for (const f of filters) {
756
+ conditions.push(compileFilter(f, params));
757
+ }
758
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
759
+ let sql = `SELECT ${projections.join(", ")} FROM ${quoteIdent2(table)}${where}`;
760
+ sql += compileOrderBy(options, params);
761
+ sql += compileLimit(options, params);
762
+ return { stmt: { sql, params }, columns };
763
+ }
764
+ function decodeProjectedRow(row, columns) {
765
+ const out = {};
766
+ for (const c of columns) {
767
+ const raw = row[c.field];
768
+ switch (c.kind) {
769
+ case "builtin-text":
770
+ out[c.field] = raw === null || raw === void 0 ? null : String(raw);
771
+ break;
772
+ case "builtin-int":
773
+ if (raw === null || raw === void 0) {
774
+ out[c.field] = null;
775
+ } else if (typeof raw === "bigint") {
776
+ out[c.field] = Number(raw);
777
+ } else if (typeof raw === "number") {
778
+ out[c.field] = raw;
779
+ } else {
780
+ out[c.field] = Number(raw);
781
+ }
782
+ break;
783
+ case "builtin-timestamp": {
784
+ const ms = rowTimestampToMillis(raw);
785
+ out[c.field] = GraphTimestampImpl.fromMillis(ms);
786
+ break;
787
+ }
788
+ case "data":
789
+ if (raw === null || raw === void 0 || raw === "") {
790
+ out[c.field] = {};
791
+ } else {
792
+ out[c.field] = JSON.parse(raw);
793
+ }
794
+ break;
795
+ case "json": {
796
+ const t = row[c.typeAlias];
797
+ if (raw === null || raw === void 0) {
798
+ out[c.field] = null;
799
+ } else if (t === "object" || t === "array") {
800
+ out[c.field] = typeof raw === "string" ? JSON.parse(raw) : raw;
801
+ } else if (t === "integer" && typeof raw === "bigint") {
802
+ out[c.field] = Number(raw);
803
+ } else {
804
+ out[c.field] = raw;
805
+ }
806
+ break;
807
+ }
808
+ }
809
+ }
810
+ return out;
811
+ }
812
+ function compileAggregate(table, spec, filters) {
813
+ const aliases = Object.keys(spec);
814
+ if (aliases.length === 0) {
815
+ throw new FiregraphError(
816
+ "aggregate() requires at least one aggregation in the `aggregates` map.",
817
+ "INVALID_QUERY"
818
+ );
819
+ }
820
+ const projections = [];
821
+ for (const alias of aliases) {
822
+ const { op, field } = spec[alias];
823
+ validateJsonPathKey(alias, BACKEND_ERR_LABEL);
824
+ if (op === "count") {
825
+ if (field !== void 0) {
826
+ throw new FiregraphError(
827
+ `Aggregate '${alias}' op 'count' must not specify a field \u2014 count operates on rows, not a column expression.`,
828
+ "INVALID_QUERY"
829
+ );
830
+ }
831
+ projections.push(`COUNT(*) AS ${quoteIdent2(alias)}`);
832
+ continue;
833
+ }
834
+ if (!field) {
835
+ throw new FiregraphError(
836
+ `Aggregate '${alias}' op '${op}' requires a field.`,
837
+ "INVALID_QUERY"
838
+ );
839
+ }
840
+ const { expr } = compileFieldRef(field);
841
+ const numeric = `CAST(${expr} AS REAL)`;
842
+ if (op === "sum") projections.push(`SUM(${numeric}) AS ${quoteIdent2(alias)}`);
843
+ else if (op === "avg") projections.push(`AVG(${numeric}) AS ${quoteIdent2(alias)}`);
844
+ else if (op === "min") projections.push(`MIN(${numeric}) AS ${quoteIdent2(alias)}`);
845
+ else if (op === "max") projections.push(`MAX(${numeric}) AS ${quoteIdent2(alias)}`);
846
+ else
847
+ throw new FiregraphError(
848
+ `SQLite backend does not support aggregate op: ${String(op)}`,
849
+ "INVALID_QUERY"
850
+ );
851
+ }
852
+ const params = [];
853
+ const conditions = [];
854
+ for (const f of filters) {
855
+ conditions.push(compileFilter(f, params));
856
+ }
857
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
858
+ const sql = `SELECT ${projections.join(", ")} FROM ${quoteIdent2(table)}${where}`;
859
+ return { stmt: { sql, params }, aliases };
860
+ }
861
+ function compileSet(table, docId, record, nowMillis, mode) {
862
+ assertJsonSafePayload(record.data, BACKEND_LABEL);
863
+ if (mode === "replace") {
864
+ const sql2 = `INSERT OR REPLACE INTO ${quoteIdent2(table)} (
865
+ doc_id, a_type, a_uid, axb_type, b_type, b_uid, data, v, created_at, updated_at
866
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
867
+ const params = [
868
+ docId,
869
+ record.aType,
870
+ record.aUid,
871
+ record.axbType,
872
+ record.bType,
873
+ record.bUid,
874
+ JSON.stringify(record.data ?? {}),
875
+ record.v ?? null,
876
+ nowMillis,
877
+ nowMillis
878
+ ];
879
+ return { sql: sql2, params };
880
+ }
881
+ const insertParams = [
882
+ docId,
883
+ record.aType,
884
+ record.aUid,
885
+ record.axbType,
886
+ record.bType,
887
+ record.bUid,
888
+ JSON.stringify(record.data ?? {}),
889
+ record.v ?? null,
890
+ nowMillis,
891
+ nowMillis
892
+ ];
893
+ const ops = flattenPatch(record.data ?? {});
894
+ const updateParams = [];
895
+ const dataExpr = compileDataOpsExpr(ops, `COALESCE("data", '{}')`, updateParams, BACKEND_ERR_LABEL) ?? `COALESCE("data", '{}')`;
896
+ const sql = `INSERT INTO ${quoteIdent2(table)} (
897
+ doc_id, a_type, a_uid, axb_type, b_type, b_uid, data, v, created_at, updated_at
898
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
899
+ ON CONFLICT(doc_id) DO UPDATE SET
900
+ "a_type" = excluded."a_type",
901
+ "a_uid" = excluded."a_uid",
902
+ "axb_type" = excluded."axb_type",
903
+ "b_type" = excluded."b_type",
904
+ "b_uid" = excluded."b_uid",
905
+ "data" = ${dataExpr},
906
+ "v" = COALESCE(excluded."v", "v"),
907
+ "created_at" = excluded."created_at",
908
+ "updated_at" = excluded."updated_at"`;
909
+ return { sql, params: [...insertParams, ...updateParams] };
910
+ }
911
+ function compileUpdate(table, docId, update, nowMillis) {
912
+ assertUpdatePayloadExclusive(update);
913
+ const setClauses = [];
914
+ const params = [];
915
+ if (update.replaceData) {
916
+ assertJsonSafePayload(update.replaceData, BACKEND_LABEL);
917
+ setClauses.push(`"data" = ?`);
918
+ params.push(JSON.stringify(update.replaceData));
919
+ } else if (update.dataOps && update.dataOps.length > 0) {
920
+ for (const op of update.dataOps) {
921
+ if (!op.delete) assertJsonSafePayload(op.value, BACKEND_LABEL);
922
+ }
923
+ const expr = compileDataOpsExpr(
924
+ update.dataOps,
925
+ `COALESCE("data", '{}')`,
926
+ params,
927
+ BACKEND_ERR_LABEL
928
+ );
929
+ if (expr !== null) {
930
+ setClauses.push(`"data" = ${expr}`);
931
+ }
932
+ }
933
+ if (update.v !== void 0) {
934
+ setClauses.push(`"v" = ?`);
935
+ params.push(update.v);
936
+ }
937
+ setClauses.push(`"updated_at" = ?`);
938
+ params.push(nowMillis);
939
+ params.push(docId);
940
+ return {
941
+ sql: `UPDATE ${quoteIdent2(table)} SET ${setClauses.join(", ")} WHERE "doc_id" = ?`,
942
+ params
943
+ };
944
+ }
945
+ function compileDelete(table, docId) {
946
+ return {
947
+ sql: `DELETE FROM ${quoteIdent2(table)} WHERE "doc_id" = ?`,
948
+ params: [docId]
949
+ };
950
+ }
951
+ function compileBulkDelete(table, filters) {
952
+ const params = [];
953
+ const conditions = [];
954
+ for (const f of filters) {
955
+ conditions.push(compileFilter(f, params));
956
+ }
957
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
958
+ return {
959
+ sql: `DELETE FROM ${quoteIdent2(table)}${where}`,
960
+ params
961
+ };
962
+ }
963
+ function compileBulkUpdate(table, filters, patchData, nowMillis) {
964
+ const dataOps = flattenPatch(patchData);
965
+ if (dataOps.length === 0) {
966
+ throw new FiregraphError(
967
+ "bulkUpdate() patch.data must contain at least one leaf \u2014 an empty patch would only rewrite `updated_at`, which is almost certainly a bug. Use `setDoc` with merge mode if you want to stamp without editing data.",
968
+ "INVALID_QUERY"
969
+ );
970
+ }
971
+ for (const op of dataOps) {
972
+ if (!op.delete) assertJsonSafePayload(op.value, BACKEND_LABEL);
973
+ }
974
+ const setParams = [];
975
+ const expr = compileDataOpsExpr(dataOps, `COALESCE("data", '{}')`, setParams, BACKEND_ERR_LABEL);
976
+ if (expr === null) {
977
+ throw new FiregraphError(
978
+ "bulkUpdate() patch produced no SQL operations \u2014 internal invariant violated.",
979
+ "INVALID_ARGUMENT"
980
+ );
981
+ }
982
+ const setClauses = [`"data" = ${expr}`, `"updated_at" = ?`];
983
+ setParams.push(nowMillis);
984
+ const whereParams = [];
985
+ const conditions = [];
986
+ for (const f of filters) {
987
+ conditions.push(compileFilter(f, whereParams));
988
+ }
989
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
990
+ return {
991
+ sql: `UPDATE ${quoteIdent2(table)} SET ${setClauses.join(", ")}${where}`,
992
+ params: [...setParams, ...whereParams]
993
+ };
994
+ }
995
+ function rowToRecord(row) {
996
+ const dataString = row.data;
997
+ const data = dataString ? JSON.parse(dataString) : {};
998
+ const createdMs = rowTimestampToMillis(row.created_at);
999
+ const updatedMs = rowTimestampToMillis(row.updated_at);
1000
+ const record = {
1001
+ aType: row.a_type,
1002
+ aUid: row.a_uid,
1003
+ axbType: row.axb_type,
1004
+ bType: row.b_type,
1005
+ bUid: row.b_uid,
1006
+ data,
1007
+ createdAt: GraphTimestampImpl.fromMillis(createdMs),
1008
+ updatedAt: GraphTimestampImpl.fromMillis(updatedMs)
1009
+ };
1010
+ if (row.v !== null && row.v !== void 0) {
1011
+ record.v = Number(row.v);
1012
+ }
1013
+ return record;
1014
+ }
1015
+ function rowTimestampToMillis(value) {
1016
+ if (typeof value === "number") return value;
1017
+ if (typeof value === "bigint") return Number(value);
1018
+ if (typeof value === "string") {
1019
+ const n = Number(value);
1020
+ if (Number.isFinite(n)) return n;
1021
+ }
1022
+ throw new FiregraphError(
1023
+ `SQLite row has non-numeric timestamp column: ${typeof value} (${String(value)})`,
1024
+ "INVALID_QUERY"
1025
+ );
1026
+ }
1027
+
1028
+ // src/internal/sqlite-search.ts
1029
+ var VECTOR_DISTANCE_UDF = "firegraph_vector_distance";
1030
+ var DISTANCE_ALIAS = "__fg_distance";
1031
+ var BACKEND_ERR_LABEL2 = "SQLite backend";
1032
+ var ENVELOPE_FIELDS = /* @__PURE__ */ new Set([
1033
+ "aType",
1034
+ "aUid",
1035
+ "axbType",
1036
+ "bType",
1037
+ "bUid",
1038
+ "createdAt",
1039
+ "updatedAt",
1040
+ "v"
1041
+ ]);
1042
+ function ftsTableName(table) {
1043
+ return `${table}_fts`;
1044
+ }
1045
+ function ftsMapTableName(table) {
1046
+ return `${table}_fts_map`;
1047
+ }
1048
+ function textExtractionExpr(dataRef) {
1049
+ return `(SELECT coalesce(group_concat("value", ' '), '') FROM json_tree(coalesce(${dataRef}, '{}')) WHERE "type" = 'text')`;
1050
+ }
1051
+ function buildFtsDDL(table) {
1052
+ const t = quoteIdent2(table);
1053
+ const fts = quoteIdent2(ftsTableName(table));
1054
+ const map = quoteIdent2(ftsMapTableName(table));
1055
+ const mappedId = `(SELECT "id" FROM ${map} WHERE "doc_id" = new."doc_id")`;
1056
+ const reindexBody = ` INSERT INTO ${map} ("doc_id") SELECT new."doc_id" WHERE NOT EXISTS (SELECT 1 FROM ${map} WHERE "doc_id" = new."doc_id");
1057
+ DELETE FROM ${fts} WHERE rowid = ${mappedId};
1058
+ INSERT INTO ${fts} (rowid, "text") VALUES (${mappedId}, ${textExtractionExpr('new."data"')});
1059
+ `;
1060
+ return [
1061
+ `CREATE TABLE IF NOT EXISTS ${map} (
1062
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
1063
+ "doc_id" TEXT NOT NULL UNIQUE
1064
+ )`,
1065
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${fts} USING fts5("text")`,
1066
+ `CREATE TRIGGER IF NOT EXISTS ${quoteIdent2(`${table}_fts_ai`)} AFTER INSERT ON ${t} BEGIN
1067
+ ${reindexBody}END`,
1068
+ `CREATE TRIGGER IF NOT EXISTS ${quoteIdent2(`${table}_fts_au`)} AFTER UPDATE ON ${t} BEGIN
1069
+ ${reindexBody}END`,
1070
+ `CREATE TRIGGER IF NOT EXISTS ${quoteIdent2(`${table}_fts_ad`)} AFTER DELETE ON ${t} BEGIN
1071
+ DELETE FROM ${fts} WHERE rowid = (SELECT "id" FROM ${map} WHERE "doc_id" = old."doc_id");
1072
+ DELETE FROM ${map} WHERE "doc_id" = old."doc_id";
1073
+ END`
1074
+ ];
1075
+ }
1076
+ function buildFtsSyncStatements(table) {
1077
+ const t = quoteIdent2(table);
1078
+ const fts = quoteIdent2(ftsTableName(table));
1079
+ const map = quoteIdent2(ftsMapTableName(table));
1080
+ return [
1081
+ `DELETE FROM ${fts} WHERE rowid IN (
1082
+ SELECT m."id" FROM ${map} m LEFT JOIN ${t} t ON t."doc_id" = m."doc_id"
1083
+ WHERE t."doc_id" IS NULL
1084
+ )`,
1085
+ `DELETE FROM ${map} WHERE "doc_id" NOT IN (SELECT "doc_id" FROM ${t})`,
1086
+ `INSERT OR IGNORE INTO ${map} ("doc_id") SELECT "doc_id" FROM ${t}`,
1087
+ `INSERT INTO ${fts} (rowid, "text")
1088
+ SELECT m."id", ${textExtractionExpr('t."data"')}
1089
+ FROM ${t} t JOIN ${map} m ON m."doc_id" = t."doc_id"
1090
+ WHERE m."id" NOT IN (SELECT rowid FROM ${fts})`
1091
+ ];
1092
+ }
1093
+ function buildLocalSearchDDL(table) {
1094
+ return [...buildFtsDDL(table), ...buildFtsSyncStatements(table)];
1095
+ }
1096
+ function normalizeVectorFieldPath(label, field) {
1097
+ if (ENVELOPE_FIELDS.has(field)) {
1098
+ throw new FiregraphError(
1099
+ `findNearest(): ${label} '${field}' is a built-in envelope field \u2014 vectors must live under \`data.*\`. Use a path like 'data.${field}' if you really meant a nested data field.`,
1100
+ "INVALID_QUERY"
1101
+ );
1102
+ }
1103
+ if (field === "data" || field.startsWith("data.")) return field;
1104
+ return `data.${field}`;
1105
+ }
1106
+ function normalizeFullTextFieldPath(field) {
1107
+ if (ENVELOPE_FIELDS.has(field)) {
1108
+ throw new FiregraphError(
1109
+ `fullTextSearch(): field '${field}' is a built-in envelope field \u2014 text-indexed fields must live under \`data.*\`. Use a path like 'data.${field}' if you really meant a nested data field.`,
1110
+ "INVALID_QUERY"
1111
+ );
1112
+ }
1113
+ if (field === "data" || field.startsWith("data.")) return field;
1114
+ return `data.${field}`;
1115
+ }
1116
+ function buildSearchFilters(params) {
1117
+ const filters = [];
1118
+ if (params.aType) filters.push({ field: "aType", op: "==", value: params.aType });
1119
+ if (params.axbType) filters.push({ field: "axbType", op: "==", value: params.axbType });
1120
+ if (params.bType) filters.push({ field: "bType", op: "==", value: params.bType });
1121
+ for (const clause of params.where ?? []) {
1122
+ const field = ENVELOPE_FIELDS.has(clause.field) || clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
1123
+ filters.push({ field, op: clause.op, value: clause.value });
1124
+ }
1125
+ return filters;
1126
+ }
1127
+ function compileFullTextSearch(table, params) {
1128
+ if (typeof params.query !== "string" || params.query.length === 0) {
1129
+ throw new FiregraphError(
1130
+ "fullTextSearch(): query must be a non-empty string.",
1131
+ "INVALID_QUERY"
1132
+ );
1133
+ }
1134
+ if (!Number.isInteger(params.limit) || params.limit <= 0) {
1135
+ throw new FiregraphError(
1136
+ `fullTextSearch(): limit must be a positive integer (got ${params.limit}).`,
1137
+ "INVALID_QUERY"
1138
+ );
1139
+ }
1140
+ const normalizedFields = params.fields?.map((f) => normalizeFullTextFieldPath(f));
1141
+ if (normalizedFields !== void 0 && normalizedFields.length > 0) {
1142
+ throw new FiregraphError(
1143
+ "fullTextSearch(): the `fields` option is not yet supported \u2014 the local SQLite FTS index stores one combined text column per record. Omit `fields` to search all string values.",
1144
+ "INVALID_QUERY"
1145
+ );
1146
+ }
1147
+ const t = quoteIdent2(table);
1148
+ const fts = quoteIdent2(ftsTableName(table));
1149
+ const map = quoteIdent2(ftsMapTableName(table));
1150
+ const sqlParams = [params.query];
1151
+ const conditions = [`${fts} MATCH ?`];
1152
+ conditions.push(...compileFilterConditions(buildSearchFilters(params), sqlParams));
1153
+ sqlParams.push(params.limit);
1154
+ const sql = `SELECT ${t}.* FROM ${fts} JOIN ${map} ON ${map}."id" = ${fts}.rowid JOIN ${t} ON ${t}."doc_id" = ${map}."doc_id" WHERE ${conditions.join(" AND ")} ORDER BY bm25(${fts}) ASC, ${t}."doc_id" ASC LIMIT ?`;
1155
+ return { sql, params: sqlParams };
1156
+ }
1157
+ var DISTANCE_MEASURES = /* @__PURE__ */ new Set(["EUCLIDEAN", "COSINE", "DOT_PRODUCT"]);
1158
+ function toNumberArray(qv) {
1159
+ if (Array.isArray(qv)) return qv;
1160
+ if (typeof qv.toArray === "function") {
1161
+ return qv.toArray();
1162
+ }
1163
+ throw new FiregraphError(
1164
+ "findNearest(): queryVector must be a number[] or a Firestore VectorValue.",
1165
+ "INVALID_QUERY"
1166
+ );
1167
+ }
1168
+ function compileFindNearest(table, params) {
1169
+ const vec = toNumberArray(params.queryVector);
1170
+ if (vec.length === 0) {
1171
+ throw new FiregraphError(
1172
+ "findNearest(): queryVector is empty \u2014 at least one dimension is required.",
1173
+ "INVALID_QUERY"
1174
+ );
1175
+ }
1176
+ if (!Number.isInteger(params.limit) || params.limit <= 0 || params.limit > 1e3) {
1177
+ throw new FiregraphError(
1178
+ `findNearest(): limit must be a positive integer \u2264 1000 (got ${params.limit}).`,
1179
+ "INVALID_QUERY"
1180
+ );
1181
+ }
1182
+ if (!DISTANCE_MEASURES.has(params.distanceMeasure)) {
1183
+ throw new FiregraphError(
1184
+ `findNearest(): unknown distanceMeasure '${String(params.distanceMeasure)}' \u2014 expected EUCLIDEAN, COSINE, or DOT_PRODUCT.`,
1185
+ "INVALID_QUERY"
1186
+ );
1187
+ }
1188
+ const vectorField = normalizeVectorFieldPath("vectorField", params.vectorField);
1189
+ let vectorExpr;
1190
+ if (vectorField === "data") {
1191
+ vectorExpr = '"data"';
1192
+ } else {
1193
+ const suffix = vectorField.slice("data.".length);
1194
+ for (const part of suffix.split(".")) {
1195
+ validateJsonPathKey(part, BACKEND_ERR_LABEL2);
1196
+ }
1197
+ vectorExpr = `json_extract("data", '$.${suffix}')`;
1198
+ }
1199
+ let distancePath = null;
1200
+ if (params.distanceResultField !== void 0) {
1201
+ const normalized = normalizeVectorFieldPath("distanceResultField", params.distanceResultField);
1202
+ if (normalized === "data") {
1203
+ throw new FiregraphError(
1204
+ `findNearest(): distanceResultField 'data' would replace the entire data payload \u2014 use a nested path like 'data.distance'.`,
1205
+ "INVALID_QUERY"
1206
+ );
1207
+ }
1208
+ distancePath = normalized.slice("data.".length).split(".");
1209
+ for (const part of distancePath) {
1210
+ validateJsonPathKey(part, BACKEND_ERR_LABEL2);
1211
+ }
1212
+ }
1213
+ const sqlParams = [JSON.stringify(vec), params.distanceMeasure];
1214
+ const conditions = compileFilterConditions(buildSearchFilters(params), sqlParams);
1215
+ const innerWhere = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
1216
+ const dist = quoteIdent2(DISTANCE_ALIAS);
1217
+ const descending = params.distanceMeasure === "DOT_PRODUCT";
1218
+ let sql = `SELECT * FROM (SELECT *, ${VECTOR_DISTANCE_UDF}(${vectorExpr}, ?, ?) AS ${dist} FROM ${quoteIdent2(table)}${innerWhere}) WHERE ${dist} IS NOT NULL`;
1219
+ if (params.distanceThreshold !== void 0) {
1220
+ sql += ` AND ${dist} ${descending ? ">=" : "<="} ?`;
1221
+ sqlParams.push(params.distanceThreshold);
1222
+ }
1223
+ sql += ` ORDER BY ${dist} ${descending ? "DESC" : "ASC"}, "doc_id" ASC LIMIT ?`;
1224
+ sqlParams.push(params.limit);
1225
+ return { stmt: { sql, params: sqlParams }, distancePath };
1226
+ }
1227
+ var memoQueryJson = null;
1228
+ var memoQueryVec = null;
1229
+ function computeVectorDistance(storedJson, queryJson, measure) {
1230
+ if (typeof storedJson !== "string" || typeof queryJson !== "string" || typeof measure !== "string") {
1231
+ return null;
1232
+ }
1233
+ let query;
1234
+ if (memoQueryJson === queryJson && memoQueryVec !== null) {
1235
+ query = memoQueryVec;
1236
+ } else {
1237
+ let parsed;
1238
+ try {
1239
+ parsed = JSON.parse(queryJson);
1240
+ } catch {
1241
+ return null;
1242
+ }
1243
+ if (!Array.isArray(parsed)) return null;
1244
+ query = parsed;
1245
+ memoQueryJson = queryJson;
1246
+ memoQueryVec = query;
1247
+ }
1248
+ let stored;
1249
+ try {
1250
+ stored = JSON.parse(storedJson);
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ if (!Array.isArray(stored) || stored.length !== query.length) return null;
1255
+ let dot = 0;
1256
+ let sumSq = 0;
1257
+ let normStored = 0;
1258
+ let normQuery = 0;
1259
+ for (let i = 0; i < query.length; i++) {
1260
+ const a = stored[i];
1261
+ const b = query[i];
1262
+ if (typeof a !== "number" || !Number.isFinite(a)) return null;
1263
+ if (typeof b !== "number" || !Number.isFinite(b)) return null;
1264
+ dot += a * b;
1265
+ const diff = a - b;
1266
+ sumSq += diff * diff;
1267
+ normStored += a * a;
1268
+ normQuery += b * b;
1269
+ }
1270
+ let result;
1271
+ switch (measure) {
1272
+ case "EUCLIDEAN":
1273
+ result = Math.sqrt(sumSq);
1274
+ break;
1275
+ case "COSINE": {
1276
+ const denom = Math.sqrt(normStored) * Math.sqrt(normQuery);
1277
+ if (denom === 0) return null;
1278
+ result = 1 - dot / denom;
1279
+ break;
1280
+ }
1281
+ case "DOT_PRODUCT":
1282
+ result = dot;
1283
+ break;
1284
+ default:
1285
+ return null;
1286
+ }
1287
+ return Number.isFinite(result) ? result : null;
1288
+ }
1289
+ function setDataPath(data, path, value) {
1290
+ let cursor = data;
1291
+ for (let i = 0; i < path.length - 1; i++) {
1292
+ const key = path[i];
1293
+ const next = cursor[key];
1294
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
1295
+ const created = {};
1296
+ cursor[key] = created;
1297
+ cursor = created;
1298
+ } else {
1299
+ cursor = next;
1300
+ }
1301
+ }
1302
+ cursor[path[path.length - 1]] = value;
1303
+ }
1304
+ function findOrphanedFtsTables(allTables, catalogTables, rootTable) {
1305
+ const names = new Set(allTables);
1306
+ const liveGraphTables = new Set(catalogTables);
1307
+ const subgraphPrefix = `${rootTable}_g_`;
1308
+ const orphans = [];
1309
+ for (const name of names) {
1310
+ let base = null;
1311
+ if (name.endsWith("_fts_map")) base = name.slice(0, -"_fts_map".length);
1312
+ else if (name.endsWith("_fts")) base = name.slice(0, -"_fts".length);
1313
+ if (base === null || !base.startsWith(subgraphPrefix)) continue;
1314
+ if (liveGraphTables.has(name)) continue;
1315
+ if (names.has(base)) continue;
1316
+ orphans.push(name);
1317
+ }
1318
+ return orphans.sort();
1319
+ }
1320
+
1321
+ // src/docid.ts
1322
+ var import_node_crypto = require("crypto");
1323
+ function computeNodeDocId(uid) {
1324
+ return uid;
1325
+ }
1326
+ function computeEdgeDocId(aUid, axbType, bUid) {
1327
+ const composite = `${aUid}${SHARD_SEPARATOR}${axbType}${SHARD_SEPARATOR}${bUid}`;
1328
+ const hash = (0, import_node_crypto.createHash)("sha256").update(composite).digest("hex");
1329
+ const shard = hash[0];
1330
+ return `${shard}${SHARD_SEPARATOR}${aUid}${SHARD_SEPARATOR}${axbType}${SHARD_SEPARATOR}${bUid}`;
1331
+ }
1332
+
1333
+ // src/sqlite/catalog.ts
1334
+ function catalogTableName(rootTable) {
1335
+ validateTableName(rootTable);
1336
+ return `${rootTable}_graphs`;
1337
+ }
1338
+ function mangleStorageScope(scope) {
1339
+ let out = "";
1340
+ for (const ch of scope) {
1341
+ if (/[A-Za-z0-9]/.test(ch)) out += ch;
1342
+ else if (ch === "_") out += "__";
1343
+ else if (ch === "-") out += "_h";
1344
+ else if (ch === "/") out += "_s";
1345
+ else out += `_u${ch.codePointAt(0).toString(16)}_`;
1346
+ }
1347
+ return out;
1348
+ }
1349
+ function tableForScope(rootTable, storageScope) {
1350
+ validateTableName(rootTable);
1351
+ if (storageScope === "") return rootTable;
1352
+ return `${rootTable}_g_${mangleStorageScope(storageScope)}`;
1353
+ }
1354
+ function escapeLikePrefix(prefix) {
1355
+ return prefix.replace(/[\\%_]/g, (c) => `\\${c}`);
1356
+ }
1357
+ function buildCatalogDDL(rootTable) {
1358
+ const t = quoteIdent2(catalogTableName(rootTable));
1359
+ return `CREATE TABLE IF NOT EXISTS ${t} (
1360
+ storage_scope TEXT NOT NULL PRIMARY KEY,
1361
+ table_name TEXT NOT NULL UNIQUE,
1362
+ scope_path TEXT NOT NULL
1363
+ )`;
1364
+ }
1365
+ function compileCatalogRegister(rootTable, storageScope, tableName, scopePath) {
1366
+ const t = quoteIdent2(catalogTableName(rootTable));
1367
+ return {
1368
+ sql: `INSERT OR IGNORE INTO ${t} (storage_scope, table_name, scope_path) VALUES (?, ?, ?)`,
1369
+ params: [storageScope, tableName, scopePath]
1370
+ };
1371
+ }
1372
+ function compileCatalogDescendants(rootTable, scopePrefix) {
1373
+ const t = quoteIdent2(catalogTableName(rootTable));
1374
+ return {
1375
+ sql: `SELECT storage_scope, table_name FROM ${t} WHERE storage_scope LIKE ? ESCAPE '\\' ORDER BY storage_scope`,
1376
+ params: [`${escapeLikePrefix(scopePrefix)}/%`]
1377
+ };
1378
+ }
1379
+ function compileCatalogDelete(rootTable, storageScope) {
1380
+ const t = quoteIdent2(catalogTableName(rootTable));
1381
+ return {
1382
+ sql: `DELETE FROM ${t} WHERE storage_scope = ?`,
1383
+ params: [storageScope]
1384
+ };
1385
+ }
1386
+
1387
+ // src/sqlite/backend.ts
1388
+ var DEFAULT_MAX_RETRIES = 3;
1389
+ var BASE_RETRY_DELAY_MS = 200;
1390
+ var MAX_RETRY_DELAY_MS = 5e3;
1391
+ function sleep(ms) {
1392
+ return new Promise((resolve) => setTimeout(resolve, ms));
1393
+ }
1394
+ function minDefined(a, b) {
1395
+ if (a === void 0) return b;
1396
+ if (b === void 0) return a;
1397
+ return Math.min(a, b);
1398
+ }
1399
+ function chunkStatements(statements, maxStatements, maxParams) {
1400
+ const stmtCap = maxStatements && maxStatements > 0 && Number.isFinite(maxStatements) ? Math.floor(maxStatements) : Infinity;
1401
+ const paramCap = maxParams && maxParams > 0 && Number.isFinite(maxParams) ? Math.floor(maxParams) : Infinity;
1402
+ if (stmtCap === Infinity && paramCap === Infinity) {
1403
+ return [statements];
1404
+ }
1405
+ const chunks = [];
1406
+ let current = [];
1407
+ let currentParamCount = 0;
1408
+ for (const stmt of statements) {
1409
+ const stmtParams = stmt.params.length;
1410
+ const wouldExceedStmt = current.length + 1 > stmtCap;
1411
+ const wouldExceedParam = currentParamCount + stmtParams > paramCap;
1412
+ if (current.length > 0 && (wouldExceedStmt || wouldExceedParam)) {
1413
+ chunks.push(current);
1414
+ current = [];
1415
+ currentParamCount = 0;
1416
+ }
1417
+ current.push(stmt);
1418
+ currentParamCount += stmtParams;
1419
+ }
1420
+ if (current.length > 0) chunks.push(current);
1421
+ return chunks;
1422
+ }
1423
+ var SqliteTransactionBackendImpl = class {
1424
+ constructor(tx, tableName) {
1425
+ this.tx = tx;
1426
+ this.tableName = tableName;
1427
+ }
1428
+ async getDoc(docId) {
1429
+ const stmt = compileSelectByDocId(this.tableName, docId);
1430
+ const rows = await this.tx.all(stmt.sql, stmt.params);
1431
+ return rows.length === 0 ? null : rowToRecord(rows[0]);
1432
+ }
1433
+ async query(filters, options) {
1434
+ const stmt = compileSelect(this.tableName, filters, options);
1435
+ const rows = await this.tx.all(stmt.sql, stmt.params);
1436
+ return rows.map(rowToRecord);
1437
+ }
1438
+ async setDoc(docId, record, mode) {
1439
+ const stmt = compileSet(this.tableName, docId, record, Date.now(), mode);
1440
+ await this.tx.run(stmt.sql, stmt.params);
1441
+ }
1442
+ async updateDoc(docId, update) {
1443
+ const stmt = compileUpdate(this.tableName, docId, update, Date.now());
1444
+ const sqlWithReturning = `${stmt.sql} RETURNING "doc_id"`;
1445
+ const rows = await this.tx.all(sqlWithReturning, stmt.params);
1446
+ if (rows.length === 0) {
1447
+ throw new FiregraphError(
1448
+ `updateDoc: no document found for doc_id=${docId} (table=${this.tableName})`,
1449
+ "NOT_FOUND"
1450
+ );
1451
+ }
1452
+ }
1453
+ async deleteDoc(docId) {
1454
+ const stmt = compileDelete(this.tableName, docId);
1455
+ await this.tx.run(stmt.sql, stmt.params);
1456
+ }
1457
+ };
1458
+ var SqliteBatchBackendImpl = class {
1459
+ constructor(executor, tableName, ensureSchema) {
1460
+ this.executor = executor;
1461
+ this.tableName = tableName;
1462
+ this.ensureSchema = ensureSchema;
1463
+ }
1464
+ statements = [];
1465
+ setDoc(docId, record, mode) {
1466
+ this.statements.push(compileSet(this.tableName, docId, record, Date.now(), mode));
1467
+ }
1468
+ updateDoc(docId, update) {
1469
+ this.statements.push(compileUpdate(this.tableName, docId, update, Date.now()));
1470
+ }
1471
+ deleteDoc(docId) {
1472
+ this.statements.push(compileDelete(this.tableName, docId));
1473
+ }
1474
+ async commit() {
1475
+ if (this.statements.length === 0) return;
1476
+ await this.ensureSchema();
1477
+ await this.executor.batch(this.statements);
1478
+ this.statements.length = 0;
1479
+ }
1480
+ };
1481
+ var SQLITE_CORE_CAPS = [
1482
+ "core.read",
1483
+ "core.write",
1484
+ "core.batch",
1485
+ "core.subgraph",
1486
+ "query.aggregate",
1487
+ "query.dml",
1488
+ "query.join",
1489
+ "query.select",
1490
+ "raw.sql"
1491
+ ];
1492
+ var SqliteBackendImpl = class _SqliteBackendImpl {
1493
+ constructor(executor, rootTable, storageScope, scopePath, registry, coreIndexes, extraTableDDL) {
1494
+ this.executor = executor;
1495
+ validateTableName(rootTable);
1496
+ this.rootTable = rootTable;
1497
+ this.collectionPath = tableForScope(rootTable, storageScope);
1498
+ this.storageScope = storageScope;
1499
+ this.scopePath = scopePath;
1500
+ this.registry = registry;
1501
+ this.coreIndexes = coreIndexes;
1502
+ this.extraTableDDL = extraTableDDL;
1503
+ const caps = new Set(SQLITE_CORE_CAPS);
1504
+ if (typeof executor.transaction === "function") {
1505
+ caps.add("core.transactions");
1506
+ }
1507
+ this.capabilities = createCapabilities(caps);
1508
+ }
1509
+ capabilities;
1510
+ /** Physical table holding this graph's triples. */
1511
+ collectionPath;
1512
+ scopePath;
1513
+ /** Storage scope (interleaved parent UIDs + subgraph names) — `''` at root. */
1514
+ storageScope;
1515
+ /** Root graph's table name — prefix for subgraph tables and the catalog. */
1516
+ rootTable;
1517
+ registry;
1518
+ coreIndexes;
1519
+ extraTableDDL;
1520
+ ensured = null;
1521
+ /**
1522
+ * Lazily create this graph's table + indexes + the catalog, and register
1523
+ * the graph in the catalog. Runs once per backend instance; the DDL is
1524
+ * all `IF NOT EXISTS` / `INSERT OR IGNORE`, so concurrent instances over
1525
+ * the same database converge safely.
1526
+ */
1527
+ ensureSchema() {
1528
+ if (!this.ensured) {
1529
+ this.ensured = this.doEnsureSchema().catch((err) => {
1530
+ this.ensured = null;
1531
+ throw err;
1532
+ });
1533
+ }
1534
+ return this.ensured;
1535
+ }
1536
+ /** @internal See `SqliteStorageBackend.ensureReady`. */
1537
+ async ensureReady(force = false) {
1538
+ if (force) this.ensured = null;
1539
+ await this.ensureSchema();
1540
+ }
1541
+ async doEnsureSchema() {
1542
+ const ddl = [
1543
+ ...buildSchemaStatements(this.collectionPath, {
1544
+ coreIndexes: this.coreIndexes,
1545
+ registry: this.registry
1546
+ }),
1547
+ ...this.extraTableDDL ? this.extraTableDDL(this.collectionPath) : [],
1548
+ buildCatalogDDL(this.rootTable)
1549
+ ];
1550
+ const statements = ddl.map((sql) => ({ sql, params: [] }));
1551
+ statements.push(
1552
+ compileCatalogRegister(
1553
+ this.rootTable,
1554
+ this.storageScope,
1555
+ this.collectionPath,
1556
+ this.scopePath
1557
+ )
1558
+ );
1559
+ const chunks = chunkStatements(
1560
+ statements,
1561
+ this.executor.maxBatchSize,
1562
+ this.executor.maxBatchParams
1563
+ );
1564
+ for (const chunk of chunks) {
1565
+ await this.executor.batch(chunk);
1566
+ }
1567
+ }
1568
+ /**
1569
+ * Run `op` with the schema bootstrap applied, self-healing when this
1570
+ * graph's table was dropped out from under the instance — a parent's
1571
+ * cascade delete DROPs descendant tables, but subgraph handles created
1572
+ * before the cascade still point at this (now missing) table with a
1573
+ * resolved bootstrap cache. On a "no such table: <own table>" error the
1574
+ * cache resets, the empty graph is recreated, and the op retries once.
1575
+ * This matches Firestore semantics, where a deleted subcollection reads
1576
+ * as empty and writes recreate it.
1577
+ */
1578
+ async withSchema(op) {
1579
+ await this.ensureSchema();
1580
+ try {
1581
+ return await op();
1582
+ } catch (err) {
1583
+ if (!this.isMissingOwnTable(err)) throw err;
1584
+ this.ensured = null;
1585
+ await this.ensureSchema();
1586
+ return op();
1587
+ }
1588
+ }
1589
+ /** True when `err` is SQLite's missing-table error naming OUR table. */
1590
+ isMissingOwnTable(err) {
1591
+ const message = err instanceof Error ? err.message : String(err);
1592
+ return message.includes(`no such table: ${this.collectionPath}`);
1593
+ }
1594
+ // --- Reads ---
1595
+ async getDoc(docId) {
1596
+ return this.withSchema(async () => {
1597
+ const stmt = compileSelectByDocId(this.collectionPath, docId);
1598
+ const rows = await this.executor.all(stmt.sql, stmt.params);
1599
+ return rows.length === 0 ? null : rowToRecord(rows[0]);
1600
+ });
1601
+ }
1602
+ async query(filters, options) {
1603
+ return this.withSchema(async () => {
1604
+ const stmt = compileSelect(this.collectionPath, filters, options);
1605
+ const rows = await this.executor.all(stmt.sql, stmt.params);
1606
+ return rows.map(rowToRecord);
1607
+ });
1608
+ }
1609
+ // --- Writes ---
1610
+ async setDoc(docId, record, mode) {
1611
+ return this.withSchema(async () => {
1612
+ const stmt = compileSet(this.collectionPath, docId, record, Date.now(), mode);
1613
+ await this.executor.run(stmt.sql, stmt.params);
1614
+ });
1615
+ }
1616
+ async updateDoc(docId, update) {
1617
+ return this.withSchema(async () => {
1618
+ const stmt = compileUpdate(this.collectionPath, docId, update, Date.now());
1619
+ const sqlWithReturning = `${stmt.sql} RETURNING "doc_id"`;
1620
+ const rows = await this.executor.all(sqlWithReturning, stmt.params);
1621
+ if (rows.length === 0) {
1622
+ throw new FiregraphError(
1623
+ `updateDoc: no document found for doc_id=${docId} (table=${this.collectionPath})`,
1624
+ "NOT_FOUND"
1625
+ );
1626
+ }
1627
+ });
1628
+ }
1629
+ async deleteDoc(docId) {
1630
+ return this.withSchema(async () => {
1631
+ const stmt = compileDelete(this.collectionPath, docId);
1632
+ await this.executor.run(stmt.sql, stmt.params);
1633
+ });
1634
+ }
1635
+ // --- Transactions / Batches ---
1636
+ async runTransaction(fn) {
1637
+ if (!this.executor.transaction) {
1638
+ throw new FiregraphError(
1639
+ "Interactive transactions are not supported by this SQLite driver. D1 in particular has no read-then-conditional-write transactions; use a Durable Object SQLite client instead, or rewrite the code path as a batch().",
1640
+ "UNSUPPORTED_OPERATION"
1641
+ );
1642
+ }
1643
+ await this.ensureSchema();
1644
+ return this.executor.transaction(async (tx) => {
1645
+ const txBackend = new SqliteTransactionBackendImpl(tx, this.collectionPath);
1646
+ return fn(txBackend);
1647
+ });
1648
+ }
1649
+ createBatch() {
1650
+ return new SqliteBatchBackendImpl(
1651
+ this.executor,
1652
+ this.collectionPath,
1653
+ () => this.ensureSchema()
1654
+ );
1655
+ }
1656
+ // --- Subgraphs ---
1657
+ subgraph(parentNodeUid, name) {
1658
+ if (!parentNodeUid || parentNodeUid.includes("/")) {
1659
+ throw new FiregraphError(
1660
+ `Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
1661
+ "INVALID_SUBGRAPH"
1662
+ );
1663
+ }
1664
+ if (!name || name.includes("/")) {
1665
+ throw new FiregraphError(
1666
+ `Subgraph name must not contain "/" and must be non-empty: got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
1667
+ "INVALID_SUBGRAPH"
1668
+ );
1669
+ }
1670
+ const newStorageScope = this.storageScope ? `${this.storageScope}/${parentNodeUid}/${name}` : `${parentNodeUid}/${name}`;
1671
+ const newScope = this.scopePath ? `${this.scopePath}/${name}` : name;
1672
+ return new _SqliteBackendImpl(
1673
+ this.executor,
1674
+ this.rootTable,
1675
+ newStorageScope,
1676
+ newScope,
1677
+ this.registry,
1678
+ this.coreIndexes,
1679
+ this.extraTableDDL
1680
+ );
1681
+ }
1682
+ // --- Cascade & bulk ---
1683
+ async removeNodeCascade(uid, reader, options) {
1684
+ await this.ensureSchema();
1685
+ const [outgoingRaw, incomingRaw] = await Promise.all([
1686
+ reader.findEdges({ aUid: uid, allowCollectionScan: true, limit: 0 }),
1687
+ reader.findEdges({ bUid: uid, allowCollectionScan: true, limit: 0 })
1688
+ ]);
1689
+ const seen = /* @__PURE__ */ new Set();
1690
+ const edgeDocIds = [];
1691
+ for (const edge of [...outgoingRaw, ...incomingRaw]) {
1692
+ if (edge.axbType === NODE_RELATION) continue;
1693
+ const docId = computeEdgeDocId(edge.aUid, edge.axbType, edge.bUid);
1694
+ if (!seen.has(docId)) {
1695
+ seen.add(docId);
1696
+ edgeDocIds.push(docId);
1697
+ }
1698
+ }
1699
+ const nodeDocId = computeNodeDocId(uid);
1700
+ const shouldDeleteSubgraphs = options?.deleteSubcollections !== false;
1701
+ const descendants = [];
1702
+ let subgraphRowCount = 0;
1703
+ if (shouldDeleteSubgraphs) {
1704
+ const prefix = this.storageScope ? `${this.storageScope}/${uid}` : uid;
1705
+ const descStmt = compileCatalogDescendants(this.rootTable, prefix);
1706
+ const rows = await this.executor.all(descStmt.sql, descStmt.params);
1707
+ for (const row of rows) {
1708
+ const tableName = String(row.table_name);
1709
+ validateTableName(tableName);
1710
+ descendants.push({ storageScope: String(row.storage_scope), tableName });
1711
+ }
1712
+ for (const d of descendants) {
1713
+ const countRows = await this.executor.all(
1714
+ `SELECT COUNT(*) AS n FROM ${quoteIdent2(d.tableName)}`,
1715
+ []
1716
+ );
1717
+ const n = countRows[0]?.n;
1718
+ subgraphRowCount += typeof n === "bigint" ? Number(n) : Number(n ?? 0);
1719
+ }
1720
+ }
1721
+ const writeStatements = edgeDocIds.map(
1722
+ (id) => compileDelete(this.collectionPath, id)
1723
+ );
1724
+ writeStatements.push(compileDelete(this.collectionPath, nodeDocId));
1725
+ for (const d of descendants) {
1726
+ writeStatements.push({ sql: `DROP TABLE IF EXISTS ${quoteIdent2(d.tableName)}`, params: [] });
1727
+ writeStatements.push(compileCatalogDelete(this.rootTable, d.storageScope));
1728
+ }
1729
+ const {
1730
+ deleted: stmtDeleted,
1731
+ batches,
1732
+ errors
1733
+ } = await this.executeChunkedBatches(writeStatements, options);
1734
+ const allOk = errors.length === 0;
1735
+ const edgesDeleted = allOk ? edgeDocIds.length : 0;
1736
+ const nodeDeleted = allOk;
1737
+ const bookkeepingContribution = allOk ? descendants.length * 2 : 0;
1738
+ const deleted = stmtDeleted - bookkeepingContribution + (allOk ? subgraphRowCount : 0);
1739
+ return { deleted, batches, errors, edgesDeleted, nodeDeleted };
1740
+ }
1741
+ async bulkRemoveEdges(params, reader, options) {
1742
+ await this.ensureSchema();
1743
+ const effectiveParams = params.limit !== void 0 ? { ...params, allowCollectionScan: params.allowCollectionScan ?? true } : { ...params, limit: 0, allowCollectionScan: params.allowCollectionScan ?? true };
1744
+ const edges = await reader.findEdges(effectiveParams);
1745
+ const docIds = edges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
1746
+ if (docIds.length === 0) {
1747
+ return { deleted: 0, batches: 0, errors: [] };
1748
+ }
1749
+ const statements = docIds.map((id) => compileDelete(this.collectionPath, id));
1750
+ return this.executeChunkedBatches(statements, options);
1751
+ }
1752
+ /**
1753
+ * Submit `statements` to the executor as one or more `batch()` calls,
1754
+ * chunking by `executor.maxBatchSize` (e.g. D1's ~100-statement cap).
1755
+ * Drivers that don't advertise a cap submit everything in one batch,
1756
+ * preserving cross-batch atomicity.
1757
+ *
1758
+ * Each chunk is retried with exponential backoff up to `maxRetries`
1759
+ * (default 3) before being recorded in `errors`. The loop continues past
1760
+ * a permanently failed chunk so the caller still gets partial progress
1761
+ * visibility — to halt on first failure, set `maxRetries: 0` and check
1762
+ * `result.errors.length` after the call.
1763
+ *
1764
+ * Returns `BulkResult`-shaped fields. `deleted` reflects only the
1765
+ * statement count of *successfully committed* batches — a DROP TABLE
1766
+ * statement contributes 1 to that total even though it may remove many
1767
+ * rows; `removeNodeCascade` patches that up with pre-counted row totals.
1768
+ *
1769
+ * **Atomicity caveat (D1):** when chunking kicks in, atomicity is lost
1770
+ * across chunk boundaries — one chunk may commit while a later one fails.
1771
+ * `removeNodeCascade` is idempotent (deleting the same docs again is a
1772
+ * no-op) so a caller can simply retry on partial failure. `bulkRemoveEdges`
1773
+ * is also idempotent for the same reason. DO SQLite leaves `maxBatchSize`
1774
+ * unset, so everything funnels through one atomic `transactionSync` and
1775
+ * this caveat does not apply.
1776
+ */
1777
+ async executeChunkedBatches(statements, options) {
1778
+ if (statements.length === 0) {
1779
+ return { deleted: 0, batches: 0, errors: [] };
1780
+ }
1781
+ const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
1782
+ const callerBatchSize = options?.batchSize;
1783
+ const stmtCap = minDefined(callerBatchSize, this.executor.maxBatchSize);
1784
+ const chunks = chunkStatements(statements, stmtCap, this.executor.maxBatchParams);
1785
+ const errors = [];
1786
+ let deleted = 0;
1787
+ let batches = 0;
1788
+ const totalBatches = chunks.length;
1789
+ const driverParamCap = this.executor.maxBatchParams;
1790
+ for (let batchIndex = 0; batchIndex < chunks.length; batchIndex++) {
1791
+ const chunk = chunks[batchIndex];
1792
+ const isUnretriableOversize = chunk.length === 1 && driverParamCap !== void 0 && chunk[0].params.length > driverParamCap;
1793
+ let committed = false;
1794
+ let lastError = null;
1795
+ const effectiveRetries = isUnretriableOversize ? 0 : maxRetries;
1796
+ for (let attempt = 0; attempt <= effectiveRetries; attempt++) {
1797
+ try {
1798
+ await this.executor.batch(chunk);
1799
+ committed = true;
1800
+ break;
1801
+ } catch (err) {
1802
+ lastError = err instanceof Error ? err : new Error(String(err));
1803
+ if (attempt < effectiveRetries) {
1804
+ const delay = Math.min(BASE_RETRY_DELAY_MS * Math.pow(2, attempt), MAX_RETRY_DELAY_MS);
1805
+ await sleep(delay);
1806
+ }
1807
+ }
1808
+ }
1809
+ if (committed) {
1810
+ deleted += chunk.length;
1811
+ batches += 1;
1812
+ } else if (lastError) {
1813
+ errors.push({
1814
+ batchIndex,
1815
+ error: lastError,
1816
+ operationCount: chunk.length
1817
+ });
1818
+ }
1819
+ if (options?.onProgress) {
1820
+ options.onProgress({
1821
+ completedBatches: batches,
1822
+ totalBatches,
1823
+ deletedSoFar: deleted
1824
+ });
1825
+ }
1826
+ }
1827
+ return { deleted, batches, errors };
1828
+ }
1829
+ // `findEdgesGlobal` is deliberately NOT defined on this class. Each graph
1830
+ // is its own table, so a "collection group" query would mean scanning every
1831
+ // table listed in the catalog — an unbounded fan-out the cross-backend
1832
+ // contract treats as unsupported (the Cloudflare DO edition makes the same
1833
+ // call: no cross-DO index, no `findEdgesGlobal`). The client surfaces
1834
+ // `UNSUPPORTED_OPERATION` when the method is absent.
1835
+ // --- Aggregate ---
1836
+ /**
1837
+ * Run an aggregate query in a single SQL statement. Supports the full
1838
+ * count/sum/avg/min/max set — the SQLite engine evaluates each aggregate
1839
+ * function over the filtered row set and the executor returns one row
1840
+ * with one column per alias. SUM/MIN/MAX of an empty set returns 0
1841
+ * (SQLite's `SUM(NULL) = NULL` is mapped to a clean number for the
1842
+ * cross-backend contract); AVG returns NaN, matching the mathematical
1843
+ * convention and the Firestore Standard helper.
1844
+ */
1845
+ async aggregate(spec, filters) {
1846
+ const { stmt, aliases } = compileAggregate(this.collectionPath, spec, filters);
1847
+ const rows = await this.withSchema(() => this.executor.all(stmt.sql, stmt.params));
1848
+ const row = rows[0] ?? {};
1849
+ const out = {};
1850
+ for (const alias of aliases) {
1851
+ const v = row[alias];
1852
+ if (v === null || v === void 0) {
1853
+ const op = spec[alias].op;
1854
+ out[alias] = op === "avg" ? Number.NaN : 0;
1855
+ } else if (typeof v === "bigint") {
1856
+ out[alias] = Number(v);
1857
+ } else if (typeof v === "number") {
1858
+ out[alias] = v;
1859
+ } else {
1860
+ out[alias] = Number(v);
1861
+ }
1862
+ }
1863
+ return out;
1864
+ }
1865
+ // --- Server-side DML ---
1866
+ /**
1867
+ * Delete every row matching `filters` in a single SQL DELETE statement.
1868
+ *
1869
+ * Uses `RETURNING "doc_id"` to count rows touched — the SQLite executor's
1870
+ * `run` returns void, so RETURNING + `all()` is the portable way to learn
1871
+ * how many rows the engine actually deleted. SQLite ≥ 3.35 supports
1872
+ * `DELETE … RETURNING`; better-sqlite3, D1, and DO SQLite all run on a
1873
+ * recent enough engine.
1874
+ *
1875
+ * Single-statement DML doesn't chunk: the engine handles N rows in one
1876
+ * shot, so `BulkOptions.batchSize` is intentionally ignored. The retry
1877
+ * loop here exists only for transient driver errors (e.g. D1 surface
1878
+ * congestion); a permanent failure is surfaced via the `errors` array
1879
+ * with `batchIndex: 0` so callers see the same shape as `bulkRemoveEdges`.
1880
+ *
1881
+ * Subgraph isolation is physical — the statement only ever touches this
1882
+ * graph's table, so no scoping predicate is needed.
1883
+ */
1884
+ async bulkDelete(filters, options) {
1885
+ await this.ensureSchema();
1886
+ const stmt = compileBulkDelete(this.collectionPath, filters);
1887
+ return this.executeDmlWithReturning(stmt, options);
1888
+ }
1889
+ /**
1890
+ * Update every row matching `filters` with `patch.data` in a single SQL
1891
+ * UPDATE statement. The patch is deep-merged into each row's `data`
1892
+ * column via the same `flattenPatch` → `compileDataOpsExpr` pipeline that
1893
+ * `compileUpdate` (single-row) uses.
1894
+ *
1895
+ * Same contract notes as `bulkDelete` apply: single-statement, no
1896
+ * chunking, `RETURNING "doc_id"` for the affected count, retry loop for
1897
+ * transient driver errors.
1898
+ */
1899
+ async bulkUpdate(filters, patch, options) {
1900
+ await this.ensureSchema();
1901
+ const stmt = compileBulkUpdate(this.collectionPath, filters, patch.data, Date.now());
1902
+ return this.executeDmlWithReturning(stmt, options);
1903
+ }
1904
+ /**
1905
+ * Multi-source fan-out — `query.join` capability.
1906
+ *
1907
+ * Issues a single `SELECT … WHERE "aUid" IN (?, ?, …)` statement that
1908
+ * matches every edge from every source UID in one round trip. When
1909
+ * `params.hydrate === true`, follows up with a second statement that
1910
+ * fetches the target node rows; both queries hit the same table so
1911
+ * the executor amortises connection / parsing cost across them.
1912
+ *
1913
+ * Empty `params.sources` short-circuits to an empty result without
1914
+ * touching the executor — `IN ()` is not valid SQL.
1915
+ *
1916
+ * Per-source ordering / strict per-source LIMIT enforcement is NOT
1917
+ * implemented here; see the `ExpandParams.limitPerSource` JSDoc and
1918
+ * `compileExpand` for the cap semantics. Strict per-source caps would
1919
+ * require window functions and were judged out of scope for the
1920
+ * round-trip-collapse goal.
1921
+ */
1922
+ async expand(params) {
1923
+ if (params.sources.length === 0) {
1924
+ return params.hydrate ? { edges: [], targets: [] } : { edges: [] };
1925
+ }
1926
+ const stmt = compileExpand(this.collectionPath, params);
1927
+ const rows = await this.withSchema(() => this.executor.all(stmt.sql, stmt.params));
1928
+ const edges = rows.map(rowToRecord);
1929
+ if (!params.hydrate) {
1930
+ return { edges };
1931
+ }
1932
+ const direction = params.direction ?? "forward";
1933
+ const targetUids = edges.map((e) => direction === "forward" ? e.bUid : e.aUid);
1934
+ const uniqueTargets = [...new Set(targetUids)];
1935
+ if (uniqueTargets.length === 0) {
1936
+ return { edges, targets: [] };
1937
+ }
1938
+ const hydrateStmt = compileExpandHydrate(this.collectionPath, uniqueTargets);
1939
+ const hydrateRows = await this.executor.all(hydrateStmt.sql, hydrateStmt.params);
1940
+ const byUid = /* @__PURE__ */ new Map();
1941
+ for (const row of hydrateRows) {
1942
+ const node = rowToRecord(row);
1943
+ byUid.set(node.bUid, node);
1944
+ }
1945
+ const targets = targetUids.map((uid) => byUid.get(uid) ?? null);
1946
+ return { edges, targets };
1947
+ }
1948
+ /**
1949
+ * Server-side projection — `query.select` capability.
1950
+ *
1951
+ * Issues a single `SELECT json_extract(data, '$.f1'), …` statement that
1952
+ * returns only the requested fields. The compiler emits one column per
1953
+ * unique field plus a paired `json_type` column for `data.*` projections
1954
+ * so the decoder can recover JSON-encoded objects/arrays without a
1955
+ * second round trip. Migrations are NOT applied — the caller asked for
1956
+ * a partial shape, and rehydrating that into the migration pipeline
1957
+ * would require synthesising every absent field.
1958
+ *
1959
+ * The wire-payload reduction is the entire reason this method exists:
1960
+ * a list view that only needs `title` / `date` no longer drags the
1961
+ * full `data` JSON across the network. Callers that need the full
1962
+ * record should use `findEdges` (with migration support).
1963
+ */
1964
+ async findEdgesProjected(select, filters, options) {
1965
+ const { stmt, columns } = compileFindEdgesProjected(
1966
+ this.collectionPath,
1967
+ select,
1968
+ filters,
1969
+ options
1970
+ );
1971
+ const rows = await this.withSchema(() => this.executor.all(stmt.sql, stmt.params));
1972
+ return rows.map((row) => decodeProjectedRow(row, columns));
1973
+ }
1974
+ /**
1975
+ * Run a DML statement with `RETURNING "doc_id"` so we can count the
1976
+ * rows the engine touched, with the same retry/backoff contract as
1977
+ * `executeChunkedBatches`. Single statement, single batch.
1978
+ */
1979
+ async executeDmlWithReturning(stmt, options) {
1980
+ const sqlWithReturning = `${stmt.sql} RETURNING "doc_id"`;
1981
+ const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
1982
+ let lastError = null;
1983
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1984
+ try {
1985
+ const rows = await this.executor.all(sqlWithReturning, stmt.params);
1986
+ const deleted = rows.length;
1987
+ if (options?.onProgress) {
1988
+ options.onProgress({
1989
+ completedBatches: 1,
1990
+ totalBatches: 1,
1991
+ deletedSoFar: deleted
1992
+ });
1993
+ }
1994
+ return { deleted, batches: 1, errors: [] };
1995
+ } catch (err) {
1996
+ lastError = err instanceof Error ? err : new Error(String(err));
1997
+ if (attempt < maxRetries) {
1998
+ const delay = Math.min(BASE_RETRY_DELAY_MS * Math.pow(2, attempt), MAX_RETRY_DELAY_MS);
1999
+ await sleep(delay);
2000
+ }
2001
+ }
2002
+ }
2003
+ return {
2004
+ deleted: 0,
2005
+ batches: 0,
2006
+ errors: [
2007
+ {
2008
+ batchIndex: 0,
2009
+ error: lastError ?? new Error("bulk DML failed for unknown reason"),
2010
+ operationCount: 0
2011
+ }
2012
+ ]
2013
+ };
2014
+ }
2015
+ };
2016
+ function createSqliteBackend(executor, tableName, options = {}) {
2017
+ const storageScope = options.storageScope ?? "";
2018
+ const scopePath = options.scopePath ?? "";
2019
+ return new SqliteBackendImpl(
2020
+ executor,
2021
+ tableName,
2022
+ storageScope,
2023
+ scopePath,
2024
+ options.registry,
2025
+ options.coreIndexes,
2026
+ options.extraTableDDL
2027
+ );
2028
+ }
2029
+
2030
+ // src/sqlite/local.ts
2031
+ function createBetterSqliteExecutor(db) {
2032
+ return {
2033
+ async all(sql, params) {
2034
+ return db.prepare(sql).all(...params);
2035
+ },
2036
+ async run(sql, params) {
2037
+ db.prepare(sql).run(...params);
2038
+ },
2039
+ async batch(statements) {
2040
+ const tx = db.transaction((stmts) => {
2041
+ for (const s of stmts) {
2042
+ db.prepare(s.sql).run(...s.params);
2043
+ }
2044
+ });
2045
+ tx(statements);
2046
+ },
2047
+ async transaction(fn) {
2048
+ db.exec("BEGIN IMMEDIATE");
2049
+ try {
2050
+ const result = await fn({
2051
+ async all(sql, params) {
2052
+ return db.prepare(sql).all(...params);
2053
+ },
2054
+ async run(sql, params) {
2055
+ db.prepare(sql).run(...params);
2056
+ }
2057
+ });
2058
+ db.exec("COMMIT");
2059
+ return result;
2060
+ } catch (err) {
2061
+ db.exec("ROLLBACK");
2062
+ throw err;
2063
+ }
2064
+ }
2065
+ };
2066
+ }
2067
+ function isDatabase(value) {
2068
+ return typeof value === "object" && value !== null && typeof value.prepare === "function" && typeof value.exec === "function";
2069
+ }
2070
+ var PRAGMA_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2071
+ var PRAGMA_VALUE_PATTERN = /^-?[A-Za-z0-9_]+$/;
2072
+ function applyPragmas(db, pragmas) {
2073
+ for (const [key, value] of Object.entries(pragmas)) {
2074
+ if (!PRAGMA_KEY_PATTERN.test(key)) {
2075
+ throw new FiregraphError(`Invalid pragma name: ${JSON.stringify(key)}`, "INVALID_ARGUMENT");
2076
+ }
2077
+ if (!PRAGMA_VALUE_PATTERN.test(String(value)) || typeof value === "number" && !Number.isFinite(value)) {
2078
+ throw new FiregraphError(
2079
+ `Invalid pragma value for ${key}: ${JSON.stringify(value)}`,
2080
+ "INVALID_ARGUMENT"
2081
+ );
2082
+ }
2083
+ db.pragma(`${key} = ${value}`);
2084
+ }
2085
+ }
2086
+ function registerVectorUdf(db) {
2087
+ try {
2088
+ db.function(
2089
+ VECTOR_DISTANCE_UDF,
2090
+ { deterministic: true },
2091
+ (stored, query, measure) => computeVectorDistance(stored, query, measure)
2092
+ );
2093
+ } catch {
2094
+ }
2095
+ }
2096
+ async function sweepOrphanedFtsArtifacts(executor, rootTable) {
2097
+ const tableRows = await executor.all(
2098
+ `SELECT "name" FROM sqlite_master WHERE "type" = 'table'`,
2099
+ []
2100
+ );
2101
+ const allTables = tableRows.map((r) => String(r.name));
2102
+ const catalogRows = await executor.all(
2103
+ `SELECT "table_name" FROM ${quoteIdent2(catalogTableName(rootTable))}`,
2104
+ []
2105
+ );
2106
+ const catalogTables = catalogRows.map((r) => String(r.table_name));
2107
+ for (const name of findOrphanedFtsTables(allTables, catalogTables, rootTable)) {
2108
+ validateTableName(name);
2109
+ await executor.run(`DROP TABLE IF EXISTS ${quoteIdent2(name)}`, []);
2110
+ }
2111
+ }
2112
+ function wrapLocalSearchBackend(inner, executor, rootTable) {
2113
+ const caps = /* @__PURE__ */ new Set([
2114
+ ...inner.capabilities.values(),
2115
+ "search.fullText",
2116
+ "search.vector"
2117
+ ]);
2118
+ const healableTables = /* @__PURE__ */ new Set([
2119
+ inner.collectionPath,
2120
+ ftsTableName(inner.collectionPath),
2121
+ ftsMapTableName(inner.collectionPath)
2122
+ ]);
2123
+ const runWithSchema = async (op) => {
2124
+ await inner.ensureReady();
2125
+ try {
2126
+ return await op();
2127
+ } catch (err) {
2128
+ const message = err instanceof Error ? err.message : String(err);
2129
+ const missing = /no such table: (\S+)/.exec(message)?.[1];
2130
+ if (missing === void 0 || !healableTables.has(missing)) throw err;
2131
+ await inner.ensureReady(true);
2132
+ return op();
2133
+ }
2134
+ };
2135
+ const wrapper = {
2136
+ capabilities: createCapabilities(caps),
2137
+ collectionPath: inner.collectionPath,
2138
+ scopePath: inner.scopePath,
2139
+ getDoc: (docId) => inner.getDoc(docId),
2140
+ query: (filters, options) => inner.query(filters, options),
2141
+ setDoc: (docId, record, mode) => inner.setDoc(docId, record, mode),
2142
+ updateDoc: (docId, update) => inner.updateDoc(docId, update),
2143
+ deleteDoc: (docId) => inner.deleteDoc(docId),
2144
+ runTransaction: (fn) => inner.runTransaction(fn),
2145
+ createBatch: () => inner.createBatch(),
2146
+ subgraph: (parentNodeUid, name) => wrapLocalSearchBackend(inner.subgraph(parentNodeUid, name), executor, rootTable),
2147
+ removeNodeCascade: async (uid, reader, options) => {
2148
+ const result = await inner.removeNodeCascade(uid, reader, options);
2149
+ if (result.errors.length === 0) {
2150
+ await sweepOrphanedFtsArtifacts(executor, rootTable);
2151
+ }
2152
+ return result;
2153
+ },
2154
+ bulkRemoveEdges: (params, reader, options) => inner.bulkRemoveEdges(params, reader, options),
2155
+ aggregate: (spec, filters) => inner.aggregate(spec, filters),
2156
+ bulkDelete: (filters, options) => inner.bulkDelete(filters, options),
2157
+ bulkUpdate: (filters, patch, options) => inner.bulkUpdate(filters, patch, options),
2158
+ expand: (params) => inner.expand(params),
2159
+ findEdgesProjected: (select, filters, options) => inner.findEdgesProjected(select, filters, options),
2160
+ // `findEdgesGlobal` stays absent, same as the inner backend — each graph
2161
+ // is its own table; there is no cross-table index.
2162
+ async findNearest(params) {
2163
+ const { stmt, distancePath } = compileFindNearest(inner.collectionPath, params);
2164
+ const rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));
2165
+ return rows.map((row) => {
2166
+ const record = rowToRecord(row);
2167
+ if (distancePath) {
2168
+ const distance = row[DISTANCE_ALIAS];
2169
+ setDataPath(
2170
+ record.data,
2171
+ distancePath,
2172
+ typeof distance === "number" ? distance : Number(distance)
2173
+ );
2174
+ }
2175
+ return record;
2176
+ });
2177
+ },
2178
+ async fullTextSearch(params) {
2179
+ const stmt = compileFullTextSearch(inner.collectionPath, params);
2180
+ let rows;
2181
+ try {
2182
+ rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));
2183
+ } catch (err) {
2184
+ const message = err instanceof Error ? err.message : String(err);
2185
+ if (message.includes("fts5") || message.includes("unknown special query")) {
2186
+ throw new FiregraphError(
2187
+ `fullTextSearch(): invalid FTS5 query syntax \u2014 ${message}`,
2188
+ "INVALID_QUERY"
2189
+ );
2190
+ }
2191
+ throw err;
2192
+ }
2193
+ return rows.map(rowToRecord);
2194
+ }
2195
+ };
2196
+ return wrapper;
2197
+ }
2198
+ async function createLocalSqliteBackend(pathOrDb, options = {}) {
2199
+ const {
2200
+ tableName = "firegraph",
2201
+ busyTimeoutMs = 5e3,
2202
+ pragmas,
2203
+ fileMustExist,
2204
+ ...backendOptions
2205
+ } = options;
2206
+ let db;
2207
+ let ownsDb;
2208
+ if (typeof pathOrDb === "string") {
2209
+ let Database;
2210
+ try {
2211
+ Database = (await import("better-sqlite3")).default;
2212
+ } catch (err) {
2213
+ throw new FiregraphError(
2214
+ `createLocalSqliteBackend requires the optional peer dependency 'better-sqlite3' \u2014 install it to use the local SQLite backend (${err instanceof Error ? err.message : String(err)})`,
2215
+ "MISSING_DEPENDENCY"
2216
+ );
2217
+ }
2218
+ db = new Database(pathOrDb, fileMustExist ? { fileMustExist: true } : {});
2219
+ ownsDb = true;
2220
+ db.pragma("journal_mode = WAL");
2221
+ } else if (isDatabase(pathOrDb)) {
2222
+ db = pathOrDb;
2223
+ ownsDb = false;
2224
+ } else {
2225
+ throw new FiregraphError(
2226
+ "createLocalSqliteBackend expects a file path or an open better-sqlite3 Database",
2227
+ "INVALID_ARGUMENT"
2228
+ );
2229
+ }
2230
+ db.pragma(`busy_timeout = ${Math.max(0, Math.floor(busyTimeoutMs))}`);
2231
+ if (pragmas) {
2232
+ applyPragmas(db, pragmas);
2233
+ }
2234
+ registerVectorUdf(db);
2235
+ const userExtraDDL = backendOptions.extraTableDDL;
2236
+ const optionsWithSearch = {
2237
+ ...backendOptions,
2238
+ extraTableDDL: (table) => [
2239
+ ...userExtraDDL ? userExtraDDL(table) : [],
2240
+ ...buildLocalSearchDDL(table)
2241
+ ]
2242
+ };
2243
+ const executor = createBetterSqliteExecutor(db);
2244
+ const inner = createSqliteBackend(executor, tableName, optionsWithSearch);
2245
+ const backend = wrapLocalSearchBackend(inner, executor, tableName);
2246
+ let closed = false;
2247
+ return {
2248
+ backend,
2249
+ db,
2250
+ close() {
2251
+ if (closed || !ownsDb) return;
2252
+ closed = true;
2253
+ db.close();
2254
+ }
2255
+ };
2256
+ }
2257
+ // Annotate the CommonJS export names for ESM import in node:
2258
+ 0 && (module.exports = {
2259
+ createBetterSqliteExecutor,
2260
+ createLocalSqliteBackend
2261
+ });
2262
+ //# sourceMappingURL=local.cjs.map