@spooky-sync/core 0.0.1-canary.1 → 0.0.1-canary.100

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 (66) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +938 -339
  3. package/dist/index.js +2978 -423
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +548 -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 -1
  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 +848 -134
  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.test.ts +120 -0
  30. package/src/modules/feature-flag/index.ts +209 -0
  31. package/src/modules/ref-tables.test.ts +66 -0
  32. package/src/modules/ref-tables.ts +69 -0
  33. package/src/modules/sync/engine.ts +101 -37
  34. package/src/modules/sync/events/index.ts +9 -2
  35. package/src/modules/sync/queue/queue-down.ts +5 -4
  36. package/src/modules/sync/queue/queue-up.ts +14 -13
  37. package/src/modules/sync/scheduler.ts +40 -3
  38. package/src/modules/sync/sync.ts +896 -59
  39. package/src/modules/sync/utils.test.ts +269 -2
  40. package/src/modules/sync/utils.ts +182 -17
  41. package/src/otel/index.ts +127 -0
  42. package/src/services/database/database.ts +11 -11
  43. package/src/services/database/events/index.ts +2 -1
  44. package/src/services/database/local-migrator.ts +21 -21
  45. package/src/services/database/local.test.ts +33 -0
  46. package/src/services/database/local.ts +192 -32
  47. package/src/services/database/remote.ts +13 -13
  48. package/src/services/logger/index.ts +6 -101
  49. package/src/services/persistence/localstorage.ts +2 -2
  50. package/src/services/persistence/resilient.ts +41 -0
  51. package/src/services/persistence/surrealdb.ts +9 -9
  52. package/src/services/stream-processor/index.ts +248 -37
  53. package/src/services/stream-processor/permissions.test.ts +47 -0
  54. package/src/services/stream-processor/permissions.ts +53 -0
  55. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  56. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  57. package/src/services/stream-processor/wasm-types.ts +18 -2
  58. package/src/sp00ky.auth-order.test.ts +65 -0
  59. package/src/sp00ky.ts +648 -0
  60. package/src/types.ts +197 -15
  61. package/src/utils/index.ts +42 -13
  62. package/src/utils/parser.ts +3 -2
  63. package/src/utils/surql.ts +24 -15
  64. package/src/utils/withRetry.test.ts +1 -1
  65. package/tsdown.config.ts +55 -1
  66. package/src/spooky.ts +0 -346
@@ -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
@@ -45,23 +67,97 @@ import { PushEventOptions } from '../../events/index';
45
67
  */
46
68
  export class DataModule<S extends SchemaStructure> {
47
69
  private activeQueries: Map<QueryHash, QueryState> = new Map();
70
+ private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
48
71
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
72
+ private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
49
73
  private mutationCallbacks: Set<MutationCallback> = new Set();
50
74
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
51
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;
52
113
 
53
114
  constructor(
54
115
  private cache: CacheModule,
55
116
  private local: LocalDatabaseService,
56
117
  private schema: S,
57
118
  logger: Logger,
58
- 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
59
125
  ) {
60
126
  this.logger = logger.child({ service: 'DataModule' });
61
127
  }
62
128
 
63
- async init(): Promise<void> {
64
- 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;
65
161
  }
66
162
 
67
163
  // ==================== QUERY MANAGEMENT ====================
@@ -77,58 +173,45 @@ export class DataModule<S extends SchemaStructure> {
77
173
  ): Promise<QueryHash> {
78
174
  const hash = await this.calculateHash({ surql: surqlString, params });
79
175
  this.logger.debug(
80
- { hash, Category: 'spooky-client::DataModule::query' },
176
+ { hash, Category: 'sp00ky-client::DataModule::query' },
81
177
  'Query Initialization: started'
82
178
  );
83
179
 
84
- 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);
85
183
 
86
184
  if (this.activeQueries.has(hash)) {
87
185
  this.logger.debug(
88
- { hash, Category: 'spooky-client::DataModule::query' },
186
+ { hash, Category: 'sp00ky-client::DataModule::query' },
89
187
  'Query Initialization: exists, returning'
90
188
  );
91
189
  return hash;
92
190
  }
93
191
 
192
+ // Another call is already creating this query — wait for it
193
+ if (this.pendingQueries.has(hash)) {
194
+ this.logger.debug(
195
+ { hash, Category: 'sp00ky-client::DataModule::query' },
196
+ 'Query Initialization: pending, waiting for existing creation'
197
+ );
198
+ await this.pendingQueries.get(hash);
199
+ return hash;
200
+ }
201
+
94
202
  this.logger.debug(
95
- { hash, Category: 'spooky-client::DataModule::query' },
203
+ { hash, Category: 'sp00ky-client::DataModule::query' },
96
204
  'Query Initialization: not found, creating new query'
97
205
  );
98
- const queryState = await this.createNewQuery<T>({
99
- recordId,
100
- surql: surqlString,
101
- params,
102
- ttl,
103
- tableName,
104
- });
105
206
 
106
- const { localArray } = this.cache.registerQuery({
107
- queryHash: hash,
108
- surql: surqlString,
109
- params,
110
- ttl: new Duration(ttl),
111
- lastActiveAt: new Date(),
112
- });
113
-
114
- await withRetry(this.logger, () =>
115
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
116
- id: recordId,
117
- localArray,
118
- })
119
- );
120
-
121
- this.activeQueries.set(hash, queryState);
122
- this.startTTLHeartbeat(queryState);
123
- this.logger.debug(
124
- {
125
- hash,
126
- tableName,
127
- recordCount: queryState.records.length,
128
- Category: 'spooky-client::DataModule::query',
129
- },
130
- 'Query registered'
131
- );
207
+ // Create the query and track the pending promise
208
+ const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
209
+ this.pendingQueries.set(hash, promise);
210
+ try {
211
+ await promise;
212
+ } finally {
213
+ this.pendingQueries.delete(hash);
214
+ }
132
215
 
