@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

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