@spooky-sync/core 0.0.1-canary.107 → 0.0.1-canary.109

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 (33) hide show
  1. package/dist/index.d.ts +10 -114
  2. package/dist/index.js +1070 -46
  3. package/dist/sqlite-worker.d.ts +1 -0
  4. package/dist/sqlite-worker.js +107 -0
  5. package/dist/types.d.ts +157 -2
  6. package/package.json +4 -3
  7. package/src/modules/cache/index.ts +2 -2
  8. package/src/modules/crdt/crdt-field.ts +3 -3
  9. package/src/modules/crdt/crdt-hydration.test.ts +5 -5
  10. package/src/modules/crdt/index.ts +2 -2
  11. package/src/modules/data/index.ts +45 -17
  12. package/src/modules/data/window-query.ts +24 -0
  13. package/src/modules/devtools/index.ts +2 -2
  14. package/src/modules/sync/queue/queue-down.ts +2 -2
  15. package/src/modules/sync/queue/queue-up.ts +2 -2
  16. package/src/modules/sync/sync.ts +2 -2
  17. package/src/services/database/cache-engine.ts +143 -0
  18. package/src/services/database/engine-factory.ts +32 -0
  19. package/src/services/database/index.ts +6 -0
  20. package/src/services/database/local-migrator.ts +2 -2
  21. package/src/services/database/plan-render.test.ts +85 -0
  22. package/src/services/database/plan-render.ts +86 -0
  23. package/src/services/database/relation-resolver.test.ts +413 -0
  24. package/src/services/database/relation-resolver.ts +0 -0
  25. package/src/services/database/sqlite-cache-engine.ts +631 -0
  26. package/src/services/database/sqlite-worker.ts +116 -0
  27. package/src/services/database/surql-translate.ts +291 -0
  28. package/src/services/database/surreal-cache-engine.ts +122 -0
  29. package/src/services/persistence/surrealdb.ts +2 -2
  30. package/src/services/stream-processor/index.ts +2 -2
  31. package/src/sp00ky.ts +24 -7
  32. package/src/types.ts +17 -1
  33. package/tsdown.config.ts +10 -1
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRe
2
2
  import { createWasmWorkerEngines } from "@surrealdb/wasm";
3
3
  import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
4
4
  import pino from "pino";
5
+ import { applyPatch } from "fast-json-patch";
5
6
  import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
6
7
  import { LoroDoc } from "loro-crdt";
7
8
 
@@ -1046,32 +1047,1034 @@ var LocalMigrator = class {
1046
1047
  };
1047
1048
 
1048
1049
  //#endregion
