@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.71

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 (64) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +299 -369
  3. package/dist/index.js +2278 -399
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +460 -0
  7. package/package.json +37 -7
  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 +3 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +17 -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 +662 -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/ref-tables.test.ts +56 -0
  30. package/src/modules/ref-tables.ts +57 -0
  31. package/src/modules/sync/engine.ts +97 -37
  32. package/src/modules/sync/events/index.ts +3 -2
  33. package/src/modules/sync/queue/queue-down.ts +5 -4
  34. package/src/modules/sync/queue/queue-up.ts +14 -13
  35. package/src/modules/sync/scheduler.ts +2 -2
  36. package/src/modules/sync/sync.ts +553 -58
  37. package/src/modules/sync/utils.test.ts +239 -2
  38. package/src/modules/sync/utils.ts +166 -17
  39. package/src/otel/index.ts +127 -0
  40. package/src/services/database/database.ts +11 -11
  41. package/src/services/database/events/index.ts +2 -1
  42. package/src/services/database/local-migrator.ts +21 -21
  43. package/src/services/database/local.test.ts +33 -0
  44. package/src/services/database/local.ts +192 -32
  45. package/src/services/database/remote.ts +13 -13
  46. package/src/services/logger/index.ts +6 -101
  47. package/src/services/persistence/localstorage.ts +2 -2
  48. package/src/services/persistence/resilient.ts +41 -0
  49. package/src/services/persistence/surrealdb.ts +9 -9
  50. package/src/services/stream-processor/index.ts +205 -36
  51. package/src/services/stream-processor/permissions.test.ts +47 -0
  52. package/src/services/stream-processor/permissions.ts +53 -0
  53. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  54. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  55. package/src/services/stream-processor/wasm-types.ts +18 -2
  56. package/src/sp00ky.auth-order.test.ts +65 -0
  57. package/src/sp00ky.ts +582 -0
  58. package/src/types.ts +132 -13
  59. package/src/utils/index.ts +35 -13
  60. package/src/utils/parser.ts +3 -2
  61. package/src/utils/surql.ts +24 -15
  62. package/src/utils/withRetry.test.ts +1 -1
  63. package/tsdown.config.ts +55 -1
  64. 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,
@@ -34,8 +38,25 @@ import {
34
38
  extractTablePart,
35
39
  generateId,
36
40
  } from '../../utils/index';
37
- import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
38
- import { PushEventOptions } from '../../events/index';
41
+ import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
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
+ }
39
60
 
40
61
  /**
41
62
  * DataModule - Unified query and mutation management
@@ -47,9 +68,47 @@ export class DataModule<S extends SchemaStructure> {
47
68
  private activeQueries: Map<QueryHash, QueryState> = new Map();
48
69
  private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
49
70
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
71
+ private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
50
72
  private mutationCallbacks: Set<MutationCallback> = new Set();
51
73
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
52
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;
101
+ // Salt for query-id hashing. Set from SurrealDB's session::id() so two
102
+ // browser sessions registering the same logical query (same surql + params)
103
+ // don't collide on the same `_00_query` row — each session gets its own.
104
+ // Empty string until init(sessionId) is called.
105
+ private sessionId: string = '';
106
+ // Authenticated user record id (e.g. `"user:abc"`). Updated by
107
+ // `setCurrentUserId` from the auth subscription. null when
108
+ // unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
109
+ // poll and LIVE subscription target the same per-user
110
+ // `_00_list_ref_user_<id>` table the sync engine writes to.
111
+ private currentUserId: string | null = null;
53
112
 
54
113
  constructor(
55
114
  private cache: CacheModule,
@@ -61,8 +120,38 @@ export class DataModule<S extends SchemaStructure> {
61
120
  this.logger = logger.child({ service: 'DataModule' });
62
121
  }
63
122
 
64
- async init(): Promise<void> {
65
- this.logger.info({ Category: 'spooky-client::DataModule::init' }, 'DataModule initialized');
123
+ async init(sessionId: string): Promise<void> {
124
+ this.sessionId = sessionId;
125
+ this.logger.info(
126
+ { sessionId, Category: 'sp00ky-client::DataModule::init' },
127
+ 'DataModule initialized'
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Update the session salt used in query-id hashing. Call this when the
133
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
134
+ * registered queries will get fresh, session-scoped IDs.
135
+ */
136
+ setSessionId(sessionId: string): void {
137
+ this.sessionId = sessionId;
138
+ }
139
+
140
+ /**
141
+ * Update the authenticated user record id. Pass `null` on sign-out.
142
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
143
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
144
+ * SSP writes to.
145
+ */
146
+ setCurrentUserId(userId: string | null): void {
147
+ this.currentUserId = userId;
148
+ }
149
+
150
+ /** Read-only view of the authenticated user id used for per-user
151
+ * `_00_list_ref` routing. Other modules consult this so they pick the
152
+ * same table name DataModule does. */
153
+ getCurrentUserId(): string | null {
154
+ return this.currentUserId;
66
155
  }
