@spooky-sync/core 0.0.1-canary.69 → 0.0.1-canary.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +102 -2
- package/dist/index.js +1066 -129
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +74 -1
- package/package.json +2 -2
- package/src/build-globals.d.ts +6 -0
- package/src/events/index.ts +3 -0
- package/src/modules/cache/index.ts +13 -6
- package/src/modules/crdt/index.ts +9 -1
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +436 -56
- 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 +54 -1
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/sync/engine.ts +43 -29
- package/src/modules/sync/sync.ts +247 -55
- package/src/modules/sync/utils.test.ts +80 -0
- package/src/modules/sync/utils.ts +72 -0
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +182 -23
- package/src/services/persistence/resilient.ts +8 -1
- package/src/services/stream-processor/index.ts +148 -5
- 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/wasm-types.ts +16 -0
- package/src/sp00ky.ts +86 -6
- package/src/types.ts +85 -0
- package/src/utils/index.ts +13 -3
- package/tsdown.config.ts +54 -0
package/dist/index.js
CHANGED
|
@@ -152,12 +152,21 @@ function generateId() {
|
|
|
152
152
|
return Uuid.v4().toString().replace(/-/g, "");
|
|
153
153
|
}
|
|
154
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
|
+
/**
|
|
155
164
|
* Parse duration string or Duration object to milliseconds
|
|
156
165
|
*/
|
|
157
166
|
function parseDuration(duration) {
|
|
158
167
|
if (duration instanceof Duration) {
|
|
159
|
-
const ms = duration
|
|
160
|
-
if (ms) return
|
|
168
|
+
const ms = durationMillis(duration);
|
|
169
|
+
if (ms) return ms;
|
|
161
170
|
const str = duration.toString();
|
|
162
171
|
if (str !== "[object Object]") return parseDuration(str);
|
|
163
172
|
return 6e5;
|
|
@@ -209,8 +218,160 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
|
|
|
209
218
|
throw lastError;
|
|
210
219
|
}
|
|
211
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
|
+
|
|
212
349
|
//#endregion
|
|
213
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
|
+
}
|
|
214
375
|
/**
|
|
215
376
|
* DataModule - Unified query and mutation management
|
|
216
377
|
*
|
|
@@ -221,9 +382,36 @@ var DataModule = class {
|
|
|
221
382
|
activeQueries = /* @__PURE__ */ new Map();
|
|
222
383
|
pendingQueries = /* @__PURE__ */ new Map();
|
|
223
384
|
subscriptions = /* @__PURE__ */ new Map();
|
|
385
|
+
statusSubscriptions = /* @__PURE__ */ new Map();
|
|
224
386
|
mutationCallbacks = /* @__PURE__ */ new Set();
|
|
225
387
|
debounceTimers = /* @__PURE__ */ new Map();
|
|
226
388
|
logger;
|
|
389
|
+
/**
|
|
390
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
391
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
392
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
393
|
+
* constructed after DataModule.
|
|
394
|
+
*/
|
|
395
|
+
onQueryStatusChange;
|
|
396
|
+
/**
|
|
397
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
398
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
399
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
400
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
401
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
402
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
403
|
+
*/
|
|
404
|
+
onHeartbeat;
|
|
405
|
+
/**
|
|
406
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
407
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
408
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
409
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
410
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
411
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
412
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
413
|
+
*/
|
|
414
|
+
onDeregister;
|
|
227
415
|
sessionId = "";
|
|
228
416
|
currentUserId = null;
|
|
229
417
|
constructor(cache, local, schema, logger, streamDebounceTime = 100) {
|
|
@@ -323,6 +511,36 @@ var DataModule = class {
|
|
|
323
511
|
};
|
|
324
512
|
}
|
|
325
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
|
+
/**
|
|
326
544
|
* Subscribe to mutations (for sync)
|
|
327
545
|
*/
|
|
328
546
|
onMutation(callback) {
|
|
@@ -336,14 +554,39 @@ var DataModule = class {
|
|
|
336
554
|
*/
|
|
337
555
|
async onStreamUpdate(update) {
|
|
338
556
|
const { queryHash, op } = update;
|
|
339
|
-
if (op === "
|
|
340
|
-
|
|
341
|
-
|
|
557
|
+
if (op === "DELETE") {
|
|
558
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
559
|
+
if (existing) {
|
|
560
|
+
clearTimeout(existing);
|
|
342
561
|
this.debounceTimers.delete(queryHash);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
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;
|
|
347
590
|
}
|
|
348
591
|
async processStreamUpdate(update) {
|
|
349
592
|
const { queryHash, localArray, materializationTimeMs } = update;
|
|
@@ -360,10 +603,12 @@ var DataModule = class {
|
|
|
360
603
|
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
|
|
361
604
|
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
362
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);
|
|
363
609
|
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
364
610
|
try {
|
|
365
|
-
const
|
|
366
|
-
const newRecords = records || [];
|
|
611
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
367
612
|
queryState.config.localArray = localArray;
|
|
368
613
|
const prevJson = JSON.stringify(queryState.records);
|
|
369
614
|
const newJson = JSON.stringify(newRecords);
|
|
@@ -399,7 +644,7 @@ var DataModule = class {
|
|
|
399
644
|
if (subscribers) for (const callback of subscribers) callback(queryState.records);
|
|
400
645
|
this.logger.debug({
|
|
401
646
|
queryHash,
|
|
402
|
-
recordCount:
|
|
647
|
+
recordCount: newRecords?.length,
|
|
403
648
|
Category: "sp00ky-client::DataModule::onStreamUpdate"
|
|
404
649
|
}, "Query updated from stream");
|
|
405
650
|
} catch (err) {
|
|
@@ -444,6 +689,45 @@ var DataModule = class {
|
|
|
444
689
|
p99: pick(.99)
|
|
445
690
|
};
|
|
446
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
|
+
}
|
|
447
731
|
/**
|
|
448
732
|
* Get query state (for sync and devtools)
|
|
449
733
|
*/
|
|
@@ -451,6 +735,84 @@ var DataModule = class {
|
|
|
451
735
|
return this.activeQueries.get(hash);
|
|
452
736
|
}
|
|
453
737
|
/**
|
|
738
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
739
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
740
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
741
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
742
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
743
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
744
|
+
*/
|
|
745
|
+
isCold(hash) {
|
|
746
|
+
const qs = this.activeQueries.get(hash);
|
|
747
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
751
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
752
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
753
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
754
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
755
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
756
|
+
*/
|
|
757
|
+
async applyHydration(hash, rows) {
|
|
758
|
+
const queryState = this.activeQueries.get(hash);
|
|
759
|
+
if (!queryState) return;
|
|
760
|
+
queryState.hydrated = true;
|
|
761
|
+
if (rows.length === 0) return;
|
|
762
|
+
const tableName = queryState.config.tableName;
|
|
763
|
+
const batch = rows.map((record) => ({
|
|
764
|
+
table: tableName,
|
|
765
|
+
op: "CREATE",
|
|
766
|
+
record,
|
|
767
|
+
version: record._00_rv || 1
|
|
768
|
+
}));
|
|
769
|
+
await this.cache.saveBatch(batch);
|
|
770
|
+
queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
|
|
771
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
772
|
+
const subscribers = this.subscriptions.get(hash);
|
|
773
|
+
if (subscribers) for (const cb of subscribers) cb(queryState.records);
|
|
774
|
+
}
|
|
775
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
776
|
+
hasSubscribers(hash) {
|
|
777
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
781
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
782
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
783
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
784
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
785
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
786
|
+
*
|
|
787
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
788
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
789
|
+
*/
|
|
790
|
+
deregisterQuery(hash) {
|
|
791
|
+
if (this.hasSubscribers(hash)) return;
|
|
792
|
+
if (!this.activeQueries.has(hash)) return;
|
|
793
|
+
this.onDeregister?.(hash);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
797
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
798
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
799
|
+
*/
|
|
800
|
+
finalizeDeregister(hash) {
|
|
801
|
+
const qs = this.activeQueries.get(hash);
|
|
802
|
+
if (qs?.ttlTimer) {
|
|
803
|
+
clearTimeout(qs.ttlTimer);
|
|
804
|
+
qs.ttlTimer = null;
|
|
805
|
+
}
|
|
806
|
+
const debounce = this.debounceTimers.get(hash);
|
|
807
|
+
if (debounce) {
|
|
808
|
+
clearTimeout(debounce);
|
|
809
|
+
this.debounceTimers.delete(hash);
|
|
810
|
+
}
|
|
811
|
+
this.cache.unregisterQuery(hash);
|
|
812
|
+
this.activeQueries.delete(hash);
|
|
813
|
+
this.subscriptions.delete(hash);
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
454
816
|
* Get query state by id (for sync and devtools)
|
|
455
817
|
*/
|
|
456
818
|
getQueryById(id) {
|
|
@@ -502,8 +864,7 @@ var DataModule = class {
|
|
|
502
864
|
async notifyQuerySynced(queryHash) {
|
|
503
865
|
const queryState = this.activeQueries.get(queryHash);
|
|
504
866
|
if (!queryState) return;
|
|
505
|
-
const
|
|
506
|
-
const newRecords = records || [];
|
|
867
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
507
868
|
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
508
869
|
queryState.records = newRecords;
|
|
509
870
|
if (changed || queryState.updateCount === 0) {
|
|
@@ -645,8 +1006,24 @@ var DataModule = class {
|
|
|
645
1006
|
id: rid,
|
|
646
1007
|
mid: mutationId
|
|
647
1008
|
}));
|
|
648
|
-
|
|
649
|
-
|
|
1009
|
+
try {
|
|
1010
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
this.logger.error({
|
|
1013
|
+
err,
|
|
1014
|
+
id,
|
|
1015
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
1016
|
+
}, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
|
|
1017
|
+
}
|
|
1018
|
+
for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
|
|
1019
|
+
await this.notifyQuerySynced(queryHash);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
this.logger.error({
|
|
1022
|
+
err,
|
|
1023
|
+
queryHash,
|
|
1024
|
+
Category: "sp00ky-client::DataModule::delete"
|
|
1025
|
+
}, "notifyQuerySynced failed after delete");
|
|
1026
|
+
}
|
|
650
1027
|
const mutationEvent = {
|
|
651
1028
|
type: "delete",
|
|
652
1029
|
mutation_id: mutationId,
|
|
@@ -740,7 +1117,7 @@ var DataModule = class {
|
|
|
740
1117
|
tableName
|
|
741
1118
|
});
|
|
742
1119
|
const t0 = performance.now();
|
|
743
|
-
const { localArray } = this.cache.registerQuery({
|
|
1120
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
744
1121
|
queryHash: hash,
|
|
745
1122
|
surql: surqlString,
|
|
746
1123
|
params,
|
|
@@ -748,6 +1125,12 @@ var DataModule = class {
|
|
|
748
1125
|
lastActiveAt: /* @__PURE__ */ new Date()
|
|
749
1126
|
});
|
|
750
1127
|
const registrationTime = performance.now() - t0;
|
|
1128
|
+
queryState.registrationTimings = {
|
|
1129
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
1130
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
1131
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
1132
|
+
wallMs: registrationTime
|
|
1133
|
+
};
|
|
751
1134
|
await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
|
|
752
1135
|
"localArray",
|
|
753
1136
|
"registrationTime",
|
|
@@ -758,8 +1141,23 @@ var DataModule = class {
|
|
|
758
1141
|
registrationTime,
|
|
759
1142
|
rowCount: localArray.length
|
|
760
1143
|
}));
|
|
1144
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
1145
|
+
if (windowMat && localArray.length > 0) try {
|
|
1146
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
1147
|
+
const [seeded] = await this.local.query(windowMat.query, {
|
|
1148
|
+
...params,
|
|
1149
|
+
__win: winIds
|
|
1150
|
+
});
|
|
1151
|
+
queryState.records = seeded || [];
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
this.logger.warn({
|
|
1154
|
+
err,
|
|
1155
|
+
hash,
|
|
1156
|
+
Category: "sp00ky-client::DataModule::createAndRegisterQuery"
|
|
1157
|
+
}, "Failed to seed windowed initial records from localArray");
|
|
1158
|
+
}
|
|
761
1159
|
this.activeQueries.set(hash, queryState);
|
|
762
|
-
this.startTTLHeartbeat(queryState);
|
|
1160
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
763
1161
|
this.logger.debug({
|
|
764
1162
|
hash,
|
|
765
1163
|
tableName,
|
|
@@ -797,7 +1195,7 @@ var DataModule = class {
|
|
|
797
1195
|
params: parseParams(tableSchema.columns, configRecord.params)
|
|
798
1196
|
};
|
|
799
1197
|
let records = [];
|
|
800
|
-
try {
|
|
1198
|
+
if (buildWindowMaterialization(surqlString) === null) try {
|
|
801
1199
|
const [result] = await this.local.query(surqlString, params);
|
|
802
1200
|
records = result || [];
|
|
803
1201
|
} catch (err) {
|
|
@@ -816,7 +1214,16 @@ var DataModule = class {
|
|
|
816
1214
|
updateCount: persistedUpdateCount,
|
|
817
1215
|
materializationSamples: [],
|
|
818
1216
|
lastIngestLatencyMs: null,
|
|
819
|
-
errorCount: persistedErrorCount
|
|
1217
|
+
errorCount: persistedErrorCount,
|
|
1218
|
+
status: "idle",
|
|
1219
|
+
phaseSamples: {},
|
|
1220
|
+
phaseLast: {},
|
|
1221
|
+
registrationTimings: {
|
|
1222
|
+
parseMs: null,
|
|
1223
|
+
planMs: null,
|
|
1224
|
+
snapshotMs: null,
|
|
1225
|
+
wallMs: null
|
|
1226
|
+
}
|
|
820
1227
|
};
|
|
821
1228
|
}
|
|
822
1229
|
async calculateHash(data) {
|
|
@@ -828,23 +1235,27 @@ var DataModule = class {
|
|
|
828
1235
|
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
|
829
1236
|
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
830
1237
|
}
|
|
831
|
-
startTTLHeartbeat(queryState) {
|
|
1238
|
+
startTTLHeartbeat(queryState, hash) {
|
|
832
1239
|
if (queryState.ttlTimer) return;
|
|
833
1240
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
|
|
834
1241
|
queryState.ttlTimer = setTimeout(() => {
|
|
1242
|
+
queryState.ttlTimer = null;
|
|
1243
|
+
if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
|
|
1244
|
+
this.logger.debug({
|
|
1245
|
+
hash,
|
|
1246
|
+
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
1247
|
+
}, "TTL heartbeat: no subscribers, stopping");
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
this.onHeartbeat?.(hash);
|
|
835
1251
|
this.logger.debug({
|
|
1252
|
+
hash,
|
|
836
1253
|
id: encodeRecordId(queryState.config.id),
|
|
837
1254
|
Category: "sp00ky-client::DataModule::startTTLHeartbeat"
|
|
838
|
-
}, "TTL heartbeat");
|
|
839
|
-
this.startTTLHeartbeat(queryState);
|
|
1255
|
+
}, "TTL heartbeat sent");
|
|
1256
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
840
1257
|
}, heartbeatTime);
|
|
841
1258
|
}
|
|
842
|
-
stopTTLHeartbeat(queryState) {
|
|
843
|
-
if (queryState.ttlTimer) {
|
|
844
|
-
clearTimeout(queryState.ttlTimer);
|
|
845
|
-
queryState.ttlTimer = null;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
1259
|
async replaceRecordInQueries(record) {
|
|
849
1260
|
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
850
1261
|
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
@@ -1137,38 +1548,153 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
1137
1548
|
}
|
|
1138
1549
|
async connect() {
|
|
1139
1550
|
const { namespace, database } = this.getConfig();
|
|
1551
|
+
const store = this.getConfig().store ?? "memory";
|
|
1552
|
+
const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
|
|
1140
1553
|
this.logger.info({
|
|
1141
1554
|
namespace,
|
|
1142
1555
|
database,
|
|
1556
|
+
storeUrl,
|
|
1143
1557
|
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1144
1558
|
}, "Connecting to local database");
|
|
1559
|
+
this.registerUnloadClose();
|
|
1145
1560
|
try {
|
|
1146
|
-
|
|
1147
|
-
this.logger.debug({
|
|
1148
|
-
storeUrl,
|
|
1149
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1150
|
-
}, "[LocalDatabaseService] Calling client.connect");
|
|
1151
|
-
await this.client.connect(storeUrl, {});
|
|
1152
|
-
this.logger.debug({
|
|
1153
|
-
namespace,
|
|
1154
|
-
database,
|
|
1155
|
-
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1156
|
-
}, "[LocalDatabaseService] client.connect returned. Calling client.use");
|
|
1157
|
-
await this.client.use({
|
|
1158
|
-
namespace,
|
|
1159
|
-
database
|
|
1160
|
-
});
|
|
1161
|
-
this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
1561
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1162
1562
|
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
|
|
1563
|
+
return;
|
|
1163
1564
|
} catch (err) {
|
|
1164
|
-
|
|
1565
|
+
if (store === "memory" || !isLocalStoreOpenError(err)) {
|
|
1566
|
+
this.logger.error({
|
|
1567
|
+
err,
|
|
1568
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1569
|
+
}, "Failed to connect to local database");
|
|
1570
|
+
throw err;
|
|
1571
|
+
}
|
|
1572
|
+
this.logger.warn({
|
|
1165
1573
|
err,
|
|
1166
1574
|
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1167
|
-
}, "
|
|
1168
|
-
|
|
1575
|
+
}, "Local IndexedDB store failed to open; retrying before clearing");
|
|
1576
|
+
}
|
|
1577
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1578
|
+
try {
|
|
1579
|
+
await this.client.close();
|
|
1580
|
+
} catch {}
|
|
1581
|
+
await delay(150 * attempt);
|
|
1582
|
+
try {
|
|
1583
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1584
|
+
this.logger.info({
|
|
1585
|
+
attempt,
|
|
1586
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1587
|
+
}, "Connected to local database on retry (cache preserved)");
|
|
1588
|
+
return;
|
|
1589
|
+
} catch (retryErr) {
|
|
1590
|
+
this.logger.warn({
|
|
1591
|
+
err: retryErr,
|
|
1592
|
+
attempt,
|
|
1593
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1594
|
+
}, "Local store retry failed");
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
await this.client.close();
|
|
1599
|
+
} catch {}
|
|
1600
|
+
await dropLocalIndexedDbStores(this.logger);
|
|
1601
|
+
try {
|
|
1602
|
+
await this.openStore(storeUrl, namespace, database);
|
|
1603
|
+
this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
|
|
1604
|
+
} catch (retryErr) {
|
|
1605
|
+
this.logger.error({
|
|
1606
|
+
err: retryErr,
|
|
1607
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1608
|
+
}, "Local store still failing after clear; falling back to in-memory");
|
|
1609
|
+
try {
|
|
1610
|
+
await this.client.close();
|
|
1611
|
+
} catch {}
|
|
1612
|
+
await this.openStore("mem://", namespace, database);
|
|
1613
|
+
this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
|
|
1169
1614
|
}
|
|
1170
1615
|
}
|
|
1616
|
+
unloadCloseRegistered = false;
|
|
1617
|
+
/**
|
|
1618
|
+
* Close the local DB on page unload so the SurrealDB-WASM worker releases its
|
|
1619
|
+
* IndexedDB connection cleanly. Without this, the previous page's connection
|
|
1620
|
+
* lingers; the next load's `client.connect` opens the store but the first
|
|
1621
|
+
* write transaction in `client.use` hits an "IndexedDB error" — which then
|
|
1622
|
+
* (mis)triggered the corrupt-store recovery and WIPED the cache on every
|
|
1623
|
+
* reload, making warm loads as slow as cold ones. `pagehide` is the reliable
|
|
1624
|
+
* unload signal (fires on bfcache + normal navigation); `close()` is async but
|
|
1625
|
+
* the WASM worker initiates the IndexedDB connection teardown synchronously.
|
|
1626
|
+
*/
|
|
1627
|
+
registerUnloadClose() {
|
|
1628
|
+
if (this.unloadCloseRegistered || typeof window === "undefined") return;
|
|
1629
|
+
this.unloadCloseRegistered = true;
|
|
1630
|
+
const close = () => {
|
|
1631
|
+
try {
|
|
1632
|
+
this.client.close();
|
|
1633
|
+
} catch {}
|
|
1634
|
+
};
|
|
1635
|
+
window.addEventListener("pagehide", close);
|
|
1636
|
+
window.addEventListener("beforeunload", close);
|
|
1637
|
+
}
|
|
1638
|
+
async openStore(storeUrl, namespace, database) {
|
|
1639
|
+
this.logger.debug({
|
|
1640
|
+
storeUrl,
|
|
1641
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1642
|
+
}, "[LocalDatabaseService] Calling client.connect");
|
|
1643
|
+
await this.client.connect(storeUrl, {});
|
|
1644
|
+
this.logger.debug({
|
|
1645
|
+
namespace,
|
|
1646
|
+
database,
|
|
1647
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1648
|
+
}, "[LocalDatabaseService] client.connect returned. Calling client.use");
|
|
1649
|
+
await this.client.use({
|
|
1650
|
+
namespace,
|
|
1651
|
+
database
|
|
1652
|
+
});
|
|
1653
|
+
this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
1654
|
+
}
|
|
1171
1655
|
};
|
|
1656
|
+
function delay(ms) {
|
|
1657
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1658
|
+
}
|
|
1659
|
+
/** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
|
|
1660
|
+
* store can't be opened (corrupt / version-incompatible / blocked). Exported
|
|
1661
|
+
* for unit testing the error-message match. */
|
|
1662
|
+
function isLocalStoreOpenError(err) {
|
|
1663
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
1664
|
+
return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
|
|
1665
|
+
}
|
|
1666
|
+
/** Best-effort delete of this client's IndexedDB store(s). The persistent local
|
|
1667
|
+
* DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
|
|
1668
|
+
* IndexedDB databases whose names include `sp00ky`. Resolves even on
|
|
1669
|
+
* error/blocked so startup can proceed. No-op outside a browser. */
|
|
1670
|
+
async function dropLocalIndexedDbStores(logger) {
|
|
1671
|
+
if (typeof indexedDB === "undefined") return;
|
|
1672
|
+
const remove = (name) => new Promise((resolve) => {
|
|
1673
|
+
try {
|
|
1674
|
+
const req = indexedDB.deleteDatabase(name);
|
|
1675
|
+
req.onsuccess = () => resolve();
|
|
1676
|
+
req.onerror = () => resolve();
|
|
1677
|
+
req.onblocked = () => resolve();
|
|
1678
|
+
} catch {
|
|
1679
|
+
resolve();
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
try {
|
|
1683
|
+
let names = [];
|
|
1684
|
+
if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && n.toLowerCase().includes("sp00ky"));
|
|
1685
|
+
if (names.length === 0) names = ["sp00ky"];
|
|
1686
|
+
await Promise.all(names.map(remove));
|
|
1687
|
+
logger.info({
|
|
1688
|
+
names,
|
|
1689
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1690
|
+
}, "Cleared local IndexedDB store(s)");
|
|
1691
|
+
} catch (e) {
|
|
1692
|
+
logger.warn({
|
|
1693
|
+
err: e,
|
|
1694
|
+
Category: "sp00ky-client::LocalDatabaseService::connect"
|
|
1695
|
+
}, "Failed to enumerate/clear IndexedDB; proceeding anyway");
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1172
1698
|
|
|
1173
1699
|
//#endregion
|
|
1174
1700
|
//#region src/services/database/remote.ts
|
|
@@ -1727,36 +2253,54 @@ function resolveListRefPollInterval(opt) {
|
|
|
1727
2253
|
return opt;
|
|
1728
2254
|
}
|
|
1729
2255
|
/**
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
2256
|
+
* Ceiling for the adaptive list_ref poll backoff. An idle page (no LIVE
|
|
2257
|
+
* events, no poll-detected list_ref changes) coasts up to this cadence;
|
|
2258
|
+
* the existing healthy-LIVE safety net runs at the same 5s, so this keeps
|
|
2259
|
+
* the worst-case catch-up latency for a missed cross-session change at the
|
|
2260
|
+
* cadence the codebase already treats as acceptable.
|
|
1734
2261
|
*/
|
|
1735
|
-
const
|
|
2262
|
+
const LIST_REF_POLL_MAX_INTERVAL_MS = 5e3;
|
|
1736
2263
|
/**
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
1739
|
-
*
|
|
1740
|
-
*
|
|
2264
|
+
* Adaptive poll delay: stay at the responsive `baseIntervalMs` while
|
|
2265
|
+
* changes are arriving, and exponentially back off toward `maxIntervalMs`
|
|
2266
|
+
* while the `_00_list_ref` is quiet.
|
|
2267
|
+
*
|
|
2268
|
+
* `idleStreak` is the count of consecutive poll cycles that observed *no*
|
|
2269
|
+
* change. `Sp00kySync` resets it to 0 whenever a poll detects a real
|
|
2270
|
+
* remoteArray change OR a LIVE event lands, so any activity snaps the poll
|
|
2271
|
+
* straight back to `baseIntervalMs`. A streak of 0 (something just
|
|
2272
|
+
* happened) → base; otherwise `base * 2^streak` capped at `maxIntervalMs`.
|
|
2273
|
+
*
|
|
2274
|
+
* This replaces {@link nextPollDelayMs}: the old helper slowed the poll
|
|
2275
|
+
* only while LIVE was *delivering*, but the cross-session LIVE-permission
|
|
2276
|
+
* gap means LIVE frequently never fires here, so it left a fully idle page
|
|
2277
|
+
* polling every `base` ms forever (the "continuous queries while idle"
|
|
2278
|
+
* symptom). Backing off on observed idleness instead covers the
|
|
2279
|
+
* LIVE-healthy case for free (LIVE applies the change → the next poll sees
|
|
2280
|
+
* nothing new → the streak grows → it backs off).
|
|
1741
2281
|
*/
|
|
1742
|
-
|
|
2282
|
+
function listRefPollDelayMs(args) {
|
|
2283
|
+
const { idleStreak, baseIntervalMs, maxIntervalMs = LIST_REF_POLL_MAX_INTERVAL_MS } = args;
|
|
2284
|
+
const cap = Math.max(baseIntervalMs, maxIntervalMs);
|
|
2285
|
+
if (idleStreak <= 0) return baseIntervalMs;
|
|
2286
|
+
const exponent = Math.min(idleStreak, 30);
|
|
2287
|
+
return Math.min(baseIntervalMs * 2 ** exponent, cap);
|
|
2288
|
+
}
|
|
1743
2289
|
/**
|
|
1744
|
-
*
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
* The healthy interval is clamped to at least `baseIntervalMs` so
|
|
1751
|
-
* configuring an aggressive base (e.g. 100ms) never gets implicitly
|
|
1752
|
-
* widened by this helper.
|
|
2290
|
+
* Order-insensitive equality for two `RecordVersionArray`s (each a list of
|
|
2291
|
+
* `[recordIdString, version]`). The `_00_list_ref` SELECT has no `ORDER
|
|
2292
|
+
* BY`, so row order can differ between polls without anything having
|
|
2293
|
+
* actually changed — comparing as an id→version map avoids false
|
|
2294
|
+
* "changed" verdicts that would defeat the idle backoff. Record ids are
|
|
2295
|
+
* unique within a query's list_ref, so a map is a faithful representation.
|
|
1753
2296
|
*/
|
|
1754
|
-
function
|
|
1755
|
-
|
|
1756
|
-
if (
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
2297
|
+
function recordVersionArraysEqual(a, b) {
|
|
2298
|
+
if (a === b) return true;
|
|
2299
|
+
if (a.length !== b.length) return false;
|
|
2300
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2301
|
+
for (const [id, version] of a) byId.set(id, version);
|
|
2302
|
+
for (const [id, version] of b) if (byId.get(id) !== version) return false;
|
|
2303
|
+
return true;
|
|
1760
2304
|
}
|
|
1761
2305
|
|
|
1762
2306
|
//#endregion
|
|
@@ -1789,13 +2333,19 @@ var SyncEngine = class {
|
|
|
1789
2333
|
removed,
|
|
1790
2334
|
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
1791
2335
|
}, "SyncEngine.syncRecords diff");
|
|
1792
|
-
|
|
2336
|
+
let stillRemoteIds = [];
|
|
2337
|
+
if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
|
|
1793
2338
|
const toFetch = [...added, ...updated];
|
|
1794
2339
|
const idsToFetch = toFetch.map((x) => x.id);
|
|
1795
|
-
if (idsToFetch.length === 0) return
|
|
2340
|
+
if (idsToFetch.length === 0) return {
|
|
2341
|
+
remoteFetchMs: 0,
|
|
2342
|
+
stillRemoteIds
|
|
2343
|
+
};
|
|
1796
2344
|
const versionMap = /* @__PURE__ */ new Map();
|
|
1797
2345
|
for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
|
|
2346
|
+
const remoteFetchStart = performance.now();
|
|
1798
2347
|
const [remoteResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
|
|
2348
|
+
const remoteFetchMs = performance.now() - remoteFetchStart;
|
|
1799
2349
|
const cacheBatch = [];
|
|
1800
2350
|
for (const record of remoteResults) {
|
|
1801
2351
|
if (!record?.id) {
|
|
@@ -1831,6 +2381,10 @@ var SyncEngine = class {
|
|
|
1831
2381
|
}
|
|
1832
2382
|
if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
|
|
1833
2383
|
this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
|
|
2384
|
+
return {
|
|
2385
|
+
remoteFetchMs,
|
|
2386
|
+
stillRemoteIds
|
|
2387
|
+
};
|
|
1834
2388
|
}
|
|
1835
2389
|
/**
|
|
1836
2390
|
* Handle records that exist locally but not in remote array.
|
|
@@ -1851,30 +2405,19 @@ var SyncEngine = class {
|
|
|
1851
2405
|
removed: removed.map((r) => r.toString()),
|
|
1852
2406
|
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1853
2407
|
}, "Checking removed records");
|
|
1854
|
-
const byTable = /* @__PURE__ */ new Map();
|
|
1855
|
-
for (const r of removed) {
|
|
1856
|
-
const list = byTable.get(r.table.name) ?? [];
|
|
1857
|
-
list.push(r);
|
|
1858
|
-
byTable.set(r.table.name, list);
|
|
1859
|
-
}
|
|
1860
2408
|
let existingRemoteIds;
|
|
1861
2409
|
try {
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
const [existing] = await this.remote.query("SELECT id FROM type::table($table) WHERE id IN $ids", {
|
|
1865
|
-
table,
|
|
1866
|
-
ids
|
|
1867
|
-
});
|
|
1868
|
-
for (const row of existing) existingRemoteIds.add(encodeRecordId(row.id));
|
|
1869
|
-
}
|
|
2410
|
+
const [existing] = await this.remote.query("SELECT id FROM $ids", { ids: removed });
|
|
2411
|
+
existingRemoteIds = new Set((existing ?? []).map((row) => encodeRecordId(row.id)));
|
|
1870
2412
|
} catch (err) {
|
|
1871
2413
|
this.logger.warn({
|
|
1872
2414
|
err,
|
|
1873
2415
|
removed: removed.map((r) => r.toString()),
|
|
1874
2416
|
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1875
2417
|
}, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
|
|
1876
|
-
return;
|
|
2418
|
+
return [];
|
|
1877
2419
|
}
|
|
2420
|
+
const stillRemoteIds = [];
|
|
1878
2421
|
for (const recordId of removed) {
|
|
1879
2422
|
const recordIdStr = encodeRecordId(recordId);
|
|
1880
2423
|
if (!existingRemoteIds.has(recordIdStr)) {
|
|
@@ -1883,8 +2426,9 @@ var SyncEngine = class {
|
|
|
1883
2426
|
Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
|
|
1884
2427
|
}, "Deleting confirmed removed record");
|
|
1885
2428
|
await this.cache.delete(recordId.table.name, recordIdStr);
|
|
1886
|
-
}
|
|
2429
|
+
} else stillRemoteIds.push(recordIdStr);
|
|
1887
2430
|
}
|
|
2431
|
+
return stillRemoteIds;
|
|
1888
2432
|
}
|
|
1889
2433
|
};
|
|
1890
2434
|
|
|
@@ -2025,6 +2569,8 @@ var Sp00kySync = class {
|
|
|
2025
2569
|
listRefPollTimer = null;
|
|
2026
2570
|
listRefPollRunning = false;
|
|
2027
2571
|
refSyncIntervalMs;
|
|
2572
|
+
listRefIdleStreak = 0;
|
|
2573
|
+
stillRemoteStreaks = /* @__PURE__ */ new Map();
|
|
2028
2574
|
lastLiveEventAt = null;
|
|
2029
2575
|
_liveRetryCount = 0;
|
|
2030
2576
|
get liveRetryCount() {
|
|
@@ -2126,13 +2672,14 @@ var Sp00kySync = class {
|
|
|
2126
2672
|
const schedule = (delayMs) => {
|
|
2127
2673
|
this.listRefPollTimer = setTimeout(async () => {
|
|
2128
2674
|
if (!this.listRefPollRunning) return;
|
|
2675
|
+
let changed = false;
|
|
2129
2676
|
try {
|
|
2130
|
-
await this.pollListRefForActiveQueries();
|
|
2677
|
+
changed = await this.pollListRefForActiveQueries();
|
|
2131
2678
|
} finally {
|
|
2132
2679
|
if (!this.listRefPollRunning) return;
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2680
|
+
this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
|
|
2681
|
+
schedule(listRefPollDelayMs({
|
|
2682
|
+
idleStreak: this.listRefIdleStreak,
|
|
2136
2683
|
baseIntervalMs: this.refSyncIntervalMs
|
|
2137
2684
|
}));
|
|
2138
2685
|
}
|
|
@@ -2147,11 +2694,17 @@ var Sp00kySync = class {
|
|
|
2147
2694
|
this.listRefPollTimer = null;
|
|
2148
2695
|
}
|
|
2149
2696
|
}
|
|
2697
|
+
/**
|
|
2698
|
+
* One poll cycle: refetch `_00_list_ref` for every active query. Returns
|
|
2699
|
+
* whether ANY query's remoteArray actually changed — the scheduler uses this
|
|
2700
|
+
* to drive the adaptive idle backoff.
|
|
2701
|
+
*/
|
|
2150
2702
|
async pollListRefForActiveQueries() {
|
|
2151
2703
|
const hashes = this.dataModule.getActiveQueryHashes();
|
|
2152
|
-
if (hashes.length === 0) return;
|
|
2704
|
+
if (hashes.length === 0) return false;
|
|
2705
|
+
let anyChanged = false;
|
|
2153
2706
|
for (const hash of hashes) try {
|
|
2154
|
-
await this.refetchListRefForQuery(hash);
|
|
2707
|
+
if (await this.refetchListRefForQuery(hash)) anyChanged = true;
|
|
2155
2708
|
} catch (err) {
|
|
2156
2709
|
this.logger.debug({
|
|
2157
2710
|
err: err?.message ?? err,
|
|
@@ -2159,6 +2712,7 @@ var Sp00kySync = class {
|
|
|
2159
2712
|
Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
|
|
2160
2713
|
}, "Per-query list_ref poll failed");
|
|
2161
2714
|
}
|
|
2715
|
+
return anyChanged;
|
|
2162
2716
|
}
|
|
2163
2717
|
/**
|
|
2164
2718
|
* Pull the upstream list_ref entries for `queryHash`, diff them
|
|
@@ -2170,12 +2724,16 @@ var Sp00kySync = class {
|
|
|
2170
2724
|
*/
|
|
2171
2725
|
async refetchListRefForQuery(queryHash) {
|
|
2172
2726
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2173
|
-
if (!queryState) return;
|
|
2727
|
+
if (!queryState) return false;
|
|
2174
2728
|
const listRefTbl = this.listRefTable();
|
|
2175
2729
|
const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
2176
|
-
if (!Array.isArray(items)) return;
|
|
2730
|
+
if (!Array.isArray(items)) return false;
|
|
2177
2731
|
const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
2178
|
-
|
|
2732
|
+
const prevRemote = queryState.config.remoteArray ?? [];
|
|
2733
|
+
const freshIds = new Set(fresh.map(([id]) => id));
|
|
2734
|
+
const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
|
|
2735
|
+
const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
|
|
2736
|
+
if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
|
|
2179
2737
|
try {
|
|
2180
2738
|
await this.syncQuery(queryHash);
|
|
2181
2739
|
} catch (err) {
|
|
@@ -2185,6 +2743,16 @@ var Sp00kySync = class {
|
|
|
2185
2743
|
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2186
2744
|
}, "syncQuery failed during poll");
|
|
2187
2745
|
}
|
|
2746
|
+
if (removedIds.length > 0) try {
|
|
2747
|
+
await this.dataModule.notifyQuerySynced(queryHash);
|
|
2748
|
+
} catch (err) {
|
|
2749
|
+
this.logger.info({
|
|
2750
|
+
err: err?.message ?? err,
|
|
2751
|
+
queryHash,
|
|
2752
|
+
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2753
|
+
}, "notifyQuerySynced failed during poll-removal re-render");
|
|
2754
|
+
}
|
|
2755
|
+
return changed;
|
|
2188
2756
|
}
|
|
2189
2757
|
/**
|
|
2190
2758
|
* Resolve the current `_00_list_ref` table name for the active auth
|
|
@@ -2237,6 +2805,12 @@ var Sp00kySync = class {
|
|
|
2237
2805
|
type: "register",
|
|
2238
2806
|
payload: { hash }
|
|
2239
2807
|
});
|
|
2808
|
+
if (this.currentUserId) this.restartRefLiveQuery().catch((err) => {
|
|
2809
|
+
this.logger.debug({
|
|
2810
|
+
err,
|
|
2811
|
+
Category: "sp00ky-client::Sp00kySync::onReconnect"
|
|
2812
|
+
}, "LIVE restart after reconnect failed; relying on poll fallback");
|
|
2813
|
+
});
|
|
2240
2814
|
});
|
|
2241
2815
|
}
|
|
2242
2816
|
async startRefLiveQueries() {
|
|
@@ -2253,6 +2827,7 @@ var Sp00kySync = class {
|
|
|
2253
2827
|
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
2254
2828
|
}, "Live update received");
|
|
2255
2829
|
if (message.action === "KILLED") return;
|
|
2830
|
+
if (message.value.parent != null) return;
|
|
2256
2831
|
this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
|
|
2257
2832
|
this.logger.error({
|
|
2258
2833
|
err,
|
|
@@ -2263,14 +2838,7 @@ var Sp00kySync = class {
|
|
|
2263
2838
|
}
|
|
2264
2839
|
async handleRemoteListRefChange(action, queryId, recordId, version) {
|
|
2265
2840
|
this.lastLiveEventAt = Date.now();
|
|
2266
|
-
|
|
2267
|
-
this.logger.debug({
|
|
2268
|
-
queryId: queryId.toString(),
|
|
2269
|
-
recordId: recordId.toString(),
|
|
2270
|
-
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
2271
|
-
}, "Ignoring DELETE on list_ref — should not happen");
|
|
2272
|
-
return;
|
|
2273
|
-
}
|
|
2841
|
+
this.listRefIdleStreak = 0;
|
|
2274
2842
|
const existing = this.dataModule.getQueryById(queryId);
|
|
2275
2843
|
if (!existing) {
|
|
2276
2844
|
this.logger.warn({
|
|
@@ -2289,7 +2857,8 @@ var Sp00kySync = class {
|
|
|
2289
2857
|
Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
|
|
2290
2858
|
}, "Live update is being processed");
|
|
2291
2859
|
const diff = createDiffFromDbOp(action, recordId, version, localArray);
|
|
2292
|
-
|
|
2860
|
+
const hash = extractIdPart(existing.config.id);
|
|
2861
|
+
await this.runSyncForQuery(hash, diff);
|
|
2293
2862
|
}
|
|
2294
2863
|
/**
|
|
2295
2864
|
* Enqueues a 'down' event (from remote to local) for processing.
|
|
@@ -2396,7 +2965,70 @@ var Sp00kySync = class {
|
|
|
2396
2965
|
}
|
|
2397
2966
|
const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
|
|
2398
2967
|
if (!diff) return;
|
|
2399
|
-
return this.
|
|
2968
|
+
return this.runSyncForQuery(hash, diff);
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Run a sync for a single query while reflecting its fetch status. Marks the
|
|
2972
|
+
* query `fetching` for the duration when the diff actually pulls records
|
|
2973
|
+
* (added/updated), then resets to `idle` in a `finally` so a failed sync
|
|
2974
|
+
* never leaves a query stuck `fetching`. Part A's notification coalescing
|
|
2975
|
+
* means the single resulting UI update lands after this completes.
|
|
2976
|
+
*/
|
|
2977
|
+
async runSyncForQuery(hash, diff) {
|
|
2978
|
+
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
2979
|
+
const pendingDeletes = await this.getPendingDeleteIds();
|
|
2980
|
+
if (pendingDeletes.size > 0) diff = {
|
|
2981
|
+
added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
2982
|
+
updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
|
|
2983
|
+
removed: diff.removed
|
|
2984
|
+
};
|
|
2985
|
+
}
|
|
2986
|
+
const fetching = diff.added.length + diff.updated.length > 0;
|
|
2987
|
+
if (fetching) this.dataModule.setQueryStatus(hash, "fetching");
|
|
2988
|
+
try {
|
|
2989
|
+
const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
|
|
2990
|
+
if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
|
|
2991
|
+
if (stillRemoteIds.length > 0) {
|
|
2992
|
+
const CONVERGE_AFTER = 3;
|
|
2993
|
+
const toConverge = [];
|
|
2994
|
+
for (const id of stillRemoteIds) {
|
|
2995
|
+
const key = `${hash}:${id}`;
|
|
2996
|
+
const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
|
|
2997
|
+
if (n >= CONVERGE_AFTER) {
|
|
2998
|
+
this.stillRemoteStreaks.delete(key);
|
|
2999
|
+
toConverge.push(id);
|
|
3000
|
+
} else this.stillRemoteStreaks.set(key, n);
|
|
3001
|
+
}
|
|
3002
|
+
if (toConverge.length > 0) {
|
|
3003
|
+
const local = this.dataModule.getQueryByHash(hash)?.config.localArray;
|
|
3004
|
+
if (local && local.length > 0) {
|
|
3005
|
+
const drop = new Set(toConverge);
|
|
3006
|
+
const next = local.filter(([id]) => !drop.has(id));
|
|
3007
|
+
if (next.length !== local.length) await this.dataModule.updateQueryLocalArray(hash, next);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
} finally {
|
|
3012
|
+
if (fetching) this.dataModule.setQueryStatus(hash, "idle");
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
|
|
3017
|
+
* Sync must not re-fetch/re-insert these — the remote delete is async, so the
|
|
3018
|
+
* server's `_00_list_ref` still lists them until it's processed, and the diff
|
|
3019
|
+
* would otherwise resurrect a just-deleted record.
|
|
3020
|
+
*/
|
|
3021
|
+
async getPendingDeleteIds() {
|
|
3022
|
+
try {
|
|
3023
|
+
const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
|
|
3024
|
+
return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
|
|
3025
|
+
} catch (err) {
|
|
3026
|
+
this.logger.warn({
|
|
3027
|
+
err,
|
|
3028
|
+
Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
|
|
3029
|
+
}, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
|
|
3030
|
+
return /* @__PURE__ */ new Set();
|
|
3031
|
+
}
|
|
2400
3032
|
}
|
|
2401
3033
|
/**
|
|
2402
3034
|
* Enqueues a list of mutations (up events) to be sent to the remote.
|
|
@@ -2465,14 +3097,17 @@ var Sp00kySync = class {
|
|
|
2465
3097
|
}
|
|
2466
3098
|
async cleanupQuery(queryHash) {
|
|
2467
3099
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2468
|
-
if (!queryState)
|
|
2469
|
-
|
|
2470
|
-
queryHash,
|
|
2471
|
-
Category: "sp00ky-client::Sp00kySync::cleanupQuery"
|
|
2472
|
-
}, "Query to register not found");
|
|
2473
|
-
throw new Error("Query to register not found");
|
|
2474
|
-
}
|
|
3100
|
+
if (!queryState) return;
|
|
3101
|
+
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
2475
3102
|
await this.remote.query(`DELETE $id`, { id: queryState.config.id });
|
|
3103
|
+
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
3104
|
+
this.enqueueDownEvent({
|
|
3105
|
+
type: "register",
|
|
3106
|
+
payload: { hash: queryHash }
|
|
3107
|
+
});
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
this.dataModule.finalizeDeregister(queryHash);
|
|
2476
3111
|
}
|
|
2477
3112
|
};
|
|
2478
3113
|
|
|
@@ -2483,12 +3118,64 @@ function createAuthEventSystem() {
|
|
|
2483
3118
|
return createEventSystem([AuthEventTypes.AuthStateChanged]);
|
|
2484
3119
|
}
|
|
2485
3120
|
|
|
3121
|
+
//#endregion
|
|
3122
|
+
//#region src/modules/devtools/versions.ts
|
|
3123
|
+
const UNAVAILABLE = "unavailable";
|
|
3124
|
+
function emptyBackendVersions() {
|
|
3125
|
+
return {
|
|
3126
|
+
ssp: UNAVAILABLE,
|
|
3127
|
+
scheduler: UNAVAILABLE,
|
|
3128
|
+
surrealdb: UNAVAILABLE
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
function emptyBackendInfo() {
|
|
3132
|
+
return {
|
|
3133
|
+
versions: emptyBackendVersions(),
|
|
3134
|
+
entities: []
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
/** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
|
|
3138
|
+
function normalizeServerVersion(v) {
|
|
3139
|
+
return String(v).replace(/^surrealdb-/i, "").trim();
|
|
3140
|
+
}
|
|
3141
|
+
/**
|
|
3142
|
+
* Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
|
|
3143
|
+
* array. The SurrealQL function returns the parsed `/info` array; depending on
|
|
3144
|
+
* how the result is unwrapped it may arrive as the array itself, a single
|
|
3145
|
+
* object, or `null`. Tolerant of all three.
|
|
3146
|
+
*/
|
|
3147
|
+
function toEntityArray(raw) {
|
|
3148
|
+
if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
|
|
3149
|
+
if (raw && typeof raw === "object") return [raw];
|
|
3150
|
+
return [];
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Derive component versions + the full entity list from a `/info` entity array.
|
|
3154
|
+
* `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
|
|
3155
|
+
* scheduler). Never throws; missing pieces stay `'unavailable'`.
|
|
3156
|
+
*/
|
|
3157
|
+
function parseBackendInfo(raw) {
|
|
3158
|
+
const entities = toEntityArray(raw);
|
|
3159
|
+
const versions = emptyBackendVersions();
|
|
3160
|
+
for (const entity of entities) {
|
|
3161
|
+
const version = entity.version ? String(entity.version) : void 0;
|
|
3162
|
+
if (entity.entity === "ssp" && version) versions.ssp = version;
|
|
3163
|
+
else if (entity.entity === "scheduler" && version) versions.scheduler = version;
|
|
3164
|
+
if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
|
|
3165
|
+
}
|
|
3166
|
+
return {
|
|
3167
|
+
versions,
|
|
3168
|
+
entities
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
|
|
2486
3172
|
//#endregion
|
|
2487
3173
|
//#region src/modules/devtools/index.ts
|
|
2488
3174
|
var DevToolsService = class {
|
|
2489
3175
|
eventsHistory = [];
|
|
2490
3176
|
eventIdCounter = 0;
|
|
2491
|
-
version = "
|
|
3177
|
+
version = "0.0.1-canary.70";
|
|
3178
|
+
backendInfo = emptyBackendInfo();
|
|
2492
3179
|
constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
|
|
2493
3180
|
this.databaseService = databaseService;
|
|
2494
3181
|
this.remoteDatabaseService = remoteDatabaseService;
|
|
@@ -2500,8 +3187,27 @@ var DevToolsService = class {
|
|
|
2500
3187
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
2501
3188
|
this.notifyDevTools();
|
|
2502
3189
|
});
|
|
3190
|
+
this.refreshBackendVersions();
|
|
2503
3191
|
this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
|
|
2504
3192
|
}
|
|
3193
|
+
/**
|
|
3194
|
+
* Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
|
|
3195
|
+
* over the open remote connection (no HTTP/CORS), then notify the panel.
|
|
3196
|
+
* Never throws: on failure the info stays empty/'unavailable'.
|
|
3197
|
+
*/
|
|
3198
|
+
async refreshBackendVersions() {
|
|
3199
|
+
try {
|
|
3200
|
+
const result = await this.remoteDatabaseService.query("RETURN fn::spooky::info()");
|
|
3201
|
+
this.backendInfo = parseBackendInfo(Array.isArray(result) ? result[0] : result);
|
|
3202
|
+
} catch (err) {
|
|
3203
|
+
this.logger.debug({
|
|
3204
|
+
err,
|
|
3205
|
+
Category: "sp00ky-client::DevToolsService::versions"
|
|
3206
|
+
}, "fn::spooky::info() unavailable; backend versions stay unavailable");
|
|
3207
|
+
this.backendInfo = emptyBackendInfo();
|
|
3208
|
+
}
|
|
3209
|
+
this.notifyDevTools();
|
|
3210
|
+
}
|
|
2505
3211
|
getActiveQueries() {
|
|
2506
3212
|
const result = /* @__PURE__ */ new Map();
|
|
2507
3213
|
if (!this.dataManager) return result;
|
|
@@ -2510,6 +3216,8 @@ var DevToolsService = class {
|
|
|
2510
3216
|
result.set(queryHash, {
|
|
2511
3217
|
queryHash,
|
|
2512
3218
|
status: "active",
|
|
3219
|
+
fetchStatus: q.status,
|
|
3220
|
+
isFetching: q.status === "fetching",
|
|
2513
3221
|
createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
|
|
2514
3222
|
lastUpdate: Date.now(),
|
|
2515
3223
|
updateCount: q.updateCount,
|
|
@@ -2518,7 +3226,8 @@ var DevToolsService = class {
|
|
|
2518
3226
|
dataSize: q.records?.length || 0,
|
|
2519
3227
|
data: q.records,
|
|
2520
3228
|
localArray: q.config.localArray,
|
|
2521
|
-
remoteArray: q.config.remoteArray
|
|
3229
|
+
remoteArray: q.config.remoteArray,
|
|
3230
|
+
timings: this.dataManager.phaseTimings(q)
|
|
2522
3231
|
});
|
|
2523
3232
|
});
|
|
2524
3233
|
return result;
|
|
@@ -2598,6 +3307,15 @@ var DevToolsService = class {
|
|
|
2598
3307
|
userId: this.authService.currentUser?.id
|
|
2599
3308
|
},
|
|
2600
3309
|
version: this.version,
|
|
3310
|
+
versions: {
|
|
3311
|
+
frontend: {
|
|
3312
|
+
core: "0.0.1-canary.70",
|
|
3313
|
+
wasm: "0.0.1-canary.70",
|
|
3314
|
+
surrealdb: "3.0.3"
|
|
3315
|
+
},
|
|
3316
|
+
backend: this.backendInfo.versions,
|
|
3317
|
+
entities: this.backendInfo.entities
|
|
3318
|
+
},
|
|
2601
3319
|
database: {
|
|
2602
3320
|
tables: this.schema.tables.map((t) => t.name),
|
|
2603
3321
|
tableData: {}
|
|
@@ -2640,6 +3358,7 @@ var DevToolsService = class {
|
|
|
2640
3358
|
this.eventsHistory = [];
|
|
2641
3359
|
this.notifyDevTools();
|
|
2642
3360
|
},
|
|
3361
|
+
refreshVersions: () => this.refreshBackendVersions(),
|
|
2643
3362
|
getTableData: async (tableName) => {
|
|
2644
3363
|
try {
|
|
2645
3364
|
const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
|
|
@@ -2893,6 +3612,8 @@ var StreamProcessorService = class {
|
|
|
2893
3612
|
processor;
|
|
2894
3613
|
isInitialized = false;
|
|
2895
3614
|
receivers = [];
|
|
3615
|
+
batching = false;
|
|
3616
|
+
batchBuffer = /* @__PURE__ */ new Map();
|
|
2896
3617
|
constructor(events, db, persistenceClient, logger) {
|
|
2897
3618
|
this.events = events;
|
|
2898
3619
|
this.db = db;
|
|
@@ -2907,9 +3628,75 @@ var StreamProcessorService = class {
|
|
|
2907
3628
|
this.receivers.push(receiver);
|
|
2908
3629
|
}
|
|
2909
3630
|
notifyUpdates(updates) {
|
|
3631
|
+
if (this.batching) {
|
|
3632
|
+
for (const update of updates) {
|
|
3633
|
+
const prev = this.batchBuffer.get(update.queryHash);
|
|
3634
|
+
const sum = (a, b) => (a ?? 0) + (b ?? 0);
|
|
3635
|
+
this.batchBuffer.set(update.queryHash, {
|
|
3636
|
+
...update,
|
|
3637
|
+
op: "CREATE",
|
|
3638
|
+
materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
|
|
3639
|
+
storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
|
|
3640
|
+
circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
|
|
3641
|
+
transformMs: sum(prev?.transformMs, update.transformMs)
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
return;
|
|
3645
|
+
}
|
|
3646
|
+
this.dispatchUpdates(updates);
|
|
3647
|
+
}
|
|
3648
|
+
dispatchUpdates(updates) {
|
|
2910
3649
|
for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
|
|
2911
3650
|
}
|
|
2912
3651
|
/**
|
|
3652
|
+
* Ingest a batch of record changes as a single bulk operation, firing only
|
|
3653
|
+
* one coalesced `StreamUpdate` per affected query once every record has been
|
|
3654
|
+
* ingested (instead of one update per record). Use this whenever multiple
|
|
3655
|
+
* records land at once — e.g. sync fetching N missing rows — so a list query
|
|
3656
|
+
* re-runs and the UI re-renders once for the whole batch rather than
|
|
3657
|
+
* row-by-row.
|
|
3658
|
+
*
|
|
3659
|
+
* Internally opens a coalescing window, ingests each record, then flushes;
|
|
3660
|
+
* processor state is persisted once for the whole batch. No-op for an empty
|
|
3661
|
+
* batch.
|
|
3662
|
+
*/
|
|
3663
|
+
ingestMany(records) {
|
|
3664
|
+
if (records.length === 0) return;
|
|
3665
|
+
this.beginCoalescing();
|
|
3666
|
+
try {
|
|
3667
|
+
for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
|
|
3668
|
+
} finally {
|
|
3669
|
+
this.flushCoalescing();
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
/**
|
|
3673
|
+
* Open a coalescing window. While open, the per-record stream updates
|
|
3674
|
+
* emitted by `ingest` are buffered (one entry per queryHash) instead of
|
|
3675
|
+
* dispatched. Always paired with `flushCoalescing()` in a try/finally by
|
|
3676
|
+
* `ingestMany` so the window always closes — otherwise the processor stays
|
|
3677
|
+
* stuck buffering forever.
|
|
3678
|
+
*
|
|
3679
|
+
* No-op if a window is already open (nested batches aren't expected here).
|
|
3680
|
+
*/
|
|
3681
|
+
beginCoalescing() {
|
|
3682
|
+
if (this.batching) return;
|
|
3683
|
+
this.batching = true;
|
|
3684
|
+
this.batchBuffer.clear();
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* Close the coalescing window and flush: dispatch one coalesced
|
|
3688
|
+
* `StreamUpdate` per buffered queryHash, then persist processor state once
|
|
3689
|
+
* for the whole batch (instead of once per ingest).
|
|
3690
|
+
*/
|
|
3691
|
+
flushCoalescing() {
|
|
3692
|
+
if (!this.batching) return;
|
|
3693
|
+
this.batching = false;
|
|
3694
|
+
const buffered = Array.from(this.batchBuffer.values());
|
|
3695
|
+
this.batchBuffer.clear();
|
|
3696
|
+
if (buffered.length > 0) this.dispatchUpdates(buffered);
|
|
3697
|
+
this.saveState();
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
2913
3700
|
* Initialize the WASM module and processor.
|
|
2914
3701
|
* This must be called before using other methods.
|
|
2915
3702
|
*/
|
|
@@ -2950,6 +3737,23 @@ var StreamProcessorService = class {
|
|
|
2950
3737
|
}, "Failed to load state");
|
|
2951
3738
|
}
|
|
2952
3739
|
}
|
|
3740
|
+
/**
|
|
3741
|
+
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
|
3742
|
+
* Must run after the processor exists and before any `register_view`, else
|
|
3743
|
+
* non-`_00_` tables are default-denied and registration fails.
|
|
3744
|
+
*/
|
|
3745
|
+
setPermissions(permissions) {
|
|
3746
|
+
if (!this.processor) return;
|
|
3747
|
+
if (typeof this.processor.set_permissions !== "function") {
|
|
3748
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::setPermissions" }, "set_permissions not found on processor (stale WASM build?)");
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
this.processor.set_permissions(permissions);
|
|
3752
|
+
this.logger.info({
|
|
3753
|
+
tables: Object.keys(permissions).length,
|
|
3754
|
+
Category: "sp00ky-client::StreamProcessorService::setPermissions"
|
|
3755
|
+
}, "Seeded table permissions");
|
|
3756
|
+
}
|
|
2953
3757
|
async saveState() {
|
|
2954
3758
|
if (!this.processor) return;
|
|
2955
3759
|
try {
|
|
@@ -3001,11 +3805,14 @@ var StreamProcessorService = class {
|
|
|
3001
3805
|
queryHash: u.query_id,
|
|
3002
3806
|
localArray: u.result_data,
|
|
3003
3807
|
op,
|
|
3004
|
-
materializationTimeMs
|
|
3808
|
+
materializationTimeMs,
|
|
3809
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
3810
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
3811
|
+
transformMs: u.timing_transform_ms
|
|
3005
3812
|
}));
|
|
3006
3813
|
this.notifyUpdates(updates);
|
|
3007
3814
|
}
|
|
3008
|
-
this.saveState();
|
|
3815
|
+
if (!this.batching) this.saveState();
|
|
3009
3816
|
return rawUpdates;
|
|
3010
3817
|
} catch (e) {
|
|
3011
3818
|
this.logger.error({
|
|
@@ -3047,7 +3854,12 @@ var StreamProcessorService = class {
|
|
|
3047
3854
|
if (!initialUpdate) throw new Error("Failed to register query plan");
|
|
3048
3855
|
const update = {
|
|
3049
3856
|
queryHash: initialUpdate.query_id,
|
|
3050
|
-
localArray: initialUpdate.result_data
|
|
3857
|
+
localArray: initialUpdate.result_data,
|
|
3858
|
+
registration: {
|
|
3859
|
+
parseMs: initialUpdate.timing_parse_ms ?? 0,
|
|
3860
|
+
planMs: initialUpdate.timing_plan_ms ?? 0,
|
|
3861
|
+
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
|
|
3862
|
+
}
|
|
3051
3863
|
};
|
|
3052
3864
|
this.saveState();
|
|
3053
3865
|
this.logger.debug({
|
|
@@ -3108,6 +3920,51 @@ var StreamProcessorService = class {
|
|
|
3108
3920
|
}
|
|
3109
3921
|
};
|
|
3110
3922
|
|
|
3923
|
+
//#endregion
|
|
3924
|
+
//#region src/services/stream-processor/permissions.ts
|
|
3925
|
+
/**
|
|
3926
|
+
* Extract per-table `select` permission predicates from a raw `.surql` schema.
|
|
3927
|
+
*
|
|
3928
|
+
* The in-browser SSP default-denies any non-`_00_` table that has no permission
|
|
3929
|
+
* predicate registered (see `permission_inject::build_predicate`). The client
|
|
3930
|
+
* already holds the full schema text (`config.schemaSurql`), so we parse each
|
|
3931
|
+
* `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
|
|
3932
|
+
* processor via `set_permissions` at boot — mirroring the native path that
|
|
3933
|
+
* seeds the same map from `INFO FOR DB`.
|
|
3934
|
+
*
|
|
3935
|
+
* Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
|
|
3936
|
+
* expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
|
|
3937
|
+
* `select` permission).
|
|
3938
|
+
*/
|
|
3939
|
+
function extractSelectPermissions(schemaSurql) {
|
|
3940
|
+
const out = {};
|
|
3941
|
+
if (!schemaSurql) return out;
|
|
3942
|
+
const cleaned = schemaSurql.replace(/--[^\n]*/g, "");
|
|
3943
|
+
const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
|
|
3944
|
+
let m;
|
|
3945
|
+
while ((m = tableStmt.exec(cleaned)) !== null) {
|
|
3946
|
+
const table = m[1];
|
|
3947
|
+
const body = m[2];
|
|
3948
|
+
out[table] = selectPredicateFromBody(body);
|
|
3949
|
+
}
|
|
3950
|
+
return out;
|
|
3951
|
+
}
|
|
3952
|
+
function selectPredicateFromBody(body) {
|
|
3953
|
+
const permIdx = body.search(/\bPERMISSIONS\b/i);
|
|
3954
|
+
if (permIdx === -1) return "false";
|
|
3955
|
+
const perms = body.slice(permIdx + 11).trim();
|
|
3956
|
+
if (/^FULL\b/i.test(perms)) return "true";
|
|
3957
|
+
if (/^NONE\b/i.test(perms)) return "false";
|
|
3958
|
+
const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
|
|
3959
|
+
for (const group of groups) {
|
|
3960
|
+
const where = group.search(/\bWHERE\b/i);
|
|
3961
|
+
if (where === -1) continue;
|
|
3962
|
+
const actions = group.slice(0, where).toLowerCase();
|
|
3963
|
+
if (/\bselect\b/.test(actions)) return group.slice(where + 5).trim();
|
|
3964
|
+
}
|
|
3965
|
+
return "false";
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3111
3968
|
//#endregion
|
|
3112
3969
|
//#region src/modules/cache/index.ts
|
|
3113
3970
|
/**
|
|
@@ -3185,11 +4042,17 @@ var CacheModule = class {
|
|
|
3185
4042
|
}, {});
|
|
3186
4043
|
await this.local.execute(query, params);
|
|
3187
4044
|
}
|
|
3188
|
-
|
|
4045
|
+
const bulk = populatedRecords.map((record) => {
|
|
3189
4046
|
const recordId = encodeRecordId(record.record.id);
|
|
3190
4047
|
this.versionLookups[recordId] = record.version;
|
|
3191
|
-
|
|
3192
|
-
|
|
4048
|
+
return {
|
|
4049
|
+
table: record.table,
|
|
4050
|
+
op: record.op,
|
|
4051
|
+
id: recordId,
|
|
4052
|
+
record: record.record
|
|
4053
|
+
};
|
|
4054
|
+
});
|
|
4055
|
+
this.streamProcessor.ingestMany(bulk);
|
|
3193
4056
|
this.logger.debug({
|
|
3194
4057
|
count: records.length,
|
|
3195
4058
|
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
@@ -3258,7 +4121,10 @@ var CacheModule = class {
|
|
|
3258
4121
|
arrayLength: update.localArray?.length,
|
|
3259
4122
|
Category: "sp00ky-client::CacheModule::registerQuery"
|
|
3260
4123
|
}, "Query registered successfully");
|
|
3261
|
-
return {
|
|
4124
|
+
return {
|
|
4125
|
+
localArray: update.localArray,
|
|
4126
|
+
registrationTimings: update.registration
|
|
4127
|
+
};
|
|
3262
4128
|
} catch (err) {
|
|
3263
4129
|
this.logger.error({
|
|
3264
4130
|
err,
|
|
@@ -3729,7 +4595,13 @@ var CrdtManager = class {
|
|
|
3729
4595
|
killTableSubscription(table) {
|
|
3730
4596
|
const uuid = this.liveByTable.get(table);
|
|
3731
4597
|
if (uuid) {
|
|
3732
|
-
this.remote.query("KILL $uuid", { uuid }).catch(() => {
|
|
4598
|
+
this.remote.query("KILL $uuid", { uuid }).catch((err) => {
|
|
4599
|
+
this.logger.debug({
|
|
4600
|
+
err,
|
|
4601
|
+
table,
|
|
4602
|
+
Category: "sp00ky-client::CrdtManager::killTableSubscription"
|
|
4603
|
+
}, "KILL of table LIVE failed (already closed?)");
|
|
4604
|
+
});
|
|
3733
4605
|
this.liveByTable.delete(table);
|
|
3734
4606
|
}
|
|
3735
4607
|
}
|
|
@@ -3843,7 +4715,13 @@ var ResilientPersistenceClient = class {
|
|
|
3843
4715
|
error: e,
|
|
3844
4716
|
Category: "sp00ky-client::ResilientPersistenceClient::get"
|
|
3845
4717
|
}, "Persistence read failed, dropping key");
|
|
3846
|
-
await this.inner.remove(key).catch(() => {
|
|
4718
|
+
await this.inner.remove(key).catch((removeErr) => {
|
|
4719
|
+
this.logger.debug({
|
|
4720
|
+
key,
|
|
4721
|
+
error: removeErr,
|
|
4722
|
+
Category: "sp00ky-client::ResilientPersistenceClient::get"
|
|
4723
|
+
}, "Failed to drop corrupt persistence key");
|
|
4724
|
+
});
|
|
3847
4725
|
return null;
|
|
3848
4726
|
}
|
|
3849
4727
|
}
|
|
@@ -3956,6 +4834,27 @@ var Sp00kyClient = class {
|
|
|
3956
4834
|
* Setup direct callbacks instead of event subscriptions
|
|
3957
4835
|
*/
|
|
3958
4836
|
setupCallbacks() {
|
|
4837
|
+
this.dataModule.onQueryStatusChange = (queryHash, status) => {
|
|
4838
|
+
this.devTools.logEvent("QUERY_STATUS_CHANGED", {
|
|
4839
|
+
queryHash,
|
|
4840
|
+
status
|
|
4841
|
+
});
|
|
4842
|
+
};
|
|
4843
|
+
this.dataModule.onHeartbeat = (queryHash) => {
|
|
4844
|
+
this.sync.heartbeatQuery(queryHash).catch((err) => {
|
|
4845
|
+
this.logger.warn({
|
|
4846
|
+
err,
|
|
4847
|
+
queryHash,
|
|
4848
|
+
Category: "sp00ky-client::Sp00kyClient::onHeartbeat"
|
|
4849
|
+
}, "TTL heartbeat failed");
|
|
4850
|
+
});
|
|
4851
|
+
};
|
|
4852
|
+
this.dataModule.onDeregister = (queryHash) => {
|
|
4853
|
+
this.sync.enqueueDownEvent({
|
|
4854
|
+
type: "cleanup",
|
|
4855
|
+
payload: { hash: queryHash }
|
|
4856
|
+
});
|
|
4857
|
+
};
|
|
3959
4858
|
this.dataModule.onMutation((mutations) => {
|
|
3960
4859
|
this.devTools.onMutation(mutations);
|
|
3961
4860
|
if (mutations.length > 0) this.sync.enqueueMutation(mutations);
|
|
@@ -3996,6 +4895,7 @@ var Sp00kyClient = class {
|
|
|
3996
4895
|
await this.remote.connect();
|
|
3997
4896
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
|
|
3998
4897
|
await this.streamProcessor.init();
|
|
4898
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
3999
4899
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
4000
4900
|
await this.auth.init();
|
|
4001
4901
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
|
|
@@ -4063,7 +4963,18 @@ var Sp00kyClient = class {
|
|
|
4063
4963
|
async initQuery(table, q, ttl) {
|
|
4064
4964
|
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
4065
4965
|
if (!tableSchema) throw new Error(`Table ${table} not found`);
|
|
4066
|
-
const
|
|
4966
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
4967
|
+
const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
|
|
4968
|
+
if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
|
|
4969
|
+
const [rows] = await this.remote.query(q.selectQuery.query, params);
|
|
4970
|
+
await this.dataModule.applyHydration(hash, rows ?? []);
|
|
4971
|
+
} catch (err) {
|
|
4972
|
+
this.logger.warn({
|
|
4973
|
+
err,
|
|
4974
|
+
hash,
|
|
4975
|
+
Category: "sp00ky-client::Sp00kyClient::instantHydrate"
|
|
4976
|
+
}, "Instant hydrate failed; proceeding with registration");
|
|
4977
|
+
}
|
|
4067
4978
|
await this.sync.enqueueDownEvent({
|
|
4068
4979
|
type: "register",
|
|
4069
4980
|
payload: { hash }
|
|
@@ -4077,6 +4988,32 @@ var Sp00kyClient = class {
|
|
|
4077
4988
|
async subscribe(queryHash, callback, options) {
|
|
4078
4989
|
return this.dataModule.subscribe(queryHash, callback, options);
|
|
4079
4990
|
}
|
|
4991
|
+
/**
|
|
4992
|
+
* Opt-in eager teardown for a query whose last subscriber has gone away
|
|
4993
|
+
* (e.g. a viewport-windowed list cancelling an off-screen window). No-op
|
|
4994
|
+
* while any subscriber remains. Tears down the remote `_00_query` view +
|
|
4995
|
+
* local WASM view instead of waiting for the TTL sweep. Default behavior
|
|
4996
|
+
* (no call here) keeps the view resident for cheap re-subscription.
|
|
4997
|
+
*/
|
|
4998
|
+
deregisterQuery(queryHash) {
|
|
4999
|
+
this.dataModule.deregisterQuery(queryHash);
|
|
5000
|
+
}
|
|
5001
|
+
/**
|
|
5002
|
+
* Subscribe to a query's fetch-status changes (idle/fetching). With
|
|
5003
|
+
* `{ immediate: true }` the callback fires synchronously with the current
|
|
5004
|
+
* status. Powers the `useQuery` hook's `isFetching()` accessor.
|
|
5005
|
+
*/
|
|
5006
|
+
subscribeQueryStatus(queryHash, callback, options) {
|
|
5007
|
+
return this.dataModule.subscribeStatus(queryHash, callback, options);
|
|
5008
|
+
}
|
|
5009
|
+
/**
|
|
5010
|
+
* Report the frontend processing time (ms) a client framework spent applying
|
|
5011
|
+
* an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
|
|
5012
|
+
* surface the "frontend" phase of the per-query timing breakdown.
|
|
5013
|
+
*/
|
|
5014
|
+
reportFrontendTiming(queryHash, ms) {
|
|
5015
|
+
this.dataModule.recordFrontendTiming(queryHash, ms);
|
|
5016
|
+
}
|
|
4080
5017
|
run(backend, path, payload, options) {
|
|
4081
5018
|
return this.dataModule.run(backend, path, payload, options);
|
|
4082
5019
|
}
|