1049
- //#region src/modules/data/window-query.ts
1050
+ //#region src/services/database/cache-engine.ts
1051
+ /** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
1052
+ * a guard against a cyclic schema producing unbounded fan-out. */
1053
+ var RelationCycleError = class extends Error {
1054
+ constructor(path) {
1055
+ super(`Relation nesting exceeded safe depth; possible cyclic schema: ${path.join(" -> ")}`);
1056
+ this.name = "RelationCycleError";
1057
+ }
1058
+ };
1059
+ /** Defensive ceiling on relation nesting depth. A finite plan tree never hits
1060
+ * this in practice; it exists so a malformed/cyclic plan fails loudly instead
1061
+ * of running away. */
1062
+ const MAX_RELATION_DEPTH = 12;
1063
+
1064
+ //#endregion
1065
+ //#region src/services/database/relation-resolver.ts
1050
1066
  /**
1051
- * Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
1052
- * materialized from an explicit record-id set — the window the SSP already
1053
- * computed instead of re-running the original query (with its `START m`)
1054
- * against the shared local DB.
1067
+ * Resolve the `.related()` tree of a query by **level-ordered, batched
1068
+ * fan-out** — the engine-neutral replacement for SurrealQL's nested
1069
+ * `(SELECT..) AS alias` projections. One batched fetch per relation per level
1070
+ * (never per-row N+1, never a flat join); grouping, per-parent ORDER/LIMIT and
1071
+ * attachment happen in JS.
1055
1072
  *
1056
- * Why: `DataModule.processStreamUpdate` materializes a query's rows by
1057
- * re-querying the local in-browser SurrealDB with the original surql. For an
1058
- * offset query that re-applies `START m` against the *shared* local store
1059
- * which, with sparse windowing, may hold only this window's rows — so it skips
1060
- * them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
1061
- * materialized view (`StreamUpdate.localArray`) is exactly this window's row
1062
- * ids, so we select those ids directly and re-apply the original `ORDER BY` for
1063
- * stable display order.
1073
+ * Correlation (mirrors `buildSubquery`):
1074
+ * - `one` → parent[`foreignKeyField`] = child.id → attach `bucket[0] ?? null`
1075
+ * - `many` child[`foreignKeyField`] = parent.id → attach `bucket`
1064
1076
  *
1065
- * Returns `null` for non-offset queries (`START` absent or 0) the caller
1066
- * keeps the normal re-query path, so only the broken case changes behavior.
1077
+ * Cross-level dependency is handled by ordering: a child's correlation key is a
1078
+ * field on its parent, known only once the parent level has resolved, so each
1079
+ * level's batch runs after the previous level's rows exist. `parents` is
1080
+ * mutated in place — each resolved alias is appended LAST so key order matches
1081
+ * SurrealQL's `SELECT *, <sub> AS alias` (base fields, then alias).
1082
+ *
1083
+ * @throws {RelationCycleError} if nesting exceeds {@link MAX_RELATION_DEPTH}.
1084
+ */
1085
+ async function resolveRelations(parents, relations, fetcher, depth = 0, path = []) {
1086
+ if (!relations || relations.length === 0 || parents.length === 0) return;
1087
+ if (depth >= MAX_RELATION_DEPTH) throw new RelationCycleError([...path, relations.map((r) => r.alias).join("|")]);
1088
+ await Promise.all(relations.map((relation) => resolveOne(parents, relation, fetcher, depth, path)));
1089
+ }
1090
+ async function resolveOne(parents, relation, fetcher, depth, path) {
1091
+ const isOne = relation.cardinality === "one";
1092
+ const matchField = isOne ? "id" : relation.foreignKeyField;
1093
+ const parentKeyOf = (parent) => isOne ? parent[relation.foreignKeyField] : parent["id"];
1094
+ const keySet = /* @__PURE__ */ new Map();
1095
+ for (const parent of parents) {
1096
+ const key = parentKeyOf(parent);
1097
+ if (key == null) continue;
1098
+ const k = stableKey(key);
1099
+ if (!keySet.has(k)) keySet.set(k, key);
1100
+ }
1101
+ const grouped = /* @__PURE__ */ new Map();
1102
+ if (keySet.size > 0) {
1103
+ const children = await fetcher.fetchRelation({
1104
+ table: relation.table,
1105
+ matchField,
1106
+ keys: [...keySet.values()],
1107
+ where: relation.where,
1108
+ orderBy: relation.orderBy,
1109
+ select: relation.select
1110
+ });
1111
+ await resolveRelations(children, relation.relations, fetcher, depth + 1, [...path, relation.alias]);
1112
+ for (const child of children) {
1113
+ const k = stableKey(child[matchField]);
1114
+ const bucket = grouped.get(k);
1115
+ if (bucket) bucket.push(child);
1116
+ else grouped.set(k, [child]);
1117
+ }
1118
+ }
1119
+ for (const parent of parents) {
1120
+ const key = parentKeyOf(parent);
1121
+ let bucket = key == null ? [] : grouped.get(stableKey(key)) ?? [];
1122
+ if (relation.orderBy && relation.orderBy.length > 0) bucket = sortRows(bucket, relation.orderBy);
1123
+ if (relation.limit !== void 0) bucket = bucket.slice(0, relation.limit);
1124
+ parent[relation.alias] = isOne ? bucket[0] ?? null : bucket;
1125
+ }
1126
+ }
1127
+ /**
1128
+ * Stable grouping key for a correlation value. A `RecordId` (duck-typed to
1129
+ * avoid a hard surrealdb dependency here) and its `table:id` string form must
1130
+ * collapse to the same key so a parent FK stored either way matches the child.
1131
+ */
1132
+ function stableKey(value) {
1133
+ if (value == null) return "\0null";
1134
+ if (typeof value === "string") return value;
1135
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
1136
+ if (typeof value === "object") {
1137
+ const rid = value;
1138
+ if (rid.tb !== void 0 && rid.id !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
1139
+ if (typeof rid.toString === "function") {
1140
+ const s = rid.toString();
1141
+ if (s !== "[object Object]") return s;
1142
+ }
1143
+ return JSON.stringify(value);
1144
+ }
1145
+ return String(value);
1146
+ }
1147
+ /**
1148
+ * Stable, multi-key sort matching SurrealQL `ORDER BY` semantics closely
1149
+ * enough for cache display order: ascending null-last, type-aware compare, and
1150
+ * a stable tiebreak (original index) so equal rows keep input order.
1151
+ */
1152
+ function sortRows(rows, orderBy) {
1153
+ const indexed = rows.map((row, i) => [row, i]);
1154
+ indexed.sort((a, b) => {
1155
+ for (const [field, direction] of orderBy) {
1156
+ const cmp = compareValues(a[0][field], b[0][field]);
1157
+ if (cmp !== 0) return direction === "desc" ? -cmp : cmp;
1158
+ }
1159
+ return a[1] - b[1];
1160
+ });
1161
+ return indexed.map(([row]) => row);
1162
+ }
1163
+ function compareValues(a, b) {
1164
+ if (a == null && b == null) return 0;
1165
+ if (a == null) return 1;
1166
+ if (b == null) return -1;
1167
+ if (typeof a === "number" && typeof b === "number") return a - b;
1168
+ const as = stableKey(a);
1169
+ const bs = stableKey(b);
1170
+ return as < bs ? -1 : as > bs ? 1 : 0;
1171
+ }
1172
+
1173
+ //#endregion
1174
+ //#region src/services/database/plan-render.ts
1175
+ function bind(ctx, value) {
1176
+ const name = `__p${ctx.n++}`;
1177
+ ctx.vars[name] = value;
1178
+ return `$${name}`;
1179
+ }
1180
+ function renderComparisonSurql(c, ctx) {
1181
+ const right = c.paramRef ? `$${c.paramRef}` : bind(ctx, c.value);
1182
+ return c.swap ? `${right} ${c.op} ${c.field}` : `${c.field} ${c.op} ${right}`;
1183
+ }
1184
+ /** Render a WHERE conjunction (AND of comparisons / OR-groups) to SurrealQL. */
1185
+ function renderWhereSurql(nodes, ctx) {
1186
+ return nodes.map((node) => {
1187
+ if ("or" in node) return `(${node.or.map((c) => renderComparisonSurql(c, ctx)).join(" OR ")})`;
1188
+ return renderComparisonSurql(node, ctx);
1189
+ }).join(" AND ");
1190
+ }
1191
+ function renderOrderBy(orderBy) {
1192
+ return ` ORDER BY ${orderBy.map(([f, d]) => `${f} ${d}`).join(", ")}`;
1193
+ }
1194
+ /**
1195
+ * Render the BASE of a SELECT (no relations) to SurrealQL. `params` supplies
1196
+ * pre-existing bound params (e.g. `$__win` for windowing, or `paramRef` values)
1197
+ * and is merged into the returned vars.
1198
+ */
1199
+ function renderBaseSelectSurql(plan, params = {}) {
1200
+ const ctx = {
1201
+ vars: { ...params },
1202
+ n: 0
1203
+ };
1204
+ let sql = `SELECT ${plan.select && plan.select.length > 0 ? plan.select.join(", ") : "*"} FROM ${plan.table}`;
1205
+ if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSurql(plan.where, ctx)}`;
1206
+ if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderBy(plan.orderBy);
1207
+ if (plan.limit !== void 0) sql += ` LIMIT ${plan.limit}`;
1208
+ if (plan.offset !== void 0) sql += ` START ${plan.offset}`;
1209
+ return {
1210
+ sql: `${sql};`,
1211
+ vars: ctx.vars
1212
+ };
1213
+ }
1214
+ /**
1215
+ * Render a batched relation fetch to SurrealQL:
1216
+ * `SELECT <select> FROM <table> WHERE <matchField> IN $__keys [AND <where>] [ORDER BY]`.
1217
+ * The resolver re-applies per-parent ORDER/LIMIT after grouping, so LIMIT is
1218
+ * intentionally omitted here.
1219
+ */
1220
+ function renderRelationFetchSurql(req) {
1221
+ const ctx = {
1222
+ vars: { __keys: req.keys },
1223
+ n: 0
1224
+ };
1225
+ let sql = `SELECT ${req.select && req.select.length > 0 ? ["id", ...req.select].join(", ") : "*"} FROM ${req.table} WHERE ${req.matchField} IN $__keys`;
1226
+ if (req.where && req.where.length > 0) sql += ` AND ${renderWhereSurql(req.where, ctx)}`;
1227
+ if (req.orderBy && req.orderBy.length > 0) sql += renderOrderBy(req.orderBy);
1228
+ return {
1229
+ sql: `${sql};`,
1230
+ vars: ctx.vars
1231
+ };
1232
+ }
1233
+
1234
+ //#endregion
1235
+ //#region src/services/database/surreal-cache-engine.ts
1236
+ /**
1237
+ * Default local cache backend: the in-browser SurrealDB-WASM store. Implemented
1238
+ * as a subclass of {@link LocalDatabaseService} so it remains a 100% drop-in for
1239
+ * every existing `this.local.query(...)` / `execute` / `switchStore` / `epoch`
1240
+ * call site (zero behavior change), while adding the engine-neutral verb surface
1241
+ * (`select`/`selectByIds`/`getById`/CRUD) used by the pluggable path.
1242
+ *
1243
+ * Relations are resolved with the SAME shared {@link resolveRelations} as the
1244
+ * SQLite backend, so the two engines decompose `.related()` identically.
1245
+ */
1246
+ var SurrealCacheEngine = class extends LocalDatabaseService {
1247
+ /** SurrealDB needs its SurrealQL schema provisioned locally. */
1248
+ usesSurqlSchema = true;
1249
+ /** {@link LocalCacheEngine} alias for {@link LocalDatabaseService.switchStore}. */
1250
+ switchBucket(bucketId) {
1251
+ return this.switchStore(bucketId);
1252
+ }
1253
+ async fetchRelation(req) {
1254
+ const { sql, vars } = renderRelationFetchSurql(req);
1255
+ const [rows] = await this.query(sql, vars);
1256
+ return rows ?? [];
1257
+ }
1258
+ async select(plan, params = {}) {
1259
+ if (plan.ids) {
1260
+ const result = await this.selectByIds(plan.table, plan.ids, {
1261
+ select: plan.select,
1262
+ orderBy: plan.orderBy
1263
+ });
1264
+ await resolveRelations(result, plan.relations, this);
1265
+ return result;
1266
+ }
1267
+ const { sql, vars } = renderBaseSelectSurql(plan, params);
1268
+ const [rows] = await this.query(sql, vars);
1269
+ const result = rows ?? [];
1270
+ await resolveRelations(result, plan.relations, this);
1271
+ return result;
1272
+ }
1273
+ async selectByIds(table, ids, opts) {
1274
+ if (ids.length === 0) return [];
1275
+ let sql = `SELECT ${opts?.select && opts.select.length > 0 ? ["id", ...opts.select].join(", ") : "*"} FROM $__ids`;
1276
+ if (opts?.orderBy && opts.orderBy.length > 0) sql += ` ORDER BY ${opts.orderBy.map(([f, d]) => `${f} ${d}`).join(", ")}`;
1277
+ const [rows] = await this.query(`${sql};`, { __ids: ids });
1278
+ const result = rows ?? [];
1279
+ if (!opts?.orderBy || opts.orderBy.length === 0) {
1280
+ const pos = new Map(ids.map((id, i) => [stableKey(id), i]));
1281
+ result.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
1282
+ }
1283
+ return result;
1284
+ }
1285
+ async getById(_table, id) {
1286
+ const [row] = await this.query("SELECT * FROM ONLY $__id;", { __id: id });
1287
+ return row ?? null;
1288
+ }
1289
+ async upsert(_table, id, data, mode) {
1290
+ const sql = mode === "merge" ? surql.upsertMerge("__id", "__data") : surql.upsert("__id", "__data");
1291
+ await this.query(surql.seal(sql), {
1292
+ __id: id,
1293
+ __data: data
1294
+ });
1295
+ }
1296
+ async patch(_table, id, patches) {
1297
+ await this.query(surql.seal("UPDATE ONLY $__id PATCH $__patches"), {
1298
+ __id: id,
1299
+ __patches: patches
1300
+ });
1301
+ }
1302
+ async delete(_table, id) {
1303
+ await this.query(surql.seal(surql.delete("__id")), { __id: id });
1304
+ }
1305
+ /**
1306
+ * Serialized (not strictly atomic) transaction: verbs run in order on the
1307
+ * same serialized query queue. The SurrealDB-WASM store already funnels every
1308
+ * `query()` through one queue, so these never interleave with other work;
1309
+ * true multi-statement atomicity is not required by the current call sites
1310
+ * (single-record CRDT / mutation writes).
1311
+ */
1312
+ async transaction(fn) {
1313
+ return fn({
1314
+ upsert: (t, id, data, mode) => this.upsert(t, id, data, mode),
1315
+ patch: (t, id, patches) => this.patch(t, id, patches),
1316
+ delete: (t, id) => this.delete(t, id)
1317
+ });
1318
+ }
1319
+ };
1320
+
1321
+ //#endregion
1322
+ //#region src/services/database/surql-translate.ts
1323
+ const rid = (v) => v;
1324
+ /** Table name from a record id value (`table:id` string or RecordId). */
1325
+ function tableOf(id) {
1326
+ return stableKey(id).split(":")[0];
1327
+ }
1328
+ function translateSurql(sql, vars) {
1329
+ const trimmed = sql.trim().replace(/;\s*$/, "");
1330
+ if (/^BEGIN\s+TRANSACTION/i.test(trimmed)) return {
1331
+ transaction: true,
1332
+ ops: splitStatements(trimmed.replace(/^BEGIN\s+TRANSACTION\s*;?/i, "").replace(/;?\s*COMMIT\s+TRANSACTION\s*$/i, "")).map((s) => translateStatement(s, vars))
1333
+ };
1334
+ return {
1335
+ transaction: false,
1336
+ ops: splitStatements(trimmed).map((s) => translateStatement(s, vars))
1337
+ };
1338
+ }
1339
+ function splitStatements(block) {
1340
+ return block.split(";").map((s) => s.trim()).filter(Boolean);
1341
+ }
1342
+ function translateStatement(stmt, vars) {
1343
+ const s = stmt.trim();
1344
+ let mLet = /^LET\s+\$(\w+)\s*=\s*\(([\s\S]+)\)$/i.exec(s);
1345
+ if (mLet) return {
1346
+ kind: "let",
1347
+ var: mLet[1],
1348
+ inner: translateStatement(mLet[2].trim(), vars)
1349
+ };
1350
+ let mRet = /^RETURN\s+\{([\s\S]+)\}$/i.exec(s);
1351
+ if (mRet) return {
1352
+ kind: "return",
1353
+ entries: splitTopLevel(mRet[1], ",").map((pair) => {
1354
+ const c = pair.indexOf(":");
1355
+ const key = pair.slice(0, c).trim();
1356
+ const v = pair.slice(c + 1).trim();
1357
+ return {
1358
+ key,
1359
+ var: v.startsWith("$") ? v.slice(1) : v
1360
+ };
1361
+ })
1362
+ };
1363
+ if (/^(DEFINE|REMOVE|USE|INFO|RETURN|CANCEL|COMMIT|BEGIN)\b/i.test(s)) return { kind: "noop" };
1364
+ let m = /^CREATE\s+ONLY\s+\$(\w+)\s+CONTENT\s+\$(\w+)$/i.exec(s) || /^UPSERT\s+ONLY\s+\$(\w+)\s+REPLACE\s+\$(\w+)$/i.exec(s);
1365
+ if (m) return {
1366
+ kind: "upsert",
1367
+ id: rid(vars[m[1]]),
1368
+ data: asRow(vars[m[2]]),
1369
+ mode: "replace"
1370
+ };
1371
+ m = /^UPSERT\s+ONLY\s+\$(\w+)\s+MERGE\s+\$(\w+)$/i.exec(s) || /^UPDATE\s+ONLY\s+\$(\w+)\s+MERGE\s+\$(\w+)$/i.exec(s);
1372
+ if (m) return {
1373
+ kind: "upsert",
1374
+ id: rid(vars[m[1]]),
1375
+ data: asRow(vars[m[2]]),
1376
+ mode: "merge"
1377
+ };
1378
+ m = /^CREATE\s+ONLY\s+\$(\w+)\s+SET\s+(.+)$/i.exec(s);
1379
+ if (m) {
1380
+ const data = {};
1381
+ for (const { path, value } of parseSetClauses(m[2], vars)) setPath(data, path, value);
1382
+ return {
1383
+ kind: "upsert",
1384
+ id: rid(vars[m[1]]),
1385
+ data,
1386
+ mode: "replace"
1387
+ };
1388
+ }
1389
+ m = /^UPDATE\s+\$(\w+)\s+SET\s+(.+?)(\s+RETURN\s+NONE)?$/i.exec(s);
1390
+ if (m) return {
1391
+ kind: "updateSet",
1392
+ id: rid(vars[m[1]]),
1393
+ sets: parseSetClauses(m[2], vars),
1394
+ returnNone: !!m[3]
1395
+ };
1396
+ m = /^DELETE\s+\$(\w+)$/i.exec(s);
1397
+ if (m) return {
1398
+ kind: "delete",
1399
+ id: rid(vars[m[1]])
1400
+ };
1401
+ m = /^DELETE\s+([A-Za-z_]\w*)$/i.exec(s);
1402
+ if (m) return {
1403
+ kind: "deleteAll",
1404
+ table: m[1]
1405
+ };
1406
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+ONLY\s+\$(\w+)$/i.exec(s);
1407
+ if (m) {
1408
+ const value = m[1] ? m[2].trim() : void 0;
1409
+ return {
1410
+ kind: "getById",
1411
+ id: rid(vars[m[3]]),
1412
+ select: projFields(m[2], !!m[1]),
1413
+ value
1414
+ };
1415
+ }
1416
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(s);
1417
+ if (m) {
1418
+ const value = m[1] ? m[2].trim() : void 0;
1419
+ return {
1420
+ kind: "selectByIds",
1421
+ ids: vars[m[3]] ?? [],
1422
+ select: projFields(m[2], !!m[1]),
1423
+ value
1424
+ };
1425
+ }
1426
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+([A-Za-z_]\w*)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?$/i.exec(s);
1427
+ if (m) return {
1428
+ kind: "selectTable",
1429
+ table: m[3],
1430
+ where: m[4] ? parseWhere(m[4], vars) : void 0,
1431
+ orderBy: m[5] ? parseOrderBy(m[5]) : void 0,
1432
+ select: projFields(m[2], !!m[1]),
1433
+ value: m[1] ? m[2].trim() : void 0
1434
+ };
1435
+ throw new Error(`SqliteCacheEngine: unsupported SurrealQL for translation: ${stmt}`);
1436
+ }
1437
+ function projFields(proj, isValue) {
1438
+ const p = proj.trim();
1439
+ if (isValue) return void 0;
1440
+ if (p === "*") return void 0;
1441
+ return p.split(",").map((f) => f.trim());
1442
+ }
1443
+ function asRow(v) {
1444
+ return v && typeof v === "object" ? v : {};
1445
+ }
1446
+ /** Parse `a = $x, b.c = 'lit', _00_rv += 1` into path/op/value triples. */
1447
+ function parseSetClauses(clause, vars) {
1448
+ return splitTopLevel(clause, ",").map((part) => {
1449
+ const m = /^(.+?)\s*(\+=|-=|=)\s*([\s\S]+)$/.exec(part.trim());
1450
+ if (!m) throw new Error(`SqliteCacheEngine: cannot parse SET clause: ${part}`);
1451
+ return {
1452
+ path: m[1].trim(),
1453
+ op: m[2],
1454
+ value: literalOrVar(m[3], vars)
1455
+ };
1456
+ });
1457
+ }
1458
+ function literalOrVar(token, vars) {
1459
+ const t = token.trim();
1460
+ if (t.startsWith("$")) return vars[t.slice(1)];
1461
+ if (/^'.*'$/.test(t) || /^".*"$/.test(t)) return t.slice(1, -1);
1462
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
1463
+ if (t === "true") return true;
1464
+ if (t === "false") return false;
1465
+ if (t === "NONE" || t === "NULL" || t === "null") return null;
1466
+ return t;
1467
+ }
1468
+ function parseWhere(clause, vars) {
1469
+ return splitTopLevel(clause, "AND").map((cond) => {
1470
+ const m = /^(\S+)\s*(=|!=|>=|<=|>|<)\s*(.+)$/.exec(cond.trim());
1471
+ if (!m) throw new Error(`SqliteCacheEngine: cannot parse WHERE condition: ${cond}`);
1472
+ return {
1473
+ field: m[1],
1474
+ op: m[2],
1475
+ value: literalOrVar(m[3], vars)
1476
+ };
1477
+ });
1478
+ }
1479
+ function parseOrderBy(clause) {
1480
+ return clause.split(",").map((c) => {
1481
+ const [f, dir] = c.trim().split(/\s+/);
1482
+ return [f, (dir ?? "asc").toLowerCase() === "desc" ? "desc" : "asc"];
1483
+ });
1484
+ }
1485
+ /** Split on a top-level separator, ignoring parens/quotes (bounded inputs). */
1486
+ function splitTopLevel(input, sep) {
1487
+ const out = [];
1488
+ let depth = 0;
1489
+ let inStr = null;
1490
+ let cur = "";
1491
+ const isWord = /[A-Za-z]/.test(sep);
1492
+ for (let i = 0; i < input.length; i++) {
1493
+ const ch = input[i];
1494
+ if (inStr) {
1495
+ cur += ch;
1496
+ if (ch === inStr) inStr = null;
1497
+ continue;
1498
+ }
1499
+ if (ch === "'" || ch === "\"") {
1500
+ inStr = ch;
1501
+ cur += ch;
1502
+ continue;
1503
+ }
1504
+ if (ch === "(") depth++;
1505
+ if (ch === ")") depth--;
1506
+ if (isWord ? depth === 0 && input.slice(i, i + sep.length).toUpperCase() === sep && /\s/.test(input[i - 1] ?? " ") : depth === 0 && ch === sep) {
1507
+ out.push(cur.trim());
1508
+ cur = "";
1509
+ i += sep.length - 1;
1510
+ continue;
1511
+ }
1512
+ cur += ch;
1513
+ }
1514
+ if (cur.trim()) out.push(cur.trim());
1515
+ return out;
1516
+ }
1517
+ /** Read a possibly-dotted path (`a.b.c`) from an object. */
1518
+ function getPath(obj, path) {
1519
+ let cur = obj;
1520
+ for (const k of path.split(".")) {
1521
+ if (cur == null || typeof cur !== "object") return void 0;
1522
+ cur = cur[k];
1523
+ }
1524
+ return cur;
1525
+ }
1526
+ /** Set a possibly-dotted path (`a.b.c`) on an object. */
1527
+ function setPath(obj, path, value) {
1528
+ const parts = path.split(".");
1529
+ let cur = obj;
1530
+ for (let i = 0; i < parts.length - 1; i++) {
1531
+ const k = parts[i];
1532
+ if (typeof cur[k] !== "object" || cur[k] === null) cur[k] = {};
1533
+ cur = cur[k];
1534
+ }
1535
+ cur[parts[parts.length - 1]] = value;
1536
+ }
1537
+
1538
+ //#endregion
1539
+ //#region src/services/database/sqlite-cache-engine.ts
1540
+ /**
1541
+ * Local cache backend on official SQLite-WASM in a dedicated Worker (see
1542
+ * `sqlite-worker.ts`), with OPFS SAHPool persistence. Storage model: one table
1543
+ * per schema table, `id TEXT PRIMARY KEY, data TEXT` where `data` is the row as
1544
+ * JSON. Filtering/ordering use `json_extract`. Relations are decomposed by the
1545
+ * shared {@link resolveRelations} — identical to the SurrealDB backend.
1546
+ *
1547
+ * Value normalization (JSON round-trip):
1548
+ * - `Uint8Array`/bytes → `{ "__u8": <base64> }` (CRDT snapshots survive).
1549
+ * - Record links / ids → their `table:id` string form (so `json_extract`
1550
+ * comparisons and `IN` matching are consistent). NOTE: link fields therefore
1551
+ * read back as strings, not `RecordId` instances — the one shape difference
1552
+ * from the SurrealDB backend, to be closed with schema-driven revival + an
1553
+ * oracle E2E in the browser.
1554
+ */
1555
+ var SqliteCacheEngine = class {
1556
+ worker = null;
1557
+ seq = 0;
1558
+ pending = /* @__PURE__ */ new Map();
1559
+ storeEpoch = 0;
1560
+ knownTables = /* @__PURE__ */ new Set();
1561
+ useOpfs;
1562
+ events = createDatabaseEventSystem();
1563
+ bucketId = "anon";
1564
+ /** Schemaless — tables are created lazily on first write; no migrator. */
1565
+ usesSurqlSchema = false;
1566
+ constructor(config, logger, opts = {}) {
1567
+ this.config = config;
1568
+ this.logger = logger;
1569
+ this.useOpfs = opts.useOpfs ?? true;
1570
+ }
1571
+ get epoch() {
1572
+ return this.storeEpoch;
1573
+ }
1574
+ get currentBucketId() {
1575
+ return this.bucketId;
1576
+ }
1577
+ getConfig() {
1578
+ return this.config;
1579
+ }
1580
+ getEvents() {
1581
+ return this.events;
1582
+ }
1583
+ getClient() {
1584
+ throw new Error("SqliteCacheEngine has no SurrealDB client (getClient is unavailable).");
1585
+ }
1586
+ /** LocalStore alias; SQLite has no in-flight gate, so this maps to a rebuild. */
1587
+ switchStore(bucketId) {
1588
+ return this.switchBucket(bucketId);
1589
+ }
1590
+ /** SQLite has no switch gate window; the epoch bump alone fences stale writes. */
1591
+ beginSwitch() {
1592
+ return () => {};
1593
+ }
1594
+ spawnWorker() {
1595
+ const worker = new Worker(new URL("./sqlite-worker.js", import.meta.url), { type: "module" });
1596
+ worker.onmessage = (ev) => {
1597
+ const { id, ok, error, ...rest } = ev.data ?? {};
1598
+ const p = this.pending.get(id);
1599
+ if (!p) return;
1600
+ this.pending.delete(id);
1601
+ if (ok) p.resolve(rest);
1602
+ else p.reject(new Error(error));
1603
+ };
1604
+ const failAll = (msg) => {
1605
+ const err = /* @__PURE__ */ new Error(`SQLite worker crashed: ${msg}`);
1606
+ this.logger.error({
1607
+ err,
1608
+ Category: "sp00ky-client::SqliteCacheEngine::worker"
1609
+ }, "Worker error");
1610
+ for (const [, p] of this.pending) p.reject(err);
1611
+ this.pending.clear();
1612
+ };
1613
+ worker.onerror = (e) => failAll(e.message || "onerror");
1614
+ worker.onmessageerror = () => failAll("messageerror");
1615
+ return worker;
1616
+ }
1617
+ /** Serializes every worker op so reads/writes never overlap at the VFS layer
1618
+ * (overlapping ops trip SQLITE_BUSY). Mirrors the SurrealDB engine's
1619
+ * single-flight query queue. */
1620
+ opQueue = Promise.resolve();
1621
+ call(type, payload) {
1622
+ const run = () => this.rawCall(type, payload);
1623
+ const result = this.opQueue.then(run, run);
1624
+ this.opQueue = result.then(() => void 0, () => void 0);
1625
+ return result;
1626
+ }
1627
+ rawCall(type, payload) {
1628
+ if (!this.worker) throw new Error("SqliteCacheEngine: not connected");
1629
+ const id = ++this.seq;
1630
+ const s = getStats();
1631
+ s.roundTrips++;
1632
+ s.byType[type] = (s.byType[type] ?? 0) + 1;
1633
+ if (type === "batch" && Array.isArray(payload)) {
1634
+ s.batchStatements += payload.length;
1635
+ s.maxBatch = Math.max(s.maxBatch, payload.length);
1636
+ }
1637
+ s.inFlight++;
1638
+ s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
1639
+ const done = () => {
1640
+ s.inFlight--;
1641
+ };
1642
+ return new Promise((resolve, reject) => {
1643
+ this.pending.set(id, {
1644
+ resolve: (v) => {
1645
+ done();
1646
+ resolve(v);
1647
+ },
1648
+ reject: (e) => {
1649
+ done();
1650
+ reject(e);
1651
+ }
1652
+ });
1653
+ this.worker.postMessage({
1654
+ id,
1655
+ type,
1656
+ payload
1657
+ });
1658
+ });
1659
+ }
1660
+ async connect(bucketId) {
1661
+ this.worker = this.spawnWorker();
1662
+ const { persisted } = await this.call("open", {
1663
+ dbName: bucketId,
1664
+ useOpfs: this.useOpfs
1665
+ });
1666
+ this.knownTables.clear();
1667
+ this.bucketId = bucketId;
1668
+ this.logger.info({
1669
+ bucketId,
1670
+ persisted,
1671
+ Category: "sp00ky-client::SqliteCacheEngine::connect"
1672
+ }, persisted ? "SQLite OPFS store opened" : "SQLite in-memory store opened (no OPFS)");
1673
+ }
1674
+ async switchBucket(bucketId) {
1675
+ this.storeEpoch++;
1676
+ if (this.worker) {
1677
+ try {
1678
+ await this.call("close");
1679
+ } catch {}
1680
+ this.worker.terminate();
1681
+ this.worker = null;
1682
+ }
1683
+ await this.connect(bucketId);
1684
+ }
1685
+ async close() {
1686
+ if (!this.worker) return;
1687
+ try {
1688
+ await this.call("close");
1689
+ } catch {}
1690
+ this.worker.terminate();
1691
+ this.worker = null;
1692
+ }
1693
+ async ensureTable(table) {
1694
+ if (this.knownTables.has(table)) return;
1695
+ await this.call("run", { sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
1696
+ this.knownTables.add(table);
1697
+ }
1698
+ async execRows(sql, bind) {
1699
+ const { rows } = await this.call("exec", {
1700
+ sql,
1701
+ bind
1702
+ });
1703
+ return (rows ?? []).map((r) => reviveRow(r.data));
1704
+ }
1705
+ async select(plan, params = {}) {
1706
+ if (plan.ids) {
1707
+ const result = await this.selectByIds(plan.table, plan.ids, {
1708
+ select: plan.select,
1709
+ orderBy: plan.orderBy
1710
+ });
1711
+ await resolveRelations(result, plan.relations, this);
1712
+ return result;
1713
+ }
1714
+ await this.ensureTable(plan.table);
1715
+ const bind = [];
1716
+ let sql = `SELECT data FROM "${plan.table}"`;
1717
+ if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
1718
+ if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
1719
+ if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
1720
+ if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
1721
+ const rows = await this.execRows(sql, bind);
1722
+ const projected = plan.select ? rows.map((r) => project(r, plan.select)) : rows;
1723
+ await resolveRelations(projected, plan.relations, this);
1724
+ return projected;
1725
+ }
1726
+ async fetchRelation(req) {
1727
+ await this.ensureTable(req.table);
1728
+ const keys = req.keys.map(stableKey);
1729
+ const placeholders = keys.map(() => "?").join(", ");
1730
+ const bind = [...keys];
1731
+ const lhs = req.matchField === "id" ? "id" : `json_extract(data, '$.${req.matchField}')`;
1732
+ let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
1733
+ if (req.where && req.where.length > 0) sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
1734
+ if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
1735
+ const rows = await this.execRows(sql, bind);
1736
+ return req.select ? rows.map((r) => project(r, req.select)) : rows;
1737
+ }
1738
+ async selectByIds(table, ids, opts) {
1739
+ if (ids.length === 0) return [];
1740
+ await this.ensureTable(table);
1741
+ const keys = ids.map(stableKey);
1742
+ let sql = `SELECT data FROM "${table}" WHERE id IN (${keys.map(() => "?").join(", ")})`;
1743
+ if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
1744
+ let rows = await this.execRows(sql, keys);
1745
+ if (!opts?.orderBy || opts.orderBy.length === 0) {
1746
+ const pos = new Map(keys.map((k, i) => [k, i]));
1747
+ rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
1748
+ }
1749
+ return opts?.select ? rows.map((r) => project(r, opts.select)) : rows;
1750
+ }
1751
+ async getById(table, id) {
1752
+ await this.ensureTable(table);
1753
+ return (await this.execRows(`SELECT data FROM "${table}" WHERE id = ?`, [stableKey(id)]))[0] ?? null;
1754
+ }
1755
+ async upsert(table, id, data, mode) {
1756
+ await this.ensureTable(table);
1757
+ const key = stableKey(id);
1758
+ if (mode === "merge") {
1759
+ const full = serializeRow({
1760
+ ...data,
1761
+ id: key
1762
+ });
1763
+ await this.call("run", {
1764
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
1765
+ bind: [
1766
+ key,
1767
+ full,
1768
+ full
1769
+ ]
1770
+ });
1771
+ return;
1772
+ }
1773
+ await this.call("run", {
1774
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
1775
+ bind: [key, serializeRow({
1776
+ ...data,
1777
+ id: key
1778
+ })]
1779
+ });
1780
+ }
1781
+ async patch(table, id, patches) {
1782
+ const next = applyPatch(await this.getById(table, id) ?? { id: stableKey(id) }, patches).newDocument;
1783
+ await this.upsert(table, id, next, "replace");
1784
+ }
1785
+ async delete(table, id) {
1786
+ await this.ensureTable(table);
1787
+ await this.call("run", {
1788
+ sql: `DELETE FROM "${table}" WHERE id = ?`,
1789
+ bind: [stableKey(id)]
1790
+ });
1791
+ }
1792
+ /**
1793
+ * Execute a raw SurrealQL statement by translating the client's bounded
1794
+ * vocabulary to verbs (see `surql-translate.ts`). Returns results shaped like
1795
+ * SurrealDB's `.query()` (one element per statement; a tx prepends a `null`
1796
+ * begin-result so `surql.seal` extraction lines up). Epoch-fences writes.
1797
+ */
1798
+ async query(sql, vars = {}, opts) {
1799
+ if (opts?.epoch !== void 0 && opts.epoch !== this.storeEpoch) throw new StaleEpochError();
1800
+ const start = performance.now();
1801
+ try {
1802
+ const { transaction, ops } = translateSurql(sql, vars);
1803
+ let shaped;
1804
+ if (transaction && ops.every((o) => o.kind === "upsert" || o.kind === "delete" || o.kind === "deleteAll" || o.kind === "noop")) {
1805
+ await this.runWriteBatch(ops);
1806
+ shaped = [null, ...ops.map(() => [])];
1807
+ } else {
1808
+ const results = [];
1809
+ const scope = {};
1810
+ for (const op of ops) results.push(await this.execOp(op, scope, vars));
1811
+ shaped = transaction ? [null, ...results] : results;
1812
+ }
1813
+ this.events.emit(DatabaseEventTypes.LocalQuery, {
1814
+ query: sql,
1815
+ vars,
1816
+ duration: performance.now() - start,
1817
+ success: true,
1818
+ timestamp: Date.now()
1819
+ });
1820
+ return shaped;
1821
+ } catch (err) {
1822
+ this.events.emit(DatabaseEventTypes.LocalQuery, {
1823
+ query: sql,
1824
+ vars,
1825
+ duration: performance.now() - start,
1826
+ success: false,
1827
+ error: err instanceof Error ? err.message : String(err),
1828
+ timestamp: Date.now()
1829
+ });
1830
+ throw err;
1831
+ }
1832
+ }
1833
+ async execute(query, vars, opts) {
1834
+ const raw = await this.query(query.sql, vars, opts);
1835
+ return query.extract(raw);
1836
+ }
1837
+ queryUngated(sql, vars) {
1838
+ return this.query(sql, vars);
1839
+ }
1840
+ async execOp(op, scope, vars) {
1841
+ switch (op.kind) {
1842
+ case "getById": {
1843
+ const row = await this.getById(tableOf(op.id), op.id);
1844
+ if (op.value) return row ? row[op.value] ?? null : null;
1845
+ return row ? op.select ? project(row, op.select) : row : null;
1846
+ }
1847
+ case "selectByIds": {
1848
+ if (op.ids.length === 0) return [];
1849
+ let rows = await this.selectByIds(tableOf(op.ids[0]), op.ids, {
1850
+ select: op.select,
1851
+ orderBy: op.orderBy
1852
+ });
1853
+ if (op.value) return rows.map((r) => r[op.value]);
1854
+ return rows;
1855
+ }
1856
+ case "selectTable": {
1857
+ const rows = await this.rawSelectTable(op.table, op.where, op.orderBy);
1858
+ if (op.value) return rows.map((r) => r[op.value]);
1859
+ return op.select ? rows.map((r) => project(r, op.select)) : rows;
1860
+ }
1861
+ case "upsert":
1862
+ await this.upsert(tableOf(op.id), op.id, op.data, op.mode);
1863
+ return {
1864
+ ...op.data,
1865
+ id: stableKey(op.id)
1866
+ };
1867
+ case "updateSet": {
1868
+ const existing = await this.getById(tableOf(op.id), op.id) ?? { id: stableKey(op.id) };
1869
+ for (const { path, op: setOp, value } of op.sets) if (setOp === "+=" || setOp === "-=") {
1870
+ const cur = Number(getPath(existing, path) ?? 0);
1871
+ const delta = Number(value ?? 0);
1872
+ setPath(existing, path, setOp === "+=" ? cur + delta : cur - delta);
1873
+ } else setPath(existing, path, value);
1874
+ await this.upsert(tableOf(op.id), op.id, existing, "replace");
1875
+ return op.returnNone ? null : existing;
1876
+ }
1877
+ case "delete":
1878
+ await this.delete(tableOf(op.id), op.id);
1879
+ return [];
1880
+ case "deleteAll":
1881
+ await this.ensureTable(op.table);
1882
+ await this.call("run", { sql: `DELETE FROM "${op.table}"` });
1883
+ return [];
1884
+ case "let": {
1885
+ let result = await this.execOp(op.inner, scope, vars);
1886
+ if (op.inner.kind === "upsert") result = await this.getById(tableOf(op.inner.id), op.inner.id) ?? result;
1887
+ scope[op.var] = result;
1888
+ return result;
1889
+ }
1890
+ case "return": {
1891
+ const obj = {};
1892
+ for (const { key, var: v } of op.entries) obj[key] = v in scope ? scope[v] : vars[v];
1893
+ return obj;
1894
+ }
1895
+ case "noop": return null;
1896
+ }
1897
+ }
1898
+ /**
1899
+ * Compile a pure-write op list to ONE worker `batch` (single SQLite
1900
+ * transaction). Ensures each touched table exists, then one statement per op.
1901
+ * Merges happen in-SQL via json_patch — no read-back round-trips.
1902
+ */
1903
+ async runWriteBatch(ops) {
1904
+ const stmts = [];
1905
+ const tables = /* @__PURE__ */ new Set();
1906
+ const ensure = (t) => {
1907
+ if (!tables.has(t)) {
1908
+ tables.add(t);
1909
+ this.knownTables.add(t);
1910
+ stmts.push({ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
1911
+ }
1912
+ };
1913
+ for (const op of ops) if (op.kind === "upsert") {
1914
+ const t = tableOf(op.id);
1915
+ const key = stableKey(op.id);
1916
+ ensure(t);
1917
+ if (op.mode === "merge") {
1918
+ const full = serializeRow({
1919
+ ...op.data,
1920
+ id: key
1921
+ });
1922
+ stmts.push({
1923
+ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
1924
+ bind: [
1925
+ key,
1926
+ full,
1927
+ full
1928
+ ]
1929
+ });
1930
+ } else stmts.push({
1931
+ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
1932
+ bind: [key, serializeRow({
1933
+ ...op.data,
1934
+ id: key
1935
+ })]
1936
+ });
1937
+ } else if (op.kind === "delete") {
1938
+ const t = tableOf(op.id);
1939
+ ensure(t);
1940
+ stmts.push({
1941
+ sql: `DELETE FROM "${t}" WHERE id = ?`,
1942
+ bind: [stableKey(op.id)]
1943
+ });
1944
+ } else if (op.kind === "deleteAll") {
1945
+ ensure(op.table);
1946
+ stmts.push({ sql: `DELETE FROM "${op.table}"` });
1947
+ }
1948
+ if (stmts.length > 0) await this.call("batch", stmts);
1949
+ }
1950
+ async rawSelectTable(table, where, orderBy) {
1951
+ await this.ensureTable(table);
1952
+ const bind = [];
1953
+ let sql = `SELECT data FROM "${table}"`;
1954
+ if (where && where.length > 0) sql += ` WHERE ${renderWhereSql(where, bind, {})}`;
1955
+ if (orderBy && orderBy.length > 0) sql += renderOrderSql(orderBy);
1956
+ return this.execRows(sql, bind);
1957
+ }
1958
+ async transaction(fn) {
1959
+ return fn({
1960
+ upsert: (t, id, data, mode) => this.upsert(t, id, data, mode),
1961
+ patch: (t, id, p) => this.patch(t, id, p),
1962
+ delete: (t, id) => this.delete(t, id)
1963
+ });
1964
+ }
1965
+ };
1966
+ /** Live stats, inspectable in the browser console via `__sqliteStats`. Counts
1967
+ * worker round-trips (the sync-down cost driver), batch sizes, and queue depth
1968
+ * (`maxInFlight`) so a churn OOM can be measured rather than guessed. */
1969
+ function getStats() {
1970
+ const g = globalThis;
1971
+ if (!g.__sqliteStats) g.__sqliteStats = {
1972
+ roundTrips: 0,
1973
+ batchStatements: 0,
1974
+ maxBatch: 0,
1975
+ inFlight: 0,
1976
+ maxInFlight: 0,
1977
+ byType: {}
1978
+ };
1979
+ return g.__sqliteStats;
1980
+ }
1981
+ function renderOrderSql(orderBy) {
1982
+ return ` ORDER BY ${orderBy.map(([f, d]) => `json_extract(data, '$.${f}') ${d === "desc" ? "DESC" : "ASC"}`).join(", ")}`;
1983
+ }
1984
+ function comparisonSql(c, bind, params) {
1985
+ const lhs = c.field === "id" ? "id" : `json_extract(data, '$.${c.field}')`;
1986
+ const value = c.paramRef ? params[c.paramRef] : c.value;
1987
+ bind.push(scalar(value));
1988
+ const op = c.op === "!=" ? "!=" : c.op;
1989
+ return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
1990
+ }
1991
+ function renderWhereSql(nodes, bind, params) {
1992
+ return nodes.map((node) => {
1993
+ if ("or" in node) return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(" OR ")})`;
1994
+ return comparisonSql(node, bind, params);
1995
+ }).join(" AND ");
1996
+ }
1997
+ /** A comparable scalar for SQL binding: record links → `table:id`, everything
1998
+ * else passed through (numbers/strings/bools). */
1999
+ function scalar(value) {
2000
+ if (value == null) return null;
2001
+ if (typeof value === "object") return stableKey(value);
2002
+ return value;
2003
+ }
2004
+ function serializeRow(row) {
2005
+ return JSON.stringify(row, (_k, v) => {
2006
+ if (v instanceof Uint8Array) return { __u8: toBase64(v) };
2007
+ if (v && typeof v === "object") {
2008
+ const rid = v;
2009
+ if (rid.tb !== void 0 && rid.id !== void 0) return stableKey(v);
2010
+ }
2011
+ return v;
2012
+ });
2013
+ }
2014
+ function reviveRow(json) {
2015
+ if (json.indexOf("\"__u8\"") === -1) return JSON.parse(json);
2016
+ return JSON.parse(json, (_k, v) => {
2017
+ if (v && typeof v === "object" && typeof v.__u8 === "string") return fromBase64(v.__u8);
2018
+ return v;
2019
+ });
2020
+ }
2021
+ function project(row, fields) {
2022
+ const out = {};
2023
+ for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
2024
+ return out;
2025
+ }
2026
+ function toBase64(bytes) {
2027
+ let bin = "";
2028
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
2029
+ return typeof btoa !== "undefined" ? btoa(bin) : Buffer.from(bytes).toString("base64");
2030
+ }
2031
+ function fromBase64(b64) {
2032
+ if (typeof atob !== "undefined") {
2033
+ const bin = atob(b64);
2034
+ const out = new Uint8Array(bin.length);
2035
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
2036
+ return out;
2037
+ }
2038
+ return new Uint8Array(Buffer.from(b64, "base64"));
2039
+ }
2040
+
2041
+ //#endregion
2042
+ //#region src/services/database/engine-factory.ts
2043
+ /**
2044
+ * Build the local cache engine for the given `localEngine` config choice.
1067
2045
  *
1068
- * Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
1069
- * drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
1070
- * projection are preserved verbatim (the scanner is paren/quote aware), with
1071
- * one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
1072
- * shape such windowed+subquery queries are rare and were already returning 0,
1073
- * so this is no regression.
2046
+ * - `'surrealdb'` / unset {@link SurrealCacheEngine} (subclass of
2047
+ * `LocalDatabaseService`; the historical behavior, verbatim).
2048
+ * - `'sqlite'` {@link SqliteCacheEngine} (SQLite-WASM Worker + OPFS). Backs
2049
+ * `this.local` through the SurrealQL-vocabulary shim + verb surface.
2050
+ * - a custom object used as-is (must satisfy {@link LocalStore}).
2051
+ */
2052
+ function createLocalEngine(choice, config, logger) {
2053
+ if (choice === void 0 || choice === "surrealdb") return new SurrealCacheEngine(config, logger);
2054
+ if (choice === "sqlite") return new SqliteCacheEngine(config, logger, { useOpfs: (config.store ?? "memory") !== "memory" });
2055
+ return choice;
2056
+ }
2057
+
2058
+ //#endregion
2059
+ //#region src/modules/data/window-query.ts
2060
+ /**
2061
+ * Plan-level window materialization: restrict a windowed query's base rows to
2062
+ * exactly the SSP-computed id-set, dropping `where`/`limit`/`offset` but keeping
2063
+ * `orderBy`, `select` and `relations`. The engine-neutral counterpart of
2064
+ * {@link buildWindowMaterialization} (which does the same by string surgery for
2065
+ * the raw-SurrealQL path). Returns `null` for non-offset queries so the caller
2066
+ * keeps the normal re-query path.
1074
2067
  */
