@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.
- package/dist/index.d.ts +10 -114
- package/dist/index.js +1070 -46
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +157 -2
- package/package.json +4 -3
- package/src/modules/cache/index.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +3 -3
- package/src/modules/crdt/crdt-hydration.test.ts +5 -5
- package/src/modules/crdt/index.ts +2 -2
- package/src/modules/data/index.ts +45 -17
- package/src/modules/data/window-query.ts +24 -0
- package/src/modules/devtools/index.ts +2 -2
- package/src/modules/sync/queue/queue-down.ts +2 -2
- package/src/modules/sync/queue/queue-up.ts +2 -2
- package/src/modules/sync/sync.ts +2 -2
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +2 -2
- package/src/services/database/plan-render.test.ts +85 -0
- package/src/services/database/plan-render.ts +86 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/sqlite-cache-engine.ts +631 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +122 -0
- package/src/services/persistence/surrealdb.ts +2 -2
- package/src/services/stream-processor/index.ts +2 -2
- package/src/sp00ky.ts +24 -7
- package/src/types.ts +17 -1
- 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/
|
|
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
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
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
|
-
*
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
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
|
-
*
|
|
1066
|
-
*
|
|
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
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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
|
-
|
|
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.
|
|
3937
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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 =
|
|
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
|
-
|
|
6033
|
-
|
|
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 ?? []);
|