@spooky-sync/core 0.0.1-canary.8 → 0.0.1-canary.80

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.
Files changed (65) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +854 -339
  3. package/dist/index.js +2633 -396
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +494 -0
  7. package/package.json +39 -9
  8. package/skills/sp00ky-core/SKILL.md +258 -0
  9. package/skills/sp00ky-core/references/auth.md +98 -0
  10. package/skills/sp00ky-core/references/config.md +76 -0
  11. package/src/build-globals.d.ts +12 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +9 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +59 -20
  17. package/src/modules/cache/index.ts +41 -29
  18. package/src/modules/cache/types.ts +2 -2
  19. package/src/modules/crdt/crdt-field.ts +281 -0
  20. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  21. package/src/modules/crdt/index.ts +352 -0
  22. package/src/modules/data/data.status.test.ts +108 -0
  23. package/src/modules/data/index.ts +726 -108
  24. package/src/modules/data/window-query.test.ts +52 -0
  25. package/src/modules/data/window-query.ts +130 -0
  26. package/src/modules/devtools/index.ts +89 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/feature-flag/index.ts +166 -0
  30. package/src/modules/ref-tables.test.ts +56 -0
  31. package/src/modules/ref-tables.ts +57 -0
  32. package/src/modules/sync/engine.ts +97 -37
  33. package/src/modules/sync/events/index.ts +3 -2
  34. package/src/modules/sync/queue/queue-down.ts +5 -4
  35. package/src/modules/sync/queue/queue-up.ts +14 -13
  36. package/src/modules/sync/scheduler.ts +2 -2
  37. package/src/modules/sync/sync.ts +676 -58
  38. package/src/modules/sync/utils.test.ts +269 -2
  39. package/src/modules/sync/utils.ts +182 -17
  40. package/src/otel/index.ts +127 -0
  41. package/src/services/database/database.ts +11 -11
  42. package/src/services/database/events/index.ts +2 -1
  43. package/src/services/database/local-migrator.ts +21 -21
  44. package/src/services/database/local.test.ts +33 -0
  45. package/src/services/database/local.ts +192 -32
  46. package/src/services/database/remote.ts +13 -13
  47. package/src/services/logger/index.ts +6 -101
  48. package/src/services/persistence/localstorage.ts +2 -2
  49. package/src/services/persistence/resilient.ts +41 -0
  50. package/src/services/persistence/surrealdb.ts +9 -9
  51. package/src/services/stream-processor/index.ts +248 -37
  52. package/src/services/stream-processor/permissions.test.ts +47 -0
  53. package/src/services/stream-processor/permissions.ts +53 -0
  54. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  55. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  56. package/src/services/stream-processor/wasm-types.ts +18 -2
  57. package/src/sp00ky.auth-order.test.ts +65 -0
  58. package/src/sp00ky.ts +625 -0
  59. package/src/types.ts +140 -13
  60. package/src/utils/index.ts +35 -13
  61. package/src/utils/parser.ts +3 -2
  62. package/src/utils/surql.ts +24 -15
  63. package/src/utils/withRetry.test.ts +1 -1
  64. package/tsdown.config.ts +55 -1
  65. package/src/spooky.ts +0 -392
@@ -1,28 +1,32 @@
1
1
  import { RecordId, Duration } from 'surrealdb';
