@spooky-sync/core 0.0.1-canary.1 → 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 +938 -339
- package/dist/index.js +2978 -423
- 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 -1
- 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 +848 -134
- 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 +896 -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 +42 -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 -346
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,13 +175,24 @@ 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;
|
|
169
182
|
default: return val * 6e4;
|
|
170
183
|
}
|
|
171
184
|
}
|
|
185
|
+
async function fileToUint8Array(file) {
|
|
186
|
+
const buffer = await file.arrayBuffer();
|
|
187
|
+
return new Uint8Array(buffer);
|
|
188
|
+
}
|
|
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
|
+
}
|
|
172
196
|
/**
|
|
173
197
|
* Helper for retrying DB operations with exponential backoff
|
|
174
198
|
*/
|
|
@@ -184,7 +208,7 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
184
208
|
attempt: i + 1,
|
|
185
209
|
retries,
|
|
186
210
|
error: msg,
|
|
187
|
-
Category: "
|
|
211
|
+
Category: "sp00ky-client::utils::withRetry"
|
|
188
212
|
}, "Retrying DB operation");
|
|
189
213
|
await new Promise((res) => setTimeout(res, delayMs * (i + 1)));
|
|
190
214
|
continue;
|
|
@@ -194,8 +218,160 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
194
218
|
throw lastError;
|
|
195
219
|
}
|
|
196
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
|
+
|
|
197
349
|
//#endregion
|
|
198
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
|
+
}
|
|
199
375
|
/**
|
|
200
376
|
* DataModule - Unified query and mutation management
|
|
201
377
|
*
|
|
@@ -204,19 +380,76 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
204
380
|
*/
|
|
205
381
|
var DataModule = class {
|
|
206
382
|
activeQueries = /* @__PURE__ */ new Map();
|
|
383
|
+
pendingQueries = /* @__PURE__ */ new Map();
|
|
207
384
|
subscriptions = /* @__PURE__ */ new Map();
|
|
385
|
+
statusSubscriptions = /* @__PURE__ */ new Map();
|
|
208
386
|
mutationCallbacks = /* @__PURE__ */ new Set();
|
|
209
387
|
debounceTimers = /* @__PURE__ */ new Map();
|
|
210
388
|
logger;
|
|
211
|
-
|
|
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) {
|
|
212
418
|
this.cache = cache;
|
|
213
419
|
this.local = local;
|
|
214
420
|
this.schema = schema;
|
|
215
421
|
this.streamDebounceTime = streamDebounceTime;
|
|
216
422
|
this.logger = logger.child({ service: "DataModule" });
|
|
217
423
|
}
|
|
218
|
-
async init() {
|
|
219
|
-
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;
|
|
220
453
|
}
|
|
221
454
|
/**
|
|
222
455
|
* Register a query and return its hash for subscriptions
|
|
@@ -228,46 +461,35 @@ var DataModule = class {
|
|
|
228
461
|
});
|
|
229
462
|
this.logger.debug({
|
|
230
463
|
hash,
|
|
231
|
-
Category: "
|
|
464
|
+
Category: "sp00ky-client::DataModule::query"
|
|
232
465
|
}, "Query Initialization: started");
|
|
233
|
-
const recordId = new RecordId("
|
|
466
|
+
const recordId = new RecordId("_00_query", hash);
|
|
234
467
|
if (this.activeQueries.has(hash)) {
|
|
235
468
|
this.logger.debug({
|
|
236
469
|
hash,
|
|
237
|
-
Category: "
|
|
470
|
+
Category: "sp00ky-client::DataModule::query"
|
|
238
471
|
}, "Query Initialization: exists, returning");
|
|
239
472
|
return hash;
|
|
240
473
|
}
|
|
474
|
+
if (this.pendingQueries.has(hash)) {
|
|
475
|
+
this.logger.debug({
|
|
476
|
+
hash,
|
|
477
|
+
Category: "sp00ky-client::DataModule::query"
|
|
478
|
+
}, "Query Initialization: pending, waiting for existing creation");
|
|
479
|
+
await this.pendingQueries.get(hash);
|
|
480
|
+
return hash;
|
|
481
|
+
}
|
|
241
482
|
this.logger.debug({
|
|
242
483
|
hash,
|
|
243
|
-
Category: "
|
|
484
|
+
Category: "sp00ky-client::DataModule::query"
|
|
244
485
|
}, "Query Initialization: not found, creating new query");
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
const { localArray } = this.cache.registerQuery({
|
|
253
|
-
queryHash: hash,
|
|
254
|
-
surql: surqlString,
|
|
255
|
-
params,
|
|
256
|
-
ttl: new Duration(ttl),
|
|
257
|
-
lastActiveAt: /* @__PURE__ */ new Date()
|
|
258
|
-
});
|
|
259
|
-
await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
|
|
260
|
-
id: recordId,
|
|
261
|
-
localArray
|
|
262
|
-
}));
|
|
263
|
-
this.activeQueries.set(hash, queryState);
|
|
264
|
-
this.startTTLHeartbeat(queryState);
|
|
265
|
-
this.logger.debug({
|
|
266
|
-
hash,
|
|
267
|
-
tableName,
|
|
268
|
-
recordCount: queryState.records.length,
|
|
269
|
-
Category: "spooky-client::DataModule::query"
|
|
270
|
-
}, "Query registered");
|
|
486
|
+
const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
|
|
487
|
+
this.pendingQueries.set(hash, promise);
|
|
488
|
+
try {
|
|
489
|
+
await promise;
|
|
490
|
+
} finally {
|
|
491
|
+
this.pendingQueries.delete(hash);
|
|
492
|
+
}
|
|
271
493
|
return hash;
|
|
272
494
|
}
|
|
273
495
|
/**
|
|
@@ -289,6 +511,36 @@ var DataModule = class {
|
|
|
289
511
|
};
|
|
290
512
|
}
|
|
291
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
|
+
/**
|
|
292
544
|
* Subscribe to mutations (for sync)
|
|
293
545
|
*/
|
|
294
546
|
onMutation(callback) {
|
|
@@ -302,56 +554,298 @@ var DataModule = class {
|
|
|
302
554
|
*/
|
|
303
555
|
async onStreamUpdate(update) {
|
|
304
556
|
const { queryHash, op } = update;
|
|
305
|
-
if (op === "
|
|
306
|
-
|
|
307
|
-
|
|
557
|
+
if (op === "DELETE") {
|
|
558
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
559
|
+
if (existing) {
|
|
560
|
+
clearTimeout(existing);
|
|
308
561
|
this.debounceTimers.delete(queryHash);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
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;
|
|
313
590
|
}
|
|
314
591
|
async processStreamUpdate(update) {
|
|
315
|
-
const { queryHash, localArray } = update;
|
|
592
|
+
const { queryHash, localArray, materializationTimeMs } = update;
|
|
316
593
|
const queryState = this.activeQueries.get(queryHash);
|
|
317
594
|
if (!queryState) {
|
|
318
595
|
this.logger.warn({
|
|
319
596
|
queryHash,
|
|
320
|
-
Category: "
|
|
597
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
321
598
|
}, "Received update for unknown query. Skipping...");
|
|
322
599
|
return;
|
|
323
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);
|
|
324
610
|
try {
|
|
325
|
-
const
|
|
326
|
-
queryState.records = records || [];
|
|
611
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
327
612
|
queryState.config.localArray = localArray;
|
|
328
|
-
queryState.
|
|
329
|
-
|
|
613
|
+
const prevJson = JSON.stringify(queryState.records);
|
|
614
|
+
const newJson = JSON.stringify(newRecords);
|
|
615
|
+
queryState.records = newRecords;
|
|
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
|
+
])), {
|
|
330
627
|
id: queryState.config.id,
|
|
331
|
-
localArray
|
|
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
|
|
332
635
|
});
|
|
636
|
+
if (!recordsChanged) {
|
|
637
|
+
this.logger.debug({
|
|
638
|
+
queryHash,
|
|
639
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
640
|
+
}, "Query records unchanged, skipping notification");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
333
643
|
const subscribers = this.subscriptions.get(queryHash);
|
|
334
644
|
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
335
645
|
this.logger.debug({
|
|
336
646
|
queryHash,
|
|
337
|
-
recordCount:
|
|
338
|
-
Category: "
|
|
647
|
+
recordCount: newRecords?.length,
|
|
648
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
339
649
|
}, "Query updated from stream");
|
|
340
650
|
} catch (err) {
|
|
651
|
+
queryState.errorCount++;
|
|
341
652
|
this.logger.error({
|
|
342
653
|
err,
|
|
343
654
|
queryHash,
|
|
344
|
-
Category: "
|
|
655
|
+
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
345
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
|
+
}
|
|
346
669
|
}
|
|
347
670
|
}
|
|
348
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
|
+
/**
|
|
349
732
|
* Get query state (for sync and devtools)
|
|
350
733
|
*/
|
|
351
734
|
getQueryByHash(hash) {
|
|
352
735
|
return this.activeQueries.get(hash);
|
|
353
736
|
}
|
|
354
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
|
+
/**
|
|
355
849
|
* Get query state by id (for sync and devtools)
|
|
356
850
|
*/
|
|
357
851
|
getQueryById(id) {
|
|
@@ -363,12 +857,15 @@ var DataModule = class {
|
|
|
363
857
|
getActiveQueries() {
|
|
364
858
|
return Array.from(this.activeQueries.values());
|
|
365
859
|
}
|
|
860
|
+
getActiveQueryHashes() {
|
|
861
|
+
return Array.from(this.activeQueries.keys());
|
|
862
|
+
}
|
|
366
863
|
async updateQueryLocalArray(id, localArray) {
|
|
367
864
|
const queryState = this.activeQueries.get(id);
|
|
368
865
|
if (!queryState) {
|
|
369
866
|
this.logger.warn({
|
|
370
867
|
id,
|
|
371
|
-
Category: "
|
|
868
|
+
Category: "sp00ky-client::DataModule::updateQueryLocalArray"
|
|
372
869
|
}, "Query to update local array not found");
|
|
373
870
|
return;
|
|
374
871
|
}
|
|
@@ -383,7 +880,7 @@ var DataModule = class {
|
|
|
383
880
|
if (!queryState) {
|
|
384
881
|
this.logger.warn({
|
|
385
882
|
hash,
|
|
386
|
-
Category: "
|
|
883
|
+
Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
|
|
387
884
|
}, "Query to update remote array not found");
|
|
388
885
|
return;
|
|
389
886
|
}
|
|
@@ -393,6 +890,22 @@ var DataModule = class {
|
|
|
393
890
|
remoteArray
|
|
394
891
|
});
|
|
395
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* Called after a query's initial sync completes.
|
|
895
|
+
* Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
|
|
896
|
+
*/
|
|
897
|
+
async notifyQuerySynced(queryHash) {
|
|
898
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
899
|
+
if (!queryState) return;
|
|
900
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
901
|
+
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
902
|
+
queryState.records = newRecords;
|
|
903
|
+
if (changed || queryState.updateCount === 0) {
|
|
904
|
+
queryState.updateCount++;
|
|
905
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
906
|
+
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
396
909
|
async run(backend, path, data, options) {
|
|
397
910
|
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
398
911
|
if (!route) throw new Error(`Route ${backend}.${path} not found`);
|
|
@@ -410,6 +923,8 @@ var DataModule = class {
|
|
|
410
923
|
max_retries: options?.max_retries ?? 3,
|
|
411
924
|
retry_strategy: options?.retry_strategy ?? "linear"
|
|
412
925
|
};
|
|
926
|
+
if (options?.timeout != null) record.timeout = options.timeout;
|
|
927
|
+
if (options?.delay != null) record.delay = options.delay;
|
|
413
928
|
if (options?.assignedTo) record.assigned_to = options.assignedTo;
|
|
414
929
|
const recordId = `${tableName}:${generateId()}`;
|
|
415
930
|
await this.create(recordId, record);
|
|
@@ -423,7 +938,7 @@ var DataModule = class {
|
|
|
423
938
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
424
939
|
const rid = parseRecordIdString(id);
|
|
425
940
|
const params = parseParams(tableSchema.columns, data);
|
|
426
|
-
const mutationId = parseRecordIdString(`
|
|
941
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
427
942
|
const dataKeys = Object.keys(params).map((key) => ({
|
|
428
943
|
key,
|
|
429
944
|
variable: `data_${key}`
|
|
@@ -453,7 +968,7 @@ var DataModule = class {
|
|
|
453
968
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
454
969
|
this.logger.debug({
|
|
455
970
|
id,
|
|
456
|
-
Category: "
|
|
971
|
+
Category: "sp00ky-client::DataModule::create"
|
|
457
972
|
}, "Record created");
|
|
458
973
|
return target;
|
|
459
974
|
}
|
|
@@ -466,10 +981,10 @@ var DataModule = class {
|
|
|
466
981
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
467
982
|
const rid = parseRecordIdString(id);
|
|
468
983
|
const params = parseParams(tableSchema.columns, data);
|
|
469
|
-
const mutationId = parseRecordIdString(`
|
|
984
|
+
const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
|
|
470
985
|
const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
|
|
471
986
|
const query = surql.seal(surql.tx([
|
|
472
|
-
surql.updateSet("id", [{ statement: "
|
|
987
|
+
surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
|
|
473
988
|
surql.let("updated", surql.updateMerge("id", "data")),
|
|
474
989
|
surql.createMutation("update", "mid", "id", "data"),
|
|
475
990
|
surql.returnObject([{
|
|
@@ -482,13 +997,16 @@ var DataModule = class {
|
|
|
482
997
|
mid: mutationId,
|
|
483
998
|
data: params
|
|
484
999
|
}));
|
|
485
|
-
|
|
1000
|
+
const updatedFields = { id: target.id };
|
|
1001
|
+
for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
|
|
1002
|
+
if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
|
|
1003
|
+
this.replaceRecordInQueries(updatedFields);
|
|
486
1004
|
const parsedRecord = parseParams(tableSchema.columns, target);
|
|
487
1005
|
await this.cache.save({
|
|
488
1006
|
table,
|
|
489
1007
|
op: "UPDATE",
|
|
490
1008
|
record: parsedRecord,
|
|
491
|
-
version: target.
|
|
1009
|
+
version: target._00_rv
|
|
492
1010
|
}, true);
|
|
493
1011
|
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
494
1012
|
const mutationEvent = {
|
|
@@ -503,7 +1021,7 @@ var DataModule = class {
|
|
|
503
1021
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
504
1022
|
this.logger.debug({
|
|
505
1023
|
id,
|
|
506
|
-
Category: "
|
|
1024
|
+
Category: "sp00ky-client::DataModule::update"
|
|
507
1025
|
}, "Record updated");
|
|
508
1026
|
return target;
|
|
509
1027
|
}
|
|
@@ -514,13 +1032,32 @@ var DataModule = class {
|
|
|
514
1032
|
const tableName = extractTablePart(id);
|
|
515
1033
|
if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
|
|
516
1034
|
const rid = parseRecordIdString(id);
|
|
517
|
-
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 ?? {};
|
|
518
1038
|
const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
|
|
519
1039
|
await withRetry(this.logger, () => this.local.execute(query, {
|
|
520
1040
|
id: rid,
|
|
521
1041
|
mid: mutationId
|
|
522
1042
|
}));
|
|
523
|
-
|
|
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
|
+
}
|
|
524
1061
|
const mutationEvent = {
|
|
525
1062
|
type: "delete",
|
|
526
1063
|
mutation_id: mutationId,
|
|
@@ -529,7 +1066,7 @@ var DataModule = class {
|
|
|
529
1066
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
530
1067
|
this.logger.debug({
|
|
531
1068
|
id,
|
|
532
|
-
Category: "
|
|
1069
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
533
1070
|
}, "Record deleted");
|
|
534
1071
|
}
|
|
535
1072
|
/**
|
|
@@ -544,14 +1081,14 @@ var DataModule = class {
|
|
|
544
1081
|
this.logger.info({
|
|
545
1082
|
id,
|
|
546
1083
|
tableName,
|
|
547
|
-
Category: "
|
|
1084
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
548
1085
|
}, "Rolled back optimistic create");
|
|
549
1086
|
} catch (err) {
|
|
550
1087
|
this.logger.error({
|
|
551
1088
|
err,
|
|
552
1089
|
id,
|
|
553
1090
|
tableName,
|
|
554
|
-
Category: "
|
|
1091
|
+
Category: "sp00ky-client::DataModule::rollbackCreate"
|
|
555
1092
|
}, "Failed to rollback create");
|
|
556
1093
|
}
|
|
557
1094
|
}
|
|
@@ -572,20 +1109,20 @@ var DataModule = class {
|
|
|
572
1109
|
table: tableName,
|
|
573
1110
|
op: "UPDATE",
|
|
574
1111
|
record: parsedRecord,
|
|
575
|
-
version: beforeRecord.
|
|
1112
|
+
version: beforeRecord._00_rv || 1
|
|
576
1113
|
}, true);
|
|
577
1114
|
await this.replaceRecordInQueries(beforeRecord);
|
|
578
1115
|
this.logger.info({
|
|
579
1116
|
id,
|
|
580
1117
|
tableName,
|
|
581
|
-
Category: "
|
|
1118
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
582
1119
|
}, "Rolled back optimistic update");
|
|
583
1120
|
} catch (err) {
|
|
584
1121
|
this.logger.error({
|
|
585
1122
|
err,
|
|
586
1123
|
id,
|
|
587
1124
|
tableName,
|
|
588
|
-
Category: "
|
|
1125
|
+
Category: "sp00ky-client::DataModule::rollbackUpdate"
|
|
589
1126
|
}, "Failed to rollback update");
|
|
590
1127
|
}
|
|
591
1128
|
}
|
|
@@ -605,6 +1142,64 @@ var DataModule = class {
|
|
|
605
1142
|
}
|
|
606
1143
|
}
|
|
607
1144
|
}
|
|
1145
|
+
async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName) {
|
|
1146
|
+
const queryState = await this.createNewQuery({
|
|
1147
|
+
recordId,
|
|
1148
|
+
surql: surqlString,
|
|
1149
|
+
params,
|
|
1150
|
+
ttl,
|
|
1151
|
+
tableName
|
|
1152
|
+
});
|
|
1153
|
+
const t0 = performance.now();
|
|
1154
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
1155
|
+
queryHash: hash,
|
|
1156
|
+
surql: surqlString,
|
|
1157
|
+
params,
|
|
1158
|
+
ttl: new Duration(ttl),
|
|
1159
|
+
lastActiveAt: /* @__PURE__ */ new Date()
|
|
1160
|
+
});
|
|
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
|
+
])), {
|
|
1173
|
+
id: recordId,
|
|
1174
|
+
localArray,
|
|
1175
|
+
registrationTime,
|
|
1176
|
+
rowCount: localArray.length
|
|
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
|
+
}
|
|
1193
|
+
this.activeQueries.set(hash, queryState);
|
|
1194
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
1195
|
+
this.logger.debug({
|
|
1196
|
+
hash,
|
|
1197
|
+
tableName,
|
|
1198
|
+
recordCount: queryState.records.length,
|
|
1199
|
+
Category: "sp00ky-client::DataModule::query"
|
|
1200
|
+
}, "Query registered");
|
|
1201
|
+
return hash;
|
|
1202
|
+
}
|
|
608
1203
|
async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName }) {
|
|
609
1204
|
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
610
1205
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
@@ -618,8 +1213,12 @@ var DataModule = class {
|
|
|
618
1213
|
localArray: [],
|
|
619
1214
|
remoteArray: [],
|
|
620
1215
|
lastActiveAt: /* @__PURE__ */ new Date(),
|
|
1216
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
621
1217
|
ttl,
|
|
622
|
-
tableName
|
|
1218
|
+
tableName,
|
|
1219
|
+
updateCount: 0,
|
|
1220
|
+
rowCount: 0,
|
|
1221
|
+
errorCount: 0
|
|
623
1222
|
}
|
|
624
1223
|
}));
|
|
625
1224
|
configRecord = createdRecord;
|
|
@@ -630,52 +1229,79 @@ var DataModule = class {
|
|
|
630
1229
|
params: parseParams(tableSchema.columns, configRecord.params)
|
|
631
1230
|
};
|
|
632
1231
|
let records = [];
|
|
633
|
-
try {
|
|
1232
|
+
if (buildWindowMaterialization(surqlString) === null) try {
|
|
634
1233
|
const [result] = await this.local.query(surqlString, params);
|
|
635
1234
|
records = result || [];
|
|
636
1235
|
} catch (err) {
|
|
637
1236
|
this.logger.warn({
|
|
638
1237
|
err,
|
|
639
|
-
Category: "
|
|
1238
|
+
Category: "sp00ky-client::DataModule::createNewQuery"
|
|
640
1239
|
}, "Failed to load initial cached records");
|
|
641
1240
|
}
|
|
1241
|
+
const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
|
|
1242
|
+
const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
|
|
642
1243
|
return {
|
|
643
1244
|
config,
|
|
644
1245
|
records,
|
|
645
1246
|
ttlTimer: null,
|
|
646
1247
|
ttlDurationMs: parseDuration(ttl),
|
|
647
|
-
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
|
+
}
|
|
648
1261
|
};
|
|
649
1262
|
}
|
|
650
1263
|
async calculateHash(data) {
|
|
651
|
-
const content = JSON.stringify(
|
|
1264
|
+
const content = JSON.stringify({
|
|
1265
|
+
...data,
|
|
1266
|
+
sessionId: this.sessionId
|
|
1267
|
+
});
|
|
652
1268
|
const msgBuffer = new TextEncoder().encode(content);
|
|
653
1269
|
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
|
654
1270
|
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
655
1271
|
}
|
|
656
|
-
startTTLHeartbeat(queryState) {
|
|
1272
|
+
startTTLHeartbeat(queryState, hash) {
|
|
657
1273
|
if (queryState.ttlTimer) return;
|
|
658
1274
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
|
|
659
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);
|
|
660
1285
|
this.logger.debug({
|
|
1286
|
+
hash,
|
|
661
1287
|
id: encodeRecordId(queryState.config.id),
|
|
662
|
-
Category: "
|
|
663
|
-
}, "TTL heartbeat");
|
|
664
|
-
this.startTTLHeartbeat(queryState);
|
|
1288
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
1289
|
+
}, "TTL heartbeat sent");
|
|
1290
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
665
1291
|
}, heartbeatTime);
|
|
666
1292
|
}
|
|
667
|
-
stopTTLHeartbeat(queryState) {
|
|
668
|
-
if (queryState.ttlTimer) {
|
|
669
|
-
clearTimeout(queryState.ttlTimer);
|
|
670
|
-
queryState.ttlTimer = null;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
1293
|
async replaceRecordInQueries(record) {
|
|
674
|
-
for (const queryState of this.activeQueries.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1294
|
+
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
1295
|
+
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
1296
|
+
if (index !== -1) {
|
|
1297
|
+
queryState.records[index] = {
|
|
1298
|
+
...queryState.records[index],
|
|
1299
|
+
...record
|
|
1300
|
+
};
|
|
1301
|
+
const subscribers = this.subscriptions.get(queryHash);
|
|
1302
|
+
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
679
1305
|
}
|
|
680
1306
|
};
|
|
681
1307
|
/**
|
|
@@ -685,7 +1311,7 @@ function parseUpdateOptions(id, data, options) {
|
|
|
685
1311
|
let pushEventOptions = {};
|
|
686
1312
|
if (options?.debounced) pushEventOptions = { debounced: {
|
|
687
1313
|
delay: options.debounced !== true ? options.debounced?.delay ?? 200 : 200,
|
|
688
|
-
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
|
|
689
1315
|
} };
|
|
690
1316
|
return pushEventOptions;
|
|
691
1317
|
}
|
|
@@ -722,7 +1348,7 @@ var AbstractDatabaseService = class {
|
|
|
722
1348
|
this.logger.debug({
|
|
723
1349
|
query,
|
|
724
1350
|
vars,
|
|
725
|
-
Category: "
|
|
1351
|
+
Category: "sp00ky-client::Database::query"
|
|
726
1352
|
}, "Executing query");
|
|
727
1353
|
const result = await this.client.query(query, vars);
|
|
728
1354
|
const duration = performance.now() - startTime;
|
|
@@ -737,7 +1363,7 @@ var AbstractDatabaseService = class {
|
|
|
737
1363
|
this.logger.trace({
|
|
738
1364
|
query,
|
|
739
1365
|
result,
|
|
740
|
-
Category: "
|
|
1366
|
+
Category: "sp00ky-client::Database::query"
|
|
741
1367
|
}, "Query executed successfully");
|
|
742
1368
|
} catch (err) {
|
|
743
1369
|
const duration = performance.now() - startTime;
|
|
@@ -753,7 +1379,7 @@ var AbstractDatabaseService = class {
|
|
|
753
1379
|
query,
|
|
754
1380
|
vars,
|
|
755
1381
|
err,
|
|
756
|
-
Category: "
|
|
1382
|
+
Category: "sp00ky-client::Database::query"
|
|
757
1383
|
}, "Query execution failed");
|
|
758
1384
|
reject(err);
|
|
759
1385
|
}
|
|
@@ -765,7 +1391,7 @@ var AbstractDatabaseService = class {
|
|
|
765
1391
|
return query.extract(raw);
|
|
766
1392
|
}
|
|
767
1393
|
async close() {
|
|
768
|
-
this.logger.info({ Category: "
|
|
1394
|
+
this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
|
|
769
1395
|
await this.client.close();
|
|
770
1396
|
}
|
|
771
1397
|
};
|
|
@@ -945,7 +1571,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
945
1571
|
type,
|
|
946
1572
|
phase,
|
|
947
1573
|
service: "surrealdb:local",
|
|
948
|
-
Category: "
|
|
1574
|
+
Category: "sp00ky-client::LocalDatabaseService::diagnostics"
|
|
949
1575
|
}, `Local SurrealDB diagnostics captured ${type}:${phase}`);
|
|
950
1576
|
})
|
|
951
1577
|
}), logger, events);
|
|
@@ -956,38 +1582,153 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
956
1582
|
}
|
|
957
1583
|
async connect() {
|
|
958
1584
|
const { namespace, database } = this.getConfig();
|
|
1585
|
+
const store = this.getConfig().store ?? "memory";
|
|
1586
|
+
const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
|
|
959
1587
|
this.logger.info({
|
|
960
1588
|
namespace,
|
|
961
1589
|
database,
|
|
962
|
-
|
|
1590
|
+
storeUrl,
|
|
1591
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
963
1592
|
}, "Connecting to local database");
|
|
1593
|
+
this.registerUnloadClose();
|
|
964
1594
|
try {
|
|
965
|
-
|
|
966
|
-
this.logger.
|
|
967
|
-
|
|
968
|
-
Category: "spooky-client::LocalDatabaseService::connect"
|
|
969
|
-
}, "[LocalDatabaseService] Calling client.connect");
|
|
970
|
-
await this.client.connect(storeUrl, {});
|
|
971
|
-
this.logger.debug({
|
|
972
|
-
namespace,
|
|
973
|
-
database,
|
|
974
|
-
Category: "spooky-client::LocalDatabaseService::connect"
|
|
975
|
-
}, "[LocalDatabaseService] client.connect returned. Calling client.use");
|
|
976
|
-
await this.client.use({
|
|
977
|
-
namespace,
|
|
978
|
-
database
|
|
979
|
-
});
|
|
980
|
-
this.logger.debug({ Category: "spooky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
981
|
-
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;
|
|
982
1598
|
} catch (err) {
|
|
983
|
-
|
|
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({
|
|
984
1607
|
err,
|
|
985
|
-
Category: "
|
|
986
|
-
}, "
|
|
987
|
-
|
|
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)");
|
|
988
1648
|
}
|
|
989
1649
|
}
|
|
990
|
-
|
|
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
|
+
}
|
|
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
|
+
}
|
|
991
1732
|
|
|
992
1733
|
//#endregion
|
|
993
1734
|
//#region src/services/database/remote.ts
|
|
@@ -1003,7 +1744,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1003
1744
|
type,
|
|
1004
1745
|
phase,
|
|
1005
1746
|
service: "surrealdb:remote",
|
|
1006
|
-
Category: "
|
|
1747
|
+
Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
|
|
1007
1748
|
}, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
|
|
1008
1749
|
}) }), logger, events);
|
|
1009
1750
|
this.config = config;
|
|
@@ -1018,7 +1759,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1018
1759
|
endpoint,
|
|
1019
1760
|
namespace,
|
|
1020
1761
|
database,
|
|
1021
|
-
Category: "
|
|
1762
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1022
1763
|
}, "Connecting to remote database");
|
|
1023
1764
|
try {
|
|
1024
1765
|
await this.client.connect(endpoint);
|
|
@@ -1027,18 +1768,18 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1027
1768
|
database
|
|
1028
1769
|
});
|
|
1029
1770
|
if (token) {
|
|
1030
|
-
this.logger.debug({ Category: "
|
|
1771
|
+
this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
|
|
1031
1772
|
await this.client.authenticate(token);
|
|
1032
1773
|
}
|
|
1033
|
-
this.logger.info({ Category: "
|
|
1774
|
+
this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
|
|
1034
1775
|
} catch (err) {
|
|
1035
1776
|
this.logger.error({
|
|
1036
1777
|
err,
|
|
1037
|
-
Category: "
|
|
1778
|
+
Category: "sp00ky-client::RemoteDatabaseService::connect"
|
|
1038
1779
|
}, "Failed to connect to remote database");
|
|
1039
1780
|
throw err;
|
|
1040
1781
|
}
|
|
1041
|
-
} else this.logger.warn({ Category: "
|
|
1782
|
+
} else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
|
|
1042
1783
|
}
|
|
1043
1784
|
async signin(params) {
|
|
1044
1785
|
return this.client.signin(params);
|
|
@@ -1056,70 +1797,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
1056
1797
|
|
|
1057
1798
|
//#endregion
|
|
1058
1799
|
//#region src/services/logger/index.ts
|
|
1059
|
-
|
|
1060
|
-
function mapLevelToSeverityNumber(level) {
|
|
1061
|
-
switch (level) {
|
|
1062
|
-
case "trace": return 1;
|
|
1063
|
-
case "debug": return 5;
|
|
1064
|
-
case "info": return 9;
|
|
1065
|
-
case "warn": return 13;
|
|
1066
|
-
case "error": return 17;
|
|
1067
|
-
case "fatal": return 21;
|
|
1068
|
-
default: return 9;
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
function createLogger(level = "info", otelEndpoint) {
|
|
1800
|
+
function createLogger(level = "info", transmit) {
|
|
1072
1801
|
const browserConfig = {
|
|
1073
1802
|
asObject: true,
|
|
1074
1803
|
write: (o) => {
|
|
1075
1804
|
console.log(JSON.stringify(o));
|
|
1076
1805
|
}
|
|
1077
1806
|
};
|
|
1078
|
-
if (
|
|
1079
|
-
const loggerProvider = new LoggerProvider({
|
|
1080
|
-
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "spooky-client" }),
|
|
1081
|
-
processors: [new BatchLogRecordProcessor(new OTLPLogExporter({ url: otelEndpoint }))]
|
|
1082
|
-
});
|
|
1083
|
-
const otelLogger = {};
|
|
1084
|
-
const getOtelLogger = (category) => {
|
|
1085
|
-
if (!otelLogger[category]) otelLogger[category] = loggerProvider.getLogger(category);
|
|
1086
|
-
return otelLogger[category];
|
|
1087
|
-
};
|
|
1088
|
-
browserConfig.transmit = {
|
|
1089
|
-
level,
|
|
1090
|
-
send: (levelLabel, logEvent) => {
|
|
1091
|
-
try {
|
|
1092
|
-
const messages = [...logEvent.messages];
|
|
1093
|
-
const severityNumber = mapLevelToSeverityNumber(levelLabel);
|
|
1094
|
-
let body = "";
|
|
1095
|
-
const msg = messages.pop();
|
|
1096
|
-
if (typeof msg === "string") body = msg;
|
|
1097
|
-
else if (msg) body = JSON.stringify(msg);
|
|
1098
|
-
let category = "spooky-client::unknown";
|
|
1099
|
-
const attributes = {};
|
|
1100
|
-
for (const msg of messages) if (typeof msg === "object") {
|
|
1101
|
-
if (msg.Category) {
|
|
1102
|
-
category = msg.Category;
|
|
1103
|
-
delete msg.Category;
|
|
1104
|
-
}
|
|
1105
|
-
Object.assign(attributes, msg);
|
|
1106
|
-
}
|
|
1107
|
-
getOtelLogger(category).emit({
|
|
1108
|
-
severityNumber,
|
|
1109
|
-
severityText: levelLabel.toUpperCase(),
|
|
1110
|
-
body,
|
|
1111
|
-
attributes: {
|
|
1112
|
-
...logEvent.bindings[0],
|
|
1113
|
-
...attributes
|
|
1114
|
-
},
|
|
1115
|
-
timestamp: new Date(logEvent.ts)
|
|
1116
|
-
});
|
|
1117
|
-
} catch (e) {
|
|
1118
|
-
console.warn("Failed to transmit log to OTEL endpoint", e);
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
};
|
|
1122
|
-
}
|
|
1807
|
+
if (transmit) browserConfig.transmit = transmit;
|
|
1123
1808
|
return pino({
|
|
1124
1809
|
level,
|
|
1125
1810
|
browser: browserConfig
|
|
@@ -1144,25 +1829,25 @@ var LocalMigrator = class {
|
|
|
1144
1829
|
const hash = await sha1(schemaSurql);
|
|
1145
1830
|
const { database } = this.localDb.getConfig();
|
|
1146
1831
|
if (await this.isSchemaUpToDate(hash)) {
|
|
1147
|
-
this.logger.info({ Category: "
|
|
1832
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
|
|
1148
1833
|
return;
|
|
1149
1834
|
}
|
|
1150
1835
|
await this.recreateDatabase(database);
|
|
1151
|
-
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 ";
|
|
1152
1837
|
const statements = this.splitStatements(fullSchema);
|
|
1153
1838
|
for (let i = 0; i < statements.length; i++) {
|
|
1154
1839
|
const statement = statements[i];
|
|
1155
1840
|
const cleanStatement = statement.replace(/--.*/g, "").trim();
|
|
1156
1841
|
if (cleanStatement.toUpperCase().startsWith("DEFINE INDEX")) {
|
|
1157
|
-
this.logger.warn({ Category: "
|
|
1842
|
+
this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
|
|
1158
1843
|
continue;
|
|
1159
1844
|
}
|
|
1160
1845
|
try {
|
|
1161
|
-
this.logger.info({ Category: "
|
|
1846
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
|
|
1162
1847
|
await this.localDb.query(statement);
|
|
1163
|
-
this.logger.info({ Category: "
|
|
1848
|
+
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
|
|
1164
1849
|
} catch (e) {
|
|
1165
|
-
this.logger.error({ Category: "
|
|
1850
|
+
this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
|
|
1166
1851
|
throw e;
|
|
1167
1852
|
}
|
|
1168
1853
|
}
|
|
@@ -1170,22 +1855,22 @@ var LocalMigrator = class {
|
|
|
1170
1855
|
}
|
|
1171
1856
|
async isSchemaUpToDate(hash) {
|
|
1172
1857
|
try {
|
|
1173
|
-
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;`);
|
|
1174
1859
|
return lastSchemaRecord?.hash === hash;
|
|
1175
|
-
} catch (
|
|
1860
|
+
} catch (_error) {
|
|
1176
1861
|
return false;
|
|
1177
1862
|
}
|
|
1178
1863
|
}
|
|
1179
1864
|
async recreateDatabase(database) {
|
|
1180
1865
|
try {
|
|
1181
|
-
await this.localDb.query(`DEFINE DATABASE
|
|
1182
|
-
} catch (
|
|
1866
|
+
await this.localDb.query(`DEFINE DATABASE _00_temp;`);
|
|
1867
|
+
} catch (_e) {}
|
|
1183
1868
|
try {
|
|
1184
1869
|
await this.localDb.query(`
|
|
1185
|
-
USE DB
|
|
1870
|
+
USE DB _00_temp;
|
|
1186
1871
|
REMOVE DATABASE ${database};
|
|
1187
1872
|
`);
|
|
1188
|
-
} catch (
|
|
1873
|
+
} catch (_e) {}
|
|
1189
1874
|
await this.localDb.query(`
|
|
1190
1875
|
DEFINE DATABASE ${database};
|
|
1191
1876
|
USE DB ${database};
|
|
@@ -1243,7 +1928,7 @@ var LocalMigrator = class {
|
|
|
1243
1928
|
return statements;
|
|
1244
1929
|
}
|
|
1245
1930
|
async createHashRecord(hash) {
|
|
1246
|
-
await this.localDb.query(`UPSERT
|
|
1931
|
+
await this.localDb.query(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
|
|
1247
1932
|
}
|
|
1248
1933
|
};
|
|
1249
1934
|
|
|
@@ -1264,13 +1949,15 @@ function createSyncQueueEventSystem() {
|
|
|
1264
1949
|
const SyncEventTypes = {
|
|
1265
1950
|
QueryUpdated: "SYNC_QUERY_UPDATED",
|
|
1266
1951
|
RemoteDataIngested: "SYNC_REMOTE_DATA_INGESTED",
|
|
1267
|
-
MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK"
|
|
1952
|
+
MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK",
|
|
1953
|
+
SyncHealthChanged: "SYNC_HEALTH_CHANGED"
|
|
1268
1954
|
};
|
|
1269
1955
|
function createSyncEventSystem() {
|
|
1270
1956
|
return createEventSystem([
|
|
1271
1957
|
SyncEventTypes.QueryUpdated,
|
|
1272
1958
|
SyncEventTypes.RemoteDataIngested,
|
|
1273
|
-
SyncEventTypes.MutationRolledBack
|
|
1959
|
+
SyncEventTypes.MutationRolledBack,
|
|
1960
|
+
SyncEventTypes.SyncHealthChanged
|
|
1274
1961
|
]);
|
|
1275
1962
|
}
|
|
1276
1963
|
|
|
@@ -1335,7 +2022,7 @@ var UpQueue = class {
|
|
|
1335
2022
|
this.logger.error({
|
|
1336
2023
|
error,
|
|
1337
2024
|
event,
|
|
1338
|
-
Category: "
|
|
2025
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1339
2026
|
}, "Network error processing mutation, re-queuing");
|
|
1340
2027
|
this.queue.unshift(event);
|
|
1341
2028
|
throw error;
|
|
@@ -1343,7 +2030,7 @@ var UpQueue = class {
|
|
|
1343
2030
|
this.logger.error({
|
|
1344
2031
|
error,
|
|
1345
2032
|
event,
|
|
1346
|
-
Category: "
|
|
2033
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1347
2034
|
}, "Application error processing mutation, rolling back");
|
|
1348
2035
|
try {
|
|
1349
2036
|
await this.removeEventFromDatabase(event.mutation_id);
|
|
@@ -1351,7 +2038,7 @@ var UpQueue = class {
|
|
|
1351
2038
|
this.logger.error({
|
|
1352
2039
|
error: removeError,
|
|
1353
2040
|
event,
|
|
1354
|
-
Category: "
|
|
2041
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1355
2042
|
}, "Failed to remove rolled-back mutation from database");
|
|
1356
2043
|
}
|
|
1357
2044
|
if (onRollback) try {
|
|
@@ -1360,7 +2047,7 @@ var UpQueue = class {
|
|
|
1360
2047
|
this.logger.error({
|
|
1361
2048
|
error: rollbackError,
|
|
1362
2049
|
event,
|
|
1363
|
-
Category: "
|
|
2050
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1364
2051
|
}, "Rollback handler failed");
|
|
1365
2052
|
}
|
|
1366
2053
|
this._events.addEvent({
|
|
@@ -1375,7 +2062,7 @@ var UpQueue = class {
|
|
|
1375
2062
|
this.logger.error({
|
|
1376
2063
|
error,
|
|
1377
2064
|
event,
|
|
1378
|
-
Category: "
|
|
2065
|
+
Category: "sp00ky-client::UpQueue::next"
|
|
1379
2066
|
}, "Failed to remove mutation from database after successful processing");
|
|
1380
2067
|
}
|
|
1381
2068
|
this._events.addEvent({
|
|
@@ -1389,7 +2076,7 @@ var UpQueue = class {
|
|
|
1389
2076
|
}
|
|
1390
2077
|
async loadFromDatabase() {
|
|
1391
2078
|
try {
|
|
1392
|
-
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`);
|
|
1393
2080
|
this.queue = records.map((r) => {
|
|
1394
2081
|
switch (r.mutationType) {
|
|
1395
2082
|
case "create": return {
|
|
@@ -1415,7 +2102,7 @@ var UpQueue = class {
|
|
|
1415
2102
|
this.logger.warn({
|
|
1416
2103
|
mutationType: r.mutationType,
|
|
1417
2104
|
record: r,
|
|
1418
|
-
Category: "
|
|
2105
|
+
Category: "sp00ky-client::UpQueue::loadFromDatabase"
|
|
1419
2106
|
}, "Unknown mutation type");
|
|
1420
2107
|
return null;
|
|
1421
2108
|
}
|
|
@@ -1423,7 +2110,7 @@ var UpQueue = class {
|
|
|
1423
2110
|
} catch (error) {
|
|
1424
2111
|
this.logger.error({
|
|
1425
2112
|
error,
|
|
1426
|
-
Category: "
|
|
2113
|
+
Category: "sp00ky-client::UpQueue::loadFromDatabase"
|
|
1427
2114
|
}, "Failed to load pending mutations from database");
|
|
1428
2115
|
}
|
|
1429
2116
|
}
|
|
@@ -1464,7 +2151,7 @@ var DownQueue = class {
|
|
|
1464
2151
|
this.logger.error({
|
|
1465
2152
|
error,
|
|
1466
2153
|
event,
|
|
1467
|
-
Category: "
|
|
2154
|
+
Category: "sp00ky-client::DownQueue::next"
|
|
1468
2155
|
}, "Failed to process query");
|
|
1469
2156
|
this.queue.unshift(event);
|
|
1470
2157
|
throw error;
|
|
@@ -1479,8 +2166,8 @@ var ArraySyncer = class {
|
|
|
1479
2166
|
remoteArray;
|
|
1480
2167
|
needsSort = false;
|
|
1481
2168
|
constructor(localArray, remoteArray) {
|
|
1482
|
-
this.remoteArray = remoteArray.
|
|
1483
|
-
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]));
|
|
1484
2171
|
}
|
|
1485
2172
|
/**
|
|
1486
2173
|
* Inserts an item into the local array
|
|
@@ -1516,7 +2203,6 @@ var ArraySyncer = class {
|
|
|
1516
2203
|
this.localArray.sort((a, b) => a[0].localeCompare(b[0]));
|
|
1517
2204
|
this.needsSort = false;
|
|
1518
2205
|
}
|
|
1519
|
-
console.log("xxxx555", this.localArray, this.remoteArray);
|
|
1520
2206
|
return diffRecordVersionArray(this.localArray, this.remoteArray);
|
|
1521
2207
|
}
|
|
1522
2208
|
};
|
|
@@ -1547,14 +2233,12 @@ function diffRecordVersionArray(local, remote) {
|
|
|
1547
2233
|
};
|
|
1548
2234
|
}
|
|
1549
2235
|
function createDiffFromDbOp(op, recordId, version, versions) {
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
};
|
|
1557
|
-
}
|
|
2236
|
+
const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
|
|
2237
|
+
if (old && old[1] >= version) return {
|
|
2238
|
+
added: [],
|
|
2239
|
+
updated: [],
|
|
2240
|
+
removed: []
|
|
2241
|
+
};
|
|
1558
2242
|
if (op === "CREATE") return {
|
|
1559
2243
|
added: [{
|
|
1560
2244
|
id: recordId,
|
|
@@ -1577,6 +2261,98 @@ function createDiffFromDbOp(op, recordId, version, versions) {
|
|
|
1577
2261
|
removed: [recordId]
|
|
1578
2262
|
};
|
|
1579
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
|
+
}
|
|
1580
2356
|
|
|
1581
2357
|
//#endregion
|
|
1582
2358
|
//#region src/modules/sync/engine.ts
|
|
@@ -1584,7 +2360,7 @@ function createDiffFromDbOp(op, recordId, version, versions) {
|
|
|
1584
2360
|
* SyncEngine handles the core sync operations: fetching remote records,
|
|
1585
2361
|
* caching them locally, and ingesting into DBSP.
|
|
1586
2362
|
*
|
|
1587
|
-
* This is extracted from
|
|
2363
|
+
* This is extracted from Sp00kySync to separate "how to sync" from "when to sync".
|
|
1588
2364
|
*/
|
|
1589
2365
|
var SyncEngine = class {
|
|
1590
2366
|
logger;
|
|
@@ -1593,7 +2369,7 @@ var SyncEngine = class {
|
|
|
1593
2369
|
this.remote = remote;
|
|
1594
2370
|
this.cache = cache;
|
|
1595
2371
|
this.schema = schema;
|
|
1596
|
-
this.logger = logger.child({ service: "
|
|
2372
|
+
this.logger = logger.child({ service: "Sp00kySync:SyncEngine" });
|
|
1597
2373
|
}
|
|
1598
2374
|
/**
|
|
1599
2375
|
* Sync missing/updated/removed records between local and remote.
|
|
@@ -1606,33 +2382,42 @@ var SyncEngine = class {
|
|
|
1606
2382
|
added,
|
|
1607
2383
|
updated,
|
|
1608
2384
|
removed,
|
|
1609
|
-
Category: "
|
|
2385
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
1610
2386
|
}, "SyncEngine.syncRecords diff");
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
const
|
|
1615
|
-
|
|
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;
|
|
1616
2400
|
const cacheBatch = [];
|
|
1617
|
-
for (const
|
|
2401
|
+
for (const record of remoteResults) {
|
|
1618
2402
|
if (!record?.id) {
|
|
1619
2403
|
this.logger.warn({
|
|
1620
2404
|
record,
|
|
1621
2405
|
idsToFetch,
|
|
1622
|
-
Category: "
|
|
1623
|
-
}, "Remote record has no id. Skipping record");
|
|
2406
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
2407
|
+
}, "Remote record has no id (possibly deleted). Skipping record");
|
|
1624
2408
|
continue;
|
|
1625
2409
|
}
|
|
1626
2410
|
const fullId = encodeRecordId(record.id);
|
|
1627
2411
|
const table = record.id.table.toString();
|
|
1628
2412
|
const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
|
|
2413
|
+
const version = versionMap.get(fullId) ?? 0;
|
|
1629
2414
|
const localVersion = this.cache.lookup(fullId);
|
|
1630
|
-
if (localVersion &&
|
|
2415
|
+
if (localVersion && version <= localVersion) {
|
|
1631
2416
|
this.logger.info({
|
|
1632
2417
|
recordId: fullId,
|
|
1633
|
-
version
|
|
2418
|
+
version,
|
|
1634
2419
|
localVersion,
|
|
1635
|
-
Category: "
|
|
2420
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
1636
2421
|
}, "Local version is higher than remote version. Skipping record");
|
|
1637
2422
|
continue;
|
|
1638
2423
|
}
|
|
@@ -1642,37 +2427,59 @@ var SyncEngine = class {
|
|
|
1642
2427
|
table,
|
|
1643
2428
|
op: isAdded ? "CREATE" : "UPDATE",
|
|
1644
2429
|
record: cleanedRecord,
|
|
1645
|
-
version
|
|
2430
|
+
version
|
|
1646
2431
|
});
|
|
1647
2432
|
}
|
|
1648
2433
|
if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
|
|
1649
2434
|
this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
|
|
2435
|
+
return {
|
|
2436
|
+
remoteFetchMs,
|
|
2437
|
+
stillRemoteIds
|
|
2438
|
+
};
|
|
1650
2439
|
}
|
|
1651
2440
|
/**
|
|
1652
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.
|
|
1653
2453
|
*/
|
|
1654
2454
|
async handleRemovedRecords(removed) {
|
|
1655
2455
|
this.logger.debug({
|
|
1656
2456
|
removed: removed.map((r) => r.toString()),
|
|
1657
|
-
Category: "
|
|
2457
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1658
2458
|
}, "Checking removed records");
|
|
1659
|
-
let existingRemoteIds
|
|
2459
|
+
let existingRemoteIds;
|
|
1660
2460
|
try {
|
|
1661
|
-
const [
|
|
1662
|
-
existingRemoteIds = new Set(
|
|
1663
|
-
} catch {
|
|
1664
|
-
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 [];
|
|
1665
2470
|
}
|
|
2471
|
+
const stillRemoteIds = [];
|
|
1666
2472
|
for (const recordId of removed) {
|
|
1667
2473
|
const recordIdStr = encodeRecordId(recordId);
|
|
1668
2474
|
if (!existingRemoteIds.has(recordIdStr)) {
|
|
1669
2475
|
this.logger.debug({
|
|
1670
2476
|
recordId: recordIdStr,
|
|
1671
|
-
Category: "
|
|
2477
|
+
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1672
2478
|
}, "Deleting confirmed removed record");
|
|
1673
2479
|
await this.cache.delete(recordId.table.name, recordIdStr);
|
|
1674
|
-
}
|
|
2480
|
+
} else stillRemoteIds.push(recordIdStr);
|
|
1675
2481
|
}
|
|
2482
|
+
return stillRemoteIds;
|
|
1676
2483
|
}
|
|
1677
2484
|
};
|
|
1678
2485
|
|
|
@@ -1685,13 +2492,14 @@ var SyncEngine = class {
|
|
|
1685
2492
|
var SyncScheduler = class {
|
|
1686
2493
|
isSyncingUp = false;
|
|
1687
2494
|
isSyncingDown = false;
|
|
1688
|
-
constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback) {
|
|
2495
|
+
constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
|
|
1689
2496
|
this.upQueue = upQueue;
|
|
1690
2497
|
this.downQueue = downQueue;
|
|
1691
2498
|
this.onProcessUp = onProcessUp;
|
|
1692
2499
|
this.onProcessDown = onProcessDown;
|
|
1693
2500
|
this.logger = logger;
|
|
1694
2501
|
this.onRollback = onRollback;
|
|
2502
|
+
this.onSyncOutcome = onSyncOutcome;
|
|
1695
2503
|
}
|
|
1696
2504
|
async init() {
|
|
1697
2505
|
await this.upQueue.loadFromDatabase();
|
|
@@ -1716,8 +2524,19 @@ var SyncScheduler = class {
|
|
|
1716
2524
|
async syncUp() {
|
|
1717
2525
|
if (this.isSyncingUp) return;
|
|
1718
2526
|
this.isSyncingUp = true;
|
|
2527
|
+
let processedAny = false;
|
|
1719
2528
|
try {
|
|
1720
|
-
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");
|
|
1721
2540
|
} finally {
|
|
1722
2541
|
this.isSyncingUp = false;
|
|
1723
2542
|
this.syncDown();
|
|
@@ -1730,11 +2549,20 @@ var SyncScheduler = class {
|
|
|
1730
2549
|
if (this.isSyncingDown) return;
|
|
1731
2550
|
if (this.upQueue.size > 0) return;
|
|
1732
2551
|
this.isSyncingDown = true;
|
|
2552
|
+
let processedAny = false;
|
|
1733
2553
|
try {
|
|
1734
2554
|
while (this.downQueue.size > 0) {
|
|
1735
2555
|
if (this.upQueue.size > 0) break;
|
|
1736
2556
|
await this.downQueue.next(this.onProcessDown);
|
|
2557
|
+
processedAny = true;
|
|
1737
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");
|
|
1738
2566
|
} finally {
|
|
1739
2567
|
this.isSyncingDown = false;
|
|
1740
2568
|
}
|
|
@@ -1744,23 +2572,91 @@ var SyncScheduler = class {
|
|
|
1744
2572
|
}
|
|
1745
2573
|
};
|
|
1746
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
|
+
|
|
1747
2622
|
//#endregion
|
|
1748
2623
|
//#region src/modules/sync/sync.ts
|
|
1749
2624
|
/**
|
|
1750
|
-
* The main synchronization engine for
|
|
2625
|
+
* The main synchronization engine for Sp00ky.
|
|
1751
2626
|
* Handles the bidirectional synchronization between the local database and the remote backend.
|
|
1752
2627
|
* Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
|
|
1753
2628
|
* @template S The schema structure type.
|
|
1754
2629
|
*/
|
|
1755
|
-
var
|
|
1756
|
-
clientId = "";
|
|
2630
|
+
var Sp00kySync = class Sp00kySync {
|
|
1757
2631
|
upQueue;
|
|
1758
2632
|
downQueue;
|
|
1759
2633
|
isInit = false;
|
|
1760
2634
|
logger;
|
|
1761
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
|
+
}
|
|
1762
2642
|
scheduler;
|
|
2643
|
+
wasDisconnected = false;
|
|
1763
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
|
+
}
|
|
1764
2660
|
get isSyncing() {
|
|
1765
2661
|
return this.scheduler.isSyncing;
|
|
1766
2662
|
}
|
|
@@ -1775,60 +2671,415 @@ var SpookySync = class {
|
|
|
1775
2671
|
this.upQueue.events.unsubscribe(id2);
|
|
1776
2672
|
};
|
|
1777
2673
|
}
|
|
1778
|
-
|
|
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) {
|
|
1779
2792
|
this.local = local;
|
|
1780
2793
|
this.remote = remote;
|
|
1781
2794
|
this.cache = cache;
|
|
1782
2795
|
this.dataModule = dataModule;
|
|
1783
2796
|
this.schema = schema;
|
|
1784
|
-
this.logger = logger.child({ service: "
|
|
2797
|
+
this.logger = logger.child({ service: "Sp00kySync" });
|
|
1785
2798
|
this.upQueue = new UpQueue(this.local, this.logger);
|
|
1786
2799
|
this.downQueue = new DownQueue(this.local, this.logger);
|
|
1787
2800
|
this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
|
|
1788
|
-
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);
|
|
1789
2805
|
}
|
|
1790
2806
|
/**
|
|
1791
2807
|
* Initializes the synchronization system.
|
|
1792
2808
|
* Starts the scheduler and initiates the initial sync cycles.
|
|
1793
|
-
* @param clientId The unique identifier for this client instance.
|
|
1794
2809
|
* @throws Error if already initialized.
|
|
1795
2810
|
*/
|
|
1796
|
-
async init(
|
|
1797
|
-
if (this.isInit) throw new Error("
|
|
1798
|
-
this.clientId = clientId;
|
|
2811
|
+
async init() {
|
|
2812
|
+
if (this.isInit) throw new Error("Sp00kySync is already initialized");
|
|
1799
2813
|
this.isInit = true;
|
|
1800
2814
|
await this.scheduler.init();
|
|
1801
|
-
this.
|
|
2815
|
+
this.subscribeToReconnect();
|
|
1802
2816
|
this.scheduler.syncUp();
|
|
1803
2817
|
this.scheduler.syncDown();
|
|
1804
|
-
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
|
+
});
|
|
1805
3043
|
}
|
|
1806
3044
|
async startRefLiveQueries() {
|
|
3045
|
+
const tableName = this.listRefTable();
|
|
1807
3046
|
this.logger.debug({
|
|
1808
|
-
|
|
1809
|
-
Category: "
|
|
3047
|
+
tableName,
|
|
3048
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1810
3049
|
}, "Starting ref live queries");
|
|
1811
|
-
const [queryUuid] = await this.remote.query(
|
|
1812
|
-
|
|
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) => {
|
|
1813
3053
|
this.logger.debug({
|
|
1814
3054
|
message,
|
|
1815
|
-
Category: "
|
|
3055
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1816
3056
|
}, "Live update received");
|
|
1817
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
|
+
}
|
|
1818
3067
|
this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
|
|
1819
3068
|
this.logger.error({
|
|
1820
3069
|
err,
|
|
1821
|
-
Category: "
|
|
3070
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1822
3071
|
}, "Error handling remote list ref change");
|
|
1823
3072
|
});
|
|
1824
3073
|
});
|
|
1825
3074
|
}
|
|
1826
3075
|
async handleRemoteListRefChange(action, queryId, recordId, version) {
|
|
3076
|
+
this.lastLiveEventAt = Date.now();
|
|
3077
|
+
this.listRefIdleStreak = 0;
|
|
1827
3078
|
const existing = this.dataModule.getQueryById(queryId);
|
|
1828
3079
|
if (!existing) {
|
|
1829
3080
|
this.logger.warn({
|
|
1830
3081
|
queryId: queryId.toString(),
|
|
1831
|
-
Category: "
|
|
3082
|
+
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
1832
3083
|
}, "Received remote update for unknown local query");
|
|
1833
3084
|
return;
|
|
1834
3085
|
}
|
|
@@ -1839,10 +3090,46 @@ var SpookySync = class {
|
|
|
1839
3090
|
recordId,
|
|
1840
3091
|
version,
|
|
1841
3092
|
localArray,
|
|
1842
|
-
Category: "
|
|
3093
|
+
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
1843
3094
|
}, "Live update is being processed");
|
|
1844
3095
|
const diff = createDiffFromDbOp(action, recordId, version, localArray);
|
|
1845
|
-
|
|
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]];
|
|
1846
3133
|
}
|
|
1847
3134
|
/**
|
|
1848
3135
|
* Enqueues a 'down' event (from remote to local) for processing.
|
|
@@ -1854,11 +3141,10 @@ var SpookySync = class {
|
|
|
1854
3141
|
async processUpEvent(event) {
|
|
1855
3142
|
this.logger.debug({
|
|
1856
3143
|
event,
|
|
1857
|
-
Category: "
|
|
3144
|
+
Category: "sp00ky-client::Sp00kySync::processUpEvent"
|
|
1858
3145
|
}, "Processing up event");
|
|
1859
|
-
console.log("xx1", event);
|
|
1860
3146
|
switch (event.type) {
|
|
1861
|
-
case "create":
|
|
3147
|
+
case "create": {
|
|
1862
3148
|
const dataKeys = Object.keys(event.data).map((key) => ({
|
|
1863
3149
|
key,
|
|
1864
3150
|
variable: `data_${key}`
|
|
@@ -1870,6 +3156,7 @@ var SpookySync = class {
|
|
|
1870
3156
|
...prefixedParams
|
|
1871
3157
|
});
|
|
1872
3158
|
break;
|
|
3159
|
+
}
|
|
1873
3160
|
case "update":
|
|
1874
3161
|
await this.remote.query(`UPDATE $id MERGE $data`, {
|
|
1875
3162
|
id: event.record_id,
|
|
@@ -1882,7 +3169,7 @@ var SpookySync = class {
|
|
|
1882
3169
|
default:
|
|
1883
3170
|
this.logger.error({
|
|
1884
3171
|
event,
|
|
1885
|
-
Category: "
|
|
3172
|
+
Category: "sp00ky-client::Sp00kySync::processUpEvent"
|
|
1886
3173
|
}, "processUpEvent unknown event type");
|
|
1887
3174
|
return;
|
|
1888
3175
|
}
|
|
@@ -1895,7 +3182,7 @@ var SpookySync = class {
|
|
|
1895
3182
|
recordId,
|
|
1896
3183
|
tableName,
|
|
1897
3184
|
error: error.message,
|
|
1898
|
-
Category: "
|
|
3185
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1899
3186
|
}, "Rolling back failed mutation");
|
|
1900
3187
|
switch (event.type) {
|
|
1901
3188
|
case "create":
|
|
@@ -1905,13 +3192,13 @@ var SpookySync = class {
|
|
|
1905
3192
|
if (event.beforeRecord) await this.dataModule.rollbackUpdate(event.record_id, tableName, event.beforeRecord);
|
|
1906
3193
|
else this.logger.warn({
|
|
1907
3194
|
recordId,
|
|
1908
|
-
Category: "
|
|
3195
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1909
3196
|
}, "Cannot rollback update: no beforeRecord available. Down-sync will reconcile.");
|
|
1910
3197
|
break;
|
|
1911
3198
|
case "delete":
|
|
1912
3199
|
this.logger.warn({
|
|
1913
3200
|
recordId,
|
|
1914
|
-
Category: "
|
|
3201
|
+
Category: "sp00ky-client::Sp00kySync::handleRollback"
|
|
1915
3202
|
}, "Delete rollback not implemented. Down-sync will reconcile.");
|
|
1916
3203
|
break;
|
|
1917
3204
|
}
|
|
@@ -1924,7 +3211,7 @@ var SpookySync = class {
|
|
|
1924
3211
|
async processDownEvent(event) {
|
|
1925
3212
|
this.logger.debug({
|
|
1926
3213
|
event,
|
|
1927
|
-
Category: "
|
|
3214
|
+
Category: "sp00ky-client::Sp00kySync::processDownEvent"
|
|
1928
3215
|
}, "Processing down event");
|
|
1929
3216
|
switch (event.type) {
|
|
1930
3217
|
case "register": return this.registerQuery(event.payload.hash);
|
|
@@ -1943,13 +3230,76 @@ var SpookySync = class {
|
|
|
1943
3230
|
if (!queryState) {
|
|
1944
3231
|
this.logger.warn({
|
|
1945
3232
|
hash,
|
|
1946
|
-
Category: "
|
|
3233
|
+
Category: "sp00ky-client::Sp00kySync::syncQuery"
|
|
1947
3234
|
}, "Query not found");
|
|
1948
3235
|
return;
|
|
1949
3236
|
}
|
|
1950
3237
|
const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
|
|
1951
3238
|
if (!diff) return;
|
|
1952
|
-
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
|
+
}
|
|
1953
3303
|
}
|
|
1954
3304
|
/**
|
|
1955
3305
|
* Enqueues a list of mutations (up events) to be sent to the remote.
|
|
@@ -1962,14 +3312,15 @@ var SpookySync = class {
|
|
|
1962
3312
|
try {
|
|
1963
3313
|
this.logger.debug({
|
|
1964
3314
|
queryHash,
|
|
1965
|
-
Category: "
|
|
3315
|
+
Category: "sp00ky-client::Sp00kySync::registerQuery"
|
|
1966
3316
|
}, "Register Query state");
|
|
1967
3317
|
await this.createRemoteQuery(queryHash);
|
|
1968
3318
|
await this.syncQuery(queryHash);
|
|
3319
|
+
await this.dataModule.notifyQuerySynced(queryHash);
|
|
1969
3320
|
} catch (e) {
|
|
1970
3321
|
this.logger.error({
|
|
1971
3322
|
err: e,
|
|
1972
|
-
Category: "
|
|
3323
|
+
Category: "sp00ky-client::Sp00kySync::registerQuery"
|
|
1973
3324
|
}, "registerQuery error");
|
|
1974
3325
|
throw e;
|
|
1975
3326
|
}
|
|
@@ -1979,37 +3330,80 @@ var SpookySync = class {
|
|
|
1979
3330
|
if (!queryState) {
|
|
1980
3331
|
this.logger.warn({
|
|
1981
3332
|
queryHash,
|
|
1982
|
-
Category: "
|
|
3333
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
1983
3334
|
}, "Query to register not found");
|
|
1984
3335
|
throw new Error("Query to register not found");
|
|
1985
3336
|
}
|
|
1986
3337
|
await this.remote.query("fn::query::register($config)", { config: {
|
|
1987
|
-
clientId: this.clientId,
|
|
1988
3338
|
id: queryState.config.id,
|
|
1989
3339
|
surql: queryState.config.surql,
|
|
1990
3340
|
params: queryState.config.params,
|
|
1991
3341
|
ttl: queryState.config.ttl
|
|
1992
3342
|
} });
|
|
1993
|
-
const
|
|
3343
|
+
const listRefTbl = this.listRefTable();
|
|
3344
|
+
const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
1994
3345
|
this.logger.trace({
|
|
1995
3346
|
queryId: encodeRecordId(queryState.config.id),
|
|
1996
3347
|
items,
|
|
1997
|
-
Category: "
|
|
3348
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
1998
3349
|
}, "Got query record version array from remote");
|
|
1999
3350
|
const array = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
2000
3351
|
this.logger.debug({
|
|
2001
3352
|
queryId: encodeRecordId(queryState.config.id),
|
|
2002
3353
|
array,
|
|
2003
|
-
Category: "
|
|
3354
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
2004
3355
|
}, "createdRemoteQuery");
|
|
2005
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;
|
|
2006
3400
|
}
|
|
2007
3401
|
async heartbeatQuery(queryHash) {
|
|
2008
3402
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2009
3403
|
if (!queryState) {
|
|
2010
3404
|
this.logger.warn({
|
|
2011
3405
|
queryHash,
|
|
2012
|
-
Category: "
|
|
3406
|
+
Category: "sp00ky-client::Sp00kySync::heartbeatQuery"
|
|
2013
3407
|
}, "Query to register not found");
|
|
2014
3408
|
throw new Error("Query to register not found");
|
|
2015
3409
|
}
|
|
@@ -2017,14 +3411,17 @@ var SpookySync = class {
|
|
|
2017
3411
|
}
|
|
2018
3412
|
async cleanupQuery(queryHash) {
|
|
2019
3413
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2020
|
-
if (!queryState)
|
|
2021
|
-
|
|
2022
|
-
queryHash,
|
|
2023
|
-
Category: "spooky-client::SpookySync::cleanupQuery"
|
|
2024
|
-
}, "Query to register not found");
|
|
2025
|
-
throw new Error("Query to register not found");
|
|
2026
|
-
}
|
|
3414
|
+
if (!queryState) return;
|
|
3415
|
+
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
2027
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);
|
|
2028
3425
|
}
|
|
2029
3426
|
};
|
|
2030
3427
|
|
|
@@ -2036,11 +3433,67 @@ function createAuthEventSystem() {
|
|
|
2036
3433
|
}
|
|
2037
3434
|
|
|
2038
3435
|
//#endregion
|
|
2039
|
-
//#region src/modules/devtools/
|
|
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
|
+
|
|
3486
|
+
//#endregion
|
|
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";
|
|
2040
3491
|
var DevToolsService = class {
|
|
2041
3492
|
eventsHistory = [];
|
|
2042
3493
|
eventIdCounter = 0;
|
|
2043
|
-
version =
|
|
3494
|
+
version = CORE_VERSION;
|
|
3495
|
+
backendInfo = emptyBackendInfo();
|
|
3496
|
+
enabled = false;
|
|
2044
3497
|
constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
|
|
2045
3498
|
this.databaseService = databaseService;
|
|
2046
3499
|
this.remoteDatabaseService = remoteDatabaseService;
|
|
@@ -2049,10 +3502,37 @@ var DevToolsService = class {
|
|
|
2049
3502
|
this.authService = authService;
|
|
2050
3503
|
this.dataManager = dataManager;
|
|
2051
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
|
+
});
|
|
2052
3513
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
2053
3514
|
this.notifyDevTools();
|
|
2054
3515
|
});
|
|
2055
|
-
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();
|
|
2056
3536
|
}
|
|
2057
3537
|
getActiveQueries() {
|
|
2058
3538
|
const result = /* @__PURE__ */ new Map();
|
|
@@ -2062,6 +3542,8 @@ var DevToolsService = class {
|
|
|
2062
3542
|
result.set(queryHash, {
|
|
2063
3543
|
queryHash,
|
|
2064
3544
|
status: "active",
|
|
3545
|
+
fetchStatus: q.status,
|
|
3546
|
+
isFetching: q.status === "fetching",
|
|
2065
3547
|
createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
|
|
2066
3548
|
lastUpdate: Date.now(),
|
|
2067
3549
|
updateCount: q.updateCount,
|
|
@@ -2070,7 +3552,8 @@ var DevToolsService = class {
|
|
|
2070
3552
|
dataSize: q.records?.length || 0,
|
|
2071
3553
|
data: q.records,
|
|
2072
3554
|
localArray: q.config.localArray,
|
|
2073
|
-
remoteArray: q.config.remoteArray
|
|
3555
|
+
remoteArray: q.config.remoteArray,
|
|
3556
|
+
timings: this.dataManager.phaseTimings(q)
|
|
2074
3557
|
});
|
|
2075
3558
|
});
|
|
2076
3559
|
return result;
|
|
@@ -2078,7 +3561,7 @@ var DevToolsService = class {
|
|
|
2078
3561
|
onQueryInitialized(payload) {
|
|
2079
3562
|
this.logger.debug({
|
|
2080
3563
|
payload,
|
|
2081
|
-
Category: "
|
|
3564
|
+
Category: "sp00ky-client::DevToolsService::onQueryInitialized"
|
|
2082
3565
|
}, "QueryInitialized");
|
|
2083
3566
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
2084
3567
|
this.addEvent("QUERY_REQUEST_INIT", {
|
|
@@ -2091,7 +3574,7 @@ var DevToolsService = class {
|
|
|
2091
3574
|
onQueryUpdated(payload) {
|
|
2092
3575
|
this.logger.debug({
|
|
2093
3576
|
id: payload.queryId?.toString(),
|
|
2094
|
-
Category: "
|
|
3577
|
+
Category: "sp00ky-client::DevToolsService::onQueryUpdated"
|
|
2095
3578
|
}, "QueryUpdated");
|
|
2096
3579
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
2097
3580
|
this.addEvent("QUERY_UPDATED", {
|
|
@@ -2103,7 +3586,7 @@ var DevToolsService = class {
|
|
|
2103
3586
|
onStreamUpdate(update) {
|
|
2104
3587
|
this.logger.debug({
|
|
2105
3588
|
update,
|
|
2106
|
-
Category: "
|
|
3589
|
+
Category: "sp00ky-client::DevToolsService::onStreamUpdate"
|
|
2107
3590
|
}, "StreamUpdate");
|
|
2108
3591
|
this.addEvent("STREAM_UPDATE", { updates: [update] });
|
|
2109
3592
|
this.notifyDevTools();
|
|
@@ -2133,6 +3616,7 @@ var DevToolsService = class {
|
|
|
2133
3616
|
this.notifyDevTools();
|
|
2134
3617
|
}
|
|
2135
3618
|
addEvent(eventType, payload) {
|
|
3619
|
+
if (!this.enabled) return;
|
|
2136
3620
|
this.eventsHistory.push({
|
|
2137
3621
|
id: this.eventIdCounter++,
|
|
2138
3622
|
timestamp: Date.now(),
|
|
@@ -2150,6 +3634,15 @@ var DevToolsService = class {
|
|
|
2150
3634
|
userId: this.authService.currentUser?.id
|
|
2151
3635
|
},
|
|
2152
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
|
+
},
|
|
2153
3646
|
database: {
|
|
2154
3647
|
tables: this.schema.tables.map((t) => t.name),
|
|
2155
3648
|
tableData: {}
|
|
@@ -2157,9 +3650,10 @@ var DevToolsService = class {
|
|
|
2157
3650
|
});
|
|
2158
3651
|
}
|
|
2159
3652
|
notifyDevTools() {
|
|
3653
|
+
if (!this.enabled) return;
|
|
2160
3654
|
if (typeof window !== "undefined") window.postMessage({
|
|
2161
|
-
type: "
|
|
2162
|
-
source: "
|
|
3655
|
+
type: "SP00KY_STATE_CHANGED",
|
|
3656
|
+
source: "sp00ky-devtools-page",
|
|
2163
3657
|
state: this.getState()
|
|
2164
3658
|
}, "*");
|
|
2165
3659
|
}
|
|
@@ -2185,13 +3679,14 @@ var DevToolsService = class {
|
|
|
2185
3679
|
}
|
|
2186
3680
|
exposeToWindow() {
|
|
2187
3681
|
if (typeof window !== "undefined") {
|
|
2188
|
-
window.
|
|
3682
|
+
window.__00__ = {
|
|
2189
3683
|
version: this.version,
|
|
2190
3684
|
getState: () => this.getState(),
|
|
2191
3685
|
clearHistory: () => {
|
|
2192
3686
|
this.eventsHistory = [];
|
|
2193
3687
|
this.notifyDevTools();
|
|
2194
3688
|
},
|
|
3689
|
+
refreshVersions: () => this.refreshBackendVersions(),
|
|
2195
3690
|
getTableData: async (tableName) => {
|
|
2196
3691
|
try {
|
|
2197
3692
|
const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
|
|
@@ -2206,7 +3701,7 @@ var DevToolsService = class {
|
|
|
2206
3701
|
} catch (e) {
|
|
2207
3702
|
this.logger.error({
|
|
2208
3703
|
err: e,
|
|
2209
|
-
Category: "
|
|
3704
|
+
Category: "sp00ky-client::DevToolsService::exposeToWindow"
|
|
2210
3705
|
}, "Failed to get table data");
|
|
2211
3706
|
return [];
|
|
2212
3707
|
}
|
|
@@ -2238,7 +3733,7 @@ var DevToolsService = class {
|
|
|
2238
3733
|
this.logger.debug({
|
|
2239
3734
|
query,
|
|
2240
3735
|
target,
|
|
2241
|
-
Category: "
|
|
3736
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2242
3737
|
}, "Running query (START)");
|
|
2243
3738
|
const service = target === "remote" ? this.remoteDatabaseService : this.databaseService;
|
|
2244
3739
|
const startTime = Date.now();
|
|
@@ -2249,7 +3744,7 @@ var DevToolsService = class {
|
|
|
2249
3744
|
time: queryTime,
|
|
2250
3745
|
resultType: typeof result,
|
|
2251
3746
|
isArray: Array.isArray(result),
|
|
2252
|
-
Category: "
|
|
3747
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2253
3748
|
}, "Database returned result");
|
|
2254
3749
|
const serializeStart = Date.now();
|
|
2255
3750
|
const serialized = this.serializeForDevTools(result);
|
|
@@ -2257,7 +3752,7 @@ var DevToolsService = class {
|
|
|
2257
3752
|
this.logger.debug({
|
|
2258
3753
|
serializeTime,
|
|
2259
3754
|
serializedLength: JSON.stringify(serialized).length,
|
|
2260
|
-
Category: "
|
|
3755
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2261
3756
|
}, "Serialization complete");
|
|
2262
3757
|
return {
|
|
2263
3758
|
success: true,
|
|
@@ -2269,7 +3764,7 @@ var DevToolsService = class {
|
|
|
2269
3764
|
err: e,
|
|
2270
3765
|
query,
|
|
2271
3766
|
target,
|
|
2272
|
-
Category: "
|
|
3767
|
+
Category: "sp00ky-client::DevToolsService::runQuery"
|
|
2273
3768
|
}, "Query execution failed");
|
|
2274
3769
|
return {
|
|
2275
3770
|
success: false,
|
|
@@ -2279,23 +3774,52 @@ var DevToolsService = class {
|
|
|
2279
3774
|
}
|
|
2280
3775
|
};
|
|
2281
3776
|
window.postMessage({
|
|
2282
|
-
type: "
|
|
2283
|
-
source: "
|
|
3777
|
+
type: "SP00KY_DETECTED",
|
|
3778
|
+
source: "sp00ky-devtools-page",
|
|
2284
3779
|
data: {
|
|
2285
3780
|
version: this.version,
|
|
2286
3781
|
detected: true
|
|
2287
3782
|
}
|
|
2288
3783
|
}, "*");
|
|
3784
|
+
window.dispatchEvent(new CustomEvent("sp00ky:init"));
|
|
2289
3785
|
}
|
|
2290
3786
|
}
|
|
2291
3787
|
};
|
|
2292
3788
|
|
|
2293
3789
|
//#endregion
|
|
2294
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
|
+
}
|
|
2295
3812
|
var AuthService = class {
|
|
2296
3813
|
token = null;
|
|
2297
3814
|
currentUser = null;
|
|
2298
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;
|
|
2299
3823
|
isLoading = true;
|
|
2300
3824
|
events = createAuthEventSystem();
|
|
2301
3825
|
get eventSystem() {
|
|
@@ -2336,9 +3860,9 @@ var AuthService = class {
|
|
|
2336
3860
|
async check(accessToken) {
|
|
2337
3861
|
this.isLoading = true;
|
|
2338
3862
|
try {
|
|
2339
|
-
const token = accessToken || await this.persistenceClient.get("
|
|
3863
|
+
const token = accessToken || await this.persistenceClient.get("sp00ky_auth_token");
|
|
2340
3864
|
if (!token) {
|
|
2341
|
-
this.logger.debug({ Category: "
|
|
3865
|
+
this.logger.debug({ Category: "sp00ky-client::AuthService::check" }, "No token found in storage or arguments");
|
|
2342
3866
|
this.isLoading = false;
|
|
2343
3867
|
this.isAuthenticated = false;
|
|
2344
3868
|
this.notifyListeners();
|
|
@@ -2351,22 +3875,22 @@ var AuthService = class {
|
|
|
2351
3875
|
if (user && user.id) {
|
|
2352
3876
|
this.logger.info({
|
|
2353
3877
|
user,
|
|
2354
|
-
Category: "
|
|
3878
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2355
3879
|
}, "Auth check complete (via $auth.id)");
|
|
2356
3880
|
await this.setSession(token, user);
|
|
2357
3881
|
} else {
|
|
2358
|
-
this.logger.warn({ Category: "
|
|
3882
|
+
this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "$auth.id empty, attempting manual user fetch");
|
|
2359
3883
|
const manualResult = await this.remote.query("SELECT * FROM user WHERE id = $auth.id LIMIT 1");
|
|
2360
3884
|
const manualItems = Array.isArray(manualResult) && Array.isArray(manualResult[0]) ? manualResult[0] : manualResult;
|
|
2361
3885
|
const manualUser = Array.isArray(manualItems) ? manualItems[0] : manualItems;
|
|
2362
3886
|
if (manualUser && manualUser.id) {
|
|
2363
3887
|
this.logger.info({
|
|
2364
3888
|
user: manualUser,
|
|
2365
|
-
Category: "
|
|
3889
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2366
3890
|
}, "Auth check complete (via manual fetch)");
|
|
2367
3891
|
await this.setSession(token, manualUser);
|
|
2368
3892
|
} else {
|
|
2369
|
-
this.logger.warn({ Category: "
|
|
3893
|
+
this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "Token valid but user not found via fallback");
|
|
2370
3894
|
await this.signOut();
|
|
2371
3895
|
}
|
|
2372
3896
|
}
|
|
@@ -2374,7 +3898,7 @@ var AuthService = class {
|
|
|
2374
3898
|
this.logger.error({
|
|
2375
3899
|
error,
|
|
2376
3900
|
stack: error.stack,
|
|
2377
|
-
Category: "
|
|
3901
|
+
Category: "sp00ky-client::AuthService::check"
|
|
2378
3902
|
}, "Auth check failed");
|
|
2379
3903
|
await this.signOut();
|
|
2380
3904
|
} finally {
|
|
@@ -2388,19 +3912,27 @@ var AuthService = class {
|
|
|
2388
3912
|
this.token = null;
|
|
2389
3913
|
this.currentUser = null;
|
|
2390
3914
|
this.isAuthenticated = false;
|
|
2391
|
-
|
|
3915
|
+
this.access = null;
|
|
3916
|
+
await this.persistenceClient.remove("sp00ky_auth_token");
|
|
2392
3917
|
try {
|
|
2393
3918
|
await this.remote.getClient().invalidate();
|
|
2394
|
-
} catch (
|
|
3919
|
+
} catch (_e) {}
|
|
2395
3920
|
this.notifyListeners();
|
|
2396
3921
|
}
|
|
2397
3922
|
async setSession(token, user) {
|
|
2398
3923
|
this.token = token;
|
|
2399
3924
|
this.currentUser = user;
|
|
2400
3925
|
this.isAuthenticated = true;
|
|
2401
|
-
|
|
3926
|
+
this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
|
|
3927
|
+
await this.persistenceClient.set("sp00ky_auth_token", token);
|
|
2402
3928
|
this.notifyListeners();
|
|
2403
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
|
+
}
|
|
2404
3936
|
async signUp(accessName, params) {
|
|
2405
3937
|
const def = this.getAccessDefinition(accessName);
|
|
2406
3938
|
if (!def) throw new Error(`Access definition '${accessName}' not found`);
|
|
@@ -2410,13 +3942,13 @@ var AuthService = class {
|
|
|
2410
3942
|
this.logger.info({
|
|
2411
3943
|
accessName,
|
|
2412
3944
|
runtimeParams,
|
|
2413
|
-
Category: "
|
|
3945
|
+
Category: "sp00ky-client::AuthService::signUp"
|
|
2414
3946
|
}, "Attempting signup");
|
|
2415
3947
|
const { access } = await this.remote.getClient().signup({
|
|
2416
3948
|
access: accessName,
|
|
2417
3949
|
variables: runtimeParams
|
|
2418
3950
|
});
|
|
2419
|
-
this.logger.info({ Category: "
|
|
3951
|
+
this.logger.info({ Category: "sp00ky-client::AuthService::signUp" }, "Signup successful, token received");
|
|
2420
3952
|
await this.check(access);
|
|
2421
3953
|
}
|
|
2422
3954
|
async signIn(accessName, params) {
|
|
@@ -2427,7 +3959,7 @@ var AuthService = class {
|
|
|
2427
3959
|
if (missingParams.length > 0) throw new Error(`Missing required signin params for '${accessName}': ${missingParams.join(", ")}`);
|
|
2428
3960
|
this.logger.info({
|
|
2429
3961
|
accessName,
|
|
2430
|
-
Category: "
|
|
3962
|
+
Category: "sp00ky-client::AuthService::signIn"
|
|
2431
3963
|
}, "Attempting signin");
|
|
2432
3964
|
const { access } = await this.remote.getClient().signin({
|
|
2433
3965
|
access: accessName,
|
|
@@ -2444,6 +3976,12 @@ var StreamProcessorService = class {
|
|
|
2444
3976
|
processor;
|
|
2445
3977
|
isInitialized = false;
|
|
2446
3978
|
receivers = [];
|
|
3979
|
+
batching = false;
|
|
3980
|
+
batchBuffer = /* @__PURE__ */ new Map();
|
|
3981
|
+
sessionAuth = {
|
|
3982
|
+
authId: "",
|
|
3983
|
+
access: ""
|
|
3984
|
+
};
|
|
2447
3985
|
constructor(events, db, persistenceClient, logger) {
|
|
2448
3986
|
this.events = events;
|
|
2449
3987
|
this.db = db;
|
|
@@ -2458,25 +3996,91 @@ var StreamProcessorService = class {
|
|
|
2458
3996
|
this.receivers.push(receiver);
|
|
2459
3997
|
}
|
|
2460
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) {
|
|
2461
4017
|
for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
|
|
2462
4018
|
}
|
|
2463
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
|
+
/**
|
|
2464
4068
|
* Initialize the WASM module and processor.
|
|
2465
4069
|
* This must be called before using other methods.
|
|
2466
4070
|
*/
|
|
2467
4071
|
async init() {
|
|
2468
4072
|
if (this.isInitialized) return;
|
|
2469
|
-
this.logger.info({ Category: "
|
|
4073
|
+
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initializing WASM...");
|
|
2470
4074
|
try {
|
|
2471
4075
|
await init();
|
|
2472
|
-
this.processor = new
|
|
4076
|
+
this.processor = new Sp00kyProcessor();
|
|
2473
4077
|
await this.loadState();
|
|
2474
4078
|
this.isInitialized = true;
|
|
2475
|
-
this.logger.info({ Category: "
|
|
4079
|
+
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initialized successfully");
|
|
2476
4080
|
} catch (e) {
|
|
2477
4081
|
this.logger.error({
|
|
2478
4082
|
error: e,
|
|
2479
|
-
Category: "
|
|
4083
|
+
Category: "sp00ky-client::StreamProcessorService::init"
|
|
2480
4084
|
}, "Failed to initialize");
|
|
2481
4085
|
throw e;
|
|
2482
4086
|
}
|
|
@@ -2484,37 +4088,75 @@ var StreamProcessorService = class {
|
|
|
2484
4088
|
async loadState() {
|
|
2485
4089
|
if (!this.processor) return;
|
|
2486
4090
|
try {
|
|
2487
|
-
const result = await this.persistenceClient.get("
|
|
4091
|
+
const result = await this.persistenceClient.get("_00_stream_processor_state");
|
|
2488
4092
|
if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
|
|
2489
4093
|
const state = result[0][0].state;
|
|
2490
4094
|
this.logger.info({
|
|
2491
4095
|
stateLength: state.length,
|
|
2492
|
-
Category: "
|
|
4096
|
+
Category: "sp00ky-client::StreamProcessorService::loadState"
|
|
2493
4097
|
}, "Loading state from DB");
|
|
2494
4098
|
if (typeof this.processor.load_state === "function") this.processor.load_state(state);
|
|
2495
|
-
else this.logger.warn({ Category: "
|
|
2496
|
-
} 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");
|
|
2497
4101
|
} catch (e) {
|
|
2498
4102
|
this.logger.error({
|
|
2499
4103
|
error: e,
|
|
2500
|
-
Category: "
|
|
4104
|
+
Category: "sp00ky-client::StreamProcessorService::loadState"
|
|
2501
4105
|
}, "Failed to load state");
|
|
2502
4106
|
}
|
|
2503
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
|
+
}
|
|
2504
4146
|
async saveState() {
|
|
2505
4147
|
if (!this.processor) return;
|
|
2506
4148
|
try {
|
|
2507
4149
|
if (typeof this.processor.save_state === "function") {
|
|
2508
4150
|
const state = this.processor.save_state();
|
|
2509
4151
|
if (state) {
|
|
2510
|
-
await this.persistenceClient.set("
|
|
2511
|
-
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");
|
|
2512
4154
|
}
|
|
2513
4155
|
}
|
|
2514
4156
|
} catch (e) {
|
|
2515
4157
|
this.logger.error({
|
|
2516
4158
|
error: e,
|
|
2517
|
-
Category: "
|
|
4159
|
+
Category: "sp00ky-client::StreamProcessorService::saveState"
|
|
2518
4160
|
}, "Failed to save state");
|
|
2519
4161
|
}
|
|
2520
4162
|
}
|
|
@@ -2528,36 +4170,43 @@ var StreamProcessorService = class {
|
|
|
2528
4170
|
table,
|
|
2529
4171
|
op,
|
|
2530
4172
|
id,
|
|
2531
|
-
Category: "
|
|
4173
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2532
4174
|
}, "Ingesting into ssp");
|
|
2533
4175
|
if (!this.processor) {
|
|
2534
|
-
this.logger.warn({ Category: "
|
|
4176
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingest" }, "Not initialized, skipping ingest");
|
|
2535
4177
|
return [];
|
|
2536
4178
|
}
|
|
2537
4179
|
try {
|
|
2538
4180
|
const normalizedRecord = this.normalizeValue(record);
|
|
4181
|
+
const t0 = performance.now();
|
|
2539
4182
|
const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
|
|
4183
|
+
const materializationTimeMs = performance.now() - t0;
|
|
2540
4184
|
this.logger.debug({
|
|
2541
4185
|
table,
|
|
2542
4186
|
op,
|
|
2543
4187
|
id,
|
|
2544
4188
|
rawUpdates: rawUpdates.length,
|
|
2545
|
-
|
|
4189
|
+
materializationTimeMs,
|
|
4190
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2546
4191
|
}, "Ingesting into ssp done");
|
|
2547
4192
|
if (rawUpdates && Array.isArray(rawUpdates) && rawUpdates.length > 0) {
|
|
2548
4193
|
const updates = rawUpdates.map((u) => ({
|
|
2549
4194
|
queryHash: u.query_id,
|
|
2550
4195
|
localArray: u.result_data,
|
|
2551
|
-
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
|
|
2552
4201
|
}));
|
|
2553
4202
|
this.notifyUpdates(updates);
|
|
2554
4203
|
}
|
|
2555
|
-
this.saveState();
|
|
4204
|
+
if (!this.batching) this.saveState();
|
|
2556
4205
|
return rawUpdates;
|
|
2557
4206
|
} catch (e) {
|
|
2558
4207
|
this.logger.error({
|
|
2559
4208
|
error: e,
|
|
2560
|
-
Category: "
|
|
4209
|
+
Category: "sp00ky-client::StreamProcessorService::ingest"
|
|
2561
4210
|
}, "Ingesting into ssp failed");
|
|
2562
4211
|
}
|
|
2563
4212
|
return [];
|
|
@@ -2568,46 +4217,55 @@ var StreamProcessorService = class {
|
|
|
2568
4217
|
*/
|
|
2569
4218
|
registerQueryPlan(queryPlan) {
|
|
2570
4219
|
if (!this.processor) {
|
|
2571
|
-
this.logger.warn({ Category: "
|
|
4220
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan" }, "Not initialized, skipping registration");
|
|
2572
4221
|
return;
|
|
2573
4222
|
}
|
|
2574
4223
|
this.logger.debug({
|
|
2575
4224
|
queryHash: queryPlan.queryHash,
|
|
2576
4225
|
surql: queryPlan.surql,
|
|
2577
4226
|
params: queryPlan.params,
|
|
2578
|
-
Category: "
|
|
4227
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2579
4228
|
}, "Registering query plan");
|
|
2580
4229
|
try {
|
|
2581
|
-
const
|
|
4230
|
+
const paramsWithAuth = {
|
|
4231
|
+
...this.normalizeValue(queryPlan.params),
|
|
4232
|
+
auth: { id: this.sessionAuth.authId },
|
|
4233
|
+
access: this.sessionAuth.access
|
|
4234
|
+
};
|
|
2582
4235
|
const initialUpdate = this.processor.register_view({
|
|
2583
4236
|
id: queryPlan.queryHash,
|
|
2584
4237
|
surql: queryPlan.surql,
|
|
2585
|
-
params:
|
|
4238
|
+
params: paramsWithAuth,
|
|
2586
4239
|
clientId: "local",
|
|
2587
4240
|
ttl: queryPlan.ttl.toString(),
|
|
2588
4241
|
lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2589
4242
|
});
|
|
2590
4243
|
this.logger.debug({
|
|
2591
4244
|
initialUpdate,
|
|
2592
|
-
Category: "
|
|
4245
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2593
4246
|
}, "register_view result");
|
|
2594
4247
|
if (!initialUpdate) throw new Error("Failed to register query plan");
|
|
2595
4248
|
const update = {
|
|
2596
4249
|
queryHash: initialUpdate.query_id,
|
|
2597
|
-
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
|
+
}
|
|
2598
4256
|
};
|
|
2599
4257
|
this.saveState();
|
|
2600
4258
|
this.logger.debug({
|
|
2601
4259
|
queryHash: queryPlan.queryHash,
|
|
2602
4260
|
surql: queryPlan.surql,
|
|
2603
4261
|
params: queryPlan.params,
|
|
2604
|
-
Category: "
|
|
4262
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2605
4263
|
}, "Registered query plan");
|
|
2606
4264
|
return update;
|
|
2607
4265
|
} catch (e) {
|
|
2608
4266
|
this.logger.error({
|
|
2609
4267
|
error: e,
|
|
2610
|
-
Category: "
|
|
4268
|
+
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
2611
4269
|
}, "Error registering query plan");
|
|
2612
4270
|
throw e;
|
|
2613
4271
|
}
|
|
@@ -2623,13 +4281,14 @@ var StreamProcessorService = class {
|
|
|
2623
4281
|
} catch (e) {
|
|
2624
4282
|
this.logger.error({
|
|
2625
4283
|
error: e,
|
|
2626
|
-
Category: "
|
|
4284
|
+
Category: "sp00ky-client::StreamProcessorService::unregisterQueryPlan"
|
|
2627
4285
|
}, "Error unregistering query plan");
|
|
2628
4286
|
}
|
|
2629
4287
|
}
|
|
2630
4288
|
normalizeValue(value) {
|
|
2631
4289
|
if (value === null || value === void 0) return value;
|
|
2632
4290
|
if (typeof value === "object") {
|
|
4291
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return null;
|
|
2633
4292
|
const hasTable = "table" in value && typeof value.table?.toString === "function";
|
|
2634
4293
|
const hasId = "id" in value;
|
|
2635
4294
|
const hasToString = typeof value.toString === "function";
|
|
@@ -2638,7 +4297,7 @@ var StreamProcessorService = class {
|
|
|
2638
4297
|
const result = value.toString();
|
|
2639
4298
|
this.logger.trace({
|
|
2640
4299
|
result,
|
|
2641
|
-
Category: "
|
|
4300
|
+
Category: "sp00ky-client::StreamProcessorService::normalizeValue"
|
|
2642
4301
|
}, "RecordId detected");
|
|
2643
4302
|
return result;
|
|
2644
4303
|
}
|
|
@@ -2654,6 +4313,51 @@ var StreamProcessorService = class {
|
|
|
2654
4313
|
}
|
|
2655
4314
|
};
|
|
2656
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
|
+
|
|
2657
4361
|
//#endregion
|
|
2658
4362
|
//#region src/modules/cache/index.ts
|
|
2659
4363
|
/**
|
|
@@ -2681,7 +4385,7 @@ var CacheModule = class {
|
|
|
2681
4385
|
this.logger.debug({
|
|
2682
4386
|
queryHash: update.queryHash,
|
|
2683
4387
|
arrayLength: update.localArray?.length,
|
|
2684
|
-
Category: "
|
|
4388
|
+
Category: "sp00ky-client::CacheModule::onStreamUpdate"
|
|
2685
4389
|
}, "Stream update received");
|
|
2686
4390
|
this.streamUpdateCallback(update);
|
|
2687
4391
|
}
|
|
@@ -2704,7 +4408,7 @@ var CacheModule = class {
|
|
|
2704
4408
|
if (records.length === 0) return;
|
|
2705
4409
|
this.logger.debug({
|
|
2706
4410
|
count: records.length,
|
|
2707
|
-
Category: "
|
|
4411
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2708
4412
|
}, "Saving record batch");
|
|
2709
4413
|
try {
|
|
2710
4414
|
const populatedRecords = records.map((record) => {
|
|
@@ -2713,13 +4417,13 @@ var CacheModule = class {
|
|
|
2713
4417
|
...record,
|
|
2714
4418
|
record: {
|
|
2715
4419
|
...record.record,
|
|
2716
|
-
|
|
4420
|
+
_00_rv: record.version
|
|
2717
4421
|
}
|
|
2718
4422
|
};
|
|
2719
4423
|
});
|
|
2720
4424
|
if (!skipDbInsert) {
|
|
2721
4425
|
const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
|
|
2722
|
-
return surql.
|
|
4426
|
+
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
2723
4427
|
})));
|
|
2724
4428
|
const params = populatedRecords.reduce((acc, record, i) => {
|
|
2725
4429
|
const { id, ...content } = record.record;
|
|
@@ -2731,20 +4435,26 @@ var CacheModule = class {
|
|
|
2731
4435
|
}, {});
|
|
2732
4436
|
await this.local.execute(query, params);
|
|
2733
4437
|
}
|
|
2734
|
-
|
|
4438
|
+
const bulk = populatedRecords.map((record) => {
|
|
2735
4439
|
const recordId = encodeRecordId(record.record.id);
|
|
2736
4440
|
this.versionLookups[recordId] = record.version;
|
|
2737
|
-
|
|
2738
|
-
|
|
4441
|
+
return {
|
|
4442
|
+
table: record.table,
|
|
4443
|
+
op: record.op,
|
|
4444
|
+
id: recordId,
|
|
4445
|
+
record: record.record
|
|
4446
|
+
};
|
|
4447
|
+
});
|
|
4448
|
+
this.streamProcessor.ingestMany(bulk);
|
|
2739
4449
|
this.logger.debug({
|
|
2740
4450
|
count: records.length,
|
|
2741
|
-
Category: "
|
|
4451
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2742
4452
|
}, "Batch saved successfully");
|
|
2743
4453
|
} catch (err) {
|
|
2744
4454
|
this.logger.error({
|
|
2745
4455
|
err,
|
|
2746
4456
|
count: records.length,
|
|
2747
|
-
Category: "
|
|
4457
|
+
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
2748
4458
|
}, "Failed to save batch");
|
|
2749
4459
|
throw err;
|
|
2750
4460
|
}
|
|
@@ -2752,27 +4462,27 @@ var CacheModule = class {
|
|
|
2752
4462
|
/**
|
|
2753
4463
|
* Delete a record from local DB and ingest deletion into DBSP
|
|
2754
4464
|
*/
|
|
2755
|
-
async delete(table, id, skipDbDelete = false) {
|
|
4465
|
+
async delete(table, id, skipDbDelete = false, recordData = {}) {
|
|
2756
4466
|
this.logger.debug({
|
|
2757
4467
|
table,
|
|
2758
4468
|
id,
|
|
2759
|
-
Category: "
|
|
4469
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2760
4470
|
}, "Deleting record");
|
|
2761
4471
|
try {
|
|
2762
4472
|
if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) });
|
|
2763
4473
|
delete this.versionLookups[id];
|
|
2764
|
-
|
|
4474
|
+
this.streamProcessor.ingest(table, "DELETE", id, recordData);
|
|
2765
4475
|
this.logger.debug({
|
|
2766
4476
|
table,
|
|
2767
4477
|
id,
|
|
2768
|
-
Category: "
|
|
4478
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2769
4479
|
}, "Record deleted successfully");
|
|
2770
4480
|
} catch (err) {
|
|
2771
4481
|
this.logger.error({
|
|
2772
4482
|
err,
|
|
2773
4483
|
table,
|
|
2774
4484
|
id,
|
|
2775
|
-
Category: "
|
|
4485
|
+
Category: "sp00ky-client::CacheModule::delete"
|
|
2776
4486
|
}, "Failed to delete record");
|
|
2777
4487
|
throw err;
|
|
2778
4488
|
}
|
|
@@ -2785,7 +4495,7 @@ var CacheModule = class {
|
|
|
2785
4495
|
this.logger.debug({
|
|
2786
4496
|
queryHash: config.queryHash,
|
|
2787
4497
|
surql: config.surql,
|
|
2788
|
-
Category: "
|
|
4498
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2789
4499
|
}, "Registering query");
|
|
2790
4500
|
try {
|
|
2791
4501
|
const update = this.streamProcessor.registerQueryPlan({
|
|
@@ -2802,14 +4512,17 @@ var CacheModule = class {
|
|
|
2802
4512
|
this.logger.debug({
|
|
2803
4513
|
queryHash: config.queryHash,
|
|
2804
4514
|
arrayLength: update.localArray?.length,
|
|
2805
|
-
Category: "
|
|
4515
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2806
4516
|
}, "Query registered successfully");
|
|
2807
|
-
return {
|
|
4517
|
+
return {
|
|
4518
|
+
localArray: update.localArray,
|
|
4519
|
+
registrationTimings: update.registration
|
|
4520
|
+
};
|
|
2808
4521
|
} catch (err) {
|
|
2809
4522
|
this.logger.error({
|
|
2810
4523
|
err,
|
|
2811
4524
|
queryHash: config.queryHash,
|
|
2812
|
-
Category: "
|
|
4525
|
+
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
2813
4526
|
}, "Failed to register query");
|
|
2814
4527
|
throw err;
|
|
2815
4528
|
}
|
|
@@ -2820,24 +4533,639 @@ var CacheModule = class {
|
|
|
2820
4533
|
unregisterQuery(queryHash) {
|
|
2821
4534
|
this.logger.debug({
|
|
2822
4535
|
queryHash,
|
|
2823
|
-
Category: "
|
|
4536
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2824
4537
|
}, "Unregistering query");
|
|
2825
4538
|
try {
|
|
2826
4539
|
this.streamProcessor.unregisterQueryPlan(queryHash);
|
|
2827
4540
|
this.logger.debug({
|
|
2828
4541
|
queryHash,
|
|
2829
|
-
Category: "
|
|
4542
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2830
4543
|
}, "Query unregistered successfully");
|
|
2831
4544
|
} catch (err) {
|
|
2832
4545
|
this.logger.error({
|
|
2833
4546
|
err,
|
|
2834
4547
|
queryHash,
|
|
2835
|
-
Category: "
|
|
4548
|
+
Category: "sp00ky-client::CacheModule::unregisterQuery"
|
|
2836
4549
|
}, "Failed to unregister query");
|
|
2837
4550
|
}
|
|
2838
4551
|
}
|
|
2839
4552
|
};
|
|
2840
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
|
+
|
|
2841
5169
|
//#endregion
|
|
2842
5170
|
//#region src/services/persistence/localstorage.ts
|
|
2843
5171
|
var LocalStoragePersistenceClient = class {
|
|
@@ -2870,7 +5198,7 @@ var SurrealDBPersistenceClient = class {
|
|
|
2870
5198
|
}
|
|
2871
5199
|
async set(key, val) {
|
|
2872
5200
|
try {
|
|
2873
|
-
const id = parseRecordIdString(`
|
|
5201
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2874
5202
|
await this.db.query(surql.seal(surql.upsert("id", "data")), {
|
|
2875
5203
|
id,
|
|
2876
5204
|
data: { val }
|
|
@@ -2878,41 +5206,111 @@ var SurrealDBPersistenceClient = class {
|
|
|
2878
5206
|
} catch (error) {
|
|
2879
5207
|
this.logger.error({
|
|
2880
5208
|
error,
|
|
2881
|
-
Category: "
|
|
5209
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::set"
|
|
2882
5210
|
}, "Failed to set KV");
|
|
2883
5211
|
throw error;
|
|
2884
5212
|
}
|
|
2885
5213
|
}
|
|
2886
5214
|
async get(key) {
|
|
2887
5215
|
try {
|
|
2888
|
-
const id = parseRecordIdString(`
|
|
5216
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2889
5217
|
const [result] = await this.db.query(surql.seal(surql.selectById("id", ["val"])), { id });
|
|
2890
5218
|
if (!result?.val) return null;
|
|
2891
5219
|
return result.val;
|
|
2892
5220
|
} catch (error) {
|
|
2893
5221
|
this.logger.warn({
|
|
2894
5222
|
error,
|
|
2895
|
-
Category: "
|
|
5223
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::get"
|
|
2896
5224
|
}, "Failed to get KV");
|
|
2897
5225
|
return null;
|
|
2898
5226
|
}
|
|
2899
5227
|
}
|
|
2900
5228
|
async remove(key) {
|
|
2901
5229
|
try {
|
|
2902
|
-
const id = parseRecordIdString(`
|
|
5230
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
2903
5231
|
await this.db.query(surql.seal(surql.delete("id")), { id });
|
|
2904
5232
|
} catch (err) {
|
|
2905
5233
|
this.logger.info({
|
|
2906
5234
|
err,
|
|
2907
|
-
Category: "
|
|
5235
|
+
Category: "sp00ky-client::SurrealDBPersistenceClient::remove"
|
|
2908
5236
|
}, "Failed to delete KV");
|
|
2909
5237
|
}
|
|
2910
5238
|
}
|
|
2911
5239
|
};
|
|
2912
5240
|
|
|
2913
5241
|
//#endregion
|
|
2914
|
-
//#region src/
|
|
2915
|
-
var
|
|
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
|
|
5278
|
+
var BucketHandle = class {
|
|
5279
|
+
constructor(bucketName, remote) {
|
|
5280
|
+
this.bucketName = bucketName;
|
|
5281
|
+
this.remote = remote;
|
|
5282
|
+
}
|
|
5283
|
+
async put(path, content) {
|
|
5284
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
5285
|
+
}
|
|
5286
|
+
async get(path) {
|
|
5287
|
+
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
|
|
5288
|
+
return result;
|
|
5289
|
+
}
|
|
5290
|
+
async delete(path) {
|
|
5291
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
|
|
5292
|
+
}
|
|
5293
|
+
async exists(path) {
|
|
5294
|
+
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
|
|
5295
|
+
return result;
|
|
5296
|
+
}
|
|
5297
|
+
async head(path) {
|
|
5298
|
+
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".head();`);
|
|
5299
|
+
return result;
|
|
5300
|
+
}
|
|
5301
|
+
async copy(sourcePath, targetPath) {
|
|
5302
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
|
|
5303
|
+
}
|
|
5304
|
+
async rename(sourcePath, targetPath) {
|
|
5305
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
|
|
5306
|
+
}
|
|
5307
|
+
async list(prefix) {
|
|
5308
|
+
const p = prefix ?? "";
|
|
5309
|
+
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${p}".list();`);
|
|
5310
|
+
return result;
|
|
5311
|
+
}
|
|
5312
|
+
};
|
|
5313
|
+
var Sp00kyClient = class {
|
|
2916
5314
|
local;
|
|
2917
5315
|
remote;
|
|
2918
5316
|
persistenceClient;
|
|
@@ -2921,6 +5319,8 @@ var SpookyClient = class {
|
|
|
2921
5319
|
dataModule;
|
|
2922
5320
|
sync;
|
|
2923
5321
|
devTools;
|
|
5322
|
+
crdtManager;
|
|
5323
|
+
featureFlags;
|
|
2924
5324
|
logger;
|
|
2925
5325
|
auth;
|
|
2926
5326
|
streamProcessor;
|
|
@@ -2933,33 +5333,64 @@ var SpookyClient = class {
|
|
|
2933
5333
|
get pendingMutationCount() {
|
|
2934
5334
|
return this.sync.pendingMutationCount;
|
|
2935
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
|
+
}
|
|
2936
5344
|
subscribeToPendingMutations(cb) {
|
|
2937
5345
|
return this.sync.subscribeToPendingMutations(cb);
|
|
2938
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
|
+
}
|
|
2939
5358
|
constructor(config) {
|
|
2940
5359
|
this.config = config;
|
|
2941
|
-
const logger = createLogger(config.logLevel ?? "info", config.
|
|
2942
|
-
this.logger = logger.child({ service: "
|
|
5360
|
+
const logger = createLogger(config.logLevel ?? "info", config.otelTransmit);
|
|
5361
|
+
this.logger = logger.child({ service: "Sp00kyClient" });
|
|
2943
5362
|
this.logger.info({
|
|
2944
5363
|
config: {
|
|
2945
5364
|
...config,
|
|
2946
5365
|
schema: "[SchemaStructure]"
|
|
2947
5366
|
},
|
|
2948
|
-
Category: "
|
|
2949
|
-
}, "
|
|
5367
|
+
Category: "sp00ky-client::Sp00kyClient::constructor"
|
|
5368
|
+
}, "Sp00kyClient initialized");
|
|
2950
5369
|
this.local = new LocalDatabaseService(this.config.database, logger);
|
|
2951
5370
|
this.remote = new RemoteDatabaseService(this.config.database, logger);
|
|
2952
5371
|
if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
|
|
2953
5372
|
else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
|
|
2954
5373
|
else this.persistenceClient = config.persistenceClient;
|
|
5374
|
+
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
2955
5375
|
this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
|
|
2956
5376
|
this.migrator = new LocalMigrator(this.local, logger);
|
|
2957
5377
|
this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
|
|
2958
5378
|
this.dataModule.onStreamUpdate(update);
|
|
2959
5379
|
}, logger);
|
|
5380
|
+
this.crdtManager = new CrdtManager(this.config.schema, this.local, this.remote, logger, config.crdtDebounceMs ?? 500);
|
|
2960
5381
|
this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
|
|
2961
5382
|
this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
|
|
2962
|
-
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
|
+
});
|
|
2963
5394
|
this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
|
|
2964
5395
|
this.streamProcessor.addReceiver(this.devTools);
|
|
2965
5396
|
this.setupCallbacks();
|
|
@@ -2968,6 +5399,27 @@ var SpookyClient = class {
|
|
|
2968
5399
|
* Setup direct callbacks instead of event subscriptions
|
|
2969
5400
|
*/
|
|
2970
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
|
+
};
|
|
2971
5423
|
this.dataModule.onMutation((mutations) => {
|
|
2972
5424
|
this.devTools.onMutation(mutations);
|
|
2973
5425
|
if (mutations.length > 0) this.sync.enqueueMutation(mutations);
|
|
@@ -2975,6 +5427,22 @@ var SpookyClient = class {
|
|
|
2975
5427
|
this.sync.events.subscribe("SYNC_QUERY_UPDATED", (event) => {
|
|
2976
5428
|
this.devTools.logEvent("SYNC_QUERY_UPDATED", event.payload);
|
|
2977
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
|
+
});
|
|
2978
5446
|
this.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", (event) => {
|
|
2979
5447
|
this.devTools.logEvent("LOCAL_QUERY", event.payload);
|
|
2980
5448
|
});
|
|
@@ -2983,44 +5451,90 @@ var SpookyClient = class {
|
|
|
2983
5451
|
});
|
|
2984
5452
|
}
|
|
2985
5453
|
async init() {
|
|
2986
|
-
this.logger.info({ Category: "
|
|
5454
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
|
|
2987
5455
|
try {
|
|
2988
|
-
const clientId = this.config.clientId ?? await this.loadOrGenerateClientId();
|
|
2989
|
-
this.persistClientId(clientId);
|
|
2990
|
-
this.logger.debug({
|
|
2991
|
-
clientId,
|
|
2992
|
-
Category: "spooky-client::SpookyClient::init"
|
|
2993
|
-
}, "Client ID loaded");
|
|
2994
5456
|
await this.local.connect();
|
|
2995
|
-
this.logger.debug({ Category: "
|
|
5457
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Local database connected");
|
|
2996
5458
|
await this.migrator.provision(this.config.schemaSurql);
|
|
2997
|
-
this.logger.debug({ Category: "
|
|
5459
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
|
|
2998
5460
|
await this.remote.connect();
|
|
2999
|
-
this.logger.debug({ Category: "
|
|
5461
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
|
|
3000
5462
|
await this.streamProcessor.init();
|
|
3001
|
-
this.
|
|
5463
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
5464
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
3002
5465
|
await this.auth.init();
|
|
3003
|
-
this.logger.debug({ Category: "
|
|
3004
|
-
await this.
|
|
3005
|
-
this.
|
|
3006
|
-
|
|
3007
|
-
this.logger.debug({
|
|
3008
|
-
|
|
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");
|
|
3009
5494
|
} catch (e) {
|
|
3010
5495
|
this.logger.error({
|
|
3011
5496
|
error: e,
|
|
3012
|
-
Category: "
|
|
3013
|
-
}, "
|
|
5497
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
5498
|
+
}, "Sp00kyClient initialization failed");
|
|
3014
5499
|
throw e;
|
|
3015
5500
|
}
|
|
3016
5501
|
}
|
|
3017
5502
|
async close() {
|
|
5503
|
+
await this.featureFlags.closeAll();
|
|
5504
|
+
this.crdtManager.closeAll();
|
|
3018
5505
|
await this.local.close();
|
|
3019
5506
|
await this.remote.close();
|
|
3020
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
|
+
}
|
|
3021
5520
|
authenticate(token) {
|
|
3022
5521
|
return this.remote.getClient().authenticate(token);
|
|
3023
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
|
+
}
|
|
3024
5538
|
deauthenticate() {
|
|
3025
5539
|
return this.remote.getClient().invalidate();
|
|
3026
5540
|
}
|
|
@@ -3030,7 +5544,18 @@ var SpookyClient = class {
|
|
|
3030
5544
|
async initQuery(table, q, ttl) {
|
|
3031
5545
|
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
3032
5546
|
if (!tableSchema) throw new Error(`Table ${table} not found`);
|
|
3033
|
-
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
|
+
}
|
|
3034
5559
|
await this.sync.enqueueDownEvent({
|
|
3035
5560
|
type: "register",
|
|
3036
5561
|
payload: { hash }
|
|
@@ -3044,9 +5569,38 @@ var SpookyClient = class {
|
|
|
3044
5569
|
async subscribe(queryHash, callback, options) {
|
|
3045
5570
|
return this.dataModule.subscribe(queryHash, callback, options);
|
|
3046
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
|
+
}
|
|
3047
5598
|
run(backend, path, payload, options) {
|
|
3048
5599
|
return this.dataModule.run(backend, path, payload, options);
|
|
3049
5600
|
}
|
|
5601
|
+
bucket(name) {
|
|
5602
|
+
return new BucketHandle(name, this.remote);
|
|
5603
|
+
}
|
|
3050
5604
|
create(id, data) {
|
|
3051
5605
|
return this.dataModule.create(id, data);
|
|
3052
5606
|
}
|
|
@@ -3059,24 +5613,25 @@ var SpookyClient = class {
|
|
|
3059
5613
|
async useRemote(fn) {
|
|
3060
5614
|
return fn(this.remote.getClient());
|
|
3061
5615
|
}
|
|
3062
|
-
|
|
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() {
|
|
3063
5623
|
try {
|
|
3064
|
-
this.
|
|
5624
|
+
const [sid] = await this.remote.query("RETURN <string>session::id()");
|
|
5625
|
+
return typeof sid === "string" ? sid : "";
|
|
3065
5626
|
} catch (e) {
|
|
3066
5627
|
this.logger.warn({
|
|
3067
5628
|
error: e,
|
|
3068
|
-
Category: "
|
|
3069
|
-
}, "Failed to
|
|
5629
|
+
Category: "sp00ky-client::Sp00kyClient::fetchSessionId"
|
|
5630
|
+
}, "Failed to fetch session::id() — proceeding with empty salt");
|
|
5631
|
+
return "";
|
|
3070
5632
|
}
|
|
3071
5633
|
}
|
|
3072
|
-
async loadOrGenerateClientId() {
|
|
3073
|
-
const clientId = await this.persistenceClient.get("spooky_client_id");
|
|
3074
|
-
if (clientId) return clientId;
|
|
3075
|
-
const newId = generateId();
|
|
3076
|
-
await this.persistClientId(newId);
|
|
3077
|
-
return newId;
|
|
3078
|
-
}
|
|
3079
5634
|
};
|
|
3080
5635
|
|
|
3081
5636
|
//#endregion
|
|
3082
|
-
export { AuthEventTypes, AuthService,
|
|
5637
|
+
export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
|