@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.320

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 (259) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15137 -2434
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +337 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1811 -38
  19. package/dist/worker-edges.js +439 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +180 -0
  32. package/src/boot/boot.saga.ts +142 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +51 -0
  46. package/src/client/services.ts +404 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +163 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +478 -0
  62. package/src/live/index.ts +1013 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +169 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +91 -0
  90. package/src/modules/ref-tables.ts +88 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +65 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/env.test.ts +23 -0
  110. package/src/query/env.ts +56 -0
  111. package/src/query/fetch.saga.test.ts +199 -0
  112. package/src/query/fetch.saga.ts +120 -0
  113. package/src/query/hash.test.ts +14 -0
  114. package/src/query/hash.ts +18 -0
  115. package/src/query/id-set-plan.test.ts +132 -0
  116. package/src/query/lifecycle.saga.test.ts +274 -0
  117. package/src/query/lifecycle.saga.ts +159 -0
  118. package/src/query/materialize.saga.test.ts +115 -0
  119. package/src/query/materialize.saga.ts +84 -0
  120. package/src/query/materialize.test.ts +58 -0
  121. package/src/query/materialize.ts +57 -0
  122. package/src/query/membership.saga.test.ts +350 -0
  123. package/src/query/membership.saga.ts +278 -0
  124. package/src/query/membership.test.ts +118 -0
  125. package/src/query/membership.ts +201 -0
  126. package/src/query/register.saga.test.ts +311 -0
  127. package/src/query/register.saga.ts +229 -0
  128. package/src/query/render-set.test.ts +47 -0
  129. package/src/query/render-set.ts +71 -0
  130. package/src/query/sql.test.ts +81 -0
  131. package/src/query/sql.ts +112 -0
  132. package/src/query/window-query.test.ts +70 -0
  133. package/src/query/window-query.ts +195 -0
  134. package/src/services/blobs/blob-cache.test.ts +359 -0
  135. package/src/services/blobs/blob-cache.ts +603 -0
  136. package/src/services/blobs/blob-manifest.ts +227 -0
  137. package/src/services/blobs/blob-store.test.ts +77 -0
  138. package/src/services/blobs/blob-store.ts +359 -0
  139. package/src/services/blobs/blob.fixture.ts +90 -0
  140. package/src/services/blobs/index.ts +70 -0
  141. package/src/services/database/cache-engine.ts +199 -0
  142. package/src/services/database/connection-supervisor.test.ts +318 -0
  143. package/src/services/database/connection-supervisor.ts +422 -0
  144. package/src/services/database/database.query-timeout.test.ts +140 -0
  145. package/src/services/database/database.responses.test.ts +43 -0
  146. package/src/services/database/database.ts +146 -61
  147. package/src/services/database/engine-factory.ts +33 -0
  148. package/src/services/database/errors.ts +34 -0
  149. package/src/services/database/events/index.ts +2 -1
  150. package/src/services/database/index.ts +7 -0
  151. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  152. package/src/services/database/local-migrator.integration.test.ts +110 -0
  153. package/src/services/database/local-migrator.ts +59 -16
  154. package/src/services/database/local.test.ts +64 -0
  155. package/src/services/database/local.ts +487 -63
  156. package/src/services/database/plan-render.test.ts +159 -0
  157. package/src/services/database/plan-render.ts +108 -0
  158. package/src/services/database/relation-resolver.test.ts +484 -0
  159. package/src/services/database/relation-resolver.ts +0 -0
  160. package/src/services/database/remote.ts +255 -6
  161. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  162. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  163. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  164. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  165. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  166. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  167. package/src/services/database/sqlite-lock-verify.ts +45 -0
  168. package/src/services/database/sqlite-open.test.ts +150 -0
  169. package/src/services/database/sqlite-open.ts +164 -0
  170. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  171. package/src/services/database/sqlite-plan-sql.ts +182 -0
  172. package/src/services/database/sqlite-projection.test.ts +121 -0
  173. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  174. package/src/services/database/sqlite-select.test.ts +261 -0
  175. package/src/services/database/sqlite-select.ts +131 -0
  176. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  177. package/src/services/database/sqlite-transport.ts +224 -0
  178. package/src/services/database/sqlite-worker.ts +437 -0
  179. package/src/services/database/surql-translate.ts +416 -0
  180. package/src/services/database/surreal-cache-engine.ts +161 -0
  181. package/src/services/logger/index.test.ts +132 -0
  182. package/src/services/logger/index.ts +189 -7
  183. package/src/services/persistence/localstorage.ts +2 -2
  184. package/src/services/persistence/resilient.ts +10 -3
  185. package/src/services/persistence/surrealdb.ts +4 -4
  186. package/src/services/stream-processor/index.ts +783 -68
  187. package/src/services/stream-processor/permissions.test.ts +47 -0
  188. package/src/services/stream-processor/permissions.ts +53 -0
  189. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  190. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  191. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  192. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  193. package/src/services/stream-processor/wasm-types.ts +58 -2
  194. package/src/services/tabs/broker-client.ts +283 -0
  195. package/src/services/tabs/broker.test.ts +327 -0
  196. package/src/services/tabs/coordinator.test.ts +386 -0
  197. package/src/services/tabs/coordinator.ts +638 -0
  198. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  199. package/src/services/tabs/leader-locks.ts +75 -0
  200. package/src/services/tabs/protocol.ts +262 -0
  201. package/src/services/tabs/support.ts +36 -0
  202. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  203. package/src/sp00ky.ts +5 -395
  204. package/src/state/client-state.ts +200 -0
  205. package/src/state/lifecycle.test.ts +141 -0
  206. package/src/state/lifecycle.ts +99 -0
  207. package/src/state/reducers.test.ts +304 -0
  208. package/src/state/reducers.ts +526 -0
  209. package/src/state/selectors.test.ts +175 -0
  210. package/src/state/selectors.ts +222 -0
  211. package/src/sw/index.test.ts +582 -0
  212. package/src/sw/index.ts +607 -0
  213. package/src/sw/store.ts +96 -0
  214. package/src/sync/connection.saga.test.ts +86 -0
  215. package/src/sync/connection.saga.ts +85 -0
  216. package/src/sync/live.saga.test.ts +178 -0
  217. package/src/sync/live.saga.ts +107 -0
  218. package/src/sync/policy.health.test.ts +56 -0
  219. package/src/sync/policy.test.ts +659 -0
  220. package/src/sync/policy.ts +502 -0
  221. package/src/sync/poll.saga.test.ts +51 -0
  222. package/src/sync/poll.saga.ts +36 -0
  223. package/src/sync/tabs.saga.test.ts +78 -0
  224. package/src/sync/tabs.saga.ts +96 -0
  225. package/src/testing/adapters.ts +133 -0
  226. package/src/testing/build.ts +63 -0
  227. package/src/testing/fake-services.ts +48 -0
  228. package/src/testing/run-pure.test.ts +92 -0
  229. package/src/testing/run-pure.ts +128 -0
  230. package/src/testing/services.ts +17 -0
  231. package/src/types.ts +624 -10
  232. package/src/utils/blurhash.ts +90 -0
  233. package/src/utils/edges.ts +39 -0
  234. package/src/utils/error-classification.test.ts +54 -0
  235. package/src/utils/error-classification.ts +25 -0
  236. package/src/utils/index.ts +78 -12
  237. package/src/utils/parser.test.ts +67 -102
  238. package/src/utils/parser.ts +64 -2
  239. package/src/utils/semver.test.ts +32 -0
  240. package/src/utils/semver.ts +30 -0
  241. package/src/utils/sha256.ts +15 -0
  242. package/src/utils/surql.ts +30 -18
  243. package/src/utils/utils.test.ts +3 -2
  244. package/tsdown.config.ts +117 -3
  245. package/vitest.config.ts +48 -0
  246. package/src/modules/cache/index.ts +0 -241
  247. package/src/modules/cache/types.ts +0 -19
  248. package/src/modules/data/data.test.ts +0 -58
  249. package/src/modules/data/index.ts +0 -879
  250. package/src/modules/sync/engine.ts +0 -173
  251. package/src/modules/sync/events/index.ts +0 -77
  252. package/src/modules/sync/index.ts +0 -3
  253. package/src/modules/sync/queue/index.ts +0 -2
  254. package/src/modules/sync/queue/queue-down.ts +0 -89
  255. package/src/modules/sync/queue/queue-up.ts +0 -223
  256. package/src/modules/sync/scheduler.ts +0 -84
  257. package/src/modules/sync/sync.ts +0 -421
  258. package/src/modules/sync/utils.test.ts +0 -311
  259. package/src/modules/sync/utils.ts +0 -167