67
156
 
68
157
  // ==================== QUERY MANAGEMENT ====================
@@ -78,15 +167,17 @@ export class DataModule<S extends SchemaStructure> {
78
167
  ): Promise<QueryHash> {
79
168
  const hash = await this.calculateHash({ surql: surqlString, params });
80
169
  this.logger.debug(
81
- { hash, Category: 'spooky-client::DataModule::query' },
170
+ { hash, Category: 'sp00ky-client::DataModule::query' },
82
171
  'Query Initialization: started'
83
172
  );
84
173
 
85
- const recordId = new RecordId('_spooky_query', hash);
174
+ // `_00_query` stays the single shared registration table in both
175
+ // ref-modes; the per-user split happens only on `_00_list_ref`.
176
+ const recordId = new RecordId('_00_query', hash);
86
177
 
87
178
  if (this.activeQueries.has(hash)) {
88
179
  this.logger.debug(
89
- { hash, Category: 'spooky-client::DataModule::query' },
180
+ { hash, Category: 'sp00ky-client::DataModule::query' },
90
181
  'Query Initialization: exists, returning'
91
182
  );
92
183
  return hash;
@@ -95,7 +186,7 @@ export class DataModule<S extends SchemaStructure> {
95
186
  // Another call is already creating this query — wait for it
96
187
  if (this.pendingQueries.has(hash)) {
97
188
  this.logger.debug(
98
- { hash, Category: 'spooky-client::DataModule::query' },
189
+ { hash, Category: 'sp00ky-client::DataModule::query' },
99
190
  'Query Initialization: pending, waiting for existing creation'
100
191
  );
101
192
  await this.pendingQueries.get(hash);
@@ -103,7 +194,7 @@ export class DataModule<S extends SchemaStructure> {
103
194
  }
104
195
 
105
196
  this.logger.debug(
106
- { hash, Category: 'spooky-client::DataModule::query' },
197
+ { hash, Category: 'sp00ky-client::DataModule::query' },
107
198
  'Query Initialization: not found, creating new query'
108
199
  );
109
200
 
@@ -147,11 +238,70 @@ export class DataModule<S extends SchemaStructure> {
147
238
  subs.delete(callback);
148
239
  if (subs.size === 0) {
149
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);
150
278
  }
151
279
  }
152
280
  };
153
281
  }
154
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
+
155
305
  /**
156
306
  * Subscribe to mutations (for sync)
157
307
  */
@@ -168,67 +318,166 @@ export class DataModule<S extends SchemaStructure> {
168
318
  async onStreamUpdate(update: StreamUpdate): Promise<void> {
169
319
  const { queryHash, op } = update;
170
320
 
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)!);
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);
177
338
  }
339
+ await this.processStreamUpdate(update);
340
+ return;
341
+ }
178
342
 
179
- // Set new timer
180
- const timer = setTimeout(async () => {
181
- this.debounceTimers.delete(queryHash);
182
- await this.processStreamUpdate(update);
183
- }, 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
+ }
184
348
 
