@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
|
@@ -7,19 +7,24 @@ import type {
|
|
|
7
7
|
RoutePayload,
|
|
8
8
|
} from '@spooky-sync/query-builder';
|
|
9
9
|
import type { LocalDatabaseService } from '../../services/database/index';
|
|
10
|
-
import type { CacheModule, RecordWithId } from '../cache/index';
|
|
10
|
+
import type { CacheModule, RecordWithId, CacheRecord } from '../cache/index';
|
|
11
11
|
import type { Logger } from '../../services/logger/index';
|
|
12
12
|
import type { StreamUpdate } from '../../services/stream-processor/index';
|
|
13
13
|
import type {
|
|
14
14
|
QueryConfig,
|
|
15
15
|
QueryHash,
|
|
16
16
|
QueryState,
|
|
17
|
+
QueryStatus,
|
|
18
|
+
QueryStatusCallback,
|
|
17
19
|
QueryTimeToLive,
|
|
18
20
|
QueryUpdateCallback,
|
|
19
21
|
MutationCallback,
|
|
20
22
|
RecordVersionArray,
|
|
21
23
|
QueryConfigRecord,
|
|
22
24
|
UpdateOptions,
|
|
25
|
+
QueryTimings,
|
|
26
|
+
PhaseStat,
|
|
27
|
+
RegistrationTimings,
|
|
23
28
|
RunOptions} from '../../types';
|
|
24
29
|
import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
|
|
25
30
|
import {
|
|
@@ -35,6 +40,23 @@ import {
|
|
|
35
40
|
} from '../../utils/index';
|
|
36
41
|
import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
37
42
|
import type { PushEventOptions } from '../../events/index';
|
|
43
|
+
import { buildWindowMaterialization } from './window-query';
|
|
44
|
+
|
|
45
|
+
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
46
|
+
function pushSample(samples: number[], ms: number): void {
|
|
47
|
+
samples.push(ms);
|
|
48
|
+
if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
|
|
52
|
+
function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
|
|
53
|
+
if (samples.length === 0) {
|
|
54
|
+
return { lastMs, p50: null, p90: null, p99: null, count: 0 };
|
|
55
|
+
}
|
|
56
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
57
|
+
const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
|
|
58
|
+
return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
|
|
59
|
+
}
|
|
38
60
|
|
|
39
61
|
/**
|
|
40
62
|
* DataModule - Unified query and mutation management
|
|
@@ -46,9 +68,36 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
46
68
|
private activeQueries: Map<QueryHash, QueryState> = new Map();
|
|
47
69
|
private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
|
|
48
70
|
private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
|
|
71
|
+
private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
|
|
49
72
|
private mutationCallbacks: Set<MutationCallback> = new Set();
|
|
50
73
|
private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
|
|
51
74
|
private logger: Logger;
|
|
75
|
+
/**
|
|
76
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
77
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
78
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
79
|
+
* constructed after DataModule.
|
|
80
|
+
*/
|
|
81
|
+
public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
84
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
85
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
86
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
87
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
88
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
89
|
+
*/
|
|
90
|
+
public onHeartbeat?: (hash: QueryHash) => void;
|
|
91
|
+
/**
|
|
92
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
93
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
94
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
95
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
96
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
97
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
98
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
99
|
+
*/
|
|
100
|
+
public onDeregister?: (hash: QueryHash) => void;
|
|
52
101
|
// Salt for query-id hashing. Set from SurrealDB's session::id() so two
|
|
53
102
|
// browser sessions registering the same logical query (same surql + params)
|
|
54
103
|
// don't collide on the same `_00_query` row — each session gets its own.
|
|
@@ -189,11 +238,70 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
189
238
|
subs.delete(callback);
|
|
190
239
|
if (subs.size === 0) {
|
|
191
240
|
this.subscriptions.delete(queryHash);
|
|
241
|
+
// NOTE: intentionally do NOT tear down the query / free its in-browser
|
|
242
|
+
// SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
|
|
243
|
+
// already self-stops once subscribers hit 0, so an abandoned query
|
|
244
|
+
// stops being kept alive and the server's TTL sweep removes it. Freeing
|
|
245
|
+
// the local view on every last-unsubscribe caused re-registration churn
|
|
246
|
+
// on navigation (open A → leave → open A again re-registers), which is
|
|
247
|
+
// a flakiness risk for no real benefit — keep the local view resident.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Subscribe to a query's fetch-status changes (idle/fetching).
|
|
255
|
+
* With `{ immediate: true }` the callback fires synchronously with the
|
|
256
|
+
* current status (defaults to `idle` if the query isn't registered yet).
|
|
257
|
+
*/
|
|
258
|
+
subscribeStatus(
|
|
259
|
+
queryHash: string,
|
|
260
|
+
callback: QueryStatusCallback,
|
|
261
|
+
options: { immediate?: boolean } = {}
|
|
262
|
+
): () => void {
|
|
263
|
+
if (!this.statusSubscriptions.has(queryHash)) {
|
|
264
|
+
this.statusSubscriptions.set(queryHash, new Set());
|
|
265
|
+
}
|
|
266
|
+
this.statusSubscriptions.get(queryHash)?.add(callback);
|
|
267
|
+
|
|
268
|
+
if (options.immediate) {
|
|
269
|
+
callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return () => {
|
|
273
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
274
|
+
if (subs) {
|
|
275
|
+
subs.delete(callback);
|
|
276
|
+
if (subs.size === 0) {
|
|
277
|
+
this.statusSubscriptions.delete(queryHash);
|
|
192
278
|
}
|
|
193
279
|
}
|
|
194
280
|
};
|
|
195
281
|
}
|
|
196
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Set a query's fetch status and notify status observers (DevTools +
|
|
285
|
+
* `subscribeStatus` listeners). No-op when the status is unchanged or the
|
|
286
|
+
* query is unknown.
|
|
287
|
+
*/
|
|
288
|
+
setQueryStatus(queryHash: string, status: QueryStatus): void {
|
|
289
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
290
|
+
if (!queryState || queryState.status === status) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
queryState.status = status;
|
|
294
|
+
|
|
295
|
+
this.onQueryStatusChange?.(queryHash, status);
|
|
296
|
+
|
|
297
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
298
|
+
if (subs) {
|
|
299
|
+
for (const callback of subs) {
|
|
300
|
+
callback(status);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
197
305
|
/**
|
|
198
306
|
* Subscribe to mutations (for sync)
|
|
199
307
|
*/
|
|
@@ -210,26 +318,79 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
210
318
|
async onStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
211
319
|
const { queryHash, op } = update;
|
|
212
320
|
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
321
|
+
// DELETE propagates immediately — a removed row should disappear without
|
|
322
|
+
// waiting on a debounce.
|
|
323
|
+
//
|
|
324
|
+
// CREATE and UPDATE are coalesced per query on a trailing timer. A list's
|
|
325
|
+
// rows stream in from sync as many small `_00_list_ref` diffs, each its own
|
|
326
|
+
// `cache.saveBatch` → one stream update per chunk. `ingestMany` only
|
|
327
|
+
// coalesces records ingested synchronously together, so chunks spread over
|
|
328
|
+
// time each re-materialize and re-notify — a 50-row window can fire 30+
|
|
329
|
+
// updates as it fills. Each StreamUpdate carries the full materialized
|
|
330
|
+
// `localArray`, so the latest one already reflects every prior chunk: keep
|
|
331
|
+
// only it and fire once on the trailing edge, settling the query in a couple
|
|
332
|
+
// of notifications instead of one per chunk.
|
|
333
|
+
if (op === 'DELETE') {
|
|
334
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
335
|
+
if (existing) {
|
|
336
|
+
clearTimeout(existing);
|
|
337
|
+
this.debounceTimers.delete(queryHash);
|
|
220
338
|
}
|
|
339
|
+
await this.processStreamUpdate(update);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
221
342
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
343
|
+
// Clear existing timer if any
|
|
344
|
+
if (this.debounceTimers.has(queryHash)) {
|
|
345
|
+
// oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
|
|
346
|
+
clearTimeout(this.debounceTimers.get(queryHash)!);
|
|
347
|
+
}
|
|
227
348
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
349
|
+
// Set new timer
|
|
350
|
+
const timer = setTimeout(async () => {
|
|
351
|
+
this.debounceTimers.delete(queryHash);
|
|
231
352
|
await this.processStreamUpdate(update);
|
|
353
|
+
}, this.streamDebounceTime);
|
|
354
|
+
|
|
355
|
+
this.debounceTimers.set(queryHash, timer);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Materialize a query's result rows from the local DB. For a windowed query
|
|
359
|
+
// (`LIMIT n START m`, m>0) the original surql is NOT re-run — re-applying
|
|
360
|
+
// `START m` against the shared local store would skip the window's own rows
|
|
361
|
+
// (sparse windowing) and return nothing. Instead we select the window's
|
|
362
|
+
// record id-set directly, preferring the server's `list_ref` (`remoteArray`,
|
|
363
|
+
// authoritative) over the in-browser SSP's view (`sspArray`), and re-apply
|
|
364
|
+
// the original ORDER BY for stable order.
|
|
365
|
+
private async materializeRecords(
|
|
366
|
+
queryState: QueryState,
|
|
367
|
+
sspArray?: Array<[string, number]>
|
|
368
|
+
): Promise<Record<string, any>[]> {
|
|
369
|
+
const t0 = performance.now();
|
|
370
|
+
const windowMat = buildWindowMaterialization(queryState.config.surql);
|
|
371
|
+
let records: Record<string, any>[];
|
|
372
|
+
if (windowMat) {
|
|
373
|
+
const win =
|
|
374
|
+
(queryState.config.remoteArray?.length && queryState.config.remoteArray) ||
|
|
375
|
+
(sspArray?.length && sspArray) ||
|
|
376
|
+
queryState.config.localArray ||
|
|
377
|
+
[];
|
|
378
|
+
const winIds = win.map(([id]) => parseRecordIdString(id));
|
|
379
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
|
|
380
|
+
...queryState.config.params,
|
|
381
|
+
__win: winIds,
|
|
382
|
+
});
|
|
383
|
+
records = rows || [];
|
|
384
|
+
} else {
|
|
385
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(
|
|
386
|
+
queryState.config.surql,
|
|
387
|
+
queryState.config.params
|
|
388
|
+
);
|
|
389
|
+
records = rows || [];
|
|
232
390
|
}
|
|
391
|
+
// Local SurrealDB record-fetch time → DevTools "localFetch" phase.
|
|
392
|
+
this.recordPhase(queryState, 'localFetch', performance.now() - t0);
|
|
393
|
+
return records;
|
|
233
394
|
}
|
|
234
395
|
|
|
235
396
|
private async processStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
@@ -253,17 +414,24 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
253
414
|
}
|
|
254
415
|
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
255
416
|
}
|
|
417
|
+
// Record the SSP internal sub-phase timings (from the WASM binding) so
|
|
418
|
+
// DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
|
|
419
|
+
if (typeof update.storeApplyMs === 'number')
|
|
420
|
+
this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
|
|
421
|
+
if (typeof update.circuitStepMs === 'number')
|
|
422
|
+
this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
|
|
423
|
+
if (typeof update.transformMs === 'number')
|
|
424
|
+
this.recordPhase(queryState, 'sspTransform', update.transformMs);
|
|
256
425
|
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
257
426
|
|
|
258
427
|
try {
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const newRecords = records || [];
|
|
428
|
+
// Materialize the query's rows. For a windowed (offset) query, re-running
|
|
429
|
+
// the original surql would re-apply `START n` against the shared local DB
|
|
430
|
+
// and skip the window's rows entirely; instead select the SSP's
|
|
431
|
+
// materialized window id-set (`localArray`) directly, re-applying the
|
|
432
|
+
// original ORDER BY for stable display order. Non-offset queries keep the
|
|
433
|
+
// normal re-query path.
|
|
434
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
267
435
|
queryState.config.localArray = localArray;
|
|
268
436
|
|
|
269
437
|
const prevJson = JSON.stringify(queryState.records);
|
|
@@ -321,7 +489,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
321
489
|
this.logger.debug(
|
|
322
490
|
{
|
|
323
491
|
queryHash,
|
|
324
|
-
recordCount:
|
|
492
|
+
recordCount: newRecords?.length,
|
|
325
493
|
Category: 'sp00ky-client::DataModule::onStreamUpdate',
|
|
326
494
|
},
|
|
327
495
|
'Query updated from stream'
|
|
@@ -373,6 +541,51 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
373
541
|
return { p55: pick(0.55), p90: pick(0.90), p99: pick(0.99) };
|
|
374
542
|
}
|
|
375
543
|
|
|
544
|
+
/** Record a per-phase timing sample (ms) on a query's rolling window. */
|
|
545
|
+
private recordPhase(qs: QueryState, phase: string, ms: number): void {
|
|
546
|
+
if (!Number.isFinite(ms)) return;
|
|
547
|
+
const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
|
|
548
|
+
pushSample(arr, ms);
|
|
549
|
+
qs.phaseLast[phase] = ms;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
|
|
553
|
+
recordRemoteFetch(hash: string, ms: number): void {
|
|
554
|
+
const qs = this.activeQueries.get(hash);
|
|
555
|
+
if (qs) this.recordPhase(qs, 'remoteFetch', ms);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Record the frontend reconcile time (ms) for a query. Called from `useQuery`
|
|
560
|
+
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
561
|
+
*/
|
|
562
|
+
recordFrontendTiming(hash: string, ms: number): void {
|
|
563
|
+
const qs = this.activeQueries.get(hash);
|
|
564
|
+
if (qs) this.recordPhase(qs, 'frontend', ms);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Build the per-query processing-time breakdown surfaced to the DevTools panel
|
|
569
|
+
* and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
|
|
570
|
+
* the rest come from the per-phase rolling windows + one-shot registration timings.
|
|
571
|
+
*/
|
|
572
|
+
phaseTimings(q: QueryState): QueryTimings {
|
|
573
|
+
const stat = (phase: string) =>
|
|
574
|
+
phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
|
|
575
|
+
return {
|
|
576
|
+
ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
|
|
577
|
+
sspStoreApply: stat('sspStoreApply'),
|
|
578
|
+
sspCircuitStep: stat('sspCircuitStep'),
|
|
579
|
+
sspTransform: stat('sspTransform'),
|
|
580
|
+
localFetch: stat('localFetch'),
|
|
581
|
+
remoteFetch: stat('remoteFetch'),
|
|
582
|
+
frontend: stat('frontend'),
|
|
583
|
+
registration: q.registrationTimings,
|
|
584
|
+
updateCount: q.updateCount,
|
|
585
|
+
errorCount: q.errorCount,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
376
589
|
/**
|
|
377
590
|
* Get query state (for sync and devtools)
|
|
378
591
|
*/
|
|
@@ -380,6 +593,99 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
380
593
|
return this.activeQueries.get(hash);
|
|
381
594
|
}
|
|
382
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
598
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
599
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
600
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
601
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
602
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
603
|
+
*/
|
|
604
|
+
isCold(hash: string): boolean {
|
|
605
|
+
const qs = this.activeQueries.get(hash);
|
|
606
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
611
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
612
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
613
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
614
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
615
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
616
|
+
*/
|
|
617
|
+
async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
|
|
618
|
+
const queryState = this.activeQueries.get(hash);
|
|
619
|
+
if (!queryState) return;
|
|
620
|
+
queryState.hydrated = true; // run-once, even when the remote returns nothing
|
|
621
|
+
if (rows.length === 0) return;
|
|
622
|
+
|
|
623
|
+
const tableName = queryState.config.tableName;
|
|
624
|
+
const batch: CacheRecord[] = rows.map((record) => ({
|
|
625
|
+
table: tableName,
|
|
626
|
+
op: 'CREATE' as const,
|
|
627
|
+
record,
|
|
628
|
+
version: (record._00_rv as number) || 1,
|
|
629
|
+
}));
|
|
630
|
+
await this.cache.saveBatch(batch);
|
|
631
|
+
|
|
632
|
+
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
|
633
|
+
// prefers it for windowed queries (correct window) and it feeds the version
|
|
634
|
+
// dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
|
|
635
|
+
queryState.config.remoteArray = rows.map(
|
|
636
|
+
(r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
640
|
+
const subscribers = this.subscriptions.get(hash);
|
|
641
|
+
if (subscribers) {
|
|
642
|
+
for (const cb of subscribers) cb(queryState.records);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
647
|
+
hasSubscribers(hash: string): boolean {
|
|
648
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
653
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
654
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
655
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
656
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
657
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
658
|
+
*
|
|
659
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
660
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
661
|
+
*/
|
|
662
|
+
deregisterQuery(hash: string): void {
|
|
663
|
+
if (this.hasSubscribers(hash)) return;
|
|
664
|
+
if (!this.activeQueries.has(hash)) return;
|
|
665
|
+
this.onDeregister?.(hash);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
670
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
671
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
672
|
+
*/
|
|
673
|
+
finalizeDeregister(hash: string): void {
|
|
674
|
+
const qs = this.activeQueries.get(hash);
|
|
675
|
+
if (qs?.ttlTimer) {
|
|
676
|
+
clearTimeout(qs.ttlTimer);
|
|
677
|
+
qs.ttlTimer = null;
|
|
678
|
+
}
|
|
679
|
+
const debounce = this.debounceTimers.get(hash);
|
|
680
|
+
if (debounce) {
|
|
681
|
+
clearTimeout(debounce);
|
|
682
|
+
this.debounceTimers.delete(hash);
|
|
683
|
+
}
|
|
684
|
+
this.cache.unregisterQuery(hash);
|
|
685
|
+
this.activeQueries.delete(hash);
|
|
686
|
+
this.subscriptions.delete(hash);
|
|
687
|
+
}
|
|
688
|
+
|
|
383
689
|
/**
|
|
384
690
|
* Get query state by id (for sync and devtools)
|
|
385
691
|
*/
|
|
@@ -438,12 +744,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
438
744
|
const queryState = this.activeQueries.get(queryHash);
|
|
439
745
|
if (!queryState) return;
|
|
440
746
|
|
|
441
|
-
// Re-query local DB for latest data
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
);
|
|
446
|
-
const newRecords = records || [];
|
|
747
|
+
// Re-query local DB for latest data (windowed queries materialize from the
|
|
748
|
+
// list_ref window so they resolve even if the in-browser SSP never emits —
|
|
749
|
+
// it can't compute a high offset whose preceding rows aren't resident).
|
|
750
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
447
751
|
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
448
752
|
queryState.records = newRecords;
|
|
449
753
|
|
|
@@ -692,13 +996,37 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
692
996
|
);
|
|
693
997
|
|
|
694
998
|
await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
|
|
695
|
-
await this.cache.delete(table, id, true, beforeRecord);
|
|
696
999
|
|
|
697
|
-
//
|
|
698
|
-
//
|
|
1000
|
+
// The local DELETE has now committed. Everything below must reflect that in
|
|
1001
|
+
// active live queries — so the deleted row disappears optimistically without
|
|
1002
|
+
// a reload — even if the optimistic SSP-view ingest below fails. Previously a
|
|
1003
|
+
// throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
|
|
1004
|
+
// commit, so the manual notify loop never ran and the row lingered on screen
|
|
1005
|
+
// until reload. Ingesting the delete into the in-browser SSP view is
|
|
1006
|
+
// best-effort: the manual re-materialize reads the local DB (which already
|
|
1007
|
+
// excludes the row), so the result is correct regardless.
|
|
1008
|
+
try {
|
|
1009
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
this.logger.error(
|
|
1012
|
+
{ err, id, Category: 'sp00ky-client::DataModule::delete' },
|
|
1013
|
+
'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// DBSP may not emit view updates for DELETE ops — manually notify all queries
|
|
1018
|
+
// that reference this table. Each is isolated so one failing re-materialize
|
|
1019
|
+
// can't stop the others (or the sync emit below) from running.
|
|
699
1020
|
for (const [queryHash, queryState] of this.activeQueries) {
|
|
700
1021
|
if (queryState.config.tableName === tableName) {
|
|
701
|
-
|
|
1022
|
+
try {
|
|
1023
|
+
await this.notifyQuerySynced(queryHash);
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
this.logger.error(
|
|
1026
|
+
{ err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
|
|
1027
|
+
'notifyQuerySynced failed after delete'
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
702
1030
|
}
|
|
703
1031
|
}
|
|
704
1032
|
|
|
@@ -835,7 +1163,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
835
1163
|
});
|
|
836
1164
|
|
|
837
1165
|
const t0 = performance.now();
|
|
838
|
-
const { localArray } = this.cache.registerQuery({
|
|
1166
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
839
1167
|
queryHash: hash,
|
|
840
1168
|
surql: surqlString,
|
|
841
1169
|
params,
|
|
@@ -844,6 +1172,15 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
844
1172
|
});
|
|
845
1173
|
const registrationTime = performance.now() - t0;
|
|
846
1174
|
|
|
1175
|
+
// Record the one-shot SSP registration timings (parse/plan/snapshot from the
|
|
1176
|
+
// WASM binding) + the register_view wall time for DevTools.
|
|
1177
|
+
queryState.registrationTimings = {
|
|
1178
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
1179
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
1180
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
1181
|
+
wallMs: registrationTime,
|
|
1182
|
+
};
|
|
1183
|
+
|
|
847
1184
|
await withRetry(this.logger, () =>
|
|
848
1185
|
this.local.query(
|
|
849
1186
|
surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
|
|
@@ -856,8 +1193,31 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
856
1193
|
)
|
|
857
1194
|
);
|
|
858
1195
|
|
|
1196
|
+
// Windowed (`START n`) queries skipped the raw initial load in
|
|
1197
|
+
// createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
|
|
1198
|
+
// initial rows now from the SSP's window id-set (`localArray`) via the same
|
|
1199
|
+
// window-materialization path the stream updates use — O(window), and the
|
|
1200
|
+
// ids are already the correct window — so the first paint isn't empty while
|
|
1201
|
+
// the remote `_00_list_ref` syncs in.
|
|
1202
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
1203
|
+
if (windowMat && localArray.length > 0) {
|
|
1204
|
+
try {
|
|
1205
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
1206
|
+
const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
|
|
1207
|
+
...params,
|
|
1208
|
+
__win: winIds,
|
|
1209
|
+
});
|
|
1210
|
+
queryState.records = seeded || [];
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
this.logger.warn(
|
|
1213
|
+
{ err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
|
|
1214
|
+
'Failed to seed windowed initial records from localArray'
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
859
1219
|
this.activeQueries.set(hash, queryState);
|
|
860
|
-
this.startTTLHeartbeat(queryState);
|
|
1220
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
861
1221
|
this.logger.debug(
|
|
862
1222
|
{
|
|
863
1223
|
hash,
|
|
@@ -924,14 +1284,21 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
924
1284
|
};
|
|
925
1285
|
|
|
926
1286
|
let records: Record<string, any>[] = [];
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1287
|
+
// Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
|
|
1288
|
+
// `… LIMIT n START m` against the shared local store is O(m) — it sorts and
|
|
1289
|
+
// skips m rows on every window open — AND returns the wrong rows for sparse
|
|
1290
|
+
// windows (the reason `buildWindowMaterialization` exists). Those windows are
|
|
1291
|
+
// seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
|
|
1292
|
+
if (buildWindowMaterialization(surqlString) === null) {
|
|
1293
|
+
try {
|
|
1294
|
+
const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
|
|
1295
|
+
records = result || [];
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
this.logger.warn(
|
|
1298
|
+
{ err, Category: 'sp00ky-client::DataModule::createNewQuery' },
|
|
1299
|
+
'Failed to load initial cached records'
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
935
1302
|
}
|
|
936
1303
|
|
|
937
1304
|
// Persisted counters survive a restart even though the rolling
|
|
@@ -954,6 +1321,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
954
1321
|
materializationSamples: [],
|
|
955
1322
|
lastIngestLatencyMs: null,
|
|
956
1323
|
errorCount: persistedErrorCount,
|
|
1324
|
+
status: 'idle',
|
|
1325
|
+
phaseSamples: {},
|
|
1326
|
+
phaseLast: {},
|
|
1327
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
957
1328
|
};
|
|
958
1329
|
}
|
|
959
1330
|
|
|
@@ -968,31 +1339,40 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
968
1339
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
969
1340
|
}
|
|
970
1341
|
|
|
971
|
-
private startTTLHeartbeat(queryState: QueryState): void {
|
|
1342
|
+
private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
|
|
972
1343
|
if (queryState.ttlTimer) return;
|
|
973
1344
|
|
|
974
1345
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
|
|
975
1346
|
|
|
976
1347
|
queryState.ttlTimer = setTimeout(() => {
|
|
977
|
-
|
|
1348
|
+
queryState.ttlTimer = null;
|
|
1349
|
+
// Only keep the remote query alive while something is actually watching
|
|
1350
|
+
// it. With the server now sweeping ALL expired views (not just in-circuit
|
|
1351
|
+
// ones), an un-refreshed query WOULD be swept after its TTL — so a live
|
|
1352
|
+
// subscriber must heartbeat. An abandoned query (no subscribers) is left
|
|
1353
|
+
// to expire and get swept; its local view was already torn down at the
|
|
1354
|
+
// last unsubscribe, so we just stop the timer here.
|
|
1355
|
+
const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
|
|
1356
|
+
if (subscriberCount === 0) {
|
|
1357
|
+
this.logger.debug(
|
|
1358
|
+
{ hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
|
|
1359
|
+
'TTL heartbeat: no subscribers, stopping'
|
|
1360
|
+
);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
this.onHeartbeat?.(hash);
|
|
978
1364
|
this.logger.debug(
|
|
979
1365
|
{
|
|
1366
|
+
hash,
|
|
980
1367
|
id: encodeRecordId(queryState.config.id),
|
|
981
1368
|
Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
|
|
982
1369
|
},
|
|
983
|
-
'TTL heartbeat'
|
|
1370
|
+
'TTL heartbeat sent'
|
|
984
1371
|
);
|
|
985
|
-
this.startTTLHeartbeat(queryState);
|
|
1372
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
986
1373
|
}, heartbeatTime);
|
|
987
1374
|
}
|
|
988
1375
|
|
|
989
|
-
private stopTTLHeartbeat(queryState: QueryState): void {
|
|
990
|
-
if (queryState.ttlTimer) {
|
|
991
|
-
clearTimeout(queryState.ttlTimer);
|
|
992
|
-
queryState.ttlTimer = null;
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
1376
|
private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
|
|
997
1377
|
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
998
1378
|
const index = queryState.records.findIndex((r) => r.id === record.id);
|