133
216
  return hash;
134
217
  }
@@ -161,11 +244,70 @@ export class DataModule<S extends SchemaStructure> {
161
244
  subs.delete(callback);
162
245
  if (subs.size === 0) {
163
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.
164
254
  }
165
255
  }
166
256
  };
167
257
  }
168
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
+
169
311
  /**
170
312
  * Subscribe to mutations (for sync)
171
313
  */
@@ -182,53 +324,165 @@ export class DataModule<S extends SchemaStructure> {
182
324
  async onStreamUpdate(update: StreamUpdate): Promise<void> {
183
325
  const { queryHash, op } = update;
184
326
 
185
- // Only debounce UPDATE operations
186
- // CREATE and DELETE should propagate immediately
187
- if (op === 'UPDATE') {
188
- // Clear existing timer if any
189
- if (this.debounceTimers.has(queryHash)) {
190
- 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);
191
344
  }
345
+ await this.processStreamUpdate(update);
346
+ return;
347
+ }
192
348
 
193
- // Set new timer
194
- const timer = setTimeout(async () => {
195
- this.debounceTimers.delete(queryHash);
196
- await this.processStreamUpdate(update);
197
- }, 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
+ }
198
354
 
199
- this.debounceTimers.set(queryHash, timer);
200
- } else {
201
- // CREATE and DELETE - process immediately
355
+ // Set new timer
356
+ const timer = setTimeout(async () => {
357
+ this.debounceTimers.delete(queryHash);
202
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 || [];
203
396
  }
397
+ // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
398
+ this.recordPhase(queryState, 'localFetch', performance.now() - t0);
399
+ return records;
204
400
  }
205
401
 
206
402
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
207
- const { queryHash, localArray } = update;
403
+ const { queryHash, localArray, materializationTimeMs } = update;
208
404
  const queryState = this.activeQueries.get(queryHash);
209
405
  if (!queryState) {
210
406
  this.logger.warn(
211
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
407
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
212
408
  'Received update for unknown query. Skipping...'
213
409
  );
214
410
  return;
215
411
  }
216
412
 
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);
432
+
217
433
  try {
218
- // Fetch updated records
219
- const [records] = await this.local.query<[Record<string, any>[]]>(
220
- queryState.config.surql,
221
- queryState.config.params
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);
441
+ queryState.config.localArray = localArray;
442
+
443
+ const prevJson = JSON.stringify(queryState.records);
444
+ const newJson = JSON.stringify(newRecords);
445
+ queryState.records = newRecords;
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
+ }
222
477
  );
