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