@@ -1,18 +1,89 @@
1
+ import type {
2
+ Diagnostic,
3
+ SurrealEvents} from 'surrealdb';
1
4
  import {
2
5
  applyDiagnostics,
3
6
  createRemoteEngines,
4
- Diagnostic,
5
7
  Surreal,
6
- SurrealTransaction,
7
8
  } from 'surrealdb';
8
- import { Sp00kyConfig } from '../../types';
9
- import { Logger } from '../logger/index';
9
+ import type { ConnectionState, ReconnectConfig, Sp00kyConfig } from '../../types';
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
+
33
+ /**
34
+ * Statements the remote service keeps in flight at once. Enough for the sync
35
+ * scheduler's `MAX_CONCURRENT_DOWN` registrations plus the list_ref poll and
36
+ * an app's own one-shot reads, small enough to stay polite to the server.
37
+ */
38
+ const REMOTE_MAX_CONCURRENT_QUERIES = 6;
39
+
40
+ /**
41
+ * Session ids a page remembers for its pagehide release. Only the sessions of
42
+ * the last ttl matter; this many reconnects inside one ttl is a broken network.
43
+ */
44
+ const MAX_SESSION_IDS = 16;
45
+
13
46
  export class RemoteDatabaseService extends AbstractDatabaseService {
14
47
  private config: Sp00kyConfig<any>['database'];
15
48
  protected eventType = DatabaseEventTypes.RemoteQuery;
49
+ private readonly reconnectConfig: Required<ReconnectConfig>;
50
+ /**
51
+ * In-flight `connect()`, so concurrent callers (boot + supervisor revive +
52
+ * an `online` event landing at the same moment) share one attempt instead of
53
+ * racing two sockets. Cleared on settle, so a later call always reconnects.
54
+ */
55
+ private connecting: Promise<void> | null = null;
56
+ /**
57
+ * The token to re-authenticate a freshly opened socket with, kept current by
58
+ * {@link setAuthToken}.
59
+ *
60
+ * `config.token` is fixed at construction and most apps never set it: they
61
+ * sign in later, which authenticates the socket that happens to be open at
62
+ * the time. That is enough for the SDK's OWN reconnects (it replays
63
+ * `version`/`use`/`authenticate` itself), but not for the supervisor's revive
64
+ * loop, which builds a socket from scratch. Without this the revived socket
65
+ * came back UNAUTHENTICATED and stayed that way for the life of the page,
66
+ * while the client's `currentUserId` — restored from local storage — kept
67
+ * reporting the user as signed in.
68
+ *
69
+ * The visible damage is silent and total: `fn::query::register` sends
70
+ * `<string>($auth.id OR '')`, so every view registered afterwards is stamped
71
+ * with an empty identity, and every `$auth.id` predicate in it resolves
72
+ * false. Public tables keep returning rows while everything owned by the user
73
+ * returns nothing, on a page that still looks signed in.
74
+ */
75
+ private authToken: string | null = null;
76
+ /**
77
+ * Every `session::id()` this page's sockets have had, oldest first.
78
+ *
79
+ * The server stamps a view's `subscribers` entry with the session that
80
+ * registered or heartbeated it, and every socket (an SDK reconnect, a
81
+ * supervisor revive) is a new session. The pagehide release goes over HTTP,
82
+ * whose own session id matches none of them, so it names these instead. Kept
83
+ * for the page, not the socket: a view registered before a reconnect still
84
+ * carries the old id until its next heartbeat.
85
+ */
86
+ private sessionIdsSeen: string[] = [];
16
87
 
17
88
  constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
18
89
  const events = createDatabaseEventSystem();
@@ -41,14 +112,178 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
41
112
  events
42
113
  );