2068
+ function buildWindowMaterializationPlan(plan, ids) {
2069
+ if (plan.offset === void 0 || plan.offset <= 0) return null;
2070
+ return {
2071
+ ...plan,
2072
+ ids,
2073
+ where: void 0,
2074
+ limit: void 0,
2075
+ offset: void 0
2076
+ };
2077
+ }
1075
2078
  function buildWindowMaterialization(surql, idsParam = "__win") {
1076
2079
  const kw = scanTopLevelClauses(surql);
1077
2080
  if (kw.startValue === null || kw.startValue <= 0) return null;
@@ -1283,7 +2286,7 @@ var DataModule = class {
1283
2286
  /**
1284
2287
  * Register a query and return its hash for subscriptions
1285
2288
  */
1286
- async query(tableName, surqlString, params, ttl) {
2289
+ async query(tableName, surqlString, params, ttl, plan) {
1287
2290
  const hash = await this.calculateHash({
1288
2291
  surql: surqlString,
1289
2292
  params
@@ -1312,7 +2315,7 @@ var DataModule = class {
1312
2315
  hash,
1313
2316
  Category: "sp00ky-client::DataModule::query"
1314
2317
  }, "Query Initialization: not found, creating new query");
1315
- const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
2318
+ const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName, plan);
1316
2319
  this.pendingQueries.set(hash, promise);
1317
2320
  try {
1318
2321
  await promise;
@@ -1443,16 +2446,26 @@ var DataModule = class {
1443
2446
  }
1444
2447
  async materializeRecords(queryState, sspArray) {
1445
2448
  const t0 = performance.now();
2449
+ const plan = queryState.config.plan;
1446
2450
  const windowMat = buildWindowMaterialization(queryState.config.surql);
1447
2451
  let records;
1448
2452
  if (windowMat) {
1449
2453
  const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
1450
- const [rows] = await this.local.query(windowMat.query, {
1451
- ...queryState.config.params,
1452
- __win: winIds
1453
- });
1454
- records = rows || [];
1455
- } else {
2454
+ if (plan) {
2455
+ const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? {
2456
+ ...plan,
2457
+ ids: winIds
2458
+ };
2459
+ records = await this.local.select(winPlan, queryState.config.params);
2460
+ } else {
2461
+ const [rows] = await this.local.query(windowMat.query, {
2462
+ ...queryState.config.params,
2463
+ __win: winIds
2464
+ });
2465
+ records = rows || [];
2466
+ }
2467
+ } else if (plan) records = await this.local.select(plan, queryState.config.params);
2468
+ else {
1456
2469
  const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
1457
2470
  records = rows || [];
1458
2471
  }
@@ -2128,13 +3141,14 @@ var DataModule = class {
2128
3141
  }
2129
3142
  }
2130
3143
  }
2131
- async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName) {
3144
+ async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName, plan) {
2132
3145
  const queryState = await this.createNewQuery({
2133
3146
  recordId,
2134
3147
  surql: surqlString,
2135
3148
  params,
2136
3149
  ttl,
2137
- tableName
3150
+ tableName,
3151
+ plan
2138
3152
  });
2139
3153
  const t0 = performance.now();
2140
3154
  const { localArray, registrationTimings } = this.cache.registerQuery({
@@ -2164,11 +3178,19 @@ var DataModule = class {
2164
3178
  const windowMat = buildWindowMaterialization(surqlString);
2165
3179
  if (windowMat && localArray.length > 0) try {
2166
3180
  const winIds = localArray.map(([id]) => parseRecordIdString(id));
2167
- const [seeded] = await this.local.query(windowMat.query, {
2168
- ...params,
2169
- __win: winIds
2170
- });
2171
- queryState.records = seeded || [];
3181
+ if (plan) {
3182
+ const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? {
3183
+ ...plan,
3184
+ ids: winIds
3185
+ };
3186
+ queryState.records = await this.local.select(winPlan, params);
3187
+ } else {
3188
+ const [seeded] = await this.local.query(windowMat.query, {
3189
+ ...params,
3190
+ __win: winIds
3191
+ });
3192
+ queryState.records = seeded || [];
3193
+ }
2172
3194
  } catch (err) {
2173
3195
  this.logger.warn({
2174
3196
  err,
@@ -2186,7 +3208,7 @@ var DataModule = class {
2186
3208
  }, "Query registered");
2187
3209
  return hash;
2188
3210
  }
2189
- async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName }) {
3211
+ async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName, plan }) {
2190
3212
  const tableSchema = this.schema.tables.find((t) => t.name === tableName);
2191
3213
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
2192
3214
  let [configRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: recordId }));
@@ -2212,12 +3234,12 @@ var DataModule = class {
2212
3234
  const config = {
2213
3235
  ...configRecord,
2214
3236
  id: recordId,
3237
+ plan,
2215
3238
  params: parseParams(tableSchema.columns, configRecord.params)
2216
3239
  };
2217
3240
  let records = [];
2218
3241
  if (buildWindowMaterialization(surqlString) === null) try {
2219
- const [result] = await this.local.query(surqlString, params);
2220
- records = result || [];
3242
+ records = (plan ? await this.local.select(plan, params) : (await this.local.query(surqlString, params))[0]) || [];
2221
3243
  } catch (err) {
2222
3244
  this.logger.warn({
2223
3245
  err,
@@ -3933,8 +4955,8 @@ function parseBackendInfo(raw) {
3933
4955
 
3934
4956
  //#endregion
3935
4957
  //#region src/modules/devtools/index.ts
3936
- const CORE_VERSION = "0.0.1-canary.107";
3937
- const WASM_VERSION = "0.0.1-canary.107";
4958
+ const CORE_VERSION = "0.0.1-canary.109";
4959
+ const WASM_VERSION = "0.0.1-canary.109";
3938
4960
  const SURREAL_VERSION = "3.0.3";
3939
4961
  var DevToolsService = class {
3940
4962
  eventsHistory = [];
@@ -5936,7 +6958,7 @@ var Sp00kyClient = class {
5936
6958
  },
5937
6959
  Category: "sp00ky-client::Sp00kyClient::constructor"
5938
6960
  }, "Sp00kyClient initialized");
5939
- this.local = new LocalDatabaseService(this.config.database, logger);
6961
+ this.local = createLocalEngine(this.config.localEngine, this.config.database, logger);
5940
6962
  this.remote = new RemoteDatabaseService(this.config.database, logger);
5941
6963
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
5942
6964
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
@@ -6029,8 +7051,10 @@ var Sp00kyClient = class {
6029
7051
  bootBucket,
6030
7052
  Category: "sp00ky-client::Sp00kyClient::init"
6031
7053
  }, "Local database connected");
6032
- await this.migrator.provision(this.config.schemaSurql);
6033
- this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
7054
+ if (this.local.usesSurqlSchema) {
7055
+ await this.migrator.provision(this.config.schemaSurql);
7056
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
7057
+ }
6034
7058
  await this.remote.connect();
6035
7059
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
6036
7060
  this.streamProcessor.setStateKeySuffix(bootBucket);
@@ -6127,7 +7151,7 @@ var Sp00kyClient = class {
6127
7151
  const reopen = this.local.beginSwitch();
6128
7152
  try {
6129
7153
  await this.local.switchStore(target);
6130
- await this.migrator.provision(this.config.schemaSurql);
7154
+ if (this.local.usesSurqlSchema) await this.migrator.provision(this.config.schemaSurql);
6131
7155
  await this.local.queryUngated("DELETE _00_query;");
6132
7156
  this.streamProcessor.setStateKeySuffix(target);
6133
7157
  await this.streamProcessor.reset();
@@ -6202,7 +7226,7 @@ var Sp00kyClient = class {
6202
7226
  const tableSchema = this.config.schema.tables.find((t) => t.name === table);
6203
7227
  if (!tableSchema) throw new Error(`Table ${table} not found`);
6204
7228
  const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
6205
- const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
7229
+ const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl, q.selectQuery.plan);
6206
7230
  if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
6207
7231
  const [rows] = await this.remote.query(q.selectQuery.query, params);
6208
7232
  await this.dataModule.applyHydration(hash, rows ?? []);