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

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
@@ -0,0 +1,415 @@
1
+ import type { ConnectionState, ReconnectConfig } from '../../types';
2
+ import { withTimeout } from '../../utils/index';
3
+ import type { Logger } from '../logger/index';
4
+ import type { RemoteDatabaseService } from './remote';
5
+
6
+ /**
7
+ * Keeps the remote WebSocket alive for the whole life of the page.
8
+ *
9
+ * The SurrealDB SDK reconnects on its own after a socket `close`, but that
10
+ * covers only one of three ways the connection dies:
11
+ *
12
+ * 1. **Socket closes, SDK recovers.** Handled entirely by the SDK. This
13
+ * supervisor only observes it (to report `reconnecting` upward).
14
+ * 2. **Socket closes, SDK gives up.** With `attempts: -1` this shouldn't happen
15
+ * from exhaustion — but the SDK also terminates the engine permanently when
16
+ * its post-reconnect handshake throws (it re-runs `version()`, `use()`,
17
+ * `authenticate()` on every reconnect and closes the engine on any error).
18
+ * One transient hiccup there would otherwise kill the page's connection for
19
+ * good. The revive loop re-opens from scratch.
20
+ * 3. **Socket never closes at all.** A half-open connection: the peer is gone
21
+ * (NAT timeout, wifi switch, laptop sleep) but no FIN ever arrives, so
22
+ * `readyState` stays OPEN and the SDK's own 30s ping — fire-and-forget, no
23
+ * response deadline — never notices. Nothing ever fires a `close` event, so
24
+ * nothing ever triggers a reconnect. The heartbeat detects this and forces
25
+ * the teardown that case 2's loop then repairs.
26
+ *
27
+ * Plus wake triggers: coming back `online` or un-hiding the tab probes
28
+ * immediately rather than waiting out a backoff that was scheduled while the
29
+ * network was known-down.
30
+ */
31
+ export class ConnectionSupervisor {
32
+ private readonly logger: Logger;
33
+ private readonly config: Required<ReconnectConfig>;
34
+
35
+ private state: ConnectionState = 'disconnected';
36
+ private subscribers = new Set<(state: ConnectionState) => void>();
37
+
38
+ private started = false;
39
+ private disposed = false;
40
+
41
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
42
+ private heartbeatInFlight = false;
43
+ /** Consecutive failed probes. See {@link FAILURES_BEFORE_TEARDOWN}. */
44
+ private heartbeatFailures = 0;
45
+
46
+ private reviveTimer: ReturnType<typeof setTimeout> | null = null;
47
+ private reviveAttempts = 0;
48
+ /** Timestamp of the last wake-triggered probe, for rate limiting. */
49
+ private lastWakeProbeAt = 0;
50
+ private reviving = false;
51
+ /**
52
+ * Set while the browser reports itself offline. Retrying a socket against a
53
+ * down interface only burns backoff, so the loop parks until `online` fires.
54
+ */
55
+ private suspended = false;
56
+
57
+ private teardown: Array<() => void> = [];
58
+
59
+ private static readonly REVIVE_BASE_MS = 1_000;
60
+ /**
61
+ * How many consecutive heartbeat failures it takes to tear the socket down.
62
+ *
63
+ * The probe rides the same serialized queue as every other RPC (deliberately
64
+ * — see {@link beat}), which means it cannot distinguish a WEDGED queue from
65
+ * a merely BUSY one. A single slow window (a large sync burst, one heavy
66
+ * app query) used to be enough to force-close a perfectly healthy socket,
67
+ * and the resulting reconnect re-registered every active query about a
68
+ * second later. That self-inflicted teardown manufactured the very reconnect
69
+ * storms this class exists to survive. A genuinely dead socket still fails
70
+ * every probe, so it is torn down one interval later than before.
71
+ */
72
+ private static readonly FAILURES_BEFORE_TEARDOWN = 2;
73
+ /** Retry delay after an inconclusive (first) heartbeat failure. */
74
+ private static readonly HEARTBEAT_RETRY_MS = 5_000;
75
+ /** Floor between probes triggered by wake events (tab focus, pageshow). */
76
+ private static readonly WAKE_PROBE_MIN_INTERVAL_MS = 10_000;
77
+
78
+ constructor(
79
+ private readonly remote: RemoteDatabaseService,
80
+ logger: Logger,
81
+ config?: Required<ReconnectConfig>
82
+ ) {
83
+ this.logger = logger.child({ service: 'ConnectionSupervisor' });
84
+ this.config = config ?? remote.getReconnectConfig();
85
+ }
86
+
87
+ /** Latest observed transport state. */
88
+ get connection(): ConnectionState {
89
+ return this.state;
90
+ }
91
+
92
+ /**
93
+ * Observe transport state. Fires immediately with the current value and again
94
+ * on every change. Returns an unsubscribe.
95
+ */
96
+ subscribe(cb: (state: ConnectionState) => void): () => void {
97
+ cb(this.state);
98
+ this.subscribers.add(cb);
99
+ return () => {
100
+ this.subscribers.delete(cb);
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Begin supervising. Call once, after the initial {@link
106
+ * RemoteDatabaseService.connect}. Idempotent.
107
+ */
108
+ start(): void {
109
+ if (this.started || this.disposed) return;
110
+ this.started = true;
111
+
112
+ this.setState(this.remote.getStatus());
113
+
114
+ this.teardown.push(
115
+ this.remote.subscribeConnection('connecting', () => this.setState('connecting')),
116
+ this.remote.subscribeConnection('reconnecting', () => {
117
+ this.setState('reconnecting');
118
+ // The SDK owns the retry from here; ours would fight it for the socket.
119
+ this.stopHeartbeat();
120
+ }),
121
+ this.remote.subscribeConnection('connected', () => {
122
+ this.reviveAttempts = 0;
123
+ this.clearReviveTimer();
124
+ this.setState('connected');
125
+ this.startHeartbeat();
126
+ }),
127
+ this.remote.subscribeConnection('disconnected', () => {
128
+ this.setState('disconnected');
129
+ this.stopHeartbeat();
130
+ // The SDK has stopped trying (exhausted, terminated, or handshake
131
+ // failure). From here on, reconnecting is entirely our job.
132
+ this.scheduleRevive();
133
+ }),
134
+ this.remote.subscribeConnection('error', (err) => {
135
+ this.logger.debug(
136
+ { err, Category: 'sp00ky-client::ConnectionSupervisor::error' },
137
+ 'Transport error'
138
+ );
139
+ })
140
+ );
141
+
142
+ this.installWakeTriggers();
143
+
144
+ if (this.state === 'connected') this.startHeartbeat();
145
+ else this.scheduleRevive();
146
+ }
147
+
148
+ /** Stop all timers and listeners. Safe to call more than once. */
149
+ dispose(): void {
150
+ this.disposed = true;
151
+ this.started = false;
152
+ this.stopHeartbeat();
153
+ this.clearReviveTimer();
154
+ for (const off of this.teardown) {
155
+ try {
156
+ off();
157
+ } catch {
158
+ /* ignore */
159
+ }
160
+ }
161
+ this.teardown = [];
162
+ this.subscribers.clear();
163
+ }
164
+
165
+ private setState(next: ConnectionState): void {
166
+ if (this.state === next) return;
167
+ this.state = next;
168
+ this.logger.info(
169
+ { state: next, Category: 'sp00ky-client::ConnectionSupervisor::state' },
170
+ 'Connection state changed'
171
+ );
172
+ for (const cb of this.subscribers) {
173
+ try {
174
+ cb(next);
175
+ } catch (err) {
176
+ this.logger.debug(
177
+ { err, Category: 'sp00ky-client::ConnectionSupervisor::state' },
178
+ 'Connection subscriber threw'
179
+ );
180
+ }
181
+ }
182
+ }
183
+
184
+ // ---- Revive loop -------------------------------------------------------
185
+
186
+ private clearReviveTimer(): void {
187
+ if (this.reviveTimer !== null) {
188
+ clearTimeout(this.reviveTimer);
189
+ this.reviveTimer = null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Queue the next `connect()` attempt on exponential backoff, capped at
195
+ * `superviseRetryDelayMaxMs`. Never gives up — the page is expected to
196
+ * outlive any outage.
197
+ */
198
+ private scheduleRevive(): void {
199
+ if (this.disposed || this.suspended) return;
200
+ if (this.reviveTimer !== null || this.reviving) return;
201
+ const delay = Math.min(
202
+ this.config.superviseRetryDelayMaxMs,
203
+ ConnectionSupervisor.REVIVE_BASE_MS * 2 ** this.reviveAttempts
204
+ );
205
+ this.reviveTimer = setTimeout(() => {
206
+ this.reviveTimer = null;
207
+ void this.revive();
208
+ }, delay);
209
+ }
210
+
211
+ private async revive(): Promise<void> {
212
+ if (this.disposed || this.suspended || this.reviving) return;
213
+ // The SDK may have recovered on its own between scheduling and firing.
214
+ if (this.remote.getStatus() === 'connected') {
215
+ this.reviveAttempts = 0;
216
+ return;
217
+ }
218
+ this.reviving = true;
219
+ this.reviveAttempts++;
220
+ this.setState('reconnecting');
221
+ this.logger.info(
222
+ {
223
+ attempt: this.reviveAttempts,
224
+ Category: 'sp00ky-client::ConnectionSupervisor::revive',
225
+ },
226
+ 'Re-opening the remote connection'
227
+ );
228
+ try {
229
+ await this.remote.connect();
230
+ // Don't reset `reviveAttempts` or start the heartbeat here — the
231
+ // `connected` handler does both, and it's the only signal that the
232
+ // handshake (version/use/authenticate) actually completed.
233
+ } catch (err) {
234
+ this.logger.warn(
235
+ {
236
+ err,
237
+ attempt: this.reviveAttempts,
238
+ Category: 'sp00ky-client::ConnectionSupervisor::revive',
239
+ },
240
+ 'Reconnect attempt failed; will retry'
241
+ );
242
+ } finally {
243
+ this.reviving = false;
244
+ }
245
+ if (this.remote.getStatus() !== 'connected') this.scheduleRevive();
246
+ }
247
+
248
+ // ---- Heartbeat watchdog ------------------------------------------------
249
+
250
+ private stopHeartbeat(): void {
251
+ if (this.heartbeatTimer !== null) {
252
+ clearTimeout(this.heartbeatTimer);
253
+ this.heartbeatTimer = null;
254
+ }
255
+ }
256
+
257
+ private startHeartbeat(): void {
258
+ this.stopHeartbeat();
259
+ if (this.disposed || this.suspended) return;
260
+ if (!(this.config.heartbeatIntervalMs > 0)) return;
261
+ this.heartbeatTimer = setTimeout(
262
+ () => void this.beat(),
263
+ this.config.heartbeatIntervalMs
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Probe the server end-to-end. Deliberately goes through
269
+ * `remote.query` — the same serialized queue every other remote call uses —
270
+ * so a queue wedged behind a stuck RPC also fails the heartbeat instead of
271
+ * being invisible to it.
272
+ */
273
+ private async beat(): Promise<void> {
274
+ this.heartbeatTimer = null;
275
+ if (this.disposed || this.suspended) return;
276
+ if (this.remote.getStatus() !== 'connected') return;
277
+ if (this.heartbeatInFlight) {
278
+ this.startHeartbeat();
279
+ return;
280
+ }
281
+ this.heartbeatInFlight = true;
282
+ try {
283
+ await withTimeout(
284
+ this.remote.query('RETURN true'),
285
+ this.config.heartbeatTimeoutMs,
286
+ `Heartbeat timed out after ${this.config.heartbeatTimeoutMs}ms`
287
+ );
288
+ this.heartbeatFailures = 0;
289
+ this.startHeartbeat();
290
+ } catch (err) {
291
+ this.heartbeatFailures++;
292
+ if (this.heartbeatFailures < ConnectionSupervisor.FAILURES_BEFORE_TEARDOWN) {
293
+ // Inconclusive: the probe shares a queue with ordinary traffic, so this
294
+ // may just be a busy window rather than a dead socket. Re-probe soon
295
+ // instead of tearing down a connection that is probably fine.
296
+ this.logger.debug(
297
+ {
298
+ err,
299
+ failures: this.heartbeatFailures,
300
+ Category: 'sp00ky-client::ConnectionSupervisor::heartbeat',
301
+ },
302
+ 'Heartbeat failed; re-probing before tearing the socket down'
303
+ );
304
+ this.stopHeartbeat();
305
+ if (!this.disposed && !this.suspended) {
306
+ this.heartbeatTimer = setTimeout(
307
+ () => void this.beat(),
308
+ Math.min(ConnectionSupervisor.HEARTBEAT_RETRY_MS, this.config.heartbeatIntervalMs)
309
+ );
310
+ }
311
+ return;
312
+ }
313
+ this.logger.warn(
314
+ {
315
+ err,
316
+ failures: this.heartbeatFailures,
317
+ Category: 'sp00ky-client::ConnectionSupervisor::heartbeat',
318
+ },
319
+ 'Heartbeat failed repeatedly; tearing the socket down to force a reconnect'
320
+ );
321
+ this.heartbeatFailures = 0;
322
+ // Force the `close` the transport never delivered. The resulting
323
+ // `disconnected` event drives the revive loop.
324
+ await this.remote.forceClose();
325
+ if (this.remote.getStatus() !== 'connected') this.scheduleRevive();
326
+ } finally {
327
+ this.heartbeatInFlight = false;
328
+ }
329
+ }
330
+
331
+ // ---- Wake triggers -----------------------------------------------------
332
+
333
+ /**
334
+ * A restored network or an un-hidden tab is the strongest available hint that
335
+ * a reconnect will now succeed, so probe immediately instead of waiting out a
336
+ * backoff scheduled under worse conditions.
337
+ */
338
+ private installWakeTriggers(): void {
339
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
340
+ const onOnline = () => {
341
+ this.suspended = false;
342
+ this.wake('online');
343
+ };
344
+ const onOffline = () => {
345
+ this.logger.info(
346
+ { Category: 'sp00ky-client::ConnectionSupervisor::offline' },
347
+ 'Browser reports offline; parking reconnects until online'
348
+ );
349
+ this.suspended = true;
350
+ this.stopHeartbeat();
351
+ this.clearReviveTimer();
352
+ this.setState('disconnected');
353
+ };
354
+ window.addEventListener('online', onOnline);
355
+ window.addEventListener('offline', onOffline);
356
+ this.teardown.push(
357
+ () => window.removeEventListener('online', onOnline),
358
+ () => window.removeEventListener('offline', onOffline)
359
+ );
360
+ // A tab restored from bfcache keeps its dead socket; `pageshow` is the
361
+ // only event that fires in that path.
362
+ const onPageShow = () => this.wake('pageshow');
363
+ window.addEventListener('pageshow', onPageShow);
364
+ this.teardown.push(() => window.removeEventListener('pageshow', onPageShow));
365
+ }
366
+
367
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
368
+ const onVisibility = () => {
369
+ if (document.visibilityState !== 'visible') return;
370
+ this.wake('visibilitychange');
371
+ };
372
+ document.addEventListener('visibilitychange', onVisibility);
373
+ this.teardown.push(() => document.removeEventListener('visibilitychange', onVisibility));
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Reset the backoff and act on whichever problem is present: reconnect if the
379
+ * socket is gone, otherwise probe it (it may be half-open — which is exactly
380
+ * what a sleep/wake cycle produces).
381
+ */
382
+ private wake(reason: string): void {
383
+ if (this.disposed || this.suspended) return;
384
+ // `visibilitychange` fires on every alt-tab, every window minimise and every
385
+ // focus change. Probing a connected socket each time meant an ordinary
386
+ // afternoon of tab-switching issued a steady stream of extra RPCs, each of
387
+ // which could trip the heartbeat teardown above. A healthy socket does not
388
+ // become unhealthy because the user looked away for four seconds, so probes
389
+ // from wake triggers are rate-limited; a genuinely dead socket is still
390
+ // caught by the regular heartbeat.
391
+ const now = Date.now();
392
+ if (
393
+ this.remote.getStatus() === 'connected' &&
394
+ now - this.lastWakeProbeAt < ConnectionSupervisor.WAKE_PROBE_MIN_INTERVAL_MS
395
+ ) {
396
+ return;
397
+ }
398
+ this.lastWakeProbeAt = now;
399
+ this.logger.debug(
400
+ { reason, state: this.state, Category: 'sp00ky-client::ConnectionSupervisor::wake' },
401
+ 'Wake trigger; probing the connection'
402
+ );
403
+ this.reviveAttempts = 0;
404
+ if (this.remote.getStatus() === 'connected') {
405
+ this.stopHeartbeat();
406
+ void this.beat();
407
+ return;
408
+ }
409
+ // 'reconnecting' means the SDK's own loop owns the socket; don't race it.
410
+ if (this.remote.getStatus() === 'disconnected') {
411
+ this.clearReviveTimer();
412
+ void this.revive();
413
+ }
414
+ }
415
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { AbstractDatabaseService } from './database';
3
+ import { DatabaseEventTypes, createDatabaseEventSystem } from './events/index';
4
+ import { classifySyncError } from '../../utils/index';
5
+
6
+ // Every remote query is serialized through one promise chain (to keep the WASM
7
+ // engine's transactions safe), so a call that never settles doesn't just fail —
8
+ // it blocks every later query behind it, including the sync poll's own health
9
+ // probe. That's how a half-open socket wedges the client while health still
10
+ // reports `healthy`: nothing ever throws, so nothing ever degrades.
11
+
12
+ const silentLogger: any = (() => {
13
+ const l: any = {
14
+ child: () => l,
15
+ debug: () => {},
16
+ info: () => {},
17
+ warn: () => {},
18
+ error: () => {},
19
+ trace: () => {},
20
+ };
21
+ return l;
22
+ })();
23
+
24
+ class TestService extends AbstractDatabaseService {
25
+ protected eventType = DatabaseEventTypes.RemoteQuery;
26
+ constructor(client: any, timeoutMs: number) {
27
+ super(client, silentLogger, createDatabaseEventSystem());
28
+ this.queryTimeoutMs = timeoutMs;
29
+ }
30
+ async connect(): Promise<void> {}
31
+ }
32
+
33
+ describe('AbstractDatabaseService query timeout', () => {
34
+ beforeEach(() => vi.useFakeTimers());
35
+ afterEach(() => vi.useRealTimers());
36
+
37
+ it('rejects a never-settling query and classifies it as a network failure', async () => {
38
+ const client = { query: vi.fn().mockImplementation(() => new Promise(() => {})) };
39
+ const svc = new TestService(client, 1_000);
40
+
41
+ const pending = svc.query('SELECT * FROM thread');
42
+ const assertion = expect(pending).rejects.toThrow(/timed out/);
43
+ await vi.advanceTimersByTimeAsync(1_000);
44
+ await assertion;
45
+
46
+ // "timed out" in the message is load-bearing: it routes the failure to
47
+ // `network`, which makes the sync queues retry instead of rolling the
48
+ // mutation back as a permanent application error.
49
+ const err = await pending.catch((e) => e);
50
+ expect(classifySyncError(err)).toBe('network');
51
+ });
52
+
53
+ it('unblocks the serialized queue so later queries still run', async () => {
54
+ let calls = 0;
55
+ const client = {
56
+ query: vi.fn().mockImplementation(() => {
57
+ calls++;
58
+ // First call hangs forever; the second would never be reached at all if
59
+ // the timeout didn't settle the chain link.
60
+ if (calls === 1) return new Promise(() => {});
61
+ return Promise.resolve([['ok']]);
62
+ }),
63
+ };
64
+ const svc = new TestService(client, 1_000);
65
+
66
+ const stuck = svc.query('SELECT 1');
67
+ const next = svc.query('SELECT 2');
68
+ const stuckAssertion = expect(stuck).rejects.toThrow(/timed out/);
69
+
70
+ await vi.advanceTimersByTimeAsync(1_000);
71
+ await stuckAssertion;
72
+ await expect(next).resolves.toEqual([['ok']]);
73
+ });
74
+
75
+ it('leaves queries unbounded when the timeout is disabled', async () => {
76
+ const client = { query: vi.fn().mockResolvedValue([['ok']]) };
77
+ const svc = new TestService(client, 0);
78
+
79
+ await expect(svc.query('SELECT 1')).resolves.toEqual([['ok']]);
80
+ // No timer was armed, so a slow-but-alive query is never cut off.
81
+ expect(vi.getTimerCount()).toBe(0);
82
+ });
83
+ });
@@ -1,16 +1,31 @@
1
- import { Surreal, SurrealTransaction } from 'surrealdb';
2
- import { createLogger, Logger } from '../logger/index';
3
- import {
1
+ import type { Surreal, SurrealTransaction } from 'surrealdb';
2
+ import type { Logger } from '../logger/index';
3
+ import type {
4
4
  DatabaseEventSystem,
5
- DatabaseEventTypes,
6
- DatabaseQueryEventPayload,
7
- } from './events/index';
8
- import { SealedQuery } from '../../utils/surql';
5
+ DatabaseEventTypes} from './events/index';
6
+ import type { SealedQuery } from '../../utils/surql';
7
+ import { withTimeout } from '../../utils/index';
9
8
 
10
9
  export abstract class AbstractDatabaseService {
11
10
  protected client: Surreal;
12
11
  protected logger: Logger;
13
12
  protected events: DatabaseEventSystem;
13
+ /**
14
+ * Per-query deadline in ms; `0` disables. The remote service sets it from
15
+ * `queryTimeoutMs` (see `RemoteDatabaseService`), the local one from
16
+ * `localOpTimeoutMs` (see `LocalDatabaseService`): a local query can be
17
+ * legitimately slow, but it must never be endless - every query waits on the
18
+ * previous link of {@link query}'s chain, and one that never settled wedged
19
+ * every later local op behind it.
20
+ */
21
+ protected queryTimeoutMs = 0;
22
+
23
+ /** The error a deadline expiry rejects with; the local service substitutes
24
+ * its typed `LocalOpTimeoutError`. "timed out" in the message is
25
+ * load-bearing either way: `classifySyncError` keys off it. */
26
+ protected timeoutError(_query: string): Error {
27
+ return new Error(`Remote query timed out after ${this.queryTimeoutMs}ms`);
28
+ }
14
29
  protected abstract eventType:
15
30
  | typeof DatabaseEventTypes.LocalQuery
16
31
  | typeof DatabaseEventTypes.RemoteQuery;
@@ -39,21 +54,34 @@ export abstract class AbstractDatabaseService {
39
54
 
40
55
  /**
41
56
  * Execute a query with serialized execution to prevent WASM transaction issues.
57
+ *
58
+ * Serialization means every query waits on the previous one, so a call that
59
+ * never settles blocks the whole chain forever. {@link queryTimeoutMs} bounds
60
+ * each link: on expiry this promise rejects and the chain moves on, even
61
+ * though the underlying RPC is still parked in the SDK's pending map.
42
62
  */
43
63
  async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
44
64
  return new Promise((resolve, reject) => {
45
65
  this.queryQueue = this.queryQueue
66
+ // oxlint-disable-next-line promise/always-return
46
67
  .then(async () => {
47
68
  const startTime = performance.now();
48
69
  try {
49
70
  this.logger.debug(
50
- { query, vars, Category: 'spooky-client::Database::query' },
71
+ { query, vars, Category: 'sp00ky-client::Database::query' },
51
72
  'Executing query'
52
73
  );
53
74
  const pending = this.client.query(query, vars);
54
75
  // In SurrealDB 2.0, .query() collects results by default.
55
76
  // We cast to T directly as proper typing depends on the caller knowing the return structure.
56
- const result = (await pending) as unknown as T;
77
+ // "timed out" in the message is load-bearing: `classifySyncError`
78
+ // keys off it to classify this as `network` so the sync queues
79
+ // retry rather than rolling the mutation back.
80
+ const result = (await withTimeout(
81
+ pending as unknown as Promise<T>,
82
+ this.queryTimeoutMs,
83
+ () => this.timeoutError(query)
84
+ )) as T;
57
85
  const duration = performance.now() - startTime;
58
86
 
59
87
  // Emit query event
@@ -67,7 +95,7 @@ export abstract class AbstractDatabaseService {
67
95
 
68
96
  resolve(result);
69
97
  this.logger.trace(
70
- { query, result, Category: 'spooky-client::Database::query' },
98
+ { query, result, Category: 'sp00ky-client::Database::query' },
71
99
  'Query executed successfully'
72
100
  );
73
101
  } catch (err) {
@@ -84,9 +112,10 @@ export abstract class AbstractDatabaseService {
84
112
  });
85
113
 
86
114
  this.logger.error(
87
- { query, vars, err, Category: 'spooky-client::Database::query' },
115
+ { query, vars, err, Category: 'sp00ky-client::Database::query' },
88
116
  'Query execution failed'
89
117
  );
118
+ // oxlint-disable-next-line no-multiple-resolved -- resolve/reject are in try/catch, mutually exclusive
90
119
  reject(err);
91
120
  }
92
121
  })
@@ -102,7 +131,7 @@ export abstract class AbstractDatabaseService {
102
131
  }
103
132
 
104
133
  async close(): Promise<void> {
105
- this.logger.info({ Category: 'spooky-client::Database::close' }, 'Closing database connection');
134
+ this.logger.info({ Category: 'sp00ky-client::Database::close' }, 'Closing database connection');
106
135
  await this.client.close();
107
136
  }
108
137
  }
@@ -0,0 +1,33 @@
1
+ import type { Sp00kyConfig } from '../../types';
2
+ import type { Logger } from '../logger/index';
3
+ import type { LocalEngineChoice, LocalStore } from './cache-engine';
4
+ import { SurrealCacheEngine } from './surreal-cache-engine';
5
+ import { SqliteCacheEngine } from './sqlite-cache-engine';
6
+
7
+ /**
8
+ * Build the local cache engine for the given `localEngine` config choice.
9
+ *
10
+ * - `'surrealdb'` / unset → {@link SurrealCacheEngine} (subclass of
11
+ * `LocalDatabaseService`; the historical behavior, verbatim).
12
+ * - `'sqlite'` → {@link SqliteCacheEngine} (SQLite-WASM Worker + OPFS). Backs
13
+ * `this.local` through the SurrealQL-vocabulary shim + verb surface.
14
+ * - a custom object → used as-is (must satisfy {@link LocalStore}).
15
+ */
16
+ export function createLocalEngine(
17
+ choice: LocalEngineChoice | undefined,
18
+ config: Sp00kyConfig<any>['database'],
19
+ logger: Logger,
20
+ opts: { shared?: boolean } = {}
21
+ ): LocalStore {
22
+ if (choice === undefined || choice === 'surrealdb') {
23
+ return new SurrealCacheEngine(config, logger);
24
+ }
25
+ if (choice === 'sqlite') {
26
+ // Mirror the SurrealDB engine's `store` semantics: 'memory' → in-memory
27
+ // SQLite (transient), 'indexeddb' → OPFS-backed (durable).
28
+ const useOpfs = (config.store ?? 'memory') !== 'memory';
29
+ return new SqliteCacheEngine(config, logger, { useOpfs, shared: opts.shared });
30
+ }
31
+ // Custom engine instance — trust it to satisfy LocalStore.
32
+ return choice as unknown as LocalStore;
33
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A local-store operation that did not answer within its deadline.
3
+ *
4
+ * The local write path (`db.create` / `db.update` / `db.delete`, every local
5
+ * query behind them) used to have no deadline anywhere: the SQLite worker
6
+ * transport parks a call until the worker replies, the surrealdb engine's
7
+ * query chain waits on the previous link, and `withRetry` retries without a
8
+ * clock. One op that never settled (a worker starved behind a long select, a
9
+ * lock verification awaiting `navigator.locks.query()` forever) left the
10
+ * caller's promise pending for the tab's lifetime - a chat composer that never
11
+ * re-enabled, a call that never got past "Connecting".
12
+ *
13
+ * The message says "timed out" on purpose: `classifySyncError` keys off it and
14
+ * treats the failure as transient (re-queue), never as an application error
15
+ * that rolls the mutation back. `retryable: false` keeps `withRetry` from
16
+ * spinning on it: the op is still running in the engine, retrying queues a
17
+ * second copy behind it.
18
+ */
19
+ export class LocalOpTimeoutError extends Error {
20
+ override readonly name = 'LocalOpTimeoutError';
21
+ readonly retryable = false;
22
+ readonly op: string;
23
+ readonly timeoutMs: number;
24
+
25
+ constructor(op: string, timeoutMs: number) {
26
+ super(`Local database operation timed out after ${timeoutMs}ms (${op})`);
27
+ this.op = op;
28
+ this.timeoutMs = timeoutMs;
29
+ }
30
+ }
31
+
32
+ /** Default deadline for one local-store operation. Generous: a cold 4k-row
33
+ * select on a throttled tab is seconds, not tens of seconds. */
34
+ export const DEFAULT_LOCAL_OP_TIMEOUT_MS = 30_000;
@@ -1,4 +1,5 @@
1
- import { createEventSystem, EventDefinition, EventSystem } from '../../../events/index';
1
+ import type { EventDefinition, EventSystem } from '../../../events/index';
2
+ import { createEventSystem } from '../../../events/index';
2
3
 
3
4
  export const DatabaseEventTypes = {
4
5
  LocalQuery: 'DATABASE_LOCAL_QUERY',