@spooky-sync/core 0.0.1-canary.8 → 0.0.1-canary.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +854 -339
  3. package/dist/index.js +2633 -396
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +494 -0
  7. package/package.json +39 -9
  8. package/skills/sp00ky-core/SKILL.md +258 -0
  9. package/skills/sp00ky-core/references/auth.md +98 -0
  10. package/skills/sp00ky-core/references/config.md +76 -0
  11. package/src/build-globals.d.ts +12 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +9 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +59 -20
  17. package/src/modules/cache/index.ts +41 -29
  18. package/src/modules/cache/types.ts +2 -2
  19. package/src/modules/crdt/crdt-field.ts +281 -0
  20. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  21. package/src/modules/crdt/index.ts +352 -0
  22. package/src/modules/data/data.status.test.ts +108 -0
  23. package/src/modules/data/index.ts +726 -108
  24. package/src/modules/data/window-query.test.ts +52 -0
  25. package/src/modules/data/window-query.ts +130 -0
  26. package/src/modules/devtools/index.ts +89 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/feature-flag/index.ts +166 -0
  30. package/src/modules/ref-tables.test.ts +56 -0
  31. package/src/modules/ref-tables.ts +57 -0
  32. package/src/modules/sync/engine.ts +97 -37
  33. package/src/modules/sync/events/index.ts +3 -2
  34. package/src/modules/sync/queue/queue-down.ts +5 -4
  35. package/src/modules/sync/queue/queue-up.ts +14 -13
  36. package/src/modules/sync/scheduler.ts +2 -2
  37. package/src/modules/sync/sync.ts +676 -58
  38. package/src/modules/sync/utils.test.ts +269 -2
  39. package/src/modules/sync/utils.ts +182 -17
  40. package/src/otel/index.ts +127 -0
  41. package/src/services/database/database.ts +11 -11
  42. package/src/services/database/events/index.ts +2 -1
  43. package/src/services/database/local-migrator.ts +21 -21
  44. package/src/services/database/local.test.ts +33 -0
  45. package/src/services/database/local.ts +192 -32
  46. package/src/services/database/remote.ts +13 -13
  47. package/src/services/logger/index.ts +6 -101
  48. package/src/services/persistence/localstorage.ts +2 -2
  49. package/src/services/persistence/resilient.ts +41 -0
  50. package/src/services/persistence/surrealdb.ts +9 -9
  51. package/src/services/stream-processor/index.ts +248 -37
  52. package/src/services/stream-processor/permissions.test.ts +47 -0
  53. package/src/services/stream-processor/permissions.ts +53 -0
  54. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  55. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  56. package/src/services/stream-processor/wasm-types.ts +18 -2
  57. package/src/sp00ky.auth-order.test.ts +65 -0
  58. package/src/sp00ky.ts +625 -0
  59. package/src/types.ts +140 -13
  60. package/src/utils/index.ts +35 -13
  61. package/src/utils/parser.ts +3 -2
  62. package/src/utils/surql.ts +24 -15
  63. package/src/utils/withRetry.test.ts +1 -1
  64. package/tsdown.config.ts +55 -1
  65. package/src/spooky.ts +0 -392
