@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.130

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 (90) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1171 -372
  3. package/dist/index.js +5716 -1278
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +746 -0
  9. package/package.json +39 -8
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.hydration.test.ts +150 -0
  25. package/src/modules/data/data.rebind.test.ts +147 -0
  26. package/src/modules/data/data.recurring.test.ts +137 -0
  27. package/src/modules/data/data.status.test.ts +249 -0
  28. package/src/modules/data/index.ts +1228 -127
  29. package/src/modules/data/window-query.test.ts +52 -0
  30. package/src/modules/data/window-query.ts +154 -0
  31. package/src/modules/devtools/index.ts +191 -30
  32. package/src/modules/devtools/versions.test.ts +74 -0
  33. package/src/modules/devtools/versions.ts +81 -0
  34. package/src/modules/feature-flag/index.test.ts +120 -0
  35. package/src/modules/feature-flag/index.ts +209 -0
  36. package/src/modules/ref-tables.test.ts +91 -0
  37. package/src/modules/ref-tables.ts +88 -0
  38. package/src/modules/sync/engine.ts +101 -37
  39. package/src/modules/sync/events/index.ts +9 -2
  40. package/src/modules/sync/queue/queue-down.ts +12 -5
  41. package/src/modules/sync/queue/queue-up.ts +29 -14
  42. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  43. package/src/modules/sync/scheduler.ts +73 -7
  44. package/src/modules/sync/sync.health.test.ts +149 -0
  45. package/src/modules/sync/sync.subquery.test.ts +82 -0
  46. package/src/modules/sync/sync.ts +1017 -62
  47. package/src/modules/sync/utils.test.ts +269 -2
  48. package/src/modules/sync/utils.ts +182 -17
  49. package/src/otel/index.ts +127 -0
  50. package/src/services/database/cache-engine.ts +143 -0
  51. package/src/services/database/database.ts +11 -11
  52. package/src/services/database/engine-factory.ts +32 -0
  53. package/src/services/database/events/index.ts +2 -1
  54. package/src/services/database/index.ts +6 -0
  55. package/src/services/database/local-migrator.ts +28 -27
  56. package/src/services/database/local.test.ts +64 -0
  57. package/src/services/database/local.ts +452 -66
  58. package/src/services/database/plan-render.test.ts +133 -0
  59. package/src/services/database/plan-render.ts +100 -0
  60. package/src/services/database/relation-resolver.test.ts +413 -0
  61. package/src/services/database/relation-resolver.ts +0 -0
  62. package/src/services/database/remote.ts +13 -13
  63. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  64. package/src/services/database/sqlite-cache-engine.ts +699 -0
  65. package/src/services/database/sqlite-worker.ts +116 -0
  66. package/src/services/database/surql-translate.ts +291 -0
  67. package/src/services/database/surreal-cache-engine.ts +122 -0
  68. package/src/services/logger/index.ts +6 -101
  69. package/src/services/persistence/localstorage.ts +2 -2
  70. package/src/services/persistence/resilient.ts +41 -0
  71. package/src/services/persistence/surrealdb.ts +10 -10
  72. package/src/services/stream-processor/index.ts +295 -38
  73. package/src/services/stream-processor/permissions.test.ts +47 -0
  74. package/src/services/stream-processor/permissions.ts +53 -0
  75. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  76. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  77. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  78. package/src/services/stream-processor/wasm-types.ts +18 -2
  79. package/src/sp00ky.auth-order.test.ts +92 -0
  80. package/src/sp00ky.init-query.test.ts +185 -0
  81. package/src/sp00ky.ts +1016 -0
  82. package/src/types.ts +258 -15
  83. package/src/utils/error-classification.test.ts +44 -0
  84. package/src/utils/error-classification.ts +7 -0
  85. package/src/utils/index.ts +35 -13
  86. package/src/utils/parser.ts +3 -2
  87. package/src/utils/surql.ts +24 -15
  88. package/src/utils/withRetry.test.ts +1 -1
  89. package/tsdown.config.ts +77 -1
  90. package/src/spooky.ts +0 -392
@@ -1,28 +1,34 @@
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
+ QueryPlan,
8
9
  } 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,
10
+ import type { LocalStore } from '../../services/database/index';
11
+ import { StaleEpochError } from '../../services/database/index';
12
+ import type { CacheModule, RecordWithId, CacheRecord } from '../cache/index';
13
+ import type { Logger } from '../../services/logger/index';
14
+ import type { StreamUpdate } from '../../services/stream-processor/index';
15
+ import type {
15
16
  QueryConfig,
16
17
  QueryHash,
17
18
  QueryState,
19
+ QueryStatus,
20
+ QueryStatusCallback,
18
21
  QueryTimeToLive,
19
22
  QueryUpdateCallback,
20
23
  MutationCallback,
21
24
  RecordVersionArray,
22
25
  QueryConfigRecord,
23
26
  UpdateOptions,
24
- RunOptions,
25
- } from '../../types';
27
+ QueryTimings,
28
+ PhaseStat,
29
+ RegistrationTimings,
30
+ RunOptions} from '../../types';
31
+ import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
26
32
  import {
27
33
  parseRecordIdString,
28
34
  extractIdPart,
@@ -31,11 +37,29 @@ import {
31
37
  withRetry,
32
38
  surql,
33
39
  parseParams,
40
+ cleanRecord,
34
41
  extractTablePart,
35
42
  generateId,
36
43
  } from '../../utils/index';
37
- import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
38
- import { PushEventOptions } from '../../events/index';
44
+ import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
45
+ import type { PushEventOptions } from '../../events/index';
46
+ import { buildWindowMaterialization, buildWindowMaterializationPlan } from './window-query';
47
+
48
+ /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
49
+ function pushSample(samples: number[], ms: number): void {
50
+ samples.push(ms);
51
+ if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
52
+ }
53
+
54
+ /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
55
+ function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
56
+ if (samples.length === 0) {
57
+ return { lastMs, p50: null, p90: null, p99: null, count: 0 };
58
+ }
59
+ const sorted = [...samples].sort((a, b) => a - b);
60
+ const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
61
+ return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
62
+ }
39
63
 
40
64
  /**
41
65
  * DataModule - Unified query and mutation management
@@ -47,22 +71,105 @@ export class DataModule<S extends SchemaStructure> {
47
71
  private activeQueries: Map<QueryHash, QueryState> = new Map();
48
72
  private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
49
73
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
74
+ private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
50
75
  private mutationCallbacks: Set<MutationCallback> = new Set();
51
76
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
77
+ // The update each debounce timer would process on its trailing edge. Kept in a
78
+ // map (not just the timer closure) so `flushPendingStreamUpdate` can process
79
+ // it early — the sync engine flushes before flipping a query to `idle`, so
80
+ // subscribers never observe idle status with stale (partial-window) rows.
81
+ private pendingStreamUpdates: Map<QueryHash, StreamUpdate> = new Map();
82
+ // Refcount of in-flight fetch cycles per query (registration + concurrent
83
+ // poll/LIVE sync rounds can overlap). Status flips to `fetching` on 0→1 and
84
+ // back to `idle` only on 1→0, so an inner cycle finishing can't emit a
85
+ // premature idle mid-registration.
86
+ private fetchDepth: Map<QueryHash, number> = new Map();
52
87
  private logger: Logger;
88
+ /**
89
+ * Optional observer notified whenever a query's fetch status changes.
90
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
91
+ * settable field (rather than a constructor arg) because DevTools is
92
+ * constructed after DataModule.
93
+ */
94
+ public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
95
+ /**
96
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
97
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
98
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
99
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
100
+ * field (not a constructor arg) because the sync engine is wired after
101
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
102
+ */
103
+ public onHeartbeat?: (hash: QueryHash) => void;
104
+ /**
105
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
106
+ * viewport-windowed list cancelling an off-screen window) loses its last
107
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
108
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
109
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
110
+ * {@link finalizeDeregister} only after that remote delete, so a fast
111
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
112
+ */
113
+ public onDeregister?: (hash: QueryHash) => void;
114
+ // Salt for query-id hashing. Set from SurrealDB's session::id() so two
115
+ // browser sessions registering the same logical query (same surql + params)
116
+ // don't collide on the same `_00_query` row — each session gets its own.
117
+ // Empty string until init(sessionId) is called.
118
+ private sessionId: string = '';
119
+ // Authenticated user record id (e.g. `"user:abc"`). Updated by
120
+ // `setCurrentUserId` from the auth subscription. null when
121
+ // unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
122
+ // poll and LIVE subscription target the same per-user
123
+ // `_00_list_ref_user_<id>` table the sync engine writes to.
124
+ private currentUserId: string | null = null;
53
125
 