223
478
 
224
- // Update state
225
- queryState.records = records || [];
226
- queryState.config.localArray = localArray;
227
- queryState.updateCount++;
228
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
229
- id: queryState.config.id,
230
- localArray,
231
- });
479
+ if (!recordsChanged) {
480
+ this.logger.debug(
481
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
482
+ 'Query records unchanged, skipping notification'
483
+ );
484
+ return;
485
+ }
232
486
 
233
487
  // Notify subscribers
234
488
  const subscribers = this.subscriptions.get(queryHash);
@@ -241,19 +495,103 @@ export class DataModule<S extends SchemaStructure> {
241
495
  this.logger.debug(
242
496
  {
243
497
  queryHash,
244
- recordCount: records?.length,
245
- Category: 'spooky-client::DataModule::onStreamUpdate',
498
+ recordCount: newRecords?.length,
499
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
246
500
  },
247
501
  'Query updated from stream'
248
502
  );
249
503
  } catch (err) {
504
+ queryState.errorCount++;
250
505
  this.logger.error(
251
- { err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
506
+ { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
252
507
  'Failed to fetch records for stream update'
253
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
+ }
254
526
  }
255
527
  }
256
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
+
257
595
  /**
258
596
  * Get query state (for sync and devtools)
259
597
  */
@@ -261,6 +599,162 @@ export class DataModule<S extends SchemaStructure> {
261
599
  return this.activeQueries.get(hash);
262
600
  }
263
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
+
264
758
  /**
265
759
  * Get query state by id (for sync and devtools)
266
760
  */
@@ -275,11 +769,15 @@ export class DataModule<S extends SchemaStructure> {
275
769
  return Array.from(this.activeQueries.values());
276
770
  }
277
771
 
772
+ getActiveQueryHashes(): QueryHash[] {
773
+ return Array.from(this.activeQueries.keys());
774
+ }
775
+
278
776
  async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
279
777
  const queryState = this.activeQueries.get(id);
280
778
  if (!queryState) {
281
779
  this.logger.warn(
282
- { id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
780
+ { id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
283
781
  'Query to update local array not found'
284
782
  );
285
783
  return;
@@ -295,7 +793,7 @@ export class DataModule<S extends SchemaStructure> {
295
793
  const queryState = this.getQueryByHash(hash);
296
794
  if (!queryState) {
297
795
  this.logger.warn(
298
- { hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
796
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
299
797
  'Query to update remote array not found'
300
798
  );
301
799
  return;
@@ -307,6 +805,34 @@ export class DataModule<S extends SchemaStructure> {
307
805
  });
308
806
  }
309
807
 
808
+ /**
809
+ * Called after a query's initial sync completes.
810
+ * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
811
+ */
812
+ async notifyQuerySynced(queryHash: string): Promise<void> {
813
+ const queryState = this.activeQueries.get(queryHash);
814
+ if (!queryState) return;
815
+
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);
820
+ const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
821
+ queryState.records = newRecords;
822
+
823
+ // Notify if data changed OR if this is the first sync (updateCount === 0)
824
+ // The latter handles "query truly has no results" so UI can stop loading
825
+ if (changed || queryState.updateCount === 0) {
826
+ queryState.updateCount++;
827
+ const subscribers = this.subscriptions.get(queryHash);
828
+ if (subscribers) {
829
+ for (const callback of subscribers) {
830
+ callback(queryState.records);
831
+ }
832
+ }
833
+ }
834
+ }
835
+
310
836
  // ==================== RUN JOBS ====================
311
837
 
312
838
  async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
@@ -341,6 +867,14 @@ export class DataModule<S extends SchemaStructure> {
341
867
  retry_strategy: options?.retry_strategy ?? 'linear',
342
868
  };
343
869
 
870
+ if (options?.timeout != null) {
871
+ record.timeout = options.timeout;
872
+ }
873
+
874
+ if (options?.delay != null) {
875
+ record.delay = options.delay;
876
+ }
877
+
344
878
  if (options?.assignedTo) {
345
879
  record.assigned_to = options.assignedTo;
346
880
  }
@@ -363,7 +897,7 @@ export class DataModule<S extends SchemaStructure> {
363
897
 
364
898
  const rid = parseRecordIdString(id);
365
899
  const params = parseParams(tableSchema.columns, data);
366
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
900
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
367
901
 
368
902
  const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
369
903
  const prefixedParams = Object.fromEntries(
@@ -412,7 +946,7 @@ export class DataModule<S extends SchemaStructure> {
412
946
  callback([mutationEvent]);
413
947
  }
414
948
 
415
- this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
949
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
416
950
 
417
951
  return target;
418
952
  }
@@ -434,7 +968,10 @@ export class DataModule<S extends SchemaStructure> {
434
968
 
435
969
  const rid = parseRecordIdString(id);
436
970
  const params = parseParams(tableSchema.columns, data);
437
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
971
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
972
+
973
+ // Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
974
+ // NOT through the record update pipeline. This keeps the record data clean.
438
975
 
439
976
  // Capture current record state before mutation for rollback support
440
977
  const [beforeRecord] = await withRetry(this.logger, () =>
@@ -443,7 +980,7 @@ export class DataModule<S extends SchemaStructure> {
443
980
 
444
981
  const query = surql.seal<{ target: T }>(
445
982
  surql.tx([
446
- surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
983
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
447
984
  surql.let('updated', surql.updateMerge('id', 'data')),
448
985
  surql.createMutation('update', 'mid', 'id', 'data'),
449
986
  surql.returnObject([{ key: 'target', variable: 'updated' }]),
@@ -458,10 +995,19 @@ export class DataModule<S extends SchemaStructure> {
458
995
  })
459
996
  );
460
997
 
461
- // Replace record in all queries directly
462
- // Does not respect sorting or other advanced query features
463
- // But is fast for quick typing for example
464
- this.replaceRecordInQueries(target);
998
+ // Build a partial record with only the fields the user actually changed
999
+ // This avoids overwriting rich relation objects (e.g. author: {id, name, ...})
1000
+ // with flat RecordIds from the UPDATE...MERGE result
1001
+ const updatedFields: Record<string, any> = { id: target.id };
1002
+ for (const key of Object.keys(data)) {
1003
+ if (key in target) {
1004
+ updatedFields[key] = (target as Record<string, any>)[key];
1005
+ }
1006
+ }
1007
+ if ('_00_rv' in (target as Record<string, any>)) {
1008
+ updatedFields._00_rv = (target as Record<string, any>)._00_rv;
1009
+ }
1010
+ this.replaceRecordInQueries(updatedFields);
465
1011
 
466
1012
  const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
467
1013
 
@@ -471,7 +1017,7 @@ export class DataModule<S extends SchemaStructure> {
471
1017
  table: table,
472
1018
  op: 'UPDATE',
473
1019
  record: parsedRecord,
474
- version: target.spooky_rv as number,
1020
+ version: target._00_rv as number,
475
1021
  },
476
1022
  true
477
1023
  );
@@ -493,7 +1039,7 @@ export class DataModule<S extends SchemaStructure> {
493
1039
  callback([mutationEvent]);
494
1040
  }
495
1041
 
496
- this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
1042
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
497
1043
 
498
1044
  return target;
499
1045
  }
@@ -509,14 +1055,53 @@ export class DataModule<S extends SchemaStructure> {
509
1055
  }
510
1056
 
511
1057
  const rid = parseRecordIdString(id);
512
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1058
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1059
+
1060
+ // Fetch the record before deleting so DBSP can match it against query predicates
1061
+ const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
1062
+ 'SELECT * FROM ONLY $id',
1063
+ { id: rid }
1064
+ );
1065
+ const beforeRecord = beforeRecords ?? {};
513
1066
 
514
1067
  const query = surql.seal<void>(
515
1068
  surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
516
1069
  );
517
1070
 
518
1071
  await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
519
- await this.cache.delete(table, id, true);
1072
+
1073
+ // The local DELETE has now committed. Everything below must reflect that in
1074
+ // active live queries — so the deleted row disappears optimistically without
1075
+ // a reload — even if the optimistic SSP-view ingest below fails. Previously a
1076
+ // throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
1077
+ // commit, so the manual notify loop never ran and the row lingered on screen
1078
+ // until reload. Ingesting the delete into the in-browser SSP view is
1079
+ // best-effort: the manual re-materialize reads the local DB (which already
1080
+ // excludes the row), so the result is correct regardless.
1081
+ try {
1082
+ await this.cache.delete(table, id, true, beforeRecord);
1083
+ } catch (err) {
1084
+ this.logger.error(
1085
+ { err, id, Category: 'sp00ky-client::DataModule::delete' },
1086
+ 'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
1087
+ );
1088
+ }
1089
+
1090
+ // DBSP may not emit view updates for DELETE ops — manually notify all queries
1091
+ // that reference this table. Each is isolated so one failing re-materialize
1092
+ // can't stop the others (or the sync emit below) from running.
1093
+ for (const [queryHash, queryState] of this.activeQueries) {
1094
+ if (queryState.config.tableName === tableName) {
1095
+ try {
1096
+ await this.notifyQuerySynced(queryHash);
1097
+ } catch (err) {
1098
+ this.logger.error(
1099
+ { err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
1100
+ 'notifyQuerySynced failed after delete'
1101
+ );
1102
+ }
1103
+ }
1104
+ }
520
1105
 
521
1106
  // Emit mutation event
522
1107
  const mutationEvent: DeleteEvent = {
@@ -529,7 +1114,7 @@ export class DataModule<S extends SchemaStructure> {
529
1114
  callback([mutationEvent]);
530
1115
  }
531
1116
 
532
- this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
1117
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
533
1118
  }
534
1119
 
535
1120
  // ==================== ROLLBACK METHODS ====================
@@ -548,12 +1133,12 @@ export class DataModule<S extends SchemaStructure> {
548
1133
  this.removeRecordFromQueries(recordId);
549
1134
 
550
1135
  this.logger.info(
551
- { id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1136
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
552
1137
  'Rolled back optimistic create'
553
1138
  );
554
1139
  } catch (err) {
555
1140
  this.logger.error(
556
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1141
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
557
1142
  'Failed to rollback create'
558
1143
  );
559
1144
  }
@@ -588,7 +1173,7 @@ export class DataModule<S extends SchemaStructure> {
588
1173
  table: tableName,
589
1174
  op: 'UPDATE',
590
1175
  record: parsedRecord,
591
- version: (beforeRecord.spooky_rv as number) || 1,
1176
+ version: (beforeRecord._00_rv as number) || 1,
592
1177
  },
593
1178
  true
594
1179
  );
@@ -597,12 +1182,12 @@ export class DataModule<S extends SchemaStructure> {
597
1182
  await this.replaceRecordInQueries(beforeRecord);
598
1183
 
599
1184
  this.logger.info(
600
- { id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1185
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
601
1186
  'Rolled back optimistic update'
602
1187
  );
603
1188
  } catch (err) {
604
1189
  this.logger.error(
605
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1190
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
606
1191
  'Failed to rollback update'
607
1192
  );
608
1193
  }
@@ -634,6 +1219,91 @@ export class DataModule<S extends SchemaStructure> {
634
1219
 
635
1220
  // ==================== PRIVATE HELPERS ====================
636
1221
 
1222
+ private async createAndRegisterQuery<T extends TableNames<S>>(
1223
+ hash: QueryHash,
1224
+ recordId: RecordId,
1225
+ surqlString: string,
1226
+ params: Record<string, any>,
1227
+ ttl: QueryTimeToLive,
1228
+ tableName: T
1229
+ ): Promise<QueryHash> {
1230
+ const queryState = await this.createNewQuery<T>({
1231
+ recordId,
1232
+ surql: surqlString,
1233
+ params,
1234
+ ttl,
1235
+ tableName,
1236
+ });
1237
+
1238
+ const t0 = performance.now();
1239
+ const { localArray, registrationTimings } = this.cache.registerQuery({
1240
+ queryHash: hash,
1241
+ surql: surqlString,
1242
+ params,
1243
+ ttl: new Duration(ttl),
1244
+ lastActiveAt: new Date(),
1245
+ });
1246
+ const registrationTime = performance.now() - t0;
1247
+
1248
+ // Record the one-shot SSP registration timings (parse/plan/snapshot from the
1249
+ // WASM binding) + the register_view wall time for DevTools.
1250
+ queryState.registrationTimings = {
1251
+ parseMs: registrationTimings?.parseMs ?? null,
1252
+ planMs: registrationTimings?.planMs ?? null,
1253
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
1254
+ wallMs: registrationTime,
1255
+ };
1256
+
1257
+ await withRetry(this.logger, () =>
1258
+ this.local.query(
1259
+ surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
1260
+ {
1261
+ id: recordId,
1262
+ localArray,
1263
+ registrationTime,
1264
+ rowCount: localArray.length,
1265
+ }
1266
+ )
1267
+ );
1268
+
1269
+ // Windowed (`START n`) queries skipped the raw initial load in
1270
+ // createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
1271
+ // initial rows now from the SSP's window id-set (`localArray`) via the same
1272
+ // window-materialization path the stream updates use — O(window), and the
1273
+ // ids are already the correct window — so the first paint isn't empty while
1274
+ // the remote `_00_list_ref` syncs in.
1275
+ const windowMat = buildWindowMaterialization(surqlString);
1276
+ if (windowMat && localArray.length > 0) {
1277
+ try {
1278
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
1279
+ const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
1280
+ ...params,
1281
+ __win: winIds,
1282
+ });
1283
+ queryState.records = seeded || [];
1284
+ } catch (err) {
1285
+ this.logger.warn(
1286
+ { err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
1287
+ 'Failed to seed windowed initial records from localArray'
1288
+ );
1289
+ }
1290
+ }
1291
+
1292
+ this.activeQueries.set(hash, queryState);
1293
+ this.startTTLHeartbeat(queryState, hash);
1294
+ this.logger.debug(
1295
+ {
1296
+ hash,
1297
+ tableName,
1298
+ recordCount: queryState.records.length,
1299
+ Category: 'sp00ky-client::DataModule::query',
1300
+ },
1301
+ 'Query registered'
1302
+ );
1303
+
1304
+ return hash;
1305
+ }
1306
+
637
1307
  private async createNewQuery<T extends TableNames<S>>({
638
1308
  recordId,
639
1309
  surql: surqlString,
@@ -668,8 +1338,12 @@ export class DataModule<S extends SchemaStructure> {
668
1338
  localArray: [],
669
1339
  remoteArray: [],
670
1340
  lastActiveAt: new Date(),
1341
+ createdAt: new Date(),
671
1342
  ttl,
672
1343
  tableName,
1344
+ updateCount: 0,
1345
+ rowCount: 0,
1346
+ errorCount: 0,
673
1347
  },
674
1348
  })
675
1349
  );
@@ -683,68 +1357,108 @@ export class DataModule<S extends SchemaStructure> {
683
1357
  };
684
1358
 
685
1359
  let records: Record<string, any>[] = [];
686
- try {
687
- const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
688
- records = result || [];
689
- } catch (err) {
690
- this.logger.warn(
691
- { err, Category: 'spooky-client::DataModule::createNewQuery' },
692
- 'Failed to load initial cached records'
693
- );
1360
+ // Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
1361
+ // `… LIMIT n START m` against the shared local store is O(m) — it sorts and
1362
+ // skips m rows on every window open — AND returns the wrong rows for sparse
1363
+ // windows (the reason `buildWindowMaterialization` exists). Those windows are
1364
+ // seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
1365
+ if (buildWindowMaterialization(surqlString) === null) {
1366
+ try {
1367
+ const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
1368
+ records = result || [];
1369
+ } catch (err) {
1370
+ this.logger.warn(
1371
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
1372
+ 'Failed to load initial cached records'
1373
+ );
1374
+ }
694
1375
  }
695
1376
 
1377
+ // Persisted counters survive a restart even though the rolling
1378
+ // sample window is rebuilt from scratch in memory.
1379
+ const persistedUpdateCount =
1380
+ typeof (configRecord as any)?.updateCount === 'number'
1381
+ ? (configRecord as any).updateCount
1382
+ : 0;
1383
+ const persistedErrorCount =
1384
+ typeof (configRecord as any)?.errorCount === 'number'
1385
+ ? (configRecord as any).errorCount
1386
+ : 0;
1387
+
696
1388
  return {
697
1389
  config,
698
1390
  records,
699
1391
  ttlTimer: null,
700
1392
  ttlDurationMs: parseDuration(ttl),
701
- updateCount: 0,
1393
+ updateCount: persistedUpdateCount,
1394
+ materializationSamples: [],
1395
+ lastIngestLatencyMs: null,
1396
+ errorCount: persistedErrorCount,
1397
+ status: 'idle',
1398
+ phaseSamples: {},
1399
+ phaseLast: {},
1400
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
702
1401
  };
703
1402
  }
704
1403
 
705
1404
  private async calculateHash(data: any): Promise<string> {
706
- const content = JSON.stringify(data);
1405
+ // sessionId is part of the hash so the same logical query from two
1406
+ // sessions (e.g. two browser tabs of the same user) lands on different
1407
+ // `_00_query` rows and doesn't fight over a shared one.
1408
+ const content = JSON.stringify({ ...data, sessionId: this.sessionId });
707
1409
  const msgBuffer = new TextEncoder().encode(content);
708
1410
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
709
1411
  const hashArray = Array.from(new Uint8Array(hashBuffer));
710
1412
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
711
1413
  }
712
1414
 
713
- private startTTLHeartbeat(queryState: QueryState): void {
1415
+ private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
714
1416
  if (queryState.ttlTimer) return;
715
1417
 
716
1418
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
717
1419
 
718
1420
  queryState.ttlTimer = setTimeout(() => {
719
- // TODO: Emit heartbeat event for sync
1421
+ queryState.ttlTimer = null;
1422
+ // Only keep the remote query alive while something is actually watching
1423
+ // it. With the server now sweeping ALL expired views (not just in-circuit
1424
+ // ones), an un-refreshed query WOULD be swept after its TTL — so a live
1425
+ // subscriber must heartbeat. An abandoned query (no subscribers) is left
1426
+ // to expire and get swept; its local view was already torn down at the
1427
+ // last unsubscribe, so we just stop the timer here.
1428
+ const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
1429
+ if (subscriberCount === 0) {
1430
+ this.logger.debug(
1431
+ { hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
1432
+ 'TTL heartbeat: no subscribers, stopping'
1433
+ );
1434
+ return;
1435
+ }
1436
+ this.onHeartbeat?.(hash);
720
1437
  this.logger.debug(
721
1438
  {
1439
+ hash,
722
1440
  id: encodeRecordId(queryState.config.id),
723
- Category: 'spooky-client::DataModule::startTTLHeartbeat',
1441
+ Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
724
1442
  },
725
- 'TTL heartbeat'
1443
+ 'TTL heartbeat sent'
726
1444
  );
727
- this.startTTLHeartbeat(queryState);
1445
+ this.startTTLHeartbeat(queryState, hash);
728
1446
  }, heartbeatTime);
729
1447
  }
730
1448
 
731
- private stopTTLHeartbeat(queryState: QueryState): void {
732
- if (queryState.ttlTimer) {
733
- clearTimeout(queryState.ttlTimer);
734
- queryState.ttlTimer = null;
735
- }
736
- }
737
-
738
1449
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
739
- for (const queryState of this.activeQueries.values()) {
740
- this.replaceRecordInQuery(queryState, record);
741
- }
742
- }
743
-
744
- private replaceRecordInQuery(queryState: QueryState, record: Record<string, any>): void {
745
- const index = queryState.records.findIndex((r) => r.id === record.id);
746
- if (index !== -1) {
747
- queryState.records[index] = record;
1450
+ for (const [queryHash, queryState] of this.activeQueries.entries()) {
1451
+ const index = queryState.records.findIndex((r) => r.id === record.id);
1452
+ if (index !== -1) {
1453
+ queryState.records[index] = { ...queryState.records[index], ...record };
1454
+ // Notify subscribers so UI updates immediately
1455
+ const subscribers = this.subscriptions.get(queryHash);
1456
+ if (subscribers) {
1457
+ for (const callback of subscribers) {
1458
+ callback(queryState.records);
1459
+ }
1460
+ }
1461
+ }
748
1462
  }
749
1463
  }
750
1464
  }
@@ -764,7 +1478,7 @@ export function parseUpdateOptions(
764
1478
  const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
765
1479
  const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
766
1480
  const key =
767
- keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
1481
+ keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
768
1482
 
769
1483
  pushEventOptions = {
770
1484
  debounced: {