@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201

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 (148) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2184 -54
  3. package/dist/index.js +11515 -2399
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +421 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +688 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +35 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/events/index.ts +2 -1
  25. package/src/modules/auth/index.ts +59 -20
  26. package/src/modules/cache/index.ts +112 -32
  27. package/src/modules/cache/types.ts +2 -2
  28. package/src/modules/crdt/crdt-field.ts +294 -0
  29. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  30. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  31. package/src/modules/crdt/index.ts +463 -0
  32. package/src/modules/crdt/loro-loader.ts +25 -0
  33. package/src/modules/data/data.hydration.test.ts +142 -0
  34. package/src/modules/data/data.membership.test.ts +462 -0
  35. package/src/modules/data/data.rebind.test.ts +147 -0
  36. package/src/modules/data/data.run.test.ts +113 -0
  37. package/src/modules/data/data.settled-writes.test.ts +206 -0
  38. package/src/modules/data/data.status.test.ts +249 -0
  39. package/src/modules/data/id-set-plan.test.ts +122 -0
  40. package/src/modules/data/index.ts +1580 -130
  41. package/src/modules/data/mutation-id.test.ts +25 -0
  42. package/src/modules/data/mutation-id.ts +35 -0
  43. package/src/modules/data/window-query.test.ts +52 -0
  44. package/src/modules/data/window-query.ts +194 -0
  45. package/src/modules/devtools/flags.ts +349 -0
  46. package/src/modules/devtools/index.ts +386 -37
  47. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  48. package/src/modules/devtools/storage-info.test.ts +79 -0
  49. package/src/modules/devtools/storage-info.ts +168 -0
  50. package/src/modules/devtools/versions.test.ts +74 -0
  51. package/src/modules/devtools/versions.ts +110 -0
  52. package/src/modules/feature-flag/index.test.ts +251 -0
  53. package/src/modules/feature-flag/index.ts +308 -0
  54. package/src/modules/ref-tables.test.ts +91 -0
  55. package/src/modules/ref-tables.ts +88 -0
  56. package/src/modules/sync/engine.ts +101 -37
  57. package/src/modules/sync/events/index.ts +9 -2
  58. package/src/modules/sync/queue/queue-down.test.ts +107 -0
  59. package/src/modules/sync/queue/queue-down.ts +35 -6
  60. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  61. package/src/modules/sync/queue/queue-up.ts +241 -57
  62. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  63. package/src/modules/sync/scheduler.retry.test.ts +156 -0
  64. package/src/modules/sync/scheduler.ts +158 -11
  65. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  66. package/src/modules/sync/sync.health.test.ts +149 -0
  67. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  68. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  69. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  70. package/src/modules/sync/sync.subquery.test.ts +82 -0
  71. package/src/modules/sync/sync.ts +1558 -99
  72. package/src/modules/sync/utils.test.ts +269 -2
  73. package/src/modules/sync/utils.ts +201 -17
  74. package/src/otel/index.ts +13 -10
  75. package/src/services/blobs/blob-cache.test.ts +359 -0
  76. package/src/services/blobs/blob-cache.ts +603 -0
  77. package/src/services/blobs/blob-manifest.ts +227 -0
  78. package/src/services/blobs/blob-store.test.ts +77 -0
  79. package/src/services/blobs/blob-store.ts +359 -0
  80. package/src/services/blobs/blob.fixture.ts +90 -0
  81. package/src/services/blobs/index.ts +70 -0
  82. package/src/services/database/cache-engine.ts +160 -0
  83. package/src/services/database/connection-supervisor.test.ts +289 -0
  84. package/src/services/database/connection-supervisor.ts +415 -0
  85. package/src/services/database/database.query-timeout.test.ts +83 -0
  86. package/src/services/database/database.ts +32 -12
  87. package/src/services/database/engine-factory.ts +33 -0
  88. package/src/services/database/events/index.ts +2 -1
  89. package/src/services/database/index.ts +7 -0
  90. package/src/services/database/local-migrator.ts +30 -27
  91. package/src/services/database/local.test.ts +64 -0
  92. package/src/services/database/local.ts +478 -67
  93. package/src/services/database/plan-render.test.ts +159 -0
  94. package/src/services/database/plan-render.ts +108 -0
  95. package/src/services/database/relation-resolver.test.ts +413 -0
  96. package/src/services/database/relation-resolver.ts +0 -0
  97. package/src/services/database/remote.ts +110 -14
  98. package/src/services/database/sqlite-cache-engine.test.ts +558 -0
  99. package/src/services/database/sqlite-cache-engine.ts +1257 -0
  100. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  101. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  102. package/src/services/database/sqlite-open.test.ts +150 -0
  103. package/src/services/database/sqlite-open.ts +164 -0
  104. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  105. package/src/services/database/sqlite-plan-sql.ts +106 -0
  106. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  107. package/src/services/database/sqlite-select.test.ts +246 -0
  108. package/src/services/database/sqlite-select.ts +121 -0
  109. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  110. package/src/services/database/sqlite-transport.ts +221 -0
  111. package/src/services/database/sqlite-worker.ts +437 -0
  112. package/src/services/database/surql-translate.ts +416 -0
  113. package/src/services/database/surreal-cache-engine.ts +141 -0
  114. package/src/services/logger/index.ts +3 -2
  115. package/src/services/persistence/localstorage.ts +2 -2
  116. package/src/services/persistence/resilient.ts +11 -4
  117. package/src/services/persistence/surrealdb.ts +10 -10
  118. package/src/services/stream-processor/index.ts +444 -52
  119. package/src/services/stream-processor/permissions.test.ts +47 -0
  120. package/src/services/stream-processor/permissions.ts +53 -0
  121. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  122. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  123. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  124. package/src/services/stream-processor/wasm-types.ts +23 -2
  125. package/src/services/tabs/broker-client.ts +283 -0
  126. package/src/services/tabs/broker.test.ts +278 -0
  127. package/src/services/tabs/coordinator.test.ts +244 -0
  128. package/src/services/tabs/coordinator.ts +576 -0
  129. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  130. package/src/services/tabs/leader-locks.ts +75 -0
  131. package/src/services/tabs/protocol.ts +242 -0
  132. package/src/services/tabs/support.ts +36 -0
  133. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  134. package/src/sp00ky.auth-order.test.ts +92 -0
  135. package/src/sp00ky.init-query.test.ts +183 -0
  136. package/src/sp00ky.ts +1543 -0
  137. package/src/types.ts +496 -13
  138. package/src/utils/blurhash.ts +90 -0
  139. package/src/utils/error-classification.test.ts +44 -0
  140. package/src/utils/error-classification.ts +7 -0
  141. package/src/utils/index.ts +73 -13
  142. package/src/utils/parser.ts +3 -2
  143. package/src/utils/semver.test.ts +32 -0
  144. package/src/utils/semver.ts +30 -0
  145. package/src/utils/surql.ts +30 -18
  146. package/src/utils/withRetry.test.ts +1 -1
  147. package/tsdown.config.ts +86 -1
  148. package/src/spooky.ts +0 -395
@@ -1,28 +1,35 @@
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,
27
+ QueryTimings,
28
+ PhaseStat,
29
+ RegistrationTimings,
24
30
  RunOptions,
25
31
  } from '../../types';
32
+ import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
26
33
  import {
27
34
  parseRecordIdString,
28
35
  extractIdPart,
@@ -31,11 +38,43 @@ import {
31
38
  withRetry,
32
39
  surql,
33
40
  parseParams,
41
+ cleanRecord,
34
42
  extractTablePart,
35
43
  generateId,
36
44
  } from '../../utils/index';
37
- import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
38
- import { PushEventOptions } from '../../events/index';
45
+ import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
46
+ import type { PushEventOptions } from '../../events/index';
47
+ import {
48
+ buildIdSetPlan,
49
+ buildIdSetSurql,
50
+ buildWindowMaterialization,
51
+ buildWindowMaterializationPlan,
52
+ } from './window-query';
53
+ import { mintMutationId } from './mutation-id';
54
+
55
+ /**
56
+ * How many consecutive empty `_00_list_ref` reads it takes to believe a query
57
+ * really is empty, before any non-empty set has been seen this session. One
58
+ * read is the registration race (the SSP flushes a view's initial edges
59
+ * asynchronously); the second comes from the poll a cycle later.
60
+ */
61
+ const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
62
+
63
+ /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
64
+ function pushSample(samples: number[], ms: number): void {
65
+ samples.push(ms);
66
+ if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
67
+ }
68
+
69
+ /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
70
+ function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
71
+ if (samples.length === 0) {
72
+ return { lastMs, p50: null, p90: null, p99: null, count: 0 };
73
+ }
74
+ const sorted = [...samples].sort((a, b) => a - b);
75
+ const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
76
+ return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
77
+ }
39
78
 
40
79
  /**
41
80
  * DataModule - Unified query and mutation management
@@ -44,25 +83,117 @@ import { PushEventOptions } from '../../events/index';
44
83
  * Uses CacheModule for all storage operations.
45
84
  */
