@spooky-sync/core 0.0.1-canary.10 → 0.0.1-canary.101
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 +926 -339
- package/dist/index.js +2886 -402
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +548 -0
- package/package.json +39 -9
- 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 +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -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 +736 -109
- 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 +115 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +66 -0
- package/src/modules/ref-tables.ts +69 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -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 +40 -3
- package/src/modules/sync/sync.health.test.ts +116 -0
- package/src/modules/sync/sync.ts +935 -59
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -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 +248 -37
- 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 +648 -0
- package/src/types.ts +197 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- 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) {
|
|
@@ -106,6 +110,8 @@ function parseValue(name, column, value) {
|
|
|
106
110
|
//#region src/utils/error-classification.ts
|
|
107
111
|
const NETWORK_ERROR_PATTERNS = [
|
|
108
112
|
"connection",
|
|
113
|
+
"must be connected",
|
|
114
|
+
"connectionunavailable",
|
|
109
115
|
"timeout",
|
|
110
116
|
"timed out",
|
|
111
117
|
"websocket",
|
|
@@ -148,12 +154,21 @@ function generateId() {
|
|
|
148
154
|
return Uuid.v4().toString().replace(/-/g, "");
|
|
149
155
|
}
|
|
150
156
|
/**
|
|
157
|
+
* Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
|
|
158
|
+
* releases expose it under `milliseconds` (current) or the older private
|
|
159
|
+
* `_milliseconds` field, so read whichever is set; returns 0 when neither is.
|
|
160
|
+
*/
|
|
161
|
+
function durationMillis(duration) {
|
|
162
|
+
const d = duration;
|
|
163
|
+
return Number(d.milliseconds || d._milliseconds || 0);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
151
166
|
* Parse duration string or Duration object to milliseconds
|
|
152
167
|
*/
|
|
153
168
|
function parseDuration(duration) {
|
|
154
169
|
if (duration instanceof Duration) {
|
|
155
|
-
const ms = duration
|
|
156
|
-
if (ms) return
|
|
170
|
+
const ms = durationMillis(duration);
|
|
171
|
+
if (ms) return ms;
|
|
157
172
|
const str = duration.toString();
|
|
158
173
|
if (str !== "[object Object]") return parseDuration(str);
|
|
159
174
|
return 6e5;
|
|
@@ -162,7 +177,7 @@ function parseDuration(duration) {
|
|
|
162
177
|
if (typeof duration !== "string") return 6e5;
|
|
163
178
|
const match = duration.match(/^(\d+)([smh])$/);
|
|
164
179
|
if (!match) return 6e5;
|
|
165
|
-
const val = parseInt(match[1], 10);
|
|
180
|
+
const val = Number.parseInt(match[1], 10);
|
|
166
181
|
switch (match[2]) {
|
|
167
182
|
case "s": return val * 1e3;
|
|
168
183
|
case "h": return val * 36e5;
|
|
@@ -174,6 +189,13 @@ async function fileToUint8Array(file) {
|
|
|
174
189
|
return new Uint8Array(buffer);
|
|
175
190
|
}
|
|
176
191
|
/**
|
|
192
|
+
* Convert plain text to simple HTML paragraphs.
|
|
193
|
+
* Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
|
|
194
|
+
*/
|
|
195
|
+
function textToHtml(text) {
|
|
196
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").split("\n").map((l) => `<p>${l || "<br>"}</p>`).join("");
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
177
199
|
* Helper for retrying DB operations with exponential backoff
|
|
178
200
|
*/
|
|
179
201
|
async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
@@ -188,7 +210,7 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
188
210
|
attempt: i + 1,
|
|
189
211
|
retries,
|
|
190
212
|
error: msg,
|
|
191
|
-
Category: "
|
|
213
|
+
Category: "sp00ky-client::utils::withRetry"
|
|
192
214
|
}, "Retrying DB operation");
|
|
193
215
|
await new Promise((res) => setTimeout(res, delayMs * (i + 1)));
|
|
194
216
|
continue;
|
|
@@ -198,8 +220,160 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
198
220
|
throw lastError;
|
|
199
221
|
}
|
|
200
222
|
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/modules/data/window-query.ts
|
|
225
|
+
/**
|
|
226
|
+
* Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
|
|
227
|
+
* materialized from an explicit record-id set — the window the SSP already
|
|
228
|
+
* computed — instead of re-running the original query (with its `START m`)
|
|
229
|
+
* against the shared local DB.
|
|
230
|
+
*
|
|
231
|
+
* Why: `DataModule.processStreamUpdate` materializes a query's rows by
|
|
232
|
+
* re-querying the local in-browser SurrealDB with the original surql. For an
|
|
233
|
+
* offset query that re-applies `START m` against the *shared* local store —
|
|
234
|
+
* which, with sparse windowing, may hold only this window's rows — so it skips
|
|
235
|
+
* them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
|
|
236
|
+
* materialized view (`StreamUpdate.localArray`) is exactly this window's row
|
|
237
|
+
* ids, so we select those ids directly and re-apply the original `ORDER BY` for
|
|
238
|
+
* stable display order.
|
|
239
|
+
*
|
|
240
|
+
* Returns `null` for non-offset queries (`START` absent or 0) — the caller
|
|
241
|
+
* keeps the normal re-query path, so only the broken case changes behavior.
|
|
242
|
+
*
|
|
243
|
+
* Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
|
|
244
|
+
* drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
|
|
245
|
+
* projection are preserved verbatim (the scanner is paren/quote aware), with
|
|
246
|
+
* one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
|
|
247
|
+
* shape — such windowed+subquery queries are rare and were already returning 0,
|
|
248
|
+
* so this is no regression.
|
|
249
|
+
*/
|
|
250
|
+
function buildWindowMaterialization(surql, idsParam = "__win") {
|
|
251
|
+
const kw = scanTopLevelClauses(surql);
|
|
252
|
+
if (kw.startValue === null || kw.startValue <= 0) return null;
|
|
253
|
+
if (kw.fromIndex === null) return null;
|
|
254
|
+
const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
|
|
255
|
+
let orderBy = "";
|
|
256
|
+
if (kw.orderByIndex !== null) {
|
|
257
|
+
const ends = [
|
|
258
|
+
kw.limitIndex,
|
|
259
|
+
kw.startIndex,
|
|
260
|
+
kw.semicolonIndex,
|
|
261
|
+
surql.length
|
|
262
|
+
].filter((n) => n !== null && n > kw.orderByIndex);
|
|
263
|
+
const end = Math.min(...ends);
|
|
264
|
+
orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
|
|
265
|
+
}
|
|
266
|
+
return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
|
|
267
|
+
}
|
|
268
|
+
function scanTopLevelClauses(sql) {
|
|
269
|
+
const out = {
|
|
270
|
+
fromIndex: null,
|
|
271
|
+
orderByIndex: null,
|
|
272
|
+
limitIndex: null,
|
|
273
|
+
startIndex: null,
|
|
274
|
+
startValue: null,
|
|
275
|
+
semicolonIndex: null
|
|
276
|
+
};
|
|
277
|
+
let depth = 0;
|
|
278
|
+
let inStr = false;
|
|
279
|
+
for (let i = 0; i < sql.length; i++) {
|
|
280
|
+
const ch = sql[i];
|
|
281
|
+
if (inStr) {
|
|
282
|
+
if (ch === "\\") i++;
|
|
283
|
+
else if (ch === "'") inStr = false;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (ch === "'") {
|
|
287
|
+
inStr = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (ch === "(") {
|
|
291
|
+
depth++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (ch === ")") {
|
|
295
|
+
depth--;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (depth !== 0) continue;
|
|
299
|
+
if (ch === ";" && out.semicolonIndex === null) {
|
|
300
|
+
out.semicolonIndex = i;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!isWordBoundary(sql, i)) continue;
|
|
304
|
+
if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
|
|
305
|
+
out.fromIndex = i;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (out.fromIndex === null) continue;
|
|
309
|
+
if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
|
|
310
|
+
out.orderByIndex = i;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
|
|
314
|
+
out.limitIndex = i;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (out.startIndex === null && matchKeyword(sql, i, "START")) {
|
|
318
|
+
out.startIndex = i;
|
|
319
|
+
out.startValue = readNumberAfter(sql, i + 5);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
function isWordBoundary(sql, i) {
|
|
326
|
+
if (i === 0) return true;
|
|
327
|
+
return !/[A-Za-z0-9_]/.test(sql[i - 1]);
|
|
328
|
+
}
|
|
329
|
+
function matchKeyword(sql, i, keyword) {
|
|
330
|
+
const parts = keyword.split(" ");
|
|
331
|
+
let pos = i;
|
|
332
|
+
for (let p = 0; p < parts.length; p++) {
|
|
333
|
+
const word = parts[p];
|
|
334
|
+
if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
|
|
335
|
+
pos += word.length;
|
|
336
|
+
if (p < parts.length - 1) {
|
|
337
|
+
const wsStart = pos;
|
|
338
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
339
|
+
if (pos === wsStart) return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
|
|
343
|
+
}
|
|
344
|
+
function readNumberAfter(sql, from) {
|
|
345
|
+
let pos = from;
|
|
346
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
347
|
+
const m = /^\d+/.exec(sql.slice(pos));
|
|
348
|
+
return m ? parseInt(m[0], 10) : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
201
351
|
//#endregion
|
|
202
352
|
//#region src/modules/data/index.ts
|
|
353
|
+
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
354
|
+
function pushSample(samples, ms) {
|
|
355
|
+
samples.push(ms);
|
|
356
|
+
if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
|
|
357
|
+
}
|
|
358
|
+
/** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
|
|
359
|
+
function phaseStatOf(samples, lastMs) {
|
|
360
|
+
if (samples.length === 0) return {
|
|
361
|
+
lastMs,
|
|
362
|
+
p50: null,
|
|
363
|
+
p90: null,
|
|
364
|
+
p99: null,
|
|
365
|
+
count: 0
|
|
366
|
+
};
|
|
367
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
368
|
+
const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
369
|
+
return {
|
|
370
|
+
lastMs,
|
|
371
|
+
p50: pick(.5),
|
|
372
|
+
p90: pick(.9),
|
|
373
|
+
p99: pick(.99),
|
|
374
|
+
count: samples.length
|
|
375
|
+
};
|
|
376
|
+
}
|
|
203
377
|
/**
|
|
204
378
|
* DataModule - Unified query and mutation management
|
|
205
379
|
*
|
|
@@ -210,18 +384,74 @@ var DataModule = class {
|
|
|
210
384
|
activeQueries = /* @__PURE__ */ new Map();
|
|
211
385
|
pendingQueries = /* @__PURE__ */ new Map();
|
|
212
386
|
subscriptions = /* @__PURE__ */ new Map();
|
|
387
|
+
statusSubscriptions = /* @__PURE__ */ new Map();
|
|
213
388
|
mutationCallbacks = /* @__PURE__ */ new Set();
|
|
214
389
|
debounceTimers = /* @__PURE__ */ new Map();
|
|
215
390
|
logger;
|
|
216
|
-
|
|
391
|
+
/**
|
|
392
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
393
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
394
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
395
|
+
* constructed after DataModule.
|
|
396
|
+
*/
|
|
397
|
+
onQueryStatusChange;
|
|
398
|
+
/**
|
|
399
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
400
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
401
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
402
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
403
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
404
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
405
|
+
*/
|
|
406
|
+
onHeartbeat;
|
|
407
|
+
/**
|
|
408
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
409
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
410
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
411
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
412
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
413
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
414
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
415
|
+
*/
|
|
416
|
+
onDeregister;
|
|
417
|
+
sessionId = "";
|
|
418
|
+
currentUserId = null;
|
|
419
|
+
constructor(cache, local, schema, logger, streamDebounceTime = 50) {
|
|
217
420
|
this.cache = cache;
|
|
218
421
|
this.local = local;
|
|
219
422
|
this.schema = schema;
|
|
220
423
|
this.streamDebounceTime = streamDebounceTime;
|
|
221
424
|
this.logger = logger.child({ service: "DataModule" });
|
|
222
425
|
}
|
|
223
|
-
async init() {
|
|
224
|
-
this.
|
|
426
|
+
async init(sessionId) {
|
|
427
|
+
this.sessionId = sessionId;
|
|
428
|
+
this.logger.info({
|
|
429
|
+
sessionId,
|
|
430
|
+
Category: "sp00ky-client::DataModule::init"
|
|
431
|
+
}, "DataModule initialized");
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Update the session salt used in query-id hashing. Call this when the
|
|
435
|
+
* SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
|
|
436
|
+
* registered queries will get fresh, session-scoped IDs.
|
|
437
|
+
*/
|
|
438
|
+
setSessionId(sessionId) {
|
|
439
|
+
this.sessionId = sessionId;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Update the authenticated user record id. Pass `null` on sign-out.
|
|
443
|
+
* Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
|
|
444
|
+
* the poll route to the same per-user `_00_list_ref_user_<id>` the
|
|
445
|
+
* SSP writes to.
|
|
446
|
+
*/
|
|
447
|
+
setCurrentUserId(userId) {
|
|
448
|
+
this.currentUserId = userId;
|
|
449
|
+
}
|
|
450
|
+
/** Read-only view of the authenticated user id used for per-user
|
|
451
|
+
* `_00_list_ref` routing. Other modules consult this so they pick the
|
|
452
|
+
* same table name DataModule does. */
|
|
453
|
+
getCurrentUserId() {
|
|
454
|
+
return this.currentUserId;
|
|
225
455
|
}
|
|
226
456
|
/**
|
|
227
457
|
* Register a query and return its hash for subscriptions
|
|
@@ -233,27 +463,27 @@ var DataModule = class {
|
|
|
233
463
|
});
|
|
234
464
|
this.logger.debug({
|
|
235
465
|
hash,
|
|
236
|
-
Category: "
|
|
466
|
+
Category: "sp00ky-client::DataModule::query"
|
|
237
467
|
}, "Query Initialization: started");
|
|
238
|
-
const recordId = new RecordId("
|
|
468
|
+
const recordId = new RecordId("_00_query", hash);
|
|
239
469
|
if (this.activeQueries.has(hash)) {
|
|
240
470
|
this.logger.debug({
|
|
241
471
|
hash,
|
|
242
|
-
Category: "
|
|
472
|
+
Category: "sp00ky-client::DataModule::query"
|
|
243
473
|
}, "Query Initialization: exists, returning");
|
|
244
474
|
return hash;
|
|
245
475
|
}
|
|
246
476
|
if (this.pendingQueries.has(hash)) {
|
|
247
477
|
this.logger.debug({
|
|
248
478
|
hash,
|
|
249
|
-
Category: "
|
|
479
|
+
Category: "sp00ky-client::DataModule::query"
|
|
250
480
|
}, "Query Initialization: pending, waiting for existing creation");
|
|
251
481
|
await this.pendingQueries.get(hash);
|
|
252
482
|
return hash;
|
|
253
483
|
}
|
|
254
484
|
this.logger.debug({
|
|
255
485
|
hash,
|
|
256
|
-
Category: "
|
|
486
|
+
Category: "sp00ky-client::DataModule::query"
|
|
257
487
|
}, "Query Initialization: not found, creating new query");
|
|
258
488
|
const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
|
|
259
489
|
this.pendingQueries.set(hash, promise);
|
|
@@ -283,6 +513,36 @@ var DataModule = class {
|
|
|
283
513
|
};
|
|
284
514
|
}
|
|
285
515
|
/**
|
|
516
|
+
* Subscribe to a query's fetch-status changes (idle/fetching).
|
|
517
|
+
* With `{ immediate: true }` the callback fires synchronously with the
|
|
518
|
+
* current status (defaults to `idle` if the query isn't registered yet).
|
|
519
|
+
*/
|
|
520
|
+
subscribeStatus(queryHash, callback, options = {}) {
|
|
521
|
+
if (!this.statusSubscriptions.has(queryHash)) this.statusSubscriptions.set(queryHash, /* @__PURE__ */ new Set());
|
|
522
|
+
this.statusSubscriptions.get(queryHash)?.add(callback);
|
|
523
|
+
if (options.immediate) callback(this.activeQueries.get(queryHash)?.status ?? "idle");
|
|
524
|
+
return () => {
|
|
525
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
526
|
+
if (subs) {
|
|
527
|
+
subs.delete(callback);
|
|
528
|
+
if (subs.size === 0) this.statusSubscriptions.delete(queryHash);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Set a query's fetch status and notify status observers (DevTools +
|
|
534
|
+
* `subscribeStatus` listeners). No-op when the status is unchanged or the
|
|
535
|
+
* query is unknown.
|
|
536
|
+
*/
|
|
537
|
+
setQueryStatus(queryHash, status) {
|
|
538
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
539
|
+
if (!queryState || queryState.status === status) return;
|
|
540
|
+
queryState.status = status;
|
|
541
|
+
this.onQueryStatusChange?.(queryHash, status);
|
|
542
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
543
|
+
if (subs) for (const callback of subs) callback(status);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
286
546
|
* Subscribe to mutations (for sync)
|
|
287
547
|
*/
|
|
288
548
|
onMutation(callback) {
|
|
@@ -296,66 +556,298 @@ var DataModule = class {
|
|
|
296
556
|
*/
|
|
297
557
|
async onStreamUpdate(update) {
|
|
298
558
|
const { queryHash, op } = update;
|
|
299
|
-
if (op === "
|
|
300
|
-
|
|
301
|
-
|
|
559
|
+
if (op === "DELETE") {
|
|
560
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
561
|
+
if (existing) {
|
|
562
|
+
clearTimeout(existing);
|
|
302
563
|
this.debounceTimers.delete(queryHash);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
564
|
+
}
|
|
565
|
+
await this.processStreamUpdate(update);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
|
|
569
|
+
const timer = setTimeout(async () => {
|
|
570
|
+
this.debounceTimers.delete(queryHash);
|
|
571
|
+
await this.processStreamUpdate(update);
|
|
572
|
+
}, this.streamDebounceTime);
|
|
573
|
+
this.debounceTimers.set(queryHash, timer);
|
|
574
|
+
}
|
|
575
|
+
async materializeRecords(queryState, sspArray) {
|
|
576
|
+
const t0 = performance.now();
|
|
577
|
+
const windowMat = buildWindowMaterialization(queryState.config.surql);
|
|
578
|
+
let records;
|
|
579
|
+
if (windowMat) {
|
|
580
|
+
const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
|
|
581
|
+
const [rows] = await this.local.query(windowMat.query, {
|
|
582
|
+
...queryState.config.params,
|
|
583
|
+
__win: winIds
|
|
584
|
+
});
|
|
585
|
+
records = rows || [];
|
|
586
|
+
} else {
|
|
587
|
+
const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
|
|
588
|
+
records = rows || [];
|
|
589
|
+
}
|
|
590
|
+
this.recordPhase(queryState, "localFetch", performance.now() - t0);
|
|
591
|
+
return records;
|
|
307
592
|
}
|
|
308
593
|
async processStreamUpdate(update) {
|
|
309
|
-
const { queryHash, localArray } = update;
|
|
594
|
+
const { queryHash, localArray, materializationTimeMs } = update;
|
|
310
595
|
const queryState = this.activeQueries.get(queryHash);
|
|
311
596
|
if (!queryState) {
|
|
312
597
|
this.logger.warn({
|
|
313
598
|
queryHash,
|
|
314
|
-
Category: "
|
|
599
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
315
600
|
}, "Received update for unknown query. Skipping...");
|
|
316
601
|
return;
|
|
317
602
|
}
|
|
603
|
+
if (typeof materializationTimeMs === "number") {
|
|
604
|
+
queryState.materializationSamples.push(materializationTimeMs);
|
|
605
|
+
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
|
|
606
|
+
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
607
|
+
}
|
|
608
|
+
if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
|
|
609
|
+
if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
|
|
610
|
+
if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
|
|
611
|
+
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
318
612
|
try {
|
|
319
|
-
const
|
|
320
|
-
const newRecords = records || [];
|
|
613
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
321
614
|
queryState.config.localArray = localArray;
|
|
322
|
-
await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
|
|
323
|
-
id: queryState.config.id,
|
|
324
|
-
localArray
|
|
325
|
-
});
|
|
326
615
|
const prevJson = JSON.stringify(queryState.records);
|
|
327
616
|
const newJson = JSON.stringify(newRecords);
|
|
328
617
|
queryState.records = newRecords;
|
|
329
|
-
|
|
618
|
+
const recordsChanged = prevJson !== newJson;
|
|
619
|
+
if (recordsChanged) queryState.updateCount++;
|
|
620
|
+
await this.local.query(surql.seal(surql.updateSet("id", [
|
|
621
|
+
"localArray",
|
|
622
|
+
"rowCount",
|
|
623
|
+
"updateCount",
|
|
624
|
+
"lastIngestLatency",
|
|
625
|
+
"materializationP55",
|
|
626
|
+
"materializationP90",
|
|
627
|
+
"materializationP99"
|
|
628
|
+
])), {
|
|
629
|
+
id: queryState.config.id,
|
|
630
|
+
localArray,
|
|
631
|
+
rowCount: localArray.length,
|
|
632
|
+
updateCount: queryState.updateCount,
|
|
633
|
+
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
634
|
+
materializationP55: percentiles.p55,
|
|
635
|
+
materializationP90: percentiles.p90,
|
|
636
|
+
materializationP99: percentiles.p99
|
|
637
|
+
});
|
|
638
|
+
if (!recordsChanged) {
|
|
330
639
|
this.logger.debug({
|
|
331
640
|
queryHash,
|
|
332
|
-
Category: "
|
|
641
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
333
642
|
}, "Query records unchanged, skipping notification");
|
|
334
643
|
return;
|
|
335
644
|
}
|
|
336
|
-
queryState.updateCount++;
|
|
337
645
|
const subscribers = this.subscriptions.get(queryHash);
|
|
338
646
|
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
339
647
|
this.logger.debug({
|
|
340
648
|
queryHash,
|
|
341
|
-
recordCount:
|
|
342
|
-
Category: "
|
|
649
|
+
recordCount: newRecords?.length,
|
|
650
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
343
651
|
}, "Query updated from stream");
|
|
344
652
|
} catch (err) {
|
|
653
|
+
queryState.errorCount++;
|
|
345
654
|
this.logger.error({
|
|
346
655
|
err,
|
|
347
656
|
queryHash,
|
|
348
|
-
Category: "
|
|
657
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
349
658
|
}, "Failed to fetch records for stream update");
|
|
659
|
+
try {
|
|
660
|
+
await this.local.query(surql.seal(surql.updateSet("id", ["errorCount"])), {
|
|
661
|
+
id: queryState.config.id,
|
|
662
|
+
errorCount: queryState.errorCount
|
|
663
|
+
});
|
|
664
|
+
} catch (persistErr) {
|
|
665
|
+
this.logger.warn({
|
|
666
|
+
err: persistErr,
|
|
667
|
+
queryHash,
|
|
668
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
669
|
+
}, "Failed to persist incremented errorCount");
|
|
670
|
+
}
|
|
350
671
|
}
|
|
351
672
|
}
|
|
352
673
|
/**
|
|
674
|
+
* Compute p55/p90/p99 from a rolling window of materialization samples.
|
|
675
|
+
* Returns nulls for any percentile that has no samples yet so SurrealDB
|
|
676
|
+
* `option<float>` columns stay NONE rather than 0 before the first ingest.
|
|
677
|
+
*/
|
|
678
|
+
computeMaterializationPercentiles(samples) {
|
|
679
|
+
if (samples.length === 0) return {
|
|
680
|
+
p55: null,
|
|
681
|
+
p90: null,
|
|
682
|
+
p99: null
|
|
683
|
+
};
|
|
684
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
685
|
+
const pick = (q) => {
|
|
686
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
687
|
+
};
|
|
688
|
+
return {
|
|
689
|
+
p55: pick(.55),
|
|
690
|
+
p90: pick(.9),
|
|
691
|
+
p99: pick(.99)
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/** Record a per-phase timing sample (ms) on a query's rolling window. */
|
|
695
|
+
recordPhase(qs, phase, ms) {
|
|
696
|
+
if (!Number.isFinite(ms)) return;
|
|
697
|
+
pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
|
|
698
|
+
qs.phaseLast[phase] = ms;
|
|
699
|
+
}
|
|
700
|
+
/** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
|
|
701
|
+
recordRemoteFetch(hash, ms) {
|
|
702
|
+
const qs = this.activeQueries.get(hash);
|
|
703
|
+
if (qs) this.recordPhase(qs, "remoteFetch", ms);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Record the frontend reconcile time (ms) for a query. Called from `useQuery`
|
|
707
|
+
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
708
|
+
*/
|
|
709
|
+
recordFrontendTiming(hash, ms) {
|
|
710
|
+
const qs = this.activeQueries.get(hash);
|
|
711
|
+
if (qs) this.recordPhase(qs, "frontend", ms);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Build the per-query processing-time breakdown surfaced to the DevTools panel
|
|
715
|
+
* and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
|
|
716
|
+
* the rest come from the per-phase rolling windows + one-shot registration timings.
|
|
717
|
+
*/
|
|
718
|
+
phaseTimings(q) {
|
|
719
|
+
const stat = (phase) => phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
|
|
720
|
+
return {
|
|
721
|
+
ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
|
|
722
|
+
sspStoreApply: stat("sspStoreApply"),
|
|
723
|
+
sspCircuitStep: stat("sspCircuitStep"),
|
|
724
|
+
sspTransform: stat("sspTransform"),
|
|
725
|
+
localFetch: stat("localFetch"),
|
|
726
|
+
remoteFetch: stat("remoteFetch"),
|
|
727
|
+
frontend: stat("frontend"),
|
|
728
|
+
registration: q.registrationTimings,
|
|
729
|
+
updateCount: q.updateCount,
|
|
730
|
+
errorCount: q.errorCount
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
353
734
|
* Get query state (for sync and devtools)
|
|
354
735
|
*/
|
|
355
736
|
getQueryByHash(hash) {
|
|
356
737
|
return this.activeQueries.get(hash);
|
|
357
738
|
}
|
|
358
739
|
/**
|
|
740
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
741
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
742
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
743
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
744
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
745
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
746
|
+
*/
|
|
747
|
+
isCold(hash) {
|
|
748
|
+
const qs = this.activeQueries.get(hash);
|
|
749
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
753
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
754
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
755
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
756
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
757
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
758
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
759
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
760
|
+
* `seen` dedupes within the batch.
|
|
761
|
+
*/
|
|
762
|
+
collectEmbeddedChildren(record, batch, seen) {
|
|
763
|
+
const isEmbeddedRecord = (v) => !!v && typeof v === "object" && !(v instanceof RecordId) && v.id instanceof RecordId;
|
|
764
|
+
for (const value of Object.values(record)) {
|
|
765
|
+
const children = Array.isArray(value) ? value.filter(isEmbeddedRecord) : isEmbeddedRecord(value) ? [value] : [];
|
|
766
|
+
for (const child of children) {
|
|
767
|
+
const key = encodeRecordId(child.id);
|
|
768
|
+
if (seen.has(key)) continue;
|
|
769
|
+
seen.add(key);
|
|
770
|
+
this.collectEmbeddedChildren(child, batch, seen);
|
|
771
|
+
const table = child.id.table.toString();
|
|
772
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
773
|
+
batch.push({
|
|
774
|
+
table,
|
|
775
|
+
op: "CREATE",
|
|
776
|
+
record: tableSchema ? cleanRecord(tableSchema.columns, child) : child,
|
|
777
|
+
version: child._00_rv || 1
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
784
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
785
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
786
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
787
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
788
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
789
|
+
*/
|
|
790
|
+
async applyHydration(hash, rows) {
|
|
791
|
+
const queryState = this.activeQueries.get(hash);
|
|
792
|
+
if (!queryState) return;
|
|
793
|
+
queryState.hydrated = true;
|
|
794
|
+
if (rows.length === 0) return;
|
|
795
|
+
const tableName = queryState.config.tableName;
|
|
796
|
+
const batch = rows.map((record) => ({
|
|
797
|
+
table: tableName,
|
|
798
|
+
op: "CREATE",
|
|
799
|
+
record,
|
|
800
|
+
version: record._00_rv || 1
|
|
801
|
+
}));
|
|
802
|
+
const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
|
|
803
|
+
for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
|
|
804
|
+
await this.cache.saveBatch(batch);
|
|
805
|
+
queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
|
|
806
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
807
|
+
const subscribers = this.subscriptions.get(hash);
|
|
808
|
+
if (subscribers) for (const cb of subscribers) cb(queryState.records);
|
|
809
|
+
}
|
|
810
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
811
|
+
hasSubscribers(hash) {
|
|
812
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
816
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
817
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
818
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
819
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
820
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
821
|
+
*
|
|
822
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
823
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
824
|
+
*/
|
|
825
|
+
deregisterQuery(hash) {
|
|
826
|
+
if (this.hasSubscribers(hash)) return;
|
|
827
|
+
if (!this.activeQueries.has(hash)) return;
|
|
828
|
+
this.onDeregister?.(hash);
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
832
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
833
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
834
|
+
*/
|
|
835
|
+
finalizeDeregister(hash) {
|
|
836
|
+
const qs = this.activeQueries.get(hash);
|
|
837
|
+
if (qs?.ttlTimer) {
|
|
838
|
+
clearTimeout(qs.ttlTimer);
|
|
839
|
+
qs.ttlTimer = null;
|
|
840
|
+
}
|
|
841
|
+
const debounce = this.debounceTimers.get(hash);
|
|
842
|
+
if (debounce) {
|
|
843
|
+
clearTimeout(debounce);
|
|
844
|
+
this.debounceTimers.delete(hash);
|
|
845
|
+
}
|
|
846
|
+
this.cache.unregisterQuery(hash);
|
|
847
|
+
this.activeQueries.delete(hash);
|
|
848
|
+
this.subscriptions.delete(hash);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
359
851
|
* Get query state by id (for sync and devtools)
|
|
360
852
|
*/
|
|
361
853
|
getQueryById(id) {
|
|
@@ -367,12 +859,15 @@ var DataModule = class {
|
|
|
367
859
|
getActiveQueries() {
|
|
368
860
|
return Array.from(this.activeQueries.values());
|
|
369
861
|
}
|
|
862
|
+
getActiveQueryHashes() {
|
|
863
|
+
return Array.from(this.activeQueries.keys());
|
|
864
|
+
}
|
|
370
865
|
async updateQueryLocalArray(id, localArray) {
|
|
371
866
|
const queryState = this.activeQueries.get(id);
|
|
372
867
|
if (!queryState) {
|
|
373
868
|
this.logger.warn({
|
|
374
869
|
id,
|
|
375
|
-
Category: "
|
|
870
|
+
Category: "sp00ky-client::DataModule::updateQueryLocalArray"
|
|
376
871
|
}, "Query to update local array not found");
|
|
377
872
|
return;
|
|
378
873
|
}
|
|
@@ -387,7 +882,7 @@ var DataModule = class {
|
|
|
387
882
|
if (!queryState) {
|
|
388
883
|
this.logger.warn({
|
|
389
884
|
hash,
|
|
390
|
-
Category: "
|
|
885
|
+
Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
|
|
391
886
|
}, "Query to update remote array not found");
|
|
392
887
|
return;
|
|
393
888
|
}
|
|
@@ -404,8 +899,7 @@ var DataModule = class {
|
|
|
404
899
|
async notifyQuerySynced(queryHash) {
|
|
405
900
|
const queryState = this.activeQueries.get(queryHash);
|
|
406
901
|
if (!queryState) return;
|
|
407
|
-
const
|
|
408
|
-
const newRecords = records || [];
|
|
902
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
409
903
|
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
410
904
|
queryState.records = newRecords;
|
|
411
905
|
if (changed || queryState.updateCount === 0) {
|
|
@@ -431,6 +925,8 @@ var DataModule = class {
|
|
|
431
925
|
max_retries: options?.max_retries ?? 3,
|
|
432
926
|
retry_strategy: options?.retry_strategy ?? "linear"
|
|
433
927
|
};
|
|
928
|
+
if (options?.timeout != null) record.timeout = options.timeout;
|
|
929
|
+
if (options?.delay != null) record.delay = options.delay;
|
|
434
930
|
if (options?.assignedTo) record.assigned_to = options.assignedTo;
|
|
435
931
|
const recordId = `${tableName}:${generateId()}`;
|
|
436
932
|
await this.create(recordId, record);
|
|
@@ -444,7 +940,7 @@ var DataModule = class {
|
|
|
444
940
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
445
941
|
const rid = parseRecordIdString(id);
|
|
446
942
|
const params = parseParams(tableSchema.columns, data);
|
|
447
|
-
const mutationId = parseRecordIdString(`
|
|
943
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
448
944
|
const dataKeys = Object.keys(params).map((key) => ({
|
|
449
945
|
key,
|
|
450
946
|
variable: `data_${key}`
|
|
@@ -474,7 +970,7 @@ var DataModule = class {
|
|
|
474
970
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
475
971
|
this.logger.debug({
|
|
476
972
|
id,
|
|
477
|
-
Category: "
|
|
973
|
+
Category: "sp00ky-client::DataModule::create"
|
|
478
974
|
}, "Record created");
|
|
479
975
|
return target;
|
|
480
976
|
}
|
|
@@ -487,10 +983,10 @@ var DataModule = class {
|
|
|
487
983
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
488
984
|
const rid = parseRecordIdString(id);
|
|
489
985
|
const params = parseParams(tableSchema.columns, data);
|
|
490
|
-
const mutationId = parseRecordIdString(`
|
|
986
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
491
987
|
const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
|
|
492
988
|
const query = surql.seal(surql.tx([
|
|
493
|
-
surql.updateSet("id", [{ statement: "
|
|
989
|
+
surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
|
|
494
990
|
surql.let("updated", surql.updateMerge("id", "data")),
|
|
495
991
|
surql.createMutation("update", "mid", "id", "data"),
|
|
496
992
|
surql.returnObject([{
|
|
@@ -505,14 +1001,14 @@ var DataModule = class {
|
|
|
505
1001
|
}));
|
|
506
1002
|
const updatedFields = { id: target.id };
|
|
507
1003
|
for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
|
|
508
|
-
if ("
|
|
1004
|
+
if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
|
|
509
1005
|
this.replaceRecordInQueries(updatedFields);
|
|
510
1006
|
const parsedRecord = parseParams(tableSchema.columns, target);
|
|
511
1007
|
await this.cache.save({
|
|
512
1008
|
table,
|
|
513
1009
|
op: "UPDATE",
|
|
514
1010
|
record: parsedRecord,
|
|
515
|
-
version: target.
|
|
1011
|
+
version: target._00_rv
|
|
516
1012
|
}, true);
|
|
517
1013
|
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
518
1014
|
const mutationEvent = {
|
|
@@ -527,7 +1023,7 @@ var DataModule = class {
|
|
|
527
1023
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
528
1024
|
this.logger.debug({
|
|
529
1025
|
id,
|
|
530
|
-
Category: "
|
|
1026
|
+
Category: "sp00ky-client::DataModule::update"
|
|
531
1027
|
}, "Record updated");
|
|
532
1028
|
return target;
|
|
533
1029
|
}
|
|
@@ -538,13 +1034,32 @@ var DataModule = class {
|
|
|
538
1034
|
const tableName = extractTablePart(id);
|
|
539
1035
|
if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
|
|
540
1036
|
const rid = parseRecordIdString(id);
|
|
541
|
-
const mutationId = parseRecordIdString(`
|
|
1037
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
1038
|
+
const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
|
|
1039
|
+
const beforeRecord = beforeRecords ?? {};
|
|
542
1040
|
const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
|
|
543
1041
|
await withRetry(this.logger, () => this.local.execute(query, {
|
|
544
1042
|
id: rid,
|
|
545
1043
|
mid: mutationId
|
|
546
1044
|
}));
|
|
547
|
-
|
|
1045
|
+
try {
|
|
1046
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
this.logger.error({
|
|
1049
|
+
err,
|
|
1050
|
+
id,
|
|
1051
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
1052
|
+
}, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
|
|
1053
|
+
}
|
|
1054
|
+
for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
|
|
1055
|
+
await this.notifyQuerySynced(queryHash);
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
this.logger.error({
|
|
1058
|
+
err,
|
|
1059
|
+
queryHash,
|
|
1060
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
1061
|
+
}, "notifyQuerySynced failed after delete");
|
|
1062
|
+
}
|
|
548
1063
|
const mutationEvent = {
|
|
549
1064
|
type: "delete",
|
|
550
1065
|
mutation_id: mutationId,
|
|
@@ -553,7 +1068,7 @@ var DataModule = class {
|
|
|
553
1068
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
554
1069
|
this.logger.debug({
|
|
555
1070
|
id,
|
|
556
|
-
Category: "
|
|
1071
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
557
1072
|
}, "Record deleted");
|
|
558
1073
|
}
|
|
559
1074
|
/**
|
|
@@ -568,14 +1083,14 @@ var DataModule = class {
|
|
|
568
1083
|
this.logger.info({
|
|
569
1084
|
id,
|
|
570
1085
|
tableName,
|
|
571
|
-
Category: "
|
|
1086
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
572
1087
|
}, "Rolled back optimistic create");
|
|
573
1088
|
} catch (err) {
|
|
574
1089
|
this.logger.error({
|
|
575
1090
|
err,
|
|
576
1091
|
id,
|
|
577
1092
|
tableName,
|
|
578
|
-
Category: "
|
|
1093
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
579
1094
|
}, "Failed to rollback create");
|
|
580
1095
|
}
|
|
581
1096
|
}
|
|
@@ -596,20 +1111,20 @@ var DataModule = class {
|
|
|
596
1111
|
table: tableName,
|
|
597
1112
|
op: "UPDATE",
|
|
598
1113
|
record: parsedRecord,
|
|
599
|
-
version: beforeRecord.
|
|
1114
|
+
version: beforeRecord._00_rv || 1
|
|
600
1115
|
}, true);
|
|
601
1116
|
await this.replaceRecordInQueries(beforeRecord);
|
|
602
1117
|
this.logger.info({
|
|
603
1118
|
id,
|
|
604
1119
|
tableName,
|
|
605
|
-
Category: "
|
|
1120
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
606
1121
|
}, "Rolled back optimistic update");
|
|
607
1122
|
} catch (err) {
|
|
608
1123
|
this.logger.error({
|
|
609
1124
|
err,
|
|
610
1125
|
id,
|
|
611
1126
|
tableName,
|
|
612
|
-
Category: "
|
|
1127
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
613
1128
|
}, "Failed to rollback update");
|
|
614
1129
|
}
|
|
615
1130
|
}
|
|
@@ -637,24 +1152,53 @@ var DataModule = class {
|
|
|
637
1152
|
ttl,
|
|
638
1153
|
tableName
|
|
639
1154
|
});
|
|
640
|
-
const
|
|
1155
|
+
const t0 = performance.now();
|
|
1156
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
641
1157
|
queryHash: hash,
|
|
642
1158
|
surql: surqlString,
|
|
643
1159
|
params,
|
|
644
1160
|
ttl: new Duration(ttl),
|
|
645
1161
|
lastActiveAt: /* @__PURE__ */ new Date()
|
|
646
1162
|
});
|
|
647
|
-
|
|
1163
|
+
const registrationTime = performance.now() - t0;
|
|
1164
|
+
queryState.registrationTimings = {
|
|
1165
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
1166
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
1167
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
1168
|
+
wallMs: registrationTime
|
|
1169
|
+
};
|
|
1170
|
+
await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
|
|
1171
|
+
"localArray",
|
|
1172
|
+
"registrationTime",
|
|
1173
|
+
"rowCount"
|
|
1174
|
+
])), {
|
|
648
1175
|
id: recordId,
|
|
649
|
-
localArray
|
|
1176
|
+
localArray,
|
|
1177
|
+
registrationTime,
|
|
1178
|
+
rowCount: localArray.length
|
|
650
1179
|
}));
|
|
1180
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
1181
|
+
if (windowMat && localArray.length > 0) try {
|
|
1182
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
1183
|
+
const [seeded] = await this.local.query(windowMat.query, {
|
|
1184
|
+
...params,
|
|
1185
|
+
__win: winIds
|
|
1186
|
+
});
|
|
1187
|
+
queryState.records = seeded || [];
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
this.logger.warn({
|
|
1190
|
+
err,
|
|
1191
|
+
hash,
|
|
1192
|
+
Category: "sp00ky-client::DataModule::createAndRegisterQuery"
|
|
1193
|
+
}, "Failed to seed windowed initial records from localArray");
|
|
1194
|
+
}
|
|
651
1195
|
this.activeQueries.set(hash, queryState);
|
|
652
|
-
this.startTTLHeartbeat(queryState);
|
|
1196
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
653
1197
|
this.logger.debug({
|
|
654
1198
|
hash,
|
|
655
1199
|
tableName,
|
|
656
1200
|
recordCount: queryState.records.length,
|
|
657
|
-
Category: "
|
|
1201
|
+
Category: "sp00ky-client::DataModule::query"
|
|
658
1202
|
}, "Query registered");
|
|
659
1203
|
return hash;
|
|
660
1204
|
}
|
|
@@ -671,8 +1215,12 @@ var DataModule = class {
|
|
|
671
1215
|
localArray: [],
|
|
672
1216
|
remoteArray: [],
|
|
673
1217
|
lastActiveAt: /* @__PURE__ */ new Date(),
|
|
1218
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
674
1219
|
ttl,
|
|
675
|
-
tableName
|
|
1220
|
+
tableName,
|
|
1221
|
+
updateCount: 0,
|
|
1222
|
+
rowCount: 0,
|
|
1223
|
+
errorCount: 0
|
|
676
1224
|
}
|
|
677
1225
|
}));
|
|
678
1226
|
configRecord = createdRecord;
|
|
@@ -683,46 +1231,67 @@ var DataModule = class {
|
|
|
683
1231
|
params: parseParams(tableSchema.columns, configRecord.params)
|
|
684
1232
|
};
|
|
685
1233
|
let records = [];
|
|
686
|
-
try {
|
|
1234
|
+
if (buildWindowMaterialization(surqlString) === null) try {
|
|
687
1235
|
const [result] = await this.local.query(surqlString, params);
|
|
688
1236
|
records = result || [];
|
|
689
1237
|
} catch (err) {
|
|
690
1238
|
this.logger.warn({
|
|
691
1239
|
err,
|
|
692
|
-
Category: "
|
|
1240
|
+
Category: "sp00ky-client::DataModule::createNewQuery"
|
|
693
1241
|
}, "Failed to load initial cached records");
|
|
694
1242
|
}
|
|
1243
|
+
const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
|
|
1244
|
+
const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
|
|
695
1245
|
return {
|
|
696
1246
|
config,
|
|
697
1247
|
records,
|
|
698
1248
|
ttlTimer: null,
|
|
699
1249
|
ttlDurationMs: parseDuration(ttl),
|
|
700
|
-
updateCount:
|
|
1250
|
+
updateCount: persistedUpdateCount,
|
|
1251
|
+
materializationSamples: [],
|
|
1252
|
+
lastIngestLatencyMs: null,
|
|
1253
|
+
errorCount: persistedErrorCount,
|
|
1254
|
+
status: "idle",
|
|
1255
|
+
phaseSamples: {},
|
|
1256
|
+
phaseLast: {},
|
|
1257
|
+
registrationTimings: {
|
|
1258
|
+
parseMs: null,
|
|
1259
|
+
planMs: null,
|
|
1260
|
+
snapshotMs: null,
|
|
1261
|
+
wallMs: null
|
|
1262
|
+
}
|
|
701
1263
|
};
|
|
702
1264
|
}
|
|
703
1265
|
async calculateHash(data) {
|
|
704
|
-
const content = JSON.stringify(
|
|
1266
|
+
const content = JSON.stringify({
|
|
1267
|
+
...data,
|
|
1268
|
+
sessionId: this.sessionId
|
|
1269
|
+
});
|
|
705
1270
|
const msgBuffer = new TextEncoder().encode(content);
|
|
706
1271
|
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
|
707
1272
|
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
708
1273
|
}
|
|
709
|
-
startTTLHeartbeat(queryState) {
|
|
1274
|
+
startTTLHeartbeat(queryState, hash) {
|
|
710
1275
|
if (queryState.ttlTimer) return;
|
|
711
1276
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
|
|
712
1277
|
queryState.ttlTimer = setTimeout(() => {
|
|
1278
|
+
queryState.ttlTimer = null;
|
|
1279
|
+
if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
|
|
1280
|
+
this.logger.debug({
|
|
1281
|
+
hash,
|
|
1282
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
1283
|
+
}, "TTL heartbeat: no subscribers, stopping");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
this.onHeartbeat?.(hash);
|
|
713
1287
|
this.logger.debug({
|
|
1288
|
+
hash,
|
|
714
1289
|
id: encodeRecordId(queryState.config.id),
|
|
715
|
-
Category: "
|
|
716
|
-
}, "TTL heartbeat");
|
|
717
|
-
this.startTTLHeartbeat(queryState);
|
|
1290
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
1291
|
+
}, "TTL heartbeat sent");
|
|
1292
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
718
1293
|
}, heartbeatTime);
|
|
719
1294
|
}
|
|
720
|
-
stopTTLHeartbeat(queryState) {
|
|
721
|
-
if (queryState.ttlTimer) {
|
|
722
|
-
clearTimeout(queryState.ttlTimer);
|
|
723
|
-
queryState.ttlTimer = null;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
1295
|
async replaceRecordInQueries(record) {
|
|
727
1296
|
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
728
1297
|
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
@@ -744,7 +1313,7 @@ function parseUpdateOptions(id, data, options) {
|
|
|
744
1313
|
let pushEventOptions = {};
|
|
745
1314
|
if (options?.debounced) pushEventOptions = { debounced: {
|
|
746
1315
|
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).
|
|
1316
|
+
key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
|
|
748
1317
|
} };
|
|
749
1318
|
return pushEventOptions;
|
|
750
1319
|
}
|
|
@@ -781,7 +1350,7 @@ var AbstractDatabaseService = class {
|
|
|
781
1350
|
this.logger.debug({
|
|
782
1351
|
query,
|
|
783
1352
|
vars,
|
|
784
|
-
Category: "
|
|
1353
|
+
Category: "sp00ky-client::Database::query"
|
|
785
1354
|
}, "Executing query");
|
|
786
1355
|
const result = await this.client.query(query, vars);
|
|
787
1356
|
const duration = performance.now() - startTime;
|
|
@@ -796,7 +1365,7 @@ var AbstractDatabaseService = class {
|
|
|
796
1365
|
this.logger.trace({
|
|
797
1366
|
query,
|
|
798
1367
|
result,
|
|
799
|
-
Category: "
|
|
1368
|
+
Category: "sp00ky-client::Database::query"
|
|
800
1369
|
}, "Query executed successfully");
|
|
801
1370
|
} catch (err) {
|
|
802
1371
|
const duration = performance.now() - startTime;
|
|
@@ -812,7 +1381,7 @@ var AbstractDatabaseService = class {
|
|
|
812
1381
|
query,
|
|
813
1382
|
vars,
|
|
814
1383
|
err,
|
|
815
|
-
Category: "
|
|
1384
|
+
Category: "sp00ky-client::Database::query"
|
|
816
1385
|
}, "Query execution failed");
|
|
817
1386
|
reject(err);
|
|
818
1387
|
}
|
|
@@ -824,7 +1393,7 @@ var AbstractDatabaseService = class {
|
|
|
824
1393
|
return query.extract(raw);
|
|
825
1394
|
}
|
|
826
1395
|
async close() {
|
|
827
|
-
this.logger.info({ Category: "
|
|
1396
|
+
this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
|
|
828
1397
|
await this.client.close();
|
|
829
1398
|
}
|
|
830
1399
|
};
|
|
@@ -1004,7 +1573,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
1004
1573
|
type,
|
|
1005
1574
|
phase,
|
|
1006
1575
|
service: "surrealdb:local",
|
|
1007
|
-
Category: "
|
|
1576
|
+
Category: "sp00ky-client::LocalDatabaseService::diagnostics"
|
|
1008
1577
|
}, `Local SurrealDB diagnostics captured ${type}:${phase}`);
|
|
1009
1578
|
})
|
|
1010
1579
|
}), logger, events);
|
|
@@ -1015,38 +1584,153 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
1015
1584
|
}
|
|
1016
1585
|
async connect() {
|
|
1017
1586
|
const { namespace, database } = this.getConfig();
|
|
1587
|
+
const store = this.getConfig().store ?? "memory";
|
|
1588
|
+
const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
|
|
1018
1589
|
this.logger.info({
|
|
1019
1590
|
namespace,
|
|
1020
1591
|
database,
|
|
1021
|
-
|
|
1592
|
+
storeUrl,
|
|
1593
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1022
1594
|
}, "Connecting to local database");
|
|
1595
|
+
this.registerUnloadClose();
|
|
1023
1596
|
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");
|
|
1597
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1598
|
+
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
|
|
1599
|
+
return;
|
|
1041
1600
|
} catch (err) {
|
|
1042
|
-
|
|
1601
|
+
if (store === "memory" || !isLocalStoreOpenError(err)) {
|
|
1602
|
+
this.logger.error({
|
|
1603
|
+
err,
|
|
1604
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1605
|
+
}, "Failed to connect to local database");
|
|
1606
|
+
throw err;
|
|
1607
|
+
}
|
|
1608
|
+
this.logger.warn({
|
|
1043
1609
|
err,
|
|
1044
|
-
Category: "
|
|
1045
|
-
}, "
|
|
1046
|
-
|
|
1610
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1611
|
+
}, "Local IndexedDB store failed to open; retrying before clearing");
|
|
1612
|
+
}
|
|
1613
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1614
|
+
try {
|
|
1615
|
+
await this.client.close();
|
|
1616
|
+
} catch {}
|
|
1617
|
+
await delay(150 * attempt);
|
|
1618
|
+
try {
|
|
1619
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1620
|
+
this.logger.info({
|
|
1621
|
+
attempt,
|
|
1622
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1623
|
+
}, "Connected to local database on retry (cache preserved)");
|
|
1624
|
+
return;
|
|
1625
|
+
} catch (retryErr) {
|
|
1626
|
+
this.logger.warn({
|
|
1627
|
+
err: retryErr,
|
|
1628
|
+
attempt,
|
|
1629
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1630
|
+
}, "Local store retry failed");
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
try {
|
|
1634
|
+
await this.client.close();
|
|
1635
|
+
} catch {}
|
|
1636
|
+
await dropLocalIndexedDbStores(this.logger);
|
|
1637
|
+
try {
|
|
1638
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1639
|
+
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
|
|
1640
|
+
} catch (retryErr) {
|
|
1641
|
+
this.logger.error({
|
|
1642
|
+
err: retryErr,
|
|
1643
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1644
|
+
}, "Local store still failing after clear; falling back to in-memory");
|
|
1645
|
+
try {
|
|
1646
|
+
await this.client.close();
|
|
1647
|
+
} catch {}
|
|
1648
|
+
await this.openStore("mem://", namespace, database);
|
|
1649
|
+
this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
|
|
1047
1650
|
}
|
|
1048
1651
|
}
|
|
1652
|
+
unloadCloseRegistered = false;
|
|
1653
|
+
/**
|
|
1654
|
+
* Close the local DB on page unload so the SurrealDB-WASM worker releases its
|
|
1655
|
+
* IndexedDB connection cleanly. Without this, the previous page's connection
|
|
1656
|
+
* lingers; the next load's `client.connect` opens the store but the first
|
|
1657
|
+
* write transaction in `client.use` hits an "IndexedDB error" — which then
|
|
1658
|
+
* (mis)triggered the corrupt-store recovery and WIPED the cache on every
|
|
1659
|
+
* reload, making warm loads as slow as cold ones. `pagehide` is the reliable
|
|
1660
|
+
* unload signal (fires on bfcache + normal navigation); `close()` is async but
|
|
1661
|
+
* the WASM worker initiates the IndexedDB connection teardown synchronously.
|
|
1662
|
+
*/
|
|
1663
|
+
registerUnloadClose() {
|
|
1664
|
+
if (this.unloadCloseRegistered || typeof window === "undefined") return;
|
|
1665
|
+
this.unloadCloseRegistered = true;
|
|
1666
|
+
const close = () => {
|
|
1667
|
+
try {
|
|
1668
|
+
this.client.close();
|
|
1669
|
+
} catch {}
|
|
1670
|
+
};
|
|
1671
|
+
window.addEventListener("pagehide", close);
|
|
1672
|
+
window.addEventListener("beforeunload", close);
|
|
1673
|
+
}
|
|
1674
|
+
async openStore(storeUrl, namespace, database) {
|
|
1675
|
+
this.logger.debug({
|
|
1676
|
+
storeUrl,
|
|
1677
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1678
|
+
}, "[LocalDatabaseService] Calling client.connect");
|
|
1679
|
+
await this.client.connect(storeUrl, {});
|
|
1680
|
+
this.logger.debug({
|
|
1681
|
+
namespace,
|
|
1682
|
+
database,
|
|
1683
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1684
|
+
}, "[LocalDatabaseService] client.connect returned. Calling client.use");
|
|
1685
|
+
await this.client.use({
|
|
1686
|
+
namespace,
|
|
1687
|
+
database
|
|
1688
|
+
});
|
|
1689
|
+
this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
1690
|
+
}
|
|
1049
1691
|
};
|
|
1692
|
+
function delay(ms) {
|
|
1693
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1694
|
+
}
|
|
1695
|
+
/** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
|
|
1696
|
+
* store can't be opened (corrupt / version-incompatible / blocked). Exported
|
|
1697
|
+
* for unit testing the error-message match. */
|
|
1698
|
+
function isLocalStoreOpenError(err) {
|
|
1699
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
1700
|
+
return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
|
|
1701
|
+
}
|
|
1702
|
+
/** Best-effort delete of this client's IndexedDB store(s). The persistent local
|
|
1703
|
+
* DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
|
|
1704
|
+
* IndexedDB databases whose names include `sp00ky`. Resolves even on
|
|
1705
|
+
* error/blocked so startup can proceed. No-op outside a browser. */
|
|
1706
|
+
async function dropLocalIndexedDbStores(logger) {
|
|
1707
|
+
if (typeof indexedDB === "undefined") return;
|
|
1708
|
+
const remove = (name) => new Promise((resolve) => {
|
|
1709
|
+
try {
|
|
1710
|
+
const req = indexedDB.deleteDatabase(name);
|
|
1711
|
+
req.onsuccess = () => resolve();
|
|
1712
|
+
req.onerror = () => resolve();
|
|
1713
|
+
req.onblocked = () => resolve();
|
|
1714
|
+
} catch {
|
|
1715
|
+
resolve();
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
try {
|
|
1719
|
+
let names = [];
|
|
1720
|
+
if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && n.toLowerCase().includes("sp00ky"));
|
|
1721
|
+
if (names.length === 0) names = ["sp00ky"];
|
|
1722
|
+
await Promise.all(names.map(remove));
|
|
1723
|
+
logger.info({
|
|
1724
|
+
names,
|
|
1725
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1726
|
+
}, "Cleared local IndexedDB store(s)");
|
|
1727
|
+
} catch (e) {
|
|
1728
|
+
logger.warn({
|
|
1729
|
+
err: e,
|
|
1730
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1731
|
+
}, "Failed to enumerate/clear IndexedDB; proceeding anyway");
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1050
1734
|
|
|
1051
1735
|
//#endregion
|
|
1052
1736
|
//#region src/services/database/remote.ts
|
|
@@ -1062,7 +1746,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1062
1746
|
type,
|
|
1063
1747
|
phase,
|
|
1064
1748
|
service: "surrealdb:remote",
|
|
1065
|
-
Category: "
|
|
1749
|
+
Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
|
|
1066
1750
|
}, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
|
|
1067
1751
|
}) }), logger, events);
|
|
1068
1752
|
this.config = config;
|
|
@@ -1077,7 +1761,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1077
1761
|
endpoint,
|
|
1078
1762
|
namespace,
|
|
1079
1763
|
database,
|
|
1080
|
-
Category: "
|
|
1764
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1081
1765
|
}, "Connecting to remote database");
|
|
1082
1766
|
try {
|
|
1083
1767
|
await this.client.connect(endpoint);
|
|
@@ -1086,18 +1770,18 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1086
1770
|
database
|
|
1087
1771
|
});
|
|
1088
1772
|
if (token) {
|
|
1089
|
-
this.logger.debug({ Category: "
|
|
1773
|
+
this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
|
|
1090
1774
|
await this.client.authenticate(token);
|
|
1091
1775
|
}
|
|
1092
|
-
this.logger.info({ Category: "
|
|
1776
|
+
this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
|
|
1093
1777
|
} catch (err) {
|
|
1094
1778
|
this.logger.error({
|
|
1095
1779
|
err,
|
|
1096
|
-
Category: "
|
|
1780
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1097
1781
|
}, "Failed to connect to remote database");
|
|
1098
1782
|
throw err;
|
|
1099
1783
|
}
|
|
1100
|
-
} else this.logger.warn({ Category: "
|
|
1784
|
+
} else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
|
|
1101
1785
|
}
|
|
1102
1786
|
async signin(params) {
|
|
1103
1787
|
return this.client.signin(params);
|
|
@@ -1115,70 +1799,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1115
1799
|
|
|
1116
1800
|
//#endregion
|
|
1117
1801
|
//#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) {
|
|
1802
|
+
function createLogger(level = "info", transmit) {
|
|
1131
1803
|
const browserConfig = {
|
|
1132
1804
|
asObject: true,
|
|
1133
1805
|
write: (o) => {
|
|
1134
1806
|
console.log(JSON.stringify(o));
|
|
1135
1807
|
}
|
|
1136
1808
|
};
|
|
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
|
-
}
|
|
1809
|
+
if (transmit) browserConfig.transmit = transmit;
|
|
1182
1810
|
return pino({
|
|
1183
1811
|
level,
|
|
1184
1812
|
browser: browserConfig
|
|
@@ -1203,25 +1831,25 @@ var LocalMigrator = class {
|
|
|
1203
1831
|
const hash = await sha1(schemaSurql);
|
|
1204
1832
|
const { database } = this.localDb.getConfig();
|
|
1205
1833
|
if (await this.isSchemaUpToDate(hash)) {
|
|
1206
|
-
this.logger.info({ Category: "
|
|
1834
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
|
|
1207
1835
|
return;
|
|
1208
1836
|
}
|
|
1209
1837
|
await this.recreateDatabase(database);
|
|
1210
|
-
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS
|
|
1838
|
+
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
1839
|
const statements = this.splitStatements(fullSchema);
|
|
1212
1840
|
for (let i = 0; i < statements.length; i++) {
|
|
1213
1841
|
const statement = statements[i];
|
|
1214
1842
|
const cleanStatement = statement.replace(/--.*/g, "").trim();
|
|
1215
1843
|
if (cleanStatement.toUpperCase().startsWith("DEFINE INDEX")) {
|
|
1216
|
-
this.logger.warn({ Category: "
|
|
1844
|
+
this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
|
|
1217
1845
|
continue;
|
|
1218
1846
|
}
|
|
1219
1847
|
try {
|
|
1220
|
-
this.logger.info({ Category: "
|
|
1848
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
|
|
1221
1849
|
await this.localDb.query(statement);
|
|
1222
|
-
this.logger.info({ Category: "
|
|
1850
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
|
|
1223
1851
|
} catch (e) {
|
|
1224
|
-
this.logger.error({ Category: "
|
|
1852
|
+
this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
|
|
1225
1853
|
throw e;
|
|
1226
1854
|
}
|
|
1227
1855
|
}
|
|
@@ -1229,22 +1857,22 @@ var LocalMigrator = class {
|
|
|
1229
1857
|
}
|
|
1230
1858
|
async isSchemaUpToDate(hash) {
|
|
1231
1859
|
try {
|
|
1232
|
-
const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY
|
|
1860
|
+
const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
|
|
1233
1861
|
return lastSchemaRecord?.hash === hash;
|
|
1234
|
-
} catch (
|
|
1862
|
+
} catch (_error) {
|
|
1235
1863
|
return false;
|
|
1236
1864
|
}
|
|
1237
1865
|
}
|
|
1238
1866
|
async recreateDatabase(database) {
|
|
1239
1867
|
try {
|
|
1240
|
-
await this.localDb.query(`DEFINE DATABASE
|
|
1241
|
-
} catch (
|
|
1868
|
+
await this.localDb.query(`DEFINE DATABASE _00_temp;`);
|
|
1869
|
+
} catch (_e) {}
|
|
1242
1870
|
try {
|
|
1243
1871
|
await this.localDb.query(`
|
|
1244
|
-
USE DB
|
|
1872
|
+
USE DB _00_temp;
|
|
1245
1873
|
REMOVE DATABASE ${database};
|
|
1246
1874
|
`);
|
|
1247
|
-
} catch (
|
|
1875
|
+
} catch (_e) {}
|
|
1248
1876
|
await this.localDb.query(`
|
|
1249
1877
|
DEFINE DATABASE ${database};
|
|
1250
1878
|
USE DB ${database};
|
|
@@ -1302,7 +1930,7 @@ var LocalMigrator = class {
|
|
|
1302
1930
|
return statements;
|
|
1303
1931
|
}
|
|
1304
1932
|
async createHashRecord(hash) {
|
|
1305
|
-
await this.localDb.query(`UPSERT
|
|
1933
|
+
await this.localDb.query(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
|
|
1306
1934
|
}
|
|
1307
1935
|
};
|
|
1308
1936
|
|
|
@@ -1323,13 +1951,15 @@ function createSyncQueueEventSystem() {
|
|
|
1323
1951
|
const SyncEventTypes = {
|
|
1324
1952
|
QueryUpdated: "SYNC_QUERY_UPDATED",
|
|
1325
1953
|
RemoteDataIngested: "SYNC_REMOTE_DATA_INGESTED",
|
|
1326
|
-
MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK"
|
|
1954
|
+
MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK",
|
|
1955
|
+
SyncHealthChanged: "SYNC_HEALTH_CHANGED"
|
|
1327
1956
|
};
|
|
1328
1957
|
function createSyncEventSystem() {
|
|
1329
1958
|
return createEventSystem([
|
|
1330
1959
|
SyncEventTypes.QueryUpdated,
|
|
1331
1960
|
SyncEventTypes.RemoteDataIngested,
|
|
1332
|
-
SyncEventTypes.MutationRolledBack
|
|
1961
|
+
SyncEventTypes.MutationRolledBack,
|
|
1962
|
+
SyncEventTypes.SyncHealthChanged
|
|
1333
1963
|
]);
|
|
1334
1964
|
}
|
|
1335
1965
|
|
|
@@ -1394,7 +2024,7 @@ var UpQueue = class {
|
|
|
1394
2024
|
this.logger.error({
|
|
1395
2025
|
error,
|
|
1396
2026
|
event,
|
|
1397
|
-
Category: "
|
|
2027
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1398
2028
|
}, "Network error processing mutation, re-queuing");
|
|
1399
2029
|
this.queue.unshift(event);
|
|
1400
2030
|
throw error;
|
|
@@ -1402,7 +2032,7 @@ var UpQueue = class {
|
|
|
1402
2032
|
this.logger.error({
|
|
1403
2033
|
error,
|
|
1404
2034
|
event,
|
|
1405
|
-
Category: "
|
|
2035
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1406
2036
|
}, "Application error processing mutation, rolling back");
|
|
1407
2037
|
try {
|
|
1408
2038
|
await this.removeEventFromDatabase(event.mutation_id);
|
|
@@ -1410,7 +2040,7 @@ var UpQueue = class {
|
|
|
1410
2040
|
this.logger.error({
|
|
1411
2041
|
error: removeError,
|
|
1412
2042
|
event,
|
|
1413
|
-
Category: "
|
|
2043
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1414
2044
|
}, "Failed to remove rolled-back mutation from database");
|
|
1415
2045
|
}
|
|
1416
2046
|
if (onRollback) try {
|
|
@@ -1419,7 +2049,7 @@ var UpQueue = class {
|
|
|
1419
2049
|
this.logger.error({
|
|
1420
2050
|
error: rollbackError,
|
|
1421
2051
|
event,
|
|
1422
|
-
Category: "
|
|
2052
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1423
2053
|
}, "Rollback handler failed");
|
|
1424
2054
|
}
|
|
1425
2055
|
this._events.addEvent({
|
|
@@ -1434,7 +2064,7 @@ var UpQueue = class {
|
|
|
1434
2064
|
this.logger.error({
|
|
1435
2065
|
error,
|
|
1436
2066
|
event,
|
|
1437
|
-
Category: "
|
|
2067
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1438
2068
|
}, "Failed to remove mutation from database after successful processing");
|
|
1439
2069
|
}
|
|
1440
2070
|
this._events.addEvent({
|
|
@@ -1448,7 +2078,7 @@ var UpQueue = class {
|
|
|
1448
2078
|
}
|
|
1449
2079
|
async loadFromDatabase() {
|
|
1450
2080
|
try {
|
|
1451
|
-
const [records] = await this.local.query(`SELECT * FROM
|
|
2081
|
+
const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY created_at ASC`);
|
|
1452
2082
|
this.queue = records.map((r) => {
|
|
1453
2083
|
switch (r.mutationType) {
|
|
1454
2084
|
case "create": return {
|
|
@@ -1474,7 +2104,7 @@ var UpQueue = class {
|
|
|
1474
2104
|
this.logger.warn({
|
|
1475
2105
|
mutationType: r.mutationType,
|
|
1476
2106
|
record: r,
|
|
1477
|
-
Category: "
|
|
2107
|
+
Category: "sp00ky-client::UpQueue::loadFromDatabase"
|
|
1478
2108
|
}, "Unknown mutation type");
|
|
1479
2109
|
return null;
|
|
1480
2110
|
}
|
|
@@ -1482,7 +2112,7 @@ var UpQueue = class {
|
|
|
1482
2112
|
} catch (error) {
|
|
1483
2113
|
this.logger.error({
|
|
1484
2114
|
error,
|
|
1485
|
-
Category: "
|
|
2115
|
+
Category: "sp00ky-client::UpQueue::loadFromDatabase"
|
|
1486
2116
|
}, "Failed to load pending mutations from database");
|
|
1487
2117
|
}
|
|
1488
2118
|
}
|
|
@@ -1523,7 +2153,7 @@ var DownQueue = class {
|
|
|
1523
2153
|
this.logger.error({
|
|
1524
2154
|
error,
|
|
1525
2155
|
event,
|
|
1526
|
-
Category: "
|
|
2156
|
+
Category: "sp00ky-client::DownQueue::next"
|
|
1527
2157
|
}, "Failed to process query");
|
|
1528
2158
|
this.queue.unshift(event);
|
|
1529
2159
|
throw error;
|
|
@@ -1538,8 +2168,8 @@ var ArraySyncer = class {
|
|
|
1538
2168
|
remoteArray;
|
|
1539
2169
|
needsSort = false;
|
|
1540
2170
|
constructor(localArray, remoteArray) {
|
|
1541
|
-
this.remoteArray = remoteArray.
|
|
1542
|
-
this.localArray = localArray.
|
|
2171
|
+
this.remoteArray = remoteArray.toSorted((a, b) => a[0].localeCompare(b[0]));
|
|
2172
|
+
this.localArray = localArray.toSorted((a, b) => a[0].localeCompare(b[0]));
|
|
1543
2173
|
}
|
|
1544
2174
|
/**
|
|
1545
2175
|
* Inserts an item into the local array
|
|
@@ -1575,7 +2205,6 @@ var ArraySyncer = class {
|
|
|
1575
2205
|
this.localArray.sort((a, b) => a[0].localeCompare(b[0]));
|
|
1576
2206
|
this.needsSort = false;
|
|
1577
2207
|
}
|
|
1578
|
-
console.log("xxxx555", this.localArray, this.remoteArray);
|
|
1579
2208
|
return diffRecordVersionArray(this.localArray, this.remoteArray);
|
|
1580
2209
|
}
|
|
1581
2210
|
};
|
|
@@ -1606,14 +2235,12 @@ function diffRecordVersionArray(local, remote) {
|
|
|
1606
2235
|
};
|
|
1607
2236
|
}
|
|
1608
2237
|
function createDiffFromDbOp(op, recordId, version, versions) {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
};
|
|
1616
|
-
}
|
|
2238
|
+
const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
|
|
2239
|
+
if (old && old[1] >= version) return {
|
|
2240
|
+
added: [],
|
|
2241
|
+
updated: [],
|
|
2242
|
+
removed: []
|
|
2243
|
+
};
|
|
1617
2244
|
if (op === "CREATE") return {
|
|
1618
2245
|
added: [{
|
|
1619
2246
|
id: recordId,
|
|
@@ -1636,6 +2263,98 @@ function createDiffFromDbOp(op, recordId, version, versions) {
|
|
|
1636
2263
|
removed: [recordId]
|
|
1637
2264
|
};
|
|
1638
2265
|
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Default cadence for the `_00_list_ref_user_<id>` poll fallback. The
|
|
2268
|
+
* poll is the safety net for SurrealDB v3's occasionally-dropped LIVE
|
|
2269
|
+
* deliveries; 500ms is aggressive enough to feel real-time on the
|
|
2270
|
+
* happy path while keeping the per-session query load bounded.
|
|
2271
|
+
*/
|
|
2272
|
+
const DEFAULT_LIST_REF_POLL_INTERVAL_MS = 500;
|
|
2273
|
+
/**
|
|
2274
|
+
* Build the SurrealQL select that powers both the initial-fetch and
|
|
2275
|
+
* the periodic poll of `_00_list_ref[_user_<id>]`. The `parent IS NONE`
|
|
2276
|
+
* predicate excludes subquery entries (rows with `parent_rel` set)
|
|
2277
|
+
* because the client's `RecordVersionArray` only tracks primary rows;
|
|
2278
|
+
* including subquery rows would surface them as spurious "added"
|
|
2279
|
+
* diffs every tick.
|
|
2280
|
+
*/
|
|
2281
|
+
function buildListRefSelect(table) {
|
|
2282
|
+
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Build the SurrealQL select for a query's SUBQUERY child edges — the
|
|
2286
|
+
* mirror of {@link buildListRefSelect}. `.related()` queries register a
|
|
2287
|
+
* correlated subquery; the SSP materializes each matched child as a
|
|
2288
|
+
* `_00_list_ref` edge tagged with `parent`/`parent_rel` (see
|
|
2289
|
+
* `apps/ssp` edge writer). `parent IS NONE` (the primary select) drops
|
|
2290
|
+
* these, so their bodies never reach the local cache and a cold-reload
|
|
2291
|
+
* re-materialization of the correlated surql yields empty related
|
|
2292
|
+
* fields. This `parent IS NOT NONE` variant pulls the child `out`+`version`
|
|
2293
|
+
* pairs (any nesting depth) so we can sync their bodies into the local
|
|
2294
|
+
* store SEPARATELY from the primary window array.
|
|
2295
|
+
*/
|
|
2296
|
+
function buildSubqueryListRefSelect(table) {
|
|
2297
|
+
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Resolve the effective list-ref poll interval. Negative or zero
|
|
2301
|
+
* values fall back to the default — accepting them would either
|
|
2302
|
+
* disable polling silently or busy-loop the event loop.
|
|
2303
|
+
*/
|
|
2304
|
+
function resolveListRefPollInterval(opt) {
|
|
2305
|
+
if (typeof opt !== "number" || !Number.isFinite(opt) || opt <= 0) return DEFAULT_LIST_REF_POLL_INTERVAL_MS;
|
|
2306
|
+
return opt;
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Ceiling for the adaptive list_ref poll backoff. An idle page (no LIVE
|
|
2310
|
+
* events, no poll-detected list_ref changes) coasts up to this cadence;
|
|
2311
|
+
* the existing healthy-LIVE safety net runs at the same 5s, so this keeps
|
|
2312
|
+
* the worst-case catch-up latency for a missed cross-session change at the
|
|
2313
|
+
* cadence the codebase already treats as acceptable.
|
|
2314
|
+
*/
|
|
2315
|
+
const LIST_REF_POLL_MAX_INTERVAL_MS = 5e3;
|
|
2316
|
+
/**
|
|
2317
|
+
* Adaptive poll delay: stay at the responsive `baseIntervalMs` while
|
|
2318
|
+
* changes are arriving, and exponentially back off toward `maxIntervalMs`
|
|
2319
|
+
* while the `_00_list_ref` is quiet.
|
|
2320
|
+
*
|
|
2321
|
+
* `idleStreak` is the count of consecutive poll cycles that observed *no*
|
|
2322
|
+
* change. `Sp00kySync` resets it to 0 whenever a poll detects a real
|
|
2323
|
+
* remoteArray change OR a LIVE event lands, so any activity snaps the poll
|
|
2324
|
+
* straight back to `baseIntervalMs`. A streak of 0 (something just
|
|
2325
|
+
* happened) → base; otherwise `base * 2^streak` capped at `maxIntervalMs`.
|
|
2326
|
+
*
|
|
2327
|
+
* This replaces {@link nextPollDelayMs}: the old helper slowed the poll
|
|
2328
|
+
* only while LIVE was *delivering*, but the cross-session LIVE-permission
|
|
2329
|
+
* gap means LIVE frequently never fires here, so it left a fully idle page
|
|
2330
|
+
* polling every `base` ms forever (the "continuous queries while idle"
|
|
2331
|
+
* symptom). Backing off on observed idleness instead covers the
|
|
2332
|
+
* LIVE-healthy case for free (LIVE applies the change → the next poll sees
|
|
2333
|
+
* nothing new → the streak grows → it backs off).
|
|
2334
|
+
*/
|
|
2335
|
+
function listRefPollDelayMs(args) {
|
|
2336
|
+
const { idleStreak, baseIntervalMs, maxIntervalMs = LIST_REF_POLL_MAX_INTERVAL_MS } = args;
|
|
2337
|
+
const cap = Math.max(baseIntervalMs, maxIntervalMs);
|
|
2338
|
+
if (idleStreak <= 0) return baseIntervalMs;
|
|
2339
|
+
const exponent = Math.min(idleStreak, 30);
|
|
2340
|
+
return Math.min(baseIntervalMs * 2 ** exponent, cap);
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Order-insensitive equality for two `RecordVersionArray`s (each a list of
|
|
2344
|
+
* `[recordIdString, version]`). The `_00_list_ref` SELECT has no `ORDER
|
|
2345
|
+
* BY`, so row order can differ between polls without anything having
|
|
2346
|
+
* actually changed — comparing as an id→version map avoids false
|
|
2347
|
+
* "changed" verdicts that would defeat the idle backoff. Record ids are
|
|
2348
|
+
* unique within a query's list_ref, so a map is a faithful representation.
|
|
2349
|
+
*/
|
|
2350
|
+
function recordVersionArraysEqual(a, b) {
|
|
2351
|
+
if (a === b) return true;
|
|
2352
|
+
if (a.length !== b.length) return false;
|
|
2353
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2354
|
+
for (const [id, version] of a) byId.set(id, version);
|
|
2355
|
+
for (const [id, version] of b) if (byId.get(id) !== version) return false;
|
|
2356
|
+
return true;
|
|
2357
|
+
}
|
|
1639
2358
|
|
|
1640
2359
|
//#endregion
|
|
1641
2360
|
//#region src/modules/sync/engine.ts
|
|
@@ -1643,7 +2362,7 @@ function createDiffFromDbOp(op, recordId, version, versions) {
|
|
|
1643
2362
|
* SyncEngine handles the core sync operations: fetching remote records,
|
|
1644
2363
|
* caching them locally, and ingesting into DBSP.
|
|
1645
2364
|
*
|
|
1646
|
-
* This is extracted from
|
|
2365
|
+
* This is extracted from Sp00kySync to separate "how to sync" from "when to sync".
|
|
1647
2366
|
*/
|
|
1648
2367
|
var SyncEngine = class {
|
|
1649
2368
|
logger;
|
|
@@ -1652,7 +2371,7 @@ var SyncEngine = class {
|
|
|
1652
2371
|
this.remote = remote;
|
|
1653
2372
|
this.cache = cache;
|
|
1654
2373
|
this.schema = schema;
|
|
1655
|
-
this.logger = logger.child({ service: "
|
|
2374
|
+
this.logger = logger.child({ service: "Sp00kySync:SyncEngine" });
|
|
1656
2375
|
}
|
|
1657
2376
|
/**
|
|
1658
2377
|
* Sync missing/updated/removed records between local and remote.
|
|
@@ -1665,33 +2384,42 @@ var SyncEngine = class {
|
|
|
1665
2384
|
added,
|
|
1666
2385
|
updated,
|
|
1667
2386
|
removed,
|
|
1668
|
-
Category: "
|
|
2387
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
1669
2388
|
}, "SyncEngine.syncRecords diff");
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
const
|
|
1674
|
-
|
|
2389
|
+
let stillRemoteIds = [];
|
|
2390
|
+
if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
|
|
2391
|
+
const toFetch = [...added, ...updated];
|
|
2392
|
+
const idsToFetch = toFetch.map((x) => x.id);
|
|
2393
|
+
if (idsToFetch.length === 0) return {
|
|
2394
|
+
remoteFetchMs: 0,
|
|
2395
|
+
stillRemoteIds
|
|
2396
|
+
};
|
|
2397
|
+
const versionMap = /* @__PURE__ */ new Map();
|
|
2398
|
+
for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
|
|
2399
|
+
const remoteFetchStart = performance.now();
|
|
2400
|
+
const [remoteResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
|
|
2401
|
+
const remoteFetchMs = performance.now() - remoteFetchStart;
|
|
1675
2402
|
const cacheBatch = [];
|
|
1676
|
-
for (const
|
|
2403
|
+
for (const record of remoteResults) {
|
|
1677
2404
|
if (!record?.id) {
|
|
1678
2405
|
this.logger.warn({
|
|
1679
2406
|
record,
|
|
1680
2407
|
idsToFetch,
|
|
1681
|
-
Category: "
|
|
1682
|
-
}, "Remote record has no id. Skipping record");
|
|
2408
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
2409
|
+
}, "Remote record has no id (possibly deleted). Skipping record");
|
|
1683
2410
|
continue;
|
|
1684
2411
|
}
|
|
1685
2412
|
const fullId = encodeRecordId(record.id);
|
|
1686
2413
|
const table = record.id.table.toString();
|
|
1687
2414
|
const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
|
|
2415
|
+
const version = versionMap.get(fullId) ?? 0;
|
|
1688
2416
|
const localVersion = this.cache.lookup(fullId);
|
|
1689
|
-
if (localVersion &&
|
|
2417
|
+
if (localVersion && version <= localVersion) {
|
|
1690
2418
|
this.logger.info({
|
|
1691
2419
|
recordId: fullId,
|
|
1692
|
-
version
|
|
2420
|
+
version,
|
|
1693
2421
|
localVersion,
|
|
1694
|
-
Category: "
|
|
2422
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
1695
2423
|
}, "Local version is higher than remote version. Skipping record");
|
|
1696
2424
|
continue;
|
|
1697
2425
|
}
|
|
@@ -1701,37 +2429,59 @@ var SyncEngine = class {
|
|
|
1701
2429
|
table,
|
|
1702
2430
|
op: isAdded ? "CREATE" : "UPDATE",
|
|
1703
2431
|
record: cleanedRecord,
|
|
1704
|
-
version
|
|
2432
|
+
version
|
|
1705
2433
|
});
|
|
1706
2434
|
}
|
|
1707
2435
|
if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
|
|
1708
2436
|
this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
|
|
2437
|
+
return {
|
|
2438
|
+
remoteFetchMs,
|
|
2439
|
+
stillRemoteIds
|
|
2440
|
+
};
|
|
1709
2441
|
}
|
|
1710
2442
|
/**
|
|
1711
2443
|
* Handle records that exist locally but not in remote array.
|
|
2444
|
+
*
|
|
2445
|
+
* "Removed" here is a derived signal: the SSP's `_00_list_ref` array no
|
|
2446
|
+
* longer references a record that exists locally. That can mean the row
|
|
2447
|
+
* was genuinely deleted upstream — but it can also be a benign race
|
|
2448
|
+
* (e.g. a record we just created hasn't propagated into the SSP's
|
|
2449
|
+
* incantation list yet). Before deleting locally we verify against
|
|
2450
|
+
* upstream SurrealDB: if the row still exists there, skip the delete.
|
|
2451
|
+
*
|
|
2452
|
+
* On verification failure we skip deletion too. Losing a stale local
|
|
2453
|
+
* row to a later sync round is recoverable; deleting a fresh row that
|
|
2454
|
+
* upstream still has is not.
|
|
1712
2455
|
*/
|
|
1713
2456
|
async handleRemovedRecords(removed) {
|
|
1714
2457
|
this.logger.debug({
|
|
1715
2458
|
removed: removed.map((r) => r.toString()),
|
|
1716
|
-
Category: "
|
|
2459
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1717
2460
|
}, "Checking removed records");
|
|
1718
|
-
let existingRemoteIds
|
|
2461
|
+
let existingRemoteIds;
|
|
1719
2462
|
try {
|
|
1720
|
-
const [
|
|
1721
|
-
existingRemoteIds = new Set(
|
|
1722
|
-
} catch {
|
|
1723
|
-
this.logger.
|
|
2463
|
+
const [existing] = await this.remote.query("SELECT VALUE id FROM $ids", { ids: removed });
|
|
2464
|
+
existingRemoteIds = new Set((existing ?? []).filter((id) => id != null).map((id) => encodeRecordId(id)));
|
|
2465
|
+
} catch (err) {
|
|
2466
|
+
this.logger.warn({
|
|
2467
|
+
err,
|
|
2468
|
+
removed: removed.map((r) => r.toString()),
|
|
2469
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
2470
|
+
}, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
|
|
2471
|
+
return [];
|
|
1724
2472
|
}
|
|
2473
|
+
const stillRemoteIds = [];
|
|
1725
2474
|
for (const recordId of removed) {
|
|
1726
2475
|
const recordIdStr = encodeRecordId(recordId);
|
|
1727
2476
|
if (!existingRemoteIds.has(recordIdStr)) {
|
|
1728
2477
|
this.logger.debug({
|
|
1729
2478
|
recordId: recordIdStr,
|
|
1730
|
-
Category: "
|
|
2479
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1731
2480
|
}, "Deleting confirmed removed record");
|
|
1732
2481
|
await this.cache.delete(recordId.table.name, recordIdStr);
|
|
1733
|
-
}
|
|
2482
|
+
} else stillRemoteIds.push(recordIdStr);
|
|
1734
2483
|
}
|
|
2484
|
+
return stillRemoteIds;
|
|
1735
2485
|
}
|
|
1736
2486
|
};
|
|
1737
2487
|
|
|
@@ -1744,13 +2494,14 @@ var SyncEngine = class {
|
|
|
1744
2494
|
var SyncScheduler = class {
|
|
1745
2495
|
isSyncingUp = false;
|
|
1746
2496
|
isSyncingDown = false;
|
|
1747
|
-
constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback) {
|
|
2497
|
+
constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
|
|
1748
2498
|
this.upQueue = upQueue;
|
|
1749
2499
|
this.downQueue = downQueue;
|
|
1750
2500
|
this.onProcessUp = onProcessUp;
|
|
1751
2501
|
this.onProcessDown = onProcessDown;
|
|
1752
2502
|
this.logger = logger;
|
|
1753
2503
|
this.onRollback = onRollback;
|
|
2504
|
+
this.onSyncOutcome = onSyncOutcome;
|
|
1754
2505
|
}
|
|
1755
2506
|
async init() {
|
|
1756
2507
|
await this.upQueue.loadFromDatabase();
|
|
@@ -1775,8 +2526,19 @@ var SyncScheduler = class {
|
|
|
1775
2526
|
async syncUp() {
|
|
1776
2527
|
if (this.isSyncingUp) return;
|
|
1777
2528
|
this.isSyncingUp = true;
|
|
2529
|
+
let processedAny = false;
|
|
1778
2530
|
try {
|
|
1779
|
-
while (this.upQueue.size > 0)
|
|
2531
|
+
while (this.upQueue.size > 0) {
|
|
2532
|
+
await this.upQueue.next(this.onProcessUp, this.onRollback);
|
|
2533
|
+
processedAny = true;
|
|
2534
|
+
}
|
|
2535
|
+
if (processedAny) this.onSyncOutcome?.(true);
|
|
2536
|
+
} catch (error) {
|
|
2537
|
+
this.onSyncOutcome?.(false, error);
|
|
2538
|
+
this.logger.debug({
|
|
2539
|
+
error,
|
|
2540
|
+
Category: "sp00ky-client::SyncScheduler::syncUp"
|
|
2541
|
+
}, "syncUp halted on a queue error; item re-queued, will retry on next trigger");
|
|
1780
2542
|
} finally {
|
|
1781
2543
|
this.isSyncingUp = false;
|
|
1782
2544
|
this.syncDown();
|
|
@@ -1789,11 +2551,20 @@ var SyncScheduler = class {
|
|
|
1789
2551
|
if (this.isSyncingDown) return;
|
|
1790
2552
|
if (this.upQueue.size > 0) return;
|
|
1791
2553
|
this.isSyncingDown = true;
|
|
2554
|
+
let processedAny = false;
|
|
1792
2555
|
try {
|
|
1793
2556
|
while (this.downQueue.size > 0) {
|
|
1794
2557
|
if (this.upQueue.size > 0) break;
|
|
1795
2558
|
await this.downQueue.next(this.onProcessDown);
|
|
2559
|
+
processedAny = true;
|
|
1796
2560
|
}
|
|
2561
|
+
if (processedAny) this.onSyncOutcome?.(true);
|
|
2562
|
+
} catch (error) {
|
|
2563
|
+
this.onSyncOutcome?.(false, error);
|
|
2564
|
+
this.logger.debug({
|
|
2565
|
+
error,
|
|
2566
|
+
Category: "sp00ky-client::SyncScheduler::syncDown"
|
|
2567
|
+
}, "syncDown halted on a queue error; item re-queued, will retry on next trigger");
|
|
1797
2568
|
} finally {
|
|
1798
2569
|
this.isSyncingDown = false;
|
|
1799
2570
|
}
|
|
@@ -1803,23 +2574,91 @@ var SyncScheduler = class {
|
|
|
1803
2574
|
}
|
|
1804
2575
|
};
|
|
1805
2576
|
|
|
2577
|
+
//#endregion
|
|
2578
|
+
//#region src/modules/ref-tables.ts
|
|
2579
|
+
/**
|
|
2580
|
+
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
2581
|
+
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
2582
|
+
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
2583
|
+
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
2584
|
+
*/
|
|
2585
|
+
const ANON_USER_ID = "anon";
|
|
2586
|
+
/**
|
|
2587
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
2588
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
2589
|
+
* box.
|
|
2590
|
+
*/
|
|
2591
|
+
const DEFAULT_REF_MODE = "dedicated";
|
|
2592
|
+
/**
|
|
2593
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
2594
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
2595
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
2596
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
2597
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
2598
|
+
*
|
|
2599
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
2600
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
2601
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
2602
|
+
*/
|
|
2603
|
+
function sanitizeUserId(userId) {
|
|
2604
|
+
if (userId === null || userId === void 0) return null;
|
|
2605
|
+
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
2606
|
+
if (!asString) return null;
|
|
2607
|
+
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
2608
|
+
if (raw.length === 0) return null;
|
|
2609
|
+
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
2610
|
+
return raw;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
2614
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
2615
|
+
* single mode.
|
|
2616
|
+
*/
|
|
2617
|
+
function listRefTableFor(mode, userId) {
|
|
2618
|
+
if (userId === ANON_USER_ID) return "_00_list_ref_anon";
|
|
2619
|
+
if (mode === "single") return "_00_list_ref";
|
|
2620
|
+
const uid = sanitizeUserId(userId);
|
|
2621
|
+
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
2622
|
+
}
|
|
2623
|
+
|
|
1806
2624
|
//#endregion
|
|
1807
2625
|
//#region src/modules/sync/sync.ts
|
|
1808
2626
|
/**
|
|
1809
|
-
* The main synchronization engine for
|
|
2627
|
+
* The main synchronization engine for Sp00ky.
|
|
1810
2628
|
* Handles the bidirectional synchronization between the local database and the remote backend.
|
|
1811
2629
|
* Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
|
|
1812
2630
|
* @template S The schema structure type.
|
|
1813
2631
|
*/
|
|
1814
|
-
var
|
|
1815
|
-
clientId = "";
|
|
2632
|
+
var Sp00kySync = class Sp00kySync {
|
|
1816
2633
|
upQueue;
|
|
1817
2634
|
downQueue;
|
|
1818
2635
|
isInit = false;
|
|
1819
2636
|
logger;
|
|
1820
2637
|
syncEngine;
|
|
2638
|
+
/** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
|
|
2639
|
+
* from `this.events`, which carries Sp00kySync-level events like
|
|
2640
|
+
* `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
|
|
2641
|
+
get engineEvents() {
|
|
2642
|
+
return this.syncEngine.events;
|
|
2643
|
+
}
|
|
1821
2644
|
scheduler;
|
|
2645
|
+
wasDisconnected = false;
|
|
1822
2646
|
events = createSyncEventSystem();
|
|
2647
|
+
currentUserId = null;
|
|
2648
|
+
refMode = DEFAULT_REF_MODE;
|
|
2649
|
+
anonLiveEnabled;
|
|
2650
|
+
currentLiveQueryUuid = null;
|
|
2651
|
+
liveQueryUnsubscribe = null;
|
|
2652
|
+
listRefPollTimer = null;
|
|
2653
|
+
listRefPollRunning = false;
|
|
2654
|
+
refSyncIntervalMs;
|
|
2655
|
+
listRefIdleStreak = 0;
|
|
2656
|
+
stillRemoteStreaks = /* @__PURE__ */ new Map();
|
|
2657
|
+
lastLiveEventAt = null;
|
|
2658
|
+
_liveRetryCount = 0;
|
|
2659
|
+
get liveRetryCount() {
|
|
2660
|
+
return this._liveRetryCount;
|
|
2661
|
+
}
|
|
1823
2662
|
get isSyncing() {
|
|
1824
2663
|
return this.scheduler.isSyncing;
|
|
1825
2664
|
}
|
|
@@ -1834,60 +2673,440 @@ var SpookySync = class {
|
|
|
1834
2673
|
this.upQueue.events.unsubscribe(id2);
|
|
1835
2674
|
};
|
|
1836
2675
|
}
|
|
1837
|
-
|
|
2676
|
+
degradeAfterFailures;
|
|
2677
|
+
consecutiveSyncFailures = 0;
|
|
2678
|
+
syncHealthStatus = "healthy";
|
|
2679
|
+
lastSyncErrorKind;
|
|
2680
|
+
lastSyncErrorMessage;
|
|
2681
|
+
selfHealTimer = null;
|
|
2682
|
+
selfHealAttempts = 0;
|
|
2683
|
+
static SELF_HEAL_BASE_MS = 2e3;
|
|
2684
|
+
static SELF_HEAL_MAX_MS = 3e4;
|
|
2685
|
+
/** Current sync-health snapshot. */
|
|
2686
|
+
get syncHealth() {
|
|
2687
|
+
return {
|
|
2688
|
+
status: this.syncHealthStatus,
|
|
2689
|
+
consecutiveFailures: this.consecutiveSyncFailures,
|
|
2690
|
+
kind: this.syncHealthStatus === "degraded" ? this.lastSyncErrorKind : void 0,
|
|
2691
|
+
error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
/**
|
|
2695
|
+
* Observe sync health. The callback fires immediately with the current
|
|
2696
|
+
* status and again on every healthy↔degraded transition. Returns an
|
|
2697
|
+
* unsubscribe. Mirrors {@link subscribeToPendingMutations}.
|
|
2698
|
+
*/
|
|
2699
|
+
subscribeToSyncHealth(cb) {
|
|
2700
|
+
cb(this.syncHealth);
|
|
2701
|
+
const id = this.events.subscribe(SyncEventTypes.SyncHealthChanged, (event) => cb(event.payload));
|
|
2702
|
+
return () => this.events.unsubscribe(id);
|
|
2703
|
+
}
|
|
2704
|
+
emitSyncHealth() {
|
|
2705
|
+
this.events.emit(SyncEventTypes.SyncHealthChanged, this.syncHealth);
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Fed by the scheduler once per drained sync round. Individual failures are
|
|
2709
|
+
* absorbed by the queue's retry; only a run of `degradeAfterFailures`
|
|
2710
|
+
* consecutive failures flips the status to `degraded`, and the next clean
|
|
2711
|
+
* round flips it back. No-op when reporting is disabled (`degradeAfterFailures`
|
|
2712
|
+
* is 0).
|
|
2713
|
+
*/
|
|
2714
|
+
recordSyncOutcome(ok, error) {
|
|
2715
|
+
if (this.degradeAfterFailures <= 0) return;
|
|
2716
|
+
if (ok) {
|
|
2717
|
+
if (this.consecutiveSyncFailures === 0) return;
|
|
2718
|
+
this.consecutiveSyncFailures = 0;
|
|
2719
|
+
if (this.syncHealthStatus !== "healthy") {
|
|
2720
|
+
this.syncHealthStatus = "healthy";
|
|
2721
|
+
this.lastSyncErrorKind = void 0;
|
|
2722
|
+
this.lastSyncErrorMessage = void 0;
|
|
2723
|
+
this.stopSelfHeal();
|
|
2724
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kySync::syncHealth" }, "Sync recovered; health back to healthy");
|
|
2725
|
+
this.emitSyncHealth();
|
|
2726
|
+
}
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
this.consecutiveSyncFailures++;
|
|
2730
|
+
this.lastSyncErrorKind = classifySyncError(error);
|
|
2731
|
+
this.lastSyncErrorMessage = error instanceof Error ? error.message : String(error);
|
|
2732
|
+
if (this.syncHealthStatus !== "degraded" && this.consecutiveSyncFailures >= this.degradeAfterFailures) {
|
|
2733
|
+
this.syncHealthStatus = "degraded";
|
|
2734
|
+
this.logger.warn({
|
|
2735
|
+
consecutiveFailures: this.consecutiveSyncFailures,
|
|
2736
|
+
kind: this.lastSyncErrorKind,
|
|
2737
|
+
error,
|
|
2738
|
+
Category: "sp00ky-client::Sp00kySync::syncHealth"
|
|
2739
|
+
}, "Sync degraded after sustained failures");
|
|
2740
|
+
this.emitSyncHealth();
|
|
2741
|
+
this.startSelfHeal();
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Begin self-heal retries (no-op if already running). Started on the
|
|
2746
|
+
* healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
|
|
2747
|
+
*/
|
|
2748
|
+
startSelfHeal() {
|
|
2749
|
+
if (this.selfHealTimer !== null) return;
|
|
2750
|
+
this.selfHealAttempts = 0;
|
|
2751
|
+
this.scheduleSelfHeal();
|
|
2752
|
+
}
|
|
2753
|
+
scheduleSelfHeal() {
|
|
2754
|
+
const delay = Math.min(Sp00kySync.SELF_HEAL_MAX_MS, Sp00kySync.SELF_HEAL_BASE_MS * 2 ** this.selfHealAttempts);
|
|
2755
|
+
this.selfHealTimer = setTimeout(async () => {
|
|
2756
|
+
this.selfHealTimer = null;
|
|
2757
|
+
if (this.syncHealthStatus !== "degraded") return;
|
|
2758
|
+
this.selfHealAttempts++;
|
|
2759
|
+
this.logger.debug({
|
|
2760
|
+
attempt: this.selfHealAttempts,
|
|
2761
|
+
delayMs: delay,
|
|
2762
|
+
Category: "sp00ky-client::Sp00kySync::selfHeal"
|
|
2763
|
+
}, "Self-heal: re-driving sync while degraded");
|
|
2764
|
+
try {
|
|
2765
|
+
if (this.upQueue.size > 0) await this.scheduler.syncUp();
|
|
2766
|
+
else if (this.downQueue.size > 0) await this.scheduler.syncDown();
|
|
2767
|
+
else {
|
|
2768
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
2769
|
+
if (hashes.length > 0) {
|
|
2770
|
+
for (const hash of hashes) this.scheduler.enqueueDownEvent({
|
|
2771
|
+
type: "register",
|
|
2772
|
+
payload: { hash }
|
|
2773
|
+
});
|
|
2774
|
+
await this.scheduler.syncDown();
|
|
2775
|
+
} else {
|
|
2776
|
+
await this.remote.query("RETURN true");
|
|
2777
|
+
this.recordSyncOutcome(true);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
} catch (err) {
|
|
2781
|
+
this.recordSyncOutcome(false, err);
|
|
2782
|
+
}
|
|
2783
|
+
if (this.syncHealthStatus === "degraded") this.scheduleSelfHeal();
|
|
2784
|
+
}, delay);
|
|
2785
|
+
}
|
|
2786
|
+
stopSelfHeal() {
|
|
2787
|
+
if (this.selfHealTimer !== null) {
|
|
2788
|
+
clearTimeout(this.selfHealTimer);
|
|
2789
|
+
this.selfHealTimer = null;
|
|
2790
|
+
}
|
|
2791
|
+
this.selfHealAttempts = 0;
|
|
2792
|
+
}
|
|
2793
|
+
constructor(local, remote, cache, dataModule, schema, logger, options) {
|
|
1838
2794
|
this.local = local;
|
|
1839
2795
|
this.remote = remote;
|
|
1840
2796
|
this.cache = cache;
|
|
1841
2797
|
this.dataModule = dataModule;
|
|
1842
2798
|
this.schema = schema;
|
|
1843
|
-
this.logger = logger.child({ service: "
|
|
2799
|
+
this.logger = logger.child({ service: "Sp00kySync" });
|
|
1844
2800
|
this.upQueue = new UpQueue(this.local, this.logger);
|
|
1845
2801
|
this.downQueue = new DownQueue(this.local, this.logger);
|
|
1846
2802
|
this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
|
|
1847
|
-
this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this));
|
|
2803
|
+
this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this), this.recordSyncOutcome.bind(this));
|
|
2804
|
+
this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
|
|
2805
|
+
this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
|
|
2806
|
+
this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
|
|
1848
2807
|
}
|
|
1849
2808
|
/**
|
|
1850
2809
|
* Initializes the synchronization system.
|
|
1851
2810
|
* Starts the scheduler and initiates the initial sync cycles.
|
|
1852
|
-
* @param clientId The unique identifier for this client instance.
|
|
1853
2811
|
* @throws Error if already initialized.
|
|
1854
2812
|
*/
|
|
1855
|
-
async init(
|
|
1856
|
-
if (this.isInit) throw new Error("
|
|
1857
|
-
this.clientId = clientId;
|
|
2813
|
+
async init() {
|
|
2814
|
+
if (this.isInit) throw new Error("Sp00kySync is already initialized");
|
|
1858
2815
|
this.isInit = true;
|
|
1859
2816
|
await this.scheduler.init();
|
|
1860
|
-
this.
|
|
2817
|
+
this.subscribeToReconnect();
|
|
1861
2818
|
this.scheduler.syncUp();
|
|
1862
2819
|
this.scheduler.syncDown();
|
|
1863
|
-
this.
|
|
2820
|
+
if (this.anonLiveEnabled && !this.currentUserId) {
|
|
2821
|
+
this.startListRefPoll();
|
|
2822
|
+
this.restartRefLiveQuery().catch((err) => {
|
|
2823
|
+
this.logger.debug({
|
|
2824
|
+
err,
|
|
2825
|
+
Category: "sp00ky-client::Sp00kySync::init"
|
|
2826
|
+
}, "Anonymous ref LIVE start failed; relying on periodic poll fallback");
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
2831
|
+
* Push the authenticated user's record id from the parent client's
|
|
2832
|
+
* auth subscription. Tears down the existing `_00_list_ref` LIVE (if
|
|
2833
|
+
* any) and re-registers it under the new user's dedicated table so
|
|
2834
|
+
* SurrealDB binds the permission rule under the post-flip auth
|
|
2835
|
+
* context. Pass `null` on sign-out.
|
|
2836
|
+
*
|
|
2837
|
+
* The dedicated `_00_list_ref_user_<id>` table is created lazily by
|
|
2838
|
+
* the SSP when the first query registration arrives, which may be
|
|
2839
|
+
* concurrent with this call. We retry the LIVE registration with a
|
|
2840
|
+
* short backoff so a "table not found" race resolves without
|
|
2841
|
+
* surfacing as a permanent auth-loading hang.
|
|
2842
|
+
*/
|
|
2843
|
+
async setCurrentUserId(userId) {
|
|
2844
|
+
if (this.currentUserId === userId) return;
|
|
2845
|
+
this.currentUserId = userId;
|
|
2846
|
+
if (!userId) {
|
|
2847
|
+
if (this.anonLiveEnabled) {
|
|
2848
|
+
this.startListRefPoll();
|
|
2849
|
+
await this.restartRefLiveQuery().catch((err) => {
|
|
2850
|
+
this.logger.debug({
|
|
2851
|
+
err,
|
|
2852
|
+
Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
|
|
2853
|
+
}, "Anonymous ref LIVE restart failed; relying on periodic poll fallback");
|
|
2854
|
+
});
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
await this.killRefLiveQuery();
|
|
2858
|
+
this.stopListRefPoll();
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
this.startListRefPoll();
|
|
2862
|
+
const attemptDelays = [
|
|
2863
|
+
0,
|
|
2864
|
+
250,
|
|
2865
|
+
500,
|
|
2866
|
+
1e3,
|
|
2867
|
+
2e3
|
|
2868
|
+
];
|
|
2869
|
+
for (let i = 0; i < attemptDelays.length; i++) {
|
|
2870
|
+
if (attemptDelays[i] > 0) {
|
|
2871
|
+
this._liveRetryCount++;
|
|
2872
|
+
await new Promise((r) => setTimeout(r, attemptDelays[i]));
|
|
2873
|
+
}
|
|
2874
|
+
try {
|
|
2875
|
+
await this.restartRefLiveQuery();
|
|
2876
|
+
return;
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
this.logger.debug({
|
|
2879
|
+
err,
|
|
2880
|
+
attempt: i + 1,
|
|
2881
|
+
Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
|
|
2882
|
+
}, "Ref LIVE start failed; relying on periodic poll fallback");
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
startListRefPoll() {
|
|
2887
|
+
if (this.listRefPollRunning) return;
|
|
2888
|
+
this.listRefPollRunning = true;
|
|
2889
|
+
this.logger.debug({
|
|
2890
|
+
intervalMs: this.refSyncIntervalMs,
|
|
2891
|
+
Category: "sp00ky-client::Sp00kySync::startListRefPoll"
|
|
2892
|
+
}, "list_ref poll loop started");
|
|
2893
|
+
const schedule = (delayMs) => {
|
|
2894
|
+
this.listRefPollTimer = setTimeout(async () => {
|
|
2895
|
+
if (!this.listRefPollRunning) return;
|
|
2896
|
+
let changed = false;
|
|
2897
|
+
try {
|
|
2898
|
+
changed = await this.pollListRefForActiveQueries();
|
|
2899
|
+
} finally {
|
|
2900
|
+
if (!this.listRefPollRunning) return;
|
|
2901
|
+
this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
|
|
2902
|
+
schedule(listRefPollDelayMs({
|
|
2903
|
+
idleStreak: this.listRefIdleStreak,
|
|
2904
|
+
baseIntervalMs: this.refSyncIntervalMs
|
|
2905
|
+
}));
|
|
2906
|
+
}
|
|
2907
|
+
}, delayMs);
|
|
2908
|
+
};
|
|
2909
|
+
schedule(this.refSyncIntervalMs);
|
|
2910
|
+
}
|
|
2911
|
+
stopListRefPoll() {
|
|
2912
|
+
this.listRefPollRunning = false;
|
|
2913
|
+
if (this.listRefPollTimer !== null) {
|
|
2914
|
+
clearTimeout(this.listRefPollTimer);
|
|
2915
|
+
this.listRefPollTimer = null;
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* One poll cycle: refetch `_00_list_ref` for every active query. Returns
|
|
2920
|
+
* whether ANY query's remoteArray actually changed — the scheduler uses this
|
|
2921
|
+
* to drive the adaptive idle backoff.
|
|
2922
|
+
*
|
|
2923
|
+
* Also the ONLY health signal that runs while the page is idle. Sync health is
|
|
2924
|
+
* otherwise activity-driven (mutations/registrations via the scheduler,
|
|
2925
|
+
* reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
|
|
2926
|
+
* would linger until the next mutation and a genuine idle drop would be
|
|
2927
|
+
* invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
|
|
2928
|
+
* so idle health self-recovers (and self-degrades) with no user action. A clean
|
|
2929
|
+
* cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
|
|
2930
|
+
* `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
|
|
2931
|
+
*/
|
|
2932
|
+
async pollListRefForActiveQueries() {
|
|
2933
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
2934
|
+
if (hashes.length === 0) {
|
|
2935
|
+
try {
|
|
2936
|
+
await this.remote.query("RETURN true");
|
|
2937
|
+
this.recordSyncOutcome(true);
|
|
2938
|
+
} catch (err) {
|
|
2939
|
+
this.recordSyncOutcome(false, err);
|
|
2940
|
+
}
|
|
2941
|
+
return false;
|
|
2942
|
+
}
|
|
2943
|
+
let anyChanged = false;
|
|
2944
|
+
let reached = false;
|
|
2945
|
+
let firstNetworkErr;
|
|
2946
|
+
for (const hash of hashes) try {
|
|
2947
|
+
if (await this.refetchListRefForQuery(hash)) anyChanged = true;
|
|
2948
|
+
reached = true;
|
|
2949
|
+
} catch (err) {
|
|
2950
|
+
if (classifySyncError(err) === "network") {
|
|
2951
|
+
if (firstNetworkErr === void 0) firstNetworkErr = err;
|
|
2952
|
+
} else reached = true;
|
|
2953
|
+
this.logger.debug({
|
|
2954
|
+
err: err?.message ?? err,
|
|
2955
|
+
hash,
|
|
2956
|
+
Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
|
|
2957
|
+
}, "Per-query list_ref poll failed");
|
|
2958
|
+
}
|
|
2959
|
+
if (reached) this.recordSyncOutcome(true);
|
|
2960
|
+
else if (firstNetworkErr !== void 0) this.recordSyncOutcome(false, firstNetworkErr);
|
|
2961
|
+
return anyChanged;
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Pull the upstream list_ref entries for `queryHash`, diff them
|
|
2965
|
+
* against the local `remoteArray` cache, sync any added/updated rows
|
|
2966
|
+
* through the SyncEngine, then persist the new remoteArray. This is
|
|
2967
|
+
* the same shape `createRemoteQuery` does for its initial fetch and
|
|
2968
|
+
* what `handleRemoteListRefChange` does per-LIVE-event — we reuse
|
|
2969
|
+
* it on a timer as a fallback for missed LIVE notifications.
|
|
2970
|
+
*/
|
|
2971
|
+
async refetchListRefForQuery(queryHash) {
|
|
2972
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2973
|
+
if (!queryState) return false;
|
|
2974
|
+
const listRefTbl = this.listRefTable();
|
|
2975
|
+
const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
2976
|
+
if (!Array.isArray(items)) return false;
|
|
2977
|
+
const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
2978
|
+
const prevRemote = queryState.config.remoteArray ?? [];
|
|
2979
|
+
const freshIds = new Set(fresh.map(([id]) => id));
|
|
2980
|
+
const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
|
|
2981
|
+
const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
|
|
2982
|
+
if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
|
|
2983
|
+
try {
|
|
2984
|
+
await this.syncQuery(queryHash);
|
|
2985
|
+
} catch (err) {
|
|
2986
|
+
this.logger.info({
|
|
2987
|
+
err: err?.message ?? err,
|
|
2988
|
+
queryHash,
|
|
2989
|
+
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2990
|
+
}, "syncQuery failed during poll");
|
|
2991
|
+
}
|
|
2992
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
2993
|
+
this.logger.info({
|
|
2994
|
+
err: err?.message ?? err,
|
|
2995
|
+
queryHash,
|
|
2996
|
+
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2997
|
+
}, "Subquery child sync failed during poll");
|
|
2998
|
+
});
|
|
2999
|
+
if (removedIds.length > 0) try {
|
|
3000
|
+
await this.dataModule.notifyQuerySynced(queryHash);
|
|
3001
|
+
} catch (err) {
|
|
3002
|
+
this.logger.info({
|
|
3003
|
+
err: err?.message ?? err,
|
|
3004
|
+
queryHash,
|
|
3005
|
+
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
3006
|
+
}, "notifyQuerySynced failed during poll-removal re-render");
|
|
3007
|
+
}
|
|
3008
|
+
return changed;
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* Resolve the current `_00_list_ref` table name for the active auth
|
|
3012
|
+
* context. Public so the `createRemoteQuery` initial-fetch path can
|
|
3013
|
+
* read from the right per-user table.
|
|
3014
|
+
*
|
|
3015
|
+
* Reads the user id from `DataModule` rather than the local mirror,
|
|
3016
|
+
* because `DataModule.setCurrentUserId` runs synchronously from the
|
|
3017
|
+
* auth callback (before any `await`), whereas `sync.setCurrentUserId`
|
|
3018
|
+
* is async — the userQuery's initial fetch can fire between those
|
|
3019
|
+
* two points and we need the correct table name immediately.
|
|
3020
|
+
*/
|
|
3021
|
+
listRefTable() {
|
|
3022
|
+
const userId = this.dataModule.getCurrentUserId();
|
|
3023
|
+
if (userId == null && this.anonLiveEnabled) return listRefTableFor(this.refMode, ANON_USER_ID);
|
|
3024
|
+
return listRefTableFor(this.refMode, userId);
|
|
3025
|
+
}
|
|
3026
|
+
async killRefLiveQuery() {
|
|
3027
|
+
if (this.liveQueryUnsubscribe) {
|
|
3028
|
+
try {
|
|
3029
|
+
this.liveQueryUnsubscribe();
|
|
3030
|
+
} catch {}
|
|
3031
|
+
this.liveQueryUnsubscribe = null;
|
|
3032
|
+
}
|
|
3033
|
+
if (this.currentLiveQueryUuid !== null) {
|
|
3034
|
+
try {
|
|
3035
|
+
await this.remote.query("KILL $u", { u: this.currentLiveQueryUuid });
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
this.logger.debug({
|
|
3038
|
+
err,
|
|
3039
|
+
Category: "sp00ky-client::Sp00kySync::killRefLiveQuery"
|
|
3040
|
+
}, "Prior LIVE KILL failed; continuing");
|
|
3041
|
+
}
|
|
3042
|
+
this.currentLiveQueryUuid = null;
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
async restartRefLiveQuery() {
|
|
3046
|
+
await this.killRefLiveQuery();
|
|
3047
|
+
await this.startRefLiveQueries();
|
|
3048
|
+
}
|
|
3049
|
+
subscribeToReconnect() {
|
|
3050
|
+
const client = this.remote.getClient();
|
|
3051
|
+
client.subscribe("disconnected", () => {
|
|
3052
|
+
this.wasDisconnected = true;
|
|
3053
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kySync::onDisconnect" }, "Remote disconnected");
|
|
3054
|
+
});
|
|
3055
|
+
client.subscribe("connected", () => {
|
|
3056
|
+
if (!this.wasDisconnected) return;
|
|
3057
|
+
this.wasDisconnected = false;
|
|
3058
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
|
|
3059
|
+
for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
|
|
3060
|
+
type: "register",
|
|
3061
|
+
payload: { hash }
|
|
3062
|
+
});
|
|
3063
|
+
if (this.currentUserId || this.anonLiveEnabled) this.restartRefLiveQuery().catch((err) => {
|
|
3064
|
+
this.logger.debug({
|
|
3065
|
+
err,
|
|
3066
|
+
Category: "sp00ky-client::Sp00kySync::onReconnect"
|
|
3067
|
+
}, "LIVE restart after reconnect failed; relying on poll fallback");
|
|
3068
|
+
});
|
|
3069
|
+
});
|
|
1864
3070
|
}
|
|
1865
3071
|
async startRefLiveQueries() {
|
|
3072
|
+
const tableName = this.listRefTable();
|
|
1866
3073
|
this.logger.debug({
|
|
1867
|
-
|
|
1868
|
-
Category: "
|
|
3074
|
+
tableName,
|
|
3075
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1869
3076
|
}, "Starting ref live queries");
|
|
1870
|
-
const [queryUuid] = await this.remote.query(
|
|
1871
|
-
|
|
3077
|
+
const [queryUuid] = await this.remote.query(`LIVE SELECT * FROM ${tableName}`);
|
|
3078
|
+
this.currentLiveQueryUuid = queryUuid;
|
|
3079
|
+
this.liveQueryUnsubscribe = (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
|
|
1872
3080
|
this.logger.debug({
|
|
1873
3081
|
message,
|
|
1874
|
-
Category: "
|
|
3082
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1875
3083
|
}, "Live update received");
|
|
1876
3084
|
if (message.action === "KILLED") return;
|
|
3085
|
+
if (message.value.parent != null) {
|
|
3086
|
+
this.handleRemoteSubqueryChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
|
|
3087
|
+
this.logger.error({
|
|
3088
|
+
err,
|
|
3089
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
3090
|
+
}, "Error handling remote subquery change");
|
|
3091
|
+
});
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
1877
3094
|
this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
|
|
1878
3095
|
this.logger.error({
|
|
1879
3096
|
err,
|
|
1880
|
-
Category: "
|
|
3097
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1881
3098
|
}, "Error handling remote list ref change");
|
|
1882
3099
|
});
|
|
1883
3100
|
});
|
|
1884
3101
|
}
|
|
1885
3102
|
async handleRemoteListRefChange(action, queryId, recordId, version) {
|
|
3103
|
+
this.lastLiveEventAt = Date.now();
|
|
3104
|
+
this.listRefIdleStreak = 0;
|
|
1886
3105
|
const existing = this.dataModule.getQueryById(queryId);
|
|
1887
3106
|
if (!existing) {
|
|
1888
3107
|
this.logger.warn({
|
|
1889
3108
|
queryId: queryId.toString(),
|
|
1890
|
-
Category: "
|
|
3109
|
+
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
1891
3110
|
}, "Received remote update for unknown local query");
|
|
1892
3111
|
return;
|
|
1893
3112
|
}
|
|
@@ -1898,10 +3117,46 @@ var SpookySync = class {
|
|
|
1898
3117
|
recordId,
|
|
1899
3118
|
version,
|
|
1900
3119
|
localArray,
|
|
1901
|
-
Category: "
|
|
3120
|
+
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
1902
3121
|
}, "Live update is being processed");
|
|
1903
3122
|
const diff = createDiffFromDbOp(action, recordId, version, localArray);
|
|
1904
|
-
|
|
3123
|
+
const hash = extractIdPart(existing.config.id);
|
|
3124
|
+
await this.runSyncForQuery(hash, diff);
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
|
|
3128
|
+
* `parent` set) for a `.related()` query. Unlike primary rows, child rows
|
|
3129
|
+
* must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
|
|
3130
|
+
* keep the child BODY fresh in the local cache so the in-browser SSP's
|
|
3131
|
+
* subquery-table dependency re-materializes the parent view.
|
|
3132
|
+
*
|
|
3133
|
+
* CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
|
|
3134
|
+
* no-op: a child leaving this query's set must not delete a body another
|
|
3135
|
+
* query may still show (see `syncSubqueryChildren` deletion-safety note);
|
|
3136
|
+
* a genuine record delete propagates via the normal delete path.
|
|
3137
|
+
*/
|
|
3138
|
+
async handleRemoteSubqueryChange(action, queryId, childId, version) {
|
|
3139
|
+
this.lastLiveEventAt = Date.now();
|
|
3140
|
+
this.listRefIdleStreak = 0;
|
|
3141
|
+
if (action === "DELETE") return;
|
|
3142
|
+
const existing = this.dataModule.getQueryById(queryId);
|
|
3143
|
+
if (!existing) return;
|
|
3144
|
+
const item = {
|
|
3145
|
+
id: childId,
|
|
3146
|
+
version
|
|
3147
|
+
};
|
|
3148
|
+
await this.syncEngine.syncRecords(action === "CREATE" ? {
|
|
3149
|
+
added: [item],
|
|
3150
|
+
updated: [],
|
|
3151
|
+
removed: []
|
|
3152
|
+
} : {
|
|
3153
|
+
added: [],
|
|
3154
|
+
updated: [item],
|
|
3155
|
+
removed: []
|
|
3156
|
+
});
|
|
3157
|
+
const key = encodeRecordId(childId);
|
|
3158
|
+
const prev = existing.config.subqueryRemoteArray ?? [];
|
|
3159
|
+
existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
|
|
1905
3160
|
}
|
|
1906
3161
|
/**
|
|
1907
3162
|
* Enqueues a 'down' event (from remote to local) for processing.
|
|
@@ -1913,11 +3168,10 @@ var SpookySync = class {
|
|
|
1913
3168
|
async processUpEvent(event) {
|
|
1914
3169
|
this.logger.debug({
|
|
1915
3170
|
event,
|
|
1916
|
-
Category: "
|
|
3171
|
+
Category: "sp00ky-client::Sp00kySync::processUpEvent"
|
|
1917
3172
|
}, "Processing up event");
|
|
1918
|
-
console.log("xx1", event);
|
|
1919
3173
|
switch (event.type) {
|
|
1920
|
-
case "create":
|
|
3174
|
+
case "create": {
|
|
1921
3175
|
const dataKeys = Object.keys(event.data).map((key) => ({
|
|
1922
3176
|
key,
|
|
1923
3177
|
variable: `data_${key}`
|
|
@@ -1929,6 +3183,7 @@ var SpookySync = class {
|
|
|
1929
3183
|
...prefixedParams
|
|
1930
3184
|
});
|
|
1931
3185
|
break;
|
|
3186
|
+
}
|
|
1932
3187
|
case "update":
|
|
1933
3188
|
await this.remote.query(`UPDATE $id MERGE $data`, {
|
|
1934
3189
|
id: event.record_id,
|
|
@@ -1941,7 +3196,7 @@ var SpookySync = class {
|
|
|
1941
3196
|
default:
|
|
1942
3197
|
this.logger.error({
|
|
1943
3198
|
event,
|
|
1944
|
-
Category: "
|
|
3199
|
+
Category: "sp00ky-client::Sp00kySync::processUpEvent"
|
|
1945
3200
|
}, "processUpEvent unknown event type");
|
|
1946
3201
|
return;
|
|
1947
3202
|
}
|
|
@@ -1954,7 +3209,7 @@ var SpookySync = class {
|
|
|
1954
3209
|
recordId,
|
|
1955
3210
|
tableName,
|
|
1956
3211
|
error: error.message,
|
|
1957
|
-
Category: "
|
|
3212
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1958
3213
|
}, "Rolling back failed mutation");
|
|
1959
3214
|
switch (event.type) {
|
|
1960
3215
|
case "create":
|
|
@@ -1964,13 +3219,13 @@ var SpookySync = class {
|
|
|
1964
3219
|
if (event.beforeRecord) await this.dataModule.rollbackUpdate(event.record_id, tableName, event.beforeRecord);
|
|
1965
3220
|
else this.logger.warn({
|
|
1966
3221
|
recordId,
|
|
1967
|
-
Category: "
|
|
3222
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1968
3223
|
}, "Cannot rollback update: no beforeRecord available. Down-sync will reconcile.");
|
|
1969
3224
|
break;
|
|
1970
3225
|
case "delete":
|
|
1971
3226
|
this.logger.warn({
|
|
1972
3227
|
recordId,
|
|
1973
|
-
Category: "
|
|
3228
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1974
3229
|
}, "Delete rollback not implemented. Down-sync will reconcile.");
|
|
1975
3230
|
break;
|
|
1976
3231
|
}
|
|
@@ -1983,7 +3238,7 @@ var SpookySync = class {
|
|
|
1983
3238
|
async processDownEvent(event) {
|
|
1984
3239
|
this.logger.debug({
|
|
1985
3240
|
event,
|
|
1986
|
-
Category: "
|
|
3241
|
+
Category: "sp00ky-client::Sp00kySync::processDownEvent"
|
|
1987
3242
|
}, "Processing down event");
|
|
1988
3243
|
switch (event.type) {
|
|
1989
3244
|
case "register": return this.registerQuery(event.payload.hash);
|
|
@@ -2002,13 +3257,76 @@ var SpookySync = class {
|
|
|
2002
3257
|
if (!queryState) {
|
|
2003
3258
|
this.logger.warn({
|
|
2004
3259
|
hash,
|
|
2005
|
-
Category: "
|
|
3260
|
+
Category: "sp00ky-client::Sp00kySync::syncQuery"
|
|
2006
3261
|
}, "Query not found");
|
|
2007
3262
|
return;
|
|
2008
3263
|
}
|
|
2009
3264
|
const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
|
|
2010
3265
|
if (!diff) return;
|
|
2011
|
-
return this.
|
|
3266
|
+
return this.runSyncForQuery(hash, diff);
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Run a sync for a single query while reflecting its fetch status. Marks the
|
|
3270
|
+
* query `fetching` for the duration when the diff actually pulls records
|
|
3271
|
+
* (added/updated), then resets to `idle` in a `finally` so a failed sync
|
|
3272
|
+
* never leaves a query stuck `fetching`. Part A's notification coalescing
|
|
3273
|
+
* means the single resulting UI update lands after this completes.
|
|
3274
|
+
*/
|
|
3275
|
+
async runSyncForQuery(hash, diff) {
|
|
3276
|
+
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
3277
|
+
const pendingDeletes = await this.getPendingDeleteIds();
|
|
3278
|
+
if (pendingDeletes.size > 0) diff = {
|
|
3279
|
+
added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
3280
|
+
updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
3281
|
+
removed: diff.removed
|
|
3282
|
+
};
|
|
3283
|
+
}
|
|
3284
|
+
const fetching = diff.added.length + diff.updated.length > 0;
|
|
3285
|
+
if (fetching) this.dataModule.setQueryStatus(hash, "fetching");
|
|
3286
|
+
try {
|
|
3287
|
+
const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
|
|
3288
|
+
if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
|
|
3289
|
+
if (stillRemoteIds.length > 0) {
|
|
3290
|
+
const CONVERGE_AFTER = 3;
|
|
3291
|
+
const toConverge = [];
|
|
3292
|
+
for (const id of stillRemoteIds) {
|
|
3293
|
+
const key = `${hash}:${id}`;
|
|
3294
|
+
const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
|
|
3295
|
+
if (n >= CONVERGE_AFTER) {
|
|
3296
|
+
this.stillRemoteStreaks.delete(key);
|
|
3297
|
+
toConverge.push(id);
|
|
3298
|
+
} else this.stillRemoteStreaks.set(key, n);
|
|
3299
|
+
}
|
|
3300
|
+
if (toConverge.length > 0) {
|
|
3301
|
+
const local = this.dataModule.getQueryByHash(hash)?.config.localArray;
|
|
3302
|
+
if (local && local.length > 0) {
|
|
3303
|
+
const drop = new Set(toConverge);
|
|
3304
|
+
const next = local.filter(([id]) => !drop.has(id));
|
|
3305
|
+
if (next.length !== local.length) await this.dataModule.updateQueryLocalArray(hash, next);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
} finally {
|
|
3310
|
+
if (fetching) this.dataModule.setQueryStatus(hash, "idle");
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
/**
|
|
3314
|
+
* Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
|
|
3315
|
+
* Sync must not re-fetch/re-insert these — the remote delete is async, so the
|
|
3316
|
+
* server's `_00_list_ref` still lists them until it's processed, and the diff
|
|
3317
|
+
* would otherwise resurrect a just-deleted record.
|
|
3318
|
+
*/
|
|
3319
|
+
async getPendingDeleteIds() {
|
|
3320
|
+
try {
|
|
3321
|
+
const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
|
|
3322
|
+
return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
|
|
3323
|
+
} catch (err) {
|
|
3324
|
+
this.logger.warn({
|
|
3325
|
+
err,
|
|
3326
|
+
Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
|
|
3327
|
+
}, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
|
|
3328
|
+
return /* @__PURE__ */ new Set();
|
|
3329
|
+
}
|
|
2012
3330
|
}
|
|
2013
3331
|
/**
|
|
2014
3332
|
* Enqueues a list of mutations (up events) to be sent to the remote.
|
|
@@ -2021,7 +3339,7 @@ var SpookySync = class {
|
|
|
2021
3339
|
try {
|
|
2022
3340
|
this.logger.debug({
|
|
2023
3341
|
queryHash,
|
|
2024
|
-
Category: "
|
|
3342
|
+
Category: "sp00ky-client::Sp00kySync::registerQuery"
|
|
2025
3343
|
}, "Register Query state");
|
|
2026
3344
|
await this.createRemoteQuery(queryHash);
|
|
2027
3345
|
await this.syncQuery(queryHash);
|
|
@@ -2029,7 +3347,7 @@ var SpookySync = class {
|
|
|
2029
3347
|
} catch (e) {
|
|
2030
3348
|
this.logger.error({
|
|
2031
3349
|
err: e,
|
|
2032
|
-
Category: "
|
|
3350
|
+
Category: "sp00ky-client::Sp00kySync::registerQuery"
|
|
2033
3351
|
}, "registerQuery error");
|
|
2034
3352
|
throw e;
|
|
2035
3353
|
}
|
|
@@ -2039,37 +3357,80 @@ var SpookySync = class {
|
|
|
2039
3357
|
if (!queryState) {
|
|
2040
3358
|
this.logger.warn({
|
|
2041
3359
|
queryHash,
|
|
2042
|
-
Category: "
|
|
3360
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
2043
3361
|
}, "Query to register not found");
|
|
2044
3362
|
throw new Error("Query to register not found");
|
|
2045
3363
|
}
|
|
2046
3364
|
await this.remote.query("fn::query::register($config)", { config: {
|
|
2047
|
-
clientId: this.clientId,
|
|
2048
3365
|
id: queryState.config.id,
|
|
2049
3366
|
surql: queryState.config.surql,
|
|
2050
3367
|
params: queryState.config.params,
|
|
2051
3368
|
ttl: queryState.config.ttl
|
|
2052
3369
|
} });
|
|
2053
|
-
const
|
|
3370
|
+
const listRefTbl = this.listRefTable();
|
|
3371
|
+
const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
2054
3372
|
this.logger.trace({
|
|
2055
3373
|
queryId: encodeRecordId(queryState.config.id),
|
|
2056
3374
|
items,
|
|
2057
|
-
Category: "
|
|
3375
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
2058
3376
|
}, "Got query record version array from remote");
|
|
2059
3377
|
const array = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
2060
3378
|
this.logger.debug({
|
|
2061
3379
|
queryId: encodeRecordId(queryState.config.id),
|
|
2062
3380
|
array,
|
|
2063
|
-
Category: "
|
|
3381
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
2064
3382
|
}, "createdRemoteQuery");
|
|
2065
3383
|
if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array);
|
|
3384
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
3385
|
+
this.logger.info({
|
|
3386
|
+
err: err?.message ?? err,
|
|
3387
|
+
queryHash,
|
|
3388
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
3389
|
+
}, "Subquery child sync failed during registration; poll will retry");
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Sync the BODIES of a `.related()` query's subquery child rows into the
|
|
3394
|
+
* local cache, separately from the primary window array. The SSP writes
|
|
3395
|
+
* each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
|
|
3396
|
+
* `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
|
|
3397
|
+
* nesting depth). We diff against the in-memory `subqueryRemoteArray` and
|
|
3398
|
+
* fetch added/updated bodies through the SyncEngine — which `saveBatch`s
|
|
3399
|
+
* them into the local DB AND the in-browser SSP, whose subquery-table
|
|
3400
|
+
* dependency then re-materializes the parent view (no explicit notify).
|
|
3401
|
+
*
|
|
3402
|
+
* Deletion safety: we pass `removed: []` deliberately. A child body can be
|
|
3403
|
+
* shared by other queries; letting `handleRemovedRecords` delete one that
|
|
3404
|
+
* merely left THIS query's child set would clobber data another query still
|
|
3405
|
+
* shows. Genuine record deletes flow through the normal delete path; a
|
|
3406
|
+
* lingering orphan body is invisible (the correlated WHERE stops matching).
|
|
3407
|
+
*
|
|
3408
|
+
* Kept off `runSyncForQuery` on purpose so child fetches never flip the
|
|
3409
|
+
* query to `fetching` or skew its DevTools timings.
|
|
3410
|
+
*/
|
|
3411
|
+
async syncSubqueryChildren(queryHash) {
|
|
3412
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
3413
|
+
if (!queryState) return;
|
|
3414
|
+
const listRefTbl = this.listRefTable();
|
|
3415
|
+
const [items] = await this.remote.query(buildSubqueryListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
3416
|
+
if (!Array.isArray(items)) return;
|
|
3417
|
+
const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
3418
|
+
const prev = queryState.config.subqueryRemoteArray ?? [];
|
|
3419
|
+
if (recordVersionArraysEqual(fresh, prev)) return;
|
|
3420
|
+
const diff = diffRecordVersionArray(prev, fresh);
|
|
3421
|
+
if (diff.added.length > 0 || diff.updated.length > 0) await this.syncEngine.syncRecords({
|
|
3422
|
+
added: diff.added,
|
|
3423
|
+
updated: diff.updated,
|
|
3424
|
+
removed: []
|
|
3425
|
+
});
|
|
3426
|
+
queryState.config.subqueryRemoteArray = fresh;
|
|
2066
3427
|
}
|
|
2067
3428
|
async heartbeatQuery(queryHash) {
|
|
2068
3429
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2069
3430
|
if (!queryState) {
|
|
2070
3431
|
this.logger.warn({
|
|
2071
3432
|
queryHash,
|
|
2072
|
-
Category: "
|
|
3433
|
+
Category: "sp00ky-client::Sp00kySync::heartbeatQuery"
|
|
2073
3434
|
}, "Query to register not found");
|
|
2074
3435
|
throw new Error("Query to register not found");
|
|
2075
3436
|
}
|
|
@@ -2077,14 +3438,17 @@ var SpookySync = class {
|
|
|
2077
3438
|
}
|
|
2078
3439
|
async cleanupQuery(queryHash) {
|
|
2079
3440
|
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
|
-
}
|
|
3441
|
+
if (!queryState) return;
|
|
3442
|
+
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
2087
3443
|
await this.remote.query(`DELETE $id`, { id: queryState.config.id });
|
|
3444
|
+
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
3445
|
+
this.enqueueDownEvent({
|
|
3446
|
+
type: "register",
|
|
3447
|
+
payload: { hash: queryHash }
|
|
3448
|
+
});
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
this.dataModule.finalizeDeregister(queryHash);
|
|
2088
3452
|
}
|
|
2089
3453
|
};
|
|
2090
3454
|
|
|
@@ -2095,12 +3459,68 @@ function createAuthEventSystem() {
|
|
|
2095
3459
|
return createEventSystem([AuthEventTypes.AuthStateChanged]);
|
|
2096
3460
|
}
|
|
2097
3461
|
|
|
3462
|
+
//#endregion
|
|
3463
|
+
//#region src/modules/devtools/versions.ts
|
|
3464
|
+
const UNAVAILABLE = "unavailable";
|
|
3465
|
+
function emptyBackendVersions() {
|
|
3466
|
+
return {
|
|
3467
|
+
ssp: UNAVAILABLE,
|
|
3468
|
+
scheduler: UNAVAILABLE,
|
|
3469
|
+
surrealdb: UNAVAILABLE
|
|
3470
|
+
};
|
|
3471
|
+
}
|
|
3472
|
+
function emptyBackendInfo() {
|
|
3473
|
+
return {
|
|
3474
|
+
versions: emptyBackendVersions(),
|
|
3475
|
+
entities: []
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
/** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
|
|
3479
|
+
function normalizeServerVersion(v) {
|
|
3480
|
+
return String(v).replace(/^surrealdb-/i, "").trim();
|
|
3481
|
+
}
|
|
3482
|
+
/**
|
|
3483
|
+
* Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
|
|
3484
|
+
* array. The SurrealQL function returns the parsed `/info` array; depending on
|
|
3485
|
+
* how the result is unwrapped it may arrive as the array itself, a single
|
|
3486
|
+
* object, or `null`. Tolerant of all three.
|
|
3487
|
+
*/
|
|
3488
|
+
function toEntityArray(raw) {
|
|
3489
|
+
if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
|
|
3490
|
+
if (raw && typeof raw === "object") return [raw];
|
|
3491
|
+
return [];
|
|
3492
|
+
}
|
|
3493
|
+
/**
|
|
3494
|
+
* Derive component versions + the full entity list from a `/info` entity array.
|
|
3495
|
+
* `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
|
|
3496
|
+
* scheduler). Never throws; missing pieces stay `'unavailable'`.
|
|
3497
|
+
*/
|
|
3498
|
+
function parseBackendInfo(raw) {
|
|
3499
|
+
const entities = toEntityArray(raw);
|
|
3500
|
+
const versions = emptyBackendVersions();
|
|
3501
|
+
for (const entity of entities) {
|
|
3502
|
+
const version = entity.version ? String(entity.version) : void 0;
|
|
3503
|
+
if (entity.entity === "ssp" && version) versions.ssp = version;
|
|
3504
|
+
else if (entity.entity === "scheduler" && version) versions.scheduler = version;
|
|
3505
|
+
if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
|
|
3506
|
+
}
|
|
3507
|
+
return {
|
|
3508
|
+
versions,
|
|
3509
|
+
entities
|
|
3510
|
+
};
|
|
3511
|
+
}
|
|
3512
|
+
|
|
2098
3513
|
//#endregion
|
|
2099
3514
|
//#region src/modules/devtools/index.ts
|
|
3515
|
+
const CORE_VERSION = "0.0.1-canary.101";
|
|
3516
|
+
const WASM_VERSION = "0.0.1-canary.101";
|
|
3517
|
+
const SURREAL_VERSION = "3.0.3";
|
|
2100
3518
|
var DevToolsService = class {
|
|
2101
3519
|
eventsHistory = [];
|
|
2102
3520
|
eventIdCounter = 0;
|
|
2103
|
-
version =
|
|
3521
|
+
version = CORE_VERSION;
|
|
3522
|
+
backendInfo = emptyBackendInfo();
|
|
3523
|
+
enabled = false;
|
|
2104
3524
|
constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
|
|
2105
3525
|
this.databaseService = databaseService;
|
|
2106
3526
|
this.remoteDatabaseService = remoteDatabaseService;
|
|
@@ -2109,10 +3529,37 @@ var DevToolsService = class {
|
|
|
2109
3529
|
this.authService = authService;
|
|
2110
3530
|
this.dataManager = dataManager;
|
|
2111
3531
|
this.exposeToWindow();
|
|
3532
|
+
if (typeof window !== "undefined") window.addEventListener("message", (e) => {
|
|
3533
|
+
if (e.source !== window) return;
|
|
3534
|
+
const type = e.data?.type;
|
|
3535
|
+
if (type === "SP00KY_DEVTOOLS_CONNECT") {
|
|
3536
|
+
this.enabled = true;
|
|
3537
|
+
this.notifyDevTools();
|
|
3538
|
+
} else if (type === "SP00KY_DEVTOOLS_DISCONNECT") this.enabled = false;
|
|
3539
|
+
});
|
|
2112
3540
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
2113
3541
|
this.notifyDevTools();
|
|
2114
3542
|
});
|
|
2115
|
-
this.
|
|
3543
|
+
this.refreshBackendVersions();
|
|
3544
|
+
this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
|
|
3545
|
+
}
|
|
3546
|
+
/**
|
|
3547
|
+
* Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
|
|
3548
|
+
* over the open remote connection (no HTTP/CORS), then notify the panel.
|
|
3549
|
+
* Never throws: on failure the info stays empty/'unavailable'.
|
|
3550
|
+
*/
|
|
3551
|
+
async refreshBackendVersions() {
|
|
3552
|
+
try {
|
|
3553
|
+
const result = await this.remoteDatabaseService.query("RETURN fn::spooky::info()");
|
|
3554
|
+
this.backendInfo = parseBackendInfo(Array.isArray(result) ? result[0] : result);
|
|
3555
|
+
} catch (err) {
|
|
3556
|
+
this.logger.debug({
|
|
3557
|
+
err,
|
|
3558
|
+
Category: "sp00ky-client::DevToolsService::versions"
|
|
3559
|
+
}, "fn::spooky::info() unavailable; backend versions stay unavailable");
|
|
3560
|
+
this.backendInfo = emptyBackendInfo();
|
|
3561
|
+
}
|
|
3562
|
+
this.notifyDevTools();
|
|
2116
3563
|
}
|
|
2117
3564
|
getActiveQueries() {
|
|
2118
3565
|
const result = /* @__PURE__ */ new Map();
|
|
@@ -2122,6 +3569,8 @@ var DevToolsService = class {
|
|
|
2122
3569
|
result.set(queryHash, {
|
|
2123
3570
|
queryHash,
|
|
2124
3571
|
status: "active",
|
|
3572
|
+
fetchStatus: q.status,
|
|
3573
|
+
isFetching: q.status === "fetching",
|
|
2125
3574
|
createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
|
|
2126
3575
|
lastUpdate: Date.now(),
|
|
2127
3576
|
updateCount: q.updateCount,
|
|
@@ -2130,7 +3579,8 @@ var DevToolsService = class {
|
|
|
2130
3579
|
dataSize: q.records?.length || 0,
|
|
2131
3580
|
data: q.records,
|
|
2132
3581
|
localArray: q.config.localArray,
|
|
2133
|
-
remoteArray: q.config.remoteArray
|
|
3582
|
+
remoteArray: q.config.remoteArray,
|
|
3583
|
+
timings: this.dataManager.phaseTimings(q)
|
|
2134
3584
|
});
|
|
2135
3585
|
});
|
|
2136
3586
|
return result;
|
|
@@ -2138,7 +3588,7 @@ var DevToolsService = class {
|
|
|
2138
3588
|
onQueryInitialized(payload) {
|
|
2139
3589
|
this.logger.debug({
|
|
2140
3590
|
payload,
|
|
2141
|
-
Category: "
|
|
3591
|
+
Category: "sp00ky-client::DevToolsService::onQueryInitialized"
|
|
2142
3592
|
}, "QueryInitialized");
|
|
2143
3593
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
2144
3594
|
this.addEvent("QUERY_REQUEST_INIT", {
|
|
@@ -2151,7 +3601,7 @@ var DevToolsService = class {
|
|
|
2151
3601
|
onQueryUpdated(payload) {
|
|
2152
3602
|
this.logger.debug({
|
|
2153
3603
|
id: payload.queryId?.toString(),
|
|
2154
|
-
Category: "
|
|
3604
|
+
Category: "sp00ky-client::DevToolsService::onQueryUpdated"
|
|
2155
3605
|
}, "QueryUpdated");
|
|
2156
3606
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
2157
3607
|
this.addEvent("QUERY_UPDATED", {
|
|
@@ -2163,7 +3613,7 @@ var DevToolsService = class {
|
|
|
2163
3613
|
onStreamUpdate(update) {
|
|
2164
3614
|
this.logger.debug({
|
|
2165
3615
|
update,
|
|
2166
|
-
Category: "
|
|
3616
|
+
Category: "sp00ky-client::DevToolsService::onStreamUpdate"
|
|
2167
3617
|
}, "StreamUpdate");
|
|
2168
3618
|
this.addEvent("STREAM_UPDATE", { updates: [update] });
|
|
2169
3619
|
this.notifyDevTools();
|
|
@@ -2193,6 +3643,7 @@ var DevToolsService = class {
|
|
|
2193
3643
|
this.notifyDevTools();
|
|
2194
3644
|
}
|
|
2195
3645
|
addEvent(eventType, payload) {
|
|
3646
|
+
if (!this.enabled) return;
|
|
2196
3647
|
this.eventsHistory.push({
|
|
2197
3648
|
id: this.eventIdCounter++,
|
|
2198
3649
|
timestamp: Date.now(),
|
|
@@ -2210,6 +3661,15 @@ var DevToolsService = class {
|
|
|
2210
3661
|
userId: this.authService.currentUser?.id
|
|
2211
3662
|
},
|
|
2212
3663
|
version: this.version,
|
|
3664
|
+
versions: {
|
|
3665
|
+
frontend: {
|
|
3666
|
+
core: CORE_VERSION,
|
|
3667
|
+
wasm: WASM_VERSION,
|
|
3668
|
+
surrealdb: SURREAL_VERSION
|
|
3669
|
+
},
|
|
3670
|
+
backend: this.backendInfo.versions,
|
|
3671
|
+
entities: this.backendInfo.entities
|
|
3672
|
+
},
|
|
2213
3673
|
database: {
|
|
2214
3674
|
tables: this.schema.tables.map((t) => t.name),
|
|
2215
3675
|
tableData: {}
|
|
@@ -2217,9 +3677,10 @@ var DevToolsService = class {
|
|
|
2217
3677
|
});
|
|
2218
3678
|
}
|
|
2219
3679
|
notifyDevTools() {
|
|
3680
|
+
if (!this.enabled) return;
|
|
2220
3681
|
if (typeof window !== "undefined") window.postMessage({
|
|
2221
|
-
type: "
|
|
2222
|
-
source: "
|
|
3682
|
+
type: "SP00KY_STATE_CHANGED",
|
|
3683
|
+
source: "sp00ky-devtools-page",
|
|
2223
3684
|
state: this.getState()
|
|
2224
3685
|
}, "*");
|
|
2225
3686
|
}
|
|
@@ -2245,13 +3706,14 @@ var DevToolsService = class {
|
|
|
2245
3706
|
}
|
|
2246
3707
|
exposeToWindow() {
|
|
2247
3708
|
if (typeof window !== "undefined") {
|
|
2248
|
-
window.
|
|
3709
|
+
window.__00__ = {
|
|
2249
3710
|
version: this.version,
|
|
2250
3711
|
getState: () => this.getState(),
|
|
2251
3712
|
clearHistory: () => {
|
|
2252
3713
|
this.eventsHistory = [];
|
|
2253
3714
|
this.notifyDevTools();
|
|
2254
3715
|
},
|
|
3716
|
+
refreshVersions: () => this.refreshBackendVersions(),
|
|
2255
3717
|
getTableData: async (tableName) => {
|
|
2256
3718
|
try {
|
|
2257
3719
|
const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
|
|
@@ -2266,7 +3728,7 @@ var DevToolsService = class {
|
|
|
2266
3728
|
} catch (e) {
|
|
2267
3729
|
this.logger.error({
|
|
2268
3730
|
err: e,
|
|
2269
|
-
Category: "
|
|
3731
|
+
Category: "sp00ky-client::DevToolsService::exposeToWindow"
|
|
2270
3732
|
}, "Failed to get table data");
|
|
2271
3733
|
return [];
|
|
2272
3734
|
}
|
|
@@ -2298,7 +3760,7 @@ var DevToolsService = class {
|
|
|
2298
3760
|
this.logger.debug({
|
|
2299
3761
|
query,
|
|
2300
3762
|
target,
|
|
2301
|
-
Category: "
|
|
3763
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2302
3764
|
}, "Running query (START)");
|
|
2303
3765
|
const service = target === "remote" ? this.remoteDatabaseService : this.databaseService;
|
|
2304
3766
|
const startTime = Date.now();
|
|
@@ -2309,7 +3771,7 @@ var DevToolsService = class {
|
|
|
2309
3771
|
time: queryTime,
|
|
2310
3772
|
resultType: typeof result,
|
|
2311
3773
|
isArray: Array.isArray(result),
|
|
2312
|
-
Category: "
|
|
3774
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2313
3775
|
}, "Database returned result");
|
|
2314
3776
|
const serializeStart = Date.now();
|
|
2315
3777
|
const serialized = this.serializeForDevTools(result);
|
|
@@ -2317,7 +3779,7 @@ var DevToolsService = class {
|
|
|
2317
3779
|
this.logger.debug({
|
|
2318
3780
|
serializeTime,
|
|
2319
3781
|
serializedLength: JSON.stringify(serialized).length,
|
|
2320
|
-
Category: "
|
|
3782
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2321
3783
|
}, "Serialization complete");
|
|
2322
3784
|
return {
|
|
2323
3785
|
success: true,
|
|
@@ -2329,7 +3791,7 @@ var DevToolsService = class {
|
|
|
2329
3791
|
err: e,
|
|
2330
3792
|
query,
|
|
2331
3793
|
target,
|
|
2332
|
-
Category: "
|
|
3794
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2333
3795
|
}, "Query execution failed");
|
|
2334
3796
|
return {
|
|
2335
3797
|
success: false,
|
|
@@ -2339,23 +3801,52 @@ var DevToolsService = class {
|
|
|
2339
3801
|
}
|
|
2340
3802
|
};
|
|
2341
3803
|
window.postMessage({
|
|
2342
|
-
type: "
|
|
2343
|
-
source: "
|
|
3804
|
+
type: "SP00KY_DETECTED",
|
|
3805
|
+
source: "sp00ky-devtools-page",
|
|
2344
3806
|
data: {
|
|
2345
3807
|
version: this.version,
|
|
2346
3808
|
detected: true
|
|
2347
3809
|
}
|
|
2348
3810
|
}, "*");
|
|
3811
|
+
window.dispatchEvent(new CustomEvent("sp00ky:init"));
|
|
2349
3812
|
}
|
|
2350
3813
|
}
|
|
2351
3814
|
};
|
|
2352
3815
|
|
|
2353
3816
|
//#endregion
|
|
2354
3817
|
//#region src/modules/auth/index.ts
|
|
3818
|
+
/**
|
|
3819
|
+
* Read the `AC` (access-method name) claim from a SurrealDB record-access
|
|
3820
|
+
* JWT without verifying it — we only need the claim, the server enforces the
|
|
3821
|
+
* token. Returns null on any malformed input. The in-browser SSP needs this
|
|
3822
|
+
* to resolve `$access` in table permission predicates (mirrors the session's
|
|
3823
|
+
* `$access` that the server's `fn::query::register` reads).
|
|
3824
|
+
*/
|
|
3825
|
+
function decodeAccessFromToken(token) {
|
|
3826
|
+
try {
|
|
3827
|
+
const payload = token.split(".")[1];
|
|
3828
|
+
if (!payload) return null;
|
|
3829
|
+
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
3830
|
+
b64 += "=".repeat((4 - b64.length % 4) % 4);
|
|
3831
|
+
const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
3832
|
+
const claims = JSON.parse(json);
|
|
3833
|
+
const ac = claims.AC ?? claims.ac;
|
|
3834
|
+
return typeof ac === "string" ? ac : null;
|
|
3835
|
+
} catch {
|
|
3836
|
+
return null;
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
2355
3839
|
var AuthService = class {
|
|
2356
3840
|
token = null;
|
|
2357
3841
|
currentUser = null;
|
|
2358
3842
|
isAuthenticated = false;
|
|
3843
|
+
/**
|
|
3844
|
+
* The record-access method name for the current session (e.g. `"account"`),
|
|
3845
|
+
* derived from the token's `AC` claim. Consumed by the in-browser SSP's
|
|
3846
|
+
* permission injection so `$access`-gated table predicates resolve locally,
|
|
3847
|
+
* mirroring the server's `$access`. Null when logged out.
|
|
3848
|
+
*/
|
|
3849
|
+
access = null;
|
|
2359
3850
|
isLoading = true;
|
|
2360
3851
|
events = createAuthEventSystem();
|
|
2361
3852
|
get eventSystem() {
|
|
@@ -2396,9 +3887,9 @@ var AuthService = class {
|
|
|
2396
3887
|
async check(accessToken) {
|
|
2397
3888
|
this.isLoading = true;
|
|
2398
3889
|
try {
|
|
2399
|
-
const token = accessToken || await this.persistenceClient.get("
|
|
3890
|
+
const token = accessToken || await this.persistenceClient.get("sp00ky_auth_token");
|
|
2400
3891
|
if (!token) {
|
|
2401
|
-
this.logger.debug({ Category: "
|
|
3892
|
+
this.logger.debug({ Category: "sp00ky-client::AuthService::check" }, "No token found in storage or arguments");
|
|
2402
3893
|
this.isLoading = false;
|
|
2403
3894
|
this.isAuthenticated = false;
|
|
2404
3895
|
this.notifyListeners();
|
|
@@ -2411,22 +3902,22 @@ var AuthService = class {
|
|
|
2411
3902
|
if (user && user.id) {
|
|
2412
3903
|
this.logger.info({
|
|
2413
3904
|
user,
|
|
2414
|
-
Category: "
|
|
3905
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2415
3906
|
}, "Auth check complete (via $auth.id)");
|
|
2416
3907
|
await this.setSession(token, user);
|
|
2417
3908
|
} else {
|
|
2418
|
-
this.logger.warn({ Category: "
|
|
3909
|
+
this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "$auth.id empty, attempting manual user fetch");
|
|
2419
3910
|
const manualResult = await this.remote.query("SELECT * FROM user WHERE id = $auth.id LIMIT 1");
|
|
2420
3911
|
const manualItems = Array.isArray(manualResult) && Array.isArray(manualResult[0]) ? manualResult[0] : manualResult;
|
|
2421
3912
|
const manualUser = Array.isArray(manualItems) ? manualItems[0] : manualItems;
|
|
2422
3913
|
if (manualUser && manualUser.id) {
|
|
2423
3914
|
this.logger.info({
|
|
2424
3915
|
user: manualUser,
|
|
2425
|
-
Category: "
|
|
3916
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2426
3917
|
}, "Auth check complete (via manual fetch)");
|
|
2427
3918
|
await this.setSession(token, manualUser);
|
|
2428
3919
|
} else {
|
|
2429
|
-
this.logger.warn({ Category: "
|
|
3920
|
+
this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "Token valid but user not found via fallback");
|
|
2430
3921
|
await this.signOut();
|
|
2431
3922
|
}
|
|
2432
3923
|
}
|
|
@@ -2434,7 +3925,7 @@ var AuthService = class {
|
|
|
2434
3925
|
this.logger.error({
|
|
2435
3926
|
error,
|
|
2436
3927
|
stack: error.stack,
|
|
2437
|
-
Category: "
|
|
3928
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2438
3929
|
}, "Auth check failed");
|
|
2439
3930
|
await this.signOut();
|
|
2440
3931
|
} finally {
|
|
@@ -2448,19 +3939,27 @@ var AuthService = class {
|
|
|
2448
3939
|
this.token = null;
|
|
2449
3940
|
this.currentUser = null;
|
|
2450
3941
|
this.isAuthenticated = false;
|
|
2451
|
-
|
|
3942
|
+
this.access = null;
|
|
3943
|
+
await this.persistenceClient.remove("sp00ky_auth_token");
|
|
2452
3944
|
try {
|
|
2453
3945
|
await this.remote.getClient().invalidate();
|
|
2454
|
-
} catch (
|
|
3946
|
+
} catch (_e) {}
|
|
2455
3947
|
this.notifyListeners();
|
|
2456
3948
|
}
|
|
2457
3949
|
async setSession(token, user) {
|
|
2458
3950
|
this.token = token;
|
|
2459
3951
|
this.currentUser = user;
|
|
2460
3952
|
this.isAuthenticated = true;
|
|
2461
|
-
|
|
3953
|
+
this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
|
|
3954
|
+
await this.persistenceClient.set("sp00ky_auth_token", token);
|
|
2462
3955
|
this.notifyListeners();
|
|
2463
3956
|
}
|
|
3957
|
+
/** Fallback when the token carries no `AC` claim: if the schema defines
|
|
3958
|
+
* exactly one record-access method, assume the session used it. */
|
|
3959
|
+
defaultAccessName() {
|
|
3960
|
+
const names = Object.keys(this.schema.access ?? {});
|
|
3961
|
+
return names.length === 1 ? names[0] : null;
|
|
3962
|
+
}
|
|
2464
3963
|
async signUp(accessName, params) {
|
|
2465
3964
|
const def = this.getAccessDefinition(accessName);
|
|
2466
3965
|
if (!def) throw new Error(`Access definition '${accessName}' not found`);
|
|
@@ -2470,13 +3969,13 @@ var AuthService = class {
|
|
|
2470
3969
|
this.logger.info({
|
|
2471
3970
|
accessName,
|
|
2472
3971
|
runtimeParams,
|
|
2473
|
-
Category: "
|
|
3972
|
+
Category: "sp00ky-client::AuthService::signUp"
|
|
2474
3973
|
}, "Attempting signup");
|
|
2475
3974
|
const { access } = await this.remote.getClient().signup({
|
|
2476
3975
|
access: accessName,
|
|
2477
3976
|
variables: runtimeParams
|
|
2478
3977
|
});
|
|
2479
|
-
this.logger.info({ Category: "
|
|
3978
|
+
this.logger.info({ Category: "sp00ky-client::AuthService::signUp" }, "Signup successful, token received");
|
|
2480
3979
|
await this.check(access);
|
|
2481
3980
|
}
|
|
2482
3981
|
async signIn(accessName, params) {
|
|
@@ -2487,7 +3986,7 @@ var AuthService = class {
|
|
|
2487
3986
|
if (missingParams.length > 0) throw new Error(`Missing required signin params for '${accessName}': ${missingParams.join(", ")}`);
|
|
2488
3987
|
this.logger.info({
|
|
2489
3988
|
accessName,
|
|
2490
|
-
Category: "
|
|
3989
|
+
Category: "sp00ky-client::AuthService::signIn"
|
|
2491
3990
|
}, "Attempting signin");
|
|
2492
3991
|
const { access } = await this.remote.getClient().signin({
|
|
2493
3992
|
access: accessName,
|
|
@@ -2504,6 +4003,12 @@ var StreamProcessorService = class {
|
|
|
2504
4003
|
processor;
|
|
2505
4004
|
isInitialized = false;
|
|
2506
4005
|
receivers = [];
|
|
4006
|
+
batching = false;
|
|
4007
|
+
batchBuffer = /* @__PURE__ */ new Map();
|
|
4008
|
+
sessionAuth = {
|
|
4009
|
+
authId: "",
|
|
4010
|
+
access: ""
|
|
4011
|
+
};
|
|
2507
4012
|
constructor(events, db, persistenceClient, logger) {
|
|
2508
4013
|
this.events = events;
|
|
2509
4014
|
this.db = db;
|
|
@@ -2518,25 +4023,91 @@ var StreamProcessorService = class {
|
|
|
2518
4023
|
this.receivers.push(receiver);
|
|
2519
4024
|
}
|
|
2520
4025
|
notifyUpdates(updates) {
|
|
4026
|
+
if (this.batching) {
|
|
4027
|
+
for (const update of updates) {
|
|
4028
|
+
const prev = this.batchBuffer.get(update.queryHash);
|
|
4029
|
+
const sum = (a, b) => (a ?? 0) + (b ?? 0);
|
|
4030
|
+
this.batchBuffer.set(update.queryHash, {
|
|
4031
|
+
...update,
|
|
4032
|
+
op: "CREATE",
|
|
4033
|
+
materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
|
|
4034
|
+
storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
|
|
4035
|
+
circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
|
|
4036
|
+
transformMs: sum(prev?.transformMs, update.transformMs)
|
|
4037
|
+
});
|
|
4038
|
+
}
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
this.dispatchUpdates(updates);
|
|
4042
|
+
}
|
|
4043
|
+
dispatchUpdates(updates) {
|
|
2521
4044
|
for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
|
|
2522
4045
|
}
|
|
2523
4046
|
/**
|
|
4047
|
+
* Ingest a batch of record changes as a single bulk operation, firing only
|
|
4048
|
+
* one coalesced `StreamUpdate` per affected query once every record has been
|
|
4049
|
+
* ingested (instead of one update per record). Use this whenever multiple
|
|
4050
|
+
* records land at once — e.g. sync fetching N missing rows — so a list query
|
|
4051
|
+
* re-runs and the UI re-renders once for the whole batch rather than
|
|
4052
|
+
* row-by-row.
|
|
4053
|
+
*
|
|
4054
|
+
* Internally opens a coalescing window, ingests each record, then flushes;
|
|
4055
|
+
* processor state is persisted once for the whole batch. No-op for an empty
|
|
4056
|
+
* batch.
|
|
4057
|
+
*/
|
|
4058
|
+
ingestMany(records) {
|
|
4059
|
+
if (records.length === 0) return;
|
|
4060
|
+
this.beginCoalescing();
|
|
4061
|
+
try {
|
|
4062
|
+
for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
|
|
4063
|
+
} finally {
|
|
4064
|
+
this.flushCoalescing();
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
/**
|
|
4068
|
+
* Open a coalescing window. While open, the per-record stream updates
|
|
4069
|
+
* emitted by `ingest` are buffered (one entry per queryHash) instead of
|
|
4070
|
+
* dispatched. Always paired with `flushCoalescing()` in a try/finally by
|
|
4071
|
+
* `ingestMany` so the window always closes — otherwise the processor stays
|
|
4072
|
+
* stuck buffering forever.
|
|
4073
|
+
*
|
|
4074
|
+
* No-op if a window is already open (nested batches aren't expected here).
|
|
4075
|
+
*/
|
|
4076
|
+
beginCoalescing() {
|
|
4077
|
+
if (this.batching) return;
|
|
4078
|
+
this.batching = true;
|
|
4079
|
+
this.batchBuffer.clear();
|
|
4080
|
+
}
|
|
4081
|
+
/**
|
|
4082
|
+
* Close the coalescing window and flush: dispatch one coalesced
|
|
4083
|
+
* `StreamUpdate` per buffered queryHash, then persist processor state once
|
|
4084
|
+
* for the whole batch (instead of once per ingest).
|
|
4085
|
+
*/
|
|
4086
|
+
flushCoalescing() {
|
|
4087
|
+
if (!this.batching) return;
|
|
4088
|
+
this.batching = false;
|
|
4089
|
+
const buffered = Array.from(this.batchBuffer.values());
|
|
4090
|
+
this.batchBuffer.clear();
|
|
4091
|
+
if (buffered.length > 0) this.dispatchUpdates(buffered);
|
|
4092
|
+
this.saveState();
|
|
4093
|
+
}
|
|
4094
|
+
/**
|
|
2524
4095
|
* Initialize the WASM module and processor.
|
|
2525
4096
|
* This must be called before using other methods.
|
|
2526
4097
|
*/
|
|
2527
4098
|
async init() {
|
|
2528
4099
|
if (this.isInitialized) return;
|
|
2529
|
-
this.logger.info({ Category: "
|
|
4100
|
+
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initializing WASM...");
|
|
2530
4101
|
try {
|
|
2531
4102
|
await init();
|
|
2532
|
-
this.processor = new
|
|
4103
|
+
this.processor = new Sp00kyProcessor();
|
|
2533
4104
|
await this.loadState();
|
|
2534
4105
|
this.isInitialized = true;
|
|
2535
|
-
this.logger.info({ Category: "
|
|
4106
|
+
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initialized successfully");
|
|
2536
4107
|
} catch (e) {
|
|
2537
4108
|
this.logger.error({
|
|
2538
4109
|
error: e,
|
|
2539
|
-
Category: "
|
|
4110
|
+
Category: "sp00ky-client::StreamProcessorService::init"
|
|
2540
4111
|
}, "Failed to initialize");
|
|
2541
4112
|
throw e;
|
|
2542
4113
|
}
|
|
@@ -2544,37 +4115,75 @@ var StreamProcessorService = class {
|
|
|
2544
4115
|
async loadState() {
|
|
2545
4116
|
if (!this.processor) return;
|
|
2546
4117
|
try {
|
|
2547
|
-
const result = await this.persistenceClient.get("
|
|
4118
|
+
const result = await this.persistenceClient.get("_00_stream_processor_state");
|
|
2548
4119
|
if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
|
|
2549
4120
|
const state = result[0][0].state;
|
|
2550
4121
|
this.logger.info({
|
|
2551
4122
|
stateLength: state.length,
|
|
2552
|
-
Category: "
|
|
4123
|
+
Category: "sp00ky-client::StreamProcessorService::loadState"
|
|
2553
4124
|
}, "Loading state from DB");
|
|
2554
4125
|
if (typeof this.processor.load_state === "function") this.processor.load_state(state);
|
|
2555
|
-
else this.logger.warn({ Category: "
|
|
2556
|
-
} else this.logger.info({ Category: "
|
|
4126
|
+
else this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "load_state method not found on processor");
|
|
4127
|
+
} else this.logger.info({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "No saved state found");
|
|
2557
4128
|
} catch (e) {
|
|
2558
4129
|
this.logger.error({
|
|
2559
4130
|
error: e,
|
|
2560
|
-
Category: "
|
|
4131
|
+
Category: "sp00ky-client::StreamProcessorService::loadState"
|
|
2561
4132
|
}, "Failed to load state");
|
|
2562
4133
|
}
|
|
2563
4134
|
}
|
|
4135
|
+
/**
|
|
4136
|
+
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
|
4137
|
+
* Must run after the processor exists and before any `register_view`, else
|
|
4138
|
+
* non-`_00_` tables are default-denied and registration fails.
|
|
4139
|
+
*/
|
|
4140
|
+
setPermissions(permissions) {
|
|
4141
|
+
if (!this.processor) return;
|
|
4142
|
+
if (typeof this.processor.set_permissions !== "function") {
|
|
4143
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::setPermissions" }, "set_permissions not found on processor (stale WASM build?)");
|
|
4144
|
+
return;
|
|
4145
|
+
}
|
|
4146
|
+
this.processor.set_permissions(permissions);
|
|
4147
|
+
this.logger.info({
|
|
4148
|
+
tables: Object.keys(permissions).length,
|
|
4149
|
+
Category: "sp00ky-client::StreamProcessorService::setPermissions"
|
|
4150
|
+
}, "Seeded table permissions");
|
|
4151
|
+
}
|
|
4152
|
+
/**
|
|
4153
|
+
* Set the current session's auth identity for permission injection,
|
|
4154
|
+
* mirroring the server's `fn::query::register`
|
|
4155
|
+
* (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
|
|
4156
|
+
* Stored as strings (empty when logged out) and applied to every
|
|
4157
|
+
* `register_view` in {@link registerQueryPlan}. Must be set before a
|
|
4158
|
+
* `$auth`-gated query registers (and re-set on auth state changes), or the
|
|
4159
|
+
* in-browser SSP's `permission_inject` rejects it with
|
|
4160
|
+
* "requires $auth but registration params lack it".
|
|
4161
|
+
*/
|
|
4162
|
+
setSessionAuth(authId, access) {
|
|
4163
|
+
this.sessionAuth = {
|
|
4164
|
+
authId: authId ?? "",
|
|
4165
|
+
access: access ?? ""
|
|
4166
|
+
};
|
|
4167
|
+
this.logger.debug({
|
|
4168
|
+
authId: this.sessionAuth.authId,
|
|
4169
|
+
access: this.sessionAuth.access,
|
|
4170
|
+
Category: "sp00ky-client::StreamProcessorService::setSessionAuth"
|
|
4171
|
+
}, "Session auth context updated");
|
|
4172
|
+
}
|
|
2564
4173
|
async saveState() {
|
|
2565
4174
|
if (!this.processor) return;
|
|
2566
4175
|
try {
|
|
2567
4176
|
if (typeof this.processor.save_state === "function") {
|
|
2568
4177
|
const state = this.processor.save_state();
|
|
2569
4178
|
if (state) {
|
|
2570
|
-
await this.persistenceClient.set("
|
|
2571
|
-
this.logger.trace({ Category: "
|
|
4179
|
+
await this.persistenceClient.set("_00_stream_processor_state", state);
|
|
4180
|
+
this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
|
|
2572
4181
|
}
|
|
2573
4182
|
}
|
|
2574
4183
|
} catch (e) {
|
|
2575
4184
|
this.logger.error({
|
|
2576
4185
|
error: e,
|
|
2577
|
-
Category: "
|
|
4186
|
+
Category: "sp00ky-client::StreamProcessorService::saveState"
|
|
2578
4187
|
}, "Failed to save state");
|
|
2579
4188
|
}
|
|
2580
4189
|
}
|
|
@@ -2588,36 +4197,43 @@ var StreamProcessorService = class {
|
|
|
2588
4197
|
table,
|
|
2589
4198
|
op,
|
|
2590
4199
|
id,
|
|
2591
|
-
Category: "
|
|
4200
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2592
4201
|
}, "Ingesting into ssp");
|
|
2593
4202
|
if (!this.processor) {
|
|
2594
|
-
this.logger.warn({ Category: "
|
|
4203
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingest" }, "Not initialized, skipping ingest");
|
|
2595
4204
|
return [];
|
|
2596
4205
|
}
|
|
2597
4206
|
try {
|
|
2598
4207
|
const normalizedRecord = this.normalizeValue(record);
|
|
4208
|
+
const t0 = performance.now();
|
|
2599
4209
|
const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
|
|
4210
|
+
const materializationTimeMs = performance.now() - t0;
|
|
2600
4211
|
this.logger.debug({
|
|
2601
4212
|
table,
|
|
2602
4213
|
op,
|
|
2603
4214
|
id,
|
|
2604
4215
|
rawUpdates: rawUpdates.length,
|
|
2605
|
-
|
|
4216
|
+
materializationTimeMs,
|
|
4217
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2606
4218
|
}, "Ingesting into ssp done");
|
|
2607
4219
|
if (rawUpdates && Array.isArray(rawUpdates) && rawUpdates.length > 0) {
|
|
2608
4220
|
const updates = rawUpdates.map((u) => ({
|
|
2609
4221
|
queryHash: u.query_id,
|
|
2610
4222
|
localArray: u.result_data,
|
|
2611
|
-
op
|
|
4223
|
+
op,
|
|
4224
|
+
materializationTimeMs,
|
|
4225
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
4226
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
4227
|
+
transformMs: u.timing_transform_ms
|
|
2612
4228
|
}));
|
|
2613
4229
|
this.notifyUpdates(updates);
|
|
2614
4230
|
}
|
|
2615
|
-
this.saveState();
|
|
4231
|
+
if (!this.batching) this.saveState();
|
|
2616
4232
|
return rawUpdates;
|
|
2617
4233
|
} catch (e) {
|
|
2618
4234
|
this.logger.error({
|
|
2619
4235
|
error: e,
|
|
2620
|
-
Category: "
|
|
4236
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2621
4237
|
}, "Ingesting into ssp failed");
|
|
2622
4238
|
}
|
|
2623
4239
|
return [];
|
|
@@ -2628,46 +4244,55 @@ var StreamProcessorService = class {
|
|
|
2628
4244
|
*/
|
|
2629
4245
|
registerQueryPlan(queryPlan) {
|
|
2630
4246
|
if (!this.processor) {
|
|
2631
|
-
this.logger.warn({ Category: "
|
|
4247
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan" }, "Not initialized, skipping registration");
|
|
2632
4248
|
return;
|
|
2633
4249
|
}
|
|
2634
4250
|
this.logger.debug({
|
|
2635
4251
|
queryHash: queryPlan.queryHash,
|
|
2636
4252
|
surql: queryPlan.surql,
|
|
2637
4253
|
params: queryPlan.params,
|
|
2638
|
-
Category: "
|
|
4254
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2639
4255
|
}, "Registering query plan");
|
|
2640
4256
|
try {
|
|
2641
|
-
const
|
|
4257
|
+
const paramsWithAuth = {
|
|
4258
|
+
...this.normalizeValue(queryPlan.params),
|
|
4259
|
+
auth: { id: this.sessionAuth.authId },
|
|
4260
|
+
access: this.sessionAuth.access
|
|
4261
|
+
};
|
|
2642
4262
|
const initialUpdate = this.processor.register_view({
|
|
2643
4263
|
id: queryPlan.queryHash,
|
|
2644
4264
|
surql: queryPlan.surql,
|
|
2645
|
-
params:
|
|
4265
|
+
params: paramsWithAuth,
|
|
2646
4266
|
clientId: "local",
|
|
2647
4267
|
ttl: queryPlan.ttl.toString(),
|
|
2648
4268
|
lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2649
4269
|
});
|
|
2650
4270
|
this.logger.debug({
|
|
2651
4271
|
initialUpdate,
|
|
2652
|
-
Category: "
|
|
4272
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2653
4273
|
}, "register_view result");
|
|
2654
4274
|
if (!initialUpdate) throw new Error("Failed to register query plan");
|
|
2655
4275
|
const update = {
|
|
2656
4276
|
queryHash: initialUpdate.query_id,
|
|
2657
|
-
localArray: initialUpdate.result_data
|
|
4277
|
+
localArray: initialUpdate.result_data,
|
|
4278
|
+
registration: {
|
|
4279
|
+
parseMs: initialUpdate.timing_parse_ms ?? 0,
|
|
4280
|
+
planMs: initialUpdate.timing_plan_ms ?? 0,
|
|
4281
|
+
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
|
|
4282
|
+
}
|
|
2658
4283
|
};
|
|
2659
4284
|
this.saveState();
|
|
2660
4285
|
this.logger.debug({
|
|
2661
4286
|
queryHash: queryPlan.queryHash,
|
|
2662
4287
|
surql: queryPlan.surql,
|
|
2663
4288
|
params: queryPlan.params,
|
|
2664
|
-
Category: "
|
|
4289
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2665
4290
|
}, "Registered query plan");
|
|
2666
4291
|
return update;
|
|
2667
4292
|
} catch (e) {
|
|
2668
4293
|
this.logger.error({
|
|
2669
4294
|
error: e,
|
|
2670
|
-
Category: "
|
|
4295
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2671
4296
|
}, "Error registering query plan");
|
|
2672
4297
|
throw e;
|
|
2673
4298
|
}
|
|
@@ -2683,13 +4308,14 @@ var StreamProcessorService = class {
|
|
|
2683
4308
|
} catch (e) {
|
|
2684
4309
|
this.logger.error({
|
|
2685
4310
|
error: e,
|
|
2686
|
-
Category: "
|
|
4311
|
+
Category: "sp00ky-client::StreamProcessorService::unregisterQueryPlan"
|
|
2687
4312
|
}, "Error unregistering query plan");
|
|
2688
4313
|
}
|
|
2689
4314
|
}
|
|
2690
4315
|
normalizeValue(value) {
|
|
2691
4316
|
if (value === null || value === void 0) return value;
|
|
2692
4317
|
if (typeof value === "object") {
|
|
4318
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return null;
|
|
2693
4319
|
const hasTable = "table" in value && typeof value.table?.toString === "function";
|
|
2694
4320
|
const hasId = "id" in value;
|
|
2695
4321
|
const hasToString = typeof value.toString === "function";
|
|
@@ -2698,7 +4324,7 @@ var StreamProcessorService = class {
|
|
|
2698
4324
|
const result = value.toString();
|
|
2699
4325
|
this.logger.trace({
|
|
2700
4326
|
result,
|
|
2701
|
-
Category: "
|
|
4327
|
+
Category: "sp00ky-client::StreamProcessorService::normalizeValue"
|
|
2702
4328
|
}, "RecordId detected");
|
|
2703
4329
|
return result;
|
|
2704
4330
|
}
|
|
@@ -2714,6 +4340,51 @@ var StreamProcessorService = class {
|
|
|
2714
4340
|
}
|
|
2715
4341
|
};
|
|
2716
4342
|
|
|
4343
|
+
//#endregion
|
|
4344
|
+
//#region src/services/stream-processor/permissions.ts
|
|
4345
|
+
/**
|
|
4346
|
+
* Extract per-table `select` permission predicates from a raw `.surql` schema.
|
|
4347
|
+
*
|
|
4348
|
+
* The in-browser SSP default-denies any non-`_00_` table that has no permission
|
|
4349
|
+
* predicate registered (see `permission_inject::build_predicate`). The client
|
|
4350
|
+
* already holds the full schema text (`config.schemaSurql`), so we parse each
|
|
4351
|
+
* `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
|
|
4352
|
+
* processor via `set_permissions` at boot — mirroring the native path that
|
|
4353
|
+
* seeds the same map from `INFO FOR DB`.
|
|
4354
|
+
*
|
|
4355
|
+
* Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
|
|
4356
|
+
* expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
|
|
4357
|
+
* `select` permission).
|
|
4358
|
+
*/
|
|
4359
|
+
function extractSelectPermissions(schemaSurql) {
|
|
4360
|
+
const out = {};
|
|
4361
|
+
if (!schemaSurql) return out;
|
|
4362
|
+
const cleaned = schemaSurql.replace(/--[^\n]*/g, "");
|
|
4363
|
+
const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
|
|
4364
|
+
let m;
|
|
4365
|
+
while ((m = tableStmt.exec(cleaned)) !== null) {
|
|
4366
|
+
const table = m[1];
|
|
4367
|
+
const body = m[2];
|
|
4368
|
+
out[table] = selectPredicateFromBody(body);
|
|
4369
|
+
}
|
|
4370
|
+
return out;
|
|
4371
|
+
}
|
|
4372
|
+
function selectPredicateFromBody(body) {
|
|
4373
|
+
const permIdx = body.search(/\bPERMISSIONS\b/i);
|
|
4374
|
+
if (permIdx === -1) return "false";
|
|
4375
|
+
const perms = body.slice(permIdx + 11).trim();
|
|
4376
|
+
if (/^FULL\b/i.test(perms)) return "true";
|
|
4377
|
+
if (/^NONE\b/i.test(perms)) return "false";
|
|
4378
|
+
const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
|
|
4379
|
+
for (const group of groups) {
|
|
4380
|
+
const where = group.search(/\bWHERE\b/i);
|
|
4381
|
+
if (where === -1) continue;
|
|
4382
|
+
const actions = group.slice(0, where).toLowerCase();
|
|
4383
|
+
if (/\bselect\b/.test(actions)) return group.slice(where + 5).trim();
|
|
4384
|
+
}
|
|
4385
|
+
return "false";
|
|
4386
|
+
}
|
|
4387
|
+
|
|
2717
4388
|
//#endregion
|
|
2718
4389
|
//#region src/modules/cache/index.ts
|
|
2719
4390
|
/**
|
|
@@ -2741,7 +4412,7 @@ var CacheModule = class {
|
|
|
2741
4412
|
this.logger.debug({
|
|
2742
4413
|
queryHash: update.queryHash,
|
|
2743
4414
|
arrayLength: update.localArray?.length,
|
|
2744
|
-
Category: "
|
|
4415
|
+
Category: "sp00ky-client::CacheModule::onStreamUpdate"
|
|
2745
4416
|
}, "Stream update received");
|
|
2746
4417
|
this.streamUpdateCallback(update);
|
|
2747
4418
|
}
|
|
@@ -2764,7 +4435,7 @@ var CacheModule = class {
|
|
|
2764
4435
|
if (records.length === 0) return;
|
|
2765
4436
|
this.logger.debug({
|
|
2766
4437
|
count: records.length,
|
|
2767
|
-
Category: "
|
|
4438
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2768
4439
|
}, "Saving record batch");
|
|
2769
4440
|
try {
|
|
2770
4441
|
const populatedRecords = records.map((record) => {
|
|
@@ -2773,13 +4444,13 @@ var CacheModule = class {
|
|
|
2773
4444
|
...record,
|
|
2774
4445
|
record: {
|
|
2775
4446
|
...record.record,
|
|
2776
|
-
|
|
4447
|
+
_00_rv: record.version
|
|
2777
4448
|
}
|
|
2778
4449
|
};
|
|
2779
4450
|
});
|
|
2780
4451
|
if (!skipDbInsert) {
|
|
2781
4452
|
const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
|
|
2782
|
-
return surql.
|
|
4453
|
+
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
2783
4454
|
})));
|
|
2784
4455
|
const params = populatedRecords.reduce((acc, record, i) => {
|
|
2785
4456
|
const { id, ...content } = record.record;
|
|
@@ -2791,20 +4462,26 @@ var CacheModule = class {
|
|
|
2791
4462
|
}, {});
|
|
2792
4463
|
await this.local.execute(query, params);
|
|
2793
4464
|
}
|
|
2794
|
-
|
|
4465
|
+
const bulk = populatedRecords.map((record) => {
|
|
2795
4466
|
const recordId = encodeRecordId(record.record.id);
|
|
2796
4467
|
this.versionLookups[recordId] = record.version;
|
|
2797
|
-
|
|
2798
|
-
|
|
4468
|
+
return {
|
|
4469
|
+
table: record.table,
|
|
4470
|
+
op: record.op,
|
|
4471
|
+
id: recordId,
|
|
4472
|
+
record: record.record
|
|
4473
|
+
};
|
|
4474
|
+
});
|
|
4475
|
+
this.streamProcessor.ingestMany(bulk);
|
|
2799
4476
|
this.logger.debug({
|
|
2800
4477
|
count: records.length,
|
|
2801
|
-
Category: "
|
|
4478
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2802
4479
|
}, "Batch saved successfully");
|
|
2803
4480
|
} catch (err) {
|
|
2804
4481
|
this.logger.error({
|
|
2805
4482
|
err,
|
|
2806
4483
|
count: records.length,
|
|
2807
|
-
Category: "
|
|
4484
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2808
4485
|
}, "Failed to save batch");
|
|
2809
4486
|
throw err;
|
|
2810
4487
|
}
|
|
@@ -2812,27 +4489,27 @@ var CacheModule = class {
|
|
|
2812
4489
|
/**
|
|
2813
4490
|
* Delete a record from local DB and ingest deletion into DBSP
|
|
2814
4491
|
*/
|
|
2815
|
-
async delete(table, id, skipDbDelete = false) {
|
|
4492
|
+
async delete(table, id, skipDbDelete = false, recordData = {}) {
|
|
2816
4493
|
this.logger.debug({
|
|
2817
4494
|
table,
|
|
2818
4495
|
id,
|
|
2819
|
-
Category: "
|
|
4496
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2820
4497
|
}, "Deleting record");
|
|
2821
4498
|
try {
|
|
2822
4499
|
if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) });
|
|
2823
4500
|
delete this.versionLookups[id];
|
|
2824
|
-
|
|
4501
|
+
this.streamProcessor.ingest(table, "DELETE", id, recordData);
|
|
2825
4502
|
this.logger.debug({
|
|
2826
4503
|
table,
|
|
2827
4504
|
id,
|
|
2828
|
-
Category: "
|
|
4505
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2829
4506
|
}, "Record deleted successfully");
|
|
2830
4507
|
} catch (err) {
|
|
2831
4508
|
this.logger.error({
|
|
2832
4509
|
err,
|
|
2833
4510
|
table,
|
|
2834
4511
|
id,
|
|
2835
|
-
Category: "
|
|
4512
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2836
4513
|
}, "Failed to delete record");
|
|
2837
4514
|
throw err;
|
|
2838
4515
|
}
|
|
@@ -2845,7 +4522,7 @@ var CacheModule = class {
|
|
|
2845
4522
|
this.logger.debug({
|
|
2846
4523
|
queryHash: config.queryHash,
|
|
2847
4524
|
surql: config.surql,
|
|
2848
|
-
Category: "
|
|
4525
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2849
4526
|
}, "Registering query");
|
|
2850
4527
|
try {
|
|
2851
4528
|
const update = this.streamProcessor.registerQueryPlan({
|
|
@@ -2862,14 +4539,17 @@ var CacheModule = class {
|
|
|
2862
4539
|
this.logger.debug({
|
|
2863
4540
|
queryHash: config.queryHash,
|
|
2864
4541
|
arrayLength: update.localArray?.length,
|
|
2865
|
-
Category: "
|
|
4542
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2866
4543
|
}, "Query registered successfully");
|
|
2867
|
-
return {
|
|
4544
|
+
return {
|
|
4545
|
+
localArray: update.localArray,
|
|
4546
|
+
registrationTimings: update.registration
|
|
4547
|
+
};
|
|
2868
4548
|
} catch (err) {
|
|
2869
4549
|
this.logger.error({
|
|
2870
4550
|
err,
|
|
2871
4551
|
queryHash: config.queryHash,
|
|
2872
|
-
Category: "
|
|
4552
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2873
4553
|
}, "Failed to register query");
|
|
2874
4554
|
throw err;
|
|
2875
4555
|
}
|
|
@@ -2880,24 +4560,639 @@ var CacheModule = class {
|
|
|
2880
4560
|
unregisterQuery(queryHash) {
|
|
2881
4561
|
this.logger.debug({
|
|
2882
4562
|
queryHash,
|
|
2883
|
-
Category: "
|
|
4563
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2884
4564
|
}, "Unregistering query");
|
|
2885
4565
|
try {
|
|
2886
4566
|
this.streamProcessor.unregisterQueryPlan(queryHash);
|
|
2887
4567
|
this.logger.debug({
|
|
2888
4568
|
queryHash,
|
|
2889
|
-
Category: "
|
|
4569
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2890
4570
|
}, "Query unregistered successfully");
|
|
2891
4571
|
} catch (err) {
|
|
2892
4572
|
this.logger.error({
|
|
2893
4573
|
err,
|
|
2894
4574
|
queryHash,
|
|
2895
|
-
Category: "
|
|
4575
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2896
4576
|
}, "Failed to unregister query");
|
|
2897
4577
|
}
|
|
2898
4578
|
}
|
|
2899
4579
|
};
|
|
2900
4580
|
|
|
4581
|
+
//#endregion
|
|
4582
|
+
//#region src/modules/crdt/crdt-field.ts
|
|
4583
|
+
const CURSOR_COLORS = [
|
|
4584
|
+
"#3b82f6",
|
|
4585
|
+
"#ef4444",
|
|
4586
|
+
"#22c55e",
|
|
4587
|
+
"#f59e0b",
|
|
4588
|
+
"#8b5cf6",
|
|
4589
|
+
"#ec4899",
|
|
4590
|
+
"#14b8a6",
|
|
4591
|
+
"#f97316"
|
|
4592
|
+
];
|
|
4593
|
+
function cursorColorFromName(name) {
|
|
4594
|
+
let hash = 0;
|
|
4595
|
+
for (let i = 0; i < name.length; i++) hash = (hash << 5) - hash + name.charCodeAt(i) | 0;
|
|
4596
|
+
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
4597
|
+
}
|
|
4598
|
+
var CrdtField = class {
|
|
4599
|
+
doc;
|
|
4600
|
+
pushTimer = null;
|
|
4601
|
+
local = null;
|
|
4602
|
+
remote = null;
|
|
4603
|
+
recordId = null;
|
|
4604
|
+
sessionId = "";
|
|
4605
|
+
unsubscribe = null;
|
|
4606
|
+
lastPushTime = 0;
|
|
4607
|
+
lastCursorPushTime = 0;
|
|
4608
|
+
loadedFromCrdt = false;
|
|
4609
|
+
pushRetryCount = 0;
|
|
4610
|
+
logger;
|
|
4611
|
+
cursorsEnabled;
|
|
4612
|
+
/** Remote-push debounce. Local writes happen immediately on every Loro
|
|
4613
|
+
* update; the remote UPSERT is coalesced over this window. Configured
|
|
4614
|
+
* via `Sp00kyConfig.crdtDebounceMs`, default 500. */
|
|
4615
|
+
remoteDebounceMs = 500;
|
|
4616
|
+
_onCursorUpdate = null;
|
|
4617
|
+
pendingCursorUpdate = null;
|
|
4618
|
+
/** Callback set by the editor to receive remote cursor updates.
|
|
4619
|
+
* Any cursor data that arrived before this callback was set will be replayed. */
|
|
4620
|
+
set onCursorUpdate(cb) {
|
|
4621
|
+
this._onCursorUpdate = cb;
|
|
4622
|
+
if (cb && this.pendingCursorUpdate) {
|
|
4623
|
+
try {
|
|
4624
|
+
cb(this.pendingCursorUpdate);
|
|
4625
|
+
} catch (e) {
|
|
4626
|
+
this.logger?.warn({
|
|
4627
|
+
error: e,
|
|
4628
|
+
Category: "sp00ky-client::CrdtField::onCursorUpdate"
|
|
4629
|
+
}, "Failed to replay pending cursor update");
|
|
4630
|
+
}
|
|
4631
|
+
this.pendingCursorUpdate = null;
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
get onCursorUpdate() {
|
|
4635
|
+
return this._onCursorUpdate;
|
|
4636
|
+
}
|
|
4637
|
+
constructor(fieldName, cursorsEnabled, initialState, logger) {
|
|
4638
|
+
this.fieldName = fieldName;
|
|
4639
|
+
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_]*`);
|
|
4640
|
+
this.logger = logger ?? null;
|
|
4641
|
+
this.cursorsEnabled = cursorsEnabled;
|
|
4642
|
+
this.doc = new LoroDoc();
|
|
4643
|
+
if (initialState && initialState.length > 0) try {
|
|
4644
|
+
this.doc.import(initialState);
|
|
4645
|
+
this.loadedFromCrdt = true;
|
|
4646
|
+
} catch (e) {
|
|
4647
|
+
this.logger?.warn({
|
|
4648
|
+
error: e,
|
|
4649
|
+
fieldName,
|
|
4650
|
+
Category: "sp00ky-client::CrdtField::constructor"
|
|
4651
|
+
}, "Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text");
|
|
4652
|
+
}
|
|
4653
|
+
}
|
|
4654
|
+
getDoc() {
|
|
4655
|
+
return this.doc;
|
|
4656
|
+
}
|
|
4657
|
+
/** Whether the LoroDoc was loaded from saved CRDT state */
|
|
4658
|
+
hasContent() {
|
|
4659
|
+
return this.loadedFromCrdt;
|
|
4660
|
+
}
|
|
4661
|
+
startSync(local, remote, recordId, sessionId, debounceMs) {
|
|
4662
|
+
this.local = local;
|
|
4663
|
+
this.remote = remote;
|
|
4664
|
+
this.recordId = recordId;
|
|
4665
|
+
this.sessionId = sessionId;
|
|
4666
|
+
this.remoteDebounceMs = debounceMs;
|
|
4667
|
+
this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
|
|
4668
|
+
this.persistLocal();
|
|
4669
|
+
this.scheduleRemotePush();
|
|
4670
|
+
});
|
|
4671
|
+
}
|
|
4672
|
+
stopSync() {
|
|
4673
|
+
if (this.unsubscribe) {
|
|
4674
|
+
this.unsubscribe();
|
|
4675
|
+
this.unsubscribe = null;
|
|
4676
|
+
}
|
|
4677
|
+
if (this.pushTimer) {
|
|
4678
|
+
clearTimeout(this.pushTimer);
|
|
4679
|
+
this.pushTimer = null;
|
|
4680
|
+
}
|
|
4681
|
+
if (this.remote && this.recordId) this.pushToRemote();
|
|
4682
|
+
}
|
|
4683
|
+
importRemote(state) {
|
|
4684
|
+
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
4685
|
+
try {
|
|
4686
|
+
this.doc.import(state);
|
|
4687
|
+
this.persistLocal();
|
|
4688
|
+
} catch (e) {
|
|
4689
|
+
this.logger?.warn({
|
|
4690
|
+
error: e,
|
|
4691
|
+
Category: "sp00ky-client::CrdtField::importRemote"
|
|
4692
|
+
}, "Failed to import remote CRDT state");
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
exportSnapshot() {
|
|
4696
|
+
return this.doc.export({ mode: "snapshot" });
|
|
4697
|
+
}
|
|
4698
|
+
/** Push this session's cursor blob into the parent row at
|
|
4699
|
+
* `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
|
|
4700
|
+
* field — the editor still calls this method optimistically, but
|
|
4701
|
+
* without `@cursor` on the schema there's nowhere to store the blob.
|
|
4702
|
+
* The UPDATE itself fires the parent table's LIVE feed, so other
|
|
4703
|
+
* browsers receive the cursor change without a separate `_00_rv` bump. */
|
|
4704
|
+
async pushCursorState(encoded) {
|
|
4705
|
+
if (!this.remote || !this.recordId) return;
|
|
4706
|
+
if (!this.cursorsEnabled) return;
|
|
4707
|
+
this.lastCursorPushTime = Date.now();
|
|
4708
|
+
try {
|
|
4709
|
+
const state = encodeBase64(encoded);
|
|
4710
|
+
await this.remote.query(`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`, {
|
|
4711
|
+
id: parseRecordIdString(this.recordId),
|
|
4712
|
+
sid: this.sessionId,
|
|
4713
|
+
state
|
|
4714
|
+
});
|
|
4715
|
+
} catch (e) {
|
|
4716
|
+
this.logger?.warn({
|
|
4717
|
+
error: e,
|
|
4718
|
+
Category: "sp00ky-client::CrdtField::pushCursorState"
|
|
4719
|
+
}, "Failed to push cursor state");
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
/** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
|
|
4723
|
+
importRemoteCursor(base64State) {
|
|
4724
|
+
if (Date.now() - this.lastCursorPushTime < 300) return;
|
|
4725
|
+
try {
|
|
4726
|
+
const data = decodeBase64(base64State);
|
|
4727
|
+
if (this._onCursorUpdate) this._onCursorUpdate(data);
|
|
4728
|
+
else this.pendingCursorUpdate = data;
|
|
4729
|
+
} catch (e) {
|
|
4730
|
+
this.logger?.warn({
|
|
4731
|
+
error: e,
|
|
4732
|
+
Category: "sp00ky-client::CrdtField::importRemoteCursor"
|
|
4733
|
+
}, "Failed to apply remote cursor data");
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
scheduleRemotePush() {
|
|
4737
|
+
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
4738
|
+
this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
|
|
4739
|
+
}
|
|
4740
|
+
/** SET path inside a parent row for the current snapshot. `@crdt`-only
|
|
4741
|
+
* fields hold the snapshot directly (`<field>`); `@crdt @cursor`
|
|
4742
|
+
* fields hold a `{ state, cursors }` object so the snapshot lives at
|
|
4743
|
+
* `<field>.state` next to per-session cursor blobs. */
|
|
4744
|
+
statePath() {
|
|
4745
|
+
return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
|
|
4746
|
+
}
|
|
4747
|
+
/** Mirror the LoroDoc snapshot into the parent row locally. Runs on
|
|
4748
|
+
* every local update and every remote import so reloads (online or
|
|
4749
|
+
* offline) see the freshest content immediately. Failures are
|
|
4750
|
+
* swallowed — a stale local write must never block user input. */
|
|
4751
|
+
async persistLocal() {
|
|
4752
|
+
if (!this.local || !this.recordId) return;
|
|
4753
|
+
try {
|
|
4754
|
+
await this.local.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
4755
|
+
id: parseRecordIdString(this.recordId),
|
|
4756
|
+
state: this.exportSnapshot()
|
|
4757
|
+
});
|
|
4758
|
+
} catch (e) {
|
|
4759
|
+
this.logger?.debug({
|
|
4760
|
+
error: e,
|
|
4761
|
+
Category: "sp00ky-client::CrdtField::persistLocal"
|
|
4762
|
+
}, "Local CRDT persist failed (best-effort)");
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
async pushToRemote() {
|
|
4766
|
+
if (!this.remote || !this.recordId) return;
|
|
4767
|
+
this.lastPushTime = Date.now();
|
|
4768
|
+
try {
|
|
4769
|
+
await this.remote.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
4770
|
+
id: parseRecordIdString(this.recordId),
|
|
4771
|
+
state: this.exportSnapshot()
|
|
4772
|
+
});
|
|
4773
|
+
this.pushRetryCount = 0;
|
|
4774
|
+
} catch (e) {
|
|
4775
|
+
this.logger?.warn({
|
|
4776
|
+
error: e,
|
|
4777
|
+
Category: "sp00ky-client::CrdtField::pushToRemote"
|
|
4778
|
+
}, "Failed to push CRDT state to remote");
|
|
4779
|
+
if (this.pushRetryCount < 2) {
|
|
4780
|
+
this.pushRetryCount++;
|
|
4781
|
+
this.scheduleRemotePush();
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
};
|
|
4786
|
+
function decodeBase64(b64) {
|
|
4787
|
+
if (typeof atob === "function") {
|
|
4788
|
+
const binary = atob(b64);
|
|
4789
|
+
const bytes = new Uint8Array(binary.length);
|
|
4790
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
4791
|
+
return bytes;
|
|
4792
|
+
}
|
|
4793
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
4794
|
+
}
|
|
4795
|
+
function encodeBase64(bytes) {
|
|
4796
|
+
if (typeof btoa === "function") {
|
|
4797
|
+
let binary = "";
|
|
4798
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
4799
|
+
return btoa(binary);
|
|
4800
|
+
}
|
|
4801
|
+
return Buffer.from(bytes).toString("base64");
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
//#endregion
|
|
4805
|
+
//#region src/modules/crdt/index.ts
|
|
4806
|
+
/**
|
|
4807
|
+
* CrdtManager manages active CrdtField instances and their sync channels.
|
|
4808
|
+
*
|
|
4809
|
+
* Collaborative state lives in two dedicated tables (defined in
|
|
4810
|
+
* `apps/cli/src/meta_tables_remote.surql`):
|
|
4811
|
+
* - `_00_crdt` { record_id, field, state } — one row per (record, field)
|
|
4812
|
+
* - `_00_cursor` { record_id, session_id, field, state } — one row per
|
|
4813
|
+
* (record, session, field)
|
|
4814
|
+
*
|
|
4815
|
+
* Splitting them off the parent row is what makes offline edits mergeable:
|
|
4816
|
+
* each (record, field) gets its own row, so concurrent offline writes don't
|
|
4817
|
+
* collide on the parent's last-write-wins semantics.
|
|
4818
|
+
*
|
|
4819
|
+
* Cross-browser delivery still rides the parent table's existing LIVE feed
|
|
4820
|
+
* to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
|
|
4821
|
+
* (issues 3602, 4026). On every meta UPSERT the writer also bumps the
|
|
4822
|
+
* parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
|
|
4823
|
+
* feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
|
|
4824
|
+
* via subquery. Permission inheritance happens server-side via
|
|
4825
|
+
* `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
|
|
4826
|
+
*/
|
|
4827
|
+
var CrdtManager = class {
|
|
4828
|
+
fields = /* @__PURE__ */ new Map();
|
|
4829
|
+
liveByTable = /* @__PURE__ */ new Map();
|
|
4830
|
+
pendingLive = /* @__PURE__ */ new Map();
|
|
4831
|
+
logger;
|
|
4832
|
+
sessionId = "";
|
|
4833
|
+
constructor(schema, local, remote, logger, debounceMs = 500) {
|
|
4834
|
+
this.schema = schema;
|
|
4835
|
+
this.local = local;
|
|
4836
|
+
this.remote = remote;
|
|
4837
|
+
this.debounceMs = debounceMs;
|
|
4838
|
+
this.logger = logger.child({ service: "CrdtManager" });
|
|
4839
|
+
}
|
|
4840
|
+
/** Set the session id that scopes this client's cursor entries. Must be
|
|
4841
|
+
* called before `open()` for cursors to be pushed under a stable key.
|
|
4842
|
+
* Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
|
|
4843
|
+
* for the data-module salt). */
|
|
4844
|
+
setSessionId(sessionId) {
|
|
4845
|
+
this.sessionId = sessionId;
|
|
4846
|
+
}
|
|
4847
|
+
/**
|
|
4848
|
+
* Open a CRDT field for collaborative editing.
|
|
4849
|
+
*
|
|
4850
|
+
* @param table - Table name
|
|
4851
|
+
* @param recordId - Full record ID (e.g., "thread:abc")
|
|
4852
|
+
* @param field - Field name (e.g., "title", "content")
|
|
4853
|
+
* @param fallbackText - Current plain text from the record, used to seed the
|
|
4854
|
+
* LoroDoc if no CRDT state exists yet (migration path)
|
|
4855
|
+
*/
|
|
4856
|
+
async open(table, recordId, field, fallbackText) {
|
|
4857
|
+
this.assertCrdtField(table, field);
|
|
4858
|
+
const cursorsEnabled = this.fieldHasCursor(table, field);
|
|
4859
|
+
const key = this.makeKey(table, recordId, field);
|
|
4860
|
+
let crdtField = this.fields.get(key);
|
|
4861
|
+
if (crdtField) return crdtField;
|
|
4862
|
+
let initialCrdtState;
|
|
4863
|
+
try {
|
|
4864
|
+
const [result] = await this.local.query(`SELECT VALUE ${field} FROM ONLY $id`, { id: parseRecordIdString(recordId) });
|
|
4865
|
+
const snapshot = this.extractSnapshot(result, cursorsEnabled);
|
|
4866
|
+
if (snapshot) initialCrdtState = snapshot;
|
|
4867
|
+
} catch (e) {
|
|
4868
|
+
this.logger.info({
|
|
4869
|
+
error: String(e),
|
|
4870
|
+
recordId,
|
|
4871
|
+
field,
|
|
4872
|
+
Category: "sp00ky-client::CrdtManager::open"
|
|
4873
|
+
}, "No existing CRDT state found in local cache (continuing with empty doc)");
|
|
4874
|
+
}
|
|
4875
|
+
crdtField = new CrdtField(field, cursorsEnabled, initialCrdtState, this.logger);
|
|
4876
|
+
crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
|
|
4877
|
+
this.fields.set(key, crdtField);
|
|
4878
|
+
this.logger.info({
|
|
4879
|
+
key,
|
|
4880
|
+
hasInitialState: !!initialCrdtState,
|
|
4881
|
+
hasFallback: !!fallbackText,
|
|
4882
|
+
Category: "sp00ky-client::CrdtManager::open"
|
|
4883
|
+
}, "CrdtField opened");
|
|
4884
|
+
this.ensureTableSubscription(table);
|
|
4885
|
+
if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
|
|
4886
|
+
return crdtField;
|
|
4887
|
+
}
|
|
4888
|
+
close(table, recordId, field) {
|
|
4889
|
+
const key = this.makeKey(table, recordId, field);
|
|
4890
|
+
const crdtField = this.fields.get(key);
|
|
4891
|
+
if (crdtField) {
|
|
4892
|
+
crdtField.stopSync();
|
|
4893
|
+
this.fields.delete(key);
|
|
4894
|
+
}
|
|
4895
|
+
const tablePrefix = `${table}:`;
|
|
4896
|
+
if (!Array.from(this.fields.keys()).some((k) => k.startsWith(tablePrefix))) this.killTableSubscription(table);
|
|
4897
|
+
this.logger.debug({
|
|
4898
|
+
key,
|
|
4899
|
+
Category: "sp00ky-client::CrdtManager::close"
|
|
4900
|
+
}, "CrdtField closed");
|
|
4901
|
+
}
|
|
4902
|
+
closeAll() {
|
|
4903
|
+
for (const [_, field] of this.fields) field.stopSync();
|
|
4904
|
+
this.fields.clear();
|
|
4905
|
+
for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
|
|
4906
|
+
}
|
|
4907
|
+
/** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
|
|
4908
|
+
* every open CrdtField on `table`. */
|
|
4909
|
+
async ensureTableSubscription(table) {
|
|
4910
|
+
if (this.liveByTable.has(table)) return;
|
|
4911
|
+
const pending = this.pendingLive.get(table);
|
|
4912
|
+
if (pending) return pending;
|
|
4913
|
+
const start = (async () => {
|
|
4914
|
+
try {
|
|
4915
|
+
const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
|
|
4916
|
+
(await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
|
|
4917
|
+
if (message.action === "KILLED") return;
|
|
4918
|
+
if (message.action !== "CREATE" && message.action !== "UPDATE") return;
|
|
4919
|
+
this.dispatchRow(table, message.value);
|
|
4920
|
+
});
|
|
4921
|
+
this.liveByTable.set(table, uuid);
|
|
4922
|
+
this.logger.info({
|
|
4923
|
+
table,
|
|
4924
|
+
Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
|
|
4925
|
+
}, "LIVE SELECT started");
|
|
4926
|
+
} catch (e) {
|
|
4927
|
+
this.logger.warn({
|
|
4928
|
+
error: e,
|
|
4929
|
+
table,
|
|
4930
|
+
Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
|
|
4931
|
+
}, "Failed to start LIVE SELECT");
|
|
4932
|
+
}
|
|
4933
|
+
})();
|
|
4934
|
+
this.pendingLive.set(table, start);
|
|
4935
|
+
try {
|
|
4936
|
+
await start;
|
|
4937
|
+
} finally {
|
|
4938
|
+
this.pendingLive.delete(table);
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
/** Apply a parent-row payload from a non-LIVE source (e.g. the
|
|
4942
|
+
* list_ref-driven sync engine, when the cross-user LIVE on the
|
|
4943
|
+
* parent table is filtered out by the SurrealDB cross-session
|
|
4944
|
+
* permission gap). Same semantics as the internal `dispatchRow`. */
|
|
4945
|
+
applyRow(table, row) {
|
|
4946
|
+
this.dispatchRow(table, row);
|
|
4947
|
+
}
|
|
4948
|
+
/** Dispatch a parent-row LIVE event to every open CrdtField on that
|
|
4949
|
+
* record. Each open field reads its slice of the row directly — the
|
|
4950
|
+
* CRDT snapshot is a column on the parent now, so there is no
|
|
4951
|
+
* follow-up subquery. */
|
|
4952
|
+
dispatchRow(table, row) {
|
|
4953
|
+
const id = row.id != null ? String(row.id) : "";
|
|
4954
|
+
if (!id) return;
|
|
4955
|
+
const rowKeyPrefix = `${table}:${id}:`;
|
|
4956
|
+
for (const [key, crdtField] of this.fields) {
|
|
4957
|
+
if (!key.startsWith(rowKeyPrefix)) continue;
|
|
4958
|
+
const fieldName = key.slice(rowKeyPrefix.length);
|
|
4959
|
+
const cursorsEnabled = this.fieldHasCursor(table, fieldName);
|
|
4960
|
+
const slice = row[fieldName];
|
|
4961
|
+
const snapshot = this.extractSnapshot(slice, cursorsEnabled);
|
|
4962
|
+
if (snapshot) crdtField.importRemote(snapshot);
|
|
4963
|
+
if (cursorsEnabled && slice && typeof slice === "object") {
|
|
4964
|
+
const cursors = slice.cursors;
|
|
4965
|
+
if (cursors && typeof cursors === "object") for (const [sid, blob] of Object.entries(cursors)) {
|
|
4966
|
+
if (sid === this.sessionId) continue;
|
|
4967
|
+
if (typeof blob === "string" && blob.length > 0) crdtField.importRemoteCursor(blob);
|
|
4968
|
+
}
|
|
4969
|
+
}
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
/** One-shot remote fetch for a row whose CRDT field hasn't synced
|
|
4973
|
+
* locally yet (fresh device, memory-backed local DB after reload, …).
|
|
4974
|
+
* Used by `open()` when the local read came up empty. Subsequent
|
|
4975
|
+
* cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
|
|
4976
|
+
async fetchAndDispatchRow(table, id) {
|
|
4977
|
+
try {
|
|
4978
|
+
const recordId = parseRecordIdString(id);
|
|
4979
|
+
const [row] = await this.remote.query(`SELECT * FROM ONLY $id`, { id: recordId });
|
|
4980
|
+
if (!row || typeof row !== "object") return;
|
|
4981
|
+
this.dispatchRow(table, row);
|
|
4982
|
+
} catch (e) {
|
|
4983
|
+
this.logger.warn({
|
|
4984
|
+
error: e,
|
|
4985
|
+
table,
|
|
4986
|
+
id,
|
|
4987
|
+
Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
|
|
4988
|
+
}, "Failed to fetch parent row for CRDT hydration");
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4991
|
+
/** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
|
|
4992
|
+
* Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
|
|
4993
|
+
fieldHasCursor(table, field) {
|
|
4994
|
+
return !!this.schema.tables.find((t) => t.name === table)?.columns[field]?.cursor;
|
|
4995
|
+
}
|
|
4996
|
+
/** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
|
|
4997
|
+
* the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
|
|
4998
|
+
* `{ state, cursors }` where `state` carries the snapshot bytes. */
|
|
4999
|
+
extractSnapshot(value, cursorsEnabled) {
|
|
5000
|
+
const asBytes = (v) => {
|
|
5001
|
+
if (v instanceof Uint8Array) return v.length > 0 ? v : void 0;
|
|
5002
|
+
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
|
5003
|
+
if (ArrayBuffer.isView(v)) {
|
|
5004
|
+
const view = v;
|
|
5005
|
+
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
5006
|
+
}
|
|
5007
|
+
if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "number")) return Uint8Array.from(v);
|
|
5008
|
+
};
|
|
5009
|
+
if (cursorsEnabled) {
|
|
5010
|
+
if (value && typeof value === "object" && !(value instanceof Uint8Array)) return asBytes(value.state);
|
|
5011
|
+
return;
|
|
5012
|
+
}
|
|
5013
|
+
return asBytes(value);
|
|
5014
|
+
}
|
|
5015
|
+
killTableSubscription(table) {
|
|
5016
|
+
const uuid = this.liveByTable.get(table);
|
|
5017
|
+
if (uuid) {
|
|
5018
|
+
this.remote.query("KILL $uuid", { uuid }).catch((err) => {
|
|
5019
|
+
this.logger.debug({
|
|
5020
|
+
err,
|
|
5021
|
+
table,
|
|
5022
|
+
Category: "sp00ky-client::CrdtManager::killTableSubscription"
|
|
5023
|
+
}, "KILL of table LIVE failed (already closed?)");
|
|
5024
|
+
});
|
|
5025
|
+
this.liveByTable.delete(table);
|
|
5026
|
+
}
|
|
5027
|
+
}
|
|
5028
|
+
makeKey(table, recordId, field) {
|
|
5029
|
+
return `${table}:${recordId}:${field}`;
|
|
5030
|
+
}
|
|
5031
|
+
/**
|
|
5032
|
+
* Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
|
|
5033
|
+
* typos, removed annotations, and stale schema codegen at the call site instead
|
|
5034
|
+
* of silently producing a non-CRDT writer.
|
|
5035
|
+
*/
|
|
5036
|
+
assertCrdtField(table, field) {
|
|
5037
|
+
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_]*`);
|
|
5038
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
5039
|
+
if (!tableSchema) throw new Error(`openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(", ")}`);
|
|
5040
|
+
const column = tableSchema.columns[field];
|
|
5041
|
+
if (!column) throw new Error(`openCrdtField: '${table}.${field}' is not in the schema. Available fields: ${Object.keys(tableSchema.columns).join(", ")}`);
|
|
5042
|
+
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.`);
|
|
5043
|
+
}
|
|
5044
|
+
};
|
|
5045
|
+
|
|
5046
|
+
//#endregion
|
|
5047
|
+
//#region src/modules/feature-flag/index.ts
|
|
5048
|
+
const FEATURE_QUERY = "SELECT key, variant, payload FROM _00_user_feature";
|
|
5049
|
+
var FeatureFlagHandle = class {
|
|
5050
|
+
latest = {
|
|
5051
|
+
variant: void 0,
|
|
5052
|
+
payload: void 0
|
|
5053
|
+
};
|
|
5054
|
+
listeners = /* @__PURE__ */ new Set();
|
|
5055
|
+
unsubscribeFn = null;
|
|
5056
|
+
onCloseFn = null;
|
|
5057
|
+
closed = false;
|
|
5058
|
+
constructor(key, fallback) {
|
|
5059
|
+
this.key = key;
|
|
5060
|
+
this.fallback = fallback;
|
|
5061
|
+
}
|
|
5062
|
+
attach(unsubscribe) {
|
|
5063
|
+
this.unsubscribeFn?.();
|
|
5064
|
+
this.unsubscribeFn = unsubscribe;
|
|
5065
|
+
}
|
|
5066
|
+
detach() {
|
|
5067
|
+
this.unsubscribeFn?.();
|
|
5068
|
+
this.unsubscribeFn = null;
|
|
5069
|
+
}
|
|
5070
|
+
set(snapshot) {
|
|
5071
|
+
if (this.closed) return;
|
|
5072
|
+
this.latest = snapshot;
|
|
5073
|
+
for (const cb of this.listeners) cb(snapshot);
|
|
5074
|
+
}
|
|
5075
|
+
variant() {
|
|
5076
|
+
return this.latest.variant ?? this.fallback;
|
|
5077
|
+
}
|
|
5078
|
+
payload() {
|
|
5079
|
+
return this.latest.payload;
|
|
5080
|
+
}
|
|
5081
|
+
enabled() {
|
|
5082
|
+
const v = this.variant();
|
|
5083
|
+
return v !== void 0 && v !== "off";
|
|
5084
|
+
}
|
|
5085
|
+
subscribe(cb) {
|
|
5086
|
+
this.listeners.add(cb);
|
|
5087
|
+
cb({
|
|
5088
|
+
variant: this.variant(),
|
|
5089
|
+
payload: this.latest.payload
|
|
5090
|
+
});
|
|
5091
|
+
return () => {
|
|
5092
|
+
this.listeners.delete(cb);
|
|
5093
|
+
};
|
|
5094
|
+
}
|
|
5095
|
+
onClose(cb) {
|
|
5096
|
+
this.onCloseFn = cb;
|
|
5097
|
+
}
|
|
5098
|
+
close() {
|
|
5099
|
+
if (this.closed) return;
|
|
5100
|
+
this.closed = true;
|
|
5101
|
+
this.listeners.clear();
|
|
5102
|
+
this.detach();
|
|
5103
|
+
this.onCloseFn?.();
|
|
5104
|
+
}
|
|
5105
|
+
};
|
|
5106
|
+
var FeatureFlagModule = class {
|
|
5107
|
+
logger;
|
|
5108
|
+
handles = /* @__PURE__ */ new Set();
|
|
5109
|
+
authUnsubscribe = null;
|
|
5110
|
+
lastUserId = null;
|
|
5111
|
+
querySubscription = null;
|
|
5112
|
+
starting = false;
|
|
5113
|
+
ttl = "10m";
|
|
5114
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
5115
|
+
loaded = false;
|
|
5116
|
+
constructor(deps) {
|
|
5117
|
+
this.deps = deps;
|
|
5118
|
+
this.logger = deps.logger.child({ service: "FeatureFlagModule" });
|
|
5119
|
+
}
|
|
5120
|
+
init() {
|
|
5121
|
+
if (this.authUnsubscribe) return;
|
|
5122
|
+
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
5123
|
+
if (userId === this.lastUserId) return;
|
|
5124
|
+
this.lastUserId = userId;
|
|
5125
|
+
this.refresh();
|
|
5126
|
+
});
|
|
5127
|
+
}
|
|
5128
|
+
feature(key, options = {}) {
|
|
5129
|
+
const handle = new FeatureFlagHandle(key, options.fallback);
|
|
5130
|
+
this.handles.add(handle);
|
|
5131
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
5132
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
5133
|
+
if (this.loaded) handle.set(this.snapshots.get(key) ?? {
|
|
5134
|
+
variant: void 0,
|
|
5135
|
+
payload: void 0
|
|
5136
|
+
});
|
|
5137
|
+
this.ensureStarted();
|
|
5138
|
+
return handle;
|
|
5139
|
+
}
|
|
5140
|
+
async closeAll() {
|
|
5141
|
+
this.authUnsubscribe?.();
|
|
5142
|
+
this.authUnsubscribe = null;
|
|
5143
|
+
this.teardownQuery();
|
|
5144
|
+
for (const handle of [...this.handles]) handle.close();
|
|
5145
|
+
}
|
|
5146
|
+
/** Auth changed: drop the old user's query/snapshots and re-observe. */
|
|
5147
|
+
async refresh() {
|
|
5148
|
+
this.teardownQuery();
|
|
5149
|
+
this.loaded = false;
|
|
5150
|
+
this.snapshots.clear();
|
|
5151
|
+
for (const handle of this.handles) handle.set({
|
|
5152
|
+
variant: void 0,
|
|
5153
|
+
payload: void 0
|
|
5154
|
+
});
|
|
5155
|
+
await this.ensureStarted();
|
|
5156
|
+
}
|
|
5157
|
+
teardownQuery() {
|
|
5158
|
+
this.querySubscription?.();
|
|
5159
|
+
this.querySubscription = null;
|
|
5160
|
+
}
|
|
5161
|
+
/** Start the single shared live query (idempotent; no-op with no handles). */
|
|
5162
|
+
async ensureStarted() {
|
|
5163
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
5164
|
+
this.starting = true;
|
|
5165
|
+
try {
|
|
5166
|
+
const hash = await this.deps.dataModule.query("_00_user_feature", FEATURE_QUERY, {}, this.ttl);
|
|
5167
|
+
this.deps.sync.enqueueDownEvent({
|
|
5168
|
+
type: "register",
|
|
5169
|
+
payload: { hash }
|
|
5170
|
+
});
|
|
5171
|
+
this.querySubscription = this.deps.dataModule.subscribe(hash, (records) => this.applyRecords(records), { immediate: true });
|
|
5172
|
+
} catch (err) {
|
|
5173
|
+
this.logger.warn({
|
|
5174
|
+
err,
|
|
5175
|
+
Category: "sp00ky-client::FeatureFlagModule::register"
|
|
5176
|
+
}, "Failed to register feature flag query");
|
|
5177
|
+
} finally {
|
|
5178
|
+
this.starting = false;
|
|
5179
|
+
}
|
|
5180
|
+
}
|
|
5181
|
+
/** Live query result → per-key snapshots → push to every active handle. */
|
|
5182
|
+
applyRecords(records) {
|
|
5183
|
+
this.snapshots.clear();
|
|
5184
|
+
for (const row of records ?? []) if (row && typeof row.key === "string") this.snapshots.set(row.key, {
|
|
5185
|
+
variant: row.variant,
|
|
5186
|
+
payload: row.payload
|
|
5187
|
+
});
|
|
5188
|
+
this.loaded = true;
|
|
5189
|
+
for (const handle of this.handles) handle.set(this.snapshots.get(handle.key) ?? {
|
|
5190
|
+
variant: void 0,
|
|
5191
|
+
payload: void 0
|
|
5192
|
+
});
|
|
5193
|
+
}
|
|
5194
|
+
};
|
|
5195
|
+
|
|
2901
5196
|
//#endregion
|
|
2902
5197
|
//#region src/services/persistence/localstorage.ts
|
|
2903
5198
|
var LocalStoragePersistenceClient = class {
|
|
@@ -2930,7 +5225,7 @@ var SurrealDBPersistenceClient = class {
|
|
|
2930
5225
|
}
|
|
2931
5226
|
async set(key, val) {
|
|
2932
5227
|
try {
|
|
2933
|
-
const id = parseRecordIdString(`
|
|
5228
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2934
5229
|
await this.db.query(surql.seal(surql.upsert("id", "data")), {
|
|
2935
5230
|
id,
|
|
2936
5231
|
data: { val }
|
|
@@ -2938,40 +5233,75 @@ var SurrealDBPersistenceClient = class {
|
|
|
2938
5233
|
} catch (error) {
|
|
2939
5234
|
this.logger.error({
|
|
2940
5235
|
error,
|
|
2941
|
-
Category: "
|
|
5236
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::set"
|
|
2942
5237
|
}, "Failed to set KV");
|
|
2943
5238
|
throw error;
|
|
2944
5239
|
}
|
|
2945
5240
|
}
|
|
2946
5241
|
async get(key) {
|
|
2947
5242
|
try {
|
|
2948
|
-
const id = parseRecordIdString(`
|
|
5243
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2949
5244
|
const [result] = await this.db.query(surql.seal(surql.selectById("id", ["val"])), { id });
|
|
2950
5245
|
if (!result?.val) return null;
|
|
2951
5246
|
return result.val;
|
|
2952
5247
|
} catch (error) {
|
|
2953
5248
|
this.logger.warn({
|
|
2954
5249
|
error,
|
|
2955
|
-
Category: "
|
|
5250
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::get"
|
|
2956
5251
|
}, "Failed to get KV");
|
|
2957
5252
|
return null;
|
|
2958
5253
|
}
|
|
2959
5254
|
}
|
|
2960
5255
|
async remove(key) {
|
|
2961
5256
|
try {
|
|
2962
|
-
const id = parseRecordIdString(`
|
|
5257
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2963
5258
|
await this.db.query(surql.seal(surql.delete("id")), { id });
|
|
2964
5259
|
} catch (err) {
|
|
2965
5260
|
this.logger.info({
|
|
2966
5261
|
err,
|
|
2967
|
-
Category: "
|
|
5262
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::remove"
|
|
2968
5263
|
}, "Failed to delete KV");
|
|
2969
5264
|
}
|
|
2970
5265
|
}
|
|
2971
5266
|
};
|
|
2972
5267
|
|
|
2973
5268
|
//#endregion
|
|
2974
|
-
//#region src/
|
|
5269
|
+
//#region src/services/persistence/resilient.ts
|
|
5270
|
+
var ResilientPersistenceClient = class {
|
|
5271
|
+
logger;
|
|
5272
|
+
constructor(inner, logger) {
|
|
5273
|
+
this.inner = inner;
|
|
5274
|
+
this.logger = logger.child({ service: "ResilientPersistenceClient" });
|
|
5275
|
+
}
|
|
5276
|
+
set(key, value) {
|
|
5277
|
+
return this.inner.set(key, value);
|
|
5278
|
+
}
|
|
5279
|
+
async get(key) {
|
|
5280
|
+
try {
|
|
5281
|
+
return await this.inner.get(key);
|
|
5282
|
+
} catch (e) {
|
|
5283
|
+
this.logger.warn({
|
|
5284
|
+
key,
|
|
5285
|
+
error: e,
|
|
5286
|
+
Category: "sp00ky-client::ResilientPersistenceClient::get"
|
|
5287
|
+
}, "Persistence read failed, dropping key");
|
|
5288
|
+
await this.inner.remove(key).catch((removeErr) => {
|
|
5289
|
+
this.logger.debug({
|
|
5290
|
+
key,
|
|
5291
|
+
error: removeErr,
|
|
5292
|
+
Category: "sp00ky-client::ResilientPersistenceClient::get"
|
|
5293
|
+
}, "Failed to drop corrupt persistence key");
|
|
5294
|
+
});
|
|
5295
|
+
return null;
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
remove(key) {
|
|
5299
|
+
return this.inner.remove(key);
|
|
5300
|
+
}
|
|
5301
|
+
};
|
|
5302
|
+
|
|
5303
|
+
//#endregion
|
|
5304
|
+
//#region src/sp00ky.ts
|
|
2975
5305
|
var BucketHandle = class {
|
|
2976
5306
|
constructor(bucketName, remote) {
|
|
2977
5307
|
this.bucketName = bucketName;
|
|
@@ -3007,7 +5337,7 @@ var BucketHandle = class {
|
|
|
3007
5337
|
return result;
|
|
3008
5338
|
}
|
|
3009
5339
|
};
|
|
3010
|
-
var
|
|
5340
|
+
var Sp00kyClient = class {
|
|
3011
5341
|
local;
|
|
3012
5342
|
remote;
|
|
3013
5343
|
persistenceClient;
|
|
@@ -3016,6 +5346,8 @@ var SpookyClient = class {
|
|
|
3016
5346
|
dataModule;
|
|
3017
5347
|
sync;
|
|
3018
5348
|
devTools;
|
|
5349
|
+
crdtManager;
|
|
5350
|
+
featureFlags;
|
|
3019
5351
|
logger;
|
|
3020
5352
|
auth;
|
|
3021
5353
|
streamProcessor;
|
|
@@ -3028,33 +5360,64 @@ var SpookyClient = class {
|
|
|
3028
5360
|
get pendingMutationCount() {
|
|
3029
5361
|
return this.sync.pendingMutationCount;
|
|
3030
5362
|
}
|
|
5363
|
+
/** Number of times the initial list_ref LIVE subscription retried on
|
|
5364
|
+
* the most recent `setCurrentUserId` call. 0 when the SSP's
|
|
5365
|
+
* pre-emptive user-table creation got there first; >0 when LIVE
|
|
5366
|
+
* registration hit a "table not found" race. Exposed so the e2e
|
|
5367
|
+
* suite can guard the pre-emptive path against regression. */
|
|
5368
|
+
get liveRetryCount() {
|
|
5369
|
+
return this.sync.liveRetryCount;
|
|
5370
|
+
}
|
|
3031
5371
|
subscribeToPendingMutations(cb) {
|
|
3032
5372
|
return this.sync.subscribeToPendingMutations(cb);
|
|
3033
5373
|
}
|
|
5374
|
+
/** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
|
|
5375
|
+
get syncHealth() {
|
|
5376
|
+
return this.sync.syncHealth;
|
|
5377
|
+
}
|
|
5378
|
+
/**
|
|
5379
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
5380
|
+
* on every healthy↔degraded transition. Returns an unsubscribe.
|
|
5381
|
+
*/
|
|
5382
|
+
subscribeToSyncHealth(cb) {
|
|
5383
|
+
return this.sync.subscribeToSyncHealth(cb);
|
|
5384
|
+
}
|
|
3034
5385
|
constructor(config) {
|
|
3035
5386
|
this.config = config;
|
|
3036
|
-
const logger = createLogger(config.logLevel ?? "info", config.
|
|
3037
|
-
this.logger = logger.child({ service: "
|
|
5387
|
+
const logger = createLogger(config.logLevel ?? "info", config.otelTransmit);
|
|
5388
|
+
this.logger = logger.child({ service: "Sp00kyClient" });
|
|
3038
5389
|
this.logger.info({
|
|
3039
5390
|
config: {
|
|
3040
5391
|
...config,
|
|
3041
5392
|
schema: "[SchemaStructure]"
|
|
3042
5393
|
},
|
|
3043
|
-
Category: "
|
|
3044
|
-
}, "
|
|
5394
|
+
Category: "sp00ky-client::Sp00kyClient::constructor"
|
|
5395
|
+
}, "Sp00kyClient initialized");
|
|
3045
5396
|
this.local = new LocalDatabaseService(this.config.database, logger);
|
|
3046
5397
|
this.remote = new RemoteDatabaseService(this.config.database, logger);
|
|
3047
5398
|
if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
|
|
3048
5399
|
else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
|
|
3049
5400
|
else this.persistenceClient = config.persistenceClient;
|
|
5401
|
+
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
3050
5402
|
this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
|
|
3051
5403
|
this.migrator = new LocalMigrator(this.local, logger);
|
|
3052
5404
|
this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
|
|
3053
5405
|
this.dataModule.onStreamUpdate(update);
|
|
3054
5406
|
}, logger);
|
|
5407
|
+
this.crdtManager = new CrdtManager(this.config.schema, this.local, this.remote, logger, config.crdtDebounceMs ?? 500);
|
|
3055
5408
|
this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
|
|
3056
5409
|
this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
|
|
3057
|
-
this.sync = new
|
|
5410
|
+
this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, {
|
|
5411
|
+
refSyncIntervalMs: this.config.refSyncIntervalMs,
|
|
5412
|
+
anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
|
|
5413
|
+
degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3
|
|
5414
|
+
});
|
|
5415
|
+
this.featureFlags = new FeatureFlagModule({
|
|
5416
|
+
dataModule: this.dataModule,
|
|
5417
|
+
sync: this.sync,
|
|
5418
|
+
auth: this.auth,
|
|
5419
|
+
logger
|
|
5420
|
+
});
|
|
3058
5421
|
this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
|
|
3059
5422
|
this.streamProcessor.addReceiver(this.devTools);
|
|
3060
5423
|
this.setupCallbacks();
|
|
@@ -3063,6 +5426,27 @@ var SpookyClient = class {
|
|
|
3063
5426
|
* Setup direct callbacks instead of event subscriptions
|
|
3064
5427
|
*/
|
|
3065
5428
|
setupCallbacks() {
|
|
5429
|
+
this.dataModule.onQueryStatusChange = (queryHash, status) => {
|
|
5430
|
+
this.devTools.logEvent("QUERY_STATUS_CHANGED", {
|
|
5431
|
+
queryHash,
|
|
5432
|
+
status
|
|
5433
|
+
});
|
|
5434
|
+
};
|
|
5435
|
+
this.dataModule.onHeartbeat = (queryHash) => {
|
|
5436
|
+
this.sync.heartbeatQuery(queryHash).catch((err) => {
|
|
5437
|
+
this.logger.warn({
|
|
5438
|
+
err,
|
|
5439
|
+
queryHash,
|
|
5440
|
+
Category: "sp00ky-client::Sp00kyClient::onHeartbeat"
|
|
5441
|
+
}, "TTL heartbeat failed");
|
|
5442
|
+
});
|
|
5443
|
+
};
|
|
5444
|
+
this.dataModule.onDeregister = (queryHash) => {
|
|
5445
|
+
this.sync.enqueueDownEvent({
|
|
5446
|
+
type: "cleanup",
|
|
5447
|
+
payload: { hash: queryHash }
|
|
5448
|
+
});
|
|
5449
|
+
};
|
|
3066
5450
|
this.dataModule.onMutation((mutations) => {
|
|
3067
5451
|
this.devTools.onMutation(mutations);
|
|
3068
5452
|
if (mutations.length > 0) this.sync.enqueueMutation(mutations);
|
|
@@ -3070,6 +5454,22 @@ var SpookyClient = class {
|
|
|
3070
5454
|
this.sync.events.subscribe("SYNC_QUERY_UPDATED", (event) => {
|
|
3071
5455
|
this.devTools.logEvent("SYNC_QUERY_UPDATED", event.payload);
|
|
3072
5456
|
});
|
|
5457
|
+
this.sync.engineEvents.subscribe("SYNC_REMOTE_DATA_INGESTED", (event) => {
|
|
5458
|
+
try {
|
|
5459
|
+
const records = event.payload?.records ?? [];
|
|
5460
|
+
for (const row of records) {
|
|
5461
|
+
const id = row?.id;
|
|
5462
|
+
const table = id && typeof id === "object" && id.table !== void 0 ? String(id.table) : void 0;
|
|
5463
|
+
if (!table) continue;
|
|
5464
|
+
this.crdtManager.applyRow(table, row);
|
|
5465
|
+
}
|
|
5466
|
+
} catch (err) {
|
|
5467
|
+
this.logger.debug({
|
|
5468
|
+
err,
|
|
5469
|
+
Category: "sp00ky-client::engineEvents::ingested"
|
|
5470
|
+
}, "applyRow forwarding from sync ingest failed");
|
|
5471
|
+
}
|
|
5472
|
+
});
|
|
3073
5473
|
this.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", (event) => {
|
|
3074
5474
|
this.devTools.logEvent("LOCAL_QUERY", event.payload);
|
|
3075
5475
|
});
|
|
@@ -3078,44 +5478,90 @@ var SpookyClient = class {
|
|
|
3078
5478
|
});
|
|
3079
5479
|
}
|
|
3080
5480
|
async init() {
|
|
3081
|
-
this.logger.info({ Category: "
|
|
5481
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
|
|
3082
5482
|
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
5483
|
await this.local.connect();
|
|
3090
|
-
this.logger.debug({ Category: "
|
|
5484
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Local database connected");
|
|
3091
5485
|
await this.migrator.provision(this.config.schemaSurql);
|
|
3092
|
-
this.logger.debug({ Category: "
|
|
5486
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
|
|
3093
5487
|
await this.remote.connect();
|
|
3094
|
-
this.logger.debug({ Category: "
|
|
5488
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
|
|
3095
5489
|
await this.streamProcessor.init();
|
|
3096
|
-
this.
|
|
5490
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
5491
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
3097
5492
|
await this.auth.init();
|
|
3098
|
-
this.logger.debug({ Category: "
|
|
3099
|
-
await this.
|
|
3100
|
-
this.
|
|
3101
|
-
|
|
3102
|
-
this.logger.debug({
|
|
3103
|
-
|
|
5493
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
|
|
5494
|
+
const sessionId = await this.fetchSessionId();
|
|
5495
|
+
await this.dataModule.init(sessionId);
|
|
5496
|
+
this.crdtManager.setSessionId(sessionId);
|
|
5497
|
+
this.logger.debug({
|
|
5498
|
+
sessionId,
|
|
5499
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
5500
|
+
}, "DataModule initialized");
|
|
5501
|
+
this.auth.subscribe(async (userId) => {
|
|
5502
|
+
this.dataModule.setCurrentUserId(userId);
|
|
5503
|
+
this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
|
|
5504
|
+
const next = await this.fetchSessionId();
|
|
5505
|
+
this.dataModule.setSessionId(next);
|
|
5506
|
+
this.crdtManager.setSessionId(next);
|
|
5507
|
+
try {
|
|
5508
|
+
await this.sync.setCurrentUserId(userId);
|
|
5509
|
+
} catch (e) {
|
|
5510
|
+
this.logger.error({
|
|
5511
|
+
error: e,
|
|
5512
|
+
Category: "sp00ky-client::Sp00kyClient::authChange"
|
|
5513
|
+
}, "sync.setCurrentUserId failed");
|
|
5514
|
+
}
|
|
5515
|
+
});
|
|
5516
|
+
await this.sync.init();
|
|
5517
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
|
|
5518
|
+
this.featureFlags.init();
|
|
5519
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
|
|
5520
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
|
|
3104
5521
|
} catch (e) {
|
|
3105
5522
|
this.logger.error({
|
|
3106
5523
|
error: e,
|
|
3107
|
-
Category: "
|
|
3108
|
-
}, "
|
|
5524
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
5525
|
+
}, "Sp00kyClient initialization failed");
|
|
3109
5526
|
throw e;
|
|
3110
5527
|
}
|
|
3111
5528
|
}
|
|
3112
5529
|
async close() {
|
|
5530
|
+
await this.featureFlags.closeAll();
|
|
5531
|
+
this.crdtManager.closeAll();
|
|
3113
5532
|
await this.local.close();
|
|
3114
5533
|
await this.remote.close();
|
|
3115
5534
|
}
|
|
5535
|
+
/**
|
|
5536
|
+
* Subscribe to a feature flag for the current user. Returns a
|
|
5537
|
+
* `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
|
|
5538
|
+
* accessors reflect the latest assignment from `_00_user_feature`,
|
|
5539
|
+
* and whose `subscribe(cb)` fires whenever that assignment changes.
|
|
5540
|
+
*
|
|
5541
|
+
* Permissions are enforced by SurrealDB: a client can only ever see
|
|
5542
|
+
* its own row, and cannot create or modify assignments.
|
|
5543
|
+
*/
|
|
5544
|
+
feature(key, options) {
|
|
5545
|
+
return this.featureFlags.feature(key, options);
|
|
5546
|
+
}
|
|
3116
5547
|
authenticate(token) {
|
|
3117
5548
|
return this.remote.getClient().authenticate(token);
|
|
3118
5549
|
}
|
|
5550
|
+
/**
|
|
5551
|
+
* Open a CRDT field for collaborative editing.
|
|
5552
|
+
* Returns a CrdtField with a LoroDoc that can be bound to any editor.
|
|
5553
|
+
* Also starts a LIVE SELECT on the parent table for real-time sync;
|
|
5554
|
+
* incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
|
|
5555
|
+
*/
|
|
5556
|
+
async openCrdtField(table, recordId, field, fallbackText) {
|
|
5557
|
+
return this.crdtManager.open(table, recordId, field, fallbackText);
|
|
5558
|
+
}
|
|
5559
|
+
/**
|
|
5560
|
+
* Close a CRDT field when editing is done.
|
|
5561
|
+
*/
|
|
5562
|
+
closeCrdtField(table, recordId, field) {
|
|
5563
|
+
this.crdtManager.close(table, recordId, field);
|
|
5564
|
+
}
|
|
3119
5565
|
deauthenticate() {
|
|
3120
5566
|
return this.remote.getClient().invalidate();
|
|
3121
5567
|
}
|
|
@@ -3125,7 +5571,18 @@ var SpookyClient = class {
|
|
|
3125
5571
|
async initQuery(table, q, ttl) {
|
|
3126
5572
|
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
3127
5573
|
if (!tableSchema) throw new Error(`Table ${table} not found`);
|
|
3128
|
-
const
|
|
5574
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
5575
|
+
const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
|
|
5576
|
+
if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
|
|
5577
|
+
const [rows] = await this.remote.query(q.selectQuery.query, params);
|
|
5578
|
+
await this.dataModule.applyHydration(hash, rows ?? []);
|
|
5579
|
+
} catch (err) {
|
|
5580
|
+
this.logger.warn({
|
|
5581
|
+
err,
|
|
5582
|
+
hash,
|
|
5583
|
+
Category: "sp00ky-client::Sp00kyClient::instantHydrate"
|
|
5584
|
+
}, "Instant hydrate failed; proceeding with registration");
|
|
5585
|
+
}
|
|
3129
5586
|
await this.sync.enqueueDownEvent({
|
|
3130
5587
|
type: "register",
|
|
3131
5588
|
payload: { hash }
|
|
@@ -3139,6 +5596,32 @@ var SpookyClient = class {
|
|
|
3139
5596
|
async subscribe(queryHash, callback, options) {
|
|
3140
5597
|
return this.dataModule.subscribe(queryHash, callback, options);
|
|
3141
5598
|
}
|
|
5599
|
+
/**
|
|
5600
|
+
* Opt-in eager teardown for a query whose last subscriber has gone away
|
|
5601
|
+
* (e.g. a viewport-windowed list cancelling an off-screen window). No-op
|
|
5602
|
+
* while any subscriber remains. Tears down the remote `_00_query` view +
|
|
5603
|
+
* local WASM view instead of waiting for the TTL sweep. Default behavior
|
|
5604
|
+
* (no call here) keeps the view resident for cheap re-subscription.
|
|
5605
|
+
*/
|
|
5606
|
+
deregisterQuery(queryHash) {
|
|
5607
|
+
this.dataModule.deregisterQuery(queryHash);
|
|
5608
|
+
}
|
|
5609
|
+
/**
|
|
5610
|
+
* Subscribe to a query's fetch-status changes (idle/fetching). With
|
|
5611
|
+
* `{ immediate: true }` the callback fires synchronously with the current
|
|
5612
|
+
* status. Powers the `useQuery` hook's `isFetching()` accessor.
|
|
5613
|
+
*/
|
|
5614
|
+
subscribeQueryStatus(queryHash, callback, options) {
|
|
5615
|
+
return this.dataModule.subscribeStatus(queryHash, callback, options);
|
|
5616
|
+
}
|
|
5617
|
+
/**
|
|
5618
|
+
* Report the frontend processing time (ms) a client framework spent applying
|
|
5619
|
+
* an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
|
|
5620
|
+
* surface the "frontend" phase of the per-query timing breakdown.
|
|
5621
|
+
*/
|
|
5622
|
+
reportFrontendTiming(queryHash, ms) {
|
|
5623
|
+
this.dataModule.recordFrontendTiming(queryHash, ms);
|
|
5624
|
+
}
|
|
3142
5625
|
run(backend, path, payload, options) {
|
|
3143
5626
|
return this.dataModule.run(backend, path, payload, options);
|
|
3144
5627
|
}
|
|
@@ -3157,24 +5640,25 @@ var SpookyClient = class {
|
|
|
3157
5640
|
async useRemote(fn) {
|
|
3158
5641
|
return fn(this.remote.getClient());
|
|
3159
5642
|
}
|
|
3160
|
-
|
|
5643
|
+
/**
|
|
5644
|
+
* Fetch SurrealDB's `session::id()` as a string. Used as a salt for
|
|
5645
|
+
* query-id hashing so two sessions for the same user get distinct
|
|
5646
|
+
* `_00_query` rows. Returns empty string if the query fails (we still
|
|
5647
|
+
* boot, just without session scoping for IDs).
|
|
5648
|
+
*/
|
|
5649
|
+
async fetchSessionId() {
|
|
3161
5650
|
try {
|
|
3162
|
-
this.
|
|
5651
|
+
const [sid] = await this.remote.query("RETURN <string>session::id()");
|
|
5652
|
+
return typeof sid === "string" ? sid : "";
|
|
3163
5653
|
} catch (e) {
|
|
3164
5654
|
this.logger.warn({
|
|
3165
5655
|
error: e,
|
|
3166
|
-
Category: "
|
|
3167
|
-
}, "Failed to
|
|
5656
|
+
Category: "sp00ky-client::Sp00kyClient::fetchSessionId"
|
|
5657
|
+
}, "Failed to fetch session::id() — proceeding with empty salt");
|
|
5658
|
+
return "";
|
|
3168
5659
|
}
|
|
3169
5660
|
}
|
|
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
5661
|
};
|
|
3178
5662
|
|
|
3179
5663
|
//#endregion
|
|
3180
|
-
export { AuthEventTypes, AuthService, BucketHandle,
|
|
5664
|
+
export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
|