43
114
  this.config = config;
115
+ this.reconnectConfig = resolveReconnectConfig(config.reconnect);
116
+ this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 60_000);
117
+ this.maxConcurrentQueries = REMOTE_MAX_CONCURRENT_QUERIES;
118
+ this.client.subscribe('connected', () => void this.recordSessionId());
119
+ }
120
+
121
+ /** See {@link sessionIdsSeen}. */
122
+ sessionIds(): readonly string[] {
123
+ return this.sessionIdsSeen;
124
+ }
125
+
126
+ /**
127
+ * Ask the socket that just connected for its session id. Straight to the
128
+ * SDK: it needs no namespace or identity, so it need not wait behind an
129
+ * in-flight connect or take a slot from the query queue.
130
+ */
131
+ private async recordSessionId(): Promise<void> {
132
+ try {
133
+ const [sid] = (await this.client.query('RETURN <string>session::id()')) as unknown[];
134
+ if (typeof sid !== 'string' || this.sessionIdsSeen.includes(sid)) return;
135
+ this.sessionIdsSeen = [...this.sessionIdsSeen, sid].slice(-MAX_SESSION_IDS);
136
+ } catch {
137
+ // Without it this socket's views fall back to the TTL sweep.
138
+ }
44
139
  }
45
140
 