46
85
  export class DataModule<S extends SchemaStructure> {
86
+ /** Tab identity baked into mutation ids (shared-tabs rollback routing);
87
+ * undefined in solo mode, where mutation-id falls back to a session id. */
88
+ private tabId: string | undefined;
47
89
  private activeQueries: Map<QueryHash, QueryState> = new Map();
48
90
  private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
49
91
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
92
+ private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
50
93
  private mutationCallbacks: Set<MutationCallback> = new Set();
51
94
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
95
+ // The update each debounce timer would process on its trailing edge. Kept in a
96
+ // map (not just the timer closure) so `flushPendingStreamUpdate` can process
97
+ // it early — the sync engine flushes before flipping a query to `idle`, so
98
+ // subscribers never observe idle status with stale (partial-window) rows.
99
+ private pendingStreamUpdates: Map<QueryHash, StreamUpdate> = new Map();
100
+ // Refcount of in-flight fetch cycles per query (registration + concurrent
101
+ // poll/LIVE sync rounds can overlap). Status flips to `fetching` on 0→1 and
102
+ // back to `idle` only on 1→0, so an inner cycle finishing can't emit a
103
+ // premature idle mid-registration.
104
+ private fetchDepth: Map<QueryHash, number> = new Map();
52
105
  private logger: Logger;
106
+ /**
107
+ * Optional observer notified whenever a query's fetch status changes.
108
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
109
+ * settable field (rather than a constructor arg) because DevTools is
110
+ * constructed after DataModule.
111
+ */
112
+ public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
113
+ /**
114
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
115
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
116
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
117
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
118
+ * field (not a constructor arg) because the sync engine is wired after
119
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
120
+ */
121
+ public onHeartbeat?: (hash: QueryHash) => void;
122
+ /**
123
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
124
+ * viewport-windowed list cancelling an off-screen window) loses its last
125
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
126
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
127
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
128
+ * {@link finalizeDeregister} only after that remote delete, so a fast
129
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
130
+ */
131
+ public onDeregister?: (hash: QueryHash) => void;
132
+ // Salt for query-id hashing. Set from SurrealDB's session::id() so two
133
+ // browser sessions registering the same logical query (same surql + params)
134
+ // don't collide on the same `_00_query` row — each session gets its own.
135
+ // Empty string until init(sessionId) is called.
136
+ private sessionId: string = '';
137
+ // Authenticated user record id (e.g. `"user:abc"`). Updated by
138
+ // `setCurrentUserId` from the auth subscription. null when
139
+ // unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
140
+ // poll and LIVE subscription target the same per-user
141
+ // `_00_list_ref_user_<id>` table the sync engine writes to.
142
+ private currentUserId: string | null = null;
53
143
 
54
144
  constructor(
55
145
  private cache: CacheModule,
56
- private local: LocalDatabaseService,
146
+ private local: LocalStore,
57
147
  private schema: S,
58
148
  logger: Logger,
59
- private streamDebounceTime: number = 100
149
+ // Client-side SSP aggregation throttle: coalesces the in-browser
150
+ // StreamProcessor's per-record stream updates per query before notifying
151
+ // readers, so a burst of synced records repaints the UI once per window
152
+ // rather than row-by-row. 50ms keeps it responsive while still batching the
153
+ // initial-sync stream. Override via `SyncedDbConfig.streamDebounceTime`.
154
+ private streamDebounceTime: number = 50
60
155
  ) {
61
156
  this.logger = logger.child({ service: 'DataModule' });
62
157
  }
63
158
 
64
- async init(): Promise<void> {
65
- this.logger.info({ Category: 'spooky-client::DataModule::init' }, 'DataModule initialized');
159
+ async init(sessionId: string): Promise<void> {
160
+ this.sessionId = sessionId;
161
+ this.logger.info(
162
+ { sessionId, Category: 'sp00ky-client::DataModule::init' },
163
+ 'DataModule initialized'
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Update the session salt used in query-id hashing. Call this when the
169
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
170
+ * registered queries will get fresh, session-scoped IDs.
171
+ */
172
+ setSessionId(sessionId: string): void {
173
+ this.sessionId = sessionId;
174
+ }
175
+
176
+ /** Shared-tabs: bake this tab's identity into mutation ids so a rollback of
177
+ * a follower's mutation routes back to the tab that made it. */
178
+ setTabId(tabId: string): void {
179
+ this.tabId = tabId;
180
+ }
181
+
182
+ /**
183
+ * Update the authenticated user record id. Pass `null` on sign-out.
184
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
185
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
186
+ * SSP writes to.
187
+ */
188
+ setCurrentUserId(userId: string | null): void {
189
+ this.currentUserId = userId;
190
+ }
191
+
192
+ /** Read-only view of the authenticated user id used for per-user
193
+ * `_00_list_ref` routing. Other modules consult this so they pick the
194
+ * same table name DataModule does. */
195
+ getCurrentUserId(): string | null {
196
+ return this.currentUserId;
66
197
  }
67
198
 
68
199
  // ==================== QUERY MANAGEMENT ====================
@@ -74,19 +205,22 @@ export class DataModule<S extends SchemaStructure> {
74
205
  tableName: T,
75
206
  surqlString: string,
76
207
  params: Record<string, any>,
77
- ttl: QueryTimeToLive
208
+ ttl: QueryTimeToLive,
209
+ plan?: QueryPlan
78
210
  ): Promise<QueryHash> {
79
211
  const hash = await this.calculateHash({ surql: surqlString, params });
80
212
  this.logger.debug(
81
- { hash, Category: 'spooky-client::DataModule::query' },
213
+ { hash, Category: 'sp00ky-client::DataModule::query' },
82
214
  'Query Initialization: started'
83
215
  );
84
216
 
85
- const recordId = new RecordId('_spooky_query', hash);
217
+ // `_00_query` stays the single shared registration table in both
218
+ // ref-modes; the per-user split happens only on `_00_list_ref`.
219
+ const recordId = new RecordId('_00_query', hash);
86
220
 
87
221
  if (this.activeQueries.has(hash)) {
88
222
  this.logger.debug(
89
- { hash, Category: 'spooky-client::DataModule::query' },
223
+ { hash, Category: 'sp00ky-client::DataModule::query' },
90
224
  'Query Initialization: exists, returning'
91
225
  );
92
226
  return hash;
@@ -95,7 +229,7 @@ export class DataModule<S extends SchemaStructure> {
95
229
  // Another call is already creating this query — wait for it
96
230
  if (this.pendingQueries.has(hash)) {
97
231
  this.logger.debug(
98
- { hash, Category: 'spooky-client::DataModule::query' },
232
+ { hash, Category: 'sp00ky-client::DataModule::query' },
99
233
  'Query Initialization: pending, waiting for existing creation'
100
234
  );
101
235
  await this.pendingQueries.get(hash);
@@ -103,12 +237,21 @@ export class DataModule<S extends SchemaStructure> {
103
237
  }
104
238
 
105
239
  this.logger.debug(
106
- { hash, Category: 'spooky-client::DataModule::query' },
240
+ { hash, Category: 'sp00ky-client::DataModule::query' },
107
241
  'Query Initialization: not found, creating new query'
108
242
  );
109
243
 
110
244
  // Create the query and track the pending promise
111
- const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
245
+ const promise = this.createAndRegisterQuery<T>(
246
+ hash,
247
+ recordId,
248
+ surqlString,
249
+ params,
250
+ ttl,
251
+ tableName,
252
+ plan,
253
+ await this.calculateMembershipKey({ surql: surqlString, params })
254
+ );
112
255
  this.pendingQueries.set(hash, promise);
113
256
  try {
114
257
  await promise;
@@ -147,11 +290,95 @@ export class DataModule<S extends SchemaStructure> {
147
290
  subs.delete(callback);
148
291
  if (subs.size === 0) {
149
292
  this.subscriptions.delete(queryHash);
293
+ // NOTE: intentionally do NOT tear down the query / free its in-browser
294
+ // SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
295
+ // already self-stops once subscribers hit 0, so an abandoned query
296
+ // stops being kept alive and the server's TTL sweep removes it. Freeing
297
+ // the local view on every last-unsubscribe caused re-registration churn
298
+ // on navigation (open A → leave → open A again re-registers), which is
299
+ // a flakiness risk for no real benefit — keep the local view resident.
150
300
  }
151
301
  }
152
302
  };
153
303
  }
154
304
 
305
+ /**
306
+ * Subscribe to a query's fetch-status changes (idle/fetching).
307
+ * With `{ immediate: true }` the callback fires synchronously with the
308
+ * current status (defaults to `idle` if the query isn't registered yet).
309
+ */
310
+ subscribeStatus(
311
+ queryHash: string,
312
+ callback: QueryStatusCallback,
313
+ options: { immediate?: boolean } = {}
314
+ ): () => void {
315
+ if (!this.statusSubscriptions.has(queryHash)) {
316
+ this.statusSubscriptions.set(queryHash, new Set());
317
+ }
318
+ this.statusSubscriptions.get(queryHash)?.add(callback);
319
+
320
+ if (options.immediate) {
321
+ callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
322
+ }
323
+
324
+ return () => {
325
+ const subs = this.statusSubscriptions.get(queryHash);
326
+ if (subs) {
327
+ subs.delete(callback);
328
+ if (subs.size === 0) {
329
+ this.statusSubscriptions.delete(queryHash);
330
+ }
331
+ }
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Set a query's fetch status and notify status observers (DevTools +
337
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
338
+ * query is unknown.
339
+ */
340
+ setQueryStatus(queryHash: string, status: QueryStatus): void {
341
+ const queryState = this.activeQueries.get(queryHash);
342
+ if (!queryState || queryState.status === status) {
343
+ return;
344
+ }
345
+ queryState.status = status;
346
+
347
+ this.onQueryStatusChange?.(queryHash, status);
348
+
349
+ const subs = this.statusSubscriptions.get(queryHash);
350
+ if (subs) {
351
+ for (const callback of subs) {
352
+ callback(status);
353
+ }
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Enter a fetch cycle for a query. Refcounted: registration and concurrent
359
+ * poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
360
+ * cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
361
+ * emits `idle`. Always pair with `endFetching` in a `finally`.
362
+ */
363
+ beginFetching(queryHash: string): void {
364
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
365
+ this.fetchDepth.set(queryHash, depth + 1);
366
+ if (depth === 0) {
367
+ this.setQueryStatus(queryHash, 'fetching');
368
+ }
369
+ }
370
+
371
+ /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
372
+ endFetching(queryHash: string): void {
373
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
374
+ if (depth <= 1) {
375
+ this.fetchDepth.delete(queryHash);
376
+ this.setQueryStatus(queryHash, 'idle');
377
+ return;
378
+ }
379
+ this.fetchDepth.set(queryHash, depth - 1);
380
+ }
381
+
155
382
  /**
156
383
  * Subscribe to mutations (for sync)
157
384
  */
@@ -168,67 +395,392 @@ export class DataModule<S extends SchemaStructure> {
168
395
  async onStreamUpdate(update: StreamUpdate): Promise<void> {
169
396
  const { queryHash, op } = update;
170
397
 
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)!);
398
+ // DELETE propagates immediately — a removed row should disappear without
399
+ // waiting on a debounce.
400
+ //
401
+ // CREATE and UPDATE are coalesced per query on a trailing timer. A list's
402
+ // rows stream in from sync as many small `_00_list_ref` diffs, each its own
403
+ // `cache.saveBatch` → one stream update per chunk. `ingestMany` only
404
+ // coalesces records ingested synchronously together, so chunks spread over
405
+ // time each re-materialize and re-notify — a 50-row window can fire 30+
406
+ // updates as it fills. Each StreamUpdate carries the full materialized
407
+ // `localArray`, so the latest one already reflects every prior chunk: keep
408
+ // only it and fire once on the trailing edge, settling the query in a couple
409
+ // of notifications instead of one per chunk.
410
+ if (op === 'DELETE') {
411
+ const existing = this.debounceTimers.get(queryHash);
412
+ if (existing) {
413
+ clearTimeout(existing);
414
+ this.debounceTimers.delete(queryHash);
177
415
  }
416
+ // The DELETE update carries the full latest localArray, so the coalesced
417
+ // CREATE/UPDATE it supersedes is already reflected — drop it.
418
+ this.pendingStreamUpdates.delete(queryHash);
419
+ await this.processStreamUpdate(update);
420
+ return;
421
+ }
178
422
 
179
- // Set new timer
180
- const timer = setTimeout(async () => {
181
- this.debounceTimers.delete(queryHash);
182
- await this.processStreamUpdate(update);
183
- }, this.streamDebounceTime);
423
+ // Clear existing timer if any
424
+ if (this.debounceTimers.has(queryHash)) {
425
+ // oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
426
+ clearTimeout(this.debounceTimers.get(queryHash)!);
427
+ }
184
428
 
185
- this.debounceTimers.set(queryHash, timer);
186
- } else {
187
- // CREATE and DELETE - process immediately
429
+ // Set new timer
430
+ this.pendingStreamUpdates.set(queryHash, update);
431
+ const timer = setTimeout(async () => {
432
+ this.debounceTimers.delete(queryHash);
433
+ this.pendingStreamUpdates.delete(queryHash);
188
434
  await this.processStreamUpdate(update);
435
+ }, this.streamDebounceTime);
436
+
437
+ this.debounceTimers.set(queryHash, timer);
438
+ }
439
+
440
+ /**
441
+ * Process a query's pending (debounced) stream update NOW instead of on the
442
+ * trailing edge. Called by the sync engine before it flips a query back to
443
+ * `idle`, so the status change never races ahead of the rows it fetched.
444
+ * No-op when nothing is pending. The pending entry is removed before the
445
+ * await so a concurrently-firing timer can't process it twice.
446
+ */
447
+ async flushPendingStreamUpdate(queryHash: string): Promise<void> {
448
+ const timer = this.debounceTimers.get(queryHash);
449
+ if (timer) {
450
+ clearTimeout(timer);
451
+ this.debounceTimers.delete(queryHash);
452
+ }
453
+ const pending = this.pendingStreamUpdates.get(queryHash);
454
+ if (!pending) return;
455
+ this.pendingStreamUpdates.delete(queryHash);
456
+ await this.processStreamUpdate(pending);
457
+ }
458
+
459
+ /**
460
+ * Materialize a query's result rows from the local store.
461
+ *
462
+ * A query's rows are its MEMBERSHIP — the id-set the server put in
463
+ * `_00_list_ref` (`remoteArray`) — not "every local body that matches the
464
+ * WHERE". Those two disagree, and the disagreement was the bug: when a row
465
+ * leaves a query's window but still exists upstream, `handleRemovedRecords`
466
+ * keeps its local body and never re-fetches it, so a predicate re-scan finds
467
+ * that stale body still matching and keeps rendering the row. Selecting the
468
+ * id-set directly is also the only correct thing for a windowed query, where
469
+ * re-applying `START m` against the shared local store skips the window's own
470
+ * rows entirely (sparse windowing) and returns nothing.
471
+ *
472
+ * The rendered set is:
473
+ *
474
+ * (membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes
475
+ *
476
+ * The middle term keeps optimistic writes visible without re-admitting stale
477
+ * rows. Every local write is fed to the SSP (`cache.saveBatch` →
478
+ * `ingestMany`), so `localArray` answers "does this row match the predicate
479
+ * per LOCAL truth". A pending write that moves a row into the window is in
480
+ * `localArray` and shows; one that moves a row out is absent and does not; a
481
+ * stale body the server dropped has no pending write at all, so it stays out.
482
+ * `pendingDeletes` covers the reverse lag — the server still lists a row whose
483
+ * DELETE is sitting in our outbox.
484
+ *
485
+ * Falls back to the predicate scan only when membership has never been
486
+ * established (a query first run on this device), so an offline first paint
487
+ * still shows something.
488
+ */
489
+ private async materializeRecords(
490
+ queryState: QueryState,
491
+ sspArray?: Array<[string, number]>
492
+ ): Promise<Record<string, any>[]> {
493
+ const t0 = performance.now();
494
+ const records = await this.materializeFromConfig(queryState.config, sspArray);
495
+ // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
496
+ this.recordPhase(queryState, 'localFetch', performance.now() - t0);
497
+ return records;
498
+ }
499
+
500
+ /**
501
+ * The materialization itself, without the DevTools timing wrapper. Split out so
502
+ * cold-start seeding can use it before a `QueryState` exists.
503
+ */
504
+ private async materializeFromConfig(
505
+ config: QueryConfig,
506
+ sspArray?: Array<[string, number]>
507
+ ): Promise<Record<string, any>[]> {
508
+ const plan = config.plan;
509
+ const membership = this.resolveMembership(config, sspArray);
510
+ let records: Record<string, any>[];
511
+
512
+ if (membership) {
513
+ const ids = await this.buildRenderIds(config, membership, sspArray);
514
+ if (plan) {
515
+ records = await this.local.select(buildIdSetPlan(plan, ids), config.params);
516
+ } else {
517
+ const idSetSurql = buildIdSetSurql(config.surql);
518
+ if (idSetSurql) {
519
+ const [rows] = await this.local.query<[Record<string, any>[]]>(idSetSurql.query, {
520
+ ...config.params,
521
+ __win: ids,
522
+ });
523
+ records = rows || [];
524
+ } else {
525
+ // Unparseable shape (no top-level FROM): the predicate scan is the
526
+ // only option left. Rare — the query-builder always supplies a plan.
527
+ const [rows] = await this.local.query<[Record<string, any>[]]>(
528
+ config.surql,
529
+ config.params
530
+ );
531
+ records = rows || [];
532
+ }
533
+ }
534
+ } else if (plan) {
535
+ records = await this.local.select(plan, config.params);
536
+ } else {
537
+ const [rows] = await this.local.query<[Record<string, any>[]]>(config.surql, config.params);
538
+ records = rows || [];
539
+ }
540
+ return records;
541
+ }
542
+
543
+ /**
544
+ * The authoritative membership list to render from, or `null` when membership
545
+ * has never been established and the caller must fall back to a scan.
546
+ *
547
+ * A windowed query has no usable fallback — re-running its `START m` locally
548
+ * returns the wrong rows — so it renders from whatever id-set is on hand
549
+ * (SSP's included) rather than degrading to a scan. That is the pre-existing
550
+ * behavior for windows and is preserved.
551
+ */
552
+ private resolveMembership(
553
+ config: QueryConfig,
554
+ sspArray?: Array<[string, number]>
555
+ ): RecordVersionArray | null {
556
+ if (config.membershipKnown) return config.remoteArray ?? [];
557
+ if (buildWindowMaterialization(config.surql) !== null) {
558
+ return (
559
+ (config.remoteArray?.length && config.remoteArray) ||
560
+ (sspArray?.length && sspArray) ||
561
+ config.localArray ||
562
+ []
563
+ );
564
+ }
565
+ return null;
566
+ }
567
+
568
+ // ---- Settled-write grace window ----------------------------------------
569
+ //
570
+ // A local write is visible because it is in the outbox: the render set is
571
+ // `(membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes`. The outbox
572
+ // row is deleted the moment the remote push succeeds (see
573
+ // `UpQueue.next` / `SyncScheduler` — the delete is deliberately tied to the
574
+ // push, not to anything downstream), but the row does not enter `membership`
575
+ // until the SSP has ingested it, materialized the view and written the
576
+ // `_00_list_ref` edge, and this client has read that back.
577
+ //
578
+ // Between those two moments the row is in neither term, so it is rendered,
579
+ // then disappears, then returns — reported as "the comment shows on every
580
+ // other client but not on the one that wrote it". Other clients don't blink
581
+ // because a client that never established membership renders from the local
582
+ // predicate scan instead.
583
+ //
584
+ // So a settled write keeps its place in the union for a short grace period,
585
+ // until membership catches up or the deadline passes. Kept in memory only:
586
+ // it covers a round trip, and a reload re-derives membership anyway.
587
+ private readonly settledWrites = new Map<string, number>();
588
+ private readonly settledDeletes = new Map<string, number>();
589
+
590
+ /**
591
+ * Grace period for a settled write. Long enough to cover an SSP round trip
592
+ * that is running slowly (seconds, not milliseconds, when the edge path is
593
+ * backed up), short enough that a write the server silently dropped cannot
594
+ * linger misleadingly.
595
+ *
596
+ * The rejection case does NOT rely on this expiring: an application error
597
+ * rolls the mutation back and never reports it settled, so it vanishes at
598
+ * once. This deadline only bounds the case where the write succeeded and its
599
+ * membership never arrived at all.
600
+ */
601
+ private static readonly SETTLED_WRITE_GRACE_MS = 10_000;
602
+
603
+ /**
604
+ * Report that a mutation was accepted by the server and its outbox row
605
+ * removed. Called only on the SUCCESS path — a rolled-back mutation must
606
+ * disappear immediately, which is what makes this safe.
607
+ */
608
+ noteWriteSettled(recordId: string, mutationType: string): void {
609
+ const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
610
+ if (mutationType === 'delete') this.settledDeletes.set(recordId, until);
611
+ else this.settledWrites.set(recordId, until);
612
+ }
613
+
614
+ /** Drop entries past their deadline. */
615
+ private pruneSettled(now: number): void {
616
+ for (const [id, until] of this.settledWrites) {
617
+ if (until <= now) this.settledWrites.delete(id);
618
+ }
619
+ for (const [id, until] of this.settledDeletes) {
620
+ if (until <= now) this.settledDeletes.delete(id);
189
621
  }
190
622
  }
191
623
 
624
+ /** Apply the pending-write union and pending-delete subtraction, and map to
625
+ * RecordIds for the engines' id-set path. */
626
+ private async buildRenderIds(
627
+ config: QueryConfig,
628
+ membership: RecordVersionArray,
629
+ sspArray?: Array<[string, number]>
630
+ ): Promise<unknown[]> {
631
+ const { writes, deletes } = await this.getPendingRecordIds();
632
+ const now = Date.now();
633
+ this.pruneSettled(now);
634
+ // A settled write counts as pending until membership catches up. Merged
635
+ // into the same sets so the union/subtraction below is unchanged — the
636
+ // grace window changes WHEN an id leaves the render set, never how the
637
+ // set is composed.
638
+ if (this.settledWrites.size > 0) {
639
+ for (const id of this.settledWrites.keys()) writes.add(id);
640
+ }
641
+ if (this.settledDeletes.size > 0) {
642
+ for (const id of this.settledDeletes.keys()) deletes.add(id);
643
+ }
644
+
645
+ const ordered: string[] = [];
646
+ const seen = new Set<string>();
647
+ for (const [id] of membership) {
648
+ // Membership has caught up with this write: the grace window has done
649
+ // its job and ends here rather than at its deadline. Doing it inline
650
+ // keeps the common case (nothing settled) free of extra passes.
651
+ if (this.settledWrites.size > 0) this.settledWrites.delete(id);
652
+ if (deletes.has(id) || seen.has(id)) continue;
653
+ seen.add(id);
654
+ ordered.push(id);
655
+ }
656
+ // A settled DELETE is the mirror case: membership still lists the row
657
+ // until the SSP publishes its removal, so the id stays subtracted until
658
+ // membership stops naming it.
659
+ if (this.settledDeletes.size > 0) {
660
+ const stillListed = new Set(membership.map(([id]) => id));
661
+ for (const id of [...this.settledDeletes.keys()]) {
662
+ if (!stillListed.has(id)) this.settledDeletes.delete(id);
663
+ }
664
+ }
665
+ if (writes.size > 0) {
666
+ // Only pending writes the SSP agrees currently match this query — see the
667
+ // formula in `materializeRecords`. `sspArray` is the fresher signal when a
668
+ // stream update triggered this pass; `localArray` is the persisted one.
669
+ const localView = (sspArray?.length && sspArray) || config.localArray || [];
670
+ for (const [id] of localView) {
671
+ if (!writes.has(id) || deletes.has(id) || seen.has(id)) continue;
672
+ seen.add(id);
673
+ ordered.push(id);
674
+ }
675
+ }
676
+ // `_00_list_ref` is selected without an ORDER BY, so this id-set arrives in
677
+ // whatever order the server happened to return. For a query with its own
678
+ // ORDER BY that does not matter (the engine sorts), and for a window the
679
+ // id-set order IS the window's slice order and must be preserved. Anything
680
+ // else renders in server order while its first paint came from the local
681
+ // scan in id order — the same rows, visibly reshuffled a second later.
682
+ // Sorting here is what makes the two paints agree.
683
+ const hasExplicitOrder = (config.plan?.orderBy?.length ?? 0) > 0;
684
+ const isWindow = buildWindowMaterialization(config.surql) !== null;
685
+ if (!hasExplicitOrder && !isWindow) {
686
+ ordered.sort();
687
+ }
688
+ return ordered.map((id) => parseRecordIdString(id));
689
+ }
690
+
192
691
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
193
- const { queryHash, localArray } = update;
692
+ const { queryHash, localArray, materializationTimeMs } = update;
194
693
  const queryState = this.activeQueries.get(queryHash);
195
694
  if (!queryState) {
196
695
  this.logger.warn(
197
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
696
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
198
697
  'Received update for unknown query. Skipping...'
199
698
  );
200
699
  return;
201
700
  }
202
701
 
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
- );
702
+ // Update the rolling materialization-sample window before the work that
703
+ // could throw, so the percentiles still move when the downstream local
704
+ // query fails (the materialization step itself ran).
705
+ if (typeof materializationTimeMs === 'number') {
706
+ queryState.materializationSamples.push(materializationTimeMs);
707
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
708
+ queryState.materializationSamples.shift();
709
+ }
710
+ queryState.lastIngestLatencyMs = materializationTimeMs;
711
+ }
712
+ // Record the SSP internal sub-phase timings (from the WASM binding) so
713
+ // DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
714
+ if (typeof update.storeApplyMs === 'number')
715
+ this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
716
+ if (typeof update.circuitStepMs === 'number')
717
+ this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
718
+ if (typeof update.transformMs === 'number')
719
+ this.recordPhase(queryState, 'sspTransform', update.transformMs);
720
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
721
+
722
+ // Fence against bucket switches: this update's `localArray` came from the
723
+ // pre-switch SSP circuit; applying it after a switch would show (and
724
+ // persist) the previous user's ids in the new bucket.
725
+ const epoch = this.local.epoch;
209
726
 
210
- // Update state
211
- const newRecords = records || [];
727
+ try {
728
+ // Materialize the query's rows. For a windowed (offset) query, re-running
729
+ // the original surql would re-apply `START n` against the shared local DB
730
+ // and skip the window's rows entirely; instead select the SSP's
731
+ // materialized window id-set (`localArray`) directly, re-applying the
732
+ // original ORDER BY for stable display order. Non-offset queries keep the
733
+ // normal re-query path.
734
+ const newRecords = await this.materializeRecords(queryState, localArray);
735
+ if (epoch !== this.local.epoch) return;
212
736
  queryState.config.localArray = localArray;
213
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
214
- id: queryState.config.id,
215
- localArray,
216
- });
217
737
 
218
- // Skip notification if records haven't changed
219
738
  const prevJson = JSON.stringify(queryState.records);
220
739
  const newJson = JSON.stringify(newRecords);
221
740
  queryState.records = newRecords;
222
- if (prevJson === newJson) {
741
+ const recordsChanged = prevJson !== newJson;
742
+
743
+ // updateCount counts user-visible updates (matches the prior semantic),
744
+ // while the materialization sample/percentiles already moved above for
745
+ // every observed engine step.
746
+ if (recordsChanged) {
747
+ queryState.updateCount++;
748
+ queryState.lastUpdatedAt = Date.now();
749
+ }
750
+
751
+ await this.local.query(
752
+ surql.seal(
753
+ surql.updateSet('id', [
754
+ 'localArray',
755
+ 'rowCount',
756
+ 'updateCount',
757
+ 'lastIngestLatency',
758
+ 'materializationP55',
759
+ 'materializationP90',
760
+ 'materializationP99',
761
+ ])
762
+ ),
763
+ {
764
+ id: queryState.config.id,
765
+ localArray,
766
+ rowCount: localArray.length,
767
+ updateCount: queryState.updateCount,
768
+ lastIngestLatency: queryState.lastIngestLatencyMs,
769
+ materializationP55: percentiles.p55,
770
+ materializationP90: percentiles.p90,
771
+ materializationP99: percentiles.p99,
772
+ },
773
+ { epoch }
774
+ );
775
+
776
+ if (!recordsChanged) {
223
777
  this.logger.debug(
224
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
778
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
225
779
  'Query records unchanged, skipping notification'
226
780
  );
227
781
  return;
228
782
  }
229
783
 
230
- queryState.updateCount++;
231
-
232
784
  // Notify subscribers
233
785
  const subscribers = this.subscriptions.get(queryHash);
234
786
  if (subscribers) {
@@ -240,19 +792,110 @@ export class DataModule<S extends SchemaStructure> {
240
792
  this.logger.debug(
241
793
  {
242
794
  queryHash,
243
- recordCount: records?.length,
244
- Category: 'spooky-client::DataModule::onStreamUpdate',
795
+ recordCount: newRecords?.length,
796
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
245
797
  },
246
798
  'Query updated from stream'
247
799
  );
248
800
  } catch (err) {
801
+ if (err instanceof StaleEpochError) {
802
+ this.logger.debug(
803
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
804
+ 'Dropped stream update from before a bucket switch'
805
+ );
806
+ return;
807
+ }
808
+ queryState.errorCount++;
249
809
  this.logger.error(
250
- { err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
810
+ { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
251
811
  'Failed to fetch records for stream update'
252
812
  );
813
+ // Best-effort persist of the bumped errorCount; swallow secondary
814
+ // failures to avoid masking the original error in logs.
815
+ try {
816
+ await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
817
+ id: queryState.config.id,
818
+ errorCount: queryState.errorCount,
819
+ });
820
+ } catch (persistErr) {
821
+ this.logger.warn(
822
+ {
823
+ err: persistErr,
824
+ queryHash,
825
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
826
+ },
827
+ 'Failed to persist incremented errorCount'
828
+ );
829
+ }
253
830
  }
254
831
  }
255
832
 
833
+ /**
834
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
835
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
836
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
837
+ */
838
+ private computeMaterializationPercentiles(samples: number[]): {
839
+ p55: number | null;
840
+ p90: number | null;
841
+ p99: number | null;
842
+ } {
843
+ if (samples.length === 0) {
844
+ return { p55: null, p90: null, p99: null };
845
+ }
846
+ const sorted = [...samples].sort((a, b) => a - b);
847
+ const pick = (q: number) => {
848
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
849
+ return sorted[idx]!;
850
+ };
851
+ return { p55: pick(0.55), p90: pick(0.9), p99: pick(0.99) };
852
+ }
853
+
854
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
855
+ private recordPhase(qs: QueryState, phase: string, ms: number): void {
856
+ if (!Number.isFinite(ms)) return;
857
+ const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
858
+ pushSample(arr, ms);
859
+ qs.phaseLast[phase] = ms;
860
+ }
861
+
862
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
863
+ recordRemoteFetch(hash: string, ms: number): void {
864
+ const qs = this.activeQueries.get(hash);
865
+ if (qs) this.recordPhase(qs, 'remoteFetch', ms);
866
+ }
867
+
868
+ /**
869
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
870
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
871
+ */
872
+ recordFrontendTiming(hash: string, ms: number): void {
873
+ const qs = this.activeQueries.get(hash);
874
+ if (qs) this.recordPhase(qs, 'frontend', ms);
875
+ }
876
+
877
+ /**
878
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
879
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
880
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
881
+ */
882
+ phaseTimings(q: QueryState): QueryTimings {
883
+ const stat = (phase: string) =>
884
+ phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
885
+ return {
886
+ ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
887
+ sspStoreApply: stat('sspStoreApply'),
888
+ sspCircuitStep: stat('sspCircuitStep'),
889
+ sspTransform: stat('sspTransform'),
890
+ localFetch: stat('localFetch'),
891
+ remoteFetch: stat('remoteFetch'),
892
+ frontend: stat('frontend'),
893
+ registration: q.registrationTimings,
894
+ updateCount: q.updateCount,
895
+ errorCount: q.errorCount,
896
+ };
897
+ }
898
+
256
899
  /**
257
900
  * Get query state (for sync and devtools)
258
901
  */
@@ -260,6 +903,350 @@ export class DataModule<S extends SchemaStructure> {
260
903
  return this.activeQueries.get(hash);
261
904
  }
262
905
 
906
+ /**
907
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
908
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
909
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
910
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
911
+ * but it still hasn't loaded its own full window from the server — so it should
912
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
913
+ */
914
+ isCold(hash: string): boolean {
915
+ const qs = this.activeQueries.get(hash);
916
+ return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
917
+ }
918
+
919
+ /**
920
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
921
+ * `batch` (recursing for nested related fields). An embedded child is a
922
+ * value that is itself a record — a non-null object whose `id` is a
923
+ * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
924
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
925
+ * so this never mistakes a FK column for an embedded body. Children are
926
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
927
+ * to their table's real columns (which strips the alias/related fields).
928
+ * `seen` dedupes within the batch.
929
+ */
930
+ private collectEmbeddedChildren(
931
+ record: Record<string, any>,
932
+ batch: CacheRecord[],
933
+ seen: Set<string>
934
+ ): void {
935
+ const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
936
+ !!v &&
937
+ typeof v === 'object' &&
938
+ !(v instanceof RecordId) &&
939
+ (v as { id?: unknown }).id instanceof RecordId;
940
+
941
+ for (const value of Object.values(record)) {
942
+ const children = Array.isArray(value)
943
+ ? value.filter(isEmbeddedRecord)
944
+ : isEmbeddedRecord(value)
945
+ ? [value]
946
+ : [];
947
+ for (const child of children) {
948
+ const key = encodeRecordId(child.id);
949
+ if (seen.has(key)) continue;
950
+ seen.add(key);
951
+ // Recurse FIRST so nested grandchildren are captured before `cleanRecord`
952
+ // strips this child's alias fields.
953
+ this.collectEmbeddedChildren(child, batch, seen);
954
+ const table = child.id.table.toString();
955
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
956
+ batch.push({
957
+ table,
958
+ op: 'CREATE',
959
+ // Flatten the child too: a nested comment carries its own embedded
960
+ // `author` object which the schemafull `record<user>` field would
961
+ // reject. (Its author is captured as a separate row by the recursion
962
+ // above.)
963
+ record: this.flattenRelationsForStorage(child, tableSchema),
964
+ version: (child._00_rv as number) || 1,
965
+ });
966
+ }
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Prepare a subquery-bearing row (preload / hydration) for the schemafull
972
+ * local store: replace an embedded FORWARD-relation object (`author = { id, … }`)
973
+ * with its RecordId so a `record<…>` field coerces, and DROP reverse-subquery
974
+ * ARRAYS (`comments = [ … ]`) since their rows are cached separately as their
975
+ * own bodies. A flat record — as the live `SELECT * FROM $ids` sync returns,
976
+ * with relations already RecordIds — passes through unchanged.
977
+ */
978
+ private flattenRelationsForStorage(
979
+ record: Record<string, any>,
980
+ tableSchema?: { columns: Record<string, any> }
981
+ ): RecordWithId {
982
+ const isEmbeddedRecord = (v: unknown): boolean =>
983
+ !!v &&
984
+ typeof v === 'object' &&
985
+ !(v instanceof RecordId) &&
986
+ (v as { id?: unknown }).id instanceof RecordId;
987
+ const flat: Record<string, any> = {};
988
+ for (const [k, v] of Object.entries(record)) {
989
+ if (isEmbeddedRecord(v)) {
990
+ flat[k] = (v as { id: unknown }).id;
991
+ } else if (Array.isArray(v) && v.some(isEmbeddedRecord)) {
992
+ continue;
993
+ } else {
994
+ flat[k] = v;
995
+ }
996
+ }
997
+ return (tableSchema ? cleanRecord(tableSchema.columns, flat) : flat) as RecordWithId;
998
+ }
999
+
1000
+ /**
1001
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
1002
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
1003
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
1004
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
1005
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
1006
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
1007
+ */
1008
+ async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
1009
+ const queryState = this.activeQueries.get(hash);
1010
+ if (!queryState) return;
1011
+ queryState.hydrated = true; // run-once, even when the remote returns nothing
1012
+ if (rows.length === 0) return;
1013
+
1014
+ const epoch = this.local.epoch;
1015
+ const tableName = queryState.config.tableName;
1016
+ await this.buildAndSaveCacheBatch(tableName, rows);
1017
+ // Bucket switched while we persisted: these rows were fetched under the
1018
+ // previous auth context — don't let them prime the new bucket's query
1019
+ // state; the rebind's re-registration refills it. (saveBatch's own epoch
1020
+ // fence usually catches this, but the switch can land between it and here.)
1021
+ if (epoch !== this.local.epoch) return;
1022
+
1023
+ // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
1024
+ // renders from it and it feeds the version dedup. Registration later
1025
+ // overwrites it with the authoritative `_00_list_ref`.
1026
+ queryState.config.remoteArray = rows.map(
1027
+ (r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
1028
+ );
1029
+ // These rows ARE the server's answer to this query, so membership is now
1030
+ // known and rendering can stop falling back to a predicate scan. The durable
1031
+ // `_00_window` mirror is deliberately left to `updateQueryRemoteArray` (the
1032
+ // single write point) — the registration that follows lands there anyway.
1033
+ queryState.config.membershipKnown = true;
1034
+
1035
+ queryState.records = await this.materializeRecords(queryState);
1036
+ const subscribers = this.subscriptions.get(hash);
1037
+ if (subscribers) {
1038
+ for (const cb of subscribers) cb(queryState.records);
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Build the cache batch for a set of one-shot rows and persist it to the
1044
+ * local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
1045
+ * and extracts EMBEDDED related children (any nesting depth) as their own
1046
+ * records — a `.related()` query returns its children embedded, and a later
1047
+ * correlated re-materialization needs them present as standalone rows.
1048
+ * Shared by `applyHydration` (live registration) and `persistSnapshot`
1049
+ * (preload).
1050
+ */
1051
+ private async buildAndSaveCacheBatch(tableName: string, rows: RecordWithId[]): Promise<void> {
1052
+ const tableSchema = this.schema.tables.find((t) => t.name === tableName);
1053
+ const batch: CacheRecord[] = rows.map((record) => ({
1054
+ table: tableName,
1055
+ op: 'CREATE' as const,
1056
+ // Flatten embedded relations first: a preload/hydration row can carry a
1057
+ // forward relation as a full nested OBJECT (`author = { id, … }`) and a
1058
+ // reverse subquery as an array of objects (`comments = [ … ]`). The
1059
+ // schemafull local field is `record<user>`, which rejects an object
1060
+ // (`Couldn't coerce … found { id: …, username: … }`) and would throw the
1061
+ // WHOLE batch. Store the parent with `author` as its RecordId and drop the
1062
+ // subquery arrays — the children are cached as their own rows below.
1063
+ record: this.flattenRelationsForStorage(record, tableSchema),
1064
+ version: (record._00_rv as number) || 1,
1065
+ }));
1066
+
1067
+ const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
1068
+ for (const record of rows) {
1069
+ this.collectEmbeddedChildren(record, batch, seen);
1070
+ }
1071
+
1072
+ await this.cache.saveBatch(batch);
1073
+ }
1074
+
1075
+ /**
1076
+ * Preload/prewarm: persist one-shot rows (and their embedded related children)
1077
+ * into the local cache WITHOUT registering a query — no `activeQueries` entry,
1078
+ * no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
1079
+ * ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
1080
+ * first paint from them instantly, then registers a live view to freshen.
1081
+ */
1082
+ async persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void> {
1083
+ if (rows.length === 0) return;
1084
+ await this.buildAndSaveCacheBatch(tableName, rows);
1085
+ }
1086
+
1087
+ /**
1088
+ * Read the durable preload freshness marker for a query hash, or null if this
1089
+ * query was never preloaded in the current bucket. Co-located with the cached
1090
+ * rows (per-bucket `_00_preload` table) so a bucket switch that clears the
1091
+ * data also clears the marker — a stale marker can't claim "warm" when the
1092
+ * rows are gone. Any read error is treated as cold.
1093
+ */
1094
+ async getPreloadMarker(hash: string): Promise<{ fetchedAt: number; rowCount: number } | null> {
1095
+ try {
1096
+ // Pass a real RecordId: a bare string id hits the SurrealDB engine's
1097
+ // `FROM ONLY $__id` as a plain string, which "selects" the string itself
1098
+ // (a truthy non-row) instead of the record — misread as a warm marker.
1099
+ const row = await this.local.getById('_00_preload', new RecordId('_00_preload', hash));
1100
+ if (!row || typeof row !== 'object') return null;
1101
+ return {
1102
+ fetchedAt: Number((row as any).fetchedAt) || 0,
1103
+ rowCount: Number((row as any).rowCount) || 0,
1104
+ };
1105
+ } catch {
1106
+ return null;
1107
+ }
1108
+ }
1109
+
1110
+ /** Stamp the preload freshness marker after a successful snapshot fetch. */
1111
+ async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
1112
+ // RecordId, not a bare string: the SurrealDB engine binds the id verbatim,
1113
+ // and `UPSERT <string>` is an InternalError — the marker silently never
1114
+ // landed (the write is awaited inside preload's best-effort catch).
1115
+ await this.local.upsert(
1116
+ '_00_preload',
1117
+ new RecordId('_00_preload', hash),
1118
+ { fetchedAt: Date.now(), rowCount },
1119
+ 'replace'
1120
+ );
1121
+ }
1122
+
1123
+ // ---- Durable membership (`_00_window`) ---------------------------------
1124
+ //
1125
+ // A query's authoritative membership is the id-set the server put in
1126
+ // `_00_list_ref` — `config.remoteArray`. It is persisted on the `_00_query`
1127
+ // row, but that row's id is salted with `session::id()`, which is new on every
1128
+ // page load (and `''` offline), and `doSwitchBucket` wipes the table outright.
1129
+ // So membership never survived a reload: the first paint fell back to a
1130
+ // predicate scan over whatever bodies were still cached, which re-included
1131
+ // rows the server had already dropped from the window.
1132
+ //
1133
+ // `_00_window` fixes that: same data, keyed by a session-independent hash, in
1134
+ // a table nothing wipes. Mirrors the durable `_00_preload` marker above.
1135
+
1136
+ /**
1137
+ * Read the durable membership row, or `null` if this query has never had
1138
+ * authoritative membership on this device. Any read error is treated as
1139
+ * "unknown" so a broken row degrades to the predicate scan rather than
1140
+ * rendering an empty list.
1141
+ */
1142
+ async getWindowMembership(key: string): Promise<RecordVersionArray | null> {
1143
+ try {
1144
+ const row = await this.local.getById('_00_window', new RecordId('_00_window', key));
1145
+ if (!row || typeof row !== 'object') return null;
1146
+ const ids = (row as any).ids;
1147
+ return Array.isArray(ids) ? (ids as RecordVersionArray) : null;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+
1153
+ /** Persist the durable membership row. Best-effort: callers must not fail a
1154
+ * sync round because the mirror write failed. */
1155
+ async writeWindowMembership(key: string, ids: RecordVersionArray): Promise<void> {
1156
+ try {
1157
+ await this.local.upsert(
1158
+ '_00_window',
1159
+ new RecordId('_00_window', key),
1160
+ { ids, updatedAt: Date.now() },
1161
+ 'replace'
1162
+ );
1163
+ } catch (err) {
1164
+ this.logger.debug(
1165
+ { err, key, Category: 'sp00ky-client::DataModule::writeWindowMembership' },
1166
+ 'Failed to persist window membership; it will be re-derived on next register'
1167
+ );
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Record ids with a mutation still in the outbox, split by direction.
1173
+ *
1174
+ * Both halves feed {@link materializeRecords}: `writes` keeps optimistic
1175
+ * creates/updates visible before the server has acknowledged them, and
1176
+ * `deletes` suppresses rows the server still lists because our DELETE hasn't
1177
+ * been processed yet. Reading `_00_pending_mutations` (rather than tracking
1178
+ * ids in memory) is what makes both survive a reload.
1179
+ *
1180
+ * On failure returns empty sets: membership alone then decides, which can
1181
+ * briefly hide an optimistic write but never resurrects a deleted row.
1182
+ */
1183
+ async getPendingRecordIds(): Promise<{ writes: Set<string>; deletes: Set<string> }> {
1184
+ const writes = new Set<string>();
1185
+ const deletes = new Set<string>();
1186
+ try {
1187
+ const [rows] = await this.local.query<
1188
+ [{ recordId: RecordId<string>; mutationType: string }[]]
1189
+ >('SELECT recordId, mutationType FROM _00_pending_mutations');
1190
+ for (const row of rows ?? []) {
1191
+ if (!row?.recordId) continue;
1192
+ const id = encodeRecordId(row.recordId);
1193
+ if (row.mutationType === 'delete') deletes.add(id);
1194
+ else writes.add(id);
1195
+ }
1196
+ } catch (err) {
1197
+ this.logger.warn(
1198
+ { err, Category: 'sp00ky-client::DataModule::getPendingRecordIds' },
1199
+ 'Failed to read pending mutations; optimistic writes may be briefly hidden'
1200
+ );
1201
+ }
1202
+ return { writes, deletes };
1203
+ }
1204
+
1205
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
1206
+ hasSubscribers(hash: string): boolean {
1207
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
1208
+ }
1209
+
1210
+ /**
1211
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
1212
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
1213
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
1214
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
1215
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
1216
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
1217
+ *
1218
+ * NOTE: most queries should NOT use this — the default keep-alive on
1219
+ * unsubscribe avoids re-registration churn on navigation.
1220
+ */
1221
+ deregisterQuery(hash: string): void {
1222
+ if (this.hasSubscribers(hash)) return;
1223
+ if (!this.activeQueries.has(hash)) return;
1224
+ this.onDeregister?.(hash);
1225
+ }
1226
+
1227
+ /**
1228
+ * Final local teardown after the remote `_00_query` row was deleted: free the
1229
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
1230
+ * (`cleanupQuery`) guarantees no subscriber remains.
1231
+ */
1232
+ finalizeDeregister(hash: string): void {
1233
+ const qs = this.activeQueries.get(hash);
1234
+ if (qs?.ttlTimer) {
1235
+ clearTimeout(qs.ttlTimer);
1236
+ qs.ttlTimer = null;
1237
+ }
1238
+ const debounce = this.debounceTimers.get(hash);
1239
+ if (debounce) {
1240
+ clearTimeout(debounce);
1241
+ this.debounceTimers.delete(hash);
1242
+ }
1243
+ this.pendingStreamUpdates.delete(hash);
1244
+ this.fetchDepth.delete(hash);
1245
+ this.cache.unregisterQuery(hash);
1246
+ this.activeQueries.delete(hash);
1247
+ this.subscriptions.delete(hash);
1248
+ }
1249
+
263
1250
  /**
264
1251
  * Get query state by id (for sync and devtools)
265
1252
  */
@@ -274,36 +1261,252 @@ export class DataModule<S extends SchemaStructure> {
274
1261
  return Array.from(this.activeQueries.values());
275
1262
  }
276
1263
 
1264
+ getActiveQueryHashes(): QueryHash[] {
1265
+ return Array.from(this.activeQueries.keys());
1266
+ }
1267
+
277
1268
  async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
278
1269
  const queryState = this.activeQueries.get(id);
279
1270
  if (!queryState) {
280
1271
  this.logger.warn(
281
- { id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
1272
+ { id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
282
1273
  'Query to update local array not found'
283
1274
  );
284
1275
  return;
285
1276
  }
1277
+ const epoch = this.local.epoch;
286
1278
  queryState.config.localArray = localArray;
287
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
288
- id: queryState.config.id,
289
- localArray,
290
- });
1279
+ try {
1280
+ await this.local.query(
1281
+ surql.seal(surql.updateSet('id', ['localArray'])),
1282
+ {
1283
+ id: queryState.config.id,
1284
+ localArray,
1285
+ },
1286
+ { epoch }
1287
+ );
1288
+ } catch (err) {
1289
+ if (err instanceof StaleEpochError) return;
1290
+ throw err;
1291
+ }
291
1292
  }
292
1293
 
293
- async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
1294
+ async updateQueryRemoteArray(
1295
+ hash: string,
1296
+ remoteArray: RecordVersionArray,
1297
+ opts?: {
1298
+ /** `_00_query.rowCount` read in the same round trip; `null` = unknown. */
1299
+ serverRowCount?: number | null;
1300
+ }
1301
+ ): Promise<void> {
294
1302
  const queryState = this.getQueryByHash(hash);
295
1303
  if (!queryState) {
296
1304
  this.logger.warn(
297
- { hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
1305
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
298
1306
  'Query to update remote array not found'
299
1307
  );
300
1308
  return;
301
1309
  }
1310
+ // An empty id-set is only believable once a real one has arrived in this
1311
+ // session. `_00_list_ref` is published asynchronously — the SSP queues a
1312
+ // view's initial edges to a coalescing flusher and returns from
1313
+ // `fn::query::register` before they land — so an empty read right after
1314
+ // registration is routinely just "not flushed yet", for a query that has
1315
+ // rows. Taking it at face value latched `membershipKnown` on an empty set,
1316
+ // which renders nothing (no scan fallback), and mirrored `[]` into the
1317
+ // durable `_00_window` row, which kept the list blank across reloads.
1318
+ //
1319
+ // Ignoring it entirely (rather than storing `[]` with the latch withheld)
1320
+ // is deliberate: on a cold start `remoteArray` is seeded from the durable
1321
+ // row, and overwriting that with `[]` would blank the very rows the seed
1322
+ // exists to paint. The `_00_list_ref` poll re-reads within ~500ms and
1323
+ // delivers the real set.
1324
+ // `serverRowCount` is what makes the two cases separable. The SSP writes it
1325
+ // onto the `_00_query` row in the same statement that registers the view,
1326
+ // before it queues the view's initial edges — so `> 0` with no edges is the
1327
+ // flush window and `=== 0` is a genuinely empty query. `null`/undefined
1328
+ // means we could not read it (older server, row not visible yet).
1329
+ //
1330
+ // Retry counting cannot substitute for this: the poll runs 500ms after
1331
+ // registration, well inside the flush window for a real collection, so
1332
+ // "believe it the second time" blanked exactly the lists this guard exists
1333
+ // to protect — reported as rows vanishing ~2s after a page load.
1334
+ if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
1335
+ const serverRowCount = opts?.serverRowCount;
1336
+ const knownEmpty = serverRowCount === 0;
1337
+ if (!knownEmpty) {
1338
+ // Unknown row count still gets a bounded escape hatch, so a server that
1339
+ // cannot report one never strands a device on a durable seed forever.
1340
+ const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
1341
+ queryState.config.emptyReads = emptyReads;
1342
+ const exhausted =
1343
+ serverRowCount === null || serverRowCount === undefined
1344
+ ? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS
1345
+ : false; // a positive row count is never "confirmed empty"
1346
+ if (!exhausted) {
1347
+ this.logger.debug(
1348
+ {
1349
+ hash,
1350
+ emptyReads,
1351
+ serverRowCount,
1352
+ Category: 'sp00ky-client::DataModule::updateQueryRemoteArray',
1353
+ },
1354
+ 'Ignoring empty membership: the server still reports rows for this query'
1355
+ );
1356
+ return;
1357
+ }
1358
+ }
1359
+ }
1360
+
1361
+ const epoch = this.local.epoch;
302
1362
  queryState.config.remoteArray = remoteArray;
303
- await this.local.query(surql.seal(surql.updateSet('id', ['remoteArray'])), {
304
- id: queryState.config.id,
305
- remoteArray,
306
- });
1363
+ // The single point where authoritative membership arrives (registration and
1364
+ // the `_00_list_ref` poll both land here), so it is where "we now know the
1365
+ // membership" is latched and where the durable mirror is written.
1366
+ queryState.config.membershipKnown = true;
1367
+ if (remoteArray.length > 0) {
1368
+ // Earns the right to believe a later empty set — that transition is a
1369
+ // genuine removal and must be honoured, or removed rows resurrect.
1370
+ queryState.config.remoteSeen = true;
1371
+ queryState.config.emptyReads = 0;
1372
+ }
1373
+ if (queryState.config.membershipKey) {
1374
+ await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
1375
+ }
1376
+ try {
1377
+ await this.local.query(
1378
+ surql.seal(surql.updateSet('id', ['remoteArray'])),
1379
+ {
1380
+ id: queryState.config.id,
1381
+ remoteArray,
1382
+ },
1383
+ { epoch }
1384
+ );
1385
+ } catch (err) {
1386
+ if (err instanceof StaleEpochError) return;
1387
+ throw err;
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * Cancel every armed timer ahead of a local-bucket switch: stream-update
1393
+ * debounce timers (their pending updates carry the OLD bucket's id-sets) and
1394
+ * per-query TTL heartbeats (they'd refresh the previous user's remote
1395
+ * `_00_query` rows under the new session). The rebind re-arms heartbeats.
1396
+ */
1397
+ quiesce(): void {
1398
+ for (const timer of this.debounceTimers.values()) {
1399
+ clearTimeout(timer);
1400
+ }
1401
+ this.debounceTimers.clear();
1402
+ this.pendingStreamUpdates.clear();
1403
+ this.fetchDepth.clear();
1404
+ for (const queryState of this.activeQueries.values()) {
1405
+ if (queryState.ttlTimer) {
1406
+ clearTimeout(queryState.ttlTimer);
1407
+ queryState.ttlTimer = null;
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ /**
1413
+ * Re-home every active query in a freshly-opened bucket, KEEPING its hash —
1414
+ * `useQuery` subscriptions are keyed by hash and don't re-register on auth
1415
+ * changes, so the hooks must stay attached. Per query:
1416
+ * 1. reset the sync arrays + hydration flag and drop the previous user's
1417
+ * records, notifying subscribers with the new-bucket materialization
1418
+ * (usually empty) so their rows leave the UI immediately;
1419
+ * 2. recreate the `_00_query` row in the new bucket;
1420
+ * 3. re-register the SSP view on the (fresh, post-reset) processor — this
1421
+ * also rebinds the view to the NEW `$auth` context;
1422
+ * 4. restart the TTL heartbeat.
1423
+ * Returns the hashes so the caller can enqueue remote re-registration, which
1424
+ * refills records from the server via the normal register→sync→notify path.
1425
+ */
1426
+ async rebindAfterBucketSwitch(): Promise<QueryHash[]> {
1427
+ const hashes: QueryHash[] = [];
1428
+ for (const [hash, queryState] of this.activeQueries.entries()) {
1429
+ const config = queryState.config;
1430
+ config.localArray = [];
1431
+ config.remoteArray = [];
1432
+ config.subqueryRemoteArray = undefined;
1433
+ // Membership belongs to the bucket we just left. Re-seed from the NEW
1434
+ // bucket's durable row if it has one (a returning user), otherwise mark it
1435
+ // unknown so the first paint falls back to a scan instead of rendering an
1436
+ // empty list until the server answers.
1437
+ config.membershipKnown = false;
1438
+ // The new bucket's server sets have not been seen yet — an empty read
1439
+ // must be re-confirmed there too.
1440
+ config.remoteSeen = false;
1441
+ config.emptyReads = 0;
1442
+ if (config.membershipKey) {
1443
+ const durable = await this.getWindowMembership(config.membershipKey);
1444
+ // Length-checked for the same reason as the cold-start read above: an
1445
+ // empty durable row is indistinguishable from "never had membership".
1446
+ if (durable?.length) {
1447
+ config.remoteArray = durable;
1448
+ config.membershipKnown = true;
1449
+ }
1450
+ }
1451
+ queryState.hydrated = false;
1452
+ queryState.syncNotified = false;
1453
+ queryState.records = [];
1454
+ // Via setQueryStatus (not a bare assignment) so status observers see the
1455
+ // flip back to a loading state.
1456
+ this.setQueryStatus(hash, 'fetching');
1457
+
1458
+ try {
1459
+ await withRetry(this.logger, () =>
1460
+ this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
1461
+ id: config.id,
1462
+ data: {
1463
+ surql: config.surql,
1464
+ params: config.params,
1465
+ localArray: [],
1466
+ remoteArray: [],
1467
+ lastActiveAt: new Date(),
1468
+ createdAt: new Date(),
1469
+ ttl: config.ttl,
1470
+ tableName: config.tableName,
1471
+ updateCount: queryState.updateCount,
1472
+ rowCount: 0,
1473
+ errorCount: queryState.errorCount,
1474
+ },
1475
+ })
1476
+ );
1477
+
1478
+ const { localArray } = this.cache.registerQuery({
1479
+ queryHash: hash,
1480
+ surql: config.surql,
1481
+ params: config.params,
1482
+ ttl: new Duration(config.ttl),
1483
+ lastActiveAt: new Date(),
1484
+ });
1485
+ config.localArray = localArray;
1486
+ await this.local.query(surql.seal(surql.updateSet('id', ['localArray', 'rowCount'])), {
1487
+ id: config.id,
1488
+ localArray,
1489
+ rowCount: localArray.length,
1490
+ });
1491
+ } catch (err) {
1492
+ this.logger.error(
1493
+ { err, hash, Category: 'sp00ky-client::DataModule::rebindAfterBucketSwitch' },
1494
+ 'Failed to rebind query after bucket switch; remote re-registration will retry'
1495
+ );
1496
+ }
1497
+
1498
+ // Notify AFTER the SSP re-registration so a subscriber that re-reads
1499
+ // synchronously sees consistent (empty) state.
1500
+ const subscribers = this.subscriptions.get(hash);
1501
+ if (subscribers) {
1502
+ for (const callback of subscribers) {
1503
+ callback(queryState.records);
1504
+ }
1505
+ }
1506
+ this.startTTLHeartbeat(queryState, hash);
1507
+ hashes.push(hash);
1508
+ }
1509
+ return hashes;
307
1510
  }
308
1511
 
309
1512
  /**
@@ -313,20 +1516,29 @@ export class DataModule<S extends SchemaStructure> {
313
1516
  async notifyQuerySynced(queryHash: string): Promise<void> {
314
1517
  const queryState = this.activeQueries.get(queryHash);
315
1518
  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 || [];
1519
+ const epoch = this.local.epoch;
1520
+
1521
+ // Re-query local DB for latest data (windowed queries materialize from the
1522
+ // list_ref window so they resolve even if the in-browser SSP never emits —
1523
+ // it can't compute a high offset whose preceding rows aren't resident).
1524
+ const newRecords = await this.materializeRecords(queryState);
1525
+ // Bucket switched while we materialized: these rows mix old-bucket reads
1526
+ // with new-bucket state — drop them; the rebind/re-registration re-emits.
1527
+ if (epoch !== this.local.epoch) return;
323
1528
  const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
324
1529
  queryState.records = newRecords;
325
1530
 
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) {
1531
+ // Notify if data changed OR if this registration lifetime hasn't emitted a
1532
+ // post-sync notification yet. The latter handles "query truly has no
1533
+ // results" so the UI can stop loading — gated on the in-memory
1534
+ // `syncNotified` flag rather than `updateCount === 0`, because updateCount
1535
+ // is PERSISTED across deregister/re-register: a re-registered empty window
1536
+ // (updateCount > 0, records unchanged) would otherwise never emit and its
1537
+ // subscribers would show a loading state forever.
1538
+ if (changed || !queryState.syncNotified) {
1539
+ queryState.syncNotified = true;
329
1540
  queryState.updateCount++;
1541
+ queryState.lastUpdatedAt = Date.now();
330
1542
  const subscribers = this.subscriptions.get(queryHash);
331
1543
  if (subscribers) {
332
1544
  for (const callback of subscribers) {
@@ -344,6 +1556,24 @@ export class DataModule<S extends SchemaStructure> {
344
1556
  data: RoutePayload<S, B, R>,
345
1557
  options?: RunOptions
346
1558
  ): Promise<void> {
1559
+ const { tableName, record } = this.buildJobRecord(backend, path, data, options);
1560
+ const recordId = `${tableName}:${generateId()}`;
1561
+ await this.create(recordId, record);
1562
+ }
1563
+
1564
+ /**
1565
+ * Build the outbox job record + resolve its table for a backend route.
1566
+ *
1567
+ * Every job is a single execution. Recurring work is declared server-side
1568
+ * (`schedules:` in sp00ky.yml) and the scheduler creates a fresh row per cycle,
1569
+ * so nothing here needs to know about schedules.
1570
+ */
1571
+ private buildJobRecord<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
1572
+ backend: B,
1573
+ path: R,
1574
+ data: RoutePayload<S, B, R>,
1575
+ options?: RunOptions
1576
+ ): { tableName: string; record: Record<string, unknown> } {
347
1577
  const route = this.schema.backends?.[backend]?.routes?.[path];
348
1578
  if (!route) {
349
1579
  throw new Error(`Route ${backend}.${path} not found`);
@@ -366,16 +1596,28 @@ export class DataModule<S extends SchemaStructure> {
366
1596
  const record: Record<string, unknown> = {
367
1597
  path,
368
1598
  payload: JSON.stringify(payload),
1599
+ // Set explicitly: the schema's DEFAULT ALWAYS "pending" is server-side
1600
+ // only. An optimistic local create without it surfaces with status
1601
+ // undefined, so in-flight indicators keyed on pending/processing stay
1602
+ // off until the first server echo (seconds on a delayed job).
1603
+ status: 'pending',
369
1604
  max_retries: options?.max_retries ?? 3,
370
1605
  retry_strategy: options?.retry_strategy ?? 'linear',
371
1606
  };
372
1607
 
1608
+ if (options?.timeout != null) {
1609
+ record.timeout = options.timeout;
1610
+ }
1611
+
1612
+ if (options?.delay != null) {
1613
+ record.delay = options.delay;
1614
+ }
1615
+
373
1616
  if (options?.assignedTo) {
374
1617
  record.assigned_to = options.assignedTo;
375
1618
  }
376
1619
 
377
- const recordId = `${tableName}:${generateId()}`;
378
- await this.create(recordId, record);
1620
+ return { tableName, record };
379
1621
  }
380
1622
 
381
1623
  // ==================== MUTATION MANAGEMENT ====================
@@ -392,7 +1634,7 @@ export class DataModule<S extends SchemaStructure> {
392
1634
 
393
1635
  const rid = parseRecordIdString(id);
394
1636
  const params = parseParams(tableSchema.columns, data);
395
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1637
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
396
1638
 
397
1639
  const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
398
1640
  const prefixedParams = Object.fromEntries(
@@ -410,6 +1652,12 @@ export class DataModule<S extends SchemaStructure> {
410
1652
  this.local.execute(query, {
411
1653
  id: rid,
412
1654
  mid: mutationId,
1655
+ // The record itself is written with per-key `data_<field>` vars
1656
+ // (`createSet`), but the OUTBOX row needs the whole payload in one
1657
+ // `data` field so the mutation can be replayed later. Without this the
1658
+ // row is written with `data` unbound: the create becomes unsendable and
1659
+ // blocks the queue behind it.
1660
+ data: params,
413
1661
  ...prefixedParams,
414
1662
  })
415
1663
  );
@@ -441,7 +1689,7 @@ export class DataModule<S extends SchemaStructure> {
441
1689
  callback([mutationEvent]);
442
1690
  }
443
1691
 
444
- this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
1692
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
445
1693
 
446
1694
  return target;
447
1695
  }
@@ -463,7 +1711,10 @@ export class DataModule<S extends SchemaStructure> {
463
1711
 
464
1712
  const rid = parseRecordIdString(id);
465
1713
  const params = parseParams(tableSchema.columns, data);
466
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1714
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
1715
+
1716
+ // Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
1717
+ // NOT through the record update pipeline. This keeps the record data clean.
467
1718
 
468
1719
  // Capture current record state before mutation for rollback support
469
1720
  const [beforeRecord] = await withRetry(this.logger, () =>
@@ -472,7 +1723,7 @@ export class DataModule<S extends SchemaStructure> {
472
1723
 
473
1724
  const query = surql.seal<{ target: T }>(
474
1725
  surql.tx([
475
- surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
1726
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
476
1727
  surql.let('updated', surql.updateMerge('id', 'data')),
477
1728
  surql.createMutation('update', 'mid', 'id', 'data'),
478
1729
  surql.returnObject([{ key: 'target', variable: 'updated' }]),
@@ -496,8 +1747,8 @@ export class DataModule<S extends SchemaStructure> {
496
1747
  updatedFields[key] = (target as Record<string, any>)[key];
497
1748
  }
498
1749
  }
499
- if ('spooky_rv' in (target as Record<string, any>)) {
500
- updatedFields.spooky_rv = (target as Record<string, any>).spooky_rv;
1750
+ if ('_00_rv' in (target as Record<string, any>)) {
1751
+ updatedFields._00_rv = (target as Record<string, any>)._00_rv;
501
1752
  }
502
1753
  this.replaceRecordInQueries(updatedFields);
503
1754
 
@@ -509,7 +1760,7 @@ export class DataModule<S extends SchemaStructure> {
509
1760
  table: table,
510
1761
  op: 'UPDATE',
511
1762
  record: parsedRecord,
512
- version: target.spooky_rv as number,
1763
+ version: target._00_rv as number,
513
1764
  },
514
1765
  true
515
1766
  );
@@ -531,7 +1782,7 @@ export class DataModule<S extends SchemaStructure> {
531
1782
  callback([mutationEvent]);
532
1783
  }
533
1784
 
534
- this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
1785
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
535
1786
 
536
1787
  return target;
537
1788
  }
@@ -547,14 +1798,53 @@ export class DataModule<S extends SchemaStructure> {
547
1798
  }
548
1799
 
549
1800
  const rid = parseRecordIdString(id);
550
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1801
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
1802
+
1803
+ // Fetch the record before deleting so DBSP can match it against query predicates
1804
+ const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
1805
+ 'SELECT * FROM ONLY $id',
1806
+ { id: rid }
1807
+ );
1808
+ const beforeRecord = beforeRecords ?? {};
551
1809
 
552
1810
  const query = surql.seal<void>(
553
1811
  surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
554
1812
  );
555
1813
 
556
1814
  await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
557
- await this.cache.delete(table, id, true);
1815
+
1816
+ // The local DELETE has now committed. Everything below must reflect that in
1817
+ // active live queries — so the deleted row disappears optimistically without
1818
+ // a reload — even if the optimistic SSP-view ingest below fails. Previously a
1819
+ // throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
1820
+ // commit, so the manual notify loop never ran and the row lingered on screen
1821
+ // until reload. Ingesting the delete into the in-browser SSP view is
1822
+ // best-effort: the manual re-materialize reads the local DB (which already
1823
+ // excludes the row), so the result is correct regardless.
1824
+ try {
1825
+ await this.cache.delete(table, id, true, beforeRecord);
1826
+ } catch (err) {
1827
+ this.logger.error(
1828
+ { err, id, Category: 'sp00ky-client::DataModule::delete' },
1829
+ 'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
1830
+ );
1831
+ }
1832
+
1833
+ // DBSP may not emit view updates for DELETE ops — manually notify all queries
1834
+ // that reference this table. Each is isolated so one failing re-materialize
1835
+ // can't stop the others (or the sync emit below) from running.
1836
+ for (const [queryHash, queryState] of this.activeQueries) {
1837
+ if (queryState.config.tableName === tableName) {
1838
+ try {
1839
+ await this.notifyQuerySynced(queryHash);
1840
+ } catch (err) {
1841
+ this.logger.error(
1842
+ { err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
1843
+ 'notifyQuerySynced failed after delete'
1844
+ );
1845
+ }
1846
+ }
1847
+ }
558
1848
 
559
1849
  // Emit mutation event
560
1850
  const mutationEvent: DeleteEvent = {
@@ -567,7 +1857,7 @@ export class DataModule<S extends SchemaStructure> {
567
1857
  callback([mutationEvent]);
568
1858
  }
569
1859
 
570
- this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
1860
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
571
1861
  }
572
1862
 
573
1863
  // ==================== ROLLBACK METHODS ====================
@@ -579,19 +1869,17 @@ export class DataModule<S extends SchemaStructure> {
579
1869
  const id = encodeRecordId(recordId);
580
1870
 
581
1871
  try {
582
- await withRetry(this.logger, () =>
583
- this.local.query('DELETE $id', { id: recordId })
584
- );
1872
+ await withRetry(this.logger, () => this.local.query('DELETE $id', { id: recordId }));
585
1873
  await this.cache.delete(tableName, id, true);
586
1874
  this.removeRecordFromQueries(recordId);
587
1875
 
588
1876
  this.logger.info(
589
- { id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1877
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
590
1878
  'Rolled back optimistic create'
591
1879
  );
592
1880
  } catch (err) {
593
1881
  this.logger.error(
594
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
1882
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
595
1883
  'Failed to rollback create'
596
1884
  );
597
1885
  }
@@ -626,7 +1914,7 @@ export class DataModule<S extends SchemaStructure> {
626
1914
  table: tableName,
627
1915
  op: 'UPDATE',
628
1916
  record: parsedRecord,
629
- version: (beforeRecord.spooky_rv as number) || 1,
1917
+ version: (beforeRecord._00_rv as number) || 1,
630
1918
  },
631
1919
  true
632
1920
  );
@@ -635,12 +1923,12 @@ export class DataModule<S extends SchemaStructure> {
635
1923
  await this.replaceRecordInQueries(beforeRecord);
636
1924
 
637
1925
  this.logger.info(
638
- { id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1926
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
639
1927
  'Rolled back optimistic update'
640
1928
  );
641
1929
  } catch (err) {
642
1930
  this.logger.error(
643
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
1931
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
644
1932
  'Failed to rollback update'
645
1933
  );
646
1934
  }
@@ -678,7 +1966,9 @@ export class DataModule<S extends SchemaStructure> {
678
1966
  surqlString: string,
679
1967
  params: Record<string, any>,
680
1968
  ttl: QueryTimeToLive,
681
- tableName: T
1969
+ tableName: T,
1970
+ plan?: QueryPlan,
1971
+ membershipKey?: string
682
1972
  ): Promise<QueryHash> {
683
1973
  const queryState = await this.createNewQuery<T>({
684
1974
  recordId,
@@ -686,31 +1976,77 @@ export class DataModule<S extends SchemaStructure> {
686
1976
  params,
687
1977
  ttl,
688
1978
  tableName,
1979
+ plan,
1980
+ membershipKey,
689
1981
  });
690
1982
 
691
- const { localArray } = this.cache.registerQuery({
1983
+ const t0 = performance.now();
1984
+ const { localArray, registrationTimings } = this.cache.registerQuery({
692
1985
  queryHash: hash,
693
1986
  surql: surqlString,
694
1987
  params,
695
1988
  ttl: new Duration(ttl),
696
1989
  lastActiveAt: new Date(),
697
1990
  });
1991
+ const registrationTime = performance.now() - t0;
1992
+
1993
+ // Record the one-shot SSP registration timings (parse/plan/snapshot from the
1994
+ // WASM binding) + the register_view wall time for DevTools.
1995
+ queryState.registrationTimings = {
1996
+ parseMs: registrationTimings?.parseMs ?? null,
1997
+ planMs: registrationTimings?.planMs ?? null,
1998
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
1999
+ wallMs: registrationTime,
2000
+ };
698
2001
 
699
2002
  await withRetry(this.logger, () =>
700
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
701
- id: recordId,
702
- localArray,
703
- })
2003
+ this.local.query(
2004
+ surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
2005
+ {
2006
+ id: recordId,
2007
+ localArray,
2008
+ registrationTime,
2009
+ rowCount: localArray.length,
2010
+ }
2011
+ )
704
2012
  );
705
2013
 
2014
+ // Windowed (`START n`) queries skipped the raw initial load in
2015
+ // createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
2016
+ // initial rows now from the SSP's window id-set (`localArray`) via the same
2017
+ // window-materialization path the stream updates use — O(window), and the
2018
+ // ids are already the correct window — so the first paint isn't empty while
2019
+ // the remote `_00_list_ref` syncs in.
2020
+ const windowMat = buildWindowMaterialization(surqlString);
2021
+ if (windowMat && localArray.length > 0) {
2022
+ try {
2023
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
2024
+ if (plan) {
2025
+ const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
2026
+ queryState.records = await this.local.select(winPlan, params);
2027
+ } else {
2028
+ const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
2029
+ ...params,
2030
+ __win: winIds,
2031
+ });
2032
+ queryState.records = seeded || [];
2033
+ }
2034
+ } catch (err) {
2035
+ this.logger.warn(
2036
+ { err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
2037
+ 'Failed to seed windowed initial records from localArray'
2038
+ );
2039
+ }
2040
+ }
2041
+
706
2042
  this.activeQueries.set(hash, queryState);
707
- this.startTTLHeartbeat(queryState);
2043
+ this.startTTLHeartbeat(queryState, hash);
708
2044
  this.logger.debug(
709
2045
  {
710
2046
  hash,
711
2047
  tableName,
712
2048
  recordCount: queryState.records.length,
713
- Category: 'spooky-client::DataModule::query',
2049
+ Category: 'sp00ky-client::DataModule::query',
714
2050
  },
715
2051
  'Query registered'
716
2052
  );
@@ -724,14 +2060,27 @@ export class DataModule<S extends SchemaStructure> {
724
2060
  params,
725
2061
  ttl,
726
2062
  tableName,
2063
+ plan,
2064
+ membershipKey,
727
2065
  }: {
728
2066
  recordId: RecordId;
729
2067
  surql: string;
730
2068
  params: Record<string, any>;
731
2069
  ttl: QueryTimeToLive;
732
2070
  tableName: T;
2071
+ plan?: QueryPlan;
2072
+ membershipKey?: string;
733
2073
  }): Promise<QueryState> {
734
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
2074
+ // `_00_*` meta tables (feature flags, app releases) are framework-owned:
2075
+ // they exist in the client db schema by construction but are never part of
2076
+ // the app's generated `schema.tables`, so give them an empty column map
2077
+ // instead of the not-found error (which silently broke every meta-table
2078
+ // live query, e.g. feature flags never updating on this path).
2079
+ const tableSchema =
2080
+ this.schema.tables.find((t) => t.name === tableName) ??
2081
+ (String(tableName).startsWith('_00_')
2082
+ ? ({ name: tableName, columns: {} } as any)
2083
+ : undefined);
735
2084
  if (!tableSchema) {
736
2085
  throw new Error(`Table ${tableName} not found`);
737
2086
  }
@@ -752,8 +2101,12 @@ export class DataModule<S extends SchemaStructure> {
752
2101
  localArray: [],
753
2102
  remoteArray: [],
754
2103
  lastActiveAt: new Date(),
2104
+ createdAt: new Date(),
755
2105
  ttl,
756
2106
  tableName,
2107
+ updateCount: 0,
2108
+ rowCount: 0,
2109
+ errorCount: 0,
757
2110
  },
758
2111
  })
759
2112
  );
@@ -763,62 +2116,159 @@ export class DataModule<S extends SchemaStructure> {
763
2116
  const config: QueryConfig = {
764
2117
  ...configRecord,
765
2118
  id: recordId,
2119
+ // In-memory only — carries the engine-neutral plan so non-SurrealQL local
2120
+ // engines materialize via `select(plan)` instead of parsing `surql`.
2121
+ plan,
766
2122
  params: parseParams(tableSchema.columns, configRecord.params),
2123
+ membershipKey,
767
2124
  };
768
2125
 
2126
+ // The `_00_query` row we just read is session-salted, so on a reload it is
2127
+ // always a fresh row with empty arrays. Recover the last authoritative
2128
+ // membership from the durable `_00_window` row instead — this is what stops a
2129
+ // removed row reappearing after a reload, and it works with no network.
2130
+ if (membershipKey && !config.remoteArray?.length) {
2131
+ const durable = await this.getWindowMembership(membershipKey);
2132
+ // `durable.length` on purpose: an empty durable row cannot be told apart
2133
+ // from one written before this device ever saw a real id-set, and treating
2134
+ // it as known means the first paint is empty with no scan fallback. It
2135
+ // also self-heals devices poisoned by the pre-fix `writeWindowMembership`.
2136
+ if (durable?.length) {
2137
+ config.remoteArray = durable;
2138
+ config.membershipKnown = true;
2139
+ }
2140
+ } else if (config.remoteArray?.length) {
2141
+ config.membershipKnown = true;
2142
+ }
2143
+
769
2144
  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
- );
2145
+ // Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
2146
+ // `… LIMIT n START m` against the shared local store is O(m) — it sorts and
2147
+ // skips m rows on every window open — AND returns the wrong rows for sparse
2148
+ // windows (the reason `buildWindowMaterialization` exists). Those windows are
2149
+ // seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
2150
+ //
2151
+ // With known membership the same reasoning applies to EVERY query, windowed
2152
+ // or not: the predicate scan below would re-admit rows the server has already
2153
+ // dropped from the window (their bodies are still cached and still match the
2154
+ // WHERE). Seed from membership instead.
2155
+ if (config.membershipKnown) {
2156
+ try {
2157
+ records = await this.materializeFromConfig(config);
2158
+ } catch (err) {
2159
+ this.logger.warn(
2160
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
2161
+ 'Failed to seed records from durable membership'
2162
+ );
2163
+ }
2164
+ } else if (buildWindowMaterialization(surqlString) === null) {
2165
+ try {
2166
+ // Prefer the engine-neutral plan (required for non-SurrealQL engines);
2167
+ // fall back to running the raw surql on the SurrealDB engine.
2168
+ const result = plan
2169
+ ? await this.local.select(plan, params)
2170
+ : (await this.local.query<[Record<string, any>[]]>(surqlString, params))[0];
2171
+ records = result || [];
2172
+ } catch (err) {
2173
+ this.logger.warn(
2174
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
2175
+ 'Failed to load initial cached records'
2176
+ );
2177
+ }
778
2178
  }
779
2179
 
2180
+ // Persisted counters survive a restart even though the rolling
2181
+ // sample window is rebuilt from scratch in memory.
2182
+ const persistedUpdateCount =
2183
+ typeof (configRecord as any)?.updateCount === 'number'
2184
+ ? (configRecord as any).updateCount
2185
+ : 0;
2186
+ const persistedErrorCount =
2187
+ typeof (configRecord as any)?.errorCount === 'number' ? (configRecord as any).errorCount : 0;
2188
+
780
2189
  return {
781
2190
  config,
782
2191
  records,
783
2192
  ttlTimer: null,
784
2193
  ttlDurationMs: parseDuration(ttl),
785
- updateCount: 0,
2194
+ updateCount: persistedUpdateCount,
2195
+ lastUpdatedAt: null,
2196
+ materializationSamples: [],
2197
+ lastIngestLatencyMs: null,
2198
+ errorCount: persistedErrorCount,
2199
+ // Born `fetching`, not `idle`: every cold registration is followed by a
2200
+ // `register` down-event whose lifecycle (Sp00kySync.registerQuery) resolves
2201
+ // the status to `idle` once the initial sync completed. Starting idle left
2202
+ // a gap where a fresh windowed query looked settled while still empty.
2203
+ status: 'fetching',
2204
+ phaseSamples: {},
2205
+ phaseLast: {},
2206
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
786
2207
  };
787
2208
  }
788
2209
 
789
2210
  private async calculateHash(data: any): Promise<string> {
790
- const content = JSON.stringify(data);
2211
+ // sessionId is part of the hash so the same logical query from two
2212
+ // sessions (e.g. two browser tabs of the same user) lands on different
2213
+ // `_00_query` rows and doesn't fight over a shared one.
2214
+ return this.sha256(JSON.stringify({ ...data, sessionId: this.sessionId }));
2215
+ }
2216
+
2217
+ /**
2218
+ * Session-independent counterpart of {@link calculateHash}: the key for a
2219
+ * query's durable `_00_window` membership row.
2220
+ *
2221
+ * Deliberately the SAME inputs minus the `session::id()` salt, so the two keys
2222
+ * can never drift apart. The salt is right for `_00_query` (two tabs must not
2223
+ * fight over one row) and wrong for membership, which has to be recognizable
2224
+ * after a reload — a reload mints a new session id, and offline the salt is
2225
+ * `''`, so a salted key can never match what the previous session wrote.
2226
+ */
2227
+ private async calculateMembershipKey(data: any): Promise<string> {
2228
+ return this.sha256(JSON.stringify(data));
2229
+ }
2230
+
2231
+ private async sha256(content: string): Promise<string> {
791
2232
  const msgBuffer = new TextEncoder().encode(content);
792
2233
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
793
2234
  const hashArray = Array.from(new Uint8Array(hashBuffer));
794
2235
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
795
2236
  }
796
2237
 
797
- private startTTLHeartbeat(queryState: QueryState): void {
2238
+ private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
798
2239
  if (queryState.ttlTimer) return;
799
2240
 
800
2241
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
801
2242
 
802
2243
  queryState.ttlTimer = setTimeout(() => {
803
- // TODO: Emit heartbeat event for sync
2244
+ queryState.ttlTimer = null;
2245
+ // Only keep the remote query alive while something is actually watching
2246
+ // it. With the server now sweeping ALL expired views (not just in-circuit
2247
+ // ones), an un-refreshed query WOULD be swept after its TTL — so a live
2248
+ // subscriber must heartbeat. An abandoned query (no subscribers) is left
2249
+ // to expire and get swept; its local view was already torn down at the
2250
+ // last unsubscribe, so we just stop the timer here.
2251
+ const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
2252
+ if (subscriberCount === 0) {
2253
+ this.logger.debug(
2254
+ { hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
2255
+ 'TTL heartbeat: no subscribers, stopping'
2256
+ );
2257
+ return;
2258
+ }
2259
+ this.onHeartbeat?.(hash);
804
2260
  this.logger.debug(
805
2261
  {
2262
+ hash,
806
2263
  id: encodeRecordId(queryState.config.id),
807
- Category: 'spooky-client::DataModule::startTTLHeartbeat',
2264
+ Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
808
2265
  },
809
- 'TTL heartbeat'
2266
+ 'TTL heartbeat sent'
810
2267
  );
811
- this.startTTLHeartbeat(queryState);
2268
+ this.startTTLHeartbeat(queryState, hash);
812
2269
  }, heartbeatTime);
813
2270
  }
814
2271
 
815
- private stopTTLHeartbeat(queryState: QueryState): void {
816
- if (queryState.ttlTimer) {
817
- clearTimeout(queryState.ttlTimer);
818
- queryState.ttlTimer = null;
819
- }
820
- }
821
-
822
2272
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
823
2273
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
824
2274
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -851,7 +2301,7 @@ export function parseUpdateOptions(
851
2301
  const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
852
2302
  const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
853
2303
  const key =
854
- keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
2304
+ keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
855
2305
 
856
2306
  pushEventOptions = {
857
2307
  debounced: {