54
126
  constructor(
55
127
  private cache: CacheModule,
56
- private local: LocalDatabaseService,
128
+ private local: LocalStore,
57
129
  private schema: S,
58
130
  logger: Logger,
59
- private streamDebounceTime: number = 100
131
+ // Client-side SSP aggregation throttle: coalesces the in-browser
132
+ // StreamProcessor's per-record stream updates per query before notifying
133
+ // readers, so a burst of synced records repaints the UI once per window
134
+ // rather than row-by-row. 50ms keeps it responsive while still batching the
135
+ // initial-sync stream. Override via `SyncedDbConfig.streamDebounceTime`.
136
+ private streamDebounceTime: number = 50
60
137
  ) {
61
138
  this.logger = logger.child({ service: 'DataModule' });
62
139
  }
63
140
 
64
- async init(): Promise<void> {
65
- this.logger.info({ Category: 'spooky-client::DataModule::init' }, 'DataModule initialized');
141
+ async init(sessionId: string): Promise<void> {
142
+ this.sessionId = sessionId;
143
+ this.logger.info(
144
+ { sessionId, Category: 'sp00ky-client::DataModule::init' },
145
+ 'DataModule initialized'
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Update the session salt used in query-id hashing. Call this when the
151
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
152
+ * registered queries will get fresh, session-scoped IDs.
153
+ */
154
+ setSessionId(sessionId: string): void {
155
+ this.sessionId = sessionId;
156
+ }
157
+
158
+ /**
159
+ * Update the authenticated user record id. Pass `null` on sign-out.
160
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
161
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
162
+ * SSP writes to.
163
+ */
164
+ setCurrentUserId(userId: string | null): void {
165
+ this.currentUserId = userId;
166
+ }
167
+
168
+ /** Read-only view of the authenticated user id used for per-user
169
+ * `_00_list_ref` routing. Other modules consult this so they pick the
170
+ * same table name DataModule does. */
171
+ getCurrentUserId(): string | null {
172
+ return this.currentUserId;
66
173
  }
67
174
 
68
175
  // ==================== QUERY MANAGEMENT ====================
@@ -74,19 +181,22 @@ export class DataModule<S extends SchemaStructure> {
74
181
  tableName: T,
75
182
  surqlString: string,
76
183
  params: Record<string, any>,
77
- ttl: QueryTimeToLive
184
+ ttl: QueryTimeToLive,
185
+ plan?: QueryPlan
78
186
  ): Promise<QueryHash> {
79
187
  const hash = await this.calculateHash({ surql: surqlString, params });
80
188
  this.logger.debug(
81
- { hash, Category: 'spooky-client::DataModule::query' },
189
+ { hash, Category: 'sp00ky-client::DataModule::query' },
82
190
  'Query Initialization: started'
83
191
  );
84
192
 
85
- const recordId = new RecordId('_spooky_query', hash);
193
+ // `_00_query` stays the single shared registration table in both
194
+ // ref-modes; the per-user split happens only on `_00_list_ref`.
195
+ const recordId = new RecordId('_00_query', hash);
86
196
 
87
197
  if (this.activeQueries.has(hash)) {
88
198
  this.logger.debug(
89
- { hash, Category: 'spooky-client::DataModule::query' },
199
+ { hash, Category: 'sp00ky-client::DataModule::query' },
90
200
  'Query Initialization: exists, returning'
91
201
  );
92
202
  return hash;
@@ -95,7 +205,7 @@ export class DataModule<S extends SchemaStructure> {
95
205
  // Another call is already creating this query — wait for it
96
206
  if (this.pendingQueries.has(hash)) {
97
207
  this.logger.debug(
98
- { hash, Category: 'spooky-client::DataModule::query' },
208
+ { hash, Category: 'sp00ky-client::DataModule::query' },
99
209
  'Query Initialization: pending, waiting for existing creation'
100
210
  );
101
211
  await this.pendingQueries.get(hash);
@@ -103,12 +213,12 @@ export class DataModule<S extends SchemaStructure> {
103
213
  }
104
214
 
105
215
  this.logger.debug(
106
- { hash, Category: 'spooky-client::DataModule::query' },
216
+ { hash, Category: 'sp00ky-client::DataModule::query' },
107
217
  'Query Initialization: not found, creating new query'
108
218
  );
109
219
 
110
220
  // Create the query and track the pending promise
111
- const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
221
+ const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName, plan);
112
222
  this.pendingQueries.set(hash, promise);
113
223
  try {
114
224
  await promise;
@@ -147,11 +257,95 @@ export class DataModule<S extends SchemaStructure> {
147
257
  subs.delete(callback);
148
258
  if (subs.size === 0) {
149
259
  this.subscriptions.delete(queryHash);
260
+ // NOTE: intentionally do NOT tear down the query / free its in-browser
261
+ // SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
262
+ // already self-stops once subscribers hit 0, so an abandoned query
263
+ // stops being kept alive and the server's TTL sweep removes it. Freeing
264
+ // the local view on every last-unsubscribe caused re-registration churn
265
+ // on navigation (open A → leave → open A again re-registers), which is
266
+ // a flakiness risk for no real benefit — keep the local view resident.
267
+ }
268
+ }
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Subscribe to a query's fetch-status changes (idle/fetching).
274
+ * With `{ immediate: true }` the callback fires synchronously with the
275
+ * current status (defaults to `idle` if the query isn't registered yet).
276
+ */
277
+ subscribeStatus(
278
+ queryHash: string,
279
+ callback: QueryStatusCallback,
280
+ options: { immediate?: boolean } = {}
281
+ ): () => void {
282
+ if (!this.statusSubscriptions.has(queryHash)) {
283
+ this.statusSubscriptions.set(queryHash, new Set());
284
+ }
285
+ this.statusSubscriptions.get(queryHash)?.add(callback);
286
+
287
+ if (options.immediate) {
288
+ callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
289
+ }
290
+
291
+ return () => {
292
+ const subs = this.statusSubscriptions.get(queryHash);
293
+ if (subs) {
294
+ subs.delete(callback);
295
+ if (subs.size === 0) {
296
+ this.statusSubscriptions.delete(queryHash);
150
297
  }
151
298
  }
152
299
  };
153
300
  }
154
301
 
302
+ /**
303
+ * Set a query's fetch status and notify status observers (DevTools +
304
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
305
+ * query is unknown.
306
+ */
307
+ setQueryStatus(queryHash: string, status: QueryStatus): void {
308
+ const queryState = this.activeQueries.get(queryHash);
309
+ if (!queryState || queryState.status === status) {
310
+ return;
311
+ }
312
+ queryState.status = status;
313
+
314
+ this.onQueryStatusChange?.(queryHash, status);
315
+
316
+ const subs = this.statusSubscriptions.get(queryHash);
317
+ if (subs) {
318
+ for (const callback of subs) {
319
+ callback(status);
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Enter a fetch cycle for a query. Refcounted: registration and concurrent
326
+ * poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
327
+ * cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
328
+ * emits `idle`. Always pair with `endFetching` in a `finally`.
329
+ */
330
+ beginFetching(queryHash: string): void {
331
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
332
+ this.fetchDepth.set(queryHash, depth + 1);
333
+ if (depth === 0) {
334
+ this.setQueryStatus(queryHash, 'fetching');
335
+ }
336
+ }
337
+
338
+ /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
339
+ endFetching(queryHash: string): void {
340
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
341
+ if (depth <= 1) {
342
+ this.fetchDepth.delete(queryHash);
343
+ this.setQueryStatus(queryHash, 'idle');
344
+ return;
345
+ }
346
+ this.fetchDepth.set(queryHash, depth - 1);
347
+ }
348
+
155
349
  /**
156
350
  * Subscribe to mutations (for sync)
157
351
  */
@@ -168,67 +362,208 @@ export class DataModule<S extends SchemaStructure> {
168
362
  async onStreamUpdate(update: StreamUpdate): Promise<void> {
169
363
  const { queryHash, op } = update;
170
364
 
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)!);
365
+ // DELETE propagates immediately — a removed row should disappear without
366
+ // waiting on a debounce.
367
+ //
368
+ // CREATE and UPDATE are coalesced per query on a trailing timer. A list's
369
+ // rows stream in from sync as many small `_00_list_ref` diffs, each its own
370
+ // `cache.saveBatch` → one stream update per chunk. `ingestMany` only
371
+ // coalesces records ingested synchronously together, so chunks spread over
372
+ // time each re-materialize and re-notify — a 50-row window can fire 30+
373
+ // updates as it fills. Each StreamUpdate carries the full materialized
374
+ // `localArray`, so the latest one already reflects every prior chunk: keep
375
+ // only it and fire once on the trailing edge, settling the query in a couple
376
+ // of notifications instead of one per chunk.
377
+ if (op === 'DELETE') {
378
+ const existing = this.debounceTimers.get(queryHash);
379
+ if (existing) {
380
+ clearTimeout(existing);
381
+ this.debounceTimers.delete(queryHash);
177
382
  }
383
+ // The DELETE update carries the full latest localArray, so the coalesced
384
+ // CREATE/UPDATE it supersedes is already reflected — drop it.
385
+ this.pendingStreamUpdates.delete(queryHash);
386
+ await this.processStreamUpdate(update);
387
+ return;
388
+ }
178
389
 
179
- // Set new timer
180
- const timer = setTimeout(async () => {
181
- this.debounceTimers.delete(queryHash);
182
- await this.processStreamUpdate(update);
183
- }, this.streamDebounceTime);
390
+ // Clear existing timer if any
391
+ if (this.debounceTimers.has(queryHash)) {
392
+ // oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
393
+ clearTimeout(this.debounceTimers.get(queryHash)!);
394
+ }
184
395
 
185
- this.debounceTimers.set(queryHash, timer);
186
- } else {
187
- // CREATE and DELETE - process immediately
396
+ // Set new timer
397
+ this.pendingStreamUpdates.set(queryHash, update);
398
+ const timer = setTimeout(async () => {
399
+ this.debounceTimers.delete(queryHash);
400
+ this.pendingStreamUpdates.delete(queryHash);
188
401
  await this.processStreamUpdate(update);
402
+ }, this.streamDebounceTime);
403
+
404
+ this.debounceTimers.set(queryHash, timer);
405
+ }
406
+
407
+ /**
408
+ * Process a query's pending (debounced) stream update NOW instead of on the
409
+ * trailing edge. Called by the sync engine before it flips a query back to
410
+ * `idle`, so the status change never races ahead of the rows it fetched.
411
+ * No-op when nothing is pending. The pending entry is removed before the
412
+ * await so a concurrently-firing timer can't process it twice.
413
+ */
414
+ async flushPendingStreamUpdate(queryHash: string): Promise<void> {
415
+ const timer = this.debounceTimers.get(queryHash);
416
+ if (timer) {
417
+ clearTimeout(timer);
418
+ this.debounceTimers.delete(queryHash);
419
+ }
420
+ const pending = this.pendingStreamUpdates.get(queryHash);
421
+ if (!pending) return;
422
+ this.pendingStreamUpdates.delete(queryHash);
423
+ await this.processStreamUpdate(pending);
424
+ }
425
+
426
+ // Materialize a query's result rows from the local DB. For a windowed query
427
+ // (`LIMIT n START m`, m>0) the original surql is NOT re-run — re-applying
428
+ // `START m` against the shared local store would skip the window's own rows
429
+ // (sparse windowing) and return nothing. Instead we select the window's
430
+ // record id-set directly, preferring the server's `list_ref` (`remoteArray`,
431
+ // authoritative) over the in-browser SSP's view (`sspArray`), and re-apply
432
+ // the original ORDER BY for stable order.
433
+ private async materializeRecords(
434
+ queryState: QueryState,
435
+ sspArray?: Array<[string, number]>
436
+ ): Promise<Record<string, any>[]> {
437
+ const t0 = performance.now();
438
+ const plan = queryState.config.plan;
439
+ const windowMat = buildWindowMaterialization(queryState.config.surql);
440
+ let records: Record<string, any>[];
441
+ if (windowMat) {
442
+ const win =
443
+ (queryState.config.remoteArray?.length && queryState.config.remoteArray) ||
444
+ (sspArray?.length && sspArray) ||
445
+ queryState.config.localArray ||
446
+ [];
447
+ const winIds = win.map(([id]) => parseRecordIdString(id));
448
+ if (plan) {
449
+ // Engine-neutral window materialization: select exactly the id-set,
450
+ // keeping ORDER BY + relations. Works on any local engine.
451
+ const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
452
+ records = await this.local.select(winPlan, queryState.config.params);
453
+ } else {
454
+ const [rows] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
455
+ ...queryState.config.params,
456
+ __win: winIds,
457
+ });
458
+ records = rows || [];
459
+ }
460
+ } else if (plan) {
461
+ records = await this.local.select(plan, queryState.config.params);
462
+ } else {
463
+ const [rows] = await this.local.query<[Record<string, any>[]]>(
464
+ queryState.config.surql,
465
+ queryState.config.params
466
+ );
467
+ records = rows || [];
189
468
  }
469
+ // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
470
+ this.recordPhase(queryState, 'localFetch', performance.now() - t0);
471
+ return records;
190
472
  }
191
473
 
192
474
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
193
- const { queryHash, localArray } = update;
475
+ const { queryHash, localArray, materializationTimeMs } = update;
194
476
  const queryState = this.activeQueries.get(queryHash);
195
477
  if (!queryState) {
196
478
  this.logger.warn(
197
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
479
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
198
480
  'Received update for unknown query. Skipping...'
199
481
  );
200
482
  return;
201
483
  }
202
484
 
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
- );
485
+ // Update the rolling materialization-sample window before the work that
486
+ // could throw, so the percentiles still move when the downstream local
487
+ // query fails (the materialization step itself ran).
488
+ if (typeof materializationTimeMs === 'number') {
489
+ queryState.materializationSamples.push(materializationTimeMs);
490
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
491
+ queryState.materializationSamples.shift();
492
+ }
493
+ queryState.lastIngestLatencyMs = materializationTimeMs;
494
+ }
495
+ // Record the SSP internal sub-phase timings (from the WASM binding) so
496
+ // DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
497
+ if (typeof update.storeApplyMs === 'number')
498
+ this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
499
+ if (typeof update.circuitStepMs === 'number')
500
+ this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
501
+ if (typeof update.transformMs === 'number')
502
+ this.recordPhase(queryState, 'sspTransform', update.transformMs);
503
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
504
+
505
+ // Fence against bucket switches: this update's `localArray` came from the
506
+ // pre-switch SSP circuit; applying it after a switch would show (and
507
+ // persist) the previous user's ids in the new bucket.
508
+ const epoch = this.local.epoch;
209
509
 
210
- // Update state
211
- const newRecords = records || [];
510
+ try {
511
+ // Materialize the query's rows. For a windowed (offset) query, re-running
512
+ // the original surql would re-apply `START n` against the shared local DB
513
+ // and skip the window's rows entirely; instead select the SSP's
514
+ // materialized window id-set (`localArray`) directly, re-applying the
515
+ // original ORDER BY for stable display order. Non-offset queries keep the
516
+ // normal re-query path.
517
+ const newRecords = await this.materializeRecords(queryState, localArray);
518
+ if (epoch !== this.local.epoch) return;
212
519
  queryState.config.localArray = localArray;
213
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
214
- id: queryState.config.id,
215
- localArray,
216
- });
217
520
 
218
- // Skip notification if records haven't changed
219
521
  const prevJson = JSON.stringify(queryState.records);
220
522
  const newJson = JSON.stringify(newRecords);
221
523
  queryState.records = newRecords;
222
- if (prevJson === newJson) {
524
+ const recordsChanged = prevJson !== newJson;
525
+
526
+ // updateCount counts user-visible updates (matches the prior semantic),
527
+ // while the materialization sample/percentiles already moved above for
528
+ // every observed engine step.
529
+ if (recordsChanged) {
530
+ queryState.updateCount++;
531
+ queryState.lastUpdatedAt = Date.now();
532
+ }
533
+
534
+ await this.local.query(
535
+ surql.seal(
536
+ surql.updateSet('id', [
537
+ 'localArray',
538
+ 'rowCount',
539
+ 'updateCount',
540
+ 'lastIngestLatency',
541
+ 'materializationP55',
542
+ 'materializationP90',
543
+ 'materializationP99',
544
+ ])
545
+ ),
546
+ {
547
+ id: queryState.config.id,
548
+ localArray,
549
+ rowCount: localArray.length,
550
+ updateCount: queryState.updateCount,
551
+ lastIngestLatency: queryState.lastIngestLatencyMs,
552
+ materializationP55: percentiles.p55,
553
+ materializationP90: percentiles.p90,
554
+ materializationP99: percentiles.p99,
555
+ },
556
+ { epoch }
557
+ );
558
+
559
+ if (!recordsChanged) {
223
560
  this.logger.debug(
224
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
561
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
225
562
  'Query records unchanged, skipping notification'
226
563
  );
227
564
  return;
228
565
  }
229
566
 
230
- queryState.updateCount++;
231
-
232
567
  // Notify subscribers
233
568
  const subscribers = this.subscriptions.get(queryHash);
234
569
  if (subscribers) {
@@ -240,17 +575,108 @@ export class DataModule<S extends SchemaStructure> {
240
575
  this.logger.debug(
241
576
  {
242
577
  queryHash,
243
- recordCount: records?.length,
244
- Category: 'spooky-client::DataModule::onStreamUpdate',
578
+ recordCount: newRecords?.length,
579
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
245
580
  },
246
581
  'Query updated from stream'
247
582
  );
248
583
  } catch (err) {
584
+ if (err instanceof StaleEpochError) {
585
+ this.logger.debug(
586
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
587
+ 'Dropped stream update from before a bucket switch'
588
+ );
589
+ return;
590
+ }
591
+ queryState.errorCount++;
249
592
  this.logger.error(
250
- { err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
593
+ { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
251
594
  'Failed to fetch records for stream update'
252
595
  );
596
+ // Best-effort persist of the bumped errorCount; swallow secondary
597
+ // failures to avoid masking the original error in logs.
598
+ try {
599
+ await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
600
+ id: queryState.config.id,
601
+ errorCount: queryState.errorCount,
602
+ });
603
+ } catch (persistErr) {
604
+ this.logger.warn(
605
+ {
606
+ err: persistErr,
607
+ queryHash,
608
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
609
+ },
610
+ 'Failed to persist incremented errorCount'
611
+ );
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
618
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
619
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
620
+ */
621
+ private computeMaterializationPercentiles(samples: number[]): {
622
+ p55: number | null;
623
+ p90: number | null;
624
+ p99: number | null;
625
+ } {
626
+ if (samples.length === 0) {
627
+ return { p55: null, p90: null, p99: null };
253
628
  }
629
+ const sorted = [...samples].sort((a, b) => a - b);
630
+ const pick = (q: number) => {
631
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
632
+ return sorted[idx]!;
633
+ };
634
+ return { p55: pick(0.55), p90: pick(0.90), p99: pick(0.99) };
635
+ }
636
+
637
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
638
+ private recordPhase(qs: QueryState, phase: string, ms: number): void {
639
+ if (!Number.isFinite(ms)) return;
640
+ const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
641
+ pushSample(arr, ms);
642
+ qs.phaseLast[phase] = ms;
643
+ }
644
+
645
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
646
+ recordRemoteFetch(hash: string, ms: number): void {
647
+ const qs = this.activeQueries.get(hash);
648
+ if (qs) this.recordPhase(qs, 'remoteFetch', ms);
649
+ }
650
+
651
+ /**
652
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
653
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
654
+ */
655
+ recordFrontendTiming(hash: string, ms: number): void {
656
+ const qs = this.activeQueries.get(hash);
657
+ if (qs) this.recordPhase(qs, 'frontend', ms);
658
+ }
659
+
660
+ /**
661
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
662
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
663
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
664
+ */
665
+ phaseTimings(q: QueryState): QueryTimings {
666
+ const stat = (phase: string) =>
667
+ phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
668
+ return {
669
+ ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
670
+ sspStoreApply: stat('sspStoreApply'),
671
+ sspCircuitStep: stat('sspCircuitStep'),
672
+ sspTransform: stat('sspTransform'),
673
+ localFetch: stat('localFetch'),
674
+ remoteFetch: stat('remoteFetch'),
675
+ frontend: stat('frontend'),
676
+ registration: q.registrationTimings,
677
+ updateCount: q.updateCount,
678
+ errorCount: q.errorCount,
679
+ };
254
680
  }
255
681
 
256
682
  /**
@@ -260,6 +686,273 @@ export class DataModule<S extends SchemaStructure> {
260
686
  return this.activeQueries.get(hash);
261
687
  }
262
688
 
689
+ /**
690
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
691
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
692
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
693
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
694
+ * but it still hasn't loaded its own full window from the server — so it should
695
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
696
+ */
697
+ isCold(hash: string): boolean {
698
+ const qs = this.activeQueries.get(hash);
699
+ return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
700
+ }
701
+
702
+ /**
703
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
704
+ * `batch` (recursing for nested related fields). An embedded child is a
705
+ * value that is itself a record — a non-null object whose `id` is a
706
+ * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
707
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
708
+ * so this never mistakes a FK column for an embedded body. Children are
709
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
710
+ * to their table's real columns (which strips the alias/related fields).
711
+ * `seen` dedupes within the batch.
712
+ */
713
+ private collectEmbeddedChildren(
714
+ record: Record<string, any>,
715
+ batch: CacheRecord[],
716
+ seen: Set<string>
717
+ ): void {
718
+ const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
719
+ !!v &&
720
+ typeof v === 'object' &&
721
+ !(v instanceof RecordId) &&
722
+ (v as { id?: unknown }).id instanceof RecordId;
723
+
724
+ for (const value of Object.values(record)) {
725
+ const children = Array.isArray(value)
726
+ ? value.filter(isEmbeddedRecord)
727
+ : isEmbeddedRecord(value)
728
+ ? [value]
729
+ : [];
730
+ for (const child of children) {
731
+ const key = encodeRecordId(child.id);
732
+ if (seen.has(key)) continue;
733
+ seen.add(key);
734
+ // Recurse FIRST so nested grandchildren are captured before `cleanRecord`
735
+ // strips this child's alias fields.
736
+ this.collectEmbeddedChildren(child, batch, seen);
737
+ const table = child.id.table.toString();
738
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
739
+ batch.push({
740
+ table,
741
+ op: 'CREATE',
742
+ // Flatten the child too: a nested comment carries its own embedded
743
+ // `author` object which the schemafull `record<user>` field would
744
+ // reject. (Its author is captured as a separate row by the recursion
745
+ // above.)
746
+ record: this.flattenRelationsForStorage(child, tableSchema),
747
+ version: (child._00_rv as number) || 1,
748
+ });
749
+ }
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Prepare a subquery-bearing row (preload / hydration) for the schemafull
755
+ * local store: replace an embedded FORWARD-relation object (`author = { id, … }`)
756
+ * with its RecordId so a `record<…>` field coerces, and DROP reverse-subquery
757
+ * ARRAYS (`comments = [ … ]`) since their rows are cached separately as their
758
+ * own bodies. A flat record — as the live `SELECT * FROM $ids` sync returns,
759
+ * with relations already RecordIds — passes through unchanged.
760
+ */
761
+ private flattenRelationsForStorage(
762
+ record: Record<string, any>,
763
+ tableSchema?: { columns: Record<string, any> }
764
+ ): RecordWithId {
765
+ const isEmbeddedRecord = (v: unknown): boolean =>
766
+ !!v &&
767
+ typeof v === 'object' &&
768
+ !(v instanceof RecordId) &&
769
+ (v as { id?: unknown }).id instanceof RecordId;
770
+ const flat: Record<string, any> = {};
771
+ for (const [k, v] of Object.entries(record)) {
772
+ if (isEmbeddedRecord(v)) {
773
+ flat[k] = (v as { id: unknown }).id;
774
+ } else if (Array.isArray(v) && v.some(isEmbeddedRecord)) {
775
+ continue;
776
+ } else {
777
+ flat[k] = v;
778
+ }
779
+ }
780
+ return (tableSchema ? cleanRecord(tableSchema.columns, flat) : flat) as RecordWithId;
781
+ }
782
+
783
+ /**
784
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
785
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
786
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
787
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
788
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
789
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
790
+ */
791
+ async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
792
+ const queryState = this.activeQueries.get(hash);
793
+ if (!queryState) return;
794
+ queryState.hydrated = true; // run-once, even when the remote returns nothing
795
+ if (rows.length === 0) return;
796
+
797
+ const epoch = this.local.epoch;
798
+ const tableName = queryState.config.tableName;
799
+ await this.buildAndSaveCacheBatch(tableName, rows);
800
+ // Bucket switched while we persisted: these rows were fetched under the
801
+ // previous auth context — don't let them prime the new bucket's query
802
+ // state; the rebind's re-registration refills it. (saveBatch's own epoch
803
+ // fence usually catches this, but the switch can land between it and here.)
804
+ if (epoch !== this.local.epoch) return;
805
+
806
+ // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
807
+ // prefers it for windowed queries (correct window) and it feeds the version
808
+ // dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
809
+ queryState.config.remoteArray = rows.map(
810
+ (r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
811
+ );
812
+
813
+ queryState.records = await this.materializeRecords(queryState);
814
+ const subscribers = this.subscriptions.get(hash);
815
+ if (subscribers) {
816
+ for (const cb of subscribers) cb(queryState.records);
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Build the cache batch for a set of one-shot rows and persist it to the
822
+ * local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
823
+ * and extracts EMBEDDED related children (any nesting depth) as their own
824
+ * records — a `.related()` query returns its children embedded, and a later
825
+ * correlated re-materialization needs them present as standalone rows.
826
+ * Shared by `applyHydration` (live registration) and `persistSnapshot`
827
+ * (preload).
828
+ */
829
+ private async buildAndSaveCacheBatch(
830
+ tableName: string,
831
+ rows: RecordWithId[]
832
+ ): Promise<void> {
833
+ const tableSchema = this.schema.tables.find((t) => t.name === tableName);
834
+ const batch: CacheRecord[] = rows.map((record) => ({
835
+ table: tableName,
836
+ op: 'CREATE' as const,
837
+ // Flatten embedded relations first: a preload/hydration row can carry a
838
+ // forward relation as a full nested OBJECT (`author = { id, … }`) and a
839
+ // reverse subquery as an array of objects (`comments = [ … ]`). The
840
+ // schemafull local field is `record<user>`, which rejects an object
841
+ // (`Couldn't coerce … found { id: …, username: … }`) and would throw the
842
+ // WHOLE batch. Store the parent with `author` as its RecordId and drop the
843
+ // subquery arrays — the children are cached as their own rows below.
844
+ record: this.flattenRelationsForStorage(record, tableSchema),
845
+ version: (record._00_rv as number) || 1,
846
+ }));
847
+
848
+ const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
849
+ for (const record of rows) {
850
+ this.collectEmbeddedChildren(record, batch, seen);
851
+ }
852
+
853
+ await this.cache.saveBatch(batch);
854
+ }
855
+
856
+ /**
857
+ * Preload/prewarm: persist one-shot rows (and their embedded related children)
858
+ * into the local cache WITHOUT registering a query — no `activeQueries` entry,
859
+ * no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
860
+ * ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
861
+ * first paint from them instantly, then registers a live view to freshen.
862
+ */
863
+ async persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void> {
864
+ if (rows.length === 0) return;
865
+ await this.buildAndSaveCacheBatch(tableName, rows);
866
+ }
867
+
868
+ /**
869
+ * Read the durable preload freshness marker for a query hash, or null if this
870
+ * query was never preloaded in the current bucket. Co-located with the cached
871
+ * rows (per-bucket `_00_preload` table) so a bucket switch that clears the
872
+ * data also clears the marker — a stale marker can't claim "warm" when the
873
+ * rows are gone. Any read error is treated as cold.
874
+ */
875
+ async getPreloadMarker(
876
+ hash: string
877
+ ): Promise<{ fetchedAt: number; rowCount: number } | null> {
878
+ try {
879
+ const row = await this.local.getById('_00_preload', hash);
880
+ if (!row) return null;
881
+ return {
882
+ fetchedAt: Number((row as any).fetchedAt) || 0,
883
+ rowCount: Number((row as any).rowCount) || 0,
884
+ };
885
+ } catch {
886
+ return null;
887
+ }
888
+ }
889
+
890
+ /**
891
+ * True when this query's preload marker exists and is younger than
892
+ * `maxAgeMs`. Used by instant-hydrate to skip its one-shot fetch for rows a
893
+ * recent `preload()` already persisted (the register lifecycle re-syncs them
894
+ * authoritatively). Missing or unreadable markers are treated as stale.
895
+ */
896
+ async isPreloadFresh(hashKey: string, maxAgeMs: number): Promise<boolean> {
897
+ const marker = await this.getPreloadMarker(hashKey);
898
+ return marker !== null && Date.now() - marker.fetchedAt <= maxAgeMs;
899
+ }
900
+
901
+ /** Stamp the preload freshness marker after a successful snapshot fetch. */
902
+ async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
903
+ await this.local.upsert(
904
+ '_00_preload',
905
+ hash,
906
+ { fetchedAt: Date.now(), rowCount },
907
+ 'replace'
908
+ );
909
+ }
910
+
911
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
912
+ hasSubscribers(hash: string): boolean {
913
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
914
+ }
915
+
916
+ /**
917
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
918
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
919
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
920
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
921
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
922
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
923
+ *
924
+ * NOTE: most queries should NOT use this — the default keep-alive on
925
+ * unsubscribe avoids re-registration churn on navigation.
926
+ */
927
+ deregisterQuery(hash: string): void {
928
+ if (this.hasSubscribers(hash)) return;
929
+ if (!this.activeQueries.has(hash)) return;
930
+ this.onDeregister?.(hash);
931
+ }
932
+
933
+ /**
934
+ * Final local teardown after the remote `_00_query` row was deleted: free the
935
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
936
+ * (`cleanupQuery`) guarantees no subscriber remains.
937
+ */
938
+ finalizeDeregister(hash: string): void {
939
+ const qs = this.activeQueries.get(hash);
940
+ if (qs?.ttlTimer) {
941
+ clearTimeout(qs.ttlTimer);
942
+ qs.ttlTimer = null;
943
+ }
944
+ const debounce = this.debounceTimers.get(hash);
945
+ if (debounce) {
946
+ clearTimeout(debounce);
947
+ this.debounceTimers.delete(hash);
948
+ }
949
+ this.pendingStreamUpdates.delete(hash);
950
+ this.fetchDepth.delete(hash);
951
+ this.cache.unregisterQuery(hash);
952
+ this.activeQueries.delete(hash);
953
+ this.subscriptions.delete(hash);
954
+ }
955
+
263
956
  /**
264
957
  * Get query state by id (for sync and devtools)
265
958
  */
@@ -274,36 +967,163 @@ export class DataModule<S extends SchemaStructure> {
274
967
  return Array.from(this.activeQueries.values());
275
968
  }
276
969
 
970
+ getActiveQueryHashes(): QueryHash[] {
971
+ return Array.from(this.activeQueries.keys());
972
+ }
973
+
277
974
  async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
278
975
  const queryState = this.activeQueries.get(id);
279
976
  if (!queryState) {
280
977
  this.logger.warn(
281
- { id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
978
+ { id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
282
979
  'Query to update local array not found'
283
980
  );
284
981
  return;
285
982
  }
983
+ const epoch = this.local.epoch;
286
984
  queryState.config.localArray = localArray;
287
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
288
- id: queryState.config.id,
289
- localArray,
290
- });
985
+ try {
986
+ await this.local.query(
987
+ surql.seal(surql.updateSet('id', ['localArray'])),
988
+ {
989
+ id: queryState.config.id,
990
+ localArray,
991
+ },
992
+ { epoch }
993
+ );
994
+ } catch (err) {
995
+ if (err instanceof StaleEpochError) return;
996
+ throw err;
997
+ }
291
998
  }
292
999
 
293
1000
  async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
294
1001
  const queryState = this.getQueryByHash(hash);
295
1002
  if (!queryState) {
296
1003
  this.logger.warn(
297
- { hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
1004
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
298
1005
  'Query to update remote array not found'
299
1006
  );
300
1007
  return;
301
1008
  }
1009
+ const epoch = this.local.epoch;
302
1010
  queryState.config.remoteArray = remoteArray;
303
- await this.local.query(surql.seal(surql.updateSet('id', ['remoteArray'])), {
304
- id: queryState.config.id,
305
- remoteArray,
306
- });
1011
+ try {
1012
+ await this.local.query(
1013
+ surql.seal(surql.updateSet('id', ['remoteArray'])),
1014
+ {
1015
+ id: queryState.config.id,
1016
+ remoteArray,
1017
+ },
1018
+ { epoch }
1019
+ );
1020
+ } catch (err) {
1021
+ if (err instanceof StaleEpochError) return;
1022
+ throw err;
1023
+ }
1024
+ }
1025
+
1026
+ /**
1027
+ * Cancel every armed timer ahead of a local-bucket switch: stream-update
1028
+ * debounce timers (their pending updates carry the OLD bucket's id-sets) and
1029
+ * per-query TTL heartbeats (they'd refresh the previous user's remote
1030
+ * `_00_query` rows under the new session). The rebind re-arms heartbeats.
1031
+ */
1032
+ quiesce(): void {
1033
+ for (const timer of this.debounceTimers.values()) {
1034
+ clearTimeout(timer);
1035
+ }
1036
+ this.debounceTimers.clear();
1037
+ this.pendingStreamUpdates.clear();
1038
+ this.fetchDepth.clear();
1039
+ for (const queryState of this.activeQueries.values()) {
1040
+ if (queryState.ttlTimer) {
1041
+ clearTimeout(queryState.ttlTimer);
1042
+ queryState.ttlTimer = null;
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Re-home every active query in a freshly-opened bucket, KEEPING its hash —
1049
+ * `useQuery` subscriptions are keyed by hash and don't re-register on auth
1050
+ * changes, so the hooks must stay attached. Per query:
1051
+ * 1. reset the sync arrays + hydration flag and drop the previous user's
1052
+ * records, notifying subscribers with the new-bucket materialization
1053
+ * (usually empty) so their rows leave the UI immediately;
1054
+ * 2. recreate the `_00_query` row in the new bucket;
1055
+ * 3. re-register the SSP view on the (fresh, post-reset) processor — this
1056
+ * also rebinds the view to the NEW `$auth` context;
1057
+ * 4. restart the TTL heartbeat.
1058
+ * Returns the hashes so the caller can enqueue remote re-registration, which
1059
+ * refills records from the server via the normal register→sync→notify path.
1060
+ */
1061
+ async rebindAfterBucketSwitch(): Promise<QueryHash[]> {
1062
+ const hashes: QueryHash[] = [];
1063
+ for (const [hash, queryState] of this.activeQueries.entries()) {
1064
+ const config = queryState.config;
1065
+ config.localArray = [];
1066
+ config.remoteArray = [];
1067
+ config.subqueryRemoteArray = undefined;
1068
+ queryState.hydrated = false;
1069
+ queryState.syncNotified = false;
1070
+ queryState.records = [];
1071
+ // Via setQueryStatus (not a bare assignment) so status observers see the
1072
+ // flip back to a loading state.
1073
+ this.setQueryStatus(hash, 'fetching');
1074
+
1075
+ try {
1076
+ await withRetry(this.logger, () =>
1077
+ this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
1078
+ id: config.id,
1079
+ data: {
1080
+ surql: config.surql,
1081
+ params: config.params,
1082
+ localArray: [],
1083
+ remoteArray: [],
1084
+ lastActiveAt: new Date(),
1085
+ createdAt: new Date(),
1086
+ ttl: config.ttl,
1087
+ tableName: config.tableName,
1088
+ updateCount: queryState.updateCount,
1089
+ rowCount: 0,
1090
+ errorCount: queryState.errorCount,
1091
+ },
1092
+ })
1093
+ );
1094
+
1095
+ const { localArray } = this.cache.registerQuery({
1096
+ queryHash: hash,
1097
+ surql: config.surql,
1098
+ params: config.params,
1099
+ ttl: new Duration(config.ttl),
1100
+ lastActiveAt: new Date(),
1101
+ });
1102
+ config.localArray = localArray;
1103
+ await this.local.query(surql.seal(surql.updateSet('id', ['localArray', 'rowCount'])), {
1104
+ id: config.id,
1105
+ localArray,
1106
+ rowCount: localArray.length,
1107
+ });
1108
+ } catch (err) {
1109
+ this.logger.error(
1110
+ { err, hash, Category: 'sp00ky-client::DataModule::rebindAfterBucketSwitch' },
1111
+ 'Failed to rebind query after bucket switch; remote re-registration will retry'
1112
+ );
1113
+ }
1114
+
1115
+ // Notify AFTER the SSP re-registration so a subscriber that re-reads
1116
+ // synchronously sees consistent (empty) state.
1117
+ const subscribers = this.subscriptions.get(hash);
1118
+ if (subscribers) {
1119
+ for (const callback of subscribers) {
1120
+ callback(queryState.records);
1121
+ }
1122
+ }
1123
+ this.startTTLHeartbeat(queryState, hash);
1124
+ hashes.push(hash);
1125
+ }
1126
+ return hashes;
307
1127
  }
308
1128
 
309
1129
  /**
@@ -313,20 +1133,29 @@ export class DataModule<S extends SchemaStructure> {
313
1133
  async notifyQuerySynced(queryHash: string): Promise<void> {
314
1134
  const queryState = this.activeQueries.get(queryHash);
315
1135
  if (!queryState) return;
316
-
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 || [];
1136
+ const epoch = this.local.epoch;
1137
+
1138
+ // Re-query local DB for latest data (windowed queries materialize from the
1139
+ // list_ref window so they resolve even if the in-browser SSP never emits —
1140
+ // it can't compute a high offset whose preceding rows aren't resident).
1141
+ const newRecords = await this.materializeRecords(queryState);
1142
+ // Bucket switched while we materialized: these rows mix old-bucket reads
1143
+ // with new-bucket state — drop them; the rebind/re-registration re-emits.
1144
+ if (epoch !== this.local.epoch) return;
323
1145
  const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
324
1146
  queryState.records = newRecords;
325
1147
 
326
- // Notify if data changed OR if this is the first sync (updateCount === 0)
327
- // The latter handles "query truly has no results" so UI can stop loading
328
- if (changed || queryState.updateCount === 0) {
1148
+ // Notify if data changed OR if this registration lifetime hasn't emitted a
1149
+ // post-sync notification yet. The latter handles "query truly has no
1150
+ // results" so the UI can stop loading — gated on the in-memory
1151
+ // `syncNotified` flag rather than `updateCount === 0`, because updateCount
1152
+ // is PERSISTED across deregister/re-register: a re-registered empty window
1153
+ // (updateCount > 0, records unchanged) would otherwise never emit and its
1154
+ // subscribers would show a loading state forever.
1155
+ if (changed || !queryState.syncNotified) {
1156
+ queryState.syncNotified = true;
329
1157
  queryState.updateCount++;
1158
+ queryState.lastUpdatedAt = Date.now();
330
1159
  const subscribers = this.subscriptions.get(queryHash);
331
1160
  if (subscribers) {
332
1161
  for (const callback of subscribers) {
@@ -344,6 +1173,21 @@ export class DataModule<S extends SchemaStructure> {
344
1173
  data: RoutePayload<S, B, R>,
345
1174
  options?: RunOptions
346
1175
  ): Promise<void> {
1176
+ const { tableName, record } = this.buildJobRecord(backend, path, data, options);
1177
+ const recordId = `${tableName}:${generateId()}`;
1178
+ await this.create(recordId, record);
1179
+ }
1180
+
1181
+ /**
1182
+ * Build the outbox job record + resolve its table for a backend route. Shared
1183
+ * by `run` (one-shot) and `runRecurring` (durable schedule).
1184
+ */
1185
+ private buildJobRecord<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
1186
+ backend: B,
1187
+ path: R,
1188
+ data: RoutePayload<S, B, R>,
1189
+ options?: RunOptions
1190
+ ): { tableName: string; record: Record<string, unknown> } {
347
1191
  const route = this.schema.backends?.[backend]?.routes?.[path];
348
1192
  if (!route) {
349
1193
  throw new Error(`Route ${backend}.${path} not found`);
@@ -370,12 +1214,126 @@ export class DataModule<S extends SchemaStructure> {
370
1214
  retry_strategy: options?.retry_strategy ?? 'linear',
371
1215
  };
372
1216
 
1217
+ if (options?.timeout != null) {
1218
+ record.timeout = options.timeout;
1219
+ }
1220
+
1221
+ if (options?.delay != null) {
1222
+ record.delay = options.delay;
1223
+ }
1224
+
373
1225
  if (options?.assignedTo) {
374
1226
  record.assigned_to = options.assignedTo;
375
1227
  }
376
1228
 
377
- const recordId = `${tableName}:${generateId()}`;
378
- await this.create(recordId, record);
1229
+ return { tableName, record };
1230
+ }
1231
+
1232
+ /**
1233
+ * Deterministic id for the single recurring-schedule row of a given
1234
+ * (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
1235
+ * fork a second schedule, and `poke`/`cancel` address the same row.
1236
+ */
1237
+ private recurringJobId(tableName: string, assignedTo: string, path: string): string {
1238
+ const suffix = `${assignedTo}_${path}`.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
1239
+ return `${tableName}:${suffix}`;
1240
+ }
1241
+
1242
+ /**
1243
+ * Register a RECURRING job: one durable row per (assigned_to, path) that
1244
+ * re-runs every `options.interval` ms (measured from each run's completion).
1245
+ * Idempotent: if the schedule already exists it is left untouched, so calling
1246
+ * this on every connect/re-login never forks a second schedule. The first run
1247
+ * fires immediately (`next_run_at = now`), then every interval thereafter.
1248
+ */
1249
+ async runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
1250
+ backend: B,
1251
+ path: R,
1252
+ data: RoutePayload<S, B, R>,
1253
+ options: RunOptions & { interval: number; assignedTo: string }
1254
+ ): Promise<void> {
1255
+ if (options?.interval == null) {
1256
+ throw new Error('runRecurring requires options.interval (ms)');
1257
+ }
1258
+ if (!options?.assignedTo) {
1259
+ throw new Error('runRecurring requires options.assignedTo');
1260
+ }
1261
+
1262
+ const { tableName, record } = this.buildJobRecord(backend, path, data, options);
1263
+ const recordId = this.recurringJobId(tableName, options.assignedTo, path);
1264
+ const rid = parseRecordIdString(recordId);
1265
+
1266
+ // Single schedule per key: if the row already exists, do nothing.
1267
+ const [existing] = await withRetry(this.logger, () =>
1268
+ this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
1269
+ );
1270
+ if (existing) return;
1271
+
1272
+ record.recurring = true;
1273
+ record.interval = options.interval;
1274
+ record.next_run_at = new Date(); // run now, then re-arm to now + interval on completion
1275
+ try {
1276
+ await this.create(recordId, record);
1277
+ } catch (err) {
1278
+ // The local existence check can miss a row that exists on the server but
1279
+ // hasn't synced into this session yet (e.g. a re-login before catch-up).
1280
+ // The deterministic id makes that CREATE collide; treat it as "schedule
1281
+ // already exists" and keep runRecurring idempotent rather than throwing.
1282
+ this.logger.debug(
1283
+ { id: recordId, err: (err as Error)?.message, Category: 'sp00ky-client::DataModule::runRecurring' },
1284
+ 'runRecurring create skipped (schedule likely already exists)'
1285
+ );
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Manually trigger a recurring job NOW and reset its interval clock. Sets
1291
+ * `next_run_at = now` on the schedule row; the SSP ingest picks up the update
1292
+ * and dispatches an immediate run, after which the runner re-arms the clock
1293
+ * from that run's completion. No-op if no schedule exists (caller should have
1294
+ * created one via `runRecurring`).
1295
+ */
1296
+ async pokeRecurring<B extends BackendNames<S>>(
1297
+ backend: B,
1298
+ path: BackendRoutes<S, B>,
1299
+ options: { assignedTo: string }
1300
+ ): Promise<void> {
1301
+ if (!options?.assignedTo) {
1302
+ throw new Error('pokeRecurring requires options.assignedTo');
1303
+ }
1304
+ const tableName = this.schema.backends?.[backend]?.outboxTable;
1305
+ if (!tableName) {
1306
+ throw new Error(`Outbox table for backend ${backend} not found`);
1307
+ }
1308
+ const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
1309
+ const rid = parseRecordIdString(recordId);
1310
+
1311
+ const [existing] = await withRetry(this.logger, () =>
1312
+ this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
1313
+ );
1314
+ if (!existing) return;
1315
+
1316
+ await this.update(tableName, recordId, { next_run_at: new Date() });
1317
+ }
1318
+
1319
+ /**
1320
+ * Cancel a recurring schedule: delete the single schedule row so it stops
1321
+ * being dispatched server-side.
1322
+ */
1323
+ async cancelRecurring<B extends BackendNames<S>>(
1324
+ backend: B,
1325
+ path: BackendRoutes<S, B>,
1326
+ options: { assignedTo: string }
1327
+ ): Promise<void> {
1328
+ if (!options?.assignedTo) {
1329
+ throw new Error('cancelRecurring requires options.assignedTo');
1330
+ }
1331
+ const tableName = this.schema.backends?.[backend]?.outboxTable;
1332
+ if (!tableName) {
1333
+ throw new Error(`Outbox table for backend ${backend} not found`);
1334
+ }
1335
+ const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
1336
+ await this.delete(tableName, recordId);
379
1337
  }
380
1338
 
381
1339
  // ==================== MUTATION MANAGEMENT ====================
@@ -392,7 +1350,7 @@ export class DataModule<S extends SchemaStructure> {
392
1350
 
393
1351
  const rid = parseRecordIdString(id);
394
1352
  const params = parseParams(tableSchema.columns, data);
395
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1353
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
396
1354
 
397
1355
  const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
398
1356
  const prefixedParams = Object.fromEntries(
@@ -441,7 +1399,7 @@ export class DataModule<S extends SchemaStructure> {
441
1399
  callback([mutationEvent]);
442
1400
  }
443
1401
 
444
- this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
1402
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
445
1403
 
446
1404
  return target;
447
1405
  }
@@ -463,7 +1421,10 @@ export class DataModule<S extends SchemaStructure> {
463
1421
 
464
1422
  const rid = parseRecordIdString(id);
465
1423
  const params = parseParams(tableSchema.columns, data);
466
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1424
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1425
+
1426
+ // Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
1427
+ // NOT through the record update pipeline. This keeps the record data clean.
467
1428
 
468
1429
  // Capture current record state before mutation for rollback support
469
1430
  const [beforeRecord] = await withRetry(this.logger, () =>
@@ -472,7 +1433,7 @@ export class DataModule<S extends SchemaStructure> {
472
1433
 
473
1434
  const query = surql.seal<{ target: T }>(
474
1435
  surql.tx([
475
- surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
1436
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
476
1437
  surql.let('updated', surql.updateMerge('id', 'data')),
477
1438
  surql.createMutation('update', 'mid', 'id', 'data'),
478
1439
  surql.returnObject([{ key: 'target', variable: 'updated' }]),
@@ -496,8 +1457,8 @@ export class DataModule<S extends SchemaStructure> {
496
1457
  updatedFields[key] = (target as Record<string, any>)[key];
497
1458
  }
498
1459
  }
499
- if ('spooky_rv' in (target as Record<string, any>)) {
500
- updatedFields.spooky_rv = (target as Record<string, any>).spooky_rv;
1460
+ if ('_00_rv' in (target as Record<string, any>)) {
1461
+ updatedFields._00_rv = (target as Record<string, any>)._00_rv;
501
1462
  }
502
1463
  this.replaceRecordInQueries(updatedFields);
503
1464
 
@@ -509,7 +1470,7 @@ export class DataModule<S extends SchemaStructure> {
509
1470
  table: table,
510
1471
  op: 'UPDATE',
511
1472
  record: parsedRecord,
512
- version: target.spooky_rv as number,
1473
+ version: target._00_rv as number,
513
1474
  },
514
1475
  true
515
1476
  );
@@ -531,7 +1492,7 @@ export class DataModule<S extends SchemaStructure> {
531
1492
  callback([mutationEvent]);
532
1493
  }
533
1494
 
534
- this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
1495
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
535
1496
 
536
1497
  return target;
537
1498
  }
@@ -547,14 +1508,53 @@ export class DataModule<S extends SchemaStructure> {
547
1508
  }
548
1509
 
549
1510
  const rid = parseRecordIdString(id);
550
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1511
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1512
+
1513
+ // Fetch the record before deleting so DBSP can match it against query predicates
1514
+ const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
1515
+ 'SELECT * FROM ONLY $id',
1516
+ { id: rid }
1517
+ );
1518
+ const beforeRecord = beforeRecords ?? {};
551
1519
 
552
1520
  const query = surql.seal<void>(
553
1521
  surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
554
1522
  );
555
1523
 
556
1524
  await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
557
- await this.cache.delete(table, id, true);
1525
+
1526
+ // The local DELETE has now committed. Everything below must reflect that in
1527
+ // active live queries — so the deleted row disappears optimistically without
1528
+ // a reload — even if the optimistic SSP-view ingest below fails. Previously a
1529
+ // throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
1530
+ // commit, so the manual notify loop never ran and the row lingered on screen
1531
+ // until reload. Ingesting the delete into the in-browser SSP view is
1532
+ // best-effort: the manual re-materialize reads the local DB (which already
1533
+ // excludes the row), so the result is correct regardless.
1534
+ try {
1535
+ await this.cache.delete(table, id, true, beforeRecord);
1536
+ } catch (err) {
1537
+ this.logger.error(
1538
+ { err, id, Category: 'sp00ky-client::DataModule::delete' },
1539
+ 'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
1540
+ );
1541
+ }
1542
+
1543
+ // DBSP may not emit view updates for DELETE ops — manually notify all queries
1544
+ // that reference this table. Each is isolated so one failing re-materialize
1545
+ // can't stop the others (or the sync emit below) from running.
1546
+ for (const [queryHash, queryState] of this.activeQueries) {
1547
+ if (queryState.config.tableName === tableName) {
1548
+ try {
1549
+ await this.notifyQuerySynced(queryHash);
1550
+ } catch (err) {
1551
+ this.logger.error(
1552
+ { err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
1553
+ 'notifyQuerySynced failed after delete'
1554
+ );
1555
+ }
1556
+ }
1557
+ }
558
1558
 
559
1559
  // Emit mutation event
560
1560
  const mutationEvent: DeleteEvent = {
@@ -567,7 +1567,7 @@ export class DataModule<S extends SchemaStructure> {
567
1567
  callback([mutationEvent]);
568
1568
  }
569
1569
 
570
- this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
1570
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
571
1571
  }
572
1572
 
573
1573
  // ==================== ROLLBACK METHODS ====================
@@ -586,12 +1586,12 @@ export class DataModule<S extends SchemaStructure> {
586
1586
  this.removeRecordFromQueries(recordId);
587
1587
 
588
1588
  this.logger.info(
589
- { id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1589
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
590
1590
  'Rolled back optimistic create'
591
1591
  );
592
1592
  } catch (err) {
593
1593
  this.logger.error(
594
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1594
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
595
1595
  'Failed to rollback create'
596
1596
  );
597
1597
  }
@@ -626,7 +1626,7 @@ export class DataModule<S extends SchemaStructure> {
626
1626
  table: tableName,
627
1627
  op: 'UPDATE',
628
1628
  record: parsedRecord,
629
- version: (beforeRecord.spooky_rv as number) || 1,
1629
+ version: (beforeRecord._00_rv as number) || 1,
630
1630
  },
631
1631
  true
632
1632
  );
@@ -635,12 +1635,12 @@ export class DataModule<S extends SchemaStructure> {
635
1635
  await this.replaceRecordInQueries(beforeRecord);
636
1636
 
637
1637
  this.logger.info(
638
- { id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1638
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
639
1639
  'Rolled back optimistic update'
640
1640
  );
641
1641
  } catch (err) {
642
1642
  this.logger.error(
643
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1643
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
644
1644
  'Failed to rollback update'
645
1645
  );
646
1646
  }
@@ -678,7 +1678,8 @@ export class DataModule<S extends SchemaStructure> {
678
1678
  surqlString: string,
679
1679
  params: Record<string, any>,
680
1680
  ttl: QueryTimeToLive,
681
- tableName: T
1681
+ tableName: T,
1682
+ plan?: QueryPlan
682
1683
  ): Promise<QueryHash> {
683
1684
  const queryState = await this.createNewQuery<T>({
684
1685
  recordId,
@@ -686,31 +1687,76 @@ export class DataModule<S extends SchemaStructure> {
686
1687
  params,
687
1688
  ttl,
688
1689
  tableName,
1690
+ plan,
689
1691
  });
690
1692
 
691
- const { localArray } = this.cache.registerQuery({
1693
+ const t0 = performance.now();
1694
+ const { localArray, registrationTimings } = this.cache.registerQuery({
692
1695
  queryHash: hash,
693
1696
  surql: surqlString,
694
1697
  params,
695
1698
  ttl: new Duration(ttl),
696
1699
  lastActiveAt: new Date(),
697
1700
  });
1701
+ const registrationTime = performance.now() - t0;
1702
+
1703
+ // Record the one-shot SSP registration timings (parse/plan/snapshot from the
1704
+ // WASM binding) + the register_view wall time for DevTools.
1705
+ queryState.registrationTimings = {
1706
+ parseMs: registrationTimings?.parseMs ?? null,
1707
+ planMs: registrationTimings?.planMs ?? null,
1708
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
1709
+ wallMs: registrationTime,
1710
+ };
698
1711
 
699
1712
  await withRetry(this.logger, () =>
700
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
701
- id: recordId,
702
- localArray,
703
- })
1713
+ this.local.query(
1714
+ surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
1715
+ {
1716
+ id: recordId,
1717
+ localArray,
1718
+ registrationTime,
1719
+ rowCount: localArray.length,
1720
+ }
1721
+ )
704
1722
  );
705
1723
 
1724
+ // Windowed (`START n`) queries skipped the raw initial load in
1725
+ // createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
1726
+ // initial rows now from the SSP's window id-set (`localArray`) via the same
1727
+ // window-materialization path the stream updates use — O(window), and the
1728
+ // ids are already the correct window — so the first paint isn't empty while
1729
+ // the remote `_00_list_ref` syncs in.
1730
+ const windowMat = buildWindowMaterialization(surqlString);
1731
+ if (windowMat && localArray.length > 0) {
1732
+ try {
1733
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
1734
+ if (plan) {
1735
+ const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
1736
+ queryState.records = await this.local.select(winPlan, params);
1737
+ } else {
1738
+ const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
1739
+ ...params,
1740
+ __win: winIds,
1741
+ });
1742
+ queryState.records = seeded || [];
1743
+ }
1744
+ } catch (err) {
1745
+ this.logger.warn(
1746
+ { err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
1747
+ 'Failed to seed windowed initial records from localArray'
1748
+ );
1749
+ }
1750
+ }
1751
+
706
1752
  this.activeQueries.set(hash, queryState);
707
- this.startTTLHeartbeat(queryState);
1753
+ this.startTTLHeartbeat(queryState, hash);
708
1754
  this.logger.debug(
709
1755
  {
710
1756
  hash,
711
1757
  tableName,
712
1758
  recordCount: queryState.records.length,
713
- Category: 'spooky-client::DataModule::query',
1759
+ Category: 'sp00ky-client::DataModule::query',
714
1760
  },
715
1761
  'Query registered'
716
1762
  );
@@ -724,12 +1770,14 @@ export class DataModule<S extends SchemaStructure> {
724
1770
  params,
725
1771
  ttl,
726
1772
  tableName,
1773
+ plan,
727
1774
  }: {
728
1775
  recordId: RecordId;
729
1776
  surql: string;
730
1777
  params: Record<string, any>;
731
1778
  ttl: QueryTimeToLive;
732
1779
  tableName: T;
1780
+ plan?: QueryPlan;
733
1781
  }): Promise<QueryState> {
734
1782
  const tableSchema = this.schema.tables.find((t) => t.name === tableName);
735
1783
  if (!tableSchema) {
@@ -752,8 +1800,12 @@ export class DataModule<S extends SchemaStructure> {
752
1800
  localArray: [],
753
1801
  remoteArray: [],
754
1802
  lastActiveAt: new Date(),
1803
+ createdAt: new Date(),
755
1804
  ttl,
756
1805
  tableName,
1806
+ updateCount: 0,
1807
+ rowCount: 0,
1808
+ errorCount: 0,
757
1809
  },
758
1810
  })
759
1811
  );
@@ -763,62 +1815,111 @@ export class DataModule<S extends SchemaStructure> {
763
1815
  const config: QueryConfig = {
764
1816
  ...configRecord,
765
1817
  id: recordId,
1818
+ // In-memory only — carries the engine-neutral plan so non-SurrealQL local
1819
+ // engines materialize via `select(plan)` instead of parsing `surql`.
1820
+ plan,
766
1821
  params: parseParams(tableSchema.columns, configRecord.params),
767
1822
  };
768
1823
 
769
1824
  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
- );
1825
+ // Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
1826
+ // `… LIMIT n START m` against the shared local store is O(m) — it sorts and
1827
+ // skips m rows on every window open — AND returns the wrong rows for sparse
1828
+ // windows (the reason `buildWindowMaterialization` exists). Those windows are
1829
+ // seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
1830
+ if (buildWindowMaterialization(surqlString) === null) {
1831
+ try {
1832
+ // Prefer the engine-neutral plan (required for non-SurrealQL engines);
1833
+ // fall back to running the raw surql on the SurrealDB engine.
1834
+ const result = plan
1835
+ ? await this.local.select(plan, params)
1836
+ : (await this.local.query<[Record<string, any>[]]>(surqlString, params))[0];
1837
+ records = result || [];
1838
+ } catch (err) {
1839
+ this.logger.warn(
1840
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
1841
+ 'Failed to load initial cached records'
1842
+ );
1843
+ }
778
1844
  }
779
1845
 
1846
+ // Persisted counters survive a restart even though the rolling
1847
+ // sample window is rebuilt from scratch in memory.
1848
+ const persistedUpdateCount =
1849
+ typeof (configRecord as any)?.updateCount === 'number'
1850
+ ? (configRecord as any).updateCount
1851
+ : 0;
1852
+ const persistedErrorCount =
1853
+ typeof (configRecord as any)?.errorCount === 'number'
1854
+ ? (configRecord as any).errorCount
1855
+ : 0;
1856
+
780
1857
  return {
781
1858
  config,
782
1859
  records,
783
1860
  ttlTimer: null,
784
1861
  ttlDurationMs: parseDuration(ttl),
785
- updateCount: 0,
1862
+ updateCount: persistedUpdateCount,
1863
+ lastUpdatedAt: null,
1864
+ materializationSamples: [],
1865
+ lastIngestLatencyMs: null,
1866
+ errorCount: persistedErrorCount,
1867
+ // Born `fetching`, not `idle`: every cold registration is followed by a
1868
+ // `register` down-event whose lifecycle (Sp00kySync.registerQuery) resolves
1869
+ // the status to `idle` once the initial sync completed. Starting idle left
1870
+ // a gap where a fresh windowed query looked settled while still empty.
1871
+ status: 'fetching',
1872
+ phaseSamples: {},
1873
+ phaseLast: {},
1874
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
786
1875
  };
787
1876
  }
788
1877
 
789
1878
  private async calculateHash(data: any): Promise<string> {
790
- const content = JSON.stringify(data);
1879
+ // sessionId is part of the hash so the same logical query from two
1880
+ // sessions (e.g. two browser tabs of the same user) lands on different
1881
+ // `_00_query` rows and doesn't fight over a shared one.
1882
+ const content = JSON.stringify({ ...data, sessionId: this.sessionId });
791
1883
  const msgBuffer = new TextEncoder().encode(content);
792
1884
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
793
1885
  const hashArray = Array.from(new Uint8Array(hashBuffer));
794
1886
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
795
1887
  }
796
1888
 
797
- private startTTLHeartbeat(queryState: QueryState): void {
1889
+ private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
798
1890
  if (queryState.ttlTimer) return;
799
1891
 
800
1892
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
801
1893
 
802
1894
  queryState.ttlTimer = setTimeout(() => {
803
- // TODO: Emit heartbeat event for sync
1895
+ queryState.ttlTimer = null;
1896
+ // Only keep the remote query alive while something is actually watching
1897
+ // it. With the server now sweeping ALL expired views (not just in-circuit
1898
+ // ones), an un-refreshed query WOULD be swept after its TTL — so a live
1899
+ // subscriber must heartbeat. An abandoned query (no subscribers) is left
1900
+ // to expire and get swept; its local view was already torn down at the
1901
+ // last unsubscribe, so we just stop the timer here.
1902
+ const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
1903
+ if (subscriberCount === 0) {
1904
+ this.logger.debug(
1905
+ { hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
1906
+ 'TTL heartbeat: no subscribers, stopping'
1907
+ );
1908
+ return;
1909
+ }
1910
+ this.onHeartbeat?.(hash);
804
1911
  this.logger.debug(
805
1912
  {
1913
+ hash,
806
1914
  id: encodeRecordId(queryState.config.id),
807
- Category: 'spooky-client::DataModule::startTTLHeartbeat',
1915
+ Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
808
1916
  },
809
- 'TTL heartbeat'
1917
+ 'TTL heartbeat sent'
810
1918
  );
811
- this.startTTLHeartbeat(queryState);
1919
+ this.startTTLHeartbeat(queryState, hash);
812
1920
  }, heartbeatTime);
813
1921
  }
814
1922
 
815
- private stopTTLHeartbeat(queryState: QueryState): void {
816
- if (queryState.ttlTimer) {
817
- clearTimeout(queryState.ttlTimer);
818
- queryState.ttlTimer = null;
819
- }
820
- }
821
-
822
1923
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
823
1924
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
824
1925
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -851,7 +1952,7 @@ export function parseUpdateOptions(
851
1952
  const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
852
1953
  const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
853
1954
  const key =
854
- keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
1955
+ keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
855
1956
 
856
1957
  pushEventOptions = {
857
1958
  debounced: {