@@ -1,33 +1,125 @@
1
- import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
- import { MutationEvent, RecordVersionArray } from '../../types';
1
+ import type { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
+ import type { RecordVersionArray, RecordVersionDiff } from '../../types';
3
3
  import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
4
- import { Logger } from '../../services/logger/index';
5
- import { DownEvent, DownQueue, UpEvent, UpQueue } from './queue/index';
6
- import { RecordId, Uuid } from 'surrealdb';
7
- import { ArraySyncer, createDiffFromDbOp } from './utils';
4
+ import type { Logger } from '../../services/logger/index';
5
+ import type { DownEvent, UpEvent} from './queue/index';
6
+ import { DownQueue, UpQueue } from './queue/index';
7
+ import type { RecordId, Uuid } from 'surrealdb';
8
+ import {
9
+ ArraySyncer,
10
+ buildListRefSelect,
11
+ buildSubqueryListRefSelect,
12
+ createDiffFromDbOp,
13
+ diffRecordVersionArray,
14
+ listRefPollDelayMs,
15
+ recordVersionArraysEqual,
16
+ resolveListRefPollInterval,
17
+ } from './utils';
8
18
  import { SyncEngine } from './engine';
9
19
  import { SyncScheduler } from './scheduler';
10
- import { SchemaStructure } from '@spooky-sync/query-builder';
11
- import { CacheModule } from '../cache/index';
12
- import { DataModule } from '../data/index';
13
- import { encodeRecordId, extractTablePart, parseDuration, surql } from '../../utils/index';
20
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
21
+ import type { CacheModule } from '../cache/index';
22
+ import type { DataModule } from '../data/index';
23
+ import { encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
24
+ import { DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
14
25
 
15
26
  /**
16
- * The main synchronization engine for Spooky.
27
+ * Tunables for `Sp00kySync` construction.
28
+ */
29
+ export interface Sp00kySyncOptions {
30
+ /**
31
+ * Cadence (ms) for the `_00_list_ref` poll fallback that catches
32
+ * cross-session UPDATEs the LIVE-permission gap drops. Non-positive
33
+ * values fall back to the default; see
34
+ * {@link resolveListRefPollInterval}.
35
+ */
36
+ refSyncIntervalMs?: number;
37
+ }
38
+
39
+ /**
40
+ * The main synchronization engine for Sp00ky.
17
41
  * Handles the bidirectional synchronization between the local database and the remote backend.
18
42
  * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
19
43
  * @template S The schema structure type.
20
44
  */
21
- export class SpookySync<S extends SchemaStructure> {
22
- private clientId: string = '';
45
+ export class Sp00kySync<S extends SchemaStructure> {
23
46
  private upQueue: UpQueue;
24
47
  private downQueue: DownQueue;
25
48
  private isInit: boolean = false;
26
49
  private logger: Logger;
27
50
  private syncEngine: SyncEngine;
51
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
52
+ * from `this.events`, which carries Sp00kySync-level events like
53
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
54
+ public get engineEvents() {
55
+ return this.syncEngine.events;
56
+ }
28
57
  private scheduler: SyncScheduler;
58
+ private wasDisconnected: boolean = false;
29
59
  public events = createSyncEventSystem();
30
60
 
61
+ // Auth identity that drives per-user `_00_list_ref_user_<id>` routing
62
+ // in `RefMode.Dedicated`. Updated by `setCurrentUserId` from the auth
63
+ // subscription in `Sp00kyClient`; null when unauthenticated.
64
+ private currentUserId: string | null = null;
65
+
66
+ private refMode: RefMode = DEFAULT_REF_MODE;
67
+
68
+ // Bookkeeping for the LIVE subscription on `_00_list_ref[_user_*]`.
69
+ // SurrealDB binds the permission context at LIVE-registration time and
70
+ // the table name in dedicated mode depends on the authenticated user,
71
+ // so we have to re-register whenever auth state flips.
72
+ private currentLiveQueryUuid: Uuid | null = null;
73
+ private liveQueryUnsubscribe: (() => void) | null = null;
74
+
75
+ // Periodic re-poll of `_00_list_ref` as a safety net for missed LIVE
76
+ // notifications. SurrealDB v3 occasionally drops LIVE deliveries
77
+ // across sessions even when the row matches the permission rule;
78
+ // this catches those without requiring users to reload. The
79
+ // interval is configurable via the constructor; see
80
+ // `resolveListRefPollInterval` for fallback semantics.
81
+ //
82
+ // Self-rescheduling rather than setInterval so each tick can pick
83
+ // its own delay via `nextPollDelayMs` — slows the poll down when
84
+ // LIVE is delivering events and speeds it back up when LIVE quiets.
85
+ private listRefPollTimer: ReturnType<typeof setTimeout> | null = null;
86
+ private listRefPollRunning: boolean = false;
87
+ public readonly refSyncIntervalMs: number;
88
+
89
+ // Consecutive poll cycles that observed NO list_ref change. Drives the
90
+ // adaptive backoff in `startListRefPoll` via `listRefPollDelayMs`: an idle
91
+ // page coasts from the fast base cadence toward the 5s cap, and any activity
92
+ // (a poll-detected change or a LIVE event) resets it to 0 so the poll snaps
93
+ // back to responsive. Replaces the old LIVE-liveness backoff, which kept the
94
+ // poll pinned at 500ms forever whenever LIVE wasn't firing (the common case
95
+ // on a quiet page, thanks to the cross-session LIVE-permission gap).
96
+ private listRefIdleStreak: number = 0;
97
+
98
+ // `${queryHash}:${recordId}` -> consecutive rounds the id has been "still
99
+ // remote" (left the query's list_ref but still exists upstream). Used to
100
+ // distinguish a PERSISTENT view-membership disagreement (the `job:` churn,
101
+ // converged once it crosses the threshold) from a record that's merely
102
+ // mid-deletion (still-remote for ~one round, then gone) — which must NOT be
103
+ // converged, or it gets stranded in this window before its delete is observed.
104
+ private stillRemoteStreaks: Map<string, number> = new Map();
105
+
106
+ // Wall-clock timestamp (ms) of the most recent LIVE event delivered
107
+ // through `handleRemoteListRefChange`. Kept as a diagnostic / liveness
108
+ // signal; the poll cadence is now driven by `listRefIdleStreak`.
109
+ private lastLiveEventAt: number | null = null;
110
+
111
+ // Number of times the initial `_00_list_ref[_user_*]` LIVE subscription
112
+ // had to retry on `setCurrentUserId`. Stays at 0 when the SSP has
113
+ // pre-emptively created the user's dedicated tables; otherwise
114
+ // increments on each retry attempt until LIVE succeeds or attempts
115
+ // are exhausted. Surfaced as a diagnostic so the e2e suite can prove
116
+ // the pre-emptive table-creation path is keeping the first sign-in
117
+ // off the lazy-creation race.
118
+ private _liveRetryCount: number = 0;
119
+ public get liveRetryCount(): number {
120
+ return this._liveRetryCount;
121
+ }
122
+
31
123
  get isSyncing() {
32
124
  return this.scheduler.isSyncing;
33
125
  }
@@ -57,9 +149,10 @@ export class SpookySync<S extends SchemaStructure> {
57
149
  private cache: CacheModule,
58
150
  private dataModule: DataModule<S>,
59
151
  private schema: S,
60
- logger: Logger
152
+ logger: Logger,
153
+ options?: Sp00kySyncOptions
61
154
  ) {
62
- this.logger = logger.child({ service: 'SpookySync' });
155
+ this.logger = logger.child({ service: 'Sp00kySync' });
63
156
  this.upQueue = new UpQueue(this.local, this.logger);
64
157
  this.downQueue = new DownQueue(this.local, this.logger);
65
158
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
@@ -71,41 +164,336 @@ export class SpookySync<S extends SchemaStructure> {
71
164
  this.logger,
72
165
  this.handleRollback.bind(this)
73
166
  );
167
+ this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
74
168
  }
75
169
 
76
170
  /**
77
171
  * Initializes the synchronization system.
78
172
  * Starts the scheduler and initiates the initial sync cycles.
79
- * @param clientId The unique identifier for this client instance.
80
173
  * @throws Error if already initialized.
81
174
  */
82
- public async init(clientId: string) {
83
- if (this.isInit) throw new Error('SpookySync is already initialized');
84
- this.clientId = clientId;
175
+ public async init() {
176
+ if (this.isInit) throw new Error('Sp00kySync is already initialized');
85
177
  this.isInit = true;
86
178
  await this.scheduler.init();
87
- void this.scheduler.syncUp();
179
+ this.subscribeToReconnect();
88
180
  void this.scheduler.syncUp();
89
181
  void this.scheduler.syncDown();
90
- void this.startRefLiveQueries();
182
+ // No initial LIVE subscription — wait for `setCurrentUserId` to fire
183
+ // from the auth subscription. In dedicated mode the table name
184
+ // depends on the authenticated user, and an unauthenticated
185
+ // subscription wouldn't match any of the per-user tables anyway.
186
+ }
187
+
188
+ /**
189
+ * Push the authenticated user's record id from the parent client's
190
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
191
+ * any) and re-registers it under the new user's dedicated table so
192
+ * SurrealDB binds the permission rule under the post-flip auth
193
+ * context. Pass `null` on sign-out.
194
+ *
195
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
196
+ * the SSP when the first query registration arrives, which may be
197
+ * concurrent with this call. We retry the LIVE registration with a
198
+ * short backoff so a "table not found" race resolves without
199
+ * surfacing as a permanent auth-loading hang.
200
+ */
201
+ public async setCurrentUserId(userId: string | null): Promise<void> {
202
+ if (this.currentUserId === userId) return;
203
+ this.currentUserId = userId;
204
+ if (!userId) {
205
+ await this.killRefLiveQuery();
206
+ this.stopListRefPoll();
207
+ return;
208
+ }
209
+ // Start periodic polling FIRST so we have a deterministic fallback
210
+ // even when LIVE registration fails or SurrealDB drops a delivery.
211
+ this.startListRefPoll();
212
+ // Try to start LIVE with backoff for low-latency delivery on the
213
+ // happy path; the poll handles the rest.
214
+ const attemptDelays = [0, 250, 500, 1000, 2000];
215
+ for (let i = 0; i < attemptDelays.length; i++) {
216
+ if (attemptDelays[i] > 0) {
217
+ this._liveRetryCount++;
218
+ await new Promise((r) => setTimeout(r, attemptDelays[i]));
219
+ }
220
+ try {
221
+ await this.restartRefLiveQuery();
222
+ return;
223
+ } catch (err) {
224
+ this.logger.debug(
225
+ { err, attempt: i + 1, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
226
+ 'Ref LIVE start failed; relying on periodic poll fallback'
227
+ );
228
+ }
229
+ }
230
+ }
231
+
232
+ private startListRefPoll(): void {
233
+ if (this.listRefPollRunning) return;
234
+ this.listRefPollRunning = true;
235
+ this.logger.debug(
236
+ {
237
+ intervalMs: this.refSyncIntervalMs,
238
+ Category: 'sp00ky-client::Sp00kySync::startListRefPoll',
239
+ },
240
+ 'list_ref poll loop started'
241
+ );
242
+ const schedule = (delayMs: number) => {
243
+ this.listRefPollTimer = setTimeout(async () => {
244
+ if (!this.listRefPollRunning) return;
245
+ let changed = false;
246
+ try {
247
+ changed = await this.pollListRefForActiveQueries();
248
+ } finally {
249
+ if (!this.listRefPollRunning) return;
250
+ // Reset the idle streak on any observed change so the poll snaps
251
+ // back to the fast base cadence; otherwise grow it so a quiet page
252
+ // backs off toward the cap. (`handleRemoteListRefChange` also resets
253
+ // it when a LIVE event lands.)
254
+ this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
255
+ const next = listRefPollDelayMs({
256
+ idleStreak: this.listRefIdleStreak,
257
+ baseIntervalMs: this.refSyncIntervalMs,
258
+ });
259
+ schedule(next);
260
+ }
261
+ }, delayMs);
262
+ };
263
+ schedule(this.refSyncIntervalMs);
264
+ }
265
+
266
+ private stopListRefPoll(): void {
267
+ this.listRefPollRunning = false;
268
+ if (this.listRefPollTimer !== null) {
269
+ clearTimeout(this.listRefPollTimer);
270
+ this.listRefPollTimer = null;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
276
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
277
+ * to drive the adaptive idle backoff.
278
+ */
279
+ private async pollListRefForActiveQueries(): Promise<boolean> {
280
+ const hashes = this.dataModule.getActiveQueryHashes();
281
+ if (hashes.length === 0) return false;
282
+ let anyChanged = false;
283
+ for (const hash of hashes) {
284
+ try {
285
+ if (await this.refetchListRefForQuery(hash)) anyChanged = true;
286
+ } catch (err) {
287
+ this.logger.debug(
288
+ { err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
289
+ 'Per-query list_ref poll failed'
290
+ );
291
+ }
292
+ }
293
+ return anyChanged;
294
+ }
295
+
296
+ /**
297
+ * Pull the upstream list_ref entries for `queryHash`, diff them
298
+ * against the local `remoteArray` cache, sync any added/updated rows
299
+ * through the SyncEngine, then persist the new remoteArray. This is
300
+ * the same shape `createRemoteQuery` does for its initial fetch and
301
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
302
+ * it on a timer as a fallback for missed LIVE notifications.
303
+ */
304
+ private async refetchListRefForQuery(queryHash: string): Promise<boolean> {
305
+ const queryState = this.dataModule.getQueryByHash(queryHash);
306
+ if (!queryState) return false;
307
+ const listRefTbl = this.listRefTable();
308
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
309
+ buildListRefSelect(listRefTbl),
310
+ { in: queryState.config.id }
311
+ );
312
+ if (!Array.isArray(items)) return false;
313
+ const fresh: RecordVersionArray = items.map((item) => [
314
+ encodeRecordId(item.out),
315
+ item.version,
316
+ ]);
317
+ // Capture which ids LEFT the query's window (present in the cached
318
+ // remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
319
+ // are cross-window deletes (or rows that scrolled out). They drive the
320
+ // forced re-render below.
321
+ const prevRemote = queryState.config.remoteArray ?? [];
322
+ const freshIds = new Set(fresh.map(([id]) => id));
323
+ const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
324
+ // Idempotent poll: only persist the remoteArray when it actually changed.
325
+ // The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
326
+ // equals the cached array every tick — re-writing it (an `UPDATE _00_query`
327
+ // each cycle, per active query) was pure churn and the bulk of the idle
328
+ // traffic. `recordVersionArraysEqual` is order-insensitive because the
329
+ // list_ref SELECT has no `ORDER BY`.
330
+ const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
331
+ if (changed) {
332
+ // Update the cached remoteArray so the next diff/sync sees the new state.
333
+ // `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
334
+ // the local DB row and ingests it into the in-browser SSP — the SSP's
335
+ // stream updates run `processStreamUpdate`, which re-queries the local DB
336
+ // and notifies subscribers. We skip an explicit `notifyQuerySynced`
337
+ // because that path races the stream-update path (can notify with stale
338
+ // records).
339
+ await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
340
+ }
341
+ // Run `syncQuery` every tick regardless: it's a no-op when localArray has
342
+ // caught up to remoteArray (`if (!diff) return`, issues no query), but it
343
+ // covers the rare case where remoteArray is stable yet localArray is behind
344
+ // (a prior record fetch failed) — so a missed row still gets retried.
345
+ // For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
346
+ // confirmed-gone records from the local DB.
347
+ try {
348
+ await this.syncQuery(queryHash);
349
+ } catch (err) {
350
+ this.logger.info(
351
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
352
+ 'syncQuery failed during poll'
353
+ );
354
+ }
355
+ // Cross-session fallback for `.related()` child rows: the LIVE-permission
356
+ // gap can drop child-edge notifications, so converge their bodies on the
357
+ // poll too (idempotent — no-op when nothing changed).
358
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
359
+ this.logger.info(
360
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
361
+ 'Subquery child sync failed during poll'
362
+ );
363
+ });
364
+ // A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
365
+ // get a re-render from the SSP stream on this code path reliably (and the
366
+ // non-windowed window-0 query re-queries the local DB rather than the id-set).
367
+ // Force a re-materialize + notify so the deleted row drops from the list in
368
+ // this (second) window — the reliable, LIVE-independent cross-window path.
369
+ if (removedIds.length > 0) {
370
+ try {
371
+ await this.dataModule.notifyQuerySynced(queryHash);
372
+ } catch (err) {
373
+ this.logger.info(
374
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
375
+ 'notifyQuerySynced failed during poll-removal re-render'
376
+ );
377
+ }
378
+ }
379
+ return changed;
380
+ }
381
+
382
+ /**
383
+ * Resolve the current `_00_list_ref` table name for the active auth
384
+ * context. Public so the `createRemoteQuery` initial-fetch path can
385
+ * read from the right per-user table.
386
+ *
387
+ * Reads the user id from `DataModule` rather than the local mirror,
388
+ * because `DataModule.setCurrentUserId` runs synchronously from the
389
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
390
+ * is async — the userQuery's initial fetch can fire between those
391
+ * two points and we need the correct table name immediately.
392
+ */
393
+ public listRefTable(): string {
394
+ return listRefTableFor(this.refMode, this.dataModule.getCurrentUserId());
395
+ }
396
+
397
+ private async killRefLiveQuery(): Promise<void> {
398
+ if (this.liveQueryUnsubscribe) {
399
+ try { this.liveQueryUnsubscribe(); } catch { /* ignore */ }
400
+ this.liveQueryUnsubscribe = null;
401
+ }
402
+ if (this.currentLiveQueryUuid !== null) {
403
+ try {
404
+ await this.remote.query('KILL $u', { u: this.currentLiveQueryUuid });
405
+ } catch (err) {
406
+ this.logger.debug(
407
+ { err, Category: 'sp00ky-client::Sp00kySync::killRefLiveQuery' },
408
+ 'Prior LIVE KILL failed; continuing'
409
+ );
410
+ }
411
+ this.currentLiveQueryUuid = null;
412
+ }
413
+ }
414
+
415
+ private async restartRefLiveQuery(): Promise<void> {
416
+ await this.killRefLiveQuery();
417
+ await this.startRefLiveQueries();
418
+ }
419
+
420
+ // Only the connect that follows a prior disconnect counts as a
421
+ // reconnect; the initial connect after init() must not trigger a
422
+ // refetch storm.
423
+ private subscribeToReconnect() {
424
+ const client = this.remote.getClient();
425
+ client.subscribe('disconnected', () => {
426
+ this.wasDisconnected = true;
427
+ this.logger.info(
428
+ { Category: 'sp00ky-client::Sp00kySync::onDisconnect' },
429
+ 'Remote disconnected'
430
+ );
431
+ });
432
+ client.subscribe('connected', () => {
433
+ if (!this.wasDisconnected) return;
434
+ this.wasDisconnected = false;
435
+ this.logger.info(
436
+ { Category: 'sp00ky-client::Sp00kySync::onReconnect' },
437
+ 'Remote reconnected, refetching active queries'
438
+ );
439
+ for (const hash of this.dataModule.getActiveQueryHashes()) {
440
+ this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
441
+ }
442
+ // The WS reconnect leaves the server-side LIVE subscription dead — the
443
+ // re-enqueued `register` events only re-fetch initial state, they don't
444
+ // re-subscribe. Without this, LIVE never recovers after a reconnect and
445
+ // the poll silently becomes the sole sync path (and never backs off).
446
+ // Only when authenticated: a signed-out reconnect has no per-user table.
447
+ if (this.currentUserId) {
448
+ this.restartRefLiveQuery().catch((err) => {
449
+ this.logger.debug(
450
+ { err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
451
+ 'LIVE restart after reconnect failed; relying on poll fallback'
452
+ );
453
+ });
454
+ }
455
+ });
91
456
  }
92
457
 
93
458
  private async startRefLiveQueries() {
459
+ const tableName = this.listRefTable();
94
460
  this.logger.debug(
95
- { clientId: this.clientId, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
461
+ { tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
96
462
  'Starting ref live queries'
97
463
  );
98
464
 
99
465
  const [queryUuid] = await this.remote.query<[Uuid]>(
100
- 'LIVE SELECT * FROM _spooky_list_ref'
466
+ `LIVE SELECT * FROM ${tableName}`
101
467
  );
468
+ this.currentLiveQueryUuid = queryUuid;
102
469
 
103
- (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
470
+ const live = await this.remote.getClient().liveOf(queryUuid);
471
+ this.liveQueryUnsubscribe = live.subscribe((message) => {
104
472
  this.logger.debug(
105
- { message, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
473
+ { message, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
106
474
  'Live update received'
107
475
  );
108
476
  if (message.action === 'KILLED') return;
477
+ // Subquery child edges (rows with `parent` set) are NOT primary window
478
+ // rows — the client's `RecordVersionArray` only tracks primary rows, so
479
+ // routing them through `handleRemoteListRefChange` would surface them as
480
+ // spurious "added" diffs and pollute the window. Instead route them to
481
+ // the dedicated child-body sync so `.related()` data stays realtime
482
+ // cross-session (the LIVE-permission gap otherwise leaves it to the poll).
483
+ if ((message.value as { parent?: unknown }).parent != null) {
484
+ this.handleRemoteSubqueryChange(
485
+ message.action,
486
+ message.value.in as RecordId<string>,
487
+ message.value.out as RecordId<string>,
488
+ message.value.version as number
489
+ ).catch((err) => {
490
+ this.logger.error(
491
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
492
+ 'Error handling remote subquery change'
493
+ );
494
+ });
495
+ return;
496
+ }
109
497
  this.handleRemoteListRefChange(
110
498
  message.action,
111
499
  message.value.in as RecordId<string>,
@@ -113,7 +501,7 @@ export class SpookySync<S extends SchemaStructure> {
113
501
  message.value.version as number
114
502
  ).catch((err) => {
115
503
  this.logger.error(
116
- { err, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
504
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
117
505
  'Error handling remote list ref change'
118
506
  );
119
507
  });
@@ -126,13 +514,26 @@ export class SpookySync<S extends SchemaStructure> {
126
514
  recordId: RecordId,
127
515
  version: number
128
516
  ) {
517
+ // Any LIVE delivery is evidence of activity — a CREATE/UPDATE/DELETE on a
518
+ // query's window, or a notification for an unknown local query. Reset the
519
+ // poll's idle streak so it snaps back to the fast base cadence (the page
520
+ // is clearly not idle), and record the timestamp as a liveness diagnostic.
521
+ this.lastLiveEventAt = Date.now();
522
+ this.listRefIdleStreak = 0;
523
+
524
+ // NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
525
+ // this one) deletes a record, the server's SSP removes it from `_00_list_ref`
526
+ // and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
527
+ // turns it into a `removed: [recordId]` diff so the row drops from the window
528
+ // in realtime. (It was previously ignored, so other windows only caught up on
529
+ // reload / the slow poll.)
129
530
  const existing = this.dataModule.getQueryById(queryId);
130
531
 
131
532
  if (!existing) {
132
533
  this.logger.warn(
133
534
  {
134
535
  queryId: queryId.toString(),
135
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
536
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
136
537
  },
137
538
  'Received remote update for unknown local query'
138
539
  );
@@ -148,12 +549,55 @@ export class SpookySync<S extends SchemaStructure> {
148
549
  recordId,
149
550
  version,
150
551
  localArray,
151
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
552
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
152
553
  },
153
554
  'Live update is being processed'
154
555
  );
155
556
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
156
- await this.syncEngine.syncRecords(diff);
557
+ // `config.id` is `_00_query:<hash>`, so its id-part IS the query hash
558
+ // (a SHA-256 over query content + sessionId) — the key DataModule uses.
559
+ const hash = extractIdPart(existing.config.id);
560
+ await this.runSyncForQuery(hash, diff);
561
+ }
562
+
563
+ /**
564
+ * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
565
+ * `parent` set) for a `.related()` query. Unlike primary rows, child rows
566
+ * must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
567
+ * keep the child BODY fresh in the local cache so the in-browser SSP's
568
+ * subquery-table dependency re-materializes the parent view.
569
+ *
570
+ * CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
571
+ * no-op: a child leaving this query's set must not delete a body another
572
+ * query may still show (see `syncSubqueryChildren` deletion-safety note);
573
+ * a genuine record delete propagates via the normal delete path.
574
+ */
575
+ private async handleRemoteSubqueryChange(
576
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
577
+ queryId: RecordId,
578
+ childId: RecordId,
579
+ version: number
580
+ ) {
581
+ this.lastLiveEventAt = Date.now();
582
+ this.listRefIdleStreak = 0;
583
+
584
+ if (action === 'DELETE') return;
585
+
586
+ const existing = this.dataModule.getQueryById(queryId);
587
+ if (!existing) return;
588
+
589
+ const item = { id: childId, version };
590
+ await this.syncEngine.syncRecords(
591
+ action === 'CREATE'
592
+ ? { added: [item], updated: [], removed: [] }
593
+ : { added: [], updated: [item], removed: [] }
594
+ );
595
+
596
+ // Keep the in-memory child array in step so the poll's idempotent diff
597
+ // doesn't re-fetch this body on the next tick.
598
+ const key = encodeRecordId(childId);
599
+ const prev = existing.config.subqueryRemoteArray ?? [];
600
+ existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
157
601
  }
158
602
 
159
603
  /**
@@ -166,12 +610,11 @@ export class SpookySync<S extends SchemaStructure> {
166
610
 
167
611
  private async processUpEvent(event: UpEvent) {
168
612
  this.logger.debug(
169
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
613
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
170
614
  'Processing up event'
171
615
  );
172
- console.log('xx1', event);
173
616
  switch (event.type) {
174
- case 'create':
617
+ case 'create': {
175
618
  const dataKeys = Object.keys(event.data).map((key) => ({ key, variable: `data_${key}` }));
176
619
  const prefixedParams = Object.fromEntries(
177
620
  dataKeys.map(({ key, variable }) => [variable, event.data[key]])
@@ -182,6 +625,7 @@ export class SpookySync<S extends SchemaStructure> {
182
625
  ...prefixedParams,
183
626
  });
184
627
  break;
628
+ }
185
629
  case 'update':
186
630
  await this.remote.query(`UPDATE $id MERGE $data`, {
187
631
  id: event.record_id,
@@ -195,7 +639,7 @@ export class SpookySync<S extends SchemaStructure> {
195
639
  break;
196
640
  default:
197
641
  this.logger.error(
198
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
642
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
199
643
  'processUpEvent unknown event type'
200
644
  );
201
645
  return;
@@ -215,7 +659,7 @@ export class SpookySync<S extends SchemaStructure> {
215
659
  recordId,
216
660
  tableName,
217
661
  error: error.message,
218
- Category: 'spooky-client::SpookySync::handleRollback',
662
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
219
663
  },
220
664
  'Rolling back failed mutation'
221
665
  );
@@ -231,7 +675,7 @@ export class SpookySync<S extends SchemaStructure> {
231
675
  this.logger.warn(
232
676
  {
233
677
  recordId,
234
- Category: 'spooky-client::SpookySync::handleRollback',
678
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
235
679
  },
236
680
  'Cannot rollback update: no beforeRecord available. Down-sync will reconcile.'
237
681
  );
@@ -241,7 +685,7 @@ export class SpookySync<S extends SchemaStructure> {
241
685
  this.logger.warn(
242
686
  {
243
687
  recordId,
244
- Category: 'spooky-client::SpookySync::handleRollback',
688
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
245
689
  },
246
690
  'Delete rollback not implemented. Down-sync will reconcile.'
247
691
  );
@@ -257,7 +701,7 @@ export class SpookySync<S extends SchemaStructure> {
257
701
 
258
702
  private async processDownEvent(event: DownEvent) {
259
703
  this.logger.debug(
260
- { event, Category: 'spooky-client::SpookySync::processDownEvent' },
704
+ { event, Category: 'sp00ky-client::Sp00kySync::processDownEvent' },
261
705
  'Processing down event'
262
706
  );
263
707
  switch (event.type) {
@@ -281,7 +725,7 @@ export class SpookySync<S extends SchemaStructure> {
281
725
  const queryState = this.dataModule.getQueryByHash(hash);
282
726
  if (!queryState) {
283
727
  this.logger.warn(
284
- { hash, Category: 'spooky-client::SpookySync::syncQuery' },
728
+ { hash, Category: 'sp00ky-client::Sp00kySync::syncQuery' },
285
729
  'Query not found'
286
730
  );
287
731
  return;
@@ -295,7 +739,104 @@ export class SpookySync<S extends SchemaStructure> {
295
739
  if (!diff) {
296
740
  return;
297
741
  }
298
- return this.syncEngine.syncRecords(diff);
742
+ return this.runSyncForQuery(hash, diff);
743
+ }
744
+
745
+ /**
746
+ * Run a sync for a single query while reflecting its fetch status. Marks the
747
+ * query `fetching` for the duration when the diff actually pulls records
748
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
749
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
750
+ * means the single resulting UI update lands after this completes.
751
+ */
752
+ private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
753
+ // Don't let sync re-add a record the user just deleted locally. The remote
754
+ // delete is queued in the outbox, so until it's processed the server's
755
+ // `_00_list_ref` still lists the record — the diff then classifies it as
756
+ // `added` (present remotely, absent locally) and `syncRecords` re-fetches +
757
+ // re-inserts it, so a deleted database reappears a few seconds later. Drop
758
+ // any id with a pending local DELETE from the re-add paths. Once the remote
759
+ // delete lands, the pending row clears and the server drops it from
760
+ // `_00_list_ref`, so this guard naturally stops applying.
761
+ if (diff.added.length > 0 || diff.updated.length > 0) {
762
+ const pendingDeletes = await this.getPendingDeleteIds();
763
+ if (pendingDeletes.size > 0) {
764
+ diff = {
765
+ added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
766
+ updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
767
+ removed: diff.removed,
768
+ };
769
+ }
770
+ }
771
+
772
+ const fetching = diff.added.length + diff.updated.length > 0;
773
+ if (fetching) {
774
+ this.dataModule.setQueryStatus(hash, 'fetching');
775
+ }
776
+ try {
777
+ const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
778
+ if (fetching) {
779
+ this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
780
+ }
781
+ // Converge localArray to the authoritative remoteArray for ids that left
782
+ // the server's list_ref but still exist — a view-membership change, not a
783
+ // delete — so the poll's diff stops re-flagging them every tick (the `job:`
784
+ // churn). CRUCIAL: only converge after the id has been still-remote for
785
+ // several CONSECUTIVE rounds. A record that's merely mid-deletion is
786
+ // still-remote for ~one round (its delete hasn't committed when our
787
+ // existence check races it) and is gone the next round → it never reaches
788
+ // the threshold, so it's deleted normally instead of being stranded here.
789
+ if (stillRemoteIds.length > 0) {
790
+ const CONVERGE_AFTER = 3;
791
+ const toConverge: string[] = [];
792
+ for (const id of stillRemoteIds) {
793
+ const key = `${hash}:${id}`;
794
+ const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
795
+ if (n >= CONVERGE_AFTER) {
796
+ this.stillRemoteStreaks.delete(key);
797
+ toConverge.push(id);
798
+ } else {
799
+ this.stillRemoteStreaks.set(key, n);
800
+ }
801
+ }
802
+ if (toConverge.length > 0) {
803
+ const qs = this.dataModule.getQueryByHash(hash);
804
+ const local = qs?.config.localArray;
805
+ if (local && local.length > 0) {
806
+ const drop = new Set(toConverge);
807
+ const next = local.filter(([id]) => !drop.has(id));
808
+ if (next.length !== local.length) {
809
+ await this.dataModule.updateQueryLocalArray(hash, next);
810
+ }
811
+ }
812
+ }
813
+ }
814
+ } finally {
815
+ if (fetching) {
816
+ this.dataModule.setQueryStatus(hash, 'idle');
817
+ }
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
823
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
824
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
825
+ * would otherwise resurrect a just-deleted record.
826
+ */
827
+ private async getPendingDeleteIds(): Promise<Set<string>> {
828
+ try {
829
+ const [rows] = await this.local.query<[{ recordId: RecordId<string> }[]]>(
830
+ "SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'"
831
+ );
832
+ return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
833
+ } catch (err) {
834
+ this.logger.warn(
835
+ { err, Category: 'sp00ky-client::Sp00kySync::getPendingDeleteIds' },
836
+ 'Failed to read pending deletes; sync may briefly resurrect a just-deleted record'
837
+ );
838
+ return new Set();
839
+ }
299
840
  }
300
841
 
301
842
  /**
@@ -309,7 +850,7 @@ export class SpookySync<S extends SchemaStructure> {
309
850
  private async registerQuery(queryHash: string) {
310
851
  try {
311
852
  this.logger.debug(
312
- { queryHash, Category: 'spooky-client::SpookySync::registerQuery' },
853
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
313
854
  'Register Query state'
314
855
  );
315
856
  await this.createRemoteQuery(queryHash);
@@ -319,7 +860,7 @@ export class SpookySync<S extends SchemaStructure> {
319
860
  await this.dataModule.notifyQuerySynced(queryHash);
320
861
  } catch (e) {
321
862
  this.logger.error(
322
- { err: e, Category: 'spooky-client::SpookySync::registerQuery' },
863
+ { err: e, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
323
864
  'registerQuery error'
324
865
  );
325
866
  throw e;
@@ -331,15 +872,15 @@ export class SpookySync<S extends SchemaStructure> {
331
872
 
332
873
  if (!queryState) {
333
874
  this.logger.warn(
334
- { queryHash, Category: 'spooky-client::SpookySync::createRemoteQuery' },
875
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
335
876
  'Query to register not found'
336
877
  );
337
878
  throw new Error('Query to register not found');
338
879
  }
339
- // Delegate to remote function which handles DBSP registration & persistence
880
+ // Delegate to remote function which handles DBSP registration & persistence.
881
+ // clientId is set server-side from session::id() — see fn::query::register.
340
882
  await this.remote.query('fn::query::register($config)', {
341
883
  config: {
342
- clientId: this.clientId,
343
884
  id: queryState.config.id,
344
885
  surql: queryState.config.surql,
345
886
  params: queryState.config.params,
@@ -347,8 +888,14 @@ export class SpookySync<S extends SchemaStructure> {
347
888
  },
348
889
  });
349
890
 
891
+ // Initial materialized-view fetch — pull from the same per-user
892
+ // `_00_list_ref_user_<id>` (or global `_00_list_ref` in single
893
+ // mode) that the LIVE subscription listens on, so the two stay in
894
+ // sync. `parent IS NONE` excludes subquery entries; the
895
+ // `localArray` cache only tracks primary records.
896
+ const listRefTbl = this.listRefTable();
350
897
  const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
351
- surql.selectByFieldsAnd('_spooky_list_ref', ['in'], ['out', 'version']),
898
+ buildListRefSelect(listRefTbl),
352
899
  {
353
900
  in: queryState.config.id,
354
901
  }
@@ -358,7 +905,7 @@ export class SpookySync<S extends SchemaStructure> {
358
905
  {
359
906
  queryId: encodeRecordId(queryState.config.id),
360
907
  items,
361
- Category: 'spooky-client::SpookySync::createRemoteQuery',
908
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
362
909
  },
363
910
  'Got query record version array from remote'
364
911
  );
@@ -369,7 +916,7 @@ export class SpookySync<S extends SchemaStructure> {
369
916
  {
370
917
  queryId: encodeRecordId(queryState.config.id),
371
918
  array,
372
- Category: 'spooky-client::SpookySync::createRemoteQuery',
919
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
373
920
  },
374
921
  'createdRemoteQuery'
375
922
  );
@@ -378,13 +925,71 @@ export class SpookySync<S extends SchemaStructure> {
378
925
  /// Incantation existed already
379
926
  await this.dataModule.updateQueryRemoteArray(queryHash, array);
380
927
  }
928
+
929
+ // Pull the bodies of any `.related()` subquery children into the local
930
+ // cache. The primary fetch above (`parent IS NONE`) tracks only window
931
+ // rows, so without this a cold-reload re-materialization of the
932
+ // correlated surql finds no child rows and related fields come back
933
+ // empty. Best-effort: never fail registration over it.
934
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
935
+ this.logger.info(
936
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
937
+ 'Subquery child sync failed during registration; poll will retry'
938
+ );
939
+ });
381
940
  }
382
941
 
383
- private async heartbeatQuery(queryHash: string) {
942
+ /**
943
+ * Sync the BODIES of a `.related()` query's subquery child rows into the
944
+ * local cache, separately from the primary window array. The SSP writes
945
+ * each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
946
+ * `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
947
+ * nesting depth). We diff against the in-memory `subqueryRemoteArray` and
948
+ * fetch added/updated bodies through the SyncEngine — which `saveBatch`s
949
+ * them into the local DB AND the in-browser SSP, whose subquery-table
950
+ * dependency then re-materializes the parent view (no explicit notify).
951
+ *
952
+ * Deletion safety: we pass `removed: []` deliberately. A child body can be
953
+ * shared by other queries; letting `handleRemovedRecords` delete one that
954
+ * merely left THIS query's child set would clobber data another query still
955
+ * shows. Genuine record deletes flow through the normal delete path; a
956
+ * lingering orphan body is invisible (the correlated WHERE stops matching).
957
+ *
958
+ * Kept off `runSyncForQuery` on purpose so child fetches never flip the
959
+ * query to `fetching` or skew its DevTools timings.
960
+ */
961
+ private async syncSubqueryChildren(queryHash: string): Promise<void> {
962
+ const queryState = this.dataModule.getQueryByHash(queryHash);
963
+ if (!queryState) return;
964
+
965
+ const listRefTbl = this.listRefTable();
966
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
967
+ buildSubqueryListRefSelect(listRefTbl),
968
+ { in: queryState.config.id }
969
+ );
970
+ if (!Array.isArray(items)) return;
971
+
972
+ const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
973
+ const prev = queryState.config.subqueryRemoteArray ?? [];
974
+ if (recordVersionArraysEqual(fresh, prev)) return; // idempotent: nothing new
975
+
976
+ const diff = diffRecordVersionArray(prev, fresh);
977
+ if (diff.added.length > 0 || diff.updated.length > 0) {
978
+ await this.syncEngine.syncRecords({
979
+ added: diff.added,
980
+ updated: diff.updated,
981
+ removed: [], // never delete child bodies here — see method doc
982
+ });
983
+ }
984
+ // In-memory only — child rows must never enter the persisted primary array.
985
+ queryState.config.subqueryRemoteArray = fresh;
986
+ }
987
+
988
+ public async heartbeatQuery(queryHash: string) {
384
989
  const queryState = this.dataModule.getQueryByHash(queryHash);
385
990
  if (!queryState) {
386
991
  this.logger.warn(
387
- { queryHash, Category: 'spooky-client::SpookySync::heartbeatQuery' },
992
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::heartbeatQuery' },
388
993
  'Query to register not found'
389
994
  );
390
995
  throw new Error('Query to register not found');
@@ -394,17 +999,30 @@ export class SpookySync<S extends SchemaStructure> {
394
999
  });
395
1000
  }
396
1001
 
1002
+ // Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
1003
+ // e.g. a viewport-windowed list cancelling an off-screen window). Query ids
1004
+ // are a deterministic hash of (surql+params), so a DELETE racing a scroll-back
1005
+ // re-register (same id) could nuke a freshly-recreated view — hence two
1006
+ // guards: abort if a subscriber reappeared BEFORE the delete; re-register if
1007
+ // one reappears DURING the delete's network await. Tolerant of a
1008
+ // missing/already-gone query (no throw).
397
1009
  private async cleanupQuery(queryHash: string) {
398
1010
  const queryState = this.dataModule.getQueryByHash(queryHash);
399
- if (!queryState) {
400
- this.logger.warn(
401
- { queryHash, Category: 'spooky-client::SpookySync::cleanupQuery' },
402
- 'Query to register not found'
403
- );
404
- throw new Error('Query to register not found');
1011
+ if (!queryState) return; // already torn down / never registered
1012
+
1013
+ // Re-subscribed before the queued cleanup ran → keep everything as-is.
1014
+ if (this.dataModule.hasSubscribers(queryHash)) return;
1015
+
1016
+ await this.remote.query(`DELETE $id`, { id: queryState.config.id });
1017
+
1018
+ // Re-subscribed while we awaited the DELETE → the remote view is now gone
1019
+ // but a subscriber needs it; recreate it instead of leaving a zombie.
1020
+ if (this.dataModule.hasSubscribers(queryHash)) {
1021
+ this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
1022
+ return;
405
1023
  }
406
- await this.remote.query(`DELETE $id`, {
407
- id: queryState.config.id,
408
- });
1024
+
1025
+ // No subscribers throughout → safe to free the local view + state.
1026
+ this.dataModule.finalizeDeregister(queryHash);
409
1027
  }
410
1028
  }