@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -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 +36 -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/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -1,33 +1,213 @@
1
- import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
- import { MutationEvent, RecordVersionArray } from '../../types';
1
+ import type {
2
+ ConnectionSupervisor,
3
+ LocalStore,
4
+ RemoteDatabaseService,
5
+ } from '../../services/database/index';
6
+ import type {
7
+ ConnectionState,
8
+ RecordVersionArray,
9
+ RecordVersionDiff,
10
+ SyncHealth,
11
+ SyncHealthStatus,
12
+ } from '../../types';
3
13
  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';
14
+ import type { Logger } from '../../services/logger/index';
15
+ import type { DownEvent, UpEvent } from './queue/index';
16
+ import { DownQueue, UpQueue } from './queue/index';
17
+ import type { RecordId, Uuid } from 'surrealdb';
18
+ import {
19
+ applyRecordVersionDiff,
20
+ ArraySyncer,
21
+ buildListRefSelect,
22
+ buildQueryRowCountSelect,
23
+ buildSubqueryListRefSelect,
24
+ createDiffFromDbOp,
25
+ diffRecordVersionArray,
26
+ listRefPollDelayMs,
27
+ recordVersionArraysEqual,
28
+ resolveListRefPollInterval,
29
+ } from './utils';
8
30
  import { SyncEngine } from './engine';
9
31
  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';
32
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
33
+ import type { CacheModule } from '../cache/index';
34
+ import type { DataModule } from '../data/index';
35
+ import {
36
+ classifySyncError,
37
+ encodeRecordId,
38
+ extractIdPart,
39
+ extractTablePart,
40
+ surql,
41
+ withTimeout,
42
+ } from '../../utils/index';
43
+ import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
44
+ import { mutationOwnerTabId } from '../data/mutation-id';
45
+ import type { LeaderSyncHub, SyncForwarder } from '../../services/tabs/coordinator';
46
+ import type { IngestTuple } from '../../services/tabs/protocol';
47
+ import { parseRecordIdString } from '../../utils/index';
14
48
 
15
49
  /**
16
- * The main synchronization engine for Spooky.
50
+ * Tunables for `Sp00kySync` construction.
51
+ */
52
+ export interface Sp00kySyncOptions {
53
+ /**
54
+ * Cadence (ms) for the `_00_list_ref` poll fallback that catches
55
+ * cross-session UPDATEs the LIVE-permission gap drops. Non-positive
56
+ * values fall back to the default; see
57
+ * {@link resolveListRefPollInterval}.
58
+ */
59
+ refSyncIntervalMs?: number;
60
+ /**
61
+ * Enable realtime sync for unauthenticated clients against the shared
62
+ * `_00_list_ref_anon` table. See {@link Sp00kyConfig.enableAnonymousLiveQueries}.
63
+ * Defaults to `false`.
64
+ */
65
+ anonymousLiveQueries?: boolean;
66
+ /**
67
+ * Consecutive failed sync rounds before sync health flips to `degraded`.
68
+ * `0` disables degraded reporting. See {@link Sp00kyConfig.syncHealth}.
69
+ * Defaults to `3`.
70
+ */
71
+ degradeAfterConsecutiveFailures?: number;
72
+ /**
73
+ * Max time a single mutation push may take before it is treated as a network
74
+ * failure and retried. Guards against an RPC that never settles wedging the
75
+ * up-queue for the session. Defaults to 30000; `0` disables the timeout.
76
+ */
77
+ pushTimeoutMs?: number;
78
+ /**
79
+ * Max time a single down event (`register`/`sync`/`cleanup`) may take before
80
+ * it is treated as a network failure and retried. The mirror of
81
+ * {@link pushTimeoutMs} for the read side, which had no such guard: a
82
+ * `fn::query::register` that never settled held its slot in the down drain,
83
+ * and every later registration behind it, for the rest of the session.
84
+ * Defaults to 30000; `0` disables the timeout.
85
+ */
86
+ downTimeoutMs?: number;
87
+ /**
88
+ * Transport supervisor. Sync reads its state to report `connection` in
89
+ * {@link SyncHealth} so a UI can show "reconnecting…" the instant the socket
90
+ * drops, without waiting for the degrade threshold. Optional: omitted in
91
+ * tests, where `connection` then reports `connected`.
92
+ */
93
+ connectionSupervisor?: ConnectionSupervisor;
94
+ }
95
+
96
+ /**
97
+ * The main synchronization engine for Sp00ky.
17
98
  * Handles the bidirectional synchronization between the local database and the remote backend.
18
99
  * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
19
100
  * @template S The schema structure type.
20
101
  */
21
- export class SpookySync<S extends SchemaStructure> {
22
- private clientId: string = '';
102
+ export class Sp00kySync<S extends SchemaStructure> {
23
103
  private upQueue: UpQueue;
24
104
  private downQueue: DownQueue;
25
105
  private isInit: boolean = false;
26
106
  private logger: Logger;
27
107
  private syncEngine: SyncEngine;
108
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
109
+ * from `this.events`, which carries Sp00kySync-level events like
110
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
111
+ public get engineEvents() {
112
+ return this.syncEngine.events;
113
+ }
28
114
  private scheduler: SyncScheduler;
115
+ /**
116
+ * Set by any event that means the socket we registered on is gone, so the
117
+ * next `connected` knows it must re-subscribe rather than treat itself as the
118
+ * initial connect. See {@link subscribeToReconnect}.
119
+ */
120
+ private needsResubscribe: boolean = false;
121
+ /** When the last reconnect-driven full refetch ran, for burst coalescing. */
122
+ private lastReconnectRefetchAt = 0;
123
+ /**
124
+ * Minimum gap between reconnect-driven full refetches. Long enough to absorb
125
+ * a flapping socket (the SDK reconnect ladder starts at 1s), short enough
126
+ * that a genuine drop minutes later still refetches.
127
+ */
128
+ private static readonly RECONNECT_REFETCH_COOLDOWN_MS = 10_000;
29
129
  public events = createSyncEventSystem();
30
130
 
131
+ // Auth identity that drives per-user `_00_list_ref_user_<id>` routing
132
+ // in `RefMode.Dedicated`. Updated by `setCurrentUserId` from the auth
133
+ // subscription in `Sp00kyClient`; null when unauthenticated.
134
+ private currentUserId: string | null = null;
135
+
136
+ // ---- shared-tabs role state ----
137
+ // Followers keep their own remote WS (registration, per-query sync, poll)
138
+ // but must never drain the shared outbox or hold a second list_ref LIVE.
139
+ // The leader relays its LIVE events and routes rollbacks by mutation owner.
140
+ private tabRole: 'solo' | 'leader' | 'follower' = 'solo';
141
+ private tabId: string | null = null;
142
+ private hub: LeaderSyncHub | null = null;
143
+ private forwarder: SyncForwarder | null = null;
144
+
145
+ private refMode: RefMode = DEFAULT_REF_MODE;
146
+
147
+ // When true, an unauthenticated client still runs the `_00_list_ref` poll
148
+ // and LIVE subscription, routed to the shared `_00_list_ref_anon` table, so
149
+ // a logged-out page gets realtime `useQuery` updates. Off by default.
150
+ private readonly anonLiveEnabled: boolean;
151
+
152
+ // Bookkeeping for the LIVE subscription on `_00_list_ref[_user_*]`.
153
+ // SurrealDB binds the permission context at LIVE-registration time and
154
+ // the table name in dedicated mode depends on the authenticated user,
155
+ // so we have to re-register whenever auth state flips.
156
+ private currentLiveQueryUuid: Uuid | null = null;
157
+ private liveQueryUnsubscribe: (() => void) | null = null;
158
+
159
+ // Periodic re-poll of `_00_list_ref` as a safety net for missed LIVE
160
+ // notifications. SurrealDB v3 occasionally drops LIVE deliveries
161
+ // across sessions even when the row matches the permission rule;
162
+ // this catches those without requiring users to reload. The
163
+ // interval is configurable via the constructor; see
164
+ // `resolveListRefPollInterval` for fallback semantics.
165
+ //
166
+ // Self-rescheduling rather than setInterval so each tick can pick
167
+ // its own delay via `nextPollDelayMs` — slows the poll down when
168
+ // LIVE is delivering events and speeds it back up when LIVE quiets.
169
+ private listRefPollTimer: ReturnType<typeof setTimeout> | null = null;
170
+ private listRefPollRunning: boolean = false;
171
+ // The currently-executing poll tick, if any. `stopListRefPoll` only stops
172
+ // future ticks; a bucket switch must also AWAIT the in-flight one so its
173
+ // local writes land in the store it started against.
174
+ private listRefPollInFlight: Promise<void> | null = null;
175
+ public readonly refSyncIntervalMs: number;
176
+
177
+ // Consecutive poll cycles that observed NO list_ref change. Drives the
178
+ // adaptive backoff in `startListRefPoll` via `listRefPollDelayMs`: an idle
179
+ // page coasts from the fast base cadence toward the 5s cap, and any activity
180
+ // (a poll-detected change or a LIVE event) resets it to 0 so the poll snaps
181
+ // back to responsive. Replaces the old LIVE-liveness backoff, which kept the
182
+ // poll pinned at 500ms forever whenever LIVE wasn't firing (the common case
183
+ // on a quiet page, thanks to the cross-session LIVE-permission gap).
184
+ private listRefIdleStreak: number = 0;
185
+
186
+ // `${queryHash}:${recordId}` -> consecutive rounds the id has been "still
187
+ // remote" (left the query's list_ref but still exists upstream). Used to
188
+ // distinguish a PERSISTENT view-membership disagreement (the `job:` churn,
189
+ // converged once it crosses the threshold) from a record that's merely
190
+ // mid-deletion (still-remote for ~one round, then gone) — which must NOT be
191
+ // converged, or it gets stranded in this window before its delete is observed.
192
+ private stillRemoteStreaks: Map<string, number> = new Map();
193
+
194
+ // Wall-clock timestamp (ms) of the most recent LIVE event delivered
195
+ // through `handleRemoteListRefChange`. Kept as a diagnostic / liveness
196
+ // signal; the poll cadence is now driven by `listRefIdleStreak`.
197
+ private lastLiveEventAt: number | null = null;
198
+
199
+ // Number of times the initial `_00_list_ref[_user_*]` LIVE subscription
200
+ // had to retry on `setCurrentUserId`. Stays at 0 when the SSP has
201
+ // pre-emptively created the user's dedicated tables; otherwise
202
+ // increments on each retry attempt until LIVE succeeds or attempts
203
+ // are exhausted. Surfaced as a diagnostic so the e2e suite can prove
204
+ // the pre-emptive table-creation path is keeping the first sign-in
205
+ // off the lazy-creation race.
206
+ private _liveRetryCount: number = 0;
207
+ public get liveRetryCount(): number {
208
+ return this._liveRetryCount;
209
+ }
210
+
31
211
  get isSyncing() {
32
212
  return this.scheduler.isSyncing;
33
213
  }
@@ -37,13 +217,11 @@ export class SpookySync<S extends SchemaStructure> {
37
217
  }
38
218
 
39
219
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
40
- const id1 = this.upQueue.events.subscribe(
41
- SyncQueueEventTypes.MutationEnqueued,
42
- (event) => cb(event.payload.queueSize)
220
+ const id1 = this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, (event) =>
221
+ cb(event.payload.queueSize)
43
222
  );
44
- const id2 = this.upQueue.events.subscribe(
45
- SyncQueueEventTypes.MutationDequeued,
46
- (event) => cb(event.payload.queueSize)
223
+ const id2 = this.upQueue.events.subscribe(SyncQueueEventTypes.MutationDequeued, (event) =>
224
+ cb(event.payload.queueSize)
47
225
  );
48
226
  return () => {
49
227
  this.upQueue.events.unsubscribe(id1);
@@ -51,16 +229,233 @@ export class SpookySync<S extends SchemaStructure> {
51
229
  };
52
230
  }
53
231
 
232
+ // ---- Sync health -------------------------------------------------------
233
+ // `0` disables degraded reporting (config `syncHealth: false`). Resolved
234
+ // from config in Sp00kyClient and passed through the constructor options.
235
+ private readonly degradeAfterFailures: number;
236
+ /** Per-push RPC deadline; see {@link withPushTimeout}. */
237
+ private readonly pushTimeoutMs: number;
238
+ private readonly downTimeoutMs: number;
239
+ private consecutiveSyncFailures = 0;
240
+ private syncHealthStatus: SyncHealthStatus = 'healthy';
241
+ private lastSyncErrorKind: 'network' | 'application' | undefined;
242
+ private lastSyncErrorMessage: string | undefined;
243
+ // Latched `true` on the first successful sync round; never reset. Lets a UI
244
+ // tell a cold-start "connecting" phase (never reached the server) apart from
245
+ // a real lost connection after a working session.
246
+ private hasSyncedOnce = false;
247
+
248
+ // Self-heal: while degraded, re-drive sync on an exponential backoff so the
249
+ // app recovers on its own — even when the socket never actually dropped (in
250
+ // that case no `connected` event fires, so this re-registration is the ONLY
251
+ // thing that re-probes the server). Started on the degrade transition,
252
+ // cleared on recovery. Capped cadence so a long outage doesn't busy-loop.
253
+ private selfHealTimer: ReturnType<typeof setTimeout> | null = null;
254
+ private selfHealAttempts = 0;
255
+ private static readonly SELF_HEAL_BASE_MS = 2_000;
256
+ private static readonly SELF_HEAL_MAX_MS = 30_000;
257
+
258
+ /**
259
+ * Transport supervisor, when one was supplied. Sync only reads state from it;
260
+ * it never drives reconnects itself.
261
+ */
262
+ private readonly connectionSupervisor?: ConnectionSupervisor;
263
+ /**
264
+ * Mirror of the supervisor's state. Defaults to `connected` so a client
265
+ * constructed without a supervisor (tests, embedders) reports the same health
266
+ * shape it always has rather than a permanent false "disconnected".
267
+ */
268
+ private connectionState: ConnectionState = 'connected';
269
+
270
+ /** Current sync-health snapshot. */
271
+ get syncHealth(): SyncHealth {
272
+ return {
273
+ status: this.syncHealthStatus,
274
+ consecutiveFailures: this.consecutiveSyncFailures,
275
+ kind: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorKind : undefined,
276
+ error: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorMessage : undefined,
277
+ everConnected: this.hasSyncedOnce,
278
+ connection: this.connectionState,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Observe sync health. The callback fires immediately with the current
284
+ * status and again on every healthy↔degraded transition. Returns an
285
+ * unsubscribe. Mirrors {@link subscribeToPendingMutations}.
286
+ */
287
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
288
+ cb(this.syncHealth);
289
+ const id = this.events.subscribe(SyncEventTypes.SyncHealthChanged, (event) =>
290
+ cb(event.payload)
291
+ );
292
+ return () => this.events.unsubscribe(id);
293
+ }
294
+
295
+ private emitSyncHealth(): void {
296
+ this.events.emit(SyncEventTypes.SyncHealthChanged, this.syncHealth);
297
+ }
298
+
299
+ /**
300
+ * Mirror the supervisor's transport state into {@link SyncHealth} and emit on
301
+ * every change, so a UI can react to a dropped socket immediately instead of
302
+ * waiting for `degradeAfterFailures` failed rounds. `status` is untouched:
303
+ * a brief reconnect is not a degradation.
304
+ *
305
+ * No explicit unsubscribe: the supervisor is owned by the same client and
306
+ * drops all subscribers in its own `dispose()`, which `Sp00kyClient.close()`
307
+ * calls first.
308
+ */
309
+ private subscribeToConnectionState(): void {
310
+ if (!this.connectionSupervisor) return;
311
+ this.connectionSupervisor.subscribe((state) => {
312
+ if (this.connectionState === state) return;
313
+ this.connectionState = state;
314
+ this.emitSyncHealth();
315
+ });
316
+ }
317
+
318
+ /**
319
+ * Fed by the scheduler once per drained sync round. Individual failures are
320
+ * absorbed by the queue's retry; only a run of `degradeAfterFailures`
321
+ * consecutive failures flips the status to `degraded`, and the next clean
322
+ * round flips it back. No-op when reporting is disabled (`degradeAfterFailures`
323
+ * is 0).
324
+ */
325
+ private recordSyncOutcome(ok: boolean, error?: unknown): void {
326
+ if (this.degradeAfterFailures <= 0) return;
327
+ if (ok) {
328
+ // Latch first-ever success so a UI can drop the connecting phase. Set
329
+ // before the early return so a clean cold start (0 prior failures) counts.
330
+ this.hasSyncedOnce = true;
331
+ if (this.consecutiveSyncFailures === 0) return;
332
+ this.consecutiveSyncFailures = 0;
333
+ if (this.syncHealthStatus !== 'healthy') {
334
+ this.syncHealthStatus = 'healthy';
335
+ this.lastSyncErrorKind = undefined;
336
+ this.lastSyncErrorMessage = undefined;
337
+ this.stopSelfHeal();
338
+ this.logger.info(
339
+ { Category: 'sp00ky-client::Sp00kySync::syncHealth' },
340
+ 'Sync recovered; health back to healthy'
341
+ );
342
+ this.emitSyncHealth();
343
+ }
344
+ return;
345
+ }
346
+ this.consecutiveSyncFailures++;
347
+ this.lastSyncErrorKind = classifySyncError(error);
348
+ this.lastSyncErrorMessage = error instanceof Error ? error.message : String(error);
349
+ if (
350
+ this.syncHealthStatus !== 'degraded' &&
351
+ this.consecutiveSyncFailures >= this.degradeAfterFailures
352
+ ) {
353
+ this.syncHealthStatus = 'degraded';
354
+ this.logger.warn(
355
+ {
356
+ consecutiveFailures: this.consecutiveSyncFailures,
357
+ kind: this.lastSyncErrorKind,
358
+ error,
359
+ Category: 'sp00ky-client::Sp00kySync::syncHealth',
360
+ },
361
+ 'Sync degraded after sustained failures'
362
+ );
363
+ this.emitSyncHealth();
364
+ this.startSelfHeal();
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Begin self-heal retries (no-op if already running). Started on the
370
+ * healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
371
+ */
372
+ private startSelfHeal(): void {
373
+ if (this.selfHealTimer !== null) return;
374
+ this.selfHealAttempts = 0;
375
+ this.scheduleSelfHeal();
376
+ }
377
+
378
+ private scheduleSelfHeal(): void {
379
+ const delay = Math.min(
380
+ Sp00kySync.SELF_HEAL_MAX_MS,
381
+ Sp00kySync.SELF_HEAL_BASE_MS * 2 ** this.selfHealAttempts
382
+ );
383
+ this.selfHealTimer = setTimeout(async () => {
384
+ this.selfHealTimer = null;
385
+ if (this.syncHealthStatus !== 'degraded') return;
386
+ this.selfHealAttempts++;
387
+ this.logger.debug(
388
+ {
389
+ attempt: this.selfHealAttempts,
390
+ delayMs: delay,
391
+ Category: 'sp00ky-client::Sp00kySync::selfHeal',
392
+ },
393
+ 'Self-heal: re-driving sync while degraded'
394
+ );
395
+ try {
396
+ // Retry whatever is still queued first; the failing op (register or
397
+ // mutation) was re-queued by the queue, so this re-probes the server
398
+ // and reports the outcome through the scheduler → recordSyncOutcome.
399
+ if (this.upQueue.size > 0) {
400
+ await this.scheduler.syncUp();
401
+ } else if (this.downQueue.size > 0) {
402
+ await this.scheduler.syncDown();
403
+ } else {
404
+ // Nothing queued (e.g. the failing op was rolled back + dropped):
405
+ // re-register active queries — mirroring the reconnect handler — so
406
+ // there's a concrete op whose success flips health. If there are no
407
+ // active queries either, probe connectivity directly.
408
+ const hashes = this.dataModule.getActiveQueryHashes();
409
+ if (hashes.length > 0) {
410
+ for (const hash of hashes) {
411
+ this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
412
+ }
413
+ await this.scheduler.syncDown();
414
+ } else {
415
+ await this.remote.query('RETURN true');
416
+ this.recordSyncOutcome(true);
417
+ }
418
+ }
419
+ } catch (err) {
420
+ // Only the direct connectivity probe can throw here (syncUp/syncDown
421
+ // swallow + self-report); treat a probe failure as another failed round.
422
+ this.recordSyncOutcome(false, err);
423
+ }
424
+ // Keep retrying until recovery. recordSyncOutcome(true) calls stopSelfHeal
425
+ // (clearing any pending timer), so only continue while still degraded.
426
+ if (this.syncHealthStatus === 'degraded') this.scheduleSelfHeal();
427
+ }, delay);
428
+ }
429
+
430
+ private stopSelfHeal(): void {
431
+ if (this.selfHealTimer !== null) {
432
+ clearTimeout(this.selfHealTimer);
433
+ this.selfHealTimer = null;
434
+ }
435
+ this.selfHealAttempts = 0;
436
+ }
437
+
438
+ /**
439
+ * Release a deregistered query's remote view immediately instead of leaving
440
+ * it to the TTL sweep. Off by default; see the reasoning in
441
+ * {@link cleanupQuery}. Kept as a field rather than deleted so the eager path
442
+ * can be re-enabled in a test once the subquery-body repair path exists.
443
+ */
444
+ private readonly releaseQueriesEagerly = false;
445
+
54
446
  constructor(
55
- private local: LocalDatabaseService,
447
+ private local: LocalStore,
56
448
  private remote: RemoteDatabaseService,
57
449
  private cache: CacheModule,
58
450
  private dataModule: DataModule<S>,
59
451
  private schema: S,
60
- logger: Logger
452
+ logger: Logger,
453
+ options?: Sp00kySyncOptions
61
454
  ) {
62
- this.logger = logger.child({ service: 'SpookySync' });
63
- this.upQueue = new UpQueue(this.local, this.logger);
455
+ this.logger = logger.child({ service: 'Sp00kySync' });
456
+ this.upQueue = new UpQueue(this.local, this.logger, (dropped) =>
457
+ this.onMutationDropped(dropped)
458
+ );
64
459
  this.downQueue = new DownQueue(this.local, this.logger);
65
460
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
66
461
  this.scheduler = new SyncScheduler(
@@ -69,43 +464,784 @@ export class SpookySync<S extends SchemaStructure> {
69
464
  this.processUpEvent.bind(this),
70
465
  this.processDownEvent.bind(this),
71
466
  this.logger,
72
- this.handleRollback.bind(this)
467
+ this.handleRollback.bind(this),
468
+ this.recordSyncOutcome.bind(this),
469
+ this.handleMutationSettled.bind(this)
73
470
  );
471
+ this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
472
+ this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
473
+ this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
474
+ this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 30_000);
475
+ this.downTimeoutMs = Math.max(0, options?.downTimeoutMs ?? 30_000);
476
+ this.connectionSupervisor = options?.connectionSupervisor;
74
477
  }
75
478
 
76
479
  /**
77
480
  * Initializes the synchronization system.
78
481
  * Starts the scheduler and initiates the initial sync cycles.
79
- * @param clientId The unique identifier for this client instance.
80
482
  * @throws Error if already initialized.
81
483
  */
82
- public async init(clientId: string) {
83
- if (this.isInit) throw new Error('SpookySync is already initialized');
84
- this.clientId = clientId;
484
+ public async init() {
485
+ if (this.isInit) throw new Error('Sp00kySync is already initialized');
85
486
  this.isInit = true;
86
- await this.scheduler.init();
87
- void this.scheduler.syncUp();
487
+ await this.scheduler.init({ loadOutbox: this.tabRole !== 'follower' });
488
+ // Boot is local-first now, so init() routinely runs BEFORE the socket is
489
+ // up. Treat that as "the socket we registered on is gone": otherwise the
490
+ // first `connected` takes the initial-connect branch, returns early, and
491
+ // never re-enqueues `register` for queries that registered while offline.
492
+ // They would still heal via the down-queue backoff, but slowly and only
493
+ // because every query happens to enqueue its own register.
494
+ if (this.remote.getStatus() !== 'connected') this.needsResubscribe = true;
495
+ this.subscribeToReconnect();
496
+ this.subscribeToConnectionState();
88
497
  void this.scheduler.syncUp();
89
498
  void this.scheduler.syncDown();
90
- void this.startRefLiveQueries();
499
+ // No initial LIVE subscription — wait for `setCurrentUserId` to fire
500
+ // from the auth subscription. In dedicated mode the table name
501
+ // depends on the authenticated user, and an unauthenticated
502
+ // subscription wouldn't match any of the per-user tables anyway.
503
+ //
504
+ // Exception: when anonymous live queries are enabled, start realtime now
505
+ // against the shared `_00_list_ref_anon` table so a logged-out client
506
+ // syncs immediately. `setCurrentUserId` re-points LIVE to the per-user
507
+ // table on sign-in. Guard on `currentUserId` because the auth callback can
508
+ // fire (and authenticate) before `init()` runs — don't clobber that back
509
+ // to the anon table. `setCurrentUserId(null)` is a no-op on first load
510
+ // (it's already null), so this is the only place anon realtime starts.
511
+ if (this.anonLiveEnabled && !this.currentUserId) {
512
+ this.startListRefPoll();
513
+ this.restartRefLiveQuery().catch((err) => {
514
+ this.logger.debug(
515
+ { err, Category: 'sp00ky-client::Sp00kySync::init' },
516
+ 'Anonymous ref LIVE start failed; relying on periodic poll fallback'
517
+ );
518
+ });
519
+ }
520
+ }
521
+
522
+ // ---- shared-tabs roles ------------------------------------------------------
523
+
524
+ /** Set BEFORE init(): shapes what init boots (a follower loads no outbox and
525
+ * never starts LIVE; its own registration/poll paths stay untouched). */
526
+ public setTabContext(role: 'solo' | 'leader' | 'follower', tabId: string | null): void {
527
+ this.tabRole = role;
528
+ this.tabId = tabId;
529
+ }
530
+
531
+ /** In-flight {@link resumeLeaderDuties}, so a second call joins the first
532
+ * instead of double-draining the outbox. */
533
+ private leaderDutiesInFlight: Promise<void> | null = null;
534
+
535
+ /** Resolves once the in-browser circuit has been primed from the local
536
+ * store. Every sync diff waits on it: diffing against an empty circuit
537
+ * classifies the whole working set as missing and re-downloads it. */
538
+ private primeGate: () => Promise<void> = () => Promise.resolve();
539
+ /** The prime we last waited on. `whenPrimed` hands out one promise per
540
+ * prime, so a new identity means a new prime (boot, bucket switch) ran. */
541
+ private settledPrime: Promise<void> | null = null;
542
+
543
+ setPrimeGate(gate: () => Promise<void>): void {
544
+ this.primeGate = gate;
545
+ this.settledPrime = null;
546
+ }
547
+
548
+ /**
549
+ * Leader WIRING only, and deliberately synchronous.
550
+ *
551
+ * The coordinator publishes the leader role and tells the broker
552
+ * `leader-ready` the moment the store is adopted, and the broker can mint a
553
+ * follower's ports on the very next tick. So the follower-message handler
554
+ * has to be live before this returns, or a mutation forwarded in that window
555
+ * is dropped. Everything that can block (outbox reload, LIVE restart) moved
556
+ * to {@link resumeLeaderDuties}: a promotion that waits on the network holds
557
+ * `leader-ready` back, and a broker whose leader never reports ready serves
558
+ * no follower ports and re-elects no one, which wedges the whole namespace.
559
+ */
560
+ public promoteToLeader(hub: LeaderSyncHub): void {
561
+ this.tabRole = 'leader';
562
+ this.hub = hub;
563
+ this.forwarder = null;
564
+ this.leaderDutiesInFlight = null;
565
+ hub.onFollowerMessage = (tabId, msg) => {
566
+ switch (msg.type) {
567
+ case 'sync-hello':
568
+ break;
569
+ case 'mutation-enqueued':
570
+ // A write is activity: snap the poll back to its base cadence so the
571
+ // membership for it lands fast even if LIVE drops the event.
572
+ this.listRefIdleStreak = 0;
573
+ void this.enqueueForwardedMutation(msg.mutationId);
574
+ break;
575
+ case 'ingest':
576
+ // A follower's optimistic write. The row is already in the shared
577
+ // store; feed this tab's circuit and fan it out to every OTHER
578
+ // follower, so the write shows up everywhere in one hop instead of
579
+ // after the server round-trip (which also depends on LIVE delivery).
580
+ this.applyRelayedIngest(msg.tuples);
581
+ hub.relayIngest(msg.tuples, tabId);
582
+ this.listRefIdleStreak = 0;
583
+ break;
584
+ case 'request-poll':
585
+ this.listRefIdleStreak = 0;
586
+ break;
587
+ }
588
+ };
589
+ }
590
+
591
+ /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
592
+ * relay LIVE events and rollbacks to followers. Idempotent for a boot-time
593
+ * leader; a runtime promotion (failover) reloads the outbox, which now
594
+ * holds EVERY tab's rows, and restarts LIVE under this session. Runs in the
595
+ * background off the promotion path, so however long it takes (or if it
596
+ * never finishes) the tab is already a working leader. */
597
+ public resumeLeaderDuties(): Promise<void> {
598
+ if (this.leaderDutiesInFlight) return this.leaderDutiesInFlight;
599
+ const run = (async () => {
600
+ if (!this.isInit || this.tabRole !== 'leader') return;
601
+ await this.upQueue.loadFromDatabase();
602
+ void this.scheduler.syncUp();
603
+ if (this.currentUserId || this.anonLiveEnabled) {
604
+ this.startListRefPoll();
605
+ await this.restartRefLiveQuery().catch((err) => {
606
+ this.logger.warn(
607
+ { err, Category: 'sp00ky-client::Sp00kySync::resumeLeaderDuties' },
608
+ 'LIVE restart failed on promotion; poll fallback covers it'
609
+ );
610
+ });
611
+ }
612
+ })();
613
+ this.leaderDutiesInFlight = run;
614
+ return run.finally(() => {
615
+ if (this.leaderDutiesInFlight === run) this.leaderDutiesInFlight = null;
616
+ });
617
+ }
618
+
619
+ /** Follower duties: no outbox drain, no LIVE. Mutations forward to the
620
+ * leader; everything else (registration, per-query sync, poll) runs
621
+ * against this tab's own remote session as usual. */
622
+ public demoteToFollower(forwarder: SyncForwarder): void {
623
+ this.tabRole = 'follower';
624
+ this.hub = null;
625
+ this.forwarder = forwarder;
626
+ // A still-running resumeLeaderDuties self-cancels on its `tabRole` guard;
627
+ // drop the handle so a later re-promotion starts a fresh drain.
628
+ this.leaderDutiesInFlight = null;
629
+ void this.killRefLiveQuery();
630
+ forwarder.onLeaderMessage = (msg) => {
631
+ switch (msg.type) {
632
+ case 'ingest-relay':
633
+ this.applyRelayedIngest(msg.tuples);
634
+ break;
635
+ case 'mutation-settled':
636
+ // The leader pushed a write and deleted its outbox row from the
637
+ // shared store. Without this the row would leave this tab's render
638
+ // set (it is in neither membership nor pending writes) until the
639
+ // relayed `_00_list_ref` event lands: the blink the leader itself
640
+ // is already protected from by `handleMutationSettled`.
641
+ this.dataModule.noteWriteSettled(msg.recordId, msg.eventType);
642
+ break;
643
+ case 'list-ref-change':
644
+ void this.applyRelayedListRefChange(msg).catch((err) => {
645
+ this.logger.error(
646
+ { err, Category: 'sp00ky-client::Sp00kySync::relay' },
647
+ 'Relayed list_ref change failed'
648
+ );
649
+ });
650
+ break;
651
+ case 'mutation-rolled-back':
652
+ this.events.emit(SyncEventTypes.MutationRolledBack, {
653
+ eventType: msg.eventType,
654
+ recordId: msg.recordId,
655
+ error: msg.error,
656
+ });
657
+ break;
658
+ default:
659
+ // db-ready is consumed by the coordinator's attach handshake before
660
+ // the sync handler is installed.
661
+ break;
662
+ }
663
+ };
664
+ }
665
+
666
+ /**
667
+ * A pending mutation was discarded because it can never be sent.
668
+ *
669
+ * This is a lost write, so it must not stay invisible. Every failure in this
670
+ * chain used to be a `logger.error` an app running `logLevel: 'fatal'` never
671
+ * shows, which is how an outbox could sit undrained for hours with the UI
672
+ * reporting nothing. Surfaces as a rollback event (the mutation will never
673
+ * apply, which is what a subscriber needs to know) and degrades sync health.
674
+ */
675
+ private onMutationDropped(dropped: {
676
+ mutationId: string;
677
+ recordId?: string;
678
+ mutationType?: string;
679
+ reason: string;
680
+ }): void {
681
+ this.logger.error(
682
+ { ...dropped, Category: 'sp00ky-client::Sp00kySync::onMutationDropped' },
683
+ 'Dropped a pending mutation that can never be sent'
684
+ );
685
+ this.recordSyncOutcome(false, new Error(`dropped mutation: ${dropped.reason}`));
686
+ this.events.emit(SyncEventTypes.MutationRolledBack, {
687
+ eventType: (dropped.mutationType as 'create' | 'update' | 'delete') ?? 'update',
688
+ recordId: dropped.recordId ?? dropped.mutationId,
689
+ error: `dropped: ${dropped.reason}`,
690
+ });
691
+ }
692
+
693
+ /** A forwarded outbox row from a follower: load + drain it. Idempotent. */
694
+ public async enqueueForwardedMutation(mutationId: string): Promise<void> {
695
+ if (this.tabRole !== 'leader') return;
696
+ await this.upQueue.enqueueFromDatabase(mutationId);
697
+ }
698
+
699
+ /**
700
+ * Tuples another tab already committed to the shared store: feed them to
701
+ * THIS tab's circuit (no local write). A DELETE additionally forces a
702
+ * re-materialize of the table's queries, exactly as the writing tab does
703
+ * for itself, because the SSP may not emit a view update for it.
704
+ */
705
+ private applyRelayedIngest(tuples: IngestTuple[]): void {
706
+ this.cache.applyRelayedIngest(tuples);
707
+ const deletedTables = new Set<string>();
708
+ for (const t of tuples) if (t.op === 'DELETE') deletedTables.add(t.table);
709
+ for (const table of deletedTables) {
710
+ void this.dataModule.notifyTableQueries(table).catch((err) => {
711
+ this.logger.warn(
712
+ { err, table, Category: 'sp00ky-client::Sp00kySync::applyRelayedIngest' },
713
+ 'Re-materialize after relayed delete failed'
714
+ );
715
+ });
716
+ }
717
+ }
718
+
719
+ /** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
720
+ * and run the exact same handling the LIVE subscription would have. */
721
+ private async applyRelayedListRefChange(msg: {
722
+ action: 'CREATE' | 'UPDATE' | 'DELETE';
723
+ queryId: string;
724
+ recordId: string;
725
+ version: number;
726
+ parent: boolean;
727
+ }): Promise<void> {
728
+ const queryId = parseRecordIdString(msg.queryId);
729
+ // Foreign query (another tab's session-salted hash): not ours, ignore.
730
+ if (!this.dataModule.getQueryById(queryId)) return;
731
+ const recordId = parseRecordIdString(msg.recordId);
732
+ if (msg.parent) {
733
+ await this.handleRemoteSubqueryChange(msg.action, queryId, recordId, msg.version);
734
+ } else {
735
+ await this.handleRemoteListRefChange(msg.action, queryId, recordId, msg.version);
736
+ }
737
+ }
738
+
739
+ /** One immediate poll cycle (failover convergence). */
740
+ public async forcePollRound(): Promise<void> {
741
+ this.listRefIdleStreak = 0;
742
+ await this.pollListRefForActiveQueries().catch(() => false);
743
+ }
744
+
745
+ /**
746
+ * Quiesce all sync activity ahead of a local-bucket switch. After this
747
+ * resolves, nothing in the sync module writes to the local store: the poll
748
+ * loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
749
+ * timers are cancelled (their outbox rows are already persisted), and the
750
+ * scheduler has drained its in-flight queue item — including that item's
751
+ * outbox-row delete, which must land in the OLD bucket. Queued down-events
752
+ * are dropped (they reference old-bucket query rows; the post-switch rebind
753
+ * re-enqueues registrations). The old user's un-pushed outbox is deliberately
754
+ * NOT drained: the remote session already belongs to the next user.
755
+ */
756
+ public async prepareBucketSwitch(): Promise<void> {
757
+ this.stopSelfHeal();
758
+ this.stopListRefPoll();
759
+ if (this.listRefPollInFlight) await this.listRefPollInFlight;
760
+ await this.killRefLiveQuery();
761
+ this.upQueue.clearDebounceTimers();
762
+ await this.scheduler.pause();
763
+ this.downQueue.clear();
764
+ this.stillRemoteStreaks.clear();
765
+ this.logger.info(
766
+ { Category: 'sp00ky-client::Sp00kySync::prepareBucketSwitch' },
767
+ 'Sync quiesced for bucket switch'
768
+ );
769
+ }
770
+
771
+ /**
772
+ * Resume syncing against the freshly-opened bucket: reload the mutation
773
+ * outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
774
+ * offline work) and restart the scheduler. LIVE + the list_ref poll restart
775
+ * via the `setCurrentUserId` call that follows in the auth listener.
776
+ */
777
+ public async completeBucketSwitch(): Promise<void> {
778
+ await this.upQueue.loadFromDatabase();
779
+ this.scheduler.resume();
780
+ this.logger.info(
781
+ { Category: 'sp00ky-client::Sp00kySync::completeBucketSwitch' },
782
+ 'Sync resumed after bucket switch'
783
+ );
784
+ }
785
+
786
+ /**
787
+ * Push the authenticated user's record id from the parent client's
788
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
789
+ * any) and re-registers it under the new user's dedicated table so
790
+ * SurrealDB binds the permission rule under the post-flip auth
791
+ * context. Pass `null` on sign-out.
792
+ *
793
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
794
+ * the SSP when the first query registration arrives, which may be
795
+ * concurrent with this call. We retry the LIVE registration with a
796
+ * short backoff so a "table not found" race resolves without
797
+ * surfacing as a permanent auth-loading hang.
798
+ */
799
+ public async setCurrentUserId(userId: string | null): Promise<void> {
800
+ if (this.currentUserId === userId) return;
801
+ this.currentUserId = userId;
802
+ if (!userId) {
803
+ if (this.anonLiveEnabled) {
804
+ // Signed out but anonymous realtime is on: keep the poll running and
805
+ // re-point LIVE from the (now stale) per-user table to the shared
806
+ // `_00_list_ref_anon`. `startListRefPoll` is idempotent; the poll
807
+ // re-resolves `listRefTable()` each tick so it follows automatically.
808
+ this.startListRefPoll();
809
+ await this.restartRefLiveQuery().catch((err) => {
810
+ this.logger.debug(
811
+ { err, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
812
+ 'Anonymous ref LIVE restart failed; relying on periodic poll fallback'
813
+ );
814
+ });
815
+ return;
816
+ }
817
+ await this.killRefLiveQuery();
818
+ this.stopListRefPoll();
819
+ return;
820
+ }
821
+ // Start periodic polling FIRST so we have a deterministic fallback
822
+ // even when LIVE registration fails or SurrealDB drops a delivery.
823
+ this.startListRefPoll();
824
+ // Try to start LIVE with backoff for low-latency delivery on the
825
+ // happy path; the poll handles the rest.
826
+ const attemptDelays = [0, 250, 500, 1000, 2000];
827
+ for (let i = 0; i < attemptDelays.length; i++) {
828
+ if (attemptDelays[i] > 0) {
829
+ this._liveRetryCount++;
830
+ await new Promise((r) => setTimeout(r, attemptDelays[i]));
831
+ }
832
+ try {
833
+ await this.restartRefLiveQuery();
834
+ return;
835
+ } catch (err) {
836
+ this.logger.debug(
837
+ { err, attempt: i + 1, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
838
+ 'Ref LIVE start failed; relying on periodic poll fallback'
839
+ );
840
+ }
841
+ }
842
+ }
843
+
844
+ private startListRefPoll(): void {
845
+ if (this.listRefPollRunning) return;
846
+ this.listRefPollRunning = true;
847
+ this.logger.debug(
848
+ {
849
+ intervalMs: this.refSyncIntervalMs,
850
+ Category: 'sp00ky-client::Sp00kySync::startListRefPoll',
851
+ },
852
+ 'list_ref poll loop started'
853
+ );
854
+ const schedule = (delayMs: number) => {
855
+ this.listRefPollTimer = setTimeout(async () => {
856
+ if (!this.listRefPollRunning) return;
857
+ let changed = false;
858
+ const tick = (async () => {
859
+ changed = await this.pollListRefForActiveQueries();
860
+ })();
861
+ this.listRefPollInFlight = tick.catch(() => {});
862
+ try {
863
+ await tick;
864
+ } finally {
865
+ this.listRefPollInFlight = null;
866
+ if (!this.listRefPollRunning) return;
867
+ // Reset the idle streak on any observed change so the poll snaps
868
+ // back to the fast base cadence; otherwise grow it so a quiet page
869
+ // backs off toward the cap. (`handleRemoteListRefChange` also resets
870
+ // it when a LIVE event lands.)
871
+ this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
872
+ const next = listRefPollDelayMs({
873
+ idleStreak: this.listRefIdleStreak,
874
+ baseIntervalMs: this.refSyncIntervalMs,
875
+ });
876
+ schedule(next);
877
+ }
878
+ }, delayMs);
879
+ };
880
+ schedule(this.refSyncIntervalMs);
881
+ }
882
+
883
+ private stopListRefPoll(): void {
884
+ this.listRefPollRunning = false;
885
+ if (this.listRefPollTimer !== null) {
886
+ clearTimeout(this.listRefPollTimer);
887
+ this.listRefPollTimer = null;
888
+ }
889
+ }
890
+
891
+ /**
892
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
893
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
894
+ * to drive the adaptive idle backoff.
895
+ *
896
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
897
+ * otherwise activity-driven (mutations/registrations via the scheduler,
898
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
899
+ * would linger until the next mutation and a genuine idle drop would be
900
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
901
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
902
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
903
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
904
+ */
905
+ private async pollListRefForActiveQueries(): Promise<boolean> {
906
+ const hashes = this.dataModule.getActiveQueryHashes();
907
+ if (hashes.length === 0) {
908
+ // No active queries to piggyback on, but health still needs a heartbeat —
909
+ // probe connectivity directly so an idle page with no live queries doesn't
910
+ // go blind. Cheap, and gated by the same adaptive backoff (≤5s idle cap).
911
+ try {
912
+ await this.remote.query('RETURN true');
913
+ this.recordSyncOutcome(true);
914
+ } catch (err) {
915
+ this.recordSyncOutcome(false, err);
916
+ }
917
+ return false;
918
+ }
919
+ let anyChanged = false;
920
+ // `reached` = the server answered at least once this cycle (a success, or an
921
+ // *application* error, which still proves reachability). `firstNetworkErr`
922
+ // holds the first network-classified failure. A cycle that only produced
923
+ // network errors reports the outcome as a down round; a mixed/app cycle counts
924
+ // as reached; an all-application cycle reports nothing (that's a query-shape
925
+ // fault owned by the registration path, not a reachability signal).
926
+ let reached = false;
927
+ let firstNetworkErr: unknown;
928
+ for (const hash of hashes) {
929
+ try {
930
+ if (await this.refetchListRefForQuery(hash)) anyChanged = true;
931
+ reached = true;
932
+ } catch (err) {
933
+ if (classifySyncError(err) === 'network') {
934
+ if (firstNetworkErr === undefined) firstNetworkErr = err;
935
+ } else {
936
+ reached = true;
937
+ }
938
+ this.logger.debug(
939
+ {
940
+ err: (err as Error)?.message ?? err,
941
+ hash,
942
+ Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries',
943
+ },
944
+ 'Per-query list_ref poll failed'
945
+ );
946
+ }
947
+ }
948
+ // Call the private outcome recorder directly rather than routing through the
949
+ // scheduler — the scheduler only reports on rounds that drained ≥1 queue item
950
+ // (`processedAny`), and this isn't a queue round.
951
+ if (reached) {
952
+ this.recordSyncOutcome(true);
953
+ } else if (firstNetworkErr !== undefined) {
954
+ this.recordSyncOutcome(false, firstNetworkErr);
955
+ }
956
+ return anyChanged;
957
+ }
958
+
959
+ /**
960
+ * Pull the upstream list_ref entries for `queryHash`, diff them
961
+ * against the local `remoteArray` cache, sync any added/updated rows
962
+ * through the SyncEngine, then persist the new remoteArray. This is
963
+ * the same shape `createRemoteQuery` does for its initial fetch and
964
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
965
+ * it on a timer as a fallback for missed LIVE notifications.
966
+ */
967
+ private async refetchListRefForQuery(queryHash: string): Promise<boolean> {
968
+ const queryState = this.dataModule.getQueryByHash(queryHash);
969
+ if (!queryState) return false;
970
+ const listRefTbl = this.listRefTable();
971
+ const [items, serverRowCount] = await this.remote.query<
972
+ [{ out: RecordId<string>; version: number }[], number | null]
973
+ >(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, {
974
+ in: queryState.config.id,
975
+ });
976
+ if (!Array.isArray(items)) return false;
977
+ const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
978
+ // Capture which ids LEFT the query's window (present in the cached
979
+ // remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
980
+ // are cross-window deletes (or rows that scrolled out). They drive the
981
+ // forced re-render below.
982
+ const prevRemote = queryState.config.remoteArray ?? [];
983
+ const freshIds = new Set(fresh.map(([id]) => id));
984
+ const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
985
+ // Idempotent poll: only persist the remoteArray when it actually changed.
986
+ // The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
987
+ // equals the cached array every tick — re-writing it (an `UPDATE _00_query`
988
+ // each cycle, per active query) was pure churn and the bulk of the idle
989
+ // traffic. `recordVersionArraysEqual` is order-insensitive because the
990
+ // list_ref SELECT has no `ORDER BY`.
991
+ const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
992
+ if (changed) {
993
+ // Update the cached remoteArray so the next diff/sync sees the new state.
994
+ // `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
995
+ // the local DB row and ingests it into the in-browser SSP — the SSP's
996
+ // stream updates run `processStreamUpdate`, which re-queries the local DB
997
+ // and notifies subscribers. We skip an explicit `notifyQuerySynced`
998
+ // because that path races the stream-update path (can notify with stale
999
+ // records).
1000
+ await this.dataModule.updateQueryRemoteArray(queryHash, fresh, { serverRowCount });
1001
+ }
1002
+ // Run `syncQuery` every tick regardless: it's a no-op when localArray has
1003
+ // caught up to remoteArray (`if (!diff) return`, issues no query), but it
1004
+ // covers the rare case where remoteArray is stable yet localArray is behind
1005
+ // (a prior record fetch failed) — so a missed row still gets retried.
1006
+ // For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
1007
+ // confirmed-gone records from the local DB.
1008
+ try {
1009
+ await this.syncQuery(queryHash);
1010
+ } catch (err) {
1011
+ this.logger.info(
1012
+ {
1013
+ err: (err as Error)?.message ?? err,
1014
+ queryHash,
1015
+ Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
1016
+ },
1017
+ 'syncQuery failed during poll'
1018
+ );
1019
+ }
1020
+ // Membership moved but a row may have needed no fetch (this client wrote
1021
+ // it, so the engine already holds it at the published version): nothing
1022
+ // then re-materializes the query. Ask for one; it is a no-op behind a
1023
+ // stream update that a fetch above already queued.
1024
+ if (changed) this.dataModule.scheduleRematerialize(queryHash);
1025
+ // Cross-session fallback for `.related()` child rows: the LIVE-permission
1026
+ // gap can drop child-edge notifications, so converge their bodies on the
1027
+ // poll too (idempotent — no-op when nothing changed).
1028
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
1029
+ this.logger.info(
1030
+ {
1031
+ err: (err as Error)?.message ?? err,
1032
+ queryHash,
1033
+ Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
1034
+ },
1035
+ 'Subquery child sync failed during poll'
1036
+ );
1037
+ });
1038
+ // A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
1039
+ // get a re-render from the SSP stream on this code path reliably (and the
1040
+ // non-windowed window-0 query re-queries the local DB rather than the id-set).
1041
+ // Force a re-materialize + notify so the deleted row drops from the list in
1042
+ // this (second) window — the reliable, LIVE-independent cross-window path.
1043
+ if (removedIds.length > 0) {
1044
+ try {
1045
+ await this.dataModule.notifyQuerySynced(queryHash);
1046
+ } catch (err) {
1047
+ this.logger.info(
1048
+ {
1049
+ err: (err as Error)?.message ?? err,
1050
+ queryHash,
1051
+ Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
1052
+ },
1053
+ 'notifyQuerySynced failed during poll-removal re-render'
1054
+ );
1055
+ }
1056
+ }
1057
+ return changed;
1058
+ }
1059
+
1060
+ /**
1061
+ * Resolve the current `_00_list_ref` table name for the active auth
1062
+ * context. Public so the `createRemoteQuery` initial-fetch path can
1063
+ * read from the right per-user table.
1064
+ *
1065
+ * Reads the user id from `DataModule` rather than the local mirror,
1066
+ * because `DataModule.setCurrentUserId` runs synchronously from the
1067
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
1068
+ * is async — the userQuery's initial fetch can fire between those
1069
+ * two points and we need the correct table name immediately.
1070
+ */
1071
+ public listRefTable(): string {
1072
+ const userId = this.dataModule.getCurrentUserId();
1073
+ // Unauthenticated with the flag on → the shared `_00_list_ref_anon` table.
1074
+ if (userId == null && this.anonLiveEnabled) {
1075
+ return listRefTableFor(this.refMode, ANON_USER_ID);
1076
+ }
1077
+ return listRefTableFor(this.refMode, userId);
1078
+ }
1079
+
1080
+ private async killRefLiveQuery(): Promise<void> {
1081
+ if (this.liveQueryUnsubscribe) {
1082
+ try {
1083
+ this.liveQueryUnsubscribe();
1084
+ } catch {
1085
+ /* ignore */
1086
+ }
1087
+ this.liveQueryUnsubscribe = null;
1088
+ }
1089
+ if (this.currentLiveQueryUuid !== null) {
1090
+ // A LIVE subscription is scoped to its WebSocket session, so after a
1091
+ // reconnect the server-side one is already gone and there is nothing to
1092
+ // KILL. Sending it anyway either fails (no connection) or races the fresh
1093
+ // socket's readiness while holding up the restart behind it. Local
1094
+ // bookkeeping is cleared either way.
1095
+ if (this.remote.getStatus() === 'connected') {
1096
+ try {
1097
+ await this.remote.query('KILL $u', { u: this.currentLiveQueryUuid });
1098
+ } catch (err) {
1099
+ this.logger.debug(
1100
+ { err, Category: 'sp00ky-client::Sp00kySync::killRefLiveQuery' },
1101
+ 'Prior LIVE KILL failed; continuing'
1102
+ );
1103
+ }
1104
+ }
1105
+ this.currentLiveQueryUuid = null;
1106
+ }
1107
+ }
1108
+
1109
+ private async restartRefLiveQuery(): Promise<void> {
1110
+ await this.killRefLiveQuery();
1111
+ await this.startRefLiveQueries();
1112
+ }
1113
+
1114
+ /**
1115
+ * Drop local LIVE bookkeeping without issuing a `KILL`.
1116
+ *
1117
+ * Called when the socket dies. The server-side subscription is scoped to that
1118
+ * WebSocket session and died with it, so there is nothing left to kill — and
1119
+ * by the time the reconnect handler runs, the client reports `connected`
1120
+ * again, which would otherwise send a `KILL` for a stale uuid on the *new*
1121
+ * session and hold up the restart queued behind it.
1122
+ */
1123
+ private invalidateRefLiveQuery(): void {
1124
+ if (this.liveQueryUnsubscribe) {
1125
+ try {
1126
+ this.liveQueryUnsubscribe();
1127
+ } catch {
1128
+ /* ignore */
1129
+ }
1130
+ this.liveQueryUnsubscribe = null;
1131
+ }
1132
+ this.currentLiveQueryUuid = null;
1133
+ }
1134
+
1135
+ // Only the connect that follows a prior drop counts as a reconnect; the
1136
+ // initial connect after init() must not trigger a refetch storm.
1137
+ //
1138
+ // Both drop events have to be watched. The SDK publishes `disconnected` ONLY
1139
+ // when it has given up entirely (attempts exhausted, or the engine
1140
+ // terminated); an ordinary recovered drop goes `error` -> `reconnecting` ->
1141
+ // `connected` and never touches `disconnected`. Listening for `disconnected`
1142
+ // alone therefore misses every successful reconnect — the exact case this
1143
+ // handler exists for — and leaves the dead server-side LIVE in place with the
1144
+ // poll as the only sync path.
1145
+ private subscribeToReconnect() {
1146
+ const client = this.remote.getClient();
1147
+ client.subscribe('disconnected', () => {
1148
+ this.needsResubscribe = true;
1149
+ this.invalidateRefLiveQuery();
1150
+ this.logger.info(
1151
+ { Category: 'sp00ky-client::Sp00kySync::onDisconnect' },
1152
+ 'Remote disconnected'
1153
+ );
1154
+ });
1155
+ client.subscribe('reconnecting', () => {
1156
+ this.needsResubscribe = true;
1157
+ this.invalidateRefLiveQuery();
1158
+ this.logger.info(
1159
+ { Category: 'sp00ky-client::Sp00kySync::onReconnecting' },
1160
+ 'Remote socket dropped; awaiting reconnect'
1161
+ );
1162
+ });
1163
+ client.subscribe('connected', () => {
1164
+ if (!this.needsResubscribe) return;
1165
+ this.needsResubscribe = false;
1166
+ // A flapping socket produces reconnecting -> connected repeatedly, and
1167
+ // each cycle used to re-register EVERY active query (a busy app has
1168
+ // dozens). That is the "everything reloads about a second after a blip"
1169
+ // symptom: the SDK's retryDelay is 1s, so the refetch lands right after
1170
+ // the drop the user never saw. Collapse bursts into one refetch.
1171
+ const sinceLast = Date.now() - this.lastReconnectRefetchAt;
1172
+ if (sinceLast < Sp00kySync.RECONNECT_REFETCH_COOLDOWN_MS) {
1173
+ this.logger.debug(
1174
+ { sinceLast, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
1175
+ 'Reconnected again within the cooldown; skipping duplicate refetch'
1176
+ );
1177
+ return;
1178
+ }
1179
+ this.lastReconnectRefetchAt = Date.now();
1180
+ const hashes = this.dataModule.getActiveQueryHashes();
1181
+ this.logger.info(
1182
+ { queries: hashes.length, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
1183
+ 'Remote reconnected, refetching active queries'
1184
+ );
1185
+ for (const hash of hashes) {
1186
+ this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
1187
+ }
1188
+ // The WS reconnect leaves the server-side LIVE subscription dead — the
1189
+ // re-enqueued `register` events only re-fetch initial state, they don't
1190
+ // re-subscribe. Without this, LIVE never recovers after a reconnect and
1191
+ // the poll silently becomes the sole sync path (and never backs off).
1192
+ // Authenticated → per-user table; signed-out with anon live enabled →
1193
+ // the shared `_00_list_ref_anon`. Otherwise there's no table to re-bind.
1194
+ if (this.currentUserId || this.anonLiveEnabled) {
1195
+ this.restartRefLiveQuery().catch((err) => {
1196
+ this.logger.debug(
1197
+ { err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
1198
+ 'LIVE restart after reconnect failed; relying on poll fallback'
1199
+ );
1200
+ });
1201
+ }
1202
+ });
91
1203
  }
92
1204
 
93
1205
  private async startRefLiveQueries() {
1206
+ // Shared-tabs follower: exactly one LIVE per user exists, on the leader;
1207
+ // its events reach this tab through the relay.
1208
+ if (this.tabRole === 'follower') return;
1209
+ const tableName = this.listRefTable();
94
1210
  this.logger.debug(
95
- { clientId: this.clientId, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
1211
+ { tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
96
1212
  'Starting ref live queries'
97
1213
  );
98
1214
 
99
- const [queryUuid] = await this.remote.query<[Uuid]>(
100
- 'LIVE SELECT * FROM _spooky_list_ref'
101
- );
1215
+ const [queryUuid] = await this.remote.query<[Uuid]>(`LIVE SELECT * FROM ${tableName}`);
1216
+ this.currentLiveQueryUuid = queryUuid;
102
1217
 
103
- (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
1218
+ const live = await this.remote.getClient().liveOf(queryUuid);
1219
+ this.liveQueryUnsubscribe = live.subscribe((message) => {
104
1220
  this.logger.debug(
105
- { message, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
1221
+ { message, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
106
1222
  'Live update received'
107
1223
  );
108
1224
  if (message.action === 'KILLED') return;
1225
+ // Subquery child edges (rows with `parent` set) are NOT primary window
1226
+ // rows — the client's `RecordVersionArray` only tracks primary rows, so
1227
+ // routing them through `handleRemoteListRefChange` would surface them as
1228
+ // spurious "added" diffs and pollute the window. Instead route them to
1229
+ // the dedicated child-body sync so `.related()` data stays realtime
1230
+ // cross-session (the LIVE-permission gap otherwise leaves it to the poll).
1231
+ if ((message.value as { parent?: unknown }).parent != null) {
1232
+ this.handleRemoteSubqueryChange(
1233
+ message.action,
1234
+ message.value.in as RecordId<string>,
1235
+ message.value.out as RecordId<string>,
1236
+ message.value.version as number
1237
+ ).catch((err) => {
1238
+ this.logger.error(
1239
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
1240
+ 'Error handling remote subquery change'
1241
+ );
1242
+ });
1243
+ return;
1244
+ }
109
1245
  this.handleRemoteListRefChange(
110
1246
  message.action,
111
1247
  message.value.in as RecordId<string>,
@@ -113,7 +1249,7 @@ export class SpookySync<S extends SchemaStructure> {
113
1249
  message.value.version as number
114
1250
  ).catch((err) => {
115
1251
  this.logger.error(
116
- { err, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
1252
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
117
1253
  'Error handling remote list ref change'
118
1254
  );
119
1255
  });
@@ -126,16 +1262,47 @@ export class SpookySync<S extends SchemaStructure> {
126
1262
  recordId: RecordId,
127
1263
  version: number
128
1264
  ) {
1265
+ // Any LIVE delivery is evidence of activity — a CREATE/UPDATE/DELETE on a
1266
+ // query's window, or a notification for an unknown local query. Reset the
1267
+ // poll's idle streak so it snaps back to the fast base cadence (the page
1268
+ // is clearly not idle), and record the timestamp as a liveness diagnostic.
1269
+ this.lastLiveEventAt = Date.now();
1270
+ this.listRefIdleStreak = 0;
1271
+
1272
+ // Shared-tabs leader: the list_ref table is USER-scoped, so this LIVE also
1273
+ // carries events for FOLLOWER tabs' queries (their own session-salted
1274
+ // hashes). Relay every primary event; each follower resolves the queryId
1275
+ // against its own DataModule and ignores foreign ones. Then continue with
1276
+ // this tab's own handling below.
1277
+ this.hub?.broadcast({
1278
+ type: 'list-ref-change',
1279
+ action,
1280
+ queryId: encodeRecordId(queryId),
1281
+ recordId: encodeRecordId(recordId),
1282
+ version,
1283
+ parent: false,
1284
+ });
1285
+
1286
+ // NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
1287
+ // this one) deletes a record, the server's SSP removes it from `_00_list_ref`
1288
+ // and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
1289
+ // turns it into a `removed: [recordId]` diff so the row drops from the window
1290
+ // in realtime. (It was previously ignored, so other windows only caught up on
1291
+ // reload / the slow poll.)
129
1292
  const existing = this.dataModule.getQueryById(queryId);
130
1293
 
131
1294
  if (!existing) {
132
- this.logger.warn(
133
- {
134
- queryId: queryId.toString(),
135
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
136
- },
137
- 'Received remote update for unknown local query'
138
- );
1295
+ // With a hub attached, an unknown query is the NORMAL case (it belongs
1296
+ // to a follower tab); without one it still warrants the warning.
1297
+ if (!this.hub) {
1298
+ this.logger.warn(
1299
+ {
1300
+ queryId: queryId.toString(),
1301
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
1302
+ },
1303
+ 'Received remote update for unknown local query'
1304
+ );
1305
+ }
139
1306
  return;
140
1307
  }
141
1308
 
@@ -148,12 +1315,103 @@ export class SpookySync<S extends SchemaStructure> {
148
1315
  recordId,
149
1316
  version,
150
1317
  localArray,
151
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
1318
+ Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
152
1319
  },
153
1320
  'Live update is being processed'
154
1321
  );
155
1322
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
156
- await this.syncEngine.syncRecords(diff);
1323
+ // `config.id` is `_00_query:<hash>`, so its id-part IS the query hash
1324
+ // (a SHA-256 over query content + sessionId) — the key DataModule uses.
1325
+ const hash = extractIdPart(existing.config.id);
1326
+
1327
+ // Apply the event to `remoteArray` — the authoritative membership rows are
1328
+ // now rendered FROM. Only registration and the poll used to write it, so a
1329
+ // LIVE removal left the departed id in the list (in memory and persisted)
1330
+ // until the next poll tick, which is up to 5s of showing a deleted row. This
1331
+ // also persists the durable `_00_window` mirror, so the removal survives a
1332
+ // reload with no network.
1333
+ //
1334
+ // Derived from the raw action, NOT from `diff`: `createDiffFromDbOp` is
1335
+ // empty when the circuit already holds the row at this version, which is
1336
+ // every tab that ingested the write optimistically (its own, or one
1337
+ // relayed from another tab). The fetch is rightly skipped then, but the
1338
+ // membership still has to be recorded, or the row lives on the
1339
+ // settled-write grace alone until the poll catches it.
1340
+ if (existing.config.membershipKnown) {
1341
+ const membershipDiff: RecordVersionDiff =
1342
+ action === 'DELETE'
1343
+ ? { added: [], updated: [], removed: [recordId] }
1344
+ : { added: [{ id: recordId, version }], updated: [], removed: [] };
1345
+ const next = applyRecordVersionDiff(existing.config.remoteArray ?? [], membershipDiff);
1346
+ if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) {
1347
+ await this.dataModule.updateQueryRemoteArray(hash, next);
1348
+ }
1349
+ }
1350
+
1351
+ await this.runSyncForQuery(hash, diff);
1352
+
1353
+ // A removal-only diff sets `fetching` false in `runSyncForQuery`, so it gets
1354
+ // no `flushPendingStreamUpdate`/`endFetching` re-render — and a removal needs
1355
+ // no record fetch to trigger one either. Force it, mirroring what the poll
1356
+ // path already does for its own removals (`refetchListRefForQuery`).
1357
+ if (diff.removed.length > 0 && diff.added.length === 0 && diff.updated.length === 0) {
1358
+ await this.dataModule.notifyQuerySynced(hash);
1359
+ } else if (diff.added.length === 0 && diff.updated.length === 0) {
1360
+ // Empty diff: the circuit already holds the row at this version (this
1361
+ // tab wrote it), so no fetch and no stream update - but the membership
1362
+ // recorded above is new, and the subscribers have to hear about it.
1363
+ this.dataModule.scheduleRematerialize(hash);
1364
+ }
1365
+ }
1366
+
1367
+ /**
1368
+ * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
1369
+ * `parent` set) for a `.related()` query. Unlike primary rows, child rows
1370
+ * must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
1371
+ * keep the child BODY fresh in the local cache so the in-browser SSP's
1372
+ * subquery-table dependency re-materializes the parent view.
1373
+ *
1374
+ * CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
1375
+ * no-op: a child leaving this query's set must not delete a body another
1376
+ * query may still show (see `syncSubqueryChildren` deletion-safety note);
1377
+ * a genuine record delete propagates via the normal delete path.
1378
+ */
1379
+ private async handleRemoteSubqueryChange(
1380
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
1381
+ queryId: RecordId,
1382
+ childId: RecordId,
1383
+ version: number
1384
+ ) {
1385
+ this.lastLiveEventAt = Date.now();
1386
+ this.listRefIdleStreak = 0;
1387
+
1388
+ if (action === 'DELETE') return;
1389
+
1390
+ // Relay child-edge events too (see handleRemoteListRefChange).
1391
+ this.hub?.broadcast({
1392
+ type: 'list-ref-change',
1393
+ action,
1394
+ queryId: encodeRecordId(queryId),
1395
+ recordId: encodeRecordId(childId),
1396
+ version,
1397
+ parent: true,
1398
+ });
1399
+
1400
+ const existing = this.dataModule.getQueryById(queryId);
1401
+ if (!existing) return;
1402
+
1403
+ const item = { id: childId, version };
1404
+ await this.syncEngine.syncRecords(
1405
+ action === 'CREATE'
1406
+ ? { added: [item], updated: [], removed: [] }
1407
+ : { added: [], updated: [item], removed: [] }
1408
+ );
1409
+
1410
+ // Keep the in-memory child array in step so the poll's idempotent diff
1411
+ // doesn't re-fetch this body on the next tick.
1412
+ const key = encodeRecordId(childId);
1413
+ const prev = existing.config.subqueryRemoteArray ?? [];
1414
+ existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
157
1415
  }
158
1416
 
159
1417
  /**
@@ -164,50 +1422,103 @@ export class SpookySync<S extends SchemaStructure> {
164
1422
  this.scheduler.enqueueDownEvent(event);
165
1423
  }
166
1424
 
1425
+ /**
1426
+ * Bound a mutation push so it always settles.
1427
+ *
1428
+ * `SyncScheduler.syncUp` early-returns while `isSyncingUp` is true, and that
1429
+ * flag only clears in the `finally` of the drain loop. A push whose RPC never
1430
+ * settles (socket dropped mid-flight, response lost) therefore wedges the
1431
+ * up-queue for the rest of the session: no retry, no error, no further
1432
+ * mutation ever sent. A timeout turns that into an ordinary network failure,
1433
+ * which `UpQueue.next` re-queues for the next trigger. The message deliberately
1434
+ * contains "timed out" so `classifySyncError` treats it as `network` and
1435
+ * retries rather than rolling the mutation back.
1436
+ */
1437
+ private withPushTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
1438
+ return withTimeout(
1439
+ promise,
1440
+ this.pushTimeoutMs,
1441
+ `Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`
1442
+ );
1443
+ }
1444
+
167
1445
  private async processUpEvent(event: UpEvent) {
168
1446
  this.logger.debug(
169
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
1447
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
170
1448
  'Processing up event'
171
1449
  );
172
- console.log('xx1', event);
173
1450
  switch (event.type) {
174
- case 'create':
1451
+ case 'create': {
175
1452
  const dataKeys = Object.keys(event.data).map((key) => ({ key, variable: `data_${key}` }));
176
1453
  const prefixedParams = Object.fromEntries(
177
1454
  dataKeys.map(({ key, variable }) => [variable, event.data[key]])
178
1455
  );
179
1456
  const query = surql.seal(surql.createSet('id', dataKeys));
180
- await this.remote.query(query, {
181
- id: event.record_id,
182
- ...prefixedParams,
183
- });
1457
+ await this.withPushTimeout(
1458
+ this.remote.query(query, {
1459
+ id: event.record_id,
1460
+ ...prefixedParams,
1461
+ }),
1462
+ 'create'
1463
+ );
184
1464
  break;
1465
+ }
185
1466
  case 'update':
186
- await this.remote.query(`UPDATE $id MERGE $data`, {
187
- id: event.record_id,
188
- data: event.data,
189
- });
1467
+ await this.withPushTimeout(
1468
+ this.remote.query(`UPDATE $id MERGE $data`, {
1469
+ id: event.record_id,
1470
+ data: event.data,
1471
+ }),
1472
+ 'update'
1473
+ );
190
1474
  break;
191
1475
  case 'delete':
192
- await this.remote.query(`DELETE $id`, {
193
- id: event.record_id,
194
- });
1476
+ await this.withPushTimeout(
1477
+ this.remote.query(`DELETE $id`, {
1478
+ id: event.record_id,
1479
+ }),
1480
+ 'delete'
1481
+ );
195
1482
  break;
196
1483
  default:
197
1484
  this.logger.error(
198
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
1485
+ { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
199
1486
  'processUpEvent unknown event type'
200
1487
  );
201
1488
  return;
202
1489
  }
203
1490
  }
204
1491
 
1492
+ /**
1493
+ * A mutation the server accepted, reported once its outbox row is gone.
1494
+ *
1495
+ * Keeps the written row in the render set until its membership arrives.
1496
+ * Without this the row is briefly in neither term of
1497
+ * `(membership ∪ pendingWrites) − pendingDeletes` — the outbox delete is
1498
+ * tied to the push, while membership waits on the SSP ingesting the row,
1499
+ * materializing the view, writing the `_00_list_ref` edge and this client
1500
+ * reading it back. The writer therefore watched its own comment appear,
1501
+ * vanish, and return, while every other client showed it throughout.
1502
+ */
1503
+ private handleMutationSettled(event: UpEvent): void {
1504
+ const recordId = encodeRecordId(event.record_id);
1505
+ this.dataModule.noteWriteSettled(recordId, event.type);
1506
+ // Shared-tabs: the outbox row just left the SHARED store, so every
1507
+ // follower rendering the row as a pending write has the same gap. All of
1508
+ // them, not just the owner: any tab whose query matched the row was
1509
+ // showing it through `pendingWrites`.
1510
+ this.hub?.broadcast({
1511
+ type: 'mutation-settled',
1512
+ mutationId: encodeRecordId(event.mutation_id),
1513
+ recordId,
1514
+ eventType: event.type,
1515
+ });
1516
+ }
1517
+
205
1518
  private async handleRollback(event: UpEvent, error: Error): Promise<void> {
206
1519
  const recordId = encodeRecordId(event.record_id);
207
1520
  const tableName =
208
- event.type === 'create' && event.tableName
209
- ? event.tableName
210
- : extractTablePart(recordId);
1521
+ event.type === 'create' && event.tableName ? event.tableName : extractTablePart(recordId);
211
1522
 
212
1523
  this.logger.warn(
213
1524
  {
@@ -215,7 +1526,7 @@ export class SpookySync<S extends SchemaStructure> {
215
1526
  recordId,
216
1527
  tableName,
217
1528
  error: error.message,
218
- Category: 'spooky-client::SpookySync::handleRollback',
1529
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
219
1530
  },
220
1531
  'Rolling back failed mutation'
221
1532
  );
@@ -231,7 +1542,7 @@ export class SpookySync<S extends SchemaStructure> {
231
1542
  this.logger.warn(
232
1543
  {
233
1544
  recordId,
234
- Category: 'spooky-client::SpookySync::handleRollback',
1545
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
235
1546
  },
236
1547
  'Cannot rollback update: no beforeRecord available. Down-sync will reconcile.'
237
1548
  );
@@ -241,7 +1552,7 @@ export class SpookySync<S extends SchemaStructure> {
241
1552
  this.logger.warn(
242
1553
  {
243
1554
  recordId,
244
- Category: 'spooky-client::SpookySync::handleRollback',
1555
+ Category: 'sp00ky-client::Sp00kySync::handleRollback',
245
1556
  },
246
1557
  'Delete rollback not implemented. Down-sync will reconcile.'
247
1558
  );
@@ -253,13 +1564,46 @@ export class SpookySync<S extends SchemaStructure> {
253
1564
  recordId,
254
1565
  error: error.message,
255
1566
  });
1567
+
1568
+ // Shared-tabs: the store rollback above already propagated to every tab
1569
+ // via the ingest relay; additionally deliver the EVENT to the tab that
1570
+ // owns the mutation so its UI (toasts, subscribeToRollbacks) fires there.
1571
+ const mutationId = encodeRecordId(event.mutation_id);
1572
+ const owner = mutationOwnerTabId(mutationId);
1573
+ if (this.hub && owner && this.tabId && owner !== this.tabId) {
1574
+ this.hub.sendTo(owner, {
1575
+ type: 'mutation-rolled-back',
1576
+ mutationId,
1577
+ recordId,
1578
+ eventType: event.type,
1579
+ error: error.message,
1580
+ });
1581
+ }
256
1582
  }
257
1583
 
258
1584
  private async processDownEvent(event: DownEvent) {
259
1585
  this.logger.debug(
260
- { event, Category: 'spooky-client::SpookySync::processDownEvent' },
1586
+ { event, Category: 'sp00ky-client::Sp00kySync::processDownEvent' },
261
1587
  'Processing down event'
262
1588
  );
1589
+ // Bounded for the same reason a push is (see withPushTimeout): an RPC that
1590
+ // never settles would otherwise hold its slot in the concurrent down drain
1591
+ // — and, before that drain existed, the WHOLE queue — for the rest of the
1592
+ // session, with no retry, no error, and every dependent `useQuery` stuck
1593
+ // loading. "timed out" in the message keeps `classifySyncError` treating it
1594
+ // as a network failure, so `DownQueue.run` re-heads it for the next pass.
1595
+ return this.withDownTimeout(this.runDownEvent(event), `${event.type} ${event.payload.hash}`);
1596
+ }
1597
+
1598
+ private withDownTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
1599
+ return withTimeout(
1600
+ promise,
1601
+ this.downTimeoutMs,
1602
+ `Down event timed out after ${this.downTimeoutMs}ms (${label})`
1603
+ );
1604
+ }
1605
+
1606
+ private async runDownEvent(event: DownEvent): Promise<void> {
263
1607
  switch (event.type) {
264
1608
  case 'register':
265
1609
  return this.registerQuery(event.payload.hash);
@@ -281,7 +1625,7 @@ export class SpookySync<S extends SchemaStructure> {
281
1625
  const queryState = this.dataModule.getQueryByHash(hash);
282
1626
  if (!queryState) {
283
1627
  this.logger.warn(
284
- { hash, Category: 'spooky-client::SpookySync::syncQuery' },
1628
+ { hash, Category: 'sp00ky-client::Sp00kySync::syncQuery' },
285
1629
  'Query not found'
286
1630
  );
287
1631
  return;
@@ -295,7 +1639,120 @@ export class SpookySync<S extends SchemaStructure> {
295
1639
  if (!diff) {
296
1640
  return;
297
1641
  }
298
- return this.syncEngine.syncRecords(diff);
1642
+ return this.runSyncForQuery(hash, diff);
1643
+ }
1644
+
1645
+ /**
1646
+ * Run a sync for a single query while reflecting its fetch status. Marks the
1647
+ * query `fetching` for the duration when the diff actually pulls records
1648
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
1649
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
1650
+ * means the single resulting UI update lands after this completes.
1651
+ */
1652
+ private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
1653
+ // The diff was computed against whatever the circuit held at call time; if
1654
+ // the boot prime is still filling it, wait and recompute from the primed
1655
+ // `localArray` so the delta is real rather than "everything".
1656
+ const prime = this.primeGate();
1657
+ if (prime !== this.settledPrime) {
1658
+ await prime;
1659
+ this.settledPrime = prime;
1660
+ const fresh = this.dataModule.getQueryByHash(hash);
1661
+ if (!fresh) return;
1662
+ const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
1663
+ if (!recomputed) return;
1664
+ diff = recomputed;
1665
+ }
1666
+ // Don't let sync re-add a record the user just deleted locally. The remote
1667
+ // delete is queued in the outbox, so until it's processed the server's
1668
+ // `_00_list_ref` still lists the record — the diff then classifies it as
1669
+ // `added` (present remotely, absent locally) and `syncRecords` re-fetches +
1670
+ // re-inserts it, so a deleted database reappears a few seconds later. Drop
1671
+ // any id with a pending local DELETE from the re-add paths. Once the remote
1672
+ // delete lands, the pending row clears and the server drops it from
1673
+ // `_00_list_ref`, so this guard naturally stops applying.
1674
+ if (diff.added.length > 0 || diff.updated.length > 0) {
1675
+ const pendingDeletes = await this.getPendingDeleteIds();
1676
+ if (pendingDeletes.size > 0) {
1677
+ diff = {
1678
+ added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
1679
+ updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
1680
+ removed: diff.removed,
1681
+ };
1682
+ }
1683
+ }
1684
+
1685
+ const fetching = diff.added.length + diff.updated.length > 0;
1686
+ if (fetching) {
1687
+ this.dataModule.beginFetching(hash);
1688
+ }
1689
+ try {
1690
+ const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
1691
+ if (fetching) {
1692
+ this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
1693
+ }
1694
+ // Converge localArray to the authoritative remoteArray for ids that left
1695
+ // the server's list_ref but still exist — a view-membership change, not a
1696
+ // delete — so the poll's diff stops re-flagging them every tick (the `job:`
1697
+ // churn). CRUCIAL: only converge after the id has been still-remote for
1698
+ // several CONSECUTIVE rounds. A record that's merely mid-deletion is
1699
+ // still-remote for ~one round (its delete hasn't committed when our
1700
+ // existence check races it) and is gone the next round → it never reaches
1701
+ // the threshold, so it's deleted normally instead of being stranded here.
1702
+ if (stillRemoteIds.length > 0) {
1703
+ const CONVERGE_AFTER = 3;
1704
+ const toConverge: string[] = [];
1705
+ for (const id of stillRemoteIds) {
1706
+ const key = `${hash}:${id}`;
1707
+ const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
1708
+ if (n >= CONVERGE_AFTER) {
1709
+ this.stillRemoteStreaks.delete(key);
1710
+ toConverge.push(id);
1711
+ } else {
1712
+ this.stillRemoteStreaks.set(key, n);
1713
+ }
1714
+ }
1715
+ if (toConverge.length > 0) {
1716
+ const qs = this.dataModule.getQueryByHash(hash);
1717
+ const local = qs?.config.localArray;
1718
+ if (local && local.length > 0) {
1719
+ const drop = new Set(toConverge);
1720
+ const next = local.filter(([id]) => !drop.has(id));
1721
+ if (next.length !== local.length) {
1722
+ await this.dataModule.updateQueryLocalArray(hash, next);
1723
+ }
1724
+ }
1725
+ }
1726
+ }
1727
+ } finally {
1728
+ if (fetching) {
1729
+ // Land the coalesced result BEFORE flipping to idle: the final stream
1730
+ // update sits on a debounce timer, and an `idle` that races ahead of it
1731
+ // would let consumers treat a partially-filled window as authoritative.
1732
+ try {
1733
+ await this.dataModule.flushPendingStreamUpdate(hash);
1734
+ } catch (err) {
1735
+ this.logger.warn(
1736
+ { err, hash, Category: 'sp00ky-client::Sp00kySync::runSyncForQuery' },
1737
+ 'Failed to flush pending stream update before idle'
1738
+ );
1739
+ }
1740
+ this.dataModule.endFetching(hash);
1741
+ }
1742
+ }
1743
+ }
1744
+
1745
+ /**
1746
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
1747
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
1748
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
1749
+ * would otherwise resurrect a just-deleted record.
1750
+ */
1751
+ private async getPendingDeleteIds(): Promise<Set<string>> {
1752
+ // Single implementation, shared with the render path: `materializeRecords`
1753
+ // subtracts the same set so a row whose DELETE is still in the outbox is
1754
+ // neither re-fetched here nor rendered there.
1755
+ return (await this.dataModule.getPendingRecordIds()).deletes;
299
1756
  }
300
1757
 
301
1758
  /**
@@ -303,26 +1760,46 @@ export class SpookySync<S extends SchemaStructure> {
303
1760
  * @param mutations Array of UpEvents (create/update/delete) to enqueue.
304
1761
  */
305
1762
  public async enqueueMutation(mutations: UpEvent[]) {
1763
+ // Follower: the outbox rows are already committed in the SHARED store (the
1764
+ // mutation tx went through the leader's worker); only the leader drains,
1765
+ // so hand over the ids instead of queueing locally. A notify lost in a
1766
+ // failover window is covered by the new leader's loadFromDatabase.
1767
+ if (this.tabRole === 'follower') {
1768
+ for (const m of mutations) {
1769
+ this.forwarder?.mutationEnqueued(encodeRecordId(m.mutation_id));
1770
+ }
1771
+ return;
1772
+ }
306
1773
  this.scheduler.enqueueMutation(mutations);
307
1774
  }
308
1775
 
309
1776
  private async registerQuery(queryHash: string) {
1777
+ // Hold `fetching` across the WHOLE registration (remote view creation +
1778
+ // initial sync + post-sync notify). A query is born `fetching` in
1779
+ // createNewQuery; this refcounted cycle is what resolves it to `idle` — so
1780
+ // consumers (e.g. useQuery's `isSettled`) never see an idle query whose
1781
+ // window is still empty/partially materialized.
1782
+ this.dataModule.beginFetching(queryHash);
310
1783
  try {
311
1784
  this.logger.debug(
312
- { queryHash, Category: 'spooky-client::SpookySync::registerQuery' },
1785
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
313
1786
  'Register Query state'
314
1787
  );
315
1788
  await this.createRemoteQuery(queryHash);
316
1789
  await this.syncQuery(queryHash);
317
- // Always notify after sync completes handles empty result sets
318
- // where no stream updates fire but the UI needs to stop loading
1790
+ // Land any still-debounced stream result, then always notify handles
1791
+ // empty result sets where no stream updates fire but the UI needs to
1792
+ // stop loading.
1793
+ await this.dataModule.flushPendingStreamUpdate(queryHash);
319
1794
  await this.dataModule.notifyQuerySynced(queryHash);
320
1795
  } catch (e) {
321
1796
  this.logger.error(
322
- { err: e, Category: 'spooky-client::SpookySync::registerQuery' },
1797
+ { err: e, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
323
1798
  'registerQuery error'
324
1799
  );
325
1800
  throw e;
1801
+ } finally {
1802
+ this.dataModule.endFetching(queryHash);
326
1803
  }
327
1804
  }
328
1805
 
@@ -331,15 +1808,15 @@ export class SpookySync<S extends SchemaStructure> {
331
1808
 
332
1809
  if (!queryState) {
333
1810
  this.logger.warn(
334
- { queryHash, Category: 'spooky-client::SpookySync::createRemoteQuery' },
1811
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
335
1812
  'Query to register not found'
336
1813
  );
337
1814
  throw new Error('Query to register not found');
338
1815
  }
339
- // Delegate to remote function which handles DBSP registration & persistence
1816
+ // Delegate to remote function which handles DBSP registration & persistence.
1817
+ // clientId is set server-side from session::id() — see fn::query::register.
340
1818
  await this.remote.query('fn::query::register($config)', {
341
1819
  config: {
342
- clientId: this.clientId,
343
1820
  id: queryState.config.id,
344
1821
  surql: queryState.config.surql,
345
1822
  params: queryState.config.params,
@@ -347,18 +1824,26 @@ export class SpookySync<S extends SchemaStructure> {
347
1824
  },
348
1825
  });
349
1826
 
350
- const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
351
- surql.selectByFieldsAnd('_spooky_list_ref', ['in'], ['out', 'version']),
352
- {
353
- in: queryState.config.id,
354
- }
355
- );
1827
+ // Initial materialized-view fetch pull from the same per-user
1828
+ // `_00_list_ref_user_<id>` (or global `_00_list_ref` in single
1829
+ // mode) that the LIVE subscription listens on, so the two stay in
1830
+ // sync. `parent IS NONE` excludes subquery entries; the
1831
+ // `localArray` cache only tracks primary records.
1832
+ const listRefTbl = this.listRefTable();
1833
+ // `rowCount` rides along: it is written by the SSP in the same statement
1834
+ // that registers the view, BEFORE the edges are flushed, so it is the only
1835
+ // way to tell "this query is empty" from "its edges have not landed yet".
1836
+ const [items, serverRowCount] = await this.remote.query<
1837
+ [{ out: RecordId<string>; version: number }[], number | null]
1838
+ >(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, {
1839
+ in: queryState.config.id,
1840
+ });
356
1841
 
357
1842
  this.logger.trace(
358
1843
  {
359
1844
  queryId: encodeRecordId(queryState.config.id),
360
1845
  items,
361
- Category: 'spooky-client::SpookySync::createRemoteQuery',
1846
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
362
1847
  },
363
1848
  'Got query record version array from remote'
364
1849
  );
@@ -369,42 +1854,184 @@ export class SpookySync<S extends SchemaStructure> {
369
1854
  {
370
1855
  queryId: encodeRecordId(queryState.config.id),
371
1856
  array,
372
- Category: 'spooky-client::SpookySync::createRemoteQuery',
1857
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
373
1858
  },
374
1859
  'createdRemoteQuery'
375
1860
  );
376
1861
 
377
1862
  if (array) {
378
1863
  /// Incantation existed already
379
- await this.dataModule.updateQueryRemoteArray(queryHash, array);
1864
+ await this.dataModule.updateQueryRemoteArray(queryHash, array, { serverRowCount });
1865
+ }
1866
+
1867
+ // Pull the bodies of any `.related()` subquery children into the local
1868
+ // cache. The primary fetch above (`parent IS NONE`) tracks only window
1869
+ // rows, so without this a cold-reload re-materialization of the
1870
+ // correlated surql finds no child rows and related fields come back
1871
+ // empty. Best-effort: never fail registration over it.
1872
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
1873
+ this.logger.info(
1874
+ {
1875
+ err: (err as Error)?.message ?? err,
1876
+ queryHash,
1877
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
1878
+ },
1879
+ 'Subquery child sync failed during registration; poll will retry'
1880
+ );
1881
+ });
1882
+ }
1883
+
1884
+ /**
1885
+ * Sync the BODIES of a `.related()` query's subquery child rows into the
1886
+ * local cache, separately from the primary window array. The SSP writes
1887
+ * each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
1888
+ * `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
1889
+ * nesting depth). We diff against the in-memory `subqueryRemoteArray` and
1890
+ * fetch added/updated bodies through the SyncEngine — which `saveBatch`s
1891
+ * them into the local DB AND the in-browser SSP, whose subquery-table
1892
+ * dependency then re-materializes the parent view (no explicit notify).
1893
+ *
1894
+ * Deletion safety: we pass `removed: []` deliberately. A child body can be
1895
+ * shared by other queries; letting `handleRemovedRecords` delete one that
1896
+ * merely left THIS query's child set would clobber data another query still
1897
+ * shows. Genuine record deletes flow through the normal delete path; a
1898
+ * lingering orphan body is invisible (the correlated WHERE stops matching).
1899
+ *
1900
+ * Kept off `runSyncForQuery` on purpose so child fetches never flip the
1901
+ * query to `fetching` or skew its DevTools timings.
1902
+ */
1903
+ private async syncSubqueryChildren(queryHash: string): Promise<void> {
1904
+ const queryState = this.dataModule.getQueryByHash(queryHash);
1905
+ if (!queryState) return;
1906
+
1907
+ const listRefTbl = this.listRefTable();
1908
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
1909
+ buildSubqueryListRefSelect(listRefTbl),
1910
+ { in: queryState.config.id }
1911
+ );
1912
+ if (!Array.isArray(items)) return;
1913
+
1914
+ const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
1915
+ const prev = queryState.config.subqueryRemoteArray ?? [];
1916
+ if (recordVersionArraysEqual(fresh, prev)) return; // idempotent: nothing new
1917
+
1918
+ const diff = diffRecordVersionArray(prev, fresh);
1919
+ if (diff.added.length > 0 || diff.updated.length > 0) {
1920
+ await this.syncEngine.syncRecords({
1921
+ added: diff.added,
1922
+ updated: diff.updated,
1923
+ removed: [], // never delete child bodies here — see method doc
1924
+ });
380
1925
  }
1926
+ // In-memory only — child rows must never enter the persisted primary array.
1927
+ queryState.config.subqueryRemoteArray = fresh;
381
1928
  }
382
1929
 
383
- private async heartbeatQuery(queryHash: string) {
1930
+ public async heartbeatQuery(queryHash: string) {
384
1931
  const queryState = this.dataModule.getQueryByHash(queryHash);
385
1932
  if (!queryState) {
386
1933
  this.logger.warn(
387
- { queryHash, Category: 'spooky-client::SpookySync::heartbeatQuery' },
1934
+ { queryHash, Category: 'sp00ky-client::Sp00kySync::heartbeatQuery' },
388
1935
  'Query to register not found'
389
1936
  );
390
1937
  throw new Error('Query to register not found');
391
1938
  }
392
- await this.remote.query('fn::query::heartbeat($id)', {
1939
+ // `fn::query::heartbeat` is an `UPDATE $id SET ...`. On a record that no
1940
+ // longer exists that matches nothing and returns an empty array — it does
1941
+ // NOT recreate the row. So an unchecked heartbeat is indistinguishable from
1942
+ // a successful one, and a client whose row was reclaimed keeps beating
1943
+ // against nothing forever: no membership, no edges, no re-registration.
1944
+ // The page renders as if the data were deleted ("Game not found").
1945
+ //
1946
+ // A live query's row is reclaimed more easily than it looks. The sweep
1947
+ // expires on `lastActiveAt + ttl`, and this heartbeat runs on a timer that
1948
+ // browsers throttle hard in background tabs — so a second window left idle
1949
+ // past its TTL is the ordinary way to get here, not an edge case. Until
1950
+ // canary.194 the sweep could not actually remove the in-memory view (it
1951
+ // looked it up under the other of the two query-id spellings), which masked
1952
+ // this: the view survived its own row. Now reclamation is real, so the
1953
+ // client has to notice and rebuild.
1954
+ const result = await this.remote.query('fn::query::heartbeat($id)', {
393
1955
  id: queryState.config.id,
394
1956
  });
1957
+ const updated = Array.isArray(result) ? result[0] : undefined;
1958
+ const rowGone = Array.isArray(updated) && updated.length === 0;
1959
+ if (!rowGone) return;
1960
+
1961
+ this.logger.warn(
1962
+ {
1963
+ queryHash,
1964
+ id: String(queryState.config.id),
1965
+ Category: 'sp00ky-client::Sp00kySync::heartbeatQuery',
1966
+ },
1967
+ 'Query row was reclaimed while still in use; re-registering'
1968
+ );
1969
+ // Re-register rather than recreate the row here: the row alone is useless
1970
+ // without the SSP view behind it, and only registration rebuilds the view,
1971
+ // republishes `_00_list_ref` and writes `rowCount`.
1972
+ this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
395
1973
  }
396
1974
 
1975
+ // Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
1976
+ // e.g. a viewport-windowed list cancelling an off-screen window). Query ids
1977
+ // are a deterministic hash of (surql+params), so a release racing a
1978
+ // scroll-back re-register (same id) could nuke a freshly-recreated view —
1979
+ // hence two guards: abort if a subscriber reappeared BEFORE the release;
1980
+ // re-register if one reappears DURING the release's network await. Tolerant
1981
+ // of a missing/already-gone query (no throw).
1982
+ //
1983
+ // Releases via `fn::query::unsubscribe` rather than deleting the row
1984
+ // outright. A `_00_query` row can be shared by several sessions of the same
1985
+ // user, so a bare `DELETE` would tear the view — and every `_00_list_ref`
1986
+ // edge hanging off it — out from under other live tabs. The function drops
1987
+ // only this session from `subscribers` and deletes the row when it was the
1988
+ // last one. (The old `DELETE $id` was harmless in practice only because the
1989
+ // table granted no delete permission and it silently affected zero rows.)
397
1990
  private async cleanupQuery(queryHash: string) {
398
1991
  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');
1992
+ if (!queryState) return; // already torn down / never registered
1993
+
1994
+ // Re-subscribed before the queued cleanup ran → keep everything as-is.
1995
+ if (this.dataModule.hasSubscribers(queryHash)) return;
1996
+
1997
+ // EAGER REMOTE RELEASE IS DISABLED. Deliberate, and not a leak: the TTL
1998
+ // sweep reclaims the row and its edges on `lastActiveAt + ttl`, which is the
1999
+ // ONLY reclamation that has ever actually run in production.
2000
+ //
2001
+ // Until canary.190 `_00_query` granted no delete permission, so the bare
2002
+ // `DELETE $id` this used to issue affected zero rows. .190 granted delete
2003
+ // and .191 wired `fn::query::unsubscribe`, which made teardown real for the
2004
+ // first time -- and the guards above are best-effort by construction
2005
+ // (`hasSubscribers` can be momentarily false during a rebind or a windowed
2006
+ // list re-flow). Every misfire that had been silently inert for months
2007
+ // became a live delete of the row AND every `_00_list_ref` edge on it.
2008
+ //
2009
+ // That matches a report of chat suddenly rendering raw record ids instead
2010
+ // of users, with the message list re-flowing underneath. Server state was
2011
+ // measured intact at the time (`rowCount` equalled the actual edge count on
2012
+ // every row), so the damage is on the client side of a teardown, not in the
2013
+ // materialization.
2014
+ //
2015
+ // Re-enable only together with a repair path that can re-fetch subquery
2016
+ // child bodies whose `subqueryRemoteArray` entry claims they are already
2017
+ // synced -- otherwise a torn-down-and-recreated view never restores the
2018
+ // related records it dropped, because the idempotence check skips them.
2019
+ if (this.releaseQueriesEagerly) {
2020
+ await this.remote.query('fn::query::unsubscribe($id)', {
2021
+ id: queryState.config.id,
2022
+ });
2023
+
2024
+ // Re-subscribed while we awaited the release → re-register. Covers both
2025
+ // outcomes: if we were the last subscriber the remote view is gone and
2026
+ // this recreates it, and if it survived for other sessions this re-adds
2027
+ // us to `subscribers` so our heartbeats keep counting.
2028
+ if (this.dataModule.hasSubscribers(queryHash)) {
2029
+ this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
2030
+ return;
2031
+ }
405
2032
  }
406
- await this.remote.query(`DELETE $id`, {
407
- id: queryState.config.id,
408
- });
2033
+
2034
+ // No subscribers throughout → safe to free the local view + state.
2035
+ this.dataModule.finalizeDeregister(queryHash);
409
2036
  }
410
2037
  }