46
141
  getConfig(): Sp00kyConfig<any>['database'] {
47
142
  return this.config;
48
143
  }
49
144
 
145
+ /**
146
+ * Send one SurrealQL statement so that it survives the page going away.
147
+ *
148
+ * A WebSocket `send()` during `pagehide` is not guaranteed to flush — the
149
+ * browser may tear the socket down first, and the frame is simply lost.
150
+ * Measured: an unload-time release over the live socket reached the server
151
+ * zero times out of one. `fetch` with `keepalive` is the primitive the
152
+ * platform actually guarantees here, so this goes over SurrealDB's HTTP
153
+ * `/sql` endpoint instead of the RPC socket.
154
+ *
155
+ * Best-effort by design: no await, no retry, errors swallowed. Every caller
156
+ * must have a server-side fallback that makes a lost beacon a non-event.
157
+ *
158
+ * The statement runs in a fresh HTTP session: `session::id()` there is a new
159
+ * id, not this page's socket's, so anything keyed on it must be passed the
160
+ * ids from {@link sessionIds} instead.
161
+ */
162
+ beaconSql(sql: string): void {
163
+ try {
164
+ const { endpoint, namespace, database } = this.getConfig();
165
+ if (!endpoint || typeof fetch !== 'function') return;
166
+
167
+ // `ws(s)://host/rpc` is the socket; `http(s)://host/sql` is the same
168
+ // server's statement endpoint.
169
+ const url = new URL(endpoint);
170
+ url.protocol = url.protocol === 'wss:' ? 'https:' : url.protocol === 'ws:' ? 'http:' : url.protocol;
171
+ url.pathname = url.pathname.replace(/\/rpc\/?$/, '') + '/sql';
172
+
173
+ const headers: Record<string, string> = {
174
+ Accept: 'application/json',
175
+ 'Content-Type': 'text/plain',
176
+ };
177
+ if (namespace) headers['surreal-ns'] = namespace;
178
+ if (database) headers['surreal-db'] = database;
179
+ // Without this the statement runs unauthenticated and `$auth.id` is NONE,
180
+ // which for a per-user release means it matches nothing.
181
+ if (this.authToken) headers.Authorization = `Bearer ${this.authToken}`;
182
+
183
+ void fetch(url.toString(), {
184
+ method: 'POST',
185
+ headers,
186
+ body: sql,
187
+ keepalive: true,
188
+ credentials: 'omit',
189
+ }).catch(() => {});
190
+ } catch {
191
+ // Malformed endpoint, no fetch, blocked by CSP: the caller's fallback owns it.
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Record the token every future connect should authenticate with, or `null`
197
+ * on sign-out. See {@link authToken}.
198
+ *
199
+ * Does not touch the CURRENT socket: callers authenticate that themselves
200
+ * (sign-in and session restore both already do). This only makes the next
201
+ * from-scratch connect reproduce that state.
202
+ */
203
+ setAuthToken(token: string | null): void {
204
+ this.authToken = token;
205
+ }
206
+
207
+ /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
208
+ getReconnectConfig(): Required<ReconnectConfig> {
209
+ return this.reconnectConfig;
210
+ }
211
+
212
+ /** Current transport state as reported by the SDK. */
213
+ getStatus(): ConnectionState {
214
+ return this.client.status as ConnectionState;
215
+ }
216
+
217
+ /**
218
+ * Observe transport events. Thin passthrough so callers (the supervisor,
219
+ * sync, CRDT) don't have to reach through `getClient()`.
220
+ */
221
+ subscribeConnection<K extends RemoteConnectionEvent>(
222
+ event: K,
223
+ cb: (...payload: SurrealEvents[K]) => void
224
+ ): () => void {
225
+ return this.client.subscribe(event, cb);
226
+ }
227
+
228
+ /**
229
+ * Tear the socket down on purpose. Used by the heartbeat watchdog when a
230
+ * socket stops answering but never closes: `close()` makes the SDK publish
231
+ * `disconnected`, which is what drives the supervisor's revive loop.
232
+ */
233
+ async forceClose(): Promise<void> {
234
+ try {
235
+ await this.client.close();
236
+ } catch (err) {
237
+ this.logger.debug(
238
+ { err, Category: 'sp00ky-client::RemoteDatabaseService::forceClose' },
239
+ 'forceClose failed; treating the socket as gone anyway'
240
+ );
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Hold statements while a connect is in flight.
246
+ *
247
+ * `connect()` opens the socket, then selects the namespace/database, then
248
+ * authenticates - three round trips. The SDK considers the connection ready
249
+ * after the first, so a query queued during the other two ran on a socket
250
+ * with no namespace ("Specify a namespace to use") or, worse, no identity.
251
+ * Seen 2026-09-06 after the supervisor rebuilt a socket: a collection
252
+ * recount failed with exactly that error. Waiting on the in-flight connect
253
+ * costs nothing when no connect is running; a failed connect is not
254
+ * swallowed here - the query then fails on its own, as before.
255
+ */
256
+ protected override async beforeQuery(): Promise<void> {
257
+ const inflight = this.connecting;
258
+ if (!inflight) return;
259
+ try {
260
+ await inflight;
261
+ } catch {
262
+ // The query that follows reports the failure itself.
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Open (or re-open) the remote connection.
268
+ *
269
+ * Safe to call repeatedly: concurrent calls share the in-flight attempt, and
270
+ * a call after a `disconnected` builds a fresh socket. `use()` and
271
+ * `authenticate()` are re-applied here for the cold path; the SDK also
272
+ * replays them itself on its own internal reconnects.
273
+ */
50
274
  async connect(): Promise<void> {
51
- const { endpoint, token, namespace, database } = this.getConfig();
275
+ if (this.connecting) return this.connecting;
276
+ this.connecting = this.doConnect().finally(() => {
277
+ this.connecting = null;
278
+ });
279
+ return this.connecting;
280
+ }
281
+
282
+ private async doConnect(): Promise<void> {
283
+ const { endpoint, namespace, database } = this.getConfig();
284
+ // The live token wins over the constructor-time one: this connect may be
285
+ // the supervisor rebuilding a socket long after sign-in. See `authToken`.
286
+ const token = this.authToken ?? this.getConfig().token;
52
287
  if (endpoint) {
53
288
  this.logger.info(
54
289
  {
@@ -60,7 +295,21 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
60
295
  'Connecting to remote database'
61
296
  );
62
297
  try {
63
- await this.client.connect(endpoint);
298
+ // Without explicit options the SDK caps itself at 5 attempts (~62s of
299
+ // outage) and then gives up permanently — nothing would re-open the
300
+ // socket after that. `attempts: -1` keeps it trying; the supervisor
301
+ // still covers the case where the SDK terminates the engine because its
302
+ // post-reconnect handshake threw.
303
+ await this.client.connect(endpoint, {
304
+ reconnect: {
305
+ enabled: true,
306
+ attempts: this.reconnectConfig.attempts,
307
+ retryDelay: 1_000,
308
+ retryDelayMax: this.reconnectConfig.retryDelayMax,
309
+ retryDelayMultiplier: 2,
310
+ retryDelayJitter: 0.1,
311
+ },
312
+ });
64
313
  await this.client.use({
65
314
  namespace,
66
315
  database,