2
- import {
2
+ import type {
3
3
  SchemaStructure,
4
4
  TableNames,
5
5
  BackendNames,
6
6
  BackendRoutes,
7
7
  RoutePayload,
8
8
  } from '@spooky-sync/query-builder';
9
- import { LocalDatabaseService } from '../../services/database/index';
10
- import { CacheModule, RecordWithId } from '../cache/index';
11
- import { Logger } from '../../services/logger/index';
12
- import { StreamUpdate } from '../../services/stream-processor/index';
13
- import {
14
- MutationEvent,
9
+ import type { LocalDatabaseService } from '../../services/database/index';
10
+ import type { CacheModule, RecordWithId, CacheRecord } from '../cache/index';
11
+ import type { Logger } from '../../services/logger/index';
12
+ import type { StreamUpdate } from '../../services/stream-processor/index';
13
+ import type {
15
14
  QueryConfig,
16
15
  QueryHash,
17
16
  QueryState,
17
+ QueryStatus,
18
+ QueryStatusCallback,
18
19
  QueryTimeToLive,
19
20
  QueryUpdateCallback,
20
21
  MutationCallback,
21
22
  RecordVersionArray,
22
23
  QueryConfigRecord,
23
24
  UpdateOptions,
24
- RunOptions,
25
- } from '../../types';
25
+ QueryTimings,
26
+ PhaseStat,
27
+ RegistrationTimings,
28
+ RunOptions} from '../../types';
29
+ import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
26
30
  import {
27
31
  parseRecordIdString,
28
32
  extractIdPart,
@@ -31,11 +35,29 @@ import {
31
35
  withRetry,
32
36
  surql,
33
37
  parseParams,
38
+ cleanRecord,
34
39
  extractTablePart,
35
40
  generateId,
36
41
  } from '../../utils/index';
37
- import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
38
- import { PushEventOptions } from '../../events/index';
42
+ import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
43
+ import type { PushEventOptions } from '../../events/index';
44
+ import { buildWindowMaterialization } from './window-query';
45
+
46
+ /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
47
+ function pushSample(samples: number[], ms: number): void {
48
+ samples.push(ms);
49
+ if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
50
+ }
51
+
52
+ /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
53
+ function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
54
+ if (samples.length === 0) {
55
+ return { lastMs, p50: null, p90: null, p99: null, count: 0 };
56
+ }
57
+ const sorted = [...samples].sort((a, b) => a - b);
58
+ const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
59
+ return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
60
+ }
39
61
 
40
62
  /**
41
63
  * DataModule - Unified query and mutation management
@@ -47,9 +69,47 @@ export class DataModule<S extends SchemaStructure> {
47
69
  private activeQueries: Map<QueryHash, QueryState> = new Map();
48
70
  private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
49
71
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
72
+ private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
50
73
  private mutationCallbacks: Set<MutationCallback> = new Set();
51
74
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
52
75
  private logger: Logger;
76
+ /**
77
+ * Optional observer notified whenever a query's fetch status changes.
78
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
79
+ * settable field (rather than a constructor arg) because DevTools is
80
+ * constructed after DataModule.
81
+ */
82
+ public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
83
+ /**
84
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
85
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
86
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
87
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
88
+ * field (not a constructor arg) because the sync engine is wired after
89
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
90
+ */
91
+ public onHeartbeat?: (hash: QueryHash) => void;
92
+ /**
93
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
94
+ * viewport-windowed list cancelling an off-screen window) loses its last
95
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
96
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
97
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
98
+ * {@link finalizeDeregister} only after that remote delete, so a fast
99
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
100
+ */
101
+ public onDeregister?: (hash: QueryHash) => void;
102
+ // Salt for query-id hashing. Set from SurrealDB's session::id() so two
103
+ // browser sessions registering the same logical query (same surql + params)
104
+ // don't collide on the same `_00_query` row — each session gets its own.
105
+ // Empty string until init(sessionId) is called.
106
+ private sessionId: string = '';
107
+ // Authenticated user record id (e.g. `"user:abc"`). Updated by
108
+ // `setCurrentUserId` from the auth subscription. null when
109
+ // unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
110
+ // poll and LIVE subscription target the same per-user
111
+ // `_00_list_ref_user_<id>` table the sync engine writes to.
112
+ private currentUserId: string | null = null;
53
113
 
54
114
  constructor(
55
115
  private cache: CacheModule,
@@ -61,8 +121,38 @@ export class DataModule<S extends SchemaStructure> {
61
121
  this.logger = logger.child({ service: 'DataModule' });
62
122
  }
63
123
 
64
- async init(): Promise<void> {
65
- this.logger.info({ Category: 'spooky-client::DataModule::init' }, 'DataModule initialized');
124
+ async init(sessionId: string): Promise<void> {
125
+ this.sessionId = sessionId;
126
+ this.logger.info(
127
+ { sessionId, Category: 'sp00ky-client::DataModule::init' },
128
+ 'DataModule initialized'
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Update the session salt used in query-id hashing. Call this when the
134
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
135
+ * registered queries will get fresh, session-scoped IDs.
136
+ */
137
+ setSessionId(sessionId: string): void {
138
+ this.sessionId = sessionId;
139
+ }
140
+
141
+ /**
142
+ * Update the authenticated user record id. Pass `null` on sign-out.
143
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
144
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
145
+ * SSP writes to.
146
+ */
147
+ setCurrentUserId(userId: string | null): void {
148
+ this.currentUserId = userId;
149
+ }
150
+
151
+ /** Read-only view of the authenticated user id used for per-user
152
+ * `_00_list_ref` routing. Other modules consult this so they pick the
153
+ * same table name DataModule does. */
154
+ getCurrentUserId(): string | null {
155
+ return this.currentUserId;
66
156
  }
67
157
 
68
158
  // ==================== QUERY MANAGEMENT ====================
@@ -78,15 +168,17 @@ export class DataModule<S extends SchemaStructure> {
78
168
  ): Promise<QueryHash> {
79
169
  const hash = await this.calculateHash({ surql: surqlString, params });
80
170
  this.logger.debug(
81
- { hash, Category: 'spooky-client::DataModule::query' },
171
+ { hash, Category: 'sp00ky-client::DataModule::query' },
82
172
  'Query Initialization: started'
83
173
  );
84
174
 
85
- const recordId = new RecordId('_spooky_query', hash);
175
+ // `_00_query` stays the single shared registration table in both
176
+ // ref-modes; the per-user split happens only on `_00_list_ref`.
177
+ const recordId = new RecordId('_00_query', hash);
86
178
 
87
179
  if (this.activeQueries.has(hash)) {
88
180
  this.logger.debug(
89
- { hash, Category: 'spooky-client::DataModule::query' },
181
+ { hash, Category: 'sp00ky-client::DataModule::query' },
90
182
  'Query Initialization: exists, returning'
91
183
  );
92
184
  return hash;
@@ -95,7 +187,7 @@ export class DataModule<S extends SchemaStructure> {
95
187
  // Another call is already creating this query — wait for it
96
188
  if (this.pendingQueries.has(hash)) {
97
189
  this.logger.debug(
98
- { hash, Category: 'spooky-client::DataModule::query' },
190
+ { hash, Category: 'sp00ky-client::DataModule::query' },
99
191
  'Query Initialization: pending, waiting for existing creation'
100
192
  );
101
193
  await this.pendingQueries.get(hash);
@@ -103,7 +195,7 @@ export class DataModule<S extends SchemaStructure> {
103
195
  }
104
196
 
105
197
  this.logger.debug(
106
- { hash, Category: 'spooky-client::DataModule::query' },
198
+ { hash, Category: 'sp00ky-client::DataModule::query' },
107
199
  'Query Initialization: not found, creating new query'
108
200
  );
109
201
 
@@ -147,11 +239,70 @@ export class DataModule<S extends SchemaStructure> {
147
239
  subs.delete(callback);
148
240
  if (subs.size === 0) {
149
241
  this.subscriptions.delete(queryHash);
242
+ // NOTE: intentionally do NOT tear down the query / free its in-browser
243
+ // SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
244
+ // already self-stops once subscribers hit 0, so an abandoned query
245
+ // stops being kept alive and the server's TTL sweep removes it. Freeing
246
+ // the local view on every last-unsubscribe caused re-registration churn
247
+ // on navigation (open A → leave → open A again re-registers), which is
248
+ // a flakiness risk for no real benefit — keep the local view resident.
150
249
  }
151
250
  }
152
251
  };
153
252
  }
154
253
 
254
+ /**
255
+ * Subscribe to a query's fetch-status changes (idle/fetching).
256
+ * With `{ immediate: true }` the callback fires synchronously with the
257
+ * current status (defaults to `idle` if the query isn't registered yet).
258
+ */
259
+ subscribeStatus(
260
+ queryHash: string,
261
+ callback: QueryStatusCallback,
262
+ options: { immediate?: boolean } = {}
263
+ ): () => void {
264
+ if (!this.statusSubscriptions.has(queryHash)) {
265
+ this.statusSubscriptions.set(queryHash, new Set());
266
+ }
267
+ this.statusSubscriptions.get(queryHash)?.add(callback);
268
+
269
+ if (options.immediate) {
270
+ callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
271
+ }
272
+
273
+ return () => {
274
+ const subs = this.statusSubscriptions.get(queryHash);
275
+ if (subs) {
276
+ subs.delete(callback);
277
+ if (subs.size === 0) {
278
+ this.statusSubscriptions.delete(queryHash);
279
+ }
280
+ }
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Set a query's fetch status and notify status observers (DevTools +
286
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
287
+ * query is unknown.
288
+ */
289
+ setQueryStatus(queryHash: string, status: QueryStatus): void {
290
+ const queryState = this.activeQueries.get(queryHash);
291
+ if (!queryState || queryState.status === status) {
292
+ return;
293
+ }
294
+ queryState.status = status;
295
+
296
+ this.onQueryStatusChange?.(queryHash, status);
297
+
298
+ const subs = this.statusSubscriptions.get(queryHash);
299
+ if (subs) {
300
+ for (const callback of subs) {
301
+ callback(status);
302
+ }
303
+ }
304
+ }
305
+
155
306
  /**
156
307
  * Subscribe to mutations (for sync)
157
308
  */
@@ -168,67 +319,166 @@ export class DataModule<S extends SchemaStructure> {
168
319
  async onStreamUpdate(update: StreamUpdate): Promise<void> {
169
320
  const { queryHash, op } = update;
170
321
 
171
- // Only debounce UPDATE operations
172
- // CREATE and DELETE should propagate immediately
173
- if (op === 'UPDATE') {
174
- // Clear existing timer if any
175
- if (this.debounceTimers.has(queryHash)) {
176
- clearTimeout(this.debounceTimers.get(queryHash)!);
322
+ // DELETE propagates immediately — a removed row should disappear without
323
+ // waiting on a debounce.
324
+ //
325
+ // CREATE and UPDATE are coalesced per query on a trailing timer. A list's
326
+ // rows stream in from sync as many small `_00_list_ref` diffs, each its own
327
+ // `cache.saveBatch` → one stream update per chunk. `ingestMany` only
328
+ // coalesces records ingested synchronously together, so chunks spread over
329
+ // time each re-materialize and re-notify — a 50-row window can fire 30+
330
+ // updates as it fills. Each StreamUpdate carries the full materialized
331
+ // `localArray`, so the latest one already reflects every prior chunk: keep
332
+ // only it and fire once on the trailing edge, settling the query in a couple
333
+ // of notifications instead of one per chunk.
334
+ if (op === 'DELETE') {
335
+ const existing = this.debounceTimers.get(queryHash);
336
+ if (existing) {
337
+ clearTimeout(existing);
338
+ this.debounceTimers.delete(queryHash);
177
339
  }
340
+ await this.processStreamUpdate(update);
341
+ return;
342
+ }
178
343
 
179
- // Set new timer
180
- const timer = setTimeout(async () => {
181
- this.debounceTimers.delete(queryHash);
182
- await this.processStreamUpdate(update);
183
- }, this.streamDebounceTime);
344
+ // Clear existing timer if any
345
+ if (this.debounceTimers.has(queryHash)) {
346
+ // oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
347
+ clearTimeout(this.debounceTimers.get(queryHash)!);
348
+ }
184
349
 
185
- this.debounceTimers.set(queryHash, timer);
186
- } else {
187
- // CREATE and DELETE - process immediately
350
+ // Set new timer
351
+ const timer = setTimeout(async () => {
352
+ this.debounceTimers.delete(queryHash);
188
353
  await this.processStreamUpdate(update);
354
+ }, this.streamDebounceTime);
355
+
356
+ this.debounceTimers.set(queryHash, timer);
357
+ }
358
+
359
+ // Materialize a query's result rows from the local DB. For a windowed query
360
+ // (`LIMIT n START m`, m>0) the original surql is NOT re-run — re-applying
361
+ // `START m` against the shared local store would skip the window's own rows
362
+ // (sparse windowing) and return nothing. Instead we select the window's
363
+ // record id-set directly, preferring the server's `list_ref` (`remoteArray`,
364
+ // authoritative) over the in-browser SSP's view (`sspArray`), and re-apply
365
+ // the original ORDER BY for stable order.
366
+ private async materializeRecords(
367
+ queryState: QueryState,
368
+ sspArray?: Array<[string, number]>
369
+ ): Promise<Record<string, any>[]> {
370
+ const t0 = performance.now();
371
+ const windowMat = buildWindowMaterialization(queryState.config.surql);
372
+ let records: Record<string, any>[];
373
+ if (windowMat) {
374
+ const win =
375
+ (queryState.config.remoteArray?.length && queryState.config.remoteArray) ||
376
+ (sspArray?.length && sspArray) ||
377
+ queryState.config.localArray ||
378
+ [];
379
+ const winIds = win.map(([id]) => parseRecordIdString(id));
380
+ const [rows] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
381
+ ...queryState.config.params,
382
+ __win: winIds,
383
+ });
384
+ records = rows || [];
385
+ } else {
386
+ const [rows] = await this.local.query<[Record<string, any>[]]>(
387
+ queryState.config.surql,
388
+ queryState.config.params
389
+ );
390
+ records = rows || [];
189
391
  }
392
+ // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
393
+ this.recordPhase(queryState, 'localFetch', performance.now() - t0);
394
+ return records;
190
395
  }
191
396
 
192
397
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
193
- const { queryHash, localArray } = update;
398
+ const { queryHash, localArray, materializationTimeMs } = update;
194
399
  const queryState = this.activeQueries.get(queryHash);
195
400
  if (!queryState) {
196
401
  this.logger.warn(
197
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
402
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
198
403
  'Received update for unknown query. Skipping...'
199
404
  );
200
405
  return;
201
406
  }
202
407
 
203
- try {
204
- // Fetch updated records
205
- const [records] = await this.local.query<[Record<string, any>[]]>(
206
- queryState.config.surql,
207
- queryState.config.params
208
- );
408
+ // Update the rolling materialization-sample window before the work that
409
+ // could throw, so the percentiles still move when the downstream local
410
+ // query fails (the materialization step itself ran).
411
+ if (typeof materializationTimeMs === 'number') {
412
+ queryState.materializationSamples.push(materializationTimeMs);
413
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
414
+ queryState.materializationSamples.shift();
415
+ }
416
+ queryState.lastIngestLatencyMs = materializationTimeMs;
417
+ }
418
+ // Record the SSP internal sub-phase timings (from the WASM binding) so
419
+ // DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
420
+ if (typeof update.storeApplyMs === 'number')
421
+ this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
422
+ if (typeof update.circuitStepMs === 'number')
423
+ this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
424
+ if (typeof update.transformMs === 'number')
425
+ this.recordPhase(queryState, 'sspTransform', update.transformMs);
426
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
209
427
 
210
- // Update state
211
- const newRecords = records || [];
428
+ try {
429
+ // Materialize the query's rows. For a windowed (offset) query, re-running
430
+ // the original surql would re-apply `START n` against the shared local DB
431
+ // and skip the window's rows entirely; instead select the SSP's
432
+ // materialized window id-set (`localArray`) directly, re-applying the
433
+ // original ORDER BY for stable display order. Non-offset queries keep the
434
+ // normal re-query path.
435
+ const newRecords = await this.materializeRecords(queryState, localArray);
212
436
  queryState.config.localArray = localArray;
213
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
214
- id: queryState.config.id,
215
- localArray,
216
- });
217
437
 
218
- // Skip notification if records haven't changed
219
438
  const prevJson = JSON.stringify(queryState.records);
220
439
  const newJson = JSON.stringify(newRecords);
221
440
  queryState.records = newRecords;
222
- if (prevJson === newJson) {
441
+ const recordsChanged = prevJson !== newJson;
442
+
443
+ // updateCount counts user-visible updates (matches the prior semantic),
444
+ // while the materialization sample/percentiles already moved above for
445
+ // every observed engine step.
446
+ if (recordsChanged) {
447
+ queryState.updateCount++;
448
+ }
449
+
450
+ await this.local.query(
451
+ surql.seal(
452
+ surql.updateSet('id', [
453
+ 'localArray',
454
+ 'rowCount',
455
+ 'updateCount',
456
+ 'lastIngestLatency',
457
+ 'materializationP55',
458
+ 'materializationP90',
459
+ 'materializationP99',
460
+ ])
461
+ ),
462
+ {
463
+ id: queryState.config.id,
464
+ localArray,
465
+ rowCount: localArray.length,
466
+ updateCount: queryState.updateCount,
467
+ lastIngestLatency: queryState.lastIngestLatencyMs,
468
+ materializationP55: percentiles.p55,
469
+ materializationP90: percentiles.p90,
470
+ materializationP99: percentiles.p99,
471
+ }
472
+ );
473
+
474
+ if (!recordsChanged) {
223
475
  this.logger.debug(
224
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
476
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
225
477
  'Query records unchanged, skipping notification'
226
478
  );
227
479
  return;
228
480
  }
229
481
 
230
- queryState.updateCount++;
231
-
232
482
  // Notify subscribers
233
483
  const subscribers = this.subscriptions.get(queryHash);
234
484
  if (subscribers) {
@@ -240,19 +490,103 @@ export class DataModule<S extends SchemaStructure> {
240
490
  this.logger.debug(
241
491
  {
242
492
  queryHash,
243
- recordCount: records?.length,
244
- Category: 'spooky-client::DataModule::onStreamUpdate',
493
+ recordCount: newRecords?.length,
494
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
245
495
  },
246
496
  'Query updated from stream'
247
497
  );
248
498
  } catch (err) {
499
+ queryState.errorCount++;
249
500
  this.logger.error(
250
- { err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
501
+ { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
251
502
  'Failed to fetch records for stream update'
252
503
  );
504
+ // Best-effort persist of the bumped errorCount; swallow secondary
505
+ // failures to avoid masking the original error in logs.
506
+ try {
507
+ await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
508
+ id: queryState.config.id,
509
+ errorCount: queryState.errorCount,
510
+ });
511
+ } catch (persistErr) {
512
+ this.logger.warn(
513
+ {
514
+ err: persistErr,
515
+ queryHash,
516
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
517
+ },
518
+ 'Failed to persist incremented errorCount'
519
+ );
520
+ }
253
521
  }
254
522
  }
255
523
 
524
+ /**
525
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
526
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
527
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
528
+ */
529
+ private computeMaterializationPercentiles(samples: number[]): {
530
+ p55: number | null;
531
+ p90: number | null;
532
+ p99: number | null;
533
+ } {
534
+ if (samples.length === 0) {
535
+ return { p55: null, p90: null, p99: null };
536
+ }
537
+ const sorted = [...samples].sort((a, b) => a - b);
538
+ const pick = (q: number) => {
539
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
540
+ return sorted[idx]!;
541
+ };
542
+ return { p55: pick(0.55), p90: pick(0.90), p99: pick(0.99) };
543
+ }
544
+
545
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
546
+ private recordPhase(qs: QueryState, phase: string, ms: number): void {
547
+ if (!Number.isFinite(ms)) return;
548
+ const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
549
+ pushSample(arr, ms);
550
+ qs.phaseLast[phase] = ms;
551
+ }
552
+
553
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
554
+ recordRemoteFetch(hash: string, ms: number): void {
555
+ const qs = this.activeQueries.get(hash);
556
+ if (qs) this.recordPhase(qs, 'remoteFetch', ms);
557
+ }
558
+
559
+ /**
560
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
561
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
562
+ */
563
+ recordFrontendTiming(hash: string, ms: number): void {
564
+ const qs = this.activeQueries.get(hash);
565
+ if (qs) this.recordPhase(qs, 'frontend', ms);
566
+ }
567
+
568
+ /**
569
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
570
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
571
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
572
+ */
573
+ phaseTimings(q: QueryState): QueryTimings {
574
+ const stat = (phase: string) =>
575
+ phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
576
+ return {
577
+ ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
578
+ sspStoreApply: stat('sspStoreApply'),
579
+ sspCircuitStep: stat('sspCircuitStep'),
580
+ sspTransform: stat('sspTransform'),
581
+ localFetch: stat('localFetch'),
582
+ remoteFetch: stat('remoteFetch'),
583
+ frontend: stat('frontend'),
584
+ registration: q.registrationTimings,
585
+ updateCount: q.updateCount,
586
+ errorCount: q.errorCount,
587
+ };
588
+ }
589
+
256
590
  /**
257
591
  * Get query state (for sync and devtools)
258
592
  */
@@ -260,6 +594,162 @@ export class DataModule<S extends SchemaStructure> {
260
594
  return this.activeQueries.get(hash);
261
595
  }
262
596
 
597
+ /**
598
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
599
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
600
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
601
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
602
+ * but it still hasn't loaded its own full window from the server — so it should
603
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
604
+ */
605
+ isCold(hash: string): boolean {
606
+ const qs = this.activeQueries.get(hash);
607
+ return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
608
+ }
609
+
610
+ /**
611
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
612
+ * `batch` (recursing for nested related fields). An embedded child is a
613
+ * value that is itself a record — a non-null object whose `id` is a
614
+ * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
615
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
616
+ * so this never mistakes a FK column for an embedded body. Children are
617
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
618
+ * to their table's real columns (which strips the alias/related fields).
619
+ * `seen` dedupes within the batch.
620
+ */
621
+ private collectEmbeddedChildren(
622
+ record: Record<string, any>,
623
+ batch: CacheRecord[],
624
+ seen: Set<string>
625
+ ): void {
626
+ const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
627
+ !!v &&
628
+ typeof v === 'object' &&
629
+ !(v instanceof RecordId) &&
630
+ (v as { id?: unknown }).id instanceof RecordId;
631
+
632
+ for (const value of Object.values(record)) {
633
+ const children = Array.isArray(value)
634
+ ? value.filter(isEmbeddedRecord)
635
+ : isEmbeddedRecord(value)
636
+ ? [value]
637
+ : [];
638
+ for (const child of children) {
639
+ const key = encodeRecordId(child.id);
640
+ if (seen.has(key)) continue;
641
+ seen.add(key);
642
+ // Recurse FIRST so nested grandchildren are captured before `cleanRecord`
643
+ // strips this child's alias fields.
644
+ this.collectEmbeddedChildren(child, batch, seen);
645
+ const table = child.id.table.toString();
646
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
647
+ batch.push({
648
+ table,
649
+ op: 'CREATE',
650
+ record: (tableSchema
651
+ ? cleanRecord(tableSchema.columns, child)
652
+ : child) as RecordWithId,
653
+ version: (child._00_rv as number) || 1,
654
+ });
655
+ }
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
661
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
662
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
663
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
664
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
665
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
666
+ */
667
+ async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
668
+ const queryState = this.activeQueries.get(hash);
669
+ if (!queryState) return;
670
+ queryState.hydrated = true; // run-once, even when the remote returns nothing
671
+ if (rows.length === 0) return;
672
+
673
+ const tableName = queryState.config.tableName;
674
+ const batch: CacheRecord[] = rows.map((record) => ({
675
+ table: tableName,
676
+ op: 'CREATE' as const,
677
+ record,
678
+ version: (record._00_rv as number) || 1,
679
+ }));
680
+
681
+ // Flicker-free related data on cold first paint: a `.related()` query's
682
+ // rows arrive with their child rows EMBEDDED (the correlated subquery,
683
+ // e.g. `(SELECT * FROM comment WHERE game=$parent.id) AS comments`). The
684
+ // primary batch above persists only the parent table, so the immediate
685
+ // `materializeRecords` below would re-run the correlated surql against a
686
+ // local DB with no child rows and overwrite the embedded result with
687
+ // empties. Extract those embedded children (any nesting depth) and
688
+ // persist them as their own records so the re-materialization finds them.
689
+ const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
690
+ for (const record of rows) {
691
+ this.collectEmbeddedChildren(record, batch, seen);
692
+ }
693
+
694
+ await this.cache.saveBatch(batch);
695
+
696
+ // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
697
+ // prefers it for windowed queries (correct window) and it feeds the version
698
+ // dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
699
+ queryState.config.remoteArray = rows.map(
700
+ (r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
701
+ );
702
+
703
+ queryState.records = await this.materializeRecords(queryState);
704
+ const subscribers = this.subscriptions.get(hash);
705
+ if (subscribers) {
706
+ for (const cb of subscribers) cb(queryState.records);
707
+ }
708
+ }
709
+
710
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
711
+ hasSubscribers(hash: string): boolean {
712
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
713
+ }
714
+
715
+ /**
716
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
717
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
718
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
719
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
720
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
721
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
722
+ *
723
+ * NOTE: most queries should NOT use this — the default keep-alive on
724
+ * unsubscribe avoids re-registration churn on navigation.
725
+ */
726
+ deregisterQuery(hash: string): void {
727
+ if (this.hasSubscribers(hash)) return;
728
+ if (!this.activeQueries.has(hash)) return;
729
+ this.onDeregister?.(hash);
730
+ }
731
+
732
+ /**
733
+ * Final local teardown after the remote `_00_query` row was deleted: free the
734
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
735
+ * (`cleanupQuery`) guarantees no subscriber remains.
736
+ */
737
+ finalizeDeregister(hash: string): void {
738
+ const qs = this.activeQueries.get(hash);
739
+ if (qs?.ttlTimer) {
740
+ clearTimeout(qs.ttlTimer);
741
+ qs.ttlTimer = null;
742
+ }
743
+ const debounce = this.debounceTimers.get(hash);
744
+ if (debounce) {
745
+ clearTimeout(debounce);
746
+ this.debounceTimers.delete(hash);
747
+ }
748
+ this.cache.unregisterQuery(hash);
749
+ this.activeQueries.delete(hash);
750
+ this.subscriptions.delete(hash);
751
+ }
752
+
263
753
  /**
264
754
  * Get query state by id (for sync and devtools)
265
755
  */
@@ -274,11 +764,15 @@ export class DataModule<S extends SchemaStructure> {
274
764
  return Array.from(this.activeQueries.values());
275
765
  }
276
766
 
767
+ getActiveQueryHashes(): QueryHash[] {
768
+ return Array.from(this.activeQueries.keys());
769
+ }
770
+
277
771
  async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
278
772
  const queryState = this.activeQueries.get(id);
279
773
  if (!queryState) {
280
774
  this.logger.warn(
281
- { id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
775
+ { id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
282
776
  'Query to update local array not found'
283
777
  );
284
778
  return;
@@ -294,7 +788,7 @@ export class DataModule<S extends SchemaStructure> {
294
788
  const queryState = this.getQueryByHash(hash);
295
789
  if (!queryState) {
296
790
  this.logger.warn(
297
- { hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
791
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
298
792
  'Query to update remote array not found'
299
793
  );
300
794
  return;
@@ -314,12 +808,10 @@ export class DataModule<S extends SchemaStructure> {
314
808
  const queryState = this.activeQueries.get(queryHash);
315
809
  if (!queryState) return;
316
810
 
317
- // Re-query local DB for latest data
318
- const [records] = await this.local.query<[Record<string, any>[]]>(
319
- queryState.config.surql,
320
- queryState.config.params
321
- );
322
- const newRecords = records || [];
811
+ // Re-query local DB for latest data (windowed queries materialize from the
812
+ // list_ref window so they resolve even if the in-browser SSP never emits —
813
+ // it can't compute a high offset whose preceding rows aren't resident).
814
+ const newRecords = await this.materializeRecords(queryState);
323
815
  const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
324
816
  queryState.records = newRecords;
325
817
 
@@ -370,6 +862,10 @@ export class DataModule<S extends SchemaStructure> {
370
862
  retry_strategy: options?.retry_strategy ?? 'linear',
371
863
  };
372
864
 
865
+ if (options?.timeout != null) {
866
+ record.timeout = options.timeout;
867
+ }
868
+
373
869
  if (options?.assignedTo) {
374
870
  record.assigned_to = options.assignedTo;
375
871
  }
@@ -392,7 +888,7 @@ export class DataModule<S extends SchemaStructure> {
392
888
 
393
889
  const rid = parseRecordIdString(id);
394
890
  const params = parseParams(tableSchema.columns, data);
395
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
891
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
396
892
 
397
893
  const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
398
894
  const prefixedParams = Object.fromEntries(
@@ -441,7 +937,7 @@ export class DataModule<S extends SchemaStructure> {
441
937
  callback([mutationEvent]);
442
938
  }
443
939
 
444
- this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
940
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
445
941
 
446
942
  return target;
447
943
  }
@@ -463,7 +959,10 @@ export class DataModule<S extends SchemaStructure> {
463
959
 
464
960
  const rid = parseRecordIdString(id);
465
961
  const params = parseParams(tableSchema.columns, data);
466
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
962
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
963
+
964
+ // Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
965
+ // NOT through the record update pipeline. This keeps the record data clean.
467
966
 
468
967
  // Capture current record state before mutation for rollback support
469
968
  const [beforeRecord] = await withRetry(this.logger, () =>
@@ -472,7 +971,7 @@ export class DataModule<S extends SchemaStructure> {
472
971
 
473
972
  const query = surql.seal<{ target: T }>(
474
973
  surql.tx([
475
- surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
974
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
476
975
  surql.let('updated', surql.updateMerge('id', 'data')),
477
976
  surql.createMutation('update', 'mid', 'id', 'data'),
478
977
  surql.returnObject([{ key: 'target', variable: 'updated' }]),
@@ -496,8 +995,8 @@ export class DataModule<S extends SchemaStructure> {
496
995
  updatedFields[key] = (target as Record<string, any>)[key];
497
996
  }
498
997
  }
499
- if ('spooky_rv' in (target as Record<string, any>)) {
500
- updatedFields.spooky_rv = (target as Record<string, any>).spooky_rv;
998
+ if ('_00_rv' in (target as Record<string, any>)) {
999
+ updatedFields._00_rv = (target as Record<string, any>)._00_rv;
501
1000
  }
502
1001
  this.replaceRecordInQueries(updatedFields);
503
1002
 
@@ -509,7 +1008,7 @@ export class DataModule<S extends SchemaStructure> {
509
1008
  table: table,
510
1009
  op: 'UPDATE',
511
1010
  record: parsedRecord,
512
- version: target.spooky_rv as number,
1011
+ version: target._00_rv as number,
513
1012
  },
514
1013
  true
515
1014
  );
@@ -531,7 +1030,7 @@ export class DataModule<S extends SchemaStructure> {
531
1030
  callback([mutationEvent]);
532
1031
  }
533
1032
 
534
- this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
1033
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
535
1034
 
536
1035
  return target;
537
1036
  }
@@ -547,14 +1046,53 @@ export class DataModule<S extends SchemaStructure> {
547
1046
  }
548
1047
 
549
1048
  const rid = parseRecordIdString(id);
550
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1049
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1050
+
1051
+ // Fetch the record before deleting so DBSP can match it against query predicates
1052
+ const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
1053
+ 'SELECT * FROM ONLY $id',
1054
+ { id: rid }
1055
+ );
1056
+ const beforeRecord = beforeRecords ?? {};
551
1057
 
552
1058
  const query = surql.seal<void>(
553
1059
  surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
554
1060
  );
555
1061
 
556
1062
  await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
557
- await this.cache.delete(table, id, true);
1063
+
1064
+ // The local DELETE has now committed. Everything below must reflect that in
1065
+ // active live queries — so the deleted row disappears optimistically without
1066
+ // a reload — even if the optimistic SSP-view ingest below fails. Previously a
1067
+ // throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
1068
+ // commit, so the manual notify loop never ran and the row lingered on screen
1069
+ // until reload. Ingesting the delete into the in-browser SSP view is
1070
+ // best-effort: the manual re-materialize reads the local DB (which already
1071
+ // excludes the row), so the result is correct regardless.
1072
+ try {
1073
+ await this.cache.delete(table, id, true, beforeRecord);
1074
+ } catch (err) {
1075
+ this.logger.error(
1076
+ { err, id, Category: 'sp00ky-client::DataModule::delete' },
1077
+ 'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
1078
+ );
1079
+ }
1080
+
1081
+ // DBSP may not emit view updates for DELETE ops — manually notify all queries
1082
+ // that reference this table. Each is isolated so one failing re-materialize
1083
+ // can't stop the others (or the sync emit below) from running.
1084
+ for (const [queryHash, queryState] of this.activeQueries) {
1085
+ if (queryState.config.tableName === tableName) {
1086
+ try {
1087
+ await this.notifyQuerySynced(queryHash);
1088
+ } catch (err) {
1089
+ this.logger.error(
1090
+ { err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
1091
+ 'notifyQuerySynced failed after delete'
1092
+ );
1093
+ }
1094
+ }
1095
+ }
558
1096
 
559
1097
  // Emit mutation event
560
1098
  const mutationEvent: DeleteEvent = {
@@ -567,7 +1105,7 @@ export class DataModule<S extends SchemaStructure> {
567
1105
  callback([mutationEvent]);
568
1106
  }
569
1107
 
570
- this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
1108
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
571
1109
  }
572
1110
 
573
1111
  // ==================== ROLLBACK METHODS ====================
@@ -586,12 +1124,12 @@ export class DataModule<S extends SchemaStructure> {
586
1124
  this.removeRecordFromQueries(recordId);
587
1125
 
588
1126
  this.logger.info(
589
- { id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1127
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
590
1128
  'Rolled back optimistic create'
591
1129
  );
592
1130
  } catch (err) {
593
1131
  this.logger.error(
594
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1132
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
595
1133
  'Failed to rollback create'
596
1134
  );
597
1135
  }
@@ -626,7 +1164,7 @@ export class DataModule<S extends SchemaStructure> {
626
1164
  table: tableName,
627
1165
  op: 'UPDATE',
628
1166
  record: parsedRecord,
629
- version: (beforeRecord.spooky_rv as number) || 1,
1167
+ version: (beforeRecord._00_rv as number) || 1,
630
1168
  },
631
1169
  true
632
1170
  );
@@ -635,12 +1173,12 @@ export class DataModule<S extends SchemaStructure> {
635
1173
  await this.replaceRecordInQueries(beforeRecord);
636
1174
 
637
1175
  this.logger.info(
638
- { id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1176
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
639
1177
  'Rolled back optimistic update'
640
1178
  );
641
1179
  } catch (err) {
642
1180
  this.logger.error(
643
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1181
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
644
1182
  'Failed to rollback update'
645
1183
  );
646
1184
  }
@@ -688,29 +1226,68 @@ export class DataModule<S extends SchemaStructure> {
688
1226
  tableName,
689
1227
  });
690
1228
 
691
- const { localArray } = this.cache.registerQuery({
1229
+ const t0 = performance.now();
1230
+ const { localArray, registrationTimings } = this.cache.registerQuery({
692
1231
  queryHash: hash,
693
1232
  surql: surqlString,
694
1233
  params,
695
1234
  ttl: new Duration(ttl),
696
1235
  lastActiveAt: new Date(),
697
1236
  });
1237
+ const registrationTime = performance.now() - t0;
1238
+
1239
+ // Record the one-shot SSP registration timings (parse/plan/snapshot from the
1240
+ // WASM binding) + the register_view wall time for DevTools.
1241
+ queryState.registrationTimings = {
1242
+ parseMs: registrationTimings?.parseMs ?? null,
1243
+ planMs: registrationTimings?.planMs ?? null,
1244
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
1245
+ wallMs: registrationTime,
1246
+ };
698
1247
 
699
1248
  await withRetry(this.logger, () =>
700
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
701
- id: recordId,
702
- localArray,
703
- })
1249
+ this.local.query(
1250
+ surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
1251
+ {
1252
+ id: recordId,
1253
+ localArray,
1254
+ registrationTime,
1255
+ rowCount: localArray.length,
1256
+ }
1257
+ )
704
1258
  );
705
1259
 
1260
+ // Windowed (`START n`) queries skipped the raw initial load in
1261
+ // createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
1262
+ // initial rows now from the SSP's window id-set (`localArray`) via the same
1263
+ // window-materialization path the stream updates use — O(window), and the
1264
+ // ids are already the correct window — so the first paint isn't empty while
1265
+ // the remote `_00_list_ref` syncs in.
1266
+ const windowMat = buildWindowMaterialization(surqlString);
1267
+ if (windowMat && localArray.length > 0) {
1268
+ try {
1269
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
1270
+ const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
1271
+ ...params,
1272
+ __win: winIds,
1273
+ });
1274
+ queryState.records = seeded || [];
1275
+ } catch (err) {
1276
+ this.logger.warn(
1277
+ { err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
1278
+ 'Failed to seed windowed initial records from localArray'
1279
+ );
1280
+ }
1281
+ }
1282
+
706
1283
  this.activeQueries.set(hash, queryState);
707
- this.startTTLHeartbeat(queryState);
1284
+ this.startTTLHeartbeat(queryState, hash);
708
1285
  this.logger.debug(
709
1286
  {
710
1287
  hash,
711
1288
  tableName,
712
1289
  recordCount: queryState.records.length,
713
- Category: 'spooky-client::DataModule::query',
1290
+ Category: 'sp00ky-client::DataModule::query',
714
1291
  },
715
1292
  'Query registered'
716
1293
  );
@@ -752,8 +1329,12 @@ export class DataModule<S extends SchemaStructure> {
752
1329
  localArray: [],
753
1330
  remoteArray: [],
754
1331
  lastActiveAt: new Date(),
1332
+ createdAt: new Date(),
755
1333
  ttl,
756
1334
  tableName,
1335
+ updateCount: 0,
1336
+ rowCount: 0,
1337
+ errorCount: 0,
757
1338
  },
758
1339
  })
759
1340
  );
@@ -767,58 +1348,95 @@ export class DataModule<S extends SchemaStructure> {
767
1348
  };
768
1349
 
769
1350
  let records: Record<string, any>[] = [];
770
- try {
771
- const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
772
- records = result || [];
773
- } catch (err) {
774
- this.logger.warn(
775
- { err, Category: 'spooky-client::DataModule::createNewQuery' },
776
- 'Failed to load initial cached records'
777
- );
1351
+ // Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
1352
+ // `… LIMIT n START m` against the shared local store is O(m) — it sorts and
1353
+ // skips m rows on every window open — AND returns the wrong rows for sparse
1354
+ // windows (the reason `buildWindowMaterialization` exists). Those windows are
1355
+ // seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
1356
+ if (buildWindowMaterialization(surqlString) === null) {
1357
+ try {
1358
+ const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
1359
+ records = result || [];
1360
+ } catch (err) {
1361
+ this.logger.warn(
1362
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
1363
+ 'Failed to load initial cached records'
1364
+ );
1365
+ }
778
1366
  }
779
1367
 
1368
+ // Persisted counters survive a restart even though the rolling
1369
+ // sample window is rebuilt from scratch in memory.
1370
+ const persistedUpdateCount =
1371
+ typeof (configRecord as any)?.updateCount === 'number'
1372
+ ? (configRecord as any).updateCount
1373
+ : 0;
1374
+ const persistedErrorCount =
1375
+ typeof (configRecord as any)?.errorCount === 'number'
1376
+ ? (configRecord as any).errorCount
1377
+ : 0;
1378
+
780
1379
  return {
781
1380
  config,
782
1381
  records,
783
1382
  ttlTimer: null,
784
1383
  ttlDurationMs: parseDuration(ttl),
785
- updateCount: 0,
1384
+ updateCount: persistedUpdateCount,
1385
+ materializationSamples: [],
1386
+ lastIngestLatencyMs: null,
1387
+ errorCount: persistedErrorCount,
1388
+ status: 'idle',
1389
+ phaseSamples: {},
1390
+ phaseLast: {},
1391
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
786
1392
  };
787
1393
  }
788
1394
 
789
1395
  private async calculateHash(data: any): Promise<string> {
790
- const content = JSON.stringify(data);
1396
+ // sessionId is part of the hash so the same logical query from two
1397
+ // sessions (e.g. two browser tabs of the same user) lands on different
1398
+ // `_00_query` rows and doesn't fight over a shared one.
1399
+ const content = JSON.stringify({ ...data, sessionId: this.sessionId });
791
1400
  const msgBuffer = new TextEncoder().encode(content);
792
1401
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
793
1402
  const hashArray = Array.from(new Uint8Array(hashBuffer));
794
1403
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
795
1404
  }
796
1405
 
797
- private startTTLHeartbeat(queryState: QueryState): void {
1406
+ private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
798
1407
  if (queryState.ttlTimer) return;
799
1408
 
800
1409
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
801
1410
 
802
1411
  queryState.ttlTimer = setTimeout(() => {
803
- // TODO: Emit heartbeat event for sync
1412
+ queryState.ttlTimer = null;
1413
+ // Only keep the remote query alive while something is actually watching
1414
+ // it. With the server now sweeping ALL expired views (not just in-circuit
1415
+ // ones), an un-refreshed query WOULD be swept after its TTL — so a live
1416
+ // subscriber must heartbeat. An abandoned query (no subscribers) is left
1417
+ // to expire and get swept; its local view was already torn down at the
1418
+ // last unsubscribe, so we just stop the timer here.
1419
+ const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
1420
+ if (subscriberCount === 0) {
1421
+ this.logger.debug(
1422
+ { hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
1423
+ 'TTL heartbeat: no subscribers, stopping'
1424
+ );
1425
+ return;
1426
+ }
1427
+ this.onHeartbeat?.(hash);
804
1428
  this.logger.debug(
805
1429
  {
1430
+ hash,
806
1431
  id: encodeRecordId(queryState.config.id),
807
- Category: 'spooky-client::DataModule::startTTLHeartbeat',
1432
+ Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
808
1433
  },
809
- 'TTL heartbeat'
1434
+ 'TTL heartbeat sent'
810
1435
  );
811
- this.startTTLHeartbeat(queryState);
1436
+ this.startTTLHeartbeat(queryState, hash);
812
1437
  }, heartbeatTime);
813
1438
  }
814
1439
 
815
- private stopTTLHeartbeat(queryState: QueryState): void {
816
- if (queryState.ttlTimer) {
817
- clearTimeout(queryState.ttlTimer);
818
- queryState.ttlTimer = null;
819
- }
820
- }
821
-
822
1440
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
823
1441
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
824
1442
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -851,7 +1469,7 @@ export function parseUpdateOptions(
851
1469
  const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
852
1470
  const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
853
1471
  const key =
854
- keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
1472
+ keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
855
1473
 
856
1474
  pushEventOptions = {
857
1475
  debounced: {