@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.71
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/AGENTS.md +56 -0
- package/dist/index.d.ts +299 -369
- package/dist/index.js +2278 -399
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +460 -0
- package/package.json +37 -7
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +3 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +17 -20
- package/src/modules/cache/index.ts +41 -29
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +281 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +352 -0
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +662 -108
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +89 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +97 -37
- package/src/modules/sync/events/index.ts +3 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +2 -2
- package/src/modules/sync/sync.ts +553 -58
- package/src/modules/sync/utils.test.ts +239 -2
- package/src/modules/sync/utils.ts +166 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +192 -32
- package/src/services/database/remote.ts +13 -13
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +9 -9
- package/src/services/stream-processor/index.ts +205 -36
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +582 -0
- package/src/types.ts +132 -13
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +55 -1
- package/src/spooky.ts +0 -392
package/dist/index.js
CHANGED
|
@@ -2,13 +2,14 @@ import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRe
|
|
|
2
2
|
import { QueryBuilder, RecordId as RecordId$1 } from "@spooky-sync/query-builder";
|
|
3
3
|
import { createWasmWorkerEngines } from "@surrealdb/wasm";
|
|
4
4
|
import pino from "pino";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
8
|
-
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
9
|
-
import { createContextKey } from "@opentelemetry/api";
|
|
10
|
-
import init, { SpookyProcessor } from "@spooky-sync/ssp-wasm";
|
|
5
|
+
import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
|
|
6
|
+
import { LoroDoc } from "loro-crdt";
|
|
11
7
|
|
|
8
|
+
//#region src/types.ts
|
|
9
|
+
/** Cap on the rolling materialization-sample window kept per query in memory. */
|
|
10
|
+
const MATERIALIZATION_SAMPLE_WINDOW = 100;
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
12
13
|
//#region src/utils/surql.ts
|
|
13
14
|
const surql = {
|
|
14
15
|
seal(query, options) {
|
|
@@ -33,7 +34,7 @@ const surql = {
|
|
|
33
34
|
return `SELECT ${returnValues.join(",")} FROM ONLY $${idVar}`;
|
|
34
35
|
},
|
|
35
36
|
selectByFieldsAnd(table, whereVar, returnValues) {
|
|
36
|
-
return `SELECT ${returnValues.map((
|
|
37
|
+
return `SELECT ${returnValues.map((rv) => typeof rv === "string" ? rv : `${rv.field} as ${rv.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((wv) => typeof wv === "string" ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`).join(" AND ")}`;
|
|
37
38
|
},
|
|
38
39
|
create(idVar, dataVar) {
|
|
39
40
|
return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
|
|
@@ -44,11 +45,14 @@ const surql = {
|
|
|
44
45
|
upsert(idVar, dataVar) {
|
|
45
46
|
return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
|
|
46
47
|
},
|
|
48
|
+
upsertMerge(idVar, dataVar) {
|
|
49
|
+
return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
|
|
50
|
+
},
|
|
47
51
|
updateMerge(idVar, dataVar) {
|
|
48
52
|
return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
|
|
49
53
|
},
|
|
50
54
|
updateSet(idVar, keyDataVar) {
|
|
51
|
-
return `UPDATE $${idVar} SET ${keyDataVar.map((
|
|
55
|
+
return `UPDATE $${idVar} SET ${keyDataVar.map((kdv) => typeof kdv === "string" ? `${kdv} = $${kdv}` : "statement" in kdv ? kdv.statement : `${kdv.key} = $${kdv.variable}`).join(", ")}`;
|
|
52
56
|
},
|
|
53
57
|
delete(idVar) {
|
|
54
58
|
return `DELETE $${idVar}`;
|
|
@@ -76,7 +80,7 @@ const surql = {
|
|
|
76
80
|
//#region src/utils/parser.ts
|
|
77
81
|
function cleanRecord(tableSchema, record) {
|
|
78
82
|
const cleaned = {};
|
|
79
|
-
for (const [key, value] of Object.entries(record)) if (key === "id" || key in tableSchema) cleaned[key] = value;
|
|
83
|
+
for (const [key, value] of Object.entries(record)) if (key === "id" || key.startsWith("_00_") || key in tableSchema) cleaned[key] = value;
|
|
80
84
|
return cleaned;
|
|
81
85
|
}
|
|
82
86
|
function parseParams(tableSchema, params) {
|
|
@@ -148,12 +152,21 @@ function generateId() {
|
|
|
148
152
|
return Uuid.v4().toString().replace(/-/g, "");
|
|
149
153
|
}
|
|
150
154
|
/**
|
|
155
|
+
* Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
|
|
156
|
+
* releases expose it under `milliseconds` (current) or the older private
|
|
157
|
+
* `_milliseconds` field, so read whichever is set; returns 0 when neither is.
|
|
158
|
+
*/
|
|
159
|
+
function durationMillis(duration) {
|
|
160
|
+
const d = duration;
|
|
161
|
+
return Number(d.milliseconds || d._milliseconds || 0);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
151
164
|
* Parse duration string or Duration object to milliseconds
|
|
152
165
|
*/
|
|
153
166
|
function parseDuration(duration) {
|
|
154
167
|
if (duration instanceof Duration) {
|
|
155
|
-
const ms = duration
|
|
156
|
-
if (ms) return
|
|
168
|
+
const ms = durationMillis(duration);
|
|
169
|
+
if (ms) return ms;
|
|
157
170
|
const str = duration.toString();
|
|
158
171
|
if (str !== "[object Object]") return parseDuration(str);
|
|
159
172
|
return 6e5;
|
|
@@ -162,7 +175,7 @@ function parseDuration(duration) {
|
|
|
162
175
|
if (typeof duration !== "string") return 6e5;
|
|
163
176
|
const match = duration.match(/^(\d+)([smh])$/);
|
|
164
177
|
if (!match) return 6e5;
|
|
165
|
-
const val = parseInt(match[1], 10);
|
|
178
|
+
const val = Number.parseInt(match[1], 10);
|
|
166
179
|
switch (match[2]) {
|
|
167
180
|
case "s": return val * 1e3;
|
|
168
181
|
case "h": return val * 36e5;
|
|
@@ -174,6 +187,13 @@ async function fileToUint8Array(file) {
|
|
|
174
187
|
return new Uint8Array(buffer);
|
|
175
188
|
}
|
|
176
189
|
/**
|
|
190
|
+
* Convert plain text to simple HTML paragraphs.
|
|
191
|
+
* Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
|
|
192
|
+
*/
|
|
193
|
+
function textToHtml(text) {
|
|
194
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").split("\n").map((l) => `<p>${l || "<br>"}</p>`).join("");
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
177
197
|
* Helper for retrying DB operations with exponential backoff
|
|
178
198
|
*/
|
|
179
199
|
async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
@@ -188,7 +208,7 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
188
208
|
attempt: i + 1,
|
|
189
209
|
retries,
|
|
190
210
|
error: msg,
|
|
191
|
-
Category: "
|
|
211
|
+
Category: "sp00ky-client::utils::withRetry"
|
|
192
212
|
}, "Retrying DB operation");
|
|
193
213
|
await new Promise((res) => setTimeout(res, delayMs * (i + 1)));
|
|
194
214
|
continue;
|
|
@@ -198,8 +218,160 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
198
218
|
throw lastError;
|
|
199
219
|
}
|
|
200
220
|
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/modules/data/window-query.ts
|
|
223
|
+
/**
|
|
224
|
+
* Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
|
|
225
|
+
* materialized from an explicit record-id set — the window the SSP already
|
|
226
|
+
* computed — instead of re-running the original query (with its `START m`)
|
|
227
|
+
* against the shared local DB.
|
|
228
|
+
*
|
|
229
|
+
* Why: `DataModule.processStreamUpdate` materializes a query's rows by
|
|
230
|
+
* re-querying the local in-browser SurrealDB with the original surql. For an
|
|
231
|
+
* offset query that re-applies `START m` against the *shared* local store —
|
|
232
|
+
* which, with sparse windowing, may hold only this window's rows — so it skips
|
|
233
|
+
* them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
|
|
234
|
+
* materialized view (`StreamUpdate.localArray`) is exactly this window's row
|
|
235
|
+
* ids, so we select those ids directly and re-apply the original `ORDER BY` for
|
|
236
|
+
* stable display order.
|
|
237
|
+
*
|
|
238
|
+
* Returns `null` for non-offset queries (`START` absent or 0) — the caller
|
|
239
|
+
* keeps the normal re-query path, so only the broken case changes behavior.
|
|
240
|
+
*
|
|
241
|
+
* Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
|
|
242
|
+
* drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
|
|
243
|
+
* projection are preserved verbatim (the scanner is paren/quote aware), with
|
|
244
|
+
* one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
|
|
245
|
+
* shape — such windowed+subquery queries are rare and were already returning 0,
|
|
246
|
+
* so this is no regression.
|
|
247
|
+
*/
|
|
248
|
+
function buildWindowMaterialization(surql, idsParam = "__win") {
|
|
249
|
+
const kw = scanTopLevelClauses(surql);
|
|
250
|
+
if (kw.startValue === null || kw.startValue <= 0) return null;
|
|
251
|
+
if (kw.fromIndex === null) return null;
|
|
252
|
+
const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
|
|
253
|
+
let orderBy = "";
|
|
254
|
+
if (kw.orderByIndex !== null) {
|
|
255
|
+
const ends = [
|
|
256
|
+
kw.limitIndex,
|
|
257
|
+
kw.startIndex,
|
|
258
|
+
kw.semicolonIndex,
|
|
259
|
+
surql.length
|
|
260
|
+
].filter((n) => n !== null && n > kw.orderByIndex);
|
|
261
|
+
const end = Math.min(...ends);
|
|
262
|
+
orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
|
|
263
|
+
}
|
|
264
|
+
return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
|
|
265
|
+
}
|
|
266
|
+
function scanTopLevelClauses(sql) {
|
|
267
|
+
const out = {
|
|
268
|
+
fromIndex: null,
|
|
269
|
+
orderByIndex: null,
|
|
270
|
+
limitIndex: null,
|
|
271
|
+
startIndex: null,
|
|
272
|
+
startValue: null,
|
|
273
|
+
semicolonIndex: null
|
|
274
|
+
};
|
|
275
|
+
let depth = 0;
|
|
276
|
+
let inStr = false;
|
|
277
|
+
for (let i = 0; i < sql.length; i++) {
|
|
278
|
+
const ch = sql[i];
|
|
279
|
+
if (inStr) {
|
|
280
|
+
if (ch === "\\") i++;
|
|
281
|
+
else if (ch === "'") inStr = false;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (ch === "'") {
|
|
285
|
+
inStr = true;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (ch === "(") {
|
|
289
|
+
depth++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (ch === ")") {
|
|
293
|
+
depth--;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (depth !== 0) continue;
|
|
297
|
+
if (ch === ";" && out.semicolonIndex === null) {
|
|
298
|
+
out.semicolonIndex = i;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (!isWordBoundary(sql, i)) continue;
|
|
302
|
+
if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
|
|
303
|
+
out.fromIndex = i;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (out.fromIndex === null) continue;
|
|
307
|
+
if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
|
|
308
|
+
out.orderByIndex = i;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
|
|
312
|
+
out.limitIndex = i;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (out.startIndex === null && matchKeyword(sql, i, "START")) {
|
|
316
|
+
out.startIndex = i;
|
|
317
|
+
out.startValue = readNumberAfter(sql, i + 5);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
function isWordBoundary(sql, i) {
|
|
324
|
+
if (i === 0) return true;
|
|
325
|
+
return !/[A-Za-z0-9_]/.test(sql[i - 1]);
|
|
326
|
+
}
|
|
327
|
+
function matchKeyword(sql, i, keyword) {
|
|
328
|
+
const parts = keyword.split(" ");
|
|
329
|
+
let pos = i;
|
|
330
|
+
for (let p = 0; p < parts.length; p++) {
|
|
331
|
+
const word = parts[p];
|
|
332
|
+
if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
|
|
333
|
+
pos += word.length;
|
|
334
|
+
if (p < parts.length - 1) {
|
|
335
|
+
const wsStart = pos;
|
|
336
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
337
|
+
if (pos === wsStart) return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
|
|
341
|
+
}
|
|
342
|
+
function readNumberAfter(sql, from) {
|
|
343
|
+
let pos = from;
|
|
344
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
345
|
+
const m = /^\d+/.exec(sql.slice(pos));
|
|
346
|
+
return m ? parseInt(m[0], 10) : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
201
349
|
//#endregion
|
|
202
350
|
//#region src/modules/data/index.ts
|
|
351
|
+
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
352
|
+
function pushSample(samples, ms) {
|
|
353
|
+
samples.push(ms);
|
|
354
|
+
if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
|
|
355
|
+
}
|
|
356
|
+
/** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
|
|
357
|
+
function phaseStatOf(samples, lastMs) {
|
|
358
|
+
if (samples.length === 0) return {
|
|
359
|
+
lastMs,
|
|
360
|
+
p50: null,
|
|
361
|
+
p90: null,
|
|
362
|
+
p99: null,
|
|
363
|
+
count: 0
|
|
364
|
+
};
|
|
365
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
366
|
+
const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
367
|
+
return {
|
|
368
|
+
lastMs,
|
|
369
|
+
p50: pick(.5),
|
|
370
|
+
p90: pick(.9),
|
|
371
|
+
p99: pick(.99),
|
|
372
|
+
count: samples.length
|
|
373
|
+
};
|
|
374
|
+
}
|
|
203
375
|
/**
|
|
204
376
|
* DataModule - Unified query and mutation management
|
|
205
377
|
*
|
|
@@ -210,9 +382,38 @@ var DataModule = class {
|
|
|
210
382
|
activeQueries = /* @__PURE__ */ new Map();
|
|
211
383
|
pendingQueries = /* @__PURE__ */ new Map();
|
|
212
384
|
subscriptions = /* @__PURE__ */ new Map();
|
|
385
|
+
statusSubscriptions = /* @__PURE__ */ new Map();
|
|
213
386
|
mutationCallbacks = /* @__PURE__ */ new Set();
|
|
214
387
|
debounceTimers = /* @__PURE__ */ new Map();
|
|
215
388
|
logger;
|
|
389
|
+
/**
|
|
390
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
391
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
392
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
393
|
+
* constructed after DataModule.
|
|
394
|
+
*/
|
|
395
|
+
onQueryStatusChange;
|
|
396
|
+
/**
|
|
397
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
398
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
399
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
400
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
401
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
402
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
403
|
+
*/
|
|
404
|
+
onHeartbeat;
|
|
405
|
+
/**
|
|
406
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
407
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
408
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
409
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
410
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
411
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
412
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
413
|
+
*/
|
|
414
|
+
onDeregister;
|
|
415
|
+
sessionId = "";
|
|
416
|
+
currentUserId = null;
|
|
216
417
|
constructor(cache, local, schema, logger, streamDebounceTime = 100) {
|
|
217
418
|
this.cache = cache;
|
|
218
419
|
this.local = local;
|
|
@@ -220,8 +421,35 @@ var DataModule = class {
|
|
|
220
421
|
this.streamDebounceTime = streamDebounceTime;
|
|
221
422
|
this.logger = logger.child({ service: "DataModule" });
|
|
222
423
|
}
|
|
223
|
-
async init() {
|
|
224
|
-
this.
|
|
424
|
+
async init(sessionId) {
|
|
425
|
+
this.sessionId = sessionId;
|
|
426
|
+
this.logger.info({
|
|
427
|
+
sessionId,
|
|
428
|
+
Category: "sp00ky-client::DataModule::init"
|
|
429
|
+
}, "DataModule initialized");
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Update the session salt used in query-id hashing. Call this when the
|
|
433
|
+
* SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
|
|
434
|
+
* registered queries will get fresh, session-scoped IDs.
|
|
435
|
+
*/
|
|
436
|
+
setSessionId(sessionId) {
|
|
437
|
+
this.sessionId = sessionId;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Update the authenticated user record id. Pass `null` on sign-out.
|
|
441
|
+
* Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
|
|
442
|
+
* the poll route to the same per-user `_00_list_ref_user_<id>` the
|
|
443
|
+
* SSP writes to.
|
|
444
|
+
*/
|
|
445
|
+
setCurrentUserId(userId) {
|
|
446
|
+
this.currentUserId = userId;
|
|
447
|
+
}
|
|
448
|
+
/** Read-only view of the authenticated user id used for per-user
|
|
449
|
+
* `_00_list_ref` routing. Other modules consult this so they pick the
|
|
450
|
+
* same table name DataModule does. */
|
|
451
|
+
getCurrentUserId() {
|
|
452
|
+
return this.currentUserId;
|
|
225
453
|
}
|
|
226
454
|
/**
|
|
227
455
|
* Register a query and return its hash for subscriptions
|
|
@@ -233,27 +461,27 @@ var DataModule = class {
|
|
|
233
461
|
});
|
|
234
462
|
this.logger.debug({
|
|
235
463
|
hash,
|
|
236
|
-
Category: "
|
|
464
|
+
Category: "sp00ky-client::DataModule::query"
|
|
237
465
|
}, "Query Initialization: started");
|
|
238
|
-
const recordId = new RecordId("
|
|
466
|
+
const recordId = new RecordId("_00_query", hash);
|
|
239
467
|
if (this.activeQueries.has(hash)) {
|
|
240
468
|
this.logger.debug({
|
|
241
469
|
hash,
|
|
242
|
-
Category: "
|
|
470
|
+
Category: "sp00ky-client::DataModule::query"
|
|
243
471
|
}, "Query Initialization: exists, returning");
|
|
244
472
|
return hash;
|
|
245
473
|
}
|
|
246
474
|
if (this.pendingQueries.has(hash)) {
|
|
247
475
|
this.logger.debug({
|
|
248
476
|
hash,
|
|
249
|
-
Category: "
|
|
477
|
+
Category: "sp00ky-client::DataModule::query"
|
|
250
478
|
}, "Query Initialization: pending, waiting for existing creation");
|
|
251
479
|
await this.pendingQueries.get(hash);
|
|
252
480
|
return hash;
|
|
253
481
|
}
|
|
254
482
|
this.logger.debug({
|
|
255
483
|
hash,
|
|
256
|
-
Category: "
|
|
484
|
+
Category: "sp00ky-client::DataModule::query"
|
|
257
485
|
}, "Query Initialization: not found, creating new query");
|
|
258
486
|
const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
|
|
259
487
|
this.pendingQueries.set(hash, promise);
|
|
@@ -283,6 +511,36 @@ var DataModule = class {
|
|
|
283
511
|
};
|
|
284
512
|
}
|
|
285
513
|
/**
|
|
514
|
+
* Subscribe to a query's fetch-status changes (idle/fetching).
|
|
515
|
+
* With `{ immediate: true }` the callback fires synchronously with the
|
|
516
|
+
* current status (defaults to `idle` if the query isn't registered yet).
|
|
517
|
+
*/
|
|
518
|
+
subscribeStatus(queryHash, callback, options = {}) {
|
|
519
|
+
if (!this.statusSubscriptions.has(queryHash)) this.statusSubscriptions.set(queryHash, /* @__PURE__ */ new Set());
|
|
520
|
+
this.statusSubscriptions.get(queryHash)?.add(callback);
|
|
521
|
+
if (options.immediate) callback(this.activeQueries.get(queryHash)?.status ?? "idle");
|
|
522
|
+
return () => {
|
|
523
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
524
|
+
if (subs) {
|
|
525
|
+
subs.delete(callback);
|
|
526
|
+
if (subs.size === 0) this.statusSubscriptions.delete(queryHash);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Set a query's fetch status and notify status observers (DevTools +
|
|
532
|
+
* `subscribeStatus` listeners). No-op when the status is unchanged or the
|
|
533
|
+
* query is unknown.
|
|
534
|
+
*/
|
|
535
|
+
setQueryStatus(queryHash, status) {
|
|
536
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
537
|
+
if (!queryState || queryState.status === status) return;
|
|
538
|
+
queryState.status = status;
|
|
539
|
+
this.onQueryStatusChange?.(queryHash, status);
|
|
540
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
541
|
+
if (subs) for (const callback of subs) callback(status);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
286
544
|
* Subscribe to mutations (for sync)
|
|
287
545
|
*/
|
|
288
546
|
onMutation(callback) {
|
|
@@ -296,66 +554,265 @@ var DataModule = class {
|
|
|
296
554
|
*/
|
|
297
555
|
async onStreamUpdate(update) {
|
|
298
556
|
const { queryHash, op } = update;
|
|
299
|
-
if (op === "
|
|
300
|
-
|
|
301
|
-
|
|
557
|
+
if (op === "DELETE") {
|
|
558
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
559
|
+
if (existing) {
|
|
560
|
+
clearTimeout(existing);
|
|
302
561
|
this.debounceTimers.delete(queryHash);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
562
|
+
}
|
|
563
|
+
await this.processStreamUpdate(update);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
|
|
567
|
+
const timer = setTimeout(async () => {
|
|
568
|
+
this.debounceTimers.delete(queryHash);
|
|
569
|
+
await this.processStreamUpdate(update);
|
|
570
|
+
}, this.streamDebounceTime);
|
|
571
|
+
this.debounceTimers.set(queryHash, timer);
|
|
572
|
+
}
|
|
573
|
+
async materializeRecords(queryState, sspArray) {
|
|
574
|
+
const t0 = performance.now();
|
|
575
|
+
const windowMat = buildWindowMaterialization(queryState.config.surql);
|
|
576
|
+
let records;
|
|
577
|
+
if (windowMat) {
|
|
578
|
+
const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
|
|
579
|
+
const [rows] = await this.local.query(windowMat.query, {
|
|
580
|
+
...queryState.config.params,
|
|
581
|
+
__win: winIds
|
|
582
|
+
});
|
|
583
|
+
records = rows || [];
|
|
584
|
+
} else {
|
|
585
|
+
const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
|
|
586
|
+
records = rows || [];
|
|
587
|
+
}
|
|
588
|
+
this.recordPhase(queryState, "localFetch", performance.now() - t0);
|
|
589
|
+
return records;
|
|
307
590
|
}
|
|
308
591
|
async processStreamUpdate(update) {
|
|
309
|
-
const { queryHash, localArray } = update;
|
|
592
|
+
const { queryHash, localArray, materializationTimeMs } = update;
|
|
310
593
|
const queryState = this.activeQueries.get(queryHash);
|
|
311
594
|
if (!queryState) {
|
|
312
595
|
this.logger.warn({
|
|
313
596
|
queryHash,
|
|
314
|
-
Category: "
|
|
597
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
315
598
|
}, "Received update for unknown query. Skipping...");
|
|
316
599
|
return;
|
|
317
600
|
}
|
|
601
|
+
if (typeof materializationTimeMs === "number") {
|
|
602
|
+
queryState.materializationSamples.push(materializationTimeMs);
|
|
603
|
+
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
|
|
604
|
+
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
605
|
+
}
|
|
606
|
+
if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
|
|
607
|
+
if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
|
|
608
|
+
if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
|
|
609
|
+
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
318
610
|
try {
|
|
319
|
-
const
|
|
320
|
-
const newRecords = records || [];
|
|
611
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
321
612
|
queryState.config.localArray = localArray;
|
|
322
|
-
await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
|
|
323
|
-
id: queryState.config.id,
|
|
324
|
-
localArray
|
|
325
|
-
});
|
|
326
613
|
const prevJson = JSON.stringify(queryState.records);
|
|
327
614
|
const newJson = JSON.stringify(newRecords);
|
|
328
615
|
queryState.records = newRecords;
|
|
329
|
-
|
|
616
|
+
const recordsChanged = prevJson !== newJson;
|
|
617
|
+
if (recordsChanged) queryState.updateCount++;
|
|
618
|
+
await this.local.query(surql.seal(surql.updateSet("id", [
|
|
619
|
+
"localArray",
|
|
620
|
+
"rowCount",
|
|
621
|
+
"updateCount",
|
|
622
|
+
"lastIngestLatency",
|
|
623
|
+
"materializationP55",
|
|
624
|
+
"materializationP90",
|
|
625
|
+
"materializationP99"
|
|
626
|
+
])), {
|
|
627
|
+
id: queryState.config.id,
|
|
628
|
+
localArray,
|
|
629
|
+
rowCount: localArray.length,
|
|
630
|
+
updateCount: queryState.updateCount,
|
|
631
|
+
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
632
|
+
materializationP55: percentiles.p55,
|
|
633
|
+
materializationP90: percentiles.p90,
|
|
634
|
+
materializationP99: percentiles.p99
|
|
635
|
+
});
|
|
636
|
+
if (!recordsChanged) {
|
|
330
637
|
this.logger.debug({
|
|
331
638
|
queryHash,
|
|
332
|
-
Category: "
|
|
639
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
333
640
|
}, "Query records unchanged, skipping notification");
|
|
334
641
|
return;
|
|
335
642
|
}
|
|
336
|
-
queryState.updateCount++;
|
|
337
643
|
const subscribers = this.subscriptions.get(queryHash);
|
|
338
644
|
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
339
645
|
this.logger.debug({
|
|
340
646
|
queryHash,
|
|
341
|
-
recordCount:
|
|
342
|
-
Category: "
|
|
647
|
+
recordCount: newRecords?.length,
|
|
648
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
343
649
|
}, "Query updated from stream");
|
|
344
650
|
} catch (err) {
|
|
651
|
+
queryState.errorCount++;
|
|
345
652
|
this.logger.error({
|
|
346
653
|
err,
|
|
347
654
|
queryHash,
|
|
348
|
-
Category: "
|
|
655
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
349
656
|
}, "Failed to fetch records for stream update");
|
|
657
|
+
try {
|
|
658
|
+
await this.local.query(surql.seal(surql.updateSet("id", ["errorCount"])), {
|
|
659
|
+
id: queryState.config.id,
|
|
660
|
+
errorCount: queryState.errorCount
|
|
661
|
+
});
|
|
662
|
+
} catch (persistErr) {
|
|
663
|
+
this.logger.warn({
|
|
664
|
+
err: persistErr,
|
|
665
|
+
queryHash,
|
|
666
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
667
|
+
}, "Failed to persist incremented errorCount");
|
|
668
|
+
}
|
|
350
669
|
}
|
|
351
670
|
}
|
|
352
671
|
/**
|
|
672
|
+
* Compute p55/p90/p99 from a rolling window of materialization samples.
|
|
673
|
+
* Returns nulls for any percentile that has no samples yet so SurrealDB
|
|
674
|
+
* `option<float>` columns stay NONE rather than 0 before the first ingest.
|
|
675
|
+
*/
|
|
676
|
+
computeMaterializationPercentiles(samples) {
|
|
677
|
+
if (samples.length === 0) return {
|
|
678
|
+
p55: null,
|
|
679
|
+
p90: null,
|
|
680
|
+
p99: null
|
|
681
|
+
};
|
|
682
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
683
|
+
const pick = (q) => {
|
|
684
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
685
|
+
};
|
|
686
|
+
return {
|
|
687
|
+
p55: pick(.55),
|
|
688
|
+
p90: pick(.9),
|
|
689
|
+
p99: pick(.99)
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Record a per-phase timing sample (ms) on a query's rolling window. */
|
|
693
|
+
recordPhase(qs, phase, ms) {
|
|
694
|
+
if (!Number.isFinite(ms)) return;
|
|
695
|
+
pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
|
|
696
|
+
qs.phaseLast[phase] = ms;
|
|
697
|
+
}
|
|
698
|
+
/** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
|
|
699
|
+
recordRemoteFetch(hash, ms) {
|
|
700
|
+
const qs = this.activeQueries.get(hash);
|
|
701
|
+
if (qs) this.recordPhase(qs, "remoteFetch", ms);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Record the frontend reconcile time (ms) for a query. Called from `useQuery`
|
|
705
|
+
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
706
|
+
*/
|
|
707
|
+
recordFrontendTiming(hash, ms) {
|
|
708
|
+
const qs = this.activeQueries.get(hash);
|
|
709
|
+
if (qs) this.recordPhase(qs, "frontend", ms);
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Build the per-query processing-time breakdown surfaced to the DevTools panel
|
|
713
|
+
* and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
|
|
714
|
+
* the rest come from the per-phase rolling windows + one-shot registration timings.
|
|
715
|
+
*/
|
|
716
|
+
phaseTimings(q) {
|
|
717
|
+
const stat = (phase) => phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
|
|
718
|
+
return {
|
|
719
|
+
ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
|
|
720
|
+
sspStoreApply: stat("sspStoreApply"),
|
|
721
|
+
sspCircuitStep: stat("sspCircuitStep"),
|
|
722
|
+
sspTransform: stat("sspTransform"),
|
|
723
|
+
localFetch: stat("localFetch"),
|
|
724
|
+
remoteFetch: stat("remoteFetch"),
|
|
725
|
+
frontend: stat("frontend"),
|
|
726
|
+
registration: q.registrationTimings,
|
|
727
|
+
updateCount: q.updateCount,
|
|
728
|
+
errorCount: q.errorCount
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
353
732
|
* Get query state (for sync and devtools)
|
|
354
733
|
*/
|
|
355
734
|
getQueryByHash(hash) {
|
|
356
735
|
return this.activeQueries.get(hash);
|
|
357
736
|
}
|
|
358
737
|
/**
|
|
738
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
739
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
740
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
741
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
742
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
743
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
744
|
+
*/
|
|
745
|
+
isCold(hash) {
|
|
746
|
+
const qs = this.activeQueries.get(hash);
|
|
747
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
751
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
752
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
753
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
754
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
755
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
756
|
+
*/
|
|
757
|
+
async applyHydration(hash, rows) {
|
|
758
|
+
const queryState = this.activeQueries.get(hash);
|
|
759
|
+
if (!queryState) return;
|
|
760
|
+
queryState.hydrated = true;
|
|
761
|
+
if (rows.length === 0) return;
|
|
762
|
+
const tableName = queryState.config.tableName;
|
|
763
|
+
const batch = rows.map((record) => ({
|
|
764
|
+
table: tableName,
|
|
765
|
+
op: "CREATE",
|
|
766
|
+
record,
|
|
767
|
+
version: record._00_rv || 1
|
|
768
|
+
}));
|
|
769
|
+
await this.cache.saveBatch(batch);
|
|
770
|
+
queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
|
|
771
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
772
|
+
const subscribers = this.subscriptions.get(hash);
|
|
773
|
+
if (subscribers) for (const cb of subscribers) cb(queryState.records);
|
|
774
|
+
}
|
|
775
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
776
|
+
hasSubscribers(hash) {
|
|
777
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
781
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
782
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
783
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
784
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
785
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
786
|
+
*
|
|
787
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
788
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
789
|
+
*/
|
|
790
|
+
deregisterQuery(hash) {
|
|
791
|
+
if (this.hasSubscribers(hash)) return;
|
|
792
|
+
if (!this.activeQueries.has(hash)) return;
|
|
793
|
+
this.onDeregister?.(hash);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
797
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
798
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
799
|
+
*/
|
|
800
|
+
finalizeDeregister(hash) {
|
|
801
|
+
const qs = this.activeQueries.get(hash);
|
|
802
|
+
if (qs?.ttlTimer) {
|
|
803
|
+
clearTimeout(qs.ttlTimer);
|
|
804
|
+
qs.ttlTimer = null;
|
|
805
|
+
}
|
|
806
|
+
const debounce = this.debounceTimers.get(hash);
|
|
807
|
+
if (debounce) {
|
|
808
|
+
clearTimeout(debounce);
|
|
809
|
+
this.debounceTimers.delete(hash);
|
|
810
|
+
}
|
|
811
|
+
this.cache.unregisterQuery(hash);
|
|
812
|
+
this.activeQueries.delete(hash);
|
|
813
|
+
this.subscriptions.delete(hash);
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
359
816
|
* Get query state by id (for sync and devtools)
|
|
360
817
|
*/
|
|
361
818
|
getQueryById(id) {
|
|
@@ -367,12 +824,15 @@ var DataModule = class {
|
|
|
367
824
|
getActiveQueries() {
|
|
368
825
|
return Array.from(this.activeQueries.values());
|
|
369
826
|
}
|
|
827
|
+
getActiveQueryHashes() {
|
|
828
|
+
return Array.from(this.activeQueries.keys());
|
|
829
|
+
}
|
|
370
830
|
async updateQueryLocalArray(id, localArray) {
|
|
371
831
|
const queryState = this.activeQueries.get(id);
|
|
372
832
|
if (!queryState) {
|
|
373
833
|
this.logger.warn({
|
|
374
834
|
id,
|
|
375
|
-
Category: "
|
|
835
|
+
Category: "sp00ky-client::DataModule::updateQueryLocalArray"
|
|
376
836
|
}, "Query to update local array not found");
|
|
377
837
|
return;
|
|
378
838
|
}
|
|
@@ -387,7 +847,7 @@ var DataModule = class {
|
|
|
387
847
|
if (!queryState) {
|
|
388
848
|
this.logger.warn({
|
|
389
849
|
hash,
|
|
390
|
-
Category: "
|
|
850
|
+
Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
|
|
391
851
|
}, "Query to update remote array not found");
|
|
392
852
|
return;
|
|
393
853
|
}
|
|
@@ -404,8 +864,7 @@ var DataModule = class {
|
|
|
404
864
|
async notifyQuerySynced(queryHash) {
|
|
405
865
|
const queryState = this.activeQueries.get(queryHash);
|
|
406
866
|
if (!queryState) return;
|
|
407
|
-
const
|
|
408
|
-
const newRecords = records || [];
|
|
867
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
409
868
|
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
410
869
|
queryState.records = newRecords;
|
|
411
870
|
if (changed || queryState.updateCount === 0) {
|
|
@@ -431,6 +890,7 @@ var DataModule = class {
|
|
|
431
890
|
max_retries: options?.max_retries ?? 3,
|
|
432
891
|
retry_strategy: options?.retry_strategy ?? "linear"
|
|
433
892
|
};
|
|
893
|
+
if (options?.timeout != null) record.timeout = options.timeout;
|
|
434
894
|
if (options?.assignedTo) record.assigned_to = options.assignedTo;
|
|
435
895
|
const recordId = `${tableName}:${generateId()}`;
|
|
436
896
|
await this.create(recordId, record);
|
|
@@ -444,7 +904,7 @@ var DataModule = class {
|
|
|
444
904
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
445
905
|
const rid = parseRecordIdString(id);
|
|
446
906
|
const params = parseParams(tableSchema.columns, data);
|
|
447
|
-
const mutationId = parseRecordIdString(`
|
|
907
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
448
908
|
const dataKeys = Object.keys(params).map((key) => ({
|
|
449
909
|
key,
|
|
450
910
|
variable: `data_${key}`
|
|
@@ -474,7 +934,7 @@ var DataModule = class {
|
|
|
474
934
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
475
935
|
this.logger.debug({
|
|
476
936
|
id,
|
|
477
|
-
Category: "
|
|
937
|
+
Category: "sp00ky-client::DataModule::create"
|
|
478
938
|
}, "Record created");
|
|
479
939
|
return target;
|
|
480
940
|
}
|
|
@@ -487,10 +947,10 @@ var DataModule = class {
|
|
|
487
947
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
488
948
|
const rid = parseRecordIdString(id);
|
|
489
949
|
const params = parseParams(tableSchema.columns, data);
|
|
490
|
-
const mutationId = parseRecordIdString(`
|
|
950
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
491
951
|
const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
|
|
492
952
|
const query = surql.seal(surql.tx([
|
|
493
|
-
surql.updateSet("id", [{ statement: "
|
|
953
|
+
surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
|
|
494
954
|
surql.let("updated", surql.updateMerge("id", "data")),
|
|
495
955
|
surql.createMutation("update", "mid", "id", "data"),
|
|
496
956
|
surql.returnObject([{
|
|
@@ -505,14 +965,14 @@ var DataModule = class {
|
|
|
505
965
|
}));
|
|
506
966
|
const updatedFields = { id: target.id };
|
|
507
967
|
for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
|
|
508
|
-
if ("
|
|
968
|
+
if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
|
|
509
969
|
this.replaceRecordInQueries(updatedFields);
|
|
510
970
|
const parsedRecord = parseParams(tableSchema.columns, target);
|
|
511
971
|
await this.cache.save({
|
|
512
972
|
table,
|
|
513
973
|
op: "UPDATE",
|
|
514
974
|
record: parsedRecord,
|
|
515
|
-
version: target.
|
|
975
|
+
version: target._00_rv
|
|
516
976
|
}, true);
|
|
517
977
|
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
518
978
|
const mutationEvent = {
|
|
@@ -527,7 +987,7 @@ var DataModule = class {
|
|
|
527
987
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
528
988
|
this.logger.debug({
|
|
529
989
|
id,
|
|
530
|
-
Category: "
|
|
990
|
+
Category: "sp00ky-client::DataModule::update"
|
|
531
991
|
}, "Record updated");
|
|
532
992
|
return target;
|
|
533
993
|
}
|
|
@@ -538,13 +998,32 @@ var DataModule = class {
|
|
|
538
998
|
const tableName = extractTablePart(id);
|
|
539
999
|
if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
|
|
540
1000
|
const rid = parseRecordIdString(id);
|
|
541
|
-
const mutationId = parseRecordIdString(`
|
|
1001
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
1002
|
+
const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
|
|
1003
|
+
const beforeRecord = beforeRecords ?? {};
|
|
542
1004
|
const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
|
|
543
1005
|
await withRetry(this.logger, () => this.local.execute(query, {
|
|
544
1006
|
id: rid,
|
|
545
1007
|
mid: mutationId
|
|
546
1008
|
}));
|
|
547
|
-
|
|
1009
|
+
try {
|
|
1010
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
this.logger.error({
|
|
1013
|
+
err,
|
|
1014
|
+
id,
|
|
1015
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
1016
|
+
}, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
|
|
1017
|
+
}
|
|
1018
|
+
for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
|
|
1019
|
+
await this.notifyQuerySynced(queryHash);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
this.logger.error({
|
|
1022
|
+
err,
|
|
1023
|
+
queryHash,
|
|
1024
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
1025
|
+
}, "notifyQuerySynced failed after delete");
|
|
1026
|
+
}
|
|
548
1027
|
const mutationEvent = {
|
|
549
1028
|
type: "delete",
|
|
550
1029
|
mutation_id: mutationId,
|
|
@@ -553,7 +1032,7 @@ var DataModule = class {
|
|
|
553
1032
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
554
1033
|
this.logger.debug({
|
|
555
1034
|
id,
|
|
556
|
-
Category: "
|
|
1035
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
557
1036
|
}, "Record deleted");
|
|
558
1037
|
}
|
|
559
1038
|
/**
|
|
@@ -568,14 +1047,14 @@ var DataModule = class {
|
|
|
568
1047
|
this.logger.info({
|
|
569
1048
|
id,
|
|
570
1049
|
tableName,
|
|
571
|
-
Category: "
|
|
1050
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
572
1051
|
}, "Rolled back optimistic create");
|
|
573
1052
|
} catch (err) {
|
|
574
1053
|
this.logger.error({
|
|
575
1054
|
err,
|
|
576
1055
|
id,
|
|
577
1056
|
tableName,
|
|
578
|
-
Category: "
|
|
1057
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
579
1058
|
}, "Failed to rollback create");
|
|
580
1059
|
}
|
|
581
1060
|
}
|
|
@@ -596,20 +1075,20 @@ var DataModule = class {
|
|
|
596
1075
|
table: tableName,
|
|
597
1076
|
op: "UPDATE",
|
|
598
1077
|
record: parsedRecord,
|
|
599
|
-
version: beforeRecord.
|
|
1078
|
+
version: beforeRecord._00_rv || 1
|
|
600
1079
|
}, true);
|
|
601
1080
|
await this.replaceRecordInQueries(beforeRecord);
|
|
602
1081
|
this.logger.info({
|
|
603
1082
|
id,
|
|
604
1083
|
tableName,
|
|
605
|
-
Category: "
|
|
1084
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
606
1085
|
}, "Rolled back optimistic update");
|
|
607
1086
|
} catch (err) {
|
|
608
1087
|
this.logger.error({
|
|
609
1088
|
err,
|
|
610
1089
|
id,
|
|
611
1090
|
tableName,
|
|
612
|
-
Category: "
|
|
1091
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
613
1092
|
}, "Failed to rollback update");
|
|
614
1093
|
}
|
|
615
1094
|
}
|
|
@@ -637,24 +1116,53 @@ var DataModule = class {
|
|
|
637
1116
|
ttl,
|
|
638
1117
|
tableName
|
|
639
1118
|
});
|
|
640
|
-
const
|
|
1119
|
+
const t0 = performance.now();
|
|
1120
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
641
1121
|
queryHash: hash,
|
|
642
1122
|
surql: surqlString,
|
|
643
1123
|
params,
|
|
644
1124
|
ttl: new Duration(ttl),
|
|
645
1125
|
lastActiveAt: /* @__PURE__ */ new Date()
|
|
646
1126
|
});
|
|
647
|
-
|
|
1127
|
+
const registrationTime = performance.now() - t0;
|
|
1128
|
+
queryState.registrationTimings = {
|
|
1129
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
1130
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
1131
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
1132
|
+
wallMs: registrationTime
|
|
1133
|
+
};
|
|
1134
|
+
await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
|
|
1135
|
+
"localArray",
|
|
1136
|
+
"registrationTime",
|
|
1137
|
+
"rowCount"
|
|
1138
|
+
])), {
|
|
648
1139
|
id: recordId,
|
|
649
|
-
localArray
|
|
1140
|
+
localArray,
|
|
1141
|
+
registrationTime,
|
|
1142
|
+
rowCount: localArray.length
|
|
650
1143
|
}));
|
|
1144
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
1145
|
+
if (windowMat && localArray.length > 0) try {
|
|
1146
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
1147
|
+
const [seeded] = await this.local.query(windowMat.query, {
|
|
1148
|
+
...params,
|
|
1149
|
+
__win: winIds
|
|
1150
|
+
});
|
|
1151
|
+
queryState.records = seeded || [];
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
this.logger.warn({
|
|
1154
|
+
err,
|
|
1155
|
+
hash,
|
|
1156
|
+
Category: "sp00ky-client::DataModule::createAndRegisterQuery"
|
|
1157
|
+
}, "Failed to seed windowed initial records from localArray");
|
|
1158
|
+
}
|
|
651
1159
|
this.activeQueries.set(hash, queryState);
|
|
652
|
-
this.startTTLHeartbeat(queryState);
|
|
1160
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
653
1161
|
this.logger.debug({
|
|
654
1162
|
hash,
|
|
655
1163
|
tableName,
|
|
656
1164
|
recordCount: queryState.records.length,
|
|
657
|
-
Category: "
|
|
1165
|
+
Category: "sp00ky-client::DataModule::query"
|
|
658
1166
|
}, "Query registered");
|
|
659
1167
|
return hash;
|
|
660
1168
|
}
|
|
@@ -671,8 +1179,12 @@ var DataModule = class {
|
|
|
671
1179
|
localArray: [],
|
|
672
1180
|
remoteArray: [],
|
|
673
1181
|
lastActiveAt: /* @__PURE__ */ new Date(),
|
|
1182
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
674
1183
|
ttl,
|
|
675
|
-
tableName
|
|
1184
|
+
tableName,
|
|
1185
|
+
updateCount: 0,
|
|
1186
|
+
rowCount: 0,
|
|
1187
|
+
errorCount: 0
|
|
676
1188
|
}
|
|
677
1189
|
}));
|
|
678
1190
|
configRecord = createdRecord;
|
|
@@ -683,46 +1195,67 @@ var DataModule = class {
|
|
|
683
1195
|
params: parseParams(tableSchema.columns, configRecord.params)
|
|
684
1196
|
};
|
|
685
1197
|
let records = [];
|
|
686
|
-
try {
|
|
1198
|
+
if (buildWindowMaterialization(surqlString) === null) try {
|
|
687
1199
|
const [result] = await this.local.query(surqlString, params);
|
|
688
1200
|
records = result || [];
|
|
689
1201
|
} catch (err) {
|
|
690
1202
|
this.logger.warn({
|
|
691
1203
|
err,
|
|
692
|
-
Category: "
|
|
1204
|
+
Category: "sp00ky-client::DataModule::createNewQuery"
|
|
693
1205
|
}, "Failed to load initial cached records");
|
|
694
1206
|
}
|
|
1207
|
+
const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
|
|
1208
|
+
const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
|
|
695
1209
|
return {
|
|
696
1210
|
config,
|
|
697
1211
|
records,
|
|
698
1212
|
ttlTimer: null,
|
|
699
1213
|
ttlDurationMs: parseDuration(ttl),
|
|
700
|
-
updateCount:
|
|
1214
|
+
updateCount: persistedUpdateCount,
|
|
1215
|
+
materializationSamples: [],
|
|
1216
|
+
lastIngestLatencyMs: null,
|
|
1217
|
+
errorCount: persistedErrorCount,
|
|
1218
|
+
status: "idle",
|
|
1219
|
+
phaseSamples: {},
|
|
1220
|
+
phaseLast: {},
|
|
1221
|
+
registrationTimings: {
|
|
1222
|
+
parseMs: null,
|
|
1223
|
+
planMs: null,
|
|
1224
|
+
snapshotMs: null,
|
|
1225
|
+
wallMs: null
|
|
1226
|
+
}
|
|
701
1227
|
};
|
|
702
1228
|
}
|
|
703
1229
|
async calculateHash(data) {
|
|
704
|
-
const content = JSON.stringify(
|
|
1230
|
+
const content = JSON.stringify({
|
|
1231
|
+
...data,
|
|
1232
|
+
sessionId: this.sessionId
|
|
1233
|
+
});
|
|
705
1234
|
const msgBuffer = new TextEncoder().encode(content);
|
|
706
1235
|
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
|
707
1236
|
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
708
1237
|
}
|
|
709
|
-
startTTLHeartbeat(queryState) {
|
|
1238
|
+
startTTLHeartbeat(queryState, hash) {
|
|
710
1239
|
if (queryState.ttlTimer) return;
|
|
711
1240
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
|
|
712
1241
|
queryState.ttlTimer = setTimeout(() => {
|
|
1242
|
+
queryState.ttlTimer = null;
|
|
1243
|
+
if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
|
|
1244
|
+
this.logger.debug({
|
|
1245
|
+
hash,
|
|
1246
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
1247
|
+
}, "TTL heartbeat: no subscribers, stopping");
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
this.onHeartbeat?.(hash);
|
|
713
1251
|
this.logger.debug({
|
|
1252
|
+
hash,
|
|
714
1253
|
id: encodeRecordId(queryState.config.id),
|
|
715
|
-
Category: "
|
|
716
|
-
}, "TTL heartbeat");
|
|
717
|
-
this.startTTLHeartbeat(queryState);
|
|
1254
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
1255
|
+
}, "TTL heartbeat sent");
|
|
1256
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
718
1257
|
}, heartbeatTime);
|
|
719
1258
|
}
|
|
720
|
-
stopTTLHeartbeat(queryState) {
|
|
721
|
-
if (queryState.ttlTimer) {
|
|
722
|
-
clearTimeout(queryState.ttlTimer);
|
|
723
|
-
queryState.ttlTimer = null;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
1259
|
async replaceRecordInQueries(record) {
|
|
727
1260
|
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
728
1261
|
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
@@ -744,7 +1277,7 @@ function parseUpdateOptions(id, data, options) {
|
|
|
744
1277
|
let pushEventOptions = {};
|
|
745
1278
|
if (options?.debounced) pushEventOptions = { debounced: {
|
|
746
1279
|
delay: options.debounced !== true ? options.debounced?.delay ?? 200 : 200,
|
|
747
|
-
key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).
|
|
1280
|
+
key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
|
|
748
1281
|
} };
|
|
749
1282
|
return pushEventOptions;
|
|
750
1283
|
}
|
|
@@ -781,7 +1314,7 @@ var AbstractDatabaseService = class {
|
|
|
781
1314
|
this.logger.debug({
|
|
782
1315
|
query,
|
|
783
1316
|
vars,
|
|
784
|
-
Category: "
|
|
1317
|
+
Category: "sp00ky-client::Database::query"
|
|
785
1318
|
}, "Executing query");
|
|
786
1319
|
const result = await this.client.query(query, vars);
|
|
787
1320
|
const duration = performance.now() - startTime;
|
|
@@ -796,7 +1329,7 @@ var AbstractDatabaseService = class {
|
|
|
796
1329
|
this.logger.trace({
|
|
797
1330
|
query,
|
|
798
1331
|
result,
|
|
799
|
-
Category: "
|
|
1332
|
+
Category: "sp00ky-client::Database::query"
|
|
800
1333
|
}, "Query executed successfully");
|
|
801
1334
|
} catch (err) {
|
|
802
1335
|
const duration = performance.now() - startTime;
|
|
@@ -812,7 +1345,7 @@ var AbstractDatabaseService = class {
|
|
|
812
1345
|
query,
|
|
813
1346
|
vars,
|
|
814
1347
|
err,
|
|
815
|
-
Category: "
|
|
1348
|
+
Category: "sp00ky-client::Database::query"
|
|
816
1349
|
}, "Query execution failed");
|
|
817
1350
|
reject(err);
|
|
818
1351
|
}
|
|
@@ -824,7 +1357,7 @@ var AbstractDatabaseService = class {
|
|
|
824
1357
|
return query.extract(raw);
|
|
825
1358
|
}
|
|
826
1359
|
async close() {
|
|
827
|
-
this.logger.info({ Category: "
|
|
1360
|
+
this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
|
|
828
1361
|
await this.client.close();
|
|
829
1362
|
}
|
|
830
1363
|
};
|
|
@@ -1004,7 +1537,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
1004
1537
|
type,
|
|
1005
1538
|
phase,
|
|
1006
1539
|
service: "surrealdb:local",
|
|
1007
|
-
Category: "
|
|
1540
|
+
Category: "sp00ky-client::LocalDatabaseService::diagnostics"
|
|
1008
1541
|
}, `Local SurrealDB diagnostics captured ${type}:${phase}`);
|
|
1009
1542
|
})
|
|
1010
1543
|
}), logger, events);
|
|
@@ -1015,38 +1548,153 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
1015
1548
|
}
|
|
1016
1549
|
async connect() {
|
|
1017
1550
|
const { namespace, database } = this.getConfig();
|
|
1551
|
+
const store = this.getConfig().store ?? "memory";
|
|
1552
|
+
const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
|
|
1018
1553
|
this.logger.info({
|
|
1019
1554
|
namespace,
|
|
1020
1555
|
database,
|
|
1021
|
-
|
|
1556
|
+
storeUrl,
|
|
1557
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1022
1558
|
}, "Connecting to local database");
|
|
1559
|
+
this.registerUnloadClose();
|
|
1023
1560
|
try {
|
|
1024
|
-
|
|
1025
|
-
this.logger.
|
|
1026
|
-
|
|
1027
|
-
Category: "spooky-client::LocalDatabaseService::connect"
|
|
1028
|
-
}, "[LocalDatabaseService] Calling client.connect");
|
|
1029
|
-
await this.client.connect(storeUrl, {});
|
|
1030
|
-
this.logger.debug({
|
|
1031
|
-
namespace,
|
|
1032
|
-
database,
|
|
1033
|
-
Category: "spooky-client::LocalDatabaseService::connect"
|
|
1034
|
-
}, "[LocalDatabaseService] client.connect returned. Calling client.use");
|
|
1035
|
-
await this.client.use({
|
|
1036
|
-
namespace,
|
|
1037
|
-
database
|
|
1038
|
-
});
|
|
1039
|
-
this.logger.debug({ Category: "spooky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
1040
|
-
this.logger.info({ Category: "spooky-client::LocalDatabaseService::connect" }, "Connected to local database");
|
|
1561
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1562
|
+
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
|
|
1563
|
+
return;
|
|
1041
1564
|
} catch (err) {
|
|
1042
|
-
|
|
1565
|
+
if (store === "memory" || !isLocalStoreOpenError(err)) {
|
|
1566
|
+
this.logger.error({
|
|
1567
|
+
err,
|
|
1568
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1569
|
+
}, "Failed to connect to local database");
|
|
1570
|
+
throw err;
|
|
1571
|
+
}
|
|
1572
|
+
this.logger.warn({
|
|
1043
1573
|
err,
|
|
1044
|
-
Category: "
|
|
1045
|
-
}, "
|
|
1046
|
-
|
|
1574
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1575
|
+
}, "Local IndexedDB store failed to open; retrying before clearing");
|
|
1576
|
+
}
|
|
1577
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1578
|
+
try {
|
|
1579
|
+
await this.client.close();
|
|
1580
|
+
} catch {}
|
|
1581
|
+
await delay(150 * attempt);
|
|
1582
|
+
try {
|
|
1583
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1584
|
+
this.logger.info({
|
|
1585
|
+
attempt,
|
|
1586
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1587
|
+
}, "Connected to local database on retry (cache preserved)");
|
|
1588
|
+
return;
|
|
1589
|
+
} catch (retryErr) {
|
|
1590
|
+
this.logger.warn({
|
|
1591
|
+
err: retryErr,
|
|
1592
|
+
attempt,
|
|
1593
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1594
|
+
}, "Local store retry failed");
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
await this.client.close();
|
|
1599
|
+
} catch {}
|
|
1600
|
+
await dropLocalIndexedDbStores(this.logger);
|
|
1601
|
+
try {
|
|
1602
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1603
|
+
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
|
|
1604
|
+
} catch (retryErr) {
|
|
1605
|
+
this.logger.error({
|
|
1606
|
+
err: retryErr,
|
|
1607
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1608
|
+
}, "Local store still failing after clear; falling back to in-memory");
|
|
1609
|
+
try {
|
|
1610
|
+
await this.client.close();
|
|
1611
|
+
} catch {}
|
|
1612
|
+
await this.openStore("mem://", namespace, database);
|
|
1613
|
+
this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
|
|
1047
1614
|
}
|
|
1048
1615
|
}
|
|
1616
|
+
unloadCloseRegistered = false;
|
|
1617
|
+
/**
|
|
1618
|
+
* Close the local DB on page unload so the SurrealDB-WASM worker releases its
|
|
1619
|
+
* IndexedDB connection cleanly. Without this, the previous page's connection
|
|
1620
|
+
* lingers; the next load's `client.connect` opens the store but the first
|
|
1621
|
+
* write transaction in `client.use` hits an "IndexedDB error" — which then
|
|
1622
|
+
* (mis)triggered the corrupt-store recovery and WIPED the cache on every
|
|
1623
|
+
* reload, making warm loads as slow as cold ones. `pagehide` is the reliable
|
|
1624
|
+
* unload signal (fires on bfcache + normal navigation); `close()` is async but
|
|
1625
|
+
* the WASM worker initiates the IndexedDB connection teardown synchronously.
|
|
1626
|
+
*/
|
|
1627
|
+
registerUnloadClose() {
|
|
1628
|
+
if (this.unloadCloseRegistered || typeof window === "undefined") return;
|
|
1629
|
+
this.unloadCloseRegistered = true;
|
|
1630
|
+
const close = () => {
|
|
1631
|
+
try {
|
|
1632
|
+
this.client.close();
|
|
1633
|
+
} catch {}
|
|
1634
|
+
};
|
|
1635
|
+
window.addEventListener("pagehide", close);
|
|
1636
|
+
window.addEventListener("beforeunload", close);
|
|
1637
|
+
}
|
|
1638
|
+
async openStore(storeUrl, namespace, database) {
|
|
1639
|
+
this.logger.debug({
|
|
1640
|
+
storeUrl,
|
|
1641
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1642
|
+
}, "[LocalDatabaseService] Calling client.connect");
|
|
1643
|
+
await this.client.connect(storeUrl, {});
|
|
1644
|
+
this.logger.debug({
|
|
1645
|
+
namespace,
|
|
1646
|
+
database,
|
|
1647
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1648
|
+
}, "[LocalDatabaseService] client.connect returned. Calling client.use");
|
|
1649
|
+
await this.client.use({
|
|
1650
|
+
namespace,
|
|
1651
|
+
database
|
|
1652
|
+
});
|
|
1653
|
+
this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
1654
|
+
}
|
|
1049
1655
|
};
|
|
1656
|
+
function delay(ms) {
|
|
1657
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1658
|
+
}
|
|
1659
|
+
/** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
|
|
1660
|
+
* store can't be opened (corrupt / version-incompatible / blocked). Exported
|
|
1661
|
+
* for unit testing the error-message match. */
|
|
1662
|
+
function isLocalStoreOpenError(err) {
|
|
1663
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
1664
|
+
return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
|
|
1665
|
+
}
|
|
1666
|
+
/** Best-effort delete of this client's IndexedDB store(s). The persistent local
|
|
1667
|
+
* DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
|
|
1668
|
+
* IndexedDB databases whose names include `sp00ky`. Resolves even on
|
|
1669
|
+
* error/blocked so startup can proceed. No-op outside a browser. */
|
|
1670
|
+
async function dropLocalIndexedDbStores(logger) {
|
|
1671
|
+
if (typeof indexedDB === "undefined") return;
|
|
1672
|
+
const remove = (name) => new Promise((resolve) => {
|
|
1673
|
+
try {
|
|
1674
|
+
const req = indexedDB.deleteDatabase(name);
|
|
1675
|
+
req.onsuccess = () => resolve();
|
|
1676
|
+
req.onerror = () => resolve();
|
|
1677
|
+
req.onblocked = () => resolve();
|
|
1678
|
+
} catch {
|
|
1679
|
+
resolve();
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
try {
|
|
1683
|
+
let names = [];
|
|
1684
|
+
if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && n.toLowerCase().includes("sp00ky"));
|
|
1685
|
+
if (names.length === 0) names = ["sp00ky"];
|
|
1686
|
+
await Promise.all(names.map(remove));
|
|
1687
|
+
logger.info({
|
|
1688
|
+
names,
|
|
1689
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1690
|
+
}, "Cleared local IndexedDB store(s)");
|
|
1691
|
+
} catch (e) {
|
|
1692
|
+
logger.warn({
|
|
1693
|
+
err: e,
|
|
1694
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1695
|
+
}, "Failed to enumerate/clear IndexedDB; proceeding anyway");
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1050
1698
|
|
|
1051
1699
|
//#endregion
|
|
1052
1700
|
//#region src/services/database/remote.ts
|
|
@@ -1062,7 +1710,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1062
1710
|
type,
|
|
1063
1711
|
phase,
|
|
1064
1712
|
service: "surrealdb:remote",
|
|
1065
|
-
Category: "
|
|
1713
|
+
Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
|
|
1066
1714
|
}, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
|
|
1067
1715
|
}) }), logger, events);
|
|
1068
1716
|
this.config = config;
|
|
@@ -1077,7 +1725,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1077
1725
|
endpoint,
|
|
1078
1726
|
namespace,
|
|
1079
1727
|
database,
|
|
1080
|
-
Category: "
|
|
1728
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1081
1729
|
}, "Connecting to remote database");
|
|
1082
1730
|
try {
|
|
1083
1731
|
await this.client.connect(endpoint);
|
|
@@ -1086,18 +1734,18 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1086
1734
|
database
|
|
1087
1735
|
});
|
|
1088
1736
|
if (token) {
|
|
1089
|
-
this.logger.debug({ Category: "
|
|
1737
|
+
this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
|
|
1090
1738
|
await this.client.authenticate(token);
|
|
1091
1739
|
}
|
|
1092
|
-
this.logger.info({ Category: "
|
|
1740
|
+
this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
|
|
1093
1741
|
} catch (err) {
|
|
1094
1742
|
this.logger.error({
|
|
1095
1743
|
err,
|
|
1096
|
-
Category: "
|
|
1744
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1097
1745
|
}, "Failed to connect to remote database");
|
|
1098
1746
|
throw err;
|
|
1099
1747
|
}
|
|
1100
|
-
} else this.logger.warn({ Category: "
|
|
1748
|
+
} else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
|
|
1101
1749
|
}
|
|
1102
1750
|
async signin(params) {
|
|
1103
1751
|
return this.client.signin(params);
|
|
@@ -1115,70 +1763,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1115
1763
|
|
|
1116
1764
|
//#endregion
|
|
1117
1765
|
//#region src/services/logger/index.ts
|
|
1118
|
-
|
|
1119
|
-
function mapLevelToSeverityNumber(level) {
|
|
1120
|
-
switch (level) {
|
|
1121
|
-
case "trace": return 1;
|
|
1122
|
-
case "debug": return 5;
|
|
1123
|
-
case "info": return 9;
|
|
1124
|
-
case "warn": return 13;
|
|
1125
|
-
case "error": return 17;
|
|
1126
|
-
case "fatal": return 21;
|
|
1127
|
-
default: return 9;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
function createLogger(level = "info", otelEndpoint) {
|
|
1766
|
+
function createLogger(level = "info", transmit) {
|
|
1131
1767
|
const browserConfig = {
|
|
1132
1768
|
asObject: true,
|
|
1133
1769
|
write: (o) => {
|
|
1134
1770
|
console.log(JSON.stringify(o));
|
|
1135
1771
|
}
|
|
1136
1772
|
};
|
|
1137
|
-
if (
|
|
1138
|
-
const loggerProvider = new LoggerProvider({
|
|
1139
|
-
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "spooky-client" }),
|
|
1140
|
-
processors: [new BatchLogRecordProcessor(new OTLPLogExporter({ url: otelEndpoint }))]
|
|
1141
|
-
});
|
|
1142
|
-
const otelLogger = {};
|
|
1143
|
-
const getOtelLogger = (category) => {
|
|
1144
|
-
if (!otelLogger[category]) otelLogger[category] = loggerProvider.getLogger(category);
|
|
1145
|
-
return otelLogger[category];
|
|
1146
|
-
};
|
|
1147
|
-
browserConfig.transmit = {
|
|
1148
|
-
level,
|
|
1149
|
-
send: (levelLabel, logEvent) => {
|
|
1150
|
-
try {
|
|
1151
|
-
const messages = [...logEvent.messages];
|
|
1152
|
-
const severityNumber = mapLevelToSeverityNumber(levelLabel);
|
|
1153
|
-
let body = "";
|
|
1154
|
-
const msg = messages.pop();
|
|
1155
|
-
if (typeof msg === "string") body = msg;
|
|
1156
|
-
else if (msg) body = JSON.stringify(msg);
|
|
1157
|
-
let category = "spooky-client::unknown";
|
|
1158
|
-
const attributes = {};
|
|
1159
|
-
for (const msg of messages) if (typeof msg === "object") {
|
|
1160
|
-
if (msg.Category) {
|
|
1161
|
-
category = msg.Category;
|
|
1162
|
-
delete msg.Category;
|
|
1163
|
-
}
|
|
1164
|
-
Object.assign(attributes, msg);
|
|
1165
|
-
}
|
|
1166
|
-
getOtelLogger(category).emit({
|
|
1167
|
-
severityNumber,
|
|
1168
|
-
severityText: levelLabel.toUpperCase(),
|
|
1169
|
-
body,
|
|
1170
|
-
attributes: {
|
|
1171
|
-
...logEvent.bindings[0],
|
|
1172
|
-
...attributes
|
|
1173
|
-
},
|
|
1174
|
-
timestamp: new Date(logEvent.ts)
|
|
1175
|
-
});
|
|
1176
|
-
} catch (e) {
|
|
1177
|
-
console.warn("Failed to transmit log to OTEL endpoint", e);
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1773
|
+
if (transmit) browserConfig.transmit = transmit;
|
|
1182
1774
|
return pino({
|
|
1183
1775
|
level,
|
|
1184
1776
|
browser: browserConfig
|
|
@@ -1203,25 +1795,25 @@ var LocalMigrator = class {
|
|
|
1203
1795
|
const hash = await sha1(schemaSurql);
|
|
1204
1796
|
const { database } = this.localDb.getConfig();
|
|
1205
1797
|
if (await this.isSchemaUpToDate(hash)) {
|
|
1206
|
-
this.logger.info({ Category: "
|
|
1798
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
|
|
1207
1799
|
return;
|
|
1208
1800
|
}
|
|
1209
1801
|
await this.recreateDatabase(database);
|
|
1210
|
-
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS
|
|
1802
|
+
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
1211
1803
|
const statements = this.splitStatements(fullSchema);
|
|
1212
1804
|
for (let i = 0; i < statements.length; i++) {
|
|
1213
1805
|
const statement = statements[i];
|
|
1214
1806
|
const cleanStatement = statement.replace(/--.*/g, "").trim();
|
|
1215
1807
|
if (cleanStatement.toUpperCase().startsWith("DEFINE INDEX")) {
|
|
1216
|
-
this.logger.warn({ Category: "
|
|
1808
|
+
this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
|
|
1217
1809
|
continue;
|
|
1218
1810
|
}
|
|
1219
1811
|
try {
|
|
1220
|
-
this.logger.info({ Category: "
|
|
1812
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
|
|
1221
1813
|
await this.localDb.query(statement);
|
|
1222
|
-
this.logger.info({ Category: "
|
|
1814
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
|
|
1223
1815
|
} catch (e) {
|
|
1224
|
-
this.logger.error({ Category: "
|
|
1816
|
+
this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
|
|
1225
1817
|
throw e;
|
|
1226
1818
|
}
|
|
1227
1819
|
}
|
|
@@ -1229,22 +1821,22 @@ var LocalMigrator = class {
|
|
|
1229
1821
|
}
|
|
1230
1822
|
async isSchemaUpToDate(hash) {
|
|
1231
1823
|
try {
|
|
1232
|
-
const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY
|
|
1824
|
+
const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
|
|
1233
1825
|
return lastSchemaRecord?.hash === hash;
|
|
1234
|
-
} catch (
|
|
1826
|
+
} catch (_error) {
|
|
1235
1827
|
return false;
|
|
1236
1828
|
}
|
|
1237
1829
|
}
|
|
1238
1830
|
async recreateDatabase(database) {
|
|
1239
1831
|
try {
|
|
1240
|
-
await this.localDb.query(`DEFINE DATABASE
|
|
1241
|
-
} catch (
|
|
1832
|
+
await this.localDb.query(`DEFINE DATABASE _00_temp;`);
|
|
1833
|
+
} catch (_e) {}
|
|
1242
1834
|
try {
|
|
1243
1835
|
await this.localDb.query(`
|
|
1244
|
-
USE DB
|
|
1836
|
+
USE DB _00_temp;
|
|
1245
1837
|
REMOVE DATABASE ${database};
|
|
1246
1838
|
`);
|
|
1247
|
-
} catch (
|
|
1839
|
+
} catch (_e) {}
|
|
1248
1840
|
await this.localDb.query(`
|
|
1249
1841
|
DEFINE DATABASE ${database};
|
|
1250
1842
|
USE DB ${database};
|
|
@@ -1302,7 +1894,7 @@ var LocalMigrator = class {
|
|
|
1302
1894
|
return statements;
|
|
1303
1895
|
}
|
|
1304
1896
|
async createHashRecord(hash) {
|
|
1305
|
-
await this.localDb.query(`UPSERT
|
|
1897
|
+
await this.localDb.query(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
|
|
1306
1898
|
}
|
|
1307
1899
|
};
|
|
1308
1900
|
|
|
@@ -1394,7 +1986,7 @@ var UpQueue = class {
|
|
|
1394
1986
|
this.logger.error({
|
|
1395
1987
|
error,
|
|
1396
1988
|
event,
|
|
1397
|
-
Category: "
|
|
1989
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1398
1990
|
}, "Network error processing mutation, re-queuing");
|
|
1399
1991
|
this.queue.unshift(event);
|
|
1400
1992
|
throw error;
|
|
@@ -1402,7 +1994,7 @@ var UpQueue = class {
|
|
|
1402
1994
|
this.logger.error({
|
|
1403
1995
|
error,
|
|
1404
1996
|
event,
|
|
1405
|
-
Category: "
|
|
1997
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1406
1998
|
}, "Application error processing mutation, rolling back");
|
|
1407
1999
|
try {
|
|
1408
2000
|
await this.removeEventFromDatabase(event.mutation_id);
|
|
@@ -1410,7 +2002,7 @@ var UpQueue = class {
|
|
|
1410
2002
|
this.logger.error({
|
|
1411
2003
|
error: removeError,
|
|
1412
2004
|
event,
|
|
1413
|
-
Category: "
|
|
2005
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1414
2006
|
}, "Failed to remove rolled-back mutation from database");
|
|
1415
2007
|
}
|
|
1416
2008
|
if (onRollback) try {
|
|
@@ -1419,7 +2011,7 @@ var UpQueue = class {
|
|
|
1419
2011
|
this.logger.error({
|
|
1420
2012
|
error: rollbackError,
|
|
1421
2013
|
event,
|
|
1422
|
-
Category: "
|
|
2014
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1423
2015
|
}, "Rollback handler failed");
|
|
1424
2016
|
}
|
|
1425
2017
|
this._events.addEvent({
|
|
@@ -1434,7 +2026,7 @@ var UpQueue = class {
|
|
|
1434
2026
|
this.logger.error({
|
|
1435
2027
|
error,
|
|
1436
2028
|
event,
|
|
1437
|
-
Category: "
|
|
2029
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1438
2030
|
}, "Failed to remove mutation from database after successful processing");
|
|
1439
2031
|
}
|
|
1440
2032
|
this._events.addEvent({
|
|
@@ -1448,7 +2040,7 @@ var UpQueue = class {
|
|
|
1448
2040
|
}
|
|
1449
2041
|
async loadFromDatabase() {
|
|
1450
2042
|
try {
|
|
1451
|
-
const [records] = await this.local.query(`SELECT * FROM
|
|
2043
|
+
const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY created_at ASC`);
|
|
1452
2044
|
this.queue = records.map((r) => {
|
|
1453
2045
|
switch (r.mutationType) {
|
|
1454
2046
|
case "create": return {
|
|
@@ -1474,7 +2066,7 @@ var UpQueue = class {
|
|
|
1474
2066
|
this.logger.warn({
|
|
1475
2067
|
mutationType: r.mutationType,
|
|
1476
2068
|
record: r,
|
|
1477
|
-
Category: "
|
|
2069
|
+
Category: "sp00ky-client::UpQueue::loadFromDatabase"
|
|
1478
2070
|
}, "Unknown mutation type");
|
|
1479
2071
|
return null;
|
|
1480
2072
|
}
|
|
@@ -1482,7 +2074,7 @@ var UpQueue = class {
|
|
|
1482
2074
|
} catch (error) {
|
|
1483
2075
|
this.logger.error({
|
|
1484
2076
|
error,
|
|
1485
|
-
Category: "
|
|
2077
|
+
Category: "sp00ky-client::UpQueue::loadFromDatabase"
|
|
1486
2078
|
}, "Failed to load pending mutations from database");
|
|
1487
2079
|
}
|
|
1488
2080
|
}
|
|
@@ -1523,7 +2115,7 @@ var DownQueue = class {
|
|
|
1523
2115
|
this.logger.error({
|
|
1524
2116
|
error,
|
|
1525
2117
|
event,
|
|
1526
|
-
Category: "
|
|
2118
|
+
Category: "sp00ky-client::DownQueue::next"
|
|
1527
2119
|
}, "Failed to process query");
|
|
1528
2120
|
this.queue.unshift(event);
|
|
1529
2121
|
throw error;
|
|
@@ -1538,8 +2130,8 @@ var ArraySyncer = class {
|
|
|
1538
2130
|
remoteArray;
|
|
1539
2131
|
needsSort = false;
|
|
1540
2132
|
constructor(localArray, remoteArray) {
|
|
1541
|
-
this.remoteArray = remoteArray.
|
|
1542
|
-
this.localArray = localArray.
|
|
2133
|
+
this.remoteArray = remoteArray.toSorted((a, b) => a[0].localeCompare(b[0]));
|
|
2134
|
+
this.localArray = localArray.toSorted((a, b) => a[0].localeCompare(b[0]));
|
|
1543
2135
|
}
|
|
1544
2136
|
/**
|
|
1545
2137
|
* Inserts an item into the local array
|
|
@@ -1575,7 +2167,6 @@ var ArraySyncer = class {
|
|
|
1575
2167
|
this.localArray.sort((a, b) => a[0].localeCompare(b[0]));
|
|
1576
2168
|
this.needsSort = false;
|
|
1577
2169
|
}
|
|
1578
|
-
console.log("xxxx555", this.localArray, this.remoteArray);
|
|
1579
2170
|
return diffRecordVersionArray(this.localArray, this.remoteArray);
|
|
1580
2171
|
}
|
|
1581
2172
|
};
|
|
@@ -1606,14 +2197,12 @@ function diffRecordVersionArray(local, remote) {
|
|
|
1606
2197
|
};
|
|
1607
2198
|
}
|
|
1608
2199
|
function createDiffFromDbOp(op, recordId, version, versions) {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
};
|
|
1616
|
-
}
|
|
2200
|
+
const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
|
|
2201
|
+
if (old && old[1] >= version) return {
|
|
2202
|
+
added: [],
|
|
2203
|
+
updated: [],
|
|
2204
|
+
removed: []
|
|
2205
|
+
};
|
|
1617
2206
|
if (op === "CREATE") return {
|
|
1618
2207
|
added: [{
|
|
1619
2208
|
id: recordId,
|
|
@@ -1636,6 +2225,83 @@ function createDiffFromDbOp(op, recordId, version, versions) {
|
|
|
1636
2225
|
removed: [recordId]
|
|
1637
2226
|
};
|
|
1638
2227
|
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Default cadence for the `_00_list_ref_user_<id>` poll fallback. The
|
|
2230
|
+
* poll is the safety net for SurrealDB v3's occasionally-dropped LIVE
|
|
2231
|
+
* deliveries; 500ms is aggressive enough to feel real-time on the
|
|
2232
|
+
* happy path while keeping the per-session query load bounded.
|
|
2233
|
+
*/
|
|
2234
|
+
const DEFAULT_LIST_REF_POLL_INTERVAL_MS = 500;
|
|
2235
|
+
/**
|
|
2236
|
+
* Build the SurrealQL select that powers both the initial-fetch and
|
|
2237
|
+
* the periodic poll of `_00_list_ref[_user_<id>]`. The `parent IS NONE`
|
|
2238
|
+
* predicate excludes subquery entries (rows with `parent_rel` set)
|
|
2239
|
+
* because the client's `RecordVersionArray` only tracks primary rows;
|
|
2240
|
+
* including subquery rows would surface them as spurious "added"
|
|
2241
|
+
* diffs every tick.
|
|
2242
|
+
*/
|
|
2243
|
+
function buildListRefSelect(table) {
|
|
2244
|
+
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Resolve the effective list-ref poll interval. Negative or zero
|
|
2248
|
+
* values fall back to the default — accepting them would either
|
|
2249
|
+
* disable polling silently or busy-loop the event loop.
|
|
2250
|
+
*/
|
|
2251
|
+
function resolveListRefPollInterval(opt) {
|
|
2252
|
+
if (typeof opt !== "number" || !Number.isFinite(opt) || opt <= 0) return DEFAULT_LIST_REF_POLL_INTERVAL_MS;
|
|
2253
|
+
return opt;
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Ceiling for the adaptive list_ref poll backoff. An idle page (no LIVE
|
|
2257
|
+
* events, no poll-detected list_ref changes) coasts up to this cadence;
|
|
2258
|
+
* the existing healthy-LIVE safety net runs at the same 5s, so this keeps
|
|
2259
|
+
* the worst-case catch-up latency for a missed cross-session change at the
|
|
2260
|
+
* cadence the codebase already treats as acceptable.
|
|
2261
|
+
*/
|
|
2262
|
+
const LIST_REF_POLL_MAX_INTERVAL_MS = 5e3;
|
|
2263
|
+
/**
|
|
2264
|
+
* Adaptive poll delay: stay at the responsive `baseIntervalMs` while
|
|
2265
|
+
* changes are arriving, and exponentially back off toward `maxIntervalMs`
|
|
2266
|
+
* while the `_00_list_ref` is quiet.
|
|
2267
|
+
*
|
|
2268
|
+
* `idleStreak` is the count of consecutive poll cycles that observed *no*
|
|
2269
|
+
* change. `Sp00kySync` resets it to 0 whenever a poll detects a real
|
|
2270
|
+
* remoteArray change OR a LIVE event lands, so any activity snaps the poll
|
|
2271
|
+
* straight back to `baseIntervalMs`. A streak of 0 (something just
|
|
2272
|
+
* happened) → base; otherwise `base * 2^streak` capped at `maxIntervalMs`.
|
|
2273
|
+
*
|
|
2274
|
+
* This replaces {@link nextPollDelayMs}: the old helper slowed the poll
|
|
2275
|
+
* only while LIVE was *delivering*, but the cross-session LIVE-permission
|
|
2276
|
+
* gap means LIVE frequently never fires here, so it left a fully idle page
|
|
2277
|
+
* polling every `base` ms forever (the "continuous queries while idle"
|
|
2278
|
+
* symptom). Backing off on observed idleness instead covers the
|
|
2279
|
+
* LIVE-healthy case for free (LIVE applies the change → the next poll sees
|
|
2280
|
+
* nothing new → the streak grows → it backs off).
|
|
2281
|
+
*/
|
|
2282
|
+
function listRefPollDelayMs(args) {
|
|
2283
|
+
const { idleStreak, baseIntervalMs, maxIntervalMs = LIST_REF_POLL_MAX_INTERVAL_MS } = args;
|
|
2284
|
+
const cap = Math.max(baseIntervalMs, maxIntervalMs);
|
|
2285
|
+
if (idleStreak <= 0) return baseIntervalMs;
|
|
2286
|
+
const exponent = Math.min(idleStreak, 30);
|
|
2287
|
+
return Math.min(baseIntervalMs * 2 ** exponent, cap);
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Order-insensitive equality for two `RecordVersionArray`s (each a list of
|
|
2291
|
+
* `[recordIdString, version]`). The `_00_list_ref` SELECT has no `ORDER
|
|
2292
|
+
* BY`, so row order can differ between polls without anything having
|
|
2293
|
+
* actually changed — comparing as an id→version map avoids false
|
|
2294
|
+
* "changed" verdicts that would defeat the idle backoff. Record ids are
|
|
2295
|
+
* unique within a query's list_ref, so a map is a faithful representation.
|
|
2296
|
+
*/
|
|
2297
|
+
function recordVersionArraysEqual(a, b) {
|
|
2298
|
+
if (a === b) return true;
|
|
2299
|
+
if (a.length !== b.length) return false;
|
|
2300
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2301
|
+
for (const [id, version] of a) byId.set(id, version);
|
|
2302
|
+
for (const [id, version] of b) if (byId.get(id) !== version) return false;
|
|
2303
|
+
return true;
|
|
2304
|
+
}
|
|
1639
2305
|
|
|
1640
2306
|
//#endregion
|
|
1641
2307
|
//#region src/modules/sync/engine.ts
|
|
@@ -1643,7 +2309,7 @@ function createDiffFromDbOp(op, recordId, version, versions) {
|
|
|
1643
2309
|
* SyncEngine handles the core sync operations: fetching remote records,
|
|
1644
2310
|
* caching them locally, and ingesting into DBSP.
|
|
1645
2311
|
*
|
|
1646
|
-
* This is extracted from
|
|
2312
|
+
* This is extracted from Sp00kySync to separate "how to sync" from "when to sync".
|
|
1647
2313
|
*/
|
|
1648
2314
|
var SyncEngine = class {
|
|
1649
2315
|
logger;
|
|
@@ -1652,7 +2318,7 @@ var SyncEngine = class {
|
|
|
1652
2318
|
this.remote = remote;
|
|
1653
2319
|
this.cache = cache;
|
|
1654
2320
|
this.schema = schema;
|
|
1655
|
-
this.logger = logger.child({ service: "
|
|
2321
|
+
this.logger = logger.child({ service: "Sp00kySync:SyncEngine" });
|
|
1656
2322
|
}
|
|
1657
2323
|
/**
|
|
1658
2324
|
* Sync missing/updated/removed records between local and remote.
|
|
@@ -1665,33 +2331,42 @@ var SyncEngine = class {
|
|
|
1665
2331
|
added,
|
|
1666
2332
|
updated,
|
|
1667
2333
|
removed,
|
|
1668
|
-
Category: "
|
|
2334
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
1669
2335
|
}, "SyncEngine.syncRecords diff");
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
const
|
|
1674
|
-
|
|
2336
|
+
let stillRemoteIds = [];
|
|
2337
|
+
if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
|
|
2338
|
+
const toFetch = [...added, ...updated];
|
|
2339
|
+
const idsToFetch = toFetch.map((x) => x.id);
|
|
2340
|
+
if (idsToFetch.length === 0) return {
|
|
2341
|
+
remoteFetchMs: 0,
|
|
2342
|
+
stillRemoteIds
|
|
2343
|
+
};
|
|
2344
|
+
const versionMap = /* @__PURE__ */ new Map();
|
|
2345
|
+
for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
|
|
2346
|
+
const remoteFetchStart = performance.now();
|
|
2347
|
+
const [remoteResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
|
|
2348
|
+
const remoteFetchMs = performance.now() - remoteFetchStart;
|
|
1675
2349
|
const cacheBatch = [];
|
|
1676
|
-
for (const
|
|
2350
|
+
for (const record of remoteResults) {
|
|
1677
2351
|
if (!record?.id) {
|
|
1678
2352
|
this.logger.warn({
|
|
1679
2353
|
record,
|
|
1680
2354
|
idsToFetch,
|
|
1681
|
-
Category: "
|
|
1682
|
-
}, "Remote record has no id. Skipping record");
|
|
2355
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
2356
|
+
}, "Remote record has no id (possibly deleted). Skipping record");
|
|
1683
2357
|
continue;
|
|
1684
2358
|
}
|
|
1685
2359
|
const fullId = encodeRecordId(record.id);
|
|
1686
2360
|
const table = record.id.table.toString();
|
|
1687
2361
|
const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
|
|
2362
|
+
const version = versionMap.get(fullId) ?? 0;
|
|
1688
2363
|
const localVersion = this.cache.lookup(fullId);
|
|
1689
|
-
if (localVersion &&
|
|
2364
|
+
if (localVersion && version <= localVersion) {
|
|
1690
2365
|
this.logger.info({
|
|
1691
2366
|
recordId: fullId,
|
|
1692
|
-
version
|
|
2367
|
+
version,
|
|
1693
2368
|
localVersion,
|
|
1694
|
-
Category: "
|
|
2369
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
1695
2370
|
}, "Local version is higher than remote version. Skipping record");
|
|
1696
2371
|
continue;
|
|
1697
2372
|
}
|
|
@@ -1701,37 +2376,59 @@ var SyncEngine = class {
|
|
|
1701
2376
|
table,
|
|
1702
2377
|
op: isAdded ? "CREATE" : "UPDATE",
|
|
1703
2378
|
record: cleanedRecord,
|
|
1704
|
-
version
|
|
2379
|
+
version
|
|
1705
2380
|
});
|
|
1706
2381
|
}
|
|
1707
2382
|
if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
|
|
1708
2383
|
this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
|
|
2384
|
+
return {
|
|
2385
|
+
remoteFetchMs,
|
|
2386
|
+
stillRemoteIds
|
|
2387
|
+
};
|
|
1709
2388
|
}
|
|
1710
2389
|
/**
|
|
1711
2390
|
* Handle records that exist locally but not in remote array.
|
|
2391
|
+
*
|
|
2392
|
+
* "Removed" here is a derived signal: the SSP's `_00_list_ref` array no
|
|
2393
|
+
* longer references a record that exists locally. That can mean the row
|
|
2394
|
+
* was genuinely deleted upstream — but it can also be a benign race
|
|
2395
|
+
* (e.g. a record we just created hasn't propagated into the SSP's
|
|
2396
|
+
* incantation list yet). Before deleting locally we verify against
|
|
2397
|
+
* upstream SurrealDB: if the row still exists there, skip the delete.
|
|
2398
|
+
*
|
|
2399
|
+
* On verification failure we skip deletion too. Losing a stale local
|
|
2400
|
+
* row to a later sync round is recoverable; deleting a fresh row that
|
|
2401
|
+
* upstream still has is not.
|
|
1712
2402
|
*/
|
|
1713
2403
|
async handleRemovedRecords(removed) {
|
|
1714
2404
|
this.logger.debug({
|
|
1715
2405
|
removed: removed.map((r) => r.toString()),
|
|
1716
|
-
Category: "
|
|
2406
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1717
2407
|
}, "Checking removed records");
|
|
1718
|
-
let existingRemoteIds
|
|
2408
|
+
let existingRemoteIds;
|
|
1719
2409
|
try {
|
|
1720
|
-
const [
|
|
1721
|
-
existingRemoteIds = new Set(
|
|
1722
|
-
} catch {
|
|
1723
|
-
this.logger.
|
|
2410
|
+
const [existing] = await this.remote.query("SELECT id FROM $ids", { ids: removed });
|
|
2411
|
+
existingRemoteIds = new Set((existing ?? []).map((row) => encodeRecordId(row.id)));
|
|
2412
|
+
} catch (err) {
|
|
2413
|
+
this.logger.warn({
|
|
2414
|
+
err,
|
|
2415
|
+
removed: removed.map((r) => r.toString()),
|
|
2416
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
2417
|
+
}, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
|
|
2418
|
+
return [];
|
|
1724
2419
|
}
|
|
2420
|
+
const stillRemoteIds = [];
|
|
1725
2421
|
for (const recordId of removed) {
|
|
1726
2422
|
const recordIdStr = encodeRecordId(recordId);
|
|
1727
2423
|
if (!existingRemoteIds.has(recordIdStr)) {
|
|
1728
2424
|
this.logger.debug({
|
|
1729
2425
|
recordId: recordIdStr,
|
|
1730
|
-
Category: "
|
|
2426
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1731
2427
|
}, "Deleting confirmed removed record");
|
|
1732
2428
|
await this.cache.delete(recordId.table.name, recordIdStr);
|
|
1733
|
-
}
|
|
2429
|
+
} else stillRemoteIds.push(recordIdStr);
|
|
1734
2430
|
}
|
|
2431
|
+
return stillRemoteIds;
|
|
1735
2432
|
}
|
|
1736
2433
|
};
|
|
1737
2434
|
|
|
@@ -1804,22 +2501,81 @@ var SyncScheduler = class {
|
|
|
1804
2501
|
};
|
|
1805
2502
|
|
|
1806
2503
|
//#endregion
|
|
1807
|
-
//#region src/modules/
|
|
2504
|
+
//#region src/modules/ref-tables.ts
|
|
1808
2505
|
/**
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
1812
|
-
* @template S The schema structure type.
|
|
2506
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
2507
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
2508
|
+
* box.
|
|
1813
2509
|
*/
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
2510
|
+
const DEFAULT_REF_MODE = "dedicated";
|
|
2511
|
+
/**
|
|
2512
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
2513
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
2514
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
2515
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
2516
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
2517
|
+
*
|
|
2518
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
2519
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
2520
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
2521
|
+
*/
|
|
2522
|
+
function sanitizeUserId(userId) {
|
|
2523
|
+
if (userId === null || userId === void 0) return null;
|
|
2524
|
+
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
2525
|
+
if (!asString) return null;
|
|
2526
|
+
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
2527
|
+
if (raw.length === 0) return null;
|
|
2528
|
+
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
2529
|
+
return raw;
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
2533
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
2534
|
+
* single mode.
|
|
2535
|
+
*/
|
|
2536
|
+
function listRefTableFor(mode, userId) {
|
|
2537
|
+
if (mode === "single") return "_00_list_ref";
|
|
2538
|
+
const uid = sanitizeUserId(userId);
|
|
2539
|
+
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
//#endregion
|
|
2543
|
+
//#region src/modules/sync/sync.ts
|
|
2544
|
+
/**
|
|
2545
|
+
* The main synchronization engine for Sp00ky.
|
|
2546
|
+
* Handles the bidirectional synchronization between the local database and the remote backend.
|
|
2547
|
+
* Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
|
|
2548
|
+
* @template S The schema structure type.
|
|
2549
|
+
*/
|
|
2550
|
+
var Sp00kySync = class {
|
|
2551
|
+
upQueue;
|
|
1817
2552
|
downQueue;
|
|
1818
2553
|
isInit = false;
|
|
1819
2554
|
logger;
|
|
1820
2555
|
syncEngine;
|
|
2556
|
+
/** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
|
|
2557
|
+
* from `this.events`, which carries Sp00kySync-level events like
|
|
2558
|
+
* `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
|
|
2559
|
+
get engineEvents() {
|
|
2560
|
+
return this.syncEngine.events;
|
|
2561
|
+
}
|
|
1821
2562
|
scheduler;
|
|
2563
|
+
wasDisconnected = false;
|
|
1822
2564
|
events = createSyncEventSystem();
|
|
2565
|
+
currentUserId = null;
|
|
2566
|
+
refMode = DEFAULT_REF_MODE;
|
|
2567
|
+
currentLiveQueryUuid = null;
|
|
2568
|
+
liveQueryUnsubscribe = null;
|
|
2569
|
+
listRefPollTimer = null;
|
|
2570
|
+
listRefPollRunning = false;
|
|
2571
|
+
refSyncIntervalMs;
|
|
2572
|
+
listRefIdleStreak = 0;
|
|
2573
|
+
stillRemoteStreaks = /* @__PURE__ */ new Map();
|
|
2574
|
+
lastLiveEventAt = null;
|
|
2575
|
+
_liveRetryCount = 0;
|
|
2576
|
+
get liveRetryCount() {
|
|
2577
|
+
return this._liveRetryCount;
|
|
2578
|
+
}
|
|
1823
2579
|
get isSyncing() {
|
|
1824
2580
|
return this.scheduler.isSyncing;
|
|
1825
2581
|
}
|
|
@@ -1834,60 +2590,260 @@ var SpookySync = class {
|
|
|
1834
2590
|
this.upQueue.events.unsubscribe(id2);
|
|
1835
2591
|
};
|
|
1836
2592
|
}
|
|
1837
|
-
constructor(local, remote, cache, dataModule, schema, logger) {
|
|
2593
|
+
constructor(local, remote, cache, dataModule, schema, logger, options) {
|
|
1838
2594
|
this.local = local;
|
|
1839
2595
|
this.remote = remote;
|
|
1840
2596
|
this.cache = cache;
|
|
1841
2597
|
this.dataModule = dataModule;
|
|
1842
2598
|
this.schema = schema;
|
|
1843
|
-
this.logger = logger.child({ service: "
|
|
2599
|
+
this.logger = logger.child({ service: "Sp00kySync" });
|
|
1844
2600
|
this.upQueue = new UpQueue(this.local, this.logger);
|
|
1845
2601
|
this.downQueue = new DownQueue(this.local, this.logger);
|
|
1846
2602
|
this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
|
|
1847
2603
|
this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this));
|
|
2604
|
+
this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
|
|
1848
2605
|
}
|
|
1849
2606
|
/**
|
|
1850
2607
|
* Initializes the synchronization system.
|
|
1851
2608
|
* Starts the scheduler and initiates the initial sync cycles.
|
|
1852
|
-
* @param clientId The unique identifier for this client instance.
|
|
1853
2609
|
* @throws Error if already initialized.
|
|
1854
2610
|
*/
|
|
1855
|
-
async init(
|
|
1856
|
-
if (this.isInit) throw new Error("
|
|
1857
|
-
this.clientId = clientId;
|
|
2611
|
+
async init() {
|
|
2612
|
+
if (this.isInit) throw new Error("Sp00kySync is already initialized");
|
|
1858
2613
|
this.isInit = true;
|
|
1859
2614
|
await this.scheduler.init();
|
|
1860
|
-
this.
|
|
2615
|
+
this.subscribeToReconnect();
|
|
1861
2616
|
this.scheduler.syncUp();
|
|
1862
2617
|
this.scheduler.syncDown();
|
|
1863
|
-
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Push the authenticated user's record id from the parent client's
|
|
2621
|
+
* auth subscription. Tears down the existing `_00_list_ref` LIVE (if
|
|
2622
|
+
* any) and re-registers it under the new user's dedicated table so
|
|
2623
|
+
* SurrealDB binds the permission rule under the post-flip auth
|
|
2624
|
+
* context. Pass `null` on sign-out.
|
|
2625
|
+
*
|
|
2626
|
+
* The dedicated `_00_list_ref_user_<id>` table is created lazily by
|
|
2627
|
+
* the SSP when the first query registration arrives, which may be
|
|
2628
|
+
* concurrent with this call. We retry the LIVE registration with a
|
|
2629
|
+
* short backoff so a "table not found" race resolves without
|
|
2630
|
+
* surfacing as a permanent auth-loading hang.
|
|
2631
|
+
*/
|
|
2632
|
+
async setCurrentUserId(userId) {
|
|
2633
|
+
if (this.currentUserId === userId) return;
|
|
2634
|
+
this.currentUserId = userId;
|
|
2635
|
+
if (!userId) {
|
|
2636
|
+
await this.killRefLiveQuery();
|
|
2637
|
+
this.stopListRefPoll();
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
this.startListRefPoll();
|
|
2641
|
+
const attemptDelays = [
|
|
2642
|
+
0,
|
|
2643
|
+
250,
|
|
2644
|
+
500,
|
|
2645
|
+
1e3,
|
|
2646
|
+
2e3
|
|
2647
|
+
];
|
|
2648
|
+
for (let i = 0; i < attemptDelays.length; i++) {
|
|
2649
|
+
if (attemptDelays[i] > 0) {
|
|
2650
|
+
this._liveRetryCount++;
|
|
2651
|
+
await new Promise((r) => setTimeout(r, attemptDelays[i]));
|
|
2652
|
+
}
|
|
2653
|
+
try {
|
|
2654
|
+
await this.restartRefLiveQuery();
|
|
2655
|
+
return;
|
|
2656
|
+
} catch (err) {
|
|
2657
|
+
this.logger.debug({
|
|
2658
|
+
err,
|
|
2659
|
+
attempt: i + 1,
|
|
2660
|
+
Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
|
|
2661
|
+
}, "Ref LIVE start failed; relying on periodic poll fallback");
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
startListRefPoll() {
|
|
2666
|
+
if (this.listRefPollRunning) return;
|
|
2667
|
+
this.listRefPollRunning = true;
|
|
2668
|
+
this.logger.debug({
|
|
2669
|
+
intervalMs: this.refSyncIntervalMs,
|
|
2670
|
+
Category: "sp00ky-client::Sp00kySync::startListRefPoll"
|
|
2671
|
+
}, "list_ref poll loop started");
|
|
2672
|
+
const schedule = (delayMs) => {
|
|
2673
|
+
this.listRefPollTimer = setTimeout(async () => {
|
|
2674
|
+
if (!this.listRefPollRunning) return;
|
|
2675
|
+
let changed = false;
|
|
2676
|
+
try {
|
|
2677
|
+
changed = await this.pollListRefForActiveQueries();
|
|
2678
|
+
} finally {
|
|
2679
|
+
if (!this.listRefPollRunning) return;
|
|
2680
|
+
this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
|
|
2681
|
+
schedule(listRefPollDelayMs({
|
|
2682
|
+
idleStreak: this.listRefIdleStreak,
|
|
2683
|
+
baseIntervalMs: this.refSyncIntervalMs
|
|
2684
|
+
}));
|
|
2685
|
+
}
|
|
2686
|
+
}, delayMs);
|
|
2687
|
+
};
|
|
2688
|
+
schedule(this.refSyncIntervalMs);
|
|
2689
|
+
}
|
|
2690
|
+
stopListRefPoll() {
|
|
2691
|
+
this.listRefPollRunning = false;
|
|
2692
|
+
if (this.listRefPollTimer !== null) {
|
|
2693
|
+
clearTimeout(this.listRefPollTimer);
|
|
2694
|
+
this.listRefPollTimer = null;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* One poll cycle: refetch `_00_list_ref` for every active query. Returns
|
|
2699
|
+
* whether ANY query's remoteArray actually changed — the scheduler uses this
|
|
2700
|
+
* to drive the adaptive idle backoff.
|
|
2701
|
+
*/
|
|
2702
|
+
async pollListRefForActiveQueries() {
|
|
2703
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
2704
|
+
if (hashes.length === 0) return false;
|
|
2705
|
+
let anyChanged = false;
|
|
2706
|
+
for (const hash of hashes) try {
|
|
2707
|
+
if (await this.refetchListRefForQuery(hash)) anyChanged = true;
|
|
2708
|
+
} catch (err) {
|
|
2709
|
+
this.logger.debug({
|
|
2710
|
+
err: err?.message ?? err,
|
|
2711
|
+
hash,
|
|
2712
|
+
Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
|
|
2713
|
+
}, "Per-query list_ref poll failed");
|
|
2714
|
+
}
|
|
2715
|
+
return anyChanged;
|
|
2716
|
+
}
|
|
2717
|
+
/**
|
|
2718
|
+
* Pull the upstream list_ref entries for `queryHash`, diff them
|
|
2719
|
+
* against the local `remoteArray` cache, sync any added/updated rows
|
|
2720
|
+
* through the SyncEngine, then persist the new remoteArray. This is
|
|
2721
|
+
* the same shape `createRemoteQuery` does for its initial fetch and
|
|
2722
|
+
* what `handleRemoteListRefChange` does per-LIVE-event — we reuse
|
|
2723
|
+
* it on a timer as a fallback for missed LIVE notifications.
|
|
2724
|
+
*/
|
|
2725
|
+
async refetchListRefForQuery(queryHash) {
|
|
2726
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2727
|
+
if (!queryState) return false;
|
|
2728
|
+
const listRefTbl = this.listRefTable();
|
|
2729
|
+
const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
2730
|
+
if (!Array.isArray(items)) return false;
|
|
2731
|
+
const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
2732
|
+
const prevRemote = queryState.config.remoteArray ?? [];
|
|
2733
|
+
const freshIds = new Set(fresh.map(([id]) => id));
|
|
2734
|
+
const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
|
|
2735
|
+
const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
|
|
2736
|
+
if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
|
|
2737
|
+
try {
|
|
2738
|
+
await this.syncQuery(queryHash);
|
|
2739
|
+
} catch (err) {
|
|
2740
|
+
this.logger.info({
|
|
2741
|
+
err: err?.message ?? err,
|
|
2742
|
+
queryHash,
|
|
2743
|
+
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2744
|
+
}, "syncQuery failed during poll");
|
|
2745
|
+
}
|
|
2746
|
+
if (removedIds.length > 0) try {
|
|
2747
|
+
await this.dataModule.notifyQuerySynced(queryHash);
|
|
2748
|
+
} catch (err) {
|
|
2749
|
+
this.logger.info({
|
|
2750
|
+
err: err?.message ?? err,
|
|
2751
|
+
queryHash,
|
|
2752
|
+
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2753
|
+
}, "notifyQuerySynced failed during poll-removal re-render");
|
|
2754
|
+
}
|
|
2755
|
+
return changed;
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Resolve the current `_00_list_ref` table name for the active auth
|
|
2759
|
+
* context. Public so the `createRemoteQuery` initial-fetch path can
|
|
2760
|
+
* read from the right per-user table.
|
|
2761
|
+
*
|
|
2762
|
+
* Reads the user id from `DataModule` rather than the local mirror,
|
|
2763
|
+
* because `DataModule.setCurrentUserId` runs synchronously from the
|
|
2764
|
+
* auth callback (before any `await`), whereas `sync.setCurrentUserId`
|
|
2765
|
+
* is async — the userQuery's initial fetch can fire between those
|
|
2766
|
+
* two points and we need the correct table name immediately.
|
|
2767
|
+
*/
|
|
2768
|
+
listRefTable() {
|
|
2769
|
+
return listRefTableFor(this.refMode, this.dataModule.getCurrentUserId());
|
|
2770
|
+
}
|
|
2771
|
+
async killRefLiveQuery() {
|
|
2772
|
+
if (this.liveQueryUnsubscribe) {
|
|
2773
|
+
try {
|
|
2774
|
+
this.liveQueryUnsubscribe();
|
|
2775
|
+
} catch {}
|
|
2776
|
+
this.liveQueryUnsubscribe = null;
|
|
2777
|
+
}
|
|
2778
|
+
if (this.currentLiveQueryUuid !== null) {
|
|
2779
|
+
try {
|
|
2780
|
+
await this.remote.query("KILL $u", { u: this.currentLiveQueryUuid });
|
|
2781
|
+
} catch (err) {
|
|
2782
|
+
this.logger.debug({
|
|
2783
|
+
err,
|
|
2784
|
+
Category: "sp00ky-client::Sp00kySync::killRefLiveQuery"
|
|
2785
|
+
}, "Prior LIVE KILL failed; continuing");
|
|
2786
|
+
}
|
|
2787
|
+
this.currentLiveQueryUuid = null;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
async restartRefLiveQuery() {
|
|
2791
|
+
await this.killRefLiveQuery();
|
|
2792
|
+
await this.startRefLiveQueries();
|
|
2793
|
+
}
|
|
2794
|
+
subscribeToReconnect() {
|
|
2795
|
+
const client = this.remote.getClient();
|
|
2796
|
+
client.subscribe("disconnected", () => {
|
|
2797
|
+
this.wasDisconnected = true;
|
|
2798
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kySync::onDisconnect" }, "Remote disconnected");
|
|
2799
|
+
});
|
|
2800
|
+
client.subscribe("connected", () => {
|
|
2801
|
+
if (!this.wasDisconnected) return;
|
|
2802
|
+
this.wasDisconnected = false;
|
|
2803
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
|
|
2804
|
+
for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
|
|
2805
|
+
type: "register",
|
|
2806
|
+
payload: { hash }
|
|
2807
|
+
});
|
|
2808
|
+
if (this.currentUserId) this.restartRefLiveQuery().catch((err) => {
|
|
2809
|
+
this.logger.debug({
|
|
2810
|
+
err,
|
|
2811
|
+
Category: "sp00ky-client::Sp00kySync::onReconnect"
|
|
2812
|
+
}, "LIVE restart after reconnect failed; relying on poll fallback");
|
|
2813
|
+
});
|
|
2814
|
+
});
|
|
1864
2815
|
}
|
|
1865
2816
|
async startRefLiveQueries() {
|
|
2817
|
+
const tableName = this.listRefTable();
|
|
1866
2818
|
this.logger.debug({
|
|
1867
|
-
|
|
1868
|
-
Category: "
|
|
2819
|
+
tableName,
|
|
2820
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1869
2821
|
}, "Starting ref live queries");
|
|
1870
|
-
const [queryUuid] = await this.remote.query(
|
|
1871
|
-
|
|
2822
|
+
const [queryUuid] = await this.remote.query(`LIVE SELECT * FROM ${tableName}`);
|
|
2823
|
+
this.currentLiveQueryUuid = queryUuid;
|
|
2824
|
+
this.liveQueryUnsubscribe = (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
|
|
1872
2825
|
this.logger.debug({
|
|
1873
2826
|
message,
|
|
1874
|
-
Category: "
|
|
2827
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1875
2828
|
}, "Live update received");
|
|
1876
2829
|
if (message.action === "KILLED") return;
|
|
2830
|
+
if (message.value.parent != null) return;
|
|
1877
2831
|
this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
|
|
1878
2832
|
this.logger.error({
|
|
1879
2833
|
err,
|
|
1880
|
-
Category: "
|
|
2834
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1881
2835
|
}, "Error handling remote list ref change");
|
|
1882
2836
|
});
|
|
1883
2837
|
});
|
|
1884
2838
|
}
|
|
1885
2839
|
async handleRemoteListRefChange(action, queryId, recordId, version) {
|
|
2840
|
+
this.lastLiveEventAt = Date.now();
|
|
2841
|
+
this.listRefIdleStreak = 0;
|
|
1886
2842
|
const existing = this.dataModule.getQueryById(queryId);
|
|
1887
2843
|
if (!existing) {
|
|
1888
2844
|
this.logger.warn({
|
|
1889
2845
|
queryId: queryId.toString(),
|
|
1890
|
-
Category: "
|
|
2846
|
+
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
1891
2847
|
}, "Received remote update for unknown local query");
|
|
1892
2848
|
return;
|
|
1893
2849
|
}
|
|
@@ -1898,10 +2854,11 @@ var SpookySync = class {
|
|
|
1898
2854
|
recordId,
|
|
1899
2855
|
version,
|
|
1900
2856
|
localArray,
|
|
1901
|
-
Category: "
|
|
2857
|
+
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
1902
2858
|
}, "Live update is being processed");
|
|
1903
2859
|
const diff = createDiffFromDbOp(action, recordId, version, localArray);
|
|
1904
|
-
|
|
2860
|
+
const hash = extractIdPart(existing.config.id);
|
|
2861
|
+
await this.runSyncForQuery(hash, diff);
|
|
1905
2862
|
}
|
|
1906
2863
|
/**
|
|
1907
2864
|
* Enqueues a 'down' event (from remote to local) for processing.
|
|
@@ -1913,11 +2870,10 @@ var SpookySync = class {
|
|
|
1913
2870
|
async processUpEvent(event) {
|
|
1914
2871
|
this.logger.debug({
|
|
1915
2872
|
event,
|
|
1916
|
-
Category: "
|
|
2873
|
+
Category: "sp00ky-client::Sp00kySync::processUpEvent"
|
|
1917
2874
|
}, "Processing up event");
|
|
1918
|
-
console.log("xx1", event);
|
|
1919
2875
|
switch (event.type) {
|
|
1920
|
-
case "create":
|
|
2876
|
+
case "create": {
|
|
1921
2877
|
const dataKeys = Object.keys(event.data).map((key) => ({
|
|
1922
2878
|
key,
|
|
1923
2879
|
variable: `data_${key}`
|
|
@@ -1929,6 +2885,7 @@ var SpookySync = class {
|
|
|
1929
2885
|
...prefixedParams
|
|
1930
2886
|
});
|
|
1931
2887
|
break;
|
|
2888
|
+
}
|
|
1932
2889
|
case "update":
|
|
1933
2890
|
await this.remote.query(`UPDATE $id MERGE $data`, {
|
|
1934
2891
|
id: event.record_id,
|
|
@@ -1941,7 +2898,7 @@ var SpookySync = class {
|
|
|
1941
2898
|
default:
|
|
1942
2899
|
this.logger.error({
|
|
1943
2900
|
event,
|
|
1944
|
-
Category: "
|
|
2901
|
+
Category: "sp00ky-client::Sp00kySync::processUpEvent"
|
|
1945
2902
|
}, "processUpEvent unknown event type");
|
|
1946
2903
|
return;
|
|
1947
2904
|
}
|
|
@@ -1954,7 +2911,7 @@ var SpookySync = class {
|
|
|
1954
2911
|
recordId,
|
|
1955
2912
|
tableName,
|
|
1956
2913
|
error: error.message,
|
|
1957
|
-
Category: "
|
|
2914
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1958
2915
|
}, "Rolling back failed mutation");
|
|
1959
2916
|
switch (event.type) {
|
|
1960
2917
|
case "create":
|
|
@@ -1964,13 +2921,13 @@ var SpookySync = class {
|
|
|
1964
2921
|
if (event.beforeRecord) await this.dataModule.rollbackUpdate(event.record_id, tableName, event.beforeRecord);
|
|
1965
2922
|
else this.logger.warn({
|
|
1966
2923
|
recordId,
|
|
1967
|
-
Category: "
|
|
2924
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1968
2925
|
}, "Cannot rollback update: no beforeRecord available. Down-sync will reconcile.");
|
|
1969
2926
|
break;
|
|
1970
2927
|
case "delete":
|
|
1971
2928
|
this.logger.warn({
|
|
1972
2929
|
recordId,
|
|
1973
|
-
Category: "
|
|
2930
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1974
2931
|
}, "Delete rollback not implemented. Down-sync will reconcile.");
|
|
1975
2932
|
break;
|
|
1976
2933
|
}
|
|
@@ -1983,7 +2940,7 @@ var SpookySync = class {
|
|
|
1983
2940
|
async processDownEvent(event) {
|
|
1984
2941
|
this.logger.debug({
|
|
1985
2942
|
event,
|
|
1986
|
-
Category: "
|
|
2943
|
+
Category: "sp00ky-client::Sp00kySync::processDownEvent"
|
|
1987
2944
|
}, "Processing down event");
|
|
1988
2945
|
switch (event.type) {
|
|
1989
2946
|
case "register": return this.registerQuery(event.payload.hash);
|
|
@@ -2002,13 +2959,76 @@ var SpookySync = class {
|
|
|
2002
2959
|
if (!queryState) {
|
|
2003
2960
|
this.logger.warn({
|
|
2004
2961
|
hash,
|
|
2005
|
-
Category: "
|
|
2962
|
+
Category: "sp00ky-client::Sp00kySync::syncQuery"
|
|
2006
2963
|
}, "Query not found");
|
|
2007
2964
|
return;
|
|
2008
2965
|
}
|
|
2009
2966
|
const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
|
|
2010
2967
|
if (!diff) return;
|
|
2011
|
-
return this.
|
|
2968
|
+
return this.runSyncForQuery(hash, diff);
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Run a sync for a single query while reflecting its fetch status. Marks the
|
|
2972
|
+
* query `fetching` for the duration when the diff actually pulls records
|
|
2973
|
+
* (added/updated), then resets to `idle` in a `finally` so a failed sync
|
|
2974
|
+
* never leaves a query stuck `fetching`. Part A's notification coalescing
|
|
2975
|
+
* means the single resulting UI update lands after this completes.
|
|
2976
|
+
*/
|
|
2977
|
+
async runSyncForQuery(hash, diff) {
|
|
2978
|
+
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
2979
|
+
const pendingDeletes = await this.getPendingDeleteIds();
|
|
2980
|
+
if (pendingDeletes.size > 0) diff = {
|
|
2981
|
+
added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
2982
|
+
updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
2983
|
+
removed: diff.removed
|
|
2984
|
+
};
|
|
2985
|
+
}
|
|
2986
|
+
const fetching = diff.added.length + diff.updated.length > 0;
|
|
2987
|
+
if (fetching) this.dataModule.setQueryStatus(hash, "fetching");
|
|
2988
|
+
try {
|
|
2989
|
+
const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
|
|
2990
|
+
if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
|
|
2991
|
+
if (stillRemoteIds.length > 0) {
|
|
2992
|
+
const CONVERGE_AFTER = 3;
|
|
2993
|
+
const toConverge = [];
|
|
2994
|
+
for (const id of stillRemoteIds) {
|
|
2995
|
+
const key = `${hash}:${id}`;
|
|
2996
|
+
const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
|
|
2997
|
+
if (n >= CONVERGE_AFTER) {
|
|
2998
|
+
this.stillRemoteStreaks.delete(key);
|
|
2999
|
+
toConverge.push(id);
|
|
3000
|
+
} else this.stillRemoteStreaks.set(key, n);
|
|
3001
|
+
}
|
|
3002
|
+
if (toConverge.length > 0) {
|
|
3003
|
+
const local = this.dataModule.getQueryByHash(hash)?.config.localArray;
|
|
3004
|
+
if (local && local.length > 0) {
|
|
3005
|
+
const drop = new Set(toConverge);
|
|
3006
|
+
const next = local.filter(([id]) => !drop.has(id));
|
|
3007
|
+
if (next.length !== local.length) await this.dataModule.updateQueryLocalArray(hash, next);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
} finally {
|
|
3012
|
+
if (fetching) this.dataModule.setQueryStatus(hash, "idle");
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
|
|
3017
|
+
* Sync must not re-fetch/re-insert these — the remote delete is async, so the
|
|
3018
|
+
* server's `_00_list_ref` still lists them until it's processed, and the diff
|
|
3019
|
+
* would otherwise resurrect a just-deleted record.
|
|
3020
|
+
*/
|
|
3021
|
+
async getPendingDeleteIds() {
|
|
3022
|
+
try {
|
|
3023
|
+
const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
|
|
3024
|
+
return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
|
|
3025
|
+
} catch (err) {
|
|
3026
|
+
this.logger.warn({
|
|
3027
|
+
err,
|
|
3028
|
+
Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
|
|
3029
|
+
}, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
|
|
3030
|
+
return /* @__PURE__ */ new Set();
|
|
3031
|
+
}
|
|
2012
3032
|
}
|
|
2013
3033
|
/**
|
|
2014
3034
|
* Enqueues a list of mutations (up events) to be sent to the remote.
|
|
@@ -2021,7 +3041,7 @@ var SpookySync = class {
|
|
|
2021
3041
|
try {
|
|
2022
3042
|
this.logger.debug({
|
|
2023
3043
|
queryHash,
|
|
2024
|
-
Category: "
|
|
3044
|
+
Category: "sp00ky-client::Sp00kySync::registerQuery"
|
|
2025
3045
|
}, "Register Query state");
|
|
2026
3046
|
await this.createRemoteQuery(queryHash);
|
|
2027
3047
|
await this.syncQuery(queryHash);
|
|
@@ -2029,7 +3049,7 @@ var SpookySync = class {
|
|
|
2029
3049
|
} catch (e) {
|
|
2030
3050
|
this.logger.error({
|
|
2031
3051
|
err: e,
|
|
2032
|
-
Category: "
|
|
3052
|
+
Category: "sp00ky-client::Sp00kySync::registerQuery"
|
|
2033
3053
|
}, "registerQuery error");
|
|
2034
3054
|
throw e;
|
|
2035
3055
|
}
|
|
@@ -2039,28 +3059,28 @@ var SpookySync = class {
|
|
|
2039
3059
|
if (!queryState) {
|
|
2040
3060
|
this.logger.warn({
|
|
2041
3061
|
queryHash,
|
|
2042
|
-
Category: "
|
|
3062
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
2043
3063
|
}, "Query to register not found");
|
|
2044
3064
|
throw new Error("Query to register not found");
|
|
2045
3065
|
}
|
|
2046
3066
|
await this.remote.query("fn::query::register($config)", { config: {
|
|
2047
|
-
clientId: this.clientId,
|
|
2048
3067
|
id: queryState.config.id,
|
|
2049
3068
|
surql: queryState.config.surql,
|
|
2050
3069
|
params: queryState.config.params,
|
|
2051
3070
|
ttl: queryState.config.ttl
|
|
2052
3071
|
} });
|
|
2053
|
-
const
|
|
3072
|
+
const listRefTbl = this.listRefTable();
|
|
3073
|
+
const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
2054
3074
|
this.logger.trace({
|
|
2055
3075
|
queryId: encodeRecordId(queryState.config.id),
|
|
2056
3076
|
items,
|
|
2057
|
-
Category: "
|
|
3077
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
2058
3078
|
}, "Got query record version array from remote");
|
|
2059
3079
|
const array = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
2060
3080
|
this.logger.debug({
|
|
2061
3081
|
queryId: encodeRecordId(queryState.config.id),
|
|
2062
3082
|
array,
|
|
2063
|
-
Category: "
|
|
3083
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
2064
3084
|
}, "createdRemoteQuery");
|
|
2065
3085
|
if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array);
|
|
2066
3086
|
}
|
|
@@ -2069,7 +3089,7 @@ var SpookySync = class {
|
|
|
2069
3089
|
if (!queryState) {
|
|
2070
3090
|
this.logger.warn({
|
|
2071
3091
|
queryHash,
|
|
2072
|
-
Category: "
|
|
3092
|
+
Category: "sp00ky-client::Sp00kySync::heartbeatQuery"
|
|
2073
3093
|
}, "Query to register not found");
|
|
2074
3094
|
throw new Error("Query to register not found");
|
|
2075
3095
|
}
|
|
@@ -2077,14 +3097,17 @@ var SpookySync = class {
|
|
|
2077
3097
|
}
|
|
2078
3098
|
async cleanupQuery(queryHash) {
|
|
2079
3099
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2080
|
-
if (!queryState)
|
|
2081
|
-
|
|
2082
|
-
queryHash,
|
|
2083
|
-
Category: "spooky-client::SpookySync::cleanupQuery"
|
|
2084
|
-
}, "Query to register not found");
|
|
2085
|
-
throw new Error("Query to register not found");
|
|
2086
|
-
}
|
|
3100
|
+
if (!queryState) return;
|
|
3101
|
+
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
2087
3102
|
await this.remote.query(`DELETE $id`, { id: queryState.config.id });
|
|
3103
|
+
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
3104
|
+
this.enqueueDownEvent({
|
|
3105
|
+
type: "register",
|
|
3106
|
+
payload: { hash: queryHash }
|
|
3107
|
+
});
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
this.dataModule.finalizeDeregister(queryHash);
|
|
2088
3111
|
}
|
|
2089
3112
|
};
|
|
2090
3113
|
|
|
@@ -2095,12 +3118,67 @@ function createAuthEventSystem() {
|
|
|
2095
3118
|
return createEventSystem([AuthEventTypes.AuthStateChanged]);
|
|
2096
3119
|
}
|
|
2097
3120
|
|
|
3121
|
+
//#endregion
|
|
3122
|
+
//#region src/modules/devtools/versions.ts
|
|
3123
|
+
const UNAVAILABLE = "unavailable";
|
|
3124
|
+
function emptyBackendVersions() {
|
|
3125
|
+
return {
|
|
3126
|
+
ssp: UNAVAILABLE,
|
|
3127
|
+
scheduler: UNAVAILABLE,
|
|
3128
|
+
surrealdb: UNAVAILABLE
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
function emptyBackendInfo() {
|
|
3132
|
+
return {
|
|
3133
|
+
versions: emptyBackendVersions(),
|
|
3134
|
+
entities: []
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
/** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
|
|
3138
|
+
function normalizeServerVersion(v) {
|
|
3139
|
+
return String(v).replace(/^surrealdb-/i, "").trim();
|
|
3140
|
+
}
|
|
3141
|
+
/**
|
|
3142
|
+
* Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
|
|
3143
|
+
* array. The SurrealQL function returns the parsed `/info` array; depending on
|
|
3144
|
+
* how the result is unwrapped it may arrive as the array itself, a single
|
|
3145
|
+
* object, or `null`. Tolerant of all three.
|
|
3146
|
+
*/
|
|
3147
|
+
function toEntityArray(raw) {
|
|
3148
|
+
if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
|
|
3149
|
+
if (raw && typeof raw === "object") return [raw];
|
|
3150
|
+
return [];
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Derive component versions + the full entity list from a `/info` entity array.
|
|
3154
|
+
* `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
|
|
3155
|
+
* scheduler). Never throws; missing pieces stay `'unavailable'`.
|
|
3156
|
+
*/
|
|
3157
|
+
function parseBackendInfo(raw) {
|
|
3158
|
+
const entities = toEntityArray(raw);
|
|
3159
|
+
const versions = emptyBackendVersions();
|
|
3160
|
+
for (const entity of entities) {
|
|
3161
|
+
const version = entity.version ? String(entity.version) : void 0;
|
|
3162
|
+
if (entity.entity === "ssp" && version) versions.ssp = version;
|
|
3163
|
+
else if (entity.entity === "scheduler" && version) versions.scheduler = version;
|
|
3164
|
+
if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
|
|
3165
|
+
}
|
|
3166
|
+
return {
|
|
3167
|
+
versions,
|
|
3168
|
+
entities
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
|
|
2098
3172
|
//#endregion
|
|
2099
3173
|
//#region src/modules/devtools/index.ts
|
|
3174
|
+
const CORE_VERSION = "0.0.1-canary.71";
|
|
3175
|
+
const WASM_VERSION = "0.0.1-canary.71";
|
|
3176
|
+
const SURREAL_VERSION = "3.0.3";
|
|
2100
3177
|
var DevToolsService = class {
|
|
2101
3178
|
eventsHistory = [];
|
|
2102
3179
|
eventIdCounter = 0;
|
|
2103
|
-
version =
|
|
3180
|
+
version = CORE_VERSION;
|
|
3181
|
+
backendInfo = emptyBackendInfo();
|
|
2104
3182
|
constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
|
|
2105
3183
|
this.databaseService = databaseService;
|
|
2106
3184
|
this.remoteDatabaseService = remoteDatabaseService;
|
|
@@ -2112,7 +3190,26 @@ var DevToolsService = class {
|
|
|
2112
3190
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
2113
3191
|
this.notifyDevTools();
|
|
2114
3192
|
});
|
|
2115
|
-
this.
|
|
3193
|
+
this.refreshBackendVersions();
|
|
3194
|
+
this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
3197
|
+
* Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
|
|
3198
|
+
* over the open remote connection (no HTTP/CORS), then notify the panel.
|
|
3199
|
+
* Never throws: on failure the info stays empty/'unavailable'.
|
|
3200
|
+
*/
|
|
3201
|
+
async refreshBackendVersions() {
|
|
3202
|
+
try {
|
|
3203
|
+
const result = await this.remoteDatabaseService.query("RETURN fn::spooky::info()");
|
|
3204
|
+
this.backendInfo = parseBackendInfo(Array.isArray(result) ? result[0] : result);
|
|
3205
|
+
} catch (err) {
|
|
3206
|
+
this.logger.debug({
|
|
3207
|
+
err,
|
|
3208
|
+
Category: "sp00ky-client::DevToolsService::versions"
|
|
3209
|
+
}, "fn::spooky::info() unavailable; backend versions stay unavailable");
|
|
3210
|
+
this.backendInfo = emptyBackendInfo();
|
|
3211
|
+
}
|
|
3212
|
+
this.notifyDevTools();
|
|
2116
3213
|
}
|
|
2117
3214
|
getActiveQueries() {
|
|
2118
3215
|
const result = /* @__PURE__ */ new Map();
|
|
@@ -2122,6 +3219,8 @@ var DevToolsService = class {
|
|
|
2122
3219
|
result.set(queryHash, {
|
|
2123
3220
|
queryHash,
|
|
2124
3221
|
status: "active",
|
|
3222
|
+
fetchStatus: q.status,
|
|
3223
|
+
isFetching: q.status === "fetching",
|
|
2125
3224
|
createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
|
|
2126
3225
|
lastUpdate: Date.now(),
|
|
2127
3226
|
updateCount: q.updateCount,
|
|
@@ -2130,7 +3229,8 @@ var DevToolsService = class {
|
|
|
2130
3229
|
dataSize: q.records?.length || 0,
|
|
2131
3230
|
data: q.records,
|
|
2132
3231
|
localArray: q.config.localArray,
|
|
2133
|
-
remoteArray: q.config.remoteArray
|
|
3232
|
+
remoteArray: q.config.remoteArray,
|
|
3233
|
+
timings: this.dataManager.phaseTimings(q)
|
|
2134
3234
|
});
|
|
2135
3235
|
});
|
|
2136
3236
|
return result;
|
|
@@ -2138,7 +3238,7 @@ var DevToolsService = class {
|
|
|
2138
3238
|
onQueryInitialized(payload) {
|
|
2139
3239
|
this.logger.debug({
|
|
2140
3240
|
payload,
|
|
2141
|
-
Category: "
|
|
3241
|
+
Category: "sp00ky-client::DevToolsService::onQueryInitialized"
|
|
2142
3242
|
}, "QueryInitialized");
|
|
2143
3243
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
2144
3244
|
this.addEvent("QUERY_REQUEST_INIT", {
|
|
@@ -2151,7 +3251,7 @@ var DevToolsService = class {
|
|
|
2151
3251
|
onQueryUpdated(payload) {
|
|
2152
3252
|
this.logger.debug({
|
|
2153
3253
|
id: payload.queryId?.toString(),
|
|
2154
|
-
Category: "
|
|
3254
|
+
Category: "sp00ky-client::DevToolsService::onQueryUpdated"
|
|
2155
3255
|
}, "QueryUpdated");
|
|
2156
3256
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
2157
3257
|
this.addEvent("QUERY_UPDATED", {
|
|
@@ -2163,7 +3263,7 @@ var DevToolsService = class {
|
|
|
2163
3263
|
onStreamUpdate(update) {
|
|
2164
3264
|
this.logger.debug({
|
|
2165
3265
|
update,
|
|
2166
|
-
Category: "
|
|
3266
|
+
Category: "sp00ky-client::DevToolsService::onStreamUpdate"
|
|
2167
3267
|
}, "StreamUpdate");
|
|
2168
3268
|
this.addEvent("STREAM_UPDATE", { updates: [update] });
|
|
2169
3269
|
this.notifyDevTools();
|
|
@@ -2210,6 +3310,15 @@ var DevToolsService = class {
|
|
|
2210
3310
|
userId: this.authService.currentUser?.id
|
|
2211
3311
|
},
|
|
2212
3312
|
version: this.version,
|
|
3313
|
+
versions: {
|
|
3314
|
+
frontend: {
|
|
3315
|
+
core: CORE_VERSION,
|
|
3316
|
+
wasm: WASM_VERSION,
|
|
3317
|
+
surrealdb: SURREAL_VERSION
|
|
3318
|
+
},
|
|
3319
|
+
backend: this.backendInfo.versions,
|
|
3320
|
+
entities: this.backendInfo.entities
|
|
3321
|
+
},
|
|
2213
3322
|
database: {
|
|
2214
3323
|
tables: this.schema.tables.map((t) => t.name),
|
|
2215
3324
|
tableData: {}
|
|
@@ -2218,8 +3327,8 @@ var DevToolsService = class {
|
|
|
2218
3327
|
}
|
|
2219
3328
|
notifyDevTools() {
|
|
2220
3329
|
if (typeof window !== "undefined") window.postMessage({
|
|
2221
|
-
type: "
|
|
2222
|
-
source: "
|
|
3330
|
+
type: "SP00KY_STATE_CHANGED",
|
|
3331
|
+
source: "sp00ky-devtools-page",
|
|
2223
3332
|
state: this.getState()
|
|
2224
3333
|
}, "*");
|
|
2225
3334
|
}
|
|
@@ -2245,13 +3354,14 @@ var DevToolsService = class {
|
|
|
2245
3354
|
}
|
|
2246
3355
|
exposeToWindow() {
|
|
2247
3356
|
if (typeof window !== "undefined") {
|
|
2248
|
-
window.
|
|
3357
|
+
window.__00__ = {
|
|
2249
3358
|
version: this.version,
|
|
2250
3359
|
getState: () => this.getState(),
|
|
2251
3360
|
clearHistory: () => {
|
|
2252
3361
|
this.eventsHistory = [];
|
|
2253
3362
|
this.notifyDevTools();
|
|
2254
3363
|
},
|
|
3364
|
+
refreshVersions: () => this.refreshBackendVersions(),
|
|
2255
3365
|
getTableData: async (tableName) => {
|
|
2256
3366
|
try {
|
|
2257
3367
|
const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
|
|
@@ -2266,7 +3376,7 @@ var DevToolsService = class {
|
|
|
2266
3376
|
} catch (e) {
|
|
2267
3377
|
this.logger.error({
|
|
2268
3378
|
err: e,
|
|
2269
|
-
Category: "
|
|
3379
|
+
Category: "sp00ky-client::DevToolsService::exposeToWindow"
|
|
2270
3380
|
}, "Failed to get table data");
|
|
2271
3381
|
return [];
|
|
2272
3382
|
}
|
|
@@ -2298,7 +3408,7 @@ var DevToolsService = class {
|
|
|
2298
3408
|
this.logger.debug({
|
|
2299
3409
|
query,
|
|
2300
3410
|
target,
|
|
2301
|
-
Category: "
|
|
3411
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2302
3412
|
}, "Running query (START)");
|
|
2303
3413
|
const service = target === "remote" ? this.remoteDatabaseService : this.databaseService;
|
|
2304
3414
|
const startTime = Date.now();
|
|
@@ -2309,7 +3419,7 @@ var DevToolsService = class {
|
|
|
2309
3419
|
time: queryTime,
|
|
2310
3420
|
resultType: typeof result,
|
|
2311
3421
|
isArray: Array.isArray(result),
|
|
2312
|
-
Category: "
|
|
3422
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2313
3423
|
}, "Database returned result");
|
|
2314
3424
|
const serializeStart = Date.now();
|
|
2315
3425
|
const serialized = this.serializeForDevTools(result);
|
|
@@ -2317,7 +3427,7 @@ var DevToolsService = class {
|
|
|
2317
3427
|
this.logger.debug({
|
|
2318
3428
|
serializeTime,
|
|
2319
3429
|
serializedLength: JSON.stringify(serialized).length,
|
|
2320
|
-
Category: "
|
|
3430
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2321
3431
|
}, "Serialization complete");
|
|
2322
3432
|
return {
|
|
2323
3433
|
success: true,
|
|
@@ -2329,7 +3439,7 @@ var DevToolsService = class {
|
|
|
2329
3439
|
err: e,
|
|
2330
3440
|
query,
|
|
2331
3441
|
target,
|
|
2332
|
-
Category: "
|
|
3442
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2333
3443
|
}, "Query execution failed");
|
|
2334
3444
|
return {
|
|
2335
3445
|
success: false,
|
|
@@ -2339,13 +3449,14 @@ var DevToolsService = class {
|
|
|
2339
3449
|
}
|
|
2340
3450
|
};
|
|
2341
3451
|
window.postMessage({
|
|
2342
|
-
type: "
|
|
2343
|
-
source: "
|
|
3452
|
+
type: "SP00KY_DETECTED",
|
|
3453
|
+
source: "sp00ky-devtools-page",
|
|
2344
3454
|
data: {
|
|
2345
3455
|
version: this.version,
|
|
2346
3456
|
detected: true
|
|
2347
3457
|
}
|
|
2348
3458
|
}, "*");
|
|
3459
|
+
window.dispatchEvent(new CustomEvent("sp00ky:init"));
|
|
2349
3460
|
}
|
|
2350
3461
|
}
|
|
2351
3462
|
};
|
|
@@ -2396,9 +3507,9 @@ var AuthService = class {
|
|
|
2396
3507
|
async check(accessToken) {
|
|
2397
3508
|
this.isLoading = true;
|
|
2398
3509
|
try {
|
|
2399
|
-
const token = accessToken || await this.persistenceClient.get("
|
|
3510
|
+
const token = accessToken || await this.persistenceClient.get("sp00ky_auth_token");
|
|
2400
3511
|
if (!token) {
|
|
2401
|
-
this.logger.debug({ Category: "
|
|
3512
|
+
this.logger.debug({ Category: "sp00ky-client::AuthService::check" }, "No token found in storage or arguments");
|
|
2402
3513
|
this.isLoading = false;
|
|
2403
3514
|
this.isAuthenticated = false;
|
|
2404
3515
|
this.notifyListeners();
|
|
@@ -2411,22 +3522,22 @@ var AuthService = class {
|
|
|
2411
3522
|
if (user && user.id) {
|
|
2412
3523
|
this.logger.info({
|
|
2413
3524
|
user,
|
|
2414
|
-
Category: "
|
|
3525
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2415
3526
|
}, "Auth check complete (via $auth.id)");
|
|
2416
3527
|
await this.setSession(token, user);
|
|
2417
3528
|
} else {
|
|
2418
|
-
this.logger.warn({ Category: "
|
|
3529
|
+
this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "$auth.id empty, attempting manual user fetch");
|
|
2419
3530
|
const manualResult = await this.remote.query("SELECT * FROM user WHERE id = $auth.id LIMIT 1");
|
|
2420
3531
|
const manualItems = Array.isArray(manualResult) && Array.isArray(manualResult[0]) ? manualResult[0] : manualResult;
|
|
2421
3532
|
const manualUser = Array.isArray(manualItems) ? manualItems[0] : manualItems;
|
|
2422
3533
|
if (manualUser && manualUser.id) {
|
|
2423
3534
|
this.logger.info({
|
|
2424
3535
|
user: manualUser,
|
|
2425
|
-
Category: "
|
|
3536
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2426
3537
|
}, "Auth check complete (via manual fetch)");
|
|
2427
3538
|
await this.setSession(token, manualUser);
|
|
2428
3539
|
} else {
|
|
2429
|
-
this.logger.warn({ Category: "
|
|
3540
|
+
this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "Token valid but user not found via fallback");
|
|
2430
3541
|
await this.signOut();
|
|
2431
3542
|
}
|
|
2432
3543
|
}
|
|
@@ -2434,7 +3545,7 @@ var AuthService = class {
|
|
|
2434
3545
|
this.logger.error({
|
|
2435
3546
|
error,
|
|
2436
3547
|
stack: error.stack,
|
|
2437
|
-
Category: "
|
|
3548
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2438
3549
|
}, "Auth check failed");
|
|
2439
3550
|
await this.signOut();
|
|
2440
3551
|
} finally {
|
|
@@ -2448,17 +3559,17 @@ var AuthService = class {
|
|
|
2448
3559
|
this.token = null;
|
|
2449
3560
|
this.currentUser = null;
|
|
2450
3561
|
this.isAuthenticated = false;
|
|
2451
|
-
await this.persistenceClient.remove("
|
|
3562
|
+
await this.persistenceClient.remove("sp00ky_auth_token");
|
|
2452
3563
|
try {
|
|
2453
3564
|
await this.remote.getClient().invalidate();
|
|
2454
|
-
} catch (
|
|
3565
|
+
} catch (_e) {}
|
|
2455
3566
|
this.notifyListeners();
|
|
2456
3567
|
}
|
|
2457
3568
|
async setSession(token, user) {
|
|
2458
3569
|
this.token = token;
|
|
2459
3570
|
this.currentUser = user;
|
|
2460
3571
|
this.isAuthenticated = true;
|
|
2461
|
-
await this.persistenceClient.set("
|
|
3572
|
+
await this.persistenceClient.set("sp00ky_auth_token", token);
|
|
2462
3573
|
this.notifyListeners();
|
|
2463
3574
|
}
|
|
2464
3575
|
async signUp(accessName, params) {
|
|
@@ -2470,13 +3581,13 @@ var AuthService = class {
|
|
|
2470
3581
|
this.logger.info({
|
|
2471
3582
|
accessName,
|
|
2472
3583
|
runtimeParams,
|
|
2473
|
-
Category: "
|
|
3584
|
+
Category: "sp00ky-client::AuthService::signUp"
|
|
2474
3585
|
}, "Attempting signup");
|
|
2475
3586
|
const { access } = await this.remote.getClient().signup({
|
|
2476
3587
|
access: accessName,
|
|
2477
3588
|
variables: runtimeParams
|
|
2478
3589
|
});
|
|
2479
|
-
this.logger.info({ Category: "
|
|
3590
|
+
this.logger.info({ Category: "sp00ky-client::AuthService::signUp" }, "Signup successful, token received");
|
|
2480
3591
|
await this.check(access);
|
|
2481
3592
|
}
|
|
2482
3593
|
async signIn(accessName, params) {
|
|
@@ -2487,7 +3598,7 @@ var AuthService = class {
|
|
|
2487
3598
|
if (missingParams.length > 0) throw new Error(`Missing required signin params for '${accessName}': ${missingParams.join(", ")}`);
|
|
2488
3599
|
this.logger.info({
|
|
2489
3600
|
accessName,
|
|
2490
|
-
Category: "
|
|
3601
|
+
Category: "sp00ky-client::AuthService::signIn"
|
|
2491
3602
|
}, "Attempting signin");
|
|
2492
3603
|
const { access } = await this.remote.getClient().signin({
|
|
2493
3604
|
access: accessName,
|
|
@@ -2504,6 +3615,8 @@ var StreamProcessorService = class {
|
|
|
2504
3615
|
processor;
|
|
2505
3616
|
isInitialized = false;
|
|
2506
3617
|
receivers = [];
|
|
3618
|
+
batching = false;
|
|
3619
|
+
batchBuffer = /* @__PURE__ */ new Map();
|
|
2507
3620
|
constructor(events, db, persistenceClient, logger) {
|
|
2508
3621
|
this.events = events;
|
|
2509
3622
|
this.db = db;
|
|
@@ -2518,25 +3631,91 @@ var StreamProcessorService = class {
|
|
|
2518
3631
|
this.receivers.push(receiver);
|
|
2519
3632
|
}
|
|
2520
3633
|
notifyUpdates(updates) {
|
|
3634
|
+
if (this.batching) {
|
|
3635
|
+
for (const update of updates) {
|
|
3636
|
+
const prev = this.batchBuffer.get(update.queryHash);
|
|
3637
|
+
const sum = (a, b) => (a ?? 0) + (b ?? 0);
|
|
3638
|
+
this.batchBuffer.set(update.queryHash, {
|
|
3639
|
+
...update,
|
|
3640
|
+
op: "CREATE",
|
|
3641
|
+
materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
|
|
3642
|
+
storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
|
|
3643
|
+
circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
|
|
3644
|
+
transformMs: sum(prev?.transformMs, update.transformMs)
|
|
3645
|
+
});
|
|
3646
|
+
}
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
this.dispatchUpdates(updates);
|
|
3650
|
+
}
|
|
3651
|
+
dispatchUpdates(updates) {
|
|
2521
3652
|
for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
|
|
2522
3653
|
}
|
|
2523
3654
|
/**
|
|
3655
|
+
* Ingest a batch of record changes as a single bulk operation, firing only
|
|
3656
|
+
* one coalesced `StreamUpdate` per affected query once every record has been
|
|
3657
|
+
* ingested (instead of one update per record). Use this whenever multiple
|
|
3658
|
+
* records land at once — e.g. sync fetching N missing rows — so a list query
|
|
3659
|
+
* re-runs and the UI re-renders once for the whole batch rather than
|
|
3660
|
+
* row-by-row.
|
|
3661
|
+
*
|
|
3662
|
+
* Internally opens a coalescing window, ingests each record, then flushes;
|
|
3663
|
+
* processor state is persisted once for the whole batch. No-op for an empty
|
|
3664
|
+
* batch.
|
|
3665
|
+
*/
|
|
3666
|
+
ingestMany(records) {
|
|
3667
|
+
if (records.length === 0) return;
|
|
3668
|
+
this.beginCoalescing();
|
|
3669
|
+
try {
|
|
3670
|
+
for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
|
|
3671
|
+
} finally {
|
|
3672
|
+
this.flushCoalescing();
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
/**
|
|
3676
|
+
* Open a coalescing window. While open, the per-record stream updates
|
|
3677
|
+
* emitted by `ingest` are buffered (one entry per queryHash) instead of
|
|
3678
|
+
* dispatched. Always paired with `flushCoalescing()` in a try/finally by
|
|
3679
|
+
* `ingestMany` so the window always closes — otherwise the processor stays
|
|
3680
|
+
* stuck buffering forever.
|
|
3681
|
+
*
|
|
3682
|
+
* No-op if a window is already open (nested batches aren't expected here).
|
|
3683
|
+
*/
|
|
3684
|
+
beginCoalescing() {
|
|
3685
|
+
if (this.batching) return;
|
|
3686
|
+
this.batching = true;
|
|
3687
|
+
this.batchBuffer.clear();
|
|
3688
|
+
}
|
|
3689
|
+
/**
|
|
3690
|
+
* Close the coalescing window and flush: dispatch one coalesced
|
|
3691
|
+
* `StreamUpdate` per buffered queryHash, then persist processor state once
|
|
3692
|
+
* for the whole batch (instead of once per ingest).
|
|
3693
|
+
*/
|
|
3694
|
+
flushCoalescing() {
|
|
3695
|
+
if (!this.batching) return;
|
|
3696
|
+
this.batching = false;
|
|
3697
|
+
const buffered = Array.from(this.batchBuffer.values());
|
|
3698
|
+
this.batchBuffer.clear();
|
|
3699
|
+
if (buffered.length > 0) this.dispatchUpdates(buffered);
|
|
3700
|
+
this.saveState();
|
|
3701
|
+
}
|
|
3702
|
+
/**
|
|
2524
3703
|
* Initialize the WASM module and processor.
|
|
2525
3704
|
* This must be called before using other methods.
|
|
2526
3705
|
*/
|
|
2527
3706
|
async init() {
|
|
2528
3707
|
if (this.isInitialized) return;
|
|
2529
|
-
this.logger.info({ Category: "
|
|
3708
|
+
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initializing WASM...");
|
|
2530
3709
|
try {
|
|
2531
3710
|
await init();
|
|
2532
|
-
this.processor = new
|
|
3711
|
+
this.processor = new Sp00kyProcessor();
|
|
2533
3712
|
await this.loadState();
|
|
2534
3713
|
this.isInitialized = true;
|
|
2535
|
-
this.logger.info({ Category: "
|
|
3714
|
+
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initialized successfully");
|
|
2536
3715
|
} catch (e) {
|
|
2537
3716
|
this.logger.error({
|
|
2538
3717
|
error: e,
|
|
2539
|
-
Category: "
|
|
3718
|
+
Category: "sp00ky-client::StreamProcessorService::init"
|
|
2540
3719
|
}, "Failed to initialize");
|
|
2541
3720
|
throw e;
|
|
2542
3721
|
}
|
|
@@ -2544,37 +3723,54 @@ var StreamProcessorService = class {
|
|
|
2544
3723
|
async loadState() {
|
|
2545
3724
|
if (!this.processor) return;
|
|
2546
3725
|
try {
|
|
2547
|
-
const result = await this.persistenceClient.get("
|
|
3726
|
+
const result = await this.persistenceClient.get("_00_stream_processor_state");
|
|
2548
3727
|
if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
|
|
2549
3728
|
const state = result[0][0].state;
|
|
2550
3729
|
this.logger.info({
|
|
2551
3730
|
stateLength: state.length,
|
|
2552
|
-
Category: "
|
|
3731
|
+
Category: "sp00ky-client::StreamProcessorService::loadState"
|
|
2553
3732
|
}, "Loading state from DB");
|
|
2554
3733
|
if (typeof this.processor.load_state === "function") this.processor.load_state(state);
|
|
2555
|
-
else this.logger.warn({ Category: "
|
|
2556
|
-
} else this.logger.info({ Category: "
|
|
3734
|
+
else this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "load_state method not found on processor");
|
|
3735
|
+
} else this.logger.info({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "No saved state found");
|
|
2557
3736
|
} catch (e) {
|
|
2558
3737
|
this.logger.error({
|
|
2559
3738
|
error: e,
|
|
2560
|
-
Category: "
|
|
3739
|
+
Category: "sp00ky-client::StreamProcessorService::loadState"
|
|
2561
3740
|
}, "Failed to load state");
|
|
2562
3741
|
}
|
|
2563
3742
|
}
|
|
3743
|
+
/**
|
|
3744
|
+
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
|
3745
|
+
* Must run after the processor exists and before any `register_view`, else
|
|
3746
|
+
* non-`_00_` tables are default-denied and registration fails.
|
|
3747
|
+
*/
|
|
3748
|
+
setPermissions(permissions) {
|
|
3749
|
+
if (!this.processor) return;
|
|
3750
|
+
if (typeof this.processor.set_permissions !== "function") {
|
|
3751
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::setPermissions" }, "set_permissions not found on processor (stale WASM build?)");
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
this.processor.set_permissions(permissions);
|
|
3755
|
+
this.logger.info({
|
|
3756
|
+
tables: Object.keys(permissions).length,
|
|
3757
|
+
Category: "sp00ky-client::StreamProcessorService::setPermissions"
|
|
3758
|
+
}, "Seeded table permissions");
|
|
3759
|
+
}
|
|
2564
3760
|
async saveState() {
|
|
2565
3761
|
if (!this.processor) return;
|
|
2566
3762
|
try {
|
|
2567
3763
|
if (typeof this.processor.save_state === "function") {
|
|
2568
3764
|
const state = this.processor.save_state();
|
|
2569
3765
|
if (state) {
|
|
2570
|
-
await this.persistenceClient.set("
|
|
2571
|
-
this.logger.trace({ Category: "
|
|
3766
|
+
await this.persistenceClient.set("_00_stream_processor_state", state);
|
|
3767
|
+
this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
|
|
2572
3768
|
}
|
|
2573
3769
|
}
|
|
2574
3770
|
} catch (e) {
|
|
2575
3771
|
this.logger.error({
|
|
2576
3772
|
error: e,
|
|
2577
|
-
Category: "
|
|
3773
|
+
Category: "sp00ky-client::StreamProcessorService::saveState"
|
|
2578
3774
|
}, "Failed to save state");
|
|
2579
3775
|
}
|
|
2580
3776
|
}
|
|
@@ -2588,36 +3784,43 @@ var StreamProcessorService = class {
|
|
|
2588
3784
|
table,
|
|
2589
3785
|
op,
|
|
2590
3786
|
id,
|
|
2591
|
-
Category: "
|
|
3787
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2592
3788
|
}, "Ingesting into ssp");
|
|
2593
3789
|
if (!this.processor) {
|
|
2594
|
-
this.logger.warn({ Category: "
|
|
3790
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingest" }, "Not initialized, skipping ingest");
|
|
2595
3791
|
return [];
|
|
2596
3792
|
}
|
|
2597
3793
|
try {
|
|
2598
3794
|
const normalizedRecord = this.normalizeValue(record);
|
|
3795
|
+
const t0 = performance.now();
|
|
2599
3796
|
const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
|
|
3797
|
+
const materializationTimeMs = performance.now() - t0;
|
|
2600
3798
|
this.logger.debug({
|
|
2601
3799
|
table,
|
|
2602
3800
|
op,
|
|
2603
3801
|
id,
|
|
2604
3802
|
rawUpdates: rawUpdates.length,
|
|
2605
|
-
|
|
3803
|
+
materializationTimeMs,
|
|
3804
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2606
3805
|
}, "Ingesting into ssp done");
|
|
2607
3806
|
if (rawUpdates && Array.isArray(rawUpdates) && rawUpdates.length > 0) {
|
|
2608
3807
|
const updates = rawUpdates.map((u) => ({
|
|
2609
3808
|
queryHash: u.query_id,
|
|
2610
3809
|
localArray: u.result_data,
|
|
2611
|
-
op
|
|
3810
|
+
op,
|
|
3811
|
+
materializationTimeMs,
|
|
3812
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
3813
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
3814
|
+
transformMs: u.timing_transform_ms
|
|
2612
3815
|
}));
|
|
2613
3816
|
this.notifyUpdates(updates);
|
|
2614
3817
|
}
|
|
2615
|
-
this.saveState();
|
|
3818
|
+
if (!this.batching) this.saveState();
|
|
2616
3819
|
return rawUpdates;
|
|
2617
3820
|
} catch (e) {
|
|
2618
3821
|
this.logger.error({
|
|
2619
3822
|
error: e,
|
|
2620
|
-
Category: "
|
|
3823
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2621
3824
|
}, "Ingesting into ssp failed");
|
|
2622
3825
|
}
|
|
2623
3826
|
return [];
|
|
@@ -2628,14 +3831,14 @@ var StreamProcessorService = class {
|
|
|
2628
3831
|
*/
|
|
2629
3832
|
registerQueryPlan(queryPlan) {
|
|
2630
3833
|
if (!this.processor) {
|
|
2631
|
-
this.logger.warn({ Category: "
|
|
3834
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan" }, "Not initialized, skipping registration");
|
|
2632
3835
|
return;
|
|
2633
3836
|
}
|
|
2634
3837
|
this.logger.debug({
|
|
2635
3838
|
queryHash: queryPlan.queryHash,
|
|
2636
3839
|
surql: queryPlan.surql,
|
|
2637
3840
|
params: queryPlan.params,
|
|
2638
|
-
Category: "
|
|
3841
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2639
3842
|
}, "Registering query plan");
|
|
2640
3843
|
try {
|
|
2641
3844
|
const normalizedParams = this.normalizeValue(queryPlan.params);
|
|
@@ -2649,25 +3852,30 @@ var StreamProcessorService = class {
|
|
|
2649
3852
|
});
|
|
2650
3853
|
this.logger.debug({
|
|
2651
3854
|
initialUpdate,
|
|
2652
|
-
Category: "
|
|
3855
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2653
3856
|
}, "register_view result");
|
|
2654
3857
|
if (!initialUpdate) throw new Error("Failed to register query plan");
|
|
2655
3858
|
const update = {
|
|
2656
3859
|
queryHash: initialUpdate.query_id,
|
|
2657
|
-
localArray: initialUpdate.result_data
|
|
3860
|
+
localArray: initialUpdate.result_data,
|
|
3861
|
+
registration: {
|
|
3862
|
+
parseMs: initialUpdate.timing_parse_ms ?? 0,
|
|
3863
|
+
planMs: initialUpdate.timing_plan_ms ?? 0,
|
|
3864
|
+
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
|
|
3865
|
+
}
|
|
2658
3866
|
};
|
|
2659
3867
|
this.saveState();
|
|
2660
3868
|
this.logger.debug({
|
|
2661
3869
|
queryHash: queryPlan.queryHash,
|
|
2662
3870
|
surql: queryPlan.surql,
|
|
2663
3871
|
params: queryPlan.params,
|
|
2664
|
-
Category: "
|
|
3872
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2665
3873
|
}, "Registered query plan");
|
|
2666
3874
|
return update;
|
|
2667
3875
|
} catch (e) {
|
|
2668
3876
|
this.logger.error({
|
|
2669
3877
|
error: e,
|
|
2670
|
-
Category: "
|
|
3878
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2671
3879
|
}, "Error registering query plan");
|
|
2672
3880
|
throw e;
|
|
2673
3881
|
}
|
|
@@ -2683,13 +3891,14 @@ var StreamProcessorService = class {
|
|
|
2683
3891
|
} catch (e) {
|
|
2684
3892
|
this.logger.error({
|
|
2685
3893
|
error: e,
|
|
2686
|
-
Category: "
|
|
3894
|
+
Category: "sp00ky-client::StreamProcessorService::unregisterQueryPlan"
|
|
2687
3895
|
}, "Error unregistering query plan");
|
|
2688
3896
|
}
|
|
2689
3897
|
}
|
|
2690
3898
|
normalizeValue(value) {
|
|
2691
3899
|
if (value === null || value === void 0) return value;
|
|
2692
3900
|
if (typeof value === "object") {
|
|
3901
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return null;
|
|
2693
3902
|
const hasTable = "table" in value && typeof value.table?.toString === "function";
|
|
2694
3903
|
const hasId = "id" in value;
|
|
2695
3904
|
const hasToString = typeof value.toString === "function";
|
|
@@ -2698,7 +3907,7 @@ var StreamProcessorService = class {
|
|
|
2698
3907
|
const result = value.toString();
|
|
2699
3908
|
this.logger.trace({
|
|
2700
3909
|
result,
|
|
2701
|
-
Category: "
|
|
3910
|
+
Category: "sp00ky-client::StreamProcessorService::normalizeValue"
|
|
2702
3911
|
}, "RecordId detected");
|
|
2703
3912
|
return result;
|
|
2704
3913
|
}
|
|
@@ -2714,6 +3923,51 @@ var StreamProcessorService = class {
|
|
|
2714
3923
|
}
|
|
2715
3924
|
};
|
|
2716
3925
|
|
|
3926
|
+
//#endregion
|
|
3927
|
+
//#region src/services/stream-processor/permissions.ts
|
|
3928
|
+
/**
|
|
3929
|
+
* Extract per-table `select` permission predicates from a raw `.surql` schema.
|
|
3930
|
+
*
|
|
3931
|
+
* The in-browser SSP default-denies any non-`_00_` table that has no permission
|
|
3932
|
+
* predicate registered (see `permission_inject::build_predicate`). The client
|
|
3933
|
+
* already holds the full schema text (`config.schemaSurql`), so we parse each
|
|
3934
|
+
* `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
|
|
3935
|
+
* processor via `set_permissions` at boot — mirroring the native path that
|
|
3936
|
+
* seeds the same map from `INFO FOR DB`.
|
|
3937
|
+
*
|
|
3938
|
+
* Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
|
|
3939
|
+
* expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
|
|
3940
|
+
* `select` permission).
|
|
3941
|
+
*/
|
|
3942
|
+
function extractSelectPermissions(schemaSurql) {
|
|
3943
|
+
const out = {};
|
|
3944
|
+
if (!schemaSurql) return out;
|
|
3945
|
+
const cleaned = schemaSurql.replace(/--[^\n]*/g, "");
|
|
3946
|
+
const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
|
|
3947
|
+
let m;
|
|
3948
|
+
while ((m = tableStmt.exec(cleaned)) !== null) {
|
|
3949
|
+
const table = m[1];
|
|
3950
|
+
const body = m[2];
|
|
3951
|
+
out[table] = selectPredicateFromBody(body);
|
|
3952
|
+
}
|
|
3953
|
+
return out;
|
|
3954
|
+
}
|
|
3955
|
+
function selectPredicateFromBody(body) {
|
|
3956
|
+
const permIdx = body.search(/\bPERMISSIONS\b/i);
|
|
3957
|
+
if (permIdx === -1) return "false";
|
|
3958
|
+
const perms = body.slice(permIdx + 11).trim();
|
|
3959
|
+
if (/^FULL\b/i.test(perms)) return "true";
|
|
3960
|
+
if (/^NONE\b/i.test(perms)) return "false";
|
|
3961
|
+
const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
|
|
3962
|
+
for (const group of groups) {
|
|
3963
|
+
const where = group.search(/\bWHERE\b/i);
|
|
3964
|
+
if (where === -1) continue;
|
|
3965
|
+
const actions = group.slice(0, where).toLowerCase();
|
|
3966
|
+
if (/\bselect\b/.test(actions)) return group.slice(where + 5).trim();
|
|
3967
|
+
}
|
|
3968
|
+
return "false";
|
|
3969
|
+
}
|
|
3970
|
+
|
|
2717
3971
|
//#endregion
|
|
2718
3972
|
//#region src/modules/cache/index.ts
|
|
2719
3973
|
/**
|
|
@@ -2741,7 +3995,7 @@ var CacheModule = class {
|
|
|
2741
3995
|
this.logger.debug({
|
|
2742
3996
|
queryHash: update.queryHash,
|
|
2743
3997
|
arrayLength: update.localArray?.length,
|
|
2744
|
-
Category: "
|
|
3998
|
+
Category: "sp00ky-client::CacheModule::onStreamUpdate"
|
|
2745
3999
|
}, "Stream update received");
|
|
2746
4000
|
this.streamUpdateCallback(update);
|
|
2747
4001
|
}
|
|
@@ -2764,7 +4018,7 @@ var CacheModule = class {
|
|
|
2764
4018
|
if (records.length === 0) return;
|
|
2765
4019
|
this.logger.debug({
|
|
2766
4020
|
count: records.length,
|
|
2767
|
-
Category: "
|
|
4021
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2768
4022
|
}, "Saving record batch");
|
|
2769
4023
|
try {
|
|
2770
4024
|
const populatedRecords = records.map((record) => {
|
|
@@ -2773,13 +4027,13 @@ var CacheModule = class {
|
|
|
2773
4027
|
...record,
|
|
2774
4028
|
record: {
|
|
2775
4029
|
...record.record,
|
|
2776
|
-
|
|
4030
|
+
_00_rv: record.version
|
|
2777
4031
|
}
|
|
2778
4032
|
};
|
|
2779
4033
|
});
|
|
2780
4034
|
if (!skipDbInsert) {
|
|
2781
4035
|
const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
|
|
2782
|
-
return surql.
|
|
4036
|
+
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
2783
4037
|
})));
|
|
2784
4038
|
const params = populatedRecords.reduce((acc, record, i) => {
|
|
2785
4039
|
const { id, ...content } = record.record;
|
|
@@ -2791,20 +4045,26 @@ var CacheModule = class {
|
|
|
2791
4045
|
}, {});
|
|
2792
4046
|
await this.local.execute(query, params);
|
|
2793
4047
|
}
|
|
2794
|
-
|
|
4048
|
+
const bulk = populatedRecords.map((record) => {
|
|
2795
4049
|
const recordId = encodeRecordId(record.record.id);
|
|
2796
4050
|
this.versionLookups[recordId] = record.version;
|
|
2797
|
-
|
|
2798
|
-
|
|
4051
|
+
return {
|
|
4052
|
+
table: record.table,
|
|
4053
|
+
op: record.op,
|
|
4054
|
+
id: recordId,
|
|
4055
|
+
record: record.record
|
|
4056
|
+
};
|
|
4057
|
+
});
|
|
4058
|
+
this.streamProcessor.ingestMany(bulk);
|
|
2799
4059
|
this.logger.debug({
|
|
2800
4060
|
count: records.length,
|
|
2801
|
-
Category: "
|
|
4061
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2802
4062
|
}, "Batch saved successfully");
|
|
2803
4063
|
} catch (err) {
|
|
2804
4064
|
this.logger.error({
|
|
2805
4065
|
err,
|
|
2806
4066
|
count: records.length,
|
|
2807
|
-
Category: "
|
|
4067
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2808
4068
|
}, "Failed to save batch");
|
|
2809
4069
|
throw err;
|
|
2810
4070
|
}
|
|
@@ -2812,27 +4072,27 @@ var CacheModule = class {
|
|
|
2812
4072
|
/**
|
|
2813
4073
|
* Delete a record from local DB and ingest deletion into DBSP
|
|
2814
4074
|
*/
|
|
2815
|
-
async delete(table, id, skipDbDelete = false) {
|
|
4075
|
+
async delete(table, id, skipDbDelete = false, recordData = {}) {
|
|
2816
4076
|
this.logger.debug({
|
|
2817
4077
|
table,
|
|
2818
4078
|
id,
|
|
2819
|
-
Category: "
|
|
4079
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2820
4080
|
}, "Deleting record");
|
|
2821
4081
|
try {
|
|
2822
4082
|
if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) });
|
|
2823
4083
|
delete this.versionLookups[id];
|
|
2824
|
-
|
|
4084
|
+
this.streamProcessor.ingest(table, "DELETE", id, recordData);
|
|
2825
4085
|
this.logger.debug({
|
|
2826
4086
|
table,
|
|
2827
4087
|
id,
|
|
2828
|
-
Category: "
|
|
4088
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2829
4089
|
}, "Record deleted successfully");
|
|
2830
4090
|
} catch (err) {
|
|
2831
4091
|
this.logger.error({
|
|
2832
4092
|
err,
|
|
2833
4093
|
table,
|
|
2834
4094
|
id,
|
|
2835
|
-
Category: "
|
|
4095
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2836
4096
|
}, "Failed to delete record");
|
|
2837
4097
|
throw err;
|
|
2838
4098
|
}
|
|
@@ -2845,7 +4105,7 @@ var CacheModule = class {
|
|
|
2845
4105
|
this.logger.debug({
|
|
2846
4106
|
queryHash: config.queryHash,
|
|
2847
4107
|
surql: config.surql,
|
|
2848
|
-
Category: "
|
|
4108
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2849
4109
|
}, "Registering query");
|
|
2850
4110
|
try {
|
|
2851
4111
|
const update = this.streamProcessor.registerQueryPlan({
|
|
@@ -2862,14 +4122,17 @@ var CacheModule = class {
|
|
|
2862
4122
|
this.logger.debug({
|
|
2863
4123
|
queryHash: config.queryHash,
|
|
2864
4124
|
arrayLength: update.localArray?.length,
|
|
2865
|
-
Category: "
|
|
4125
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2866
4126
|
}, "Query registered successfully");
|
|
2867
|
-
return {
|
|
4127
|
+
return {
|
|
4128
|
+
localArray: update.localArray,
|
|
4129
|
+
registrationTimings: update.registration
|
|
4130
|
+
};
|
|
2868
4131
|
} catch (err) {
|
|
2869
4132
|
this.logger.error({
|
|
2870
4133
|
err,
|
|
2871
4134
|
queryHash: config.queryHash,
|
|
2872
|
-
Category: "
|
|
4135
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2873
4136
|
}, "Failed to register query");
|
|
2874
4137
|
throw err;
|
|
2875
4138
|
}
|
|
@@ -2880,24 +4143,489 @@ var CacheModule = class {
|
|
|
2880
4143
|
unregisterQuery(queryHash) {
|
|
2881
4144
|
this.logger.debug({
|
|
2882
4145
|
queryHash,
|
|
2883
|
-
Category: "
|
|
4146
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2884
4147
|
}, "Unregistering query");
|
|
2885
4148
|
try {
|
|
2886
4149
|
this.streamProcessor.unregisterQueryPlan(queryHash);
|
|
2887
4150
|
this.logger.debug({
|
|
2888
4151
|
queryHash,
|
|
2889
|
-
Category: "
|
|
4152
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2890
4153
|
}, "Query unregistered successfully");
|
|
2891
4154
|
} catch (err) {
|
|
2892
4155
|
this.logger.error({
|
|
2893
4156
|
err,
|
|
2894
4157
|
queryHash,
|
|
2895
|
-
Category: "
|
|
4158
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2896
4159
|
}, "Failed to unregister query");
|
|
2897
4160
|
}
|
|
2898
4161
|
}
|
|
2899
4162
|
};
|
|
2900
4163
|
|
|
4164
|
+
//#endregion
|
|
4165
|
+
//#region src/modules/crdt/crdt-field.ts
|
|
4166
|
+
const CURSOR_COLORS = [
|
|
4167
|
+
"#3b82f6",
|
|
4168
|
+
"#ef4444",
|
|
4169
|
+
"#22c55e",
|
|
4170
|
+
"#f59e0b",
|
|
4171
|
+
"#8b5cf6",
|
|
4172
|
+
"#ec4899",
|
|
4173
|
+
"#14b8a6",
|
|
4174
|
+
"#f97316"
|
|
4175
|
+
];
|
|
4176
|
+
function cursorColorFromName(name) {
|
|
4177
|
+
let hash = 0;
|
|
4178
|
+
for (let i = 0; i < name.length; i++) hash = (hash << 5) - hash + name.charCodeAt(i) | 0;
|
|
4179
|
+
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
4180
|
+
}
|
|
4181
|
+
var CrdtField = class {
|
|
4182
|
+
doc;
|
|
4183
|
+
pushTimer = null;
|
|
4184
|
+
local = null;
|
|
4185
|
+
remote = null;
|
|
4186
|
+
recordId = null;
|
|
4187
|
+
sessionId = "";
|
|
4188
|
+
unsubscribe = null;
|
|
4189
|
+
lastPushTime = 0;
|
|
4190
|
+
lastCursorPushTime = 0;
|
|
4191
|
+
loadedFromCrdt = false;
|
|
4192
|
+
pushRetryCount = 0;
|
|
4193
|
+
logger;
|
|
4194
|
+
cursorsEnabled;
|
|
4195
|
+
/** Remote-push debounce. Local writes happen immediately on every Loro
|
|
4196
|
+
* update; the remote UPSERT is coalesced over this window. Configured
|
|
4197
|
+
* via `Sp00kyConfig.crdtDebounceMs`, default 500. */
|
|
4198
|
+
remoteDebounceMs = 500;
|
|
4199
|
+
_onCursorUpdate = null;
|
|
4200
|
+
pendingCursorUpdate = null;
|
|
4201
|
+
/** Callback set by the editor to receive remote cursor updates.
|
|
4202
|
+
* Any cursor data that arrived before this callback was set will be replayed. */
|
|
4203
|
+
set onCursorUpdate(cb) {
|
|
4204
|
+
this._onCursorUpdate = cb;
|
|
4205
|
+
if (cb && this.pendingCursorUpdate) {
|
|
4206
|
+
try {
|
|
4207
|
+
cb(this.pendingCursorUpdate);
|
|
4208
|
+
} catch (e) {
|
|
4209
|
+
this.logger?.warn({
|
|
4210
|
+
error: e,
|
|
4211
|
+
Category: "sp00ky-client::CrdtField::onCursorUpdate"
|
|
4212
|
+
}, "Failed to replay pending cursor update");
|
|
4213
|
+
}
|
|
4214
|
+
this.pendingCursorUpdate = null;
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
get onCursorUpdate() {
|
|
4218
|
+
return this._onCursorUpdate;
|
|
4219
|
+
}
|
|
4220
|
+
constructor(fieldName, cursorsEnabled, initialState, logger) {
|
|
4221
|
+
this.fieldName = fieldName;
|
|
4222
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)) throw new Error(`CrdtField: refusing unsafe field identifier '${fieldName}' — must match [a-zA-Z_][a-zA-Z0-9_]*`);
|
|
4223
|
+
this.logger = logger ?? null;
|
|
4224
|
+
this.cursorsEnabled = cursorsEnabled;
|
|
4225
|
+
this.doc = new LoroDoc();
|
|
4226
|
+
if (initialState && initialState.length > 0) try {
|
|
4227
|
+
this.doc.import(initialState);
|
|
4228
|
+
this.loadedFromCrdt = true;
|
|
4229
|
+
} catch (e) {
|
|
4230
|
+
this.logger?.warn({
|
|
4231
|
+
error: e,
|
|
4232
|
+
fieldName,
|
|
4233
|
+
Category: "sp00ky-client::CrdtField::constructor"
|
|
4234
|
+
}, "Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text");
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
getDoc() {
|
|
4238
|
+
return this.doc;
|
|
4239
|
+
}
|
|
4240
|
+
/** Whether the LoroDoc was loaded from saved CRDT state */
|
|
4241
|
+
hasContent() {
|
|
4242
|
+
return this.loadedFromCrdt;
|
|
4243
|
+
}
|
|
4244
|
+
startSync(local, remote, recordId, sessionId, debounceMs) {
|
|
4245
|
+
this.local = local;
|
|
4246
|
+
this.remote = remote;
|
|
4247
|
+
this.recordId = recordId;
|
|
4248
|
+
this.sessionId = sessionId;
|
|
4249
|
+
this.remoteDebounceMs = debounceMs;
|
|
4250
|
+
this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
|
|
4251
|
+
this.persistLocal();
|
|
4252
|
+
this.scheduleRemotePush();
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4255
|
+
stopSync() {
|
|
4256
|
+
if (this.unsubscribe) {
|
|
4257
|
+
this.unsubscribe();
|
|
4258
|
+
this.unsubscribe = null;
|
|
4259
|
+
}
|
|
4260
|
+
if (this.pushTimer) {
|
|
4261
|
+
clearTimeout(this.pushTimer);
|
|
4262
|
+
this.pushTimer = null;
|
|
4263
|
+
}
|
|
4264
|
+
if (this.remote && this.recordId) this.pushToRemote();
|
|
4265
|
+
}
|
|
4266
|
+
importRemote(state) {
|
|
4267
|
+
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
4268
|
+
try {
|
|
4269
|
+
this.doc.import(state);
|
|
4270
|
+
this.persistLocal();
|
|
4271
|
+
} catch (e) {
|
|
4272
|
+
this.logger?.warn({
|
|
4273
|
+
error: e,
|
|
4274
|
+
Category: "sp00ky-client::CrdtField::importRemote"
|
|
4275
|
+
}, "Failed to import remote CRDT state");
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
exportSnapshot() {
|
|
4279
|
+
return this.doc.export({ mode: "snapshot" });
|
|
4280
|
+
}
|
|
4281
|
+
/** Push this session's cursor blob into the parent row at
|
|
4282
|
+
* `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
|
|
4283
|
+
* field — the editor still calls this method optimistically, but
|
|
4284
|
+
* without `@cursor` on the schema there's nowhere to store the blob.
|
|
4285
|
+
* The UPDATE itself fires the parent table's LIVE feed, so other
|
|
4286
|
+
* browsers receive the cursor change without a separate `_00_rv` bump. */
|
|
4287
|
+
async pushCursorState(encoded) {
|
|
4288
|
+
if (!this.remote || !this.recordId) return;
|
|
4289
|
+
if (!this.cursorsEnabled) return;
|
|
4290
|
+
this.lastCursorPushTime = Date.now();
|
|
4291
|
+
try {
|
|
4292
|
+
const state = encodeBase64(encoded);
|
|
4293
|
+
await this.remote.query(`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`, {
|
|
4294
|
+
id: parseRecordIdString(this.recordId),
|
|
4295
|
+
sid: this.sessionId,
|
|
4296
|
+
state
|
|
4297
|
+
});
|
|
4298
|
+
} catch (e) {
|
|
4299
|
+
this.logger?.warn({
|
|
4300
|
+
error: e,
|
|
4301
|
+
Category: "sp00ky-client::CrdtField::pushCursorState"
|
|
4302
|
+
}, "Failed to push cursor state");
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
/** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
|
|
4306
|
+
importRemoteCursor(base64State) {
|
|
4307
|
+
if (Date.now() - this.lastCursorPushTime < 300) return;
|
|
4308
|
+
try {
|
|
4309
|
+
const data = decodeBase64(base64State);
|
|
4310
|
+
if (this._onCursorUpdate) this._onCursorUpdate(data);
|
|
4311
|
+
else this.pendingCursorUpdate = data;
|
|
4312
|
+
} catch (e) {
|
|
4313
|
+
this.logger?.warn({
|
|
4314
|
+
error: e,
|
|
4315
|
+
Category: "sp00ky-client::CrdtField::importRemoteCursor"
|
|
4316
|
+
}, "Failed to apply remote cursor data");
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
scheduleRemotePush() {
|
|
4320
|
+
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
4321
|
+
this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
|
|
4322
|
+
}
|
|
4323
|
+
/** SET path inside a parent row for the current snapshot. `@crdt`-only
|
|
4324
|
+
* fields hold the snapshot directly (`<field>`); `@crdt @cursor`
|
|
4325
|
+
* fields hold a `{ state, cursors }` object so the snapshot lives at
|
|
4326
|
+
* `<field>.state` next to per-session cursor blobs. */
|
|
4327
|
+
statePath() {
|
|
4328
|
+
return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
|
|
4329
|
+
}
|
|
4330
|
+
/** Mirror the LoroDoc snapshot into the parent row locally. Runs on
|
|
4331
|
+
* every local update and every remote import so reloads (online or
|
|
4332
|
+
* offline) see the freshest content immediately. Failures are
|
|
4333
|
+
* swallowed — a stale local write must never block user input. */
|
|
4334
|
+
async persistLocal() {
|
|
4335
|
+
if (!this.local || !this.recordId) return;
|
|
4336
|
+
try {
|
|
4337
|
+
await this.local.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
4338
|
+
id: parseRecordIdString(this.recordId),
|
|
4339
|
+
state: this.exportSnapshot()
|
|
4340
|
+
});
|
|
4341
|
+
} catch (e) {
|
|
4342
|
+
this.logger?.debug({
|
|
4343
|
+
error: e,
|
|
4344
|
+
Category: "sp00ky-client::CrdtField::persistLocal"
|
|
4345
|
+
}, "Local CRDT persist failed (best-effort)");
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
async pushToRemote() {
|
|
4349
|
+
if (!this.remote || !this.recordId) return;
|
|
4350
|
+
this.lastPushTime = Date.now();
|
|
4351
|
+
try {
|
|
4352
|
+
await this.remote.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
4353
|
+
id: parseRecordIdString(this.recordId),
|
|
4354
|
+
state: this.exportSnapshot()
|
|
4355
|
+
});
|
|
4356
|
+
this.pushRetryCount = 0;
|
|
4357
|
+
} catch (e) {
|
|
4358
|
+
this.logger?.warn({
|
|
4359
|
+
error: e,
|
|
4360
|
+
Category: "sp00ky-client::CrdtField::pushToRemote"
|
|
4361
|
+
}, "Failed to push CRDT state to remote");
|
|
4362
|
+
if (this.pushRetryCount < 2) {
|
|
4363
|
+
this.pushRetryCount++;
|
|
4364
|
+
this.scheduleRemotePush();
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
};
|
|
4369
|
+
function decodeBase64(b64) {
|
|
4370
|
+
if (typeof atob === "function") {
|
|
4371
|
+
const binary = atob(b64);
|
|
4372
|
+
const bytes = new Uint8Array(binary.length);
|
|
4373
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
4374
|
+
return bytes;
|
|
4375
|
+
}
|
|
4376
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
4377
|
+
}
|
|
4378
|
+
function encodeBase64(bytes) {
|
|
4379
|
+
if (typeof btoa === "function") {
|
|
4380
|
+
let binary = "";
|
|
4381
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
4382
|
+
return btoa(binary);
|
|
4383
|
+
}
|
|
4384
|
+
return Buffer.from(bytes).toString("base64");
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
//#endregion
|
|
4388
|
+
//#region src/modules/crdt/index.ts
|
|
4389
|
+
/**
|
|
4390
|
+
* CrdtManager manages active CrdtField instances and their sync channels.
|
|
4391
|
+
*
|
|
4392
|
+
* Collaborative state lives in two dedicated tables (defined in
|
|
4393
|
+
* `apps/cli/src/meta_tables_remote.surql`):
|
|
4394
|
+
* - `_00_crdt` { record_id, field, state } — one row per (record, field)
|
|
4395
|
+
* - `_00_cursor` { record_id, session_id, field, state } — one row per
|
|
4396
|
+
* (record, session, field)
|
|
4397
|
+
*
|
|
4398
|
+
* Splitting them off the parent row is what makes offline edits mergeable:
|
|
4399
|
+
* each (record, field) gets its own row, so concurrent offline writes don't
|
|
4400
|
+
* collide on the parent's last-write-wins semantics.
|
|
4401
|
+
*
|
|
4402
|
+
* Cross-browser delivery still rides the parent table's existing LIVE feed
|
|
4403
|
+
* to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
|
|
4404
|
+
* (issues 3602, 4026). On every meta UPSERT the writer also bumps the
|
|
4405
|
+
* parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
|
|
4406
|
+
* feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
|
|
4407
|
+
* via subquery. Permission inheritance happens server-side via
|
|
4408
|
+
* `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
|
|
4409
|
+
*/
|
|
4410
|
+
var CrdtManager = class {
|
|
4411
|
+
fields = /* @__PURE__ */ new Map();
|
|
4412
|
+
liveByTable = /* @__PURE__ */ new Map();
|
|
4413
|
+
pendingLive = /* @__PURE__ */ new Map();
|
|
4414
|
+
logger;
|
|
4415
|
+
sessionId = "";
|
|
4416
|
+
constructor(schema, local, remote, logger, debounceMs = 500) {
|
|
4417
|
+
this.schema = schema;
|
|
4418
|
+
this.local = local;
|
|
4419
|
+
this.remote = remote;
|
|
4420
|
+
this.debounceMs = debounceMs;
|
|
4421
|
+
this.logger = logger.child({ service: "CrdtManager" });
|
|
4422
|
+
}
|
|
4423
|
+
/** Set the session id that scopes this client's cursor entries. Must be
|
|
4424
|
+
* called before `open()` for cursors to be pushed under a stable key.
|
|
4425
|
+
* Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
|
|
4426
|
+
* for the data-module salt). */
|
|
4427
|
+
setSessionId(sessionId) {
|
|
4428
|
+
this.sessionId = sessionId;
|
|
4429
|
+
}
|
|
4430
|
+
/**
|
|
4431
|
+
* Open a CRDT field for collaborative editing.
|
|
4432
|
+
*
|
|
4433
|
+
* @param table - Table name
|
|
4434
|
+
* @param recordId - Full record ID (e.g., "thread:abc")
|
|
4435
|
+
* @param field - Field name (e.g., "title", "content")
|
|
4436
|
+
* @param fallbackText - Current plain text from the record, used to seed the
|
|
4437
|
+
* LoroDoc if no CRDT state exists yet (migration path)
|
|
4438
|
+
*/
|
|
4439
|
+
async open(table, recordId, field, fallbackText) {
|
|
4440
|
+
this.assertCrdtField(table, field);
|
|
4441
|
+
const cursorsEnabled = this.fieldHasCursor(table, field);
|
|
4442
|
+
const key = this.makeKey(table, recordId, field);
|
|
4443
|
+
let crdtField = this.fields.get(key);
|
|
4444
|
+
if (crdtField) return crdtField;
|
|
4445
|
+
let initialCrdtState;
|
|
4446
|
+
try {
|
|
4447
|
+
const [result] = await this.local.query(`SELECT VALUE ${field} FROM ONLY $id`, { id: parseRecordIdString(recordId) });
|
|
4448
|
+
const snapshot = this.extractSnapshot(result, cursorsEnabled);
|
|
4449
|
+
if (snapshot) initialCrdtState = snapshot;
|
|
4450
|
+
} catch (e) {
|
|
4451
|
+
this.logger.info({
|
|
4452
|
+
error: String(e),
|
|
4453
|
+
recordId,
|
|
4454
|
+
field,
|
|
4455
|
+
Category: "sp00ky-client::CrdtManager::open"
|
|
4456
|
+
}, "No existing CRDT state found in local cache (continuing with empty doc)");
|
|
4457
|
+
}
|
|
4458
|
+
crdtField = new CrdtField(field, cursorsEnabled, initialCrdtState, this.logger);
|
|
4459
|
+
crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
|
|
4460
|
+
this.fields.set(key, crdtField);
|
|
4461
|
+
this.logger.info({
|
|
4462
|
+
key,
|
|
4463
|
+
hasInitialState: !!initialCrdtState,
|
|
4464
|
+
hasFallback: !!fallbackText,
|
|
4465
|
+
Category: "sp00ky-client::CrdtManager::open"
|
|
4466
|
+
}, "CrdtField opened");
|
|
4467
|
+
this.ensureTableSubscription(table);
|
|
4468
|
+
if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
|
|
4469
|
+
return crdtField;
|
|
4470
|
+
}
|
|
4471
|
+
close(table, recordId, field) {
|
|
4472
|
+
const key = this.makeKey(table, recordId, field);
|
|
4473
|
+
const crdtField = this.fields.get(key);
|
|
4474
|
+
if (crdtField) {
|
|
4475
|
+
crdtField.stopSync();
|
|
4476
|
+
this.fields.delete(key);
|
|
4477
|
+
}
|
|
4478
|
+
const tablePrefix = `${table}:`;
|
|
4479
|
+
if (!Array.from(this.fields.keys()).some((k) => k.startsWith(tablePrefix))) this.killTableSubscription(table);
|
|
4480
|
+
this.logger.debug({
|
|
4481
|
+
key,
|
|
4482
|
+
Category: "sp00ky-client::CrdtManager::close"
|
|
4483
|
+
}, "CrdtField closed");
|
|
4484
|
+
}
|
|
4485
|
+
closeAll() {
|
|
4486
|
+
for (const [_, field] of this.fields) field.stopSync();
|
|
4487
|
+
this.fields.clear();
|
|
4488
|
+
for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
|
|
4489
|
+
}
|
|
4490
|
+
/** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
|
|
4491
|
+
* every open CrdtField on `table`. */
|
|
4492
|
+
async ensureTableSubscription(table) {
|
|
4493
|
+
if (this.liveByTable.has(table)) return;
|
|
4494
|
+
const pending = this.pendingLive.get(table);
|
|
4495
|
+
if (pending) return pending;
|
|
4496
|
+
const start = (async () => {
|
|
4497
|
+
try {
|
|
4498
|
+
const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
|
|
4499
|
+
(await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
|
|
4500
|
+
if (message.action === "KILLED") return;
|
|
4501
|
+
if (message.action !== "CREATE" && message.action !== "UPDATE") return;
|
|
4502
|
+
this.dispatchRow(table, message.value);
|
|
4503
|
+
});
|
|
4504
|
+
this.liveByTable.set(table, uuid);
|
|
4505
|
+
this.logger.info({
|
|
4506
|
+
table,
|
|
4507
|
+
Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
|
|
4508
|
+
}, "LIVE SELECT started");
|
|
4509
|
+
} catch (e) {
|
|
4510
|
+
this.logger.warn({
|
|
4511
|
+
error: e,
|
|
4512
|
+
table,
|
|
4513
|
+
Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
|
|
4514
|
+
}, "Failed to start LIVE SELECT");
|
|
4515
|
+
}
|
|
4516
|
+
})();
|
|
4517
|
+
this.pendingLive.set(table, start);
|
|
4518
|
+
try {
|
|
4519
|
+
await start;
|
|
4520
|
+
} finally {
|
|
4521
|
+
this.pendingLive.delete(table);
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
/** Apply a parent-row payload from a non-LIVE source (e.g. the
|
|
4525
|
+
* list_ref-driven sync engine, when the cross-user LIVE on the
|
|
4526
|
+
* parent table is filtered out by the SurrealDB cross-session
|
|
4527
|
+
* permission gap). Same semantics as the internal `dispatchRow`. */
|
|
4528
|
+
applyRow(table, row) {
|
|
4529
|
+
this.dispatchRow(table, row);
|
|
4530
|
+
}
|
|
4531
|
+
/** Dispatch a parent-row LIVE event to every open CrdtField on that
|
|
4532
|
+
* record. Each open field reads its slice of the row directly — the
|
|
4533
|
+
* CRDT snapshot is a column on the parent now, so there is no
|
|
4534
|
+
* follow-up subquery. */
|
|
4535
|
+
dispatchRow(table, row) {
|
|
4536
|
+
const id = row.id != null ? String(row.id) : "";
|
|
4537
|
+
if (!id) return;
|
|
4538
|
+
const rowKeyPrefix = `${table}:${id}:`;
|
|
4539
|
+
for (const [key, crdtField] of this.fields) {
|
|
4540
|
+
if (!key.startsWith(rowKeyPrefix)) continue;
|
|
4541
|
+
const fieldName = key.slice(rowKeyPrefix.length);
|
|
4542
|
+
const cursorsEnabled = this.fieldHasCursor(table, fieldName);
|
|
4543
|
+
const slice = row[fieldName];
|
|
4544
|
+
const snapshot = this.extractSnapshot(slice, cursorsEnabled);
|
|
4545
|
+
if (snapshot) crdtField.importRemote(snapshot);
|
|
4546
|
+
if (cursorsEnabled && slice && typeof slice === "object") {
|
|
4547
|
+
const cursors = slice.cursors;
|
|
4548
|
+
if (cursors && typeof cursors === "object") for (const [sid, blob] of Object.entries(cursors)) {
|
|
4549
|
+
if (sid === this.sessionId) continue;
|
|
4550
|
+
if (typeof blob === "string" && blob.length > 0) crdtField.importRemoteCursor(blob);
|
|
4551
|
+
}
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
/** One-shot remote fetch for a row whose CRDT field hasn't synced
|
|
4556
|
+
* locally yet (fresh device, memory-backed local DB after reload, …).
|
|
4557
|
+
* Used by `open()` when the local read came up empty. Subsequent
|
|
4558
|
+
* cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
|
|
4559
|
+
async fetchAndDispatchRow(table, id) {
|
|
4560
|
+
try {
|
|
4561
|
+
const recordId = parseRecordIdString(id);
|
|
4562
|
+
const [row] = await this.remote.query(`SELECT * FROM ONLY $id`, { id: recordId });
|
|
4563
|
+
if (!row || typeof row !== "object") return;
|
|
4564
|
+
this.dispatchRow(table, row);
|
|
4565
|
+
} catch (e) {
|
|
4566
|
+
this.logger.warn({
|
|
4567
|
+
error: e,
|
|
4568
|
+
table,
|
|
4569
|
+
id,
|
|
4570
|
+
Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
|
|
4571
|
+
}, "Failed to fetch parent row for CRDT hydration");
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
4574
|
+
/** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
|
|
4575
|
+
* Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
|
|
4576
|
+
fieldHasCursor(table, field) {
|
|
4577
|
+
return !!this.schema.tables.find((t) => t.name === table)?.columns[field]?.cursor;
|
|
4578
|
+
}
|
|
4579
|
+
/** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
|
|
4580
|
+
* the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
|
|
4581
|
+
* `{ state, cursors }` where `state` carries the snapshot bytes. */
|
|
4582
|
+
extractSnapshot(value, cursorsEnabled) {
|
|
4583
|
+
const asBytes = (v) => {
|
|
4584
|
+
if (v instanceof Uint8Array) return v.length > 0 ? v : void 0;
|
|
4585
|
+
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
|
4586
|
+
if (ArrayBuffer.isView(v)) {
|
|
4587
|
+
const view = v;
|
|
4588
|
+
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
4589
|
+
}
|
|
4590
|
+
if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "number")) return Uint8Array.from(v);
|
|
4591
|
+
};
|
|
4592
|
+
if (cursorsEnabled) {
|
|
4593
|
+
if (value && typeof value === "object" && !(value instanceof Uint8Array)) return asBytes(value.state);
|
|
4594
|
+
return;
|
|
4595
|
+
}
|
|
4596
|
+
return asBytes(value);
|
|
4597
|
+
}
|
|
4598
|
+
killTableSubscription(table) {
|
|
4599
|
+
const uuid = this.liveByTable.get(table);
|
|
4600
|
+
if (uuid) {
|
|
4601
|
+
this.remote.query("KILL $uuid", { uuid }).catch((err) => {
|
|
4602
|
+
this.logger.debug({
|
|
4603
|
+
err,
|
|
4604
|
+
table,
|
|
4605
|
+
Category: "sp00ky-client::CrdtManager::killTableSubscription"
|
|
4606
|
+
}, "KILL of table LIVE failed (already closed?)");
|
|
4607
|
+
});
|
|
4608
|
+
this.liveByTable.delete(table);
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
makeKey(table, recordId, field) {
|
|
4612
|
+
return `${table}:${recordId}:${field}`;
|
|
4613
|
+
}
|
|
4614
|
+
/**
|
|
4615
|
+
* Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
|
|
4616
|
+
* typos, removed annotations, and stale schema codegen at the call site instead
|
|
4617
|
+
* of silently producing a non-CRDT writer.
|
|
4618
|
+
*/
|
|
4619
|
+
assertCrdtField(table, field) {
|
|
4620
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) throw new Error(`openCrdtField: refusing unsafe field identifier '${field}' — must match [a-zA-Z_][a-zA-Z0-9_]*`);
|
|
4621
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
4622
|
+
if (!tableSchema) throw new Error(`openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(", ")}`);
|
|
4623
|
+
const column = tableSchema.columns[field];
|
|
4624
|
+
if (!column) throw new Error(`openCrdtField: '${table}.${field}' is not in the schema. Available fields: ${Object.keys(tableSchema.columns).join(", ")}`);
|
|
4625
|
+
if (!column.crdt) throw new Error(`openCrdtField: '${table}.${field}' is not annotated '@crdt' in the schema. Add '-- @crdt text' above the field's DEFINE FIELD and regenerate the client schema.`);
|
|
4626
|
+
}
|
|
4627
|
+
};
|
|
4628
|
+
|
|
2901
4629
|
//#endregion
|
|
2902
4630
|
//#region src/services/persistence/localstorage.ts
|
|
2903
4631
|
var LocalStoragePersistenceClient = class {
|
|
@@ -2930,7 +4658,7 @@ var SurrealDBPersistenceClient = class {
|
|
|
2930
4658
|
}
|
|
2931
4659
|
async set(key, val) {
|
|
2932
4660
|
try {
|
|
2933
|
-
const id = parseRecordIdString(`
|
|
4661
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2934
4662
|
await this.db.query(surql.seal(surql.upsert("id", "data")), {
|
|
2935
4663
|
id,
|
|
2936
4664
|
data: { val }
|
|
@@ -2938,40 +4666,75 @@ var SurrealDBPersistenceClient = class {
|
|
|
2938
4666
|
} catch (error) {
|
|
2939
4667
|
this.logger.error({
|
|
2940
4668
|
error,
|
|
2941
|
-
Category: "
|
|
4669
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::set"
|
|
2942
4670
|
}, "Failed to set KV");
|
|
2943
4671
|
throw error;
|
|
2944
4672
|
}
|
|
2945
4673
|
}
|
|
2946
4674
|
async get(key) {
|
|
2947
4675
|
try {
|
|
2948
|
-
const id = parseRecordIdString(`
|
|
4676
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2949
4677
|
const [result] = await this.db.query(surql.seal(surql.selectById("id", ["val"])), { id });
|
|
2950
4678
|
if (!result?.val) return null;
|
|
2951
4679
|
return result.val;
|
|
2952
4680
|
} catch (error) {
|
|
2953
4681
|
this.logger.warn({
|
|
2954
4682
|
error,
|
|
2955
|
-
Category: "
|
|
4683
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::get"
|
|
2956
4684
|
}, "Failed to get KV");
|
|
2957
4685
|
return null;
|
|
2958
4686
|
}
|
|
2959
4687
|
}
|
|
2960
4688
|
async remove(key) {
|
|
2961
4689
|
try {
|
|
2962
|
-
const id = parseRecordIdString(`
|
|
4690
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2963
4691
|
await this.db.query(surql.seal(surql.delete("id")), { id });
|
|
2964
4692
|
} catch (err) {
|
|
2965
4693
|
this.logger.info({
|
|
2966
4694
|
err,
|
|
2967
|
-
Category: "
|
|
4695
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::remove"
|
|
2968
4696
|
}, "Failed to delete KV");
|
|
2969
4697
|
}
|
|
2970
4698
|
}
|
|
2971
4699
|
};
|
|
2972
4700
|
|
|
2973
4701
|
//#endregion
|
|
2974
|
-
//#region src/
|
|
4702
|
+
//#region src/services/persistence/resilient.ts
|
|
4703
|
+
var ResilientPersistenceClient = class {
|
|
4704
|
+
logger;
|
|
4705
|
+
constructor(inner, logger) {
|
|
4706
|
+
this.inner = inner;
|
|
4707
|
+
this.logger = logger.child({ service: "ResilientPersistenceClient" });
|
|
4708
|
+
}
|
|
4709
|
+
set(key, value) {
|
|
4710
|
+
return this.inner.set(key, value);
|
|
4711
|
+
}
|
|
4712
|
+
async get(key) {
|
|
4713
|
+
try {
|
|
4714
|
+
return await this.inner.get(key);
|
|
4715
|
+
} catch (e) {
|
|
4716
|
+
this.logger.warn({
|
|
4717
|
+
key,
|
|
4718
|
+
error: e,
|
|
4719
|
+
Category: "sp00ky-client::ResilientPersistenceClient::get"
|
|
4720
|
+
}, "Persistence read failed, dropping key");
|
|
4721
|
+
await this.inner.remove(key).catch((removeErr) => {
|
|
4722
|
+
this.logger.debug({
|
|
4723
|
+
key,
|
|
4724
|
+
error: removeErr,
|
|
4725
|
+
Category: "sp00ky-client::ResilientPersistenceClient::get"
|
|
4726
|
+
}, "Failed to drop corrupt persistence key");
|
|
4727
|
+
});
|
|
4728
|
+
return null;
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
remove(key) {
|
|
4732
|
+
return this.inner.remove(key);
|
|
4733
|
+
}
|
|
4734
|
+
};
|
|
4735
|
+
|
|
4736
|
+
//#endregion
|
|
4737
|
+
//#region src/sp00ky.ts
|
|
2975
4738
|
var BucketHandle = class {
|
|
2976
4739
|
constructor(bucketName, remote) {
|
|
2977
4740
|
this.bucketName = bucketName;
|
|
@@ -3007,7 +4770,7 @@ var BucketHandle = class {
|
|
|
3007
4770
|
return result;
|
|
3008
4771
|
}
|
|
3009
4772
|
};
|
|
3010
|
-
var
|
|
4773
|
+
var Sp00kyClient = class {
|
|
3011
4774
|
local;
|
|
3012
4775
|
remote;
|
|
3013
4776
|
persistenceClient;
|
|
@@ -3016,6 +4779,7 @@ var SpookyClient = class {
|
|
|
3016
4779
|
dataModule;
|
|
3017
4780
|
sync;
|
|
3018
4781
|
devTools;
|
|
4782
|
+
crdtManager;
|
|
3019
4783
|
logger;
|
|
3020
4784
|
auth;
|
|
3021
4785
|
streamProcessor;
|
|
@@ -3028,33 +4792,43 @@ var SpookyClient = class {
|
|
|
3028
4792
|
get pendingMutationCount() {
|
|
3029
4793
|
return this.sync.pendingMutationCount;
|
|
3030
4794
|
}
|
|
4795
|
+
/** Number of times the initial list_ref LIVE subscription retried on
|
|
4796
|
+
* the most recent `setCurrentUserId` call. 0 when the SSP's
|
|
4797
|
+
* pre-emptive user-table creation got there first; >0 when LIVE
|
|
4798
|
+
* registration hit a "table not found" race. Exposed so the e2e
|
|
4799
|
+
* suite can guard the pre-emptive path against regression. */
|
|
4800
|
+
get liveRetryCount() {
|
|
4801
|
+
return this.sync.liveRetryCount;
|
|
4802
|
+
}
|
|
3031
4803
|
subscribeToPendingMutations(cb) {
|
|
3032
4804
|
return this.sync.subscribeToPendingMutations(cb);
|
|
3033
4805
|
}
|
|
3034
4806
|
constructor(config) {
|
|
3035
4807
|
this.config = config;
|
|
3036
|
-
const logger = createLogger(config.logLevel ?? "info", config.
|
|
3037
|
-
this.logger = logger.child({ service: "
|
|
4808
|
+
const logger = createLogger(config.logLevel ?? "info", config.otelTransmit);
|
|
4809
|
+
this.logger = logger.child({ service: "Sp00kyClient" });
|
|
3038
4810
|
this.logger.info({
|
|
3039
4811
|
config: {
|
|
3040
4812
|
...config,
|
|
3041
4813
|
schema: "[SchemaStructure]"
|
|
3042
4814
|
},
|
|
3043
|
-
Category: "
|
|
3044
|
-
}, "
|
|
4815
|
+
Category: "sp00ky-client::Sp00kyClient::constructor"
|
|
4816
|
+
}, "Sp00kyClient initialized");
|
|
3045
4817
|
this.local = new LocalDatabaseService(this.config.database, logger);
|
|
3046
4818
|
this.remote = new RemoteDatabaseService(this.config.database, logger);
|
|
3047
4819
|
if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
|
|
3048
4820
|
else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
|
|
3049
4821
|
else this.persistenceClient = config.persistenceClient;
|
|
4822
|
+
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
3050
4823
|
this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
|
|
3051
4824
|
this.migrator = new LocalMigrator(this.local, logger);
|
|
3052
4825
|
this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
|
|
3053
4826
|
this.dataModule.onStreamUpdate(update);
|
|
3054
4827
|
}, logger);
|
|
4828
|
+
this.crdtManager = new CrdtManager(this.config.schema, this.local, this.remote, logger, config.crdtDebounceMs ?? 500);
|
|
3055
4829
|
this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
|
|
3056
4830
|
this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
|
|
3057
|
-
this.sync = new
|
|
4831
|
+
this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, { refSyncIntervalMs: this.config.refSyncIntervalMs });
|
|
3058
4832
|
this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
|
|
3059
4833
|
this.streamProcessor.addReceiver(this.devTools);
|
|
3060
4834
|
this.setupCallbacks();
|
|
@@ -3063,6 +4837,27 @@ var SpookyClient = class {
|
|
|
3063
4837
|
* Setup direct callbacks instead of event subscriptions
|
|
3064
4838
|
*/
|
|
3065
4839
|
setupCallbacks() {
|
|
4840
|
+
this.dataModule.onQueryStatusChange = (queryHash, status) => {
|
|
4841
|
+
this.devTools.logEvent("QUERY_STATUS_CHANGED", {
|
|
4842
|
+
queryHash,
|
|
4843
|
+
status
|
|
4844
|
+
});
|
|
4845
|
+
};
|
|
4846
|
+
this.dataModule.onHeartbeat = (queryHash) => {
|
|
4847
|
+
this.sync.heartbeatQuery(queryHash).catch((err) => {
|
|
4848
|
+
this.logger.warn({
|
|
4849
|
+
err,
|
|
4850
|
+
queryHash,
|
|
4851
|
+
Category: "sp00ky-client::Sp00kyClient::onHeartbeat"
|
|
4852
|
+
}, "TTL heartbeat failed");
|
|
4853
|
+
});
|
|
4854
|
+
};
|
|
4855
|
+
this.dataModule.onDeregister = (queryHash) => {
|
|
4856
|
+
this.sync.enqueueDownEvent({
|
|
4857
|
+
type: "cleanup",
|
|
4858
|
+
payload: { hash: queryHash }
|
|
4859
|
+
});
|
|
4860
|
+
};
|
|
3066
4861
|
this.dataModule.onMutation((mutations) => {
|
|
3067
4862
|
this.devTools.onMutation(mutations);
|
|
3068
4863
|
if (mutations.length > 0) this.sync.enqueueMutation(mutations);
|
|
@@ -3070,6 +4865,22 @@ var SpookyClient = class {
|
|
|
3070
4865
|
this.sync.events.subscribe("SYNC_QUERY_UPDATED", (event) => {
|
|
3071
4866
|
this.devTools.logEvent("SYNC_QUERY_UPDATED", event.payload);
|
|
3072
4867
|
});
|
|
4868
|
+
this.sync.engineEvents.subscribe("SYNC_REMOTE_DATA_INGESTED", (event) => {
|
|
4869
|
+
try {
|
|
4870
|
+
const records = event.payload?.records ?? [];
|
|
4871
|
+
for (const row of records) {
|
|
4872
|
+
const id = row?.id;
|
|
4873
|
+
const table = id && typeof id === "object" && id.table !== void 0 ? String(id.table) : void 0;
|
|
4874
|
+
if (!table) continue;
|
|
4875
|
+
this.crdtManager.applyRow(table, row);
|
|
4876
|
+
}
|
|
4877
|
+
} catch (err) {
|
|
4878
|
+
this.logger.debug({
|
|
4879
|
+
err,
|
|
4880
|
+
Category: "sp00ky-client::engineEvents::ingested"
|
|
4881
|
+
}, "applyRow forwarding from sync ingest failed");
|
|
4882
|
+
}
|
|
4883
|
+
});
|
|
3073
4884
|
this.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", (event) => {
|
|
3074
4885
|
this.devTools.logEvent("LOCAL_QUERY", event.payload);
|
|
3075
4886
|
});
|
|
@@ -3078,44 +4889,74 @@ var SpookyClient = class {
|
|
|
3078
4889
|
});
|
|
3079
4890
|
}
|
|
3080
4891
|
async init() {
|
|
3081
|
-
this.logger.info({ Category: "
|
|
4892
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
|
|
3082
4893
|
try {
|
|
3083
|
-
const clientId = this.config.clientId ?? await this.loadOrGenerateClientId();
|
|
3084
|
-
this.persistClientId(clientId);
|
|
3085
|
-
this.logger.debug({
|
|
3086
|
-
clientId,
|
|
3087
|
-
Category: "spooky-client::SpookyClient::init"
|
|
3088
|
-
}, "Client ID loaded");
|
|
3089
4894
|
await this.local.connect();
|
|
3090
|
-
this.logger.debug({ Category: "
|
|
4895
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Local database connected");
|
|
3091
4896
|
await this.migrator.provision(this.config.schemaSurql);
|
|
3092
|
-
this.logger.debug({ Category: "
|
|
4897
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
|
|
3093
4898
|
await this.remote.connect();
|
|
3094
|
-
this.logger.debug({ Category: "
|
|
4899
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
|
|
3095
4900
|
await this.streamProcessor.init();
|
|
3096
|
-
this.
|
|
4901
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
4902
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
3097
4903
|
await this.auth.init();
|
|
3098
|
-
this.logger.debug({ Category: "
|
|
3099
|
-
await this.
|
|
3100
|
-
this.
|
|
3101
|
-
|
|
3102
|
-
this.logger.debug({
|
|
3103
|
-
|
|
4904
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
|
|
4905
|
+
const sessionId = await this.fetchSessionId();
|
|
4906
|
+
await this.dataModule.init(sessionId);
|
|
4907
|
+
this.crdtManager.setSessionId(sessionId);
|
|
4908
|
+
this.logger.debug({
|
|
4909
|
+
sessionId,
|
|
4910
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
4911
|
+
}, "DataModule initialized");
|
|
4912
|
+
this.auth.subscribe(async (userId) => {
|
|
4913
|
+
this.dataModule.setCurrentUserId(userId);
|
|
4914
|
+
const next = await this.fetchSessionId();
|
|
4915
|
+
this.dataModule.setSessionId(next);
|
|
4916
|
+
this.crdtManager.setSessionId(next);
|
|
4917
|
+
try {
|
|
4918
|
+
await this.sync.setCurrentUserId(userId);
|
|
4919
|
+
} catch (e) {
|
|
4920
|
+
this.logger.error({
|
|
4921
|
+
error: e,
|
|
4922
|
+
Category: "sp00ky-client::Sp00kyClient::authChange"
|
|
4923
|
+
}, "sync.setCurrentUserId failed");
|
|
4924
|
+
}
|
|
4925
|
+
});
|
|
4926
|
+
await this.sync.init();
|
|
4927
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
|
|
4928
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
|
|
3104
4929
|
} catch (e) {
|
|
3105
4930
|
this.logger.error({
|
|
3106
4931
|
error: e,
|
|
3107
|
-
Category: "
|
|
3108
|
-
}, "
|
|
4932
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
4933
|
+
}, "Sp00kyClient initialization failed");
|
|
3109
4934
|
throw e;
|
|
3110
4935
|
}
|
|
3111
4936
|
}
|
|
3112
4937
|
async close() {
|
|
4938
|
+
this.crdtManager.closeAll();
|
|
3113
4939
|
await this.local.close();
|
|
3114
4940
|
await this.remote.close();
|
|
3115
4941
|
}
|
|
3116
4942
|
authenticate(token) {
|
|
3117
4943
|
return this.remote.getClient().authenticate(token);
|
|
3118
4944
|
}
|
|
4945
|
+
/**
|
|
4946
|
+
* Open a CRDT field for collaborative editing.
|
|
4947
|
+
* Returns a CrdtField with a LoroDoc that can be bound to any editor.
|
|
4948
|
+
* Also starts a LIVE SELECT on the parent table for real-time sync;
|
|
4949
|
+
* incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
|
|
4950
|
+
*/
|
|
4951
|
+
async openCrdtField(table, recordId, field, fallbackText) {
|
|
4952
|
+
return this.crdtManager.open(table, recordId, field, fallbackText);
|
|
4953
|
+
}
|
|
4954
|
+
/**
|
|
4955
|
+
* Close a CRDT field when editing is done.
|
|
4956
|
+
*/
|
|
4957
|
+
closeCrdtField(table, recordId, field) {
|
|
4958
|
+
this.crdtManager.close(table, recordId, field);
|
|
4959
|
+
}
|
|
3119
4960
|
deauthenticate() {
|
|
3120
4961
|
return this.remote.getClient().invalidate();
|
|
3121
4962
|
}
|
|
@@ -3125,7 +4966,18 @@ var SpookyClient = class {
|
|
|
3125
4966
|
async initQuery(table, q, ttl) {
|
|
3126
4967
|
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
3127
4968
|
if (!tableSchema) throw new Error(`Table ${table} not found`);
|
|
3128
|
-
const
|
|
4969
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
4970
|
+
const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
|
|
4971
|
+
if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
|
|
4972
|
+
const [rows] = await this.remote.query(q.selectQuery.query, params);
|
|
4973
|
+
await this.dataModule.applyHydration(hash, rows ?? []);
|
|
4974
|
+
} catch (err) {
|
|
4975
|
+
this.logger.warn({
|
|
4976
|
+
err,
|
|
4977
|
+
hash,
|
|
4978
|
+
Category: "sp00ky-client::Sp00kyClient::instantHydrate"
|
|
4979
|
+
}, "Instant hydrate failed; proceeding with registration");
|
|
4980
|
+
}
|
|
3129
4981
|
await this.sync.enqueueDownEvent({
|
|
3130
4982
|
type: "register",
|
|
3131
4983
|
payload: { hash }
|
|
@@ -3139,6 +4991,32 @@ var SpookyClient = class {
|
|
|
3139
4991
|
async subscribe(queryHash, callback, options) {
|
|
3140
4992
|
return this.dataModule.subscribe(queryHash, callback, options);
|
|
3141
4993
|
}
|
|
4994
|
+
/**
|
|
4995
|
+
* Opt-in eager teardown for a query whose last subscriber has gone away
|
|
4996
|
+
* (e.g. a viewport-windowed list cancelling an off-screen window). No-op
|
|
4997
|
+
* while any subscriber remains. Tears down the remote `_00_query` view +
|
|
4998
|
+
* local WASM view instead of waiting for the TTL sweep. Default behavior
|
|
4999
|
+
* (no call here) keeps the view resident for cheap re-subscription.
|
|
5000
|
+
*/
|
|
5001
|
+
deregisterQuery(queryHash) {
|
|
5002
|
+
this.dataModule.deregisterQuery(queryHash);
|
|
5003
|
+
}
|
|
5004
|
+
/**
|
|
5005
|
+
* Subscribe to a query's fetch-status changes (idle/fetching). With
|
|
5006
|
+
* `{ immediate: true }` the callback fires synchronously with the current
|
|
5007
|
+
* status. Powers the `useQuery` hook's `isFetching()` accessor.
|
|
5008
|
+
*/
|
|
5009
|
+
subscribeQueryStatus(queryHash, callback, options) {
|
|
5010
|
+
return this.dataModule.subscribeStatus(queryHash, callback, options);
|
|
5011
|
+
}
|
|
5012
|
+
/**
|
|
5013
|
+
* Report the frontend processing time (ms) a client framework spent applying
|
|
5014
|
+
* an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
|
|
5015
|
+
* surface the "frontend" phase of the per-query timing breakdown.
|
|
5016
|
+
*/
|
|
5017
|
+
reportFrontendTiming(queryHash, ms) {
|
|
5018
|
+
this.dataModule.recordFrontendTiming(queryHash, ms);
|
|
5019
|
+
}
|
|
3142
5020
|
run(backend, path, payload, options) {
|
|
3143
5021
|
return this.dataModule.run(backend, path, payload, options);
|
|
3144
5022
|
}
|
|
@@ -3157,24 +5035,25 @@ var SpookyClient = class {
|
|
|
3157
5035
|
async useRemote(fn) {
|
|
3158
5036
|
return fn(this.remote.getClient());
|
|
3159
5037
|
}
|
|
3160
|
-
|
|
5038
|
+
/**
|
|
5039
|
+
* Fetch SurrealDB's `session::id()` as a string. Used as a salt for
|
|
5040
|
+
* query-id hashing so two sessions for the same user get distinct
|
|
5041
|
+
* `_00_query` rows. Returns empty string if the query fails (we still
|
|
5042
|
+
* boot, just without session scoping for IDs).
|
|
5043
|
+
*/
|
|
5044
|
+
async fetchSessionId() {
|
|
3161
5045
|
try {
|
|
3162
|
-
this.
|
|
5046
|
+
const [sid] = await this.remote.query("RETURN <string>session::id()");
|
|
5047
|
+
return typeof sid === "string" ? sid : "";
|
|
3163
5048
|
} catch (e) {
|
|
3164
5049
|
this.logger.warn({
|
|
3165
5050
|
error: e,
|
|
3166
|
-
Category: "
|
|
3167
|
-
}, "Failed to
|
|
5051
|
+
Category: "sp00ky-client::Sp00kyClient::fetchSessionId"
|
|
5052
|
+
}, "Failed to fetch session::id() — proceeding with empty salt");
|
|
5053
|
+
return "";
|
|
3168
5054
|
}
|
|
3169
5055
|
}
|
|
3170
|
-
async loadOrGenerateClientId() {
|
|
3171
|
-
const clientId = await this.persistenceClient.get("spooky_client_id");
|
|
3172
|
-
if (clientId) return clientId;
|
|
3173
|
-
const newId = generateId();
|
|
3174
|
-
await this.persistClientId(newId);
|
|
3175
|
-
return newId;
|
|
3176
|
-
}
|
|
3177
5056
|
};
|
|
3178
5057
|
|
|
3179
5058
|
//#endregion
|
|
3180
|
-
export { AuthEventTypes, AuthService, BucketHandle,
|
|
5059
|
+
export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
|