185
- this.debounceTimers.set(queryHash, timer);
186
- } else {
187
- // CREATE and DELETE - process immediately
349
+ // Set new timer
350
+ const timer = setTimeout(async () => {
351
+ this.debounceTimers.delete(queryHash);
188
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 || [];
189
390
  }
391
+ // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
392
+ this.recordPhase(queryState, 'localFetch', performance.now() - t0);
393
+ return records;
190
394
  }
191
395
 
192
396
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
193
- const { queryHash, localArray } = update;
397
+ const { queryHash, localArray, materializationTimeMs } = update;
194
398
  const queryState = this.activeQueries.get(queryHash);
195
399
  if (!queryState) {
196
400
  this.logger.warn(
197
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
401
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
198
402
  'Received update for unknown query. Skipping...'
199
403
  );
200
404
  return;
201
405
  }
202
406
 
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
- );
407
+ // Update the rolling materialization-sample window before the work that
408
+ // could throw, so the percentiles still move when the downstream local
409
+ // query fails (the materialization step itself ran).
410
+ if (typeof materializationTimeMs === 'number') {
411
+ queryState.materializationSamples.push(materializationTimeMs);
412
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
413
+ queryState.materializationSamples.shift();
414
+ }
415
+ queryState.lastIngestLatencyMs = materializationTimeMs;
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);
425
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
209
426
 
210
- // Update state
211
- const newRecords = records || [];
427
+ try {
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);
212
435
  queryState.config.localArray = localArray;
213
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
214
- id: queryState.config.id,
215
- localArray,
216
- });
217
436
 
218
- // Skip notification if records haven't changed
219
437
  const prevJson = JSON.stringify(queryState.records);
220
438
  const newJson = JSON.stringify(newRecords);
221
439
  queryState.records = newRecords;
222
- if (prevJson === newJson) {
440
+ const recordsChanged = prevJson !== newJson;
441
+
442
+ // updateCount counts user-visible updates (matches the prior semantic),
443
+ // while the materialization sample/percentiles already moved above for
444
+ // every observed engine step.
445
+ if (recordsChanged) {
446
+ queryState.updateCount++;
447
+ }
448
+
449
+ await this.local.query(
450
+ surql.seal(
451
+ surql.updateSet('id', [
452
+ 'localArray',
453
+ 'rowCount',
454
+ 'updateCount',
455
+ 'lastIngestLatency',
456
+ 'materializationP55',
457
+ 'materializationP90',
458
+ 'materializationP99',
459
+ ])
460
+ ),
461
+ {
462
+ id: queryState.config.id,
463
+ localArray,
464
+ rowCount: localArray.length,
465
+ updateCount: queryState.updateCount,
466
+ lastIngestLatency: queryState.lastIngestLatencyMs,
467
+ materializationP55: percentiles.p55,
468
+ materializationP90: percentiles.p90,
469
+ materializationP99: percentiles.p99,
470
+ }
471
+ );
472
+
473
+ if (!recordsChanged) {
223
474
  this.logger.debug(
224
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
475
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
225
476
  'Query records unchanged, skipping notification'
226
477
  );
227
478
  return;
228
479
  }
229
480
 
230
- queryState.updateCount++;
231
-
232
481
  // Notify subscribers
233
482
  const subscribers = this.subscriptions.get(queryHash);
234
483
  if (subscribers) {
@@ -240,19 +489,103 @@ export class DataModule<S extends SchemaStructure> {
240
489
  this.logger.debug(
241
490
  {
242
491
  queryHash,
243
- recordCount: records?.length,
244
- Category: 'spooky-client::DataModule::onStreamUpdate',
492
+ recordCount: newRecords?.length,
493
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
245
494
  },
246
495
  'Query updated from stream'
247
496
  );
248
497
  } catch (err) {
498
+ queryState.errorCount++;
249
499
  this.logger.error(
250
- { err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
500
+ { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
251
501
  'Failed to fetch records for stream update'
252
502
  );
503
+ // Best-effort persist of the bumped errorCount; swallow secondary
504
+ // failures to avoid masking the original error in logs.
505
+ try {
506
+ await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
507
+ id: queryState.config.id,
508
+ errorCount: queryState.errorCount,
509
+ });
510
+ } catch (persistErr) {
511
+ this.logger.warn(
512
+ {
513
+ err: persistErr,
514
+ queryHash,
515
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
516
+ },
517
+ 'Failed to persist incremented errorCount'
518
+ );
519
+ }
253
520
  }
254
521
  }
255
522
 
523
+ /**
524
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
525
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
526
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
527
+ */
528
+ private computeMaterializationPercentiles(samples: number[]): {
529
+ p55: number | null;
530
+ p90: number | null;
531
+ p99: number | null;
532
+ } {
533
+ if (samples.length === 0) {
534
+ return { p55: null, p90: null, p99: null };
535
+ }
536
+ const sorted = [...samples].sort((a, b) => a - b);
537
+ const pick = (q: number) => {
538
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
539
+ return sorted[idx]!;
540
+ };
541
+ return { p55: pick(0.55), p90: pick(0.90), p99: pick(0.99) };
542
+ }
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
+
256
589
  /**
257
590
  * Get query state (for sync and devtools)
258
591
  */
@@ -260,6 +593,99 @@ export class DataModule<S extends SchemaStructure> {
260
593
  return this.activeQueries.get(hash);
261
594
  }
262
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
+
263
689
  /**
264
690
  * Get query state by id (for sync and devtools)
265
691
  */
@@ -274,11 +700,15 @@ export class DataModule<S extends SchemaStructure> {
274
700
  return Array.from(this.activeQueries.values());
275
701
  }
276
702
 
703
+ getActiveQueryHashes(): QueryHash[] {
704
+ return Array.from(this.activeQueries.keys());
705
+ }
706
+
277
707
  async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
278
708
  const queryState = this.activeQueries.get(id);
279
709
  if (!queryState) {
280
710
  this.logger.warn(
281
- { id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
711
+ { id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
282
712
  'Query to update local array not found'
283
713
  );
284
714
  return;
@@ -294,7 +724,7 @@ export class DataModule<S extends SchemaStructure> {
294
724
  const queryState = this.getQueryByHash(hash);
295
725
  if (!queryState) {
296
726
  this.logger.warn(
297
- { hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
727
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
298
728
  'Query to update remote array not found'
299
729
  );
300
730
  return;
@@ -314,12 +744,10 @@ export class DataModule<S extends SchemaStructure> {
314
744
  const queryState = this.activeQueries.get(queryHash);
315
745
  if (!queryState) return;
316
746
 
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 || [];
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);
323
751
  const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
324
752
  queryState.records = newRecords;
325
753
 
@@ -370,6 +798,10 @@ export class DataModule<S extends SchemaStructure> {
370
798
  retry_strategy: options?.retry_strategy ?? 'linear',
371
799
  };
372
800
 
801
+ if (options?.timeout != null) {
802
+ record.timeout = options.timeout;
803
+ }
804
+
373
805
  if (options?.assignedTo) {
374
806
  record.assigned_to = options.assignedTo;
375
807
  }
@@ -392,7 +824,7 @@ export class DataModule<S extends SchemaStructure> {
392
824
 
393
825
  const rid = parseRecordIdString(id);
394
826
  const params = parseParams(tableSchema.columns, data);
395
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
827
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
396
828
 
397
829
  const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
398
830
  const prefixedParams = Object.fromEntries(
@@ -441,7 +873,7 @@ export class DataModule<S extends SchemaStructure> {
441
873
  callback([mutationEvent]);
442
874
  }
443
875
 
444
- this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
876
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
445
877
 
446
878
  return target;
447
879
  }
@@ -463,7 +895,10 @@ export class DataModule<S extends SchemaStructure> {
463
895
 
464
896
  const rid = parseRecordIdString(id);
465
897
  const params = parseParams(tableSchema.columns, data);
466
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
898
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
899
+
900
+ // Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
901
+ // NOT through the record update pipeline. This keeps the record data clean.
467
902
 
468
903
  // Capture current record state before mutation for rollback support
469
904
  const [beforeRecord] = await withRetry(this.logger, () =>
@@ -472,7 +907,7 @@ export class DataModule<S extends SchemaStructure> {
472
907
 
473
908
  const query = surql.seal<{ target: T }>(
474
909
  surql.tx([
475
- surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
910
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
476
911
  surql.let('updated', surql.updateMerge('id', 'data')),
477
912
  surql.createMutation('update', 'mid', 'id', 'data'),
478
913
  surql.returnObject([{ key: 'target', variable: 'updated' }]),
@@ -496,8 +931,8 @@ export class DataModule<S extends SchemaStructure> {
496
931
  updatedFields[key] = (target as Record<string, any>)[key];
497
932
  }
498
933
  }
499
- if ('spooky_rv' in (target as Record<string, any>)) {
500
- updatedFields.spooky_rv = (target as Record<string, any>).spooky_rv;
934
+ if ('_00_rv' in (target as Record<string, any>)) {
935
+ updatedFields._00_rv = (target as Record<string, any>)._00_rv;
501
936
  }
502
937
  this.replaceRecordInQueries(updatedFields);
503
938
 
@@ -509,7 +944,7 @@ export class DataModule<S extends SchemaStructure> {
509
944
  table: table,
510
945
  op: 'UPDATE',
511
946
  record: parsedRecord,
512
- version: target.spooky_rv as number,
947
+ version: target._00_rv as number,
513
948
  },
514
949
  true
515
950
  );
@@ -531,7 +966,7 @@ export class DataModule<S extends SchemaStructure> {
531
966
  callback([mutationEvent]);
532
967
  }
533
968
 
534
- this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
969
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
535
970
 
536
971
  return target;
537
972
  }
@@ -547,14 +982,53 @@ export class DataModule<S extends SchemaStructure> {
547
982
  }
548
983
 
549
984
  const rid = parseRecordIdString(id);
550
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
985
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
986
+
987
+ // Fetch the record before deleting so DBSP can match it against query predicates
988
+ const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
989
+ 'SELECT * FROM ONLY $id',
990
+ { id: rid }
991
+ );
992
+ const beforeRecord = beforeRecords ?? {};
551
993
 
552
994
  const query = surql.seal<void>(
553
995
  surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
554
996
  );
555
997
 
556
998
  await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
557
- await this.cache.delete(table, id, true);
999
+
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.
1020
+ for (const [queryHash, queryState] of this.activeQueries) {
1021
+ if (queryState.config.tableName === tableName) {
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
+ }
1030
+ }
1031
+ }
558
1032
 
559
1033
  // Emit mutation event
560
1034
  const mutationEvent: DeleteEvent = {
@@ -567,7 +1041,7 @@ export class DataModule<S extends SchemaStructure> {
567
1041
  callback([mutationEvent]);
568
1042
  }
569
1043
 
570
- this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
1044
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
571
1045
  }
572
1046
 
573
1047
  // ==================== ROLLBACK METHODS ====================
@@ -586,12 +1060,12 @@ export class DataModule<S extends SchemaStructure> {
586
1060
  this.removeRecordFromQueries(recordId);
587
1061
 
588
1062
  this.logger.info(
589
- { id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1063
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
590
1064
  'Rolled back optimistic create'
591
1065
  );
592
1066
  } catch (err) {
593
1067
  this.logger.error(
594
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1068
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
595
1069
  'Failed to rollback create'
596
1070
  );
597
1071
  }
@@ -626,7 +1100,7 @@ export class DataModule<S extends SchemaStructure> {
626
1100
  table: tableName,
627
1101
  op: 'UPDATE',
628
1102
  record: parsedRecord,
629
- version: (beforeRecord.spooky_rv as number) || 1,
1103
+ version: (beforeRecord._00_rv as number) || 1,
630
1104
  },
631
1105
  true
632
1106
  );
@@ -635,12 +1109,12 @@ export class DataModule<S extends SchemaStructure> {
635
1109
  await this.replaceRecordInQueries(beforeRecord);
636
1110
 
637
1111
  this.logger.info(
638
- { id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1112
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
639
1113
  'Rolled back optimistic update'
640
1114
  );
641
1115
  } catch (err) {
642
1116
  this.logger.error(
643
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1117
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
644
1118
  'Failed to rollback update'
645
1119
  );
646
1120
  }
@@ -688,29 +1162,68 @@ export class DataModule<S extends SchemaStructure> {
688
1162
  tableName,
689
1163
  });
690
1164
 
691
- const { localArray } = this.cache.registerQuery({
1165
+ const t0 = performance.now();
1166
+ const { localArray, registrationTimings } = this.cache.registerQuery({
692
1167
  queryHash: hash,
693
1168
  surql: surqlString,
694
1169
  params,
695
1170
  ttl: new Duration(ttl),
696
1171
  lastActiveAt: new Date(),
697
1172
  });
1173
+ const registrationTime = performance.now() - t0;
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
+ };
698
1183
 
699
1184
  await withRetry(this.logger, () =>
700
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
701
- id: recordId,
702
- localArray,
703
- })
1185
+ this.local.query(
1186
+ surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
1187
+ {
1188
+ id: recordId,
1189
+ localArray,
1190
+ registrationTime,
1191
+ rowCount: localArray.length,
1192
+ }
1193
+ )
704
1194
  );
705
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
+
706
1219
  this.activeQueries.set(hash, queryState);
707
- this.startTTLHeartbeat(queryState);
1220
+ this.startTTLHeartbeat(queryState, hash);
708
1221
  this.logger.debug(
709
1222
  {
710
1223
  hash,
711
1224
  tableName,
712
1225
  recordCount: queryState.records.length,
713
- Category: 'spooky-client::DataModule::query',
1226
+ Category: 'sp00ky-client::DataModule::query',
714
1227
  },
715
1228
  'Query registered'
716
1229
  );
@@ -752,8 +1265,12 @@ export class DataModule<S extends SchemaStructure> {
752
1265
  localArray: [],
753
1266
  remoteArray: [],
754
1267
  lastActiveAt: new Date(),
1268
+ createdAt: new Date(),
755
1269
  ttl,
756
1270
  tableName,
1271
+ updateCount: 0,
1272
+ rowCount: 0,
1273
+ errorCount: 0,
757
1274
  },
758
1275
  })
759
1276
  );
@@ -767,58 +1284,95 @@ export class DataModule<S extends SchemaStructure> {
767
1284
  };
768
1285
 
769
1286
  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
- );
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
+ }
778
1302
  }
779
1303
 
1304
+ // Persisted counters survive a restart even though the rolling
1305
+ // sample window is rebuilt from scratch in memory.
1306
+ const persistedUpdateCount =
1307
+ typeof (configRecord as any)?.updateCount === 'number'
1308
+ ? (configRecord as any).updateCount
1309
+ : 0;
1310
+ const persistedErrorCount =
1311
+ typeof (configRecord as any)?.errorCount === 'number'
1312
+ ? (configRecord as any).errorCount
1313
+ : 0;
1314
+
780
1315
  return {
781
1316
  config,
782
1317
  records,
783
1318
  ttlTimer: null,
784
1319
  ttlDurationMs: parseDuration(ttl),
785
- updateCount: 0,
1320
+ updateCount: persistedUpdateCount,
1321
+ materializationSamples: [],
1322
+ lastIngestLatencyMs: null,
1323
+ errorCount: persistedErrorCount,
1324
+ status: 'idle',
1325
+ phaseSamples: {},
1326
+ phaseLast: {},
1327
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
786
1328
  };
787
1329
  }
788
1330
 
789
1331
  private async calculateHash(data: any): Promise<string> {
790
- const content = JSON.stringify(data);
1332
+ // sessionId is part of the hash so the same logical query from two
1333
+ // sessions (e.g. two browser tabs of the same user) lands on different
1334
+ // `_00_query` rows and doesn't fight over a shared one.
1335
+ const content = JSON.stringify({ ...data, sessionId: this.sessionId });
791
1336
  const msgBuffer = new TextEncoder().encode(content);
792
1337
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
793
1338
  const hashArray = Array.from(new Uint8Array(hashBuffer));
794
1339
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
795
1340
  }
796
1341
 
797
- private startTTLHeartbeat(queryState: QueryState): void {
1342
+ private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
798
1343
  if (queryState.ttlTimer) return;
799
1344
 
800
1345
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
801
1346
 
802
1347
  queryState.ttlTimer = setTimeout(() => {
803
- // 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);
804
1364
  this.logger.debug(
805
1365
  {
1366
+ hash,
806
1367
  id: encodeRecordId(queryState.config.id),
807
- Category: 'spooky-client::DataModule::startTTLHeartbeat',
1368
+ Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
808
1369
  },
809
- 'TTL heartbeat'
1370
+ 'TTL heartbeat sent'
810
1371
  );
811
- this.startTTLHeartbeat(queryState);
1372
+ this.startTTLHeartbeat(queryState, hash);
812
1373
  }, heartbeatTime);
813
1374
  }
814
1375
 
815
- private stopTTLHeartbeat(queryState: QueryState): void {
816
- if (queryState.ttlTimer) {
817
- clearTimeout(queryState.ttlTimer);
818
- queryState.ttlTimer = null;
819
- }
820
- }
821
-
822
1376
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
823
1377
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
824
1378
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -851,7 +1405,7 @@ export function parseUpdateOptions(
851
1405
  const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
852
1406
  const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
853
1407
  const key =
854
- keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
1408
+ keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
855
1409
 
856
1410
  pushEventOptions = {
857
1411
  debounced: {