@spooky-sync/core 0.0.1-canary.161 → 0.0.1-canary.162

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.
@@ -0,0 +1,351 @@
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
+
44
+ private reviveTimer: ReturnType<typeof setTimeout> | null = null;
45
+ private reviveAttempts = 0;
46
+ private reviving = false;
47
+ /**
48
+ * Set while the browser reports itself offline. Retrying a socket against a
49
+ * down interface only burns backoff, so the loop parks until `online` fires.
50
+ */
51
+ private suspended = false;
52
+
53
+ private teardown: Array<() => void> = [];
54
+
55
+ private static readonly REVIVE_BASE_MS = 1_000;
56
+
57
+ constructor(
58
+ private readonly remote: RemoteDatabaseService,
59
+ logger: Logger,
60
+ config?: Required<ReconnectConfig>
61
+ ) {
62
+ this.logger = logger.child({ service: 'ConnectionSupervisor' });
63
+ this.config = config ?? remote.getReconnectConfig();
64
+ }
65
+
66
+ /** Latest observed transport state. */
67
+ get connection(): ConnectionState {
68
+ return this.state;
69
+ }
70
+
71
+ /**
72
+ * Observe transport state. Fires immediately with the current value and again
73
+ * on every change. Returns an unsubscribe.
74
+ */
75
+ subscribe(cb: (state: ConnectionState) => void): () => void {
76
+ cb(this.state);
77
+ this.subscribers.add(cb);
78
+ return () => {
79
+ this.subscribers.delete(cb);
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Begin supervising. Call once, after the initial {@link
85
+ * RemoteDatabaseService.connect}. Idempotent.
86
+ */
87
+ start(): void {
88
+ if (this.started || this.disposed) return;
89
+ this.started = true;
90
+
91
+ this.setState(this.remote.getStatus());
92
+
93
+ this.teardown.push(
94
+ this.remote.subscribeConnection('connecting', () => this.setState('connecting')),
95
+ this.remote.subscribeConnection('reconnecting', () => {
96
+ this.setState('reconnecting');
97
+ // The SDK owns the retry from here; ours would fight it for the socket.
98
+ this.stopHeartbeat();
99
+ }),
100
+ this.remote.subscribeConnection('connected', () => {
101
+ this.reviveAttempts = 0;
102
+ this.clearReviveTimer();
103
+ this.setState('connected');
104
+ this.startHeartbeat();
105
+ }),
106
+ this.remote.subscribeConnection('disconnected', () => {
107
+ this.setState('disconnected');
108
+ this.stopHeartbeat();
109
+ // The SDK has stopped trying (exhausted, terminated, or handshake
110
+ // failure). From here on, reconnecting is entirely our job.
111
+ this.scheduleRevive();
112
+ }),
113
+ this.remote.subscribeConnection('error', (err) => {
114
+ this.logger.debug(
115
+ { err, Category: 'sp00ky-client::ConnectionSupervisor::error' },
116
+ 'Transport error'
117
+ );
118
+ })
119
+ );
120
+
121
+ this.installWakeTriggers();
122
+
123
+ if (this.state === 'connected') this.startHeartbeat();
124
+ else this.scheduleRevive();
125
+ }
126
+
127
+ /** Stop all timers and listeners. Safe to call more than once. */
128
+ dispose(): void {
129
+ this.disposed = true;
130
+ this.started = false;
131
+ this.stopHeartbeat();
132
+ this.clearReviveTimer();
133
+ for (const off of this.teardown) {
134
+ try {
135
+ off();
136
+ } catch {
137
+ /* ignore */
138
+ }
139
+ }
140
+ this.teardown = [];
141
+ this.subscribers.clear();
142
+ }
143
+
144
+ private setState(next: ConnectionState): void {
145
+ if (this.state === next) return;
146
+ this.state = next;
147
+ this.logger.info(
148
+ { state: next, Category: 'sp00ky-client::ConnectionSupervisor::state' },
149
+ 'Connection state changed'
150
+ );
151
+ for (const cb of this.subscribers) {
152
+ try {
153
+ cb(next);
154
+ } catch (err) {
155
+ this.logger.debug(
156
+ { err, Category: 'sp00ky-client::ConnectionSupervisor::state' },
157
+ 'Connection subscriber threw'
158
+ );
159
+ }
160
+ }
161
+ }
162
+
163
+ // ---- Revive loop -------------------------------------------------------
164
+
165
+ private clearReviveTimer(): void {
166
+ if (this.reviveTimer !== null) {
167
+ clearTimeout(this.reviveTimer);
168
+ this.reviveTimer = null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Queue the next `connect()` attempt on exponential backoff, capped at
174
+ * `superviseRetryDelayMaxMs`. Never gives up — the page is expected to
175
+ * outlive any outage.
176
+ */
177
+ private scheduleRevive(): void {
178
+ if (this.disposed || this.suspended) return;
179
+ if (this.reviveTimer !== null || this.reviving) return;
180
+ const delay = Math.min(
181
+ this.config.superviseRetryDelayMaxMs,
182
+ ConnectionSupervisor.REVIVE_BASE_MS * 2 ** this.reviveAttempts
183
+ );
184
+ this.reviveTimer = setTimeout(() => {
185
+ this.reviveTimer = null;
186
+ void this.revive();
187
+ }, delay);
188
+ }
189
+
190
+ private async revive(): Promise<void> {
191
+ if (this.disposed || this.suspended || this.reviving) return;
192
+ // The SDK may have recovered on its own between scheduling and firing.
193
+ if (this.remote.getStatus() === 'connected') {
194
+ this.reviveAttempts = 0;
195
+ return;
196
+ }
197
+ this.reviving = true;
198
+ this.reviveAttempts++;
199
+ this.setState('reconnecting');
200
+ this.logger.info(
201
+ {
202
+ attempt: this.reviveAttempts,
203
+ Category: 'sp00ky-client::ConnectionSupervisor::revive',
204
+ },
205
+ 'Re-opening the remote connection'
206
+ );
207
+ try {
208
+ await this.remote.connect();
209
+ // Don't reset `reviveAttempts` or start the heartbeat here — the
210
+ // `connected` handler does both, and it's the only signal that the
211
+ // handshake (version/use/authenticate) actually completed.
212
+ } catch (err) {
213
+ this.logger.warn(
214
+ {
215
+ err,
216
+ attempt: this.reviveAttempts,
217
+ Category: 'sp00ky-client::ConnectionSupervisor::revive',
218
+ },
219
+ 'Reconnect attempt failed; will retry'
220
+ );
221
+ } finally {
222
+ this.reviving = false;
223
+ }
224
+ if (this.remote.getStatus() !== 'connected') this.scheduleRevive();
225
+ }
226
+
227
+ // ---- Heartbeat watchdog ------------------------------------------------
228
+
229
+ private stopHeartbeat(): void {
230
+ if (this.heartbeatTimer !== null) {
231
+ clearTimeout(this.heartbeatTimer);
232
+ this.heartbeatTimer = null;
233
+ }
234
+ }
235
+
236
+ private startHeartbeat(): void {
237
+ this.stopHeartbeat();
238
+ if (this.disposed || this.suspended) return;
239
+ if (!(this.config.heartbeatIntervalMs > 0)) return;
240
+ this.heartbeatTimer = setTimeout(
241
+ () => void this.beat(),
242
+ this.config.heartbeatIntervalMs
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Probe the server end-to-end. Deliberately goes through
248
+ * `remote.query` — the same serialized queue every other remote call uses —
249
+ * so a queue wedged behind a stuck RPC also fails the heartbeat instead of
250
+ * being invisible to it.
251
+ */
252
+ private async beat(): Promise<void> {
253
+ this.heartbeatTimer = null;
254
+ if (this.disposed || this.suspended) return;
255
+ if (this.remote.getStatus() !== 'connected') return;
256
+ if (this.heartbeatInFlight) {
257
+ this.startHeartbeat();
258
+ return;
259
+ }
260
+ this.heartbeatInFlight = true;
261
+ try {
262
+ await withTimeout(
263
+ this.remote.query('RETURN true'),
264
+ this.config.heartbeatTimeoutMs,
265
+ `Heartbeat timed out after ${this.config.heartbeatTimeoutMs}ms`
266
+ );
267
+ this.startHeartbeat();
268
+ } catch (err) {
269
+ this.logger.warn(
270
+ { err, Category: 'sp00ky-client::ConnectionSupervisor::heartbeat' },
271
+ 'Heartbeat failed; tearing the socket down to force a reconnect'
272
+ );
273
+ // Force the `close` the transport never delivered. The resulting
274
+ // `disconnected` event drives the revive loop.
275
+ await this.remote.forceClose();
276
+ if (this.remote.getStatus() !== 'connected') this.scheduleRevive();
277
+ } finally {
278
+ this.heartbeatInFlight = false;
279
+ }
280
+ }
281
+
282
+ // ---- Wake triggers -----------------------------------------------------
283
+
284
+ /**
285
+ * A restored network or an un-hidden tab is the strongest available hint that
286
+ * a reconnect will now succeed, so probe immediately instead of waiting out a
287
+ * backoff scheduled under worse conditions.
288
+ */
289
+ private installWakeTriggers(): void {
290
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
291
+ const onOnline = () => {
292
+ this.suspended = false;
293
+ this.wake('online');
294
+ };
295
+ const onOffline = () => {
296
+ this.logger.info(
297
+ { Category: 'sp00ky-client::ConnectionSupervisor::offline' },
298
+ 'Browser reports offline; parking reconnects until online'
299
+ );
300
+ this.suspended = true;
301
+ this.stopHeartbeat();
302
+ this.clearReviveTimer();
303
+ this.setState('disconnected');
304
+ };
305
+ window.addEventListener('online', onOnline);
306
+ window.addEventListener('offline', onOffline);
307
+ this.teardown.push(
308
+ () => window.removeEventListener('online', onOnline),
309
+ () => window.removeEventListener('offline', onOffline)
310
+ );
311
+ // A tab restored from bfcache keeps its dead socket; `pageshow` is the
312
+ // only event that fires in that path.
313
+ const onPageShow = () => this.wake('pageshow');
314
+ window.addEventListener('pageshow', onPageShow);
315
+ this.teardown.push(() => window.removeEventListener('pageshow', onPageShow));
316
+ }
317
+
318
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
319
+ const onVisibility = () => {
320
+ if (document.visibilityState !== 'visible') return;
321
+ this.wake('visibilitychange');
322
+ };
323
+ document.addEventListener('visibilitychange', onVisibility);
324
+ this.teardown.push(() => document.removeEventListener('visibilitychange', onVisibility));
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Reset the backoff and act on whichever problem is present: reconnect if the
330
+ * socket is gone, otherwise probe it (it may be half-open — which is exactly
331
+ * what a sleep/wake cycle produces).
332
+ */
333
+ private wake(reason: string): void {
334
+ if (this.disposed || this.suspended) return;
335
+ this.logger.debug(
336
+ { reason, state: this.state, Category: 'sp00ky-client::ConnectionSupervisor::wake' },
337
+ 'Wake trigger; probing the connection'
338
+ );
339
+ this.reviveAttempts = 0;
340
+ if (this.remote.getStatus() === 'connected') {
341
+ this.stopHeartbeat();
342
+ void this.beat();
343
+ return;
344
+ }
345
+ // 'reconnecting' means the SDK's own loop owns the socket; don't race it.
346
+ if (this.remote.getStatus() === 'disconnected') {
347
+ this.clearReviveTimer();
348
+ void this.revive();
349
+ }
350
+ }
351
+ }
@@ -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
+ });
@@ -4,11 +4,19 @@ import type {
4
4
  DatabaseEventSystem,
5
5
  DatabaseEventTypes} from './events/index';
6
6
  import type { SealedQuery } from '../../utils/surql';
7
+ import { withTimeout } from '../../utils/index';
7
8
 
8
9
  export abstract class AbstractDatabaseService {
9
10
  protected client: Surreal;
10
11
  protected logger: Logger;
11
12
  protected events: DatabaseEventSystem;
13
+ /**
14
+ * Per-query deadline in ms; `0` disables. Only the remote service sets this
15
+ * (see `RemoteDatabaseService`) — a local query can be legitimately slow and
16
+ * has its own retry ladders, and there is no half-open-socket failure mode
17
+ * for an in-process engine.
18
+ */
19
+ protected queryTimeoutMs = 0;
12
20
  protected abstract eventType:
13
21
  | typeof DatabaseEventTypes.LocalQuery
14
22
  | typeof DatabaseEventTypes.RemoteQuery;
@@ -37,6 +45,11 @@ export abstract class AbstractDatabaseService {
37
45
 
38
46
  /**
39
47
  * Execute a query with serialized execution to prevent WASM transaction issues.
48
+ *
49
+ * Serialization means every query waits on the previous one, so a call that
50
+ * never settles blocks the whole chain forever. {@link queryTimeoutMs} bounds
51
+ * each link: on expiry this promise rejects and the chain moves on, even
52
+ * though the underlying RPC is still parked in the SDK's pending map.
40
53
  */
41
54
  async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
42
55
  return new Promise((resolve, reject) => {
@@ -52,7 +65,14 @@ export abstract class AbstractDatabaseService {
52
65
  const pending = this.client.query(query, vars);
53
66
  // In SurrealDB 2.0, .query() collects results by default.
54
67
  // We cast to T directly as proper typing depends on the caller knowing the return structure.
55
- const result = (await pending) as unknown as T;
68
+ // "timed out" in the message is load-bearing: `classifySyncError`
69
+ // keys off it to classify this as `network` so the sync queues
70
+ // retry rather than rolling the mutation back.
71
+ const result = (await withTimeout(
72
+ pending as unknown as Promise<T>,
73
+ this.queryTimeoutMs,
74
+ `Remote query timed out after ${this.queryTimeoutMs}ms`
75
+ )) as T;
56
76
  const duration = performance.now() - startTime;
57
77
 
58
78
  // Emit query event
@@ -1,6 +1,7 @@
1
1
  export * from './database';
2
2
  export * from './local';
3
3
  export * from './remote';
4
+ export * from './connection-supervisor';
4
5
  export * from './local-migrator';
5
6
  export * from './events/index';
6
7
  export * from './cache-engine';
@@ -46,6 +46,7 @@ export class LocalMigrator {
46
46
  DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
47
  DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
48
  DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
+ DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
50
  DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
51
  DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
51
52
  `;
@@ -1,18 +1,45 @@
1
1
  import type {
2
- Diagnostic} from 'surrealdb';
2
+ Diagnostic,
3
+ SurrealEvents} from 'surrealdb';
3
4
  import {
4
5
  applyDiagnostics,
5
6
  createRemoteEngines,
6
7
  Surreal,
7
8
  } from 'surrealdb';
8
- import type { Sp00kyConfig } from '../../types';
9
+ import type { ConnectionState, ReconnectConfig, Sp00kyConfig } from '../../types';
9
10
  import type { Logger } from '../logger/index';
10
11
  import { AbstractDatabaseService } from './database';
11
12
  import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
12
13
 
14
+ /** Defaults for {@link ReconnectConfig}. See that type for the rationale. */
15
+ export const RECONNECT_DEFAULTS = {
16
+ attempts: -1,
17
+ retryDelayMax: 15_000,
18
+ heartbeatIntervalMs: 20_000,
19
+ heartbeatTimeoutMs: 10_000,
20
+ superviseRetryDelayMaxMs: 15_000,
21
+ } as const;
22
+
23
+ /** Fill in {@link RECONNECT_DEFAULTS} for every field the caller omitted. */
24
+ export function resolveReconnectConfig(
25
+ input?: ReconnectConfig
26
+ ): Required<ReconnectConfig> {
27
+ return { ...RECONNECT_DEFAULTS, ...input };
28
+ }
29
+
30
+ /** Transport events the SDK publishes, mapped 1:1 to {@link ConnectionState}. */
31
+ export type RemoteConnectionEvent = ConnectionState | 'error';
32
+
13
33
  export class RemoteDatabaseService extends AbstractDatabaseService {
14
34
  private config: Sp00kyConfig<any>['database'];
15
35
  protected eventType = DatabaseEventTypes.RemoteQuery;
36
+ private readonly reconnectConfig: Required<ReconnectConfig>;
37
+ /**
38
+ * In-flight `connect()`, so concurrent callers (boot + supervisor revive +
39
+ * an `online` event landing at the same moment) share one attempt instead of
40
+ * racing two sockets. Cleared on settle, so a later call always reconnects.
41
+ */
42
+ private connecting: Promise<void> | null = null;
16
43
 
17
44
  constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
18
45
  const events = createDatabaseEventSystem();
@@ -41,13 +68,68 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
41
68
  events
42
69
  );
43
70
  this.config = config;
71
+ this.reconnectConfig = resolveReconnectConfig(config.reconnect);
72
+ this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 60_000);
44
73
  }
45
74
 
46
75
  getConfig(): Sp00kyConfig<any>['database'] {
47
76
  return this.config;
48
77
  }
49
78
 
79
+ /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
80
+ getReconnectConfig(): Required<ReconnectConfig> {
81
+ return this.reconnectConfig;
82
+ }
83
+
84
+ /** Current transport state as reported by the SDK. */
85
+ getStatus(): ConnectionState {
86
+ return this.client.status as ConnectionState;
87
+ }
88
+
89
+ /**
90
+ * Observe transport events. Thin passthrough so callers (the supervisor,
91
+ * sync, CRDT) don't have to reach through `getClient()`.
92
+ */
93
+ subscribeConnection<K extends RemoteConnectionEvent>(
94
+ event: K,
95
+ cb: (...payload: SurrealEvents[K]) => void
96
+ ): () => void {
97
+ return this.client.subscribe(event, cb);
98
+ }
99
+
100
+ /**
101
+ * Tear the socket down on purpose. Used by the heartbeat watchdog when a
102
+ * socket stops answering but never closes: `close()` makes the SDK publish
103
+ * `disconnected`, which is what drives the supervisor's revive loop.
104
+ */
105
+ async forceClose(): Promise<void> {
106
+ try {
107
+ await this.client.close();
108
+ } catch (err) {
109
+ this.logger.debug(
110
+ { err, Category: 'sp00ky-client::RemoteDatabaseService::forceClose' },
111
+ 'forceClose failed; treating the socket as gone anyway'
112
+ );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Open (or re-open) the remote connection.
118
+ *
119
+ * Safe to call repeatedly: concurrent calls share the in-flight attempt, and
120
+ * a call after a `disconnected` builds a fresh socket. `use()` and
121
+ * `authenticate()` are re-applied here for the cold path; the SDK also
122
+ * replays them itself on its own internal reconnects.
123
+ */
50
124
  async connect(): Promise<void> {
125
+ if (this.connecting) return this.connecting;
126
+ this.connecting = this.doConnect().finally(() => {
127
+ this.connecting = null;
128
+ });
129
+ return this.connecting;
130
+ }
131
+
132
+ private async doConnect(): Promise<void> {
51
133
  const { endpoint, token, namespace, database } = this.getConfig();
52
134
  if (endpoint) {
53
135
  this.logger.info(
@@ -60,7 +142,21 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
60
142
  'Connecting to remote database'
61
143
  );
62
144
  try {
63
- await this.client.connect(endpoint);
145
+ // Without explicit options the SDK caps itself at 5 attempts (~62s of
146
+ // outage) and then gives up permanently — nothing would re-open the
147
+ // socket after that. `attempts: -1` keeps it trying; the supervisor
148
+ // still covers the case where the SDK terminates the engine because its
149
+ // post-reconnect handshake threw.
150
+ await this.client.connect(endpoint, {
151
+ reconnect: {
152
+ enabled: true,
153
+ attempts: this.reconnectConfig.attempts,
154
+ retryDelay: 1_000,
155
+ retryDelayMax: this.reconnectConfig.retryDelayMax,
156
+ retryDelayMultiplier: 2,
157
+ retryDelayJitter: 0.1,
158
+ },
159
+ });
64
160
  await this.client.use({
65
161
  namespace,
66
162
  database,
@@ -53,6 +53,7 @@ const SYSTEM_TABLES = [
53
53
  '_00_stream_processor_state',
54
54
  '_00_query',
55
55
  '_00_preload',
56
+ '_00_window',
56
57
  '_00_schema',
57
58
  '_00_pending_mutations',
58
59
  // Server-written, synced-down meta tables (see meta_tables_client.surql).