@spooky-sync/core 0.0.1-canary.9 → 0.0.1-canary.90

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