@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.
@@ -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
- // Only debounce UPDATE operations
214
- // CREATE and DELETE should propagate immediately
215
- if (op === 'UPDATE') {
216
- // Clear existing timer if any
217
- if (this.debounceTimers.has(queryHash)) {
218
- // oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
219
- clearTimeout(this.debounceTimers.get(queryHash)!);
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
- // Set new timer
223
- const timer = setTimeout(async () => {
224
- this.debounceTimers.delete(queryHash);
225
- await this.processStreamUpdate(update);
226
- }, this.streamDebounceTime);
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
- this.debounceTimers.set(queryHash, timer);
229
- } else {
230
- // CREATE and DELETE - process immediately
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
- // Fetch updated records
260
- const [records] = await this.local.query<[Record<string, any>[]]>(
261
- queryState.config.surql,
262
- queryState.config.params
263
- );
264
-
265
- // Update state
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: records?.length,
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
- const [records] = await this.local.query<[Record<string, any>[]]>(
443
- queryState.config.surql,
444
- queryState.config.params
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
- // DBSP may not emit view updates for DELETE ops
698
- // manually notify all queries that reference this table
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
- await this.notifyQuerySynced(queryHash);
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
- try {
928
- const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
929
- records = result || [];
930
- } catch (err) {
931
- this.logger.warn(
932
- { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
933
- 'Failed to load initial cached records'
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
- // TODO: Emit heartbeat event for sync
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);