@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.
package/dist/index.js CHANGED
@@ -9,12 +9,260 @@ import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
9
9
  /** Cap on the rolling materialization-sample window kept per query in memory. */
10
10
  const MATERIALIZATION_SAMPLE_WINDOW = 100;
11
11
 
12
+ //#endregion
13
+ //#region src/utils/surql.ts
14
+ const surql = {
15
+ seal(query, options) {
16
+ if (typeof query === "string") return `${query};`;
17
+ const txQuery = query;
18
+ const idx = options?.resultIndex ?? txQuery.statementCount - 1;
19
+ return {
20
+ sql: `${txQuery.sql};`,
21
+ extract(results) {
22
+ return results[idx + 1];
23
+ }
24
+ };
25
+ },
26
+ tx(queries) {
27
+ return {
28
+ __brand: "TxQuery",
29
+ sql: `BEGIN TRANSACTION;\n${queries.join(";")};\nCOMMIT TRANSACTION`,
30
+ statementCount: queries.length
31
+ };
32
+ },
33
+ selectById(idVar, returnValues) {
34
+ return `SELECT ${returnValues.join(",")} FROM ONLY $${idVar}`;
35
+ },
36
+ selectByFieldsAnd(table, whereVar, returnValues) {
37
+ return `SELECT ${returnValues.map((rv) => typeof rv === "string" ? rv : `${rv.field} as ${rv.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((wv) => typeof wv === "string" ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`).join(" AND ")}`;
38
+ },
39
+ create(idVar, dataVar) {
40
+ return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
41
+ },
42
+ createSet(idVar, keyDataVars) {
43
+ return `CREATE ONLY $${idVar} SET ${keyDataVars.map((keyDataVar) => typeof keyDataVar === "string" ? `${keyDataVar} = $${keyDataVar}` : "statement" in keyDataVar ? keyDataVar.statement : `${keyDataVar.key} = $${keyDataVar.variable}`).join(", ")}`;
44
+ },
45
+ upsert(idVar, dataVar) {
46
+ return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
47
+ },
48
+ upsertMerge(idVar, dataVar) {
49
+ return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
50
+ },
51
+ updateMerge(idVar, dataVar) {
52
+ return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
53
+ },
54
+ updateSet(idVar, keyDataVar) {
55
+ return `UPDATE $${idVar} SET ${keyDataVar.map((kdv) => typeof kdv === "string" ? `${kdv} = $${kdv}` : "statement" in kdv ? kdv.statement : `${kdv.key} = $${kdv.variable}`).join(", ")}`;
56
+ },
57
+ delete(idVar) {
58
+ return `DELETE $${idVar}`;
59
+ },
60
+ let(name, query) {
61
+ return `LET $${name} = (${query})`;
62
+ },
63
+ createMutation(t, mutationIdVar, recordIdVar, dataVar, beforeRecordVar) {
64
+ switch (t) {
65
+ case "create": return dataVar ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}` : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
66
+ case "update": {
67
+ let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
68
+ if (beforeRecordVar) stmt += `, beforeRecord = $${beforeRecordVar}`;
69
+ return stmt;
70
+ }
71
+ case "delete": return `CREATE ONLY $${mutationIdVar} SET mutationType = 'delete', recordId = $${recordIdVar}`;
72
+ }
73
+ },
74
+ returnObject(entries) {
75
+ return `RETURN {${entries.map(({ key, variable }) => `${key}: $${variable}`).join(",")}}`;
76
+ }
77
+ };
78
+
79
+ //#endregion
80
+ //#region src/utils/parser.ts
81
+ function cleanRecord(tableSchema, record) {
82
+ const cleaned = {};
83
+ for (const [key, value] of Object.entries(record)) if (key === "id" || key.startsWith("_00_") || key in tableSchema) cleaned[key] = value;
84
+ return cleaned;
85
+ }
86
+ function parseParams(tableSchema, params) {
87
+ const parsedParams = {};
88
+ for (const [key, value] of Object.entries(params)) {
89
+ const column = tableSchema[key];
90
+ if (column && value !== void 0) parsedParams[key] = parseValue(key, column, value);
91
+ }
92
+ return parsedParams;
93
+ }
94
+ function parseValue(name, column, value) {
95
+ if (column.recordId) {
96
+ if (value instanceof RecordId$1) return value;
97
+ if (typeof value === "string") return parseRecordIdString(value);
98
+ throw new Error(`Invalid value for ${name}: ${value}`);
99
+ }
100
+ if (column.dateTime) {
101
+ if (value instanceof Date) return value;
102
+ if (value instanceof DateTime) return value.toDate();
103
+ if (typeof value === "number" || typeof value === "string") return new Date(value);
104
+ throw new Error(`Invalid value for ${name}: ${value}`);
105
+ }
106
+ return value;
107
+ }
108
+
109
+ //#endregion
110
+ //#region src/utils/error-classification.ts
111
+ const NETWORK_ERROR_PATTERNS = [
112
+ "connection",
113
+ "must be connected",
114
+ "connectionunavailable",
115
+ "timeout",
116
+ "timed out",
117
+ "websocket",
118
+ "fetch failed",
119
+ "disconnected",
120
+ "socket",
121
+ "network",
122
+ "econnrefused",
123
+ "econnreset",
124
+ "enotfound",
125
+ "epipe",
126
+ "abort"
127
+ ];
128
+ function classifySyncError(error) {
129
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
130
+ for (const pattern of NETWORK_ERROR_PATTERNS) if (message.includes(pattern)) return "network";
131
+ return "application";
132
+ }
133
+
134
+ //#endregion
135
+ //#region src/utils/index.ts
136
+ const encodeRecordId = (recordId) => {
137
+ return `${recordId.table.toString()}:${recordId.id}`;
138
+ };
139
+ const extractIdPart = (id) => {
140
+ if (typeof id === "string") return id.split(":").slice(1).join(":");
141
+ const idValue = id.id;
142
+ if (typeof idValue === "string") return idValue;
143
+ return String(idValue);
144
+ };
145
+ const extractTablePart = (id) => {
146
+ if (typeof id === "string") return id.split(":")[0];
147
+ return id.table.toString();
148
+ };
149
+ const parseRecordIdString = (id) => {
150
+ const [table, ...idParts] = id.split(":");
151
+ return new RecordId(table, idParts.join(":"));
152
+ };
153
+ function generateId() {
154
+ return Uuid.v4().toString().replace(/-/g, "");
155
+ }
156
+ /**
157
+ * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
158
+ * releases expose it under `milliseconds` (current) or the older private
159
+ * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
160
+ */
161
+ function durationMillis(duration) {
162
+ const d = duration;
163
+ return Number(d.milliseconds || d._milliseconds || 0);
164
+ }
165
+ /**
166
+ * Parse duration string or Duration object to milliseconds
167
+ */
168
+ function parseDuration(duration) {
169
+ if (duration instanceof Duration) {
170
+ const ms = durationMillis(duration);
171
+ if (ms) return ms;
172
+ const str = duration.toString();
173
+ if (str !== "[object Object]") return parseDuration(str);
174
+ return 6e5;
175
+ }
176
+ if (typeof duration === "bigint") return Number(duration);
177
+ if (typeof duration !== "string") return 6e5;
178
+ const match = duration.match(/^(\d+)([smh])$/);
179
+ if (!match) return 6e5;
180
+ const val = Number.parseInt(match[1], 10);
181
+ switch (match[2]) {
182
+ case "s": return val * 1e3;
183
+ case "h": return val * 36e5;
184
+ default: return val * 6e4;
185
+ }
186
+ }
187
+ async function fileToUint8Array(file) {
188
+ const buffer = await file.arrayBuffer();
189
+ return new Uint8Array(buffer);
190
+ }
191
+ /**
192
+ * Convert plain text to simple HTML paragraphs.
193
+ * Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
194
+ */
195
+ function textToHtml(text) {
196
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").split("\n").map((l) => `<p>${l || "<br>"}</p>`).join("");
197
+ }
198
+ /**
199
+ * Helper for retrying DB operations with exponential backoff
200
+ */
201
+ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
202
+ let lastError;
203
+ for (let i = 0; i < retries; i++) try {
204
+ return await operation();
205
+ } catch (err) {
206
+ lastError = err;
207
+ if (err?.message?.includes("Can not open transaction") || err?.message?.includes("transaction") || err?.message?.includes("Database is busy")) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ logger.warn({
210
+ attempt: i + 1,
211
+ retries,
212
+ error: msg,
213
+ Category: "sp00ky-client::utils::withRetry"
214
+ }, "Retrying DB operation");
215
+ await new Promise((res) => setTimeout(res, delayMs * (i + 1)));
216
+ continue;
217
+ }
218
+ throw err;
219
+ }
220
+ throw lastError;
221
+ }
222
+ /**
223
+ * Reject after `timeoutMs` if `promise` hasn't settled.
224
+ *
225
+ * Exists because a WebSocket RPC has no deadline of its own: the SurrealDB SDK
226
+ * parks each call in a pending map and only rejects it when the socket reports
227
+ * a close. On a half-open socket (peer gone, no `close` event, `readyState`
228
+ * still OPEN) the call never settles at all, which wedges anything serialized
229
+ * behind it.
230
+ *
231
+ * `message` MUST contain "timed out" so `classifySyncError` treats the
232
+ * rejection as `network` — that's what makes the sync queues retry the
233
+ * operation instead of rolling the mutation back as an application error.
234
+ *
235
+ * Non-positive `timeoutMs` returns `promise` unchanged. The underlying promise
236
+ * is NOT cancelled (nothing can cancel an in-flight RPC); its eventual
237
+ * settlement is absorbed, so attach no expectations to it after a timeout.
238
+ */
239
+ function withTimeout(promise, timeoutMs, message) {
240
+ if (!(timeoutMs > 0)) return promise;
241
+ return new Promise((resolve, reject) => {
242
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
243
+ promise.then((value) => {
244
+ clearTimeout(timer);
245
+ resolve(value);
246
+ }, (err) => {
247
+ clearTimeout(timer);
248
+ reject(err);
249
+ });
250
+ });
251
+ }
252
+
12
253
  //#endregion
13
254
  //#region src/services/database/database.ts
14
255
  var AbstractDatabaseService = class {
15
256
  client;
16
257
  logger;
17
258
  events;
259
+ /**
260
+ * Per-query deadline in ms; `0` disables. Only the remote service sets this
261
+ * (see `RemoteDatabaseService`) — a local query can be legitimately slow and
262
+ * has its own retry ladders, and there is no half-open-socket failure mode
263
+ * for an in-process engine.
264
+ */
265
+ queryTimeoutMs = 0;
18
266
  constructor(client, logger, events) {
19
267
  this.client = client;
20
268
  this.logger = logger.child({ service: "Database" });
@@ -32,6 +280,11 @@ var AbstractDatabaseService = class {
32
280
  queryQueue = Promise.resolve();
33
281
  /**
34
282
  * Execute a query with serialized execution to prevent WASM transaction issues.
283
+ *
284
+ * Serialization means every query waits on the previous one, so a call that
285
+ * never settles blocks the whole chain forever. {@link queryTimeoutMs} bounds
286
+ * each link: on expiry this promise rejects and the chain moves on, even
287
+ * though the underlying RPC is still parked in the SDK's pending map.
35
288
  */
36
289
  async query(query, vars) {
37
290
  return new Promise((resolve, reject) => {
@@ -43,7 +296,7 @@ var AbstractDatabaseService = class {
43
296
  vars,
44
297
  Category: "sp00ky-client::Database::query"
45
298
  }, "Executing query");
46
- const result = await this.client.query(query, vars);
299
+ const result = await withTimeout(this.client.query(query, vars), this.queryTimeoutMs, `Remote query timed out after ${this.queryTimeoutMs}ms`);
47
300
  const duration = performance.now() - startTime;
48
301
  this.events.emit(this.eventType, {
49
302
  query,
@@ -244,217 +497,6 @@ function createDatabaseEventSystem() {
244
497
  return createEventSystem([DatabaseEventTypes.LocalQuery, DatabaseEventTypes.RemoteQuery]);
245
498
  }
246
499
 
247
- //#endregion
248
- //#region src/utils/surql.ts
249
- const surql = {
250
- seal(query, options) {
251
- if (typeof query === "string") return `${query};`;
252
- const txQuery = query;
253
- const idx = options?.resultIndex ?? txQuery.statementCount - 1;
254
- return {
255
- sql: `${txQuery.sql};`,
256
- extract(results) {
257
- return results[idx + 1];
258
- }
259
- };
260
- },
261
- tx(queries) {
262
- return {
263
- __brand: "TxQuery",
264
- sql: `BEGIN TRANSACTION;\n${queries.join(";")};\nCOMMIT TRANSACTION`,
265
- statementCount: queries.length
266
- };
267
- },
268
- selectById(idVar, returnValues) {
269
- return `SELECT ${returnValues.join(",")} FROM ONLY $${idVar}`;
270
- },
271
- selectByFieldsAnd(table, whereVar, returnValues) {
272
- return `SELECT ${returnValues.map((rv) => typeof rv === "string" ? rv : `${rv.field} as ${rv.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((wv) => typeof wv === "string" ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`).join(" AND ")}`;
273
- },
274
- create(idVar, dataVar) {
275
- return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
276
- },
277
- createSet(idVar, keyDataVars) {
278
- return `CREATE ONLY $${idVar} SET ${keyDataVars.map((keyDataVar) => typeof keyDataVar === "string" ? `${keyDataVar} = $${keyDataVar}` : "statement" in keyDataVar ? keyDataVar.statement : `${keyDataVar.key} = $${keyDataVar.variable}`).join(", ")}`;
279
- },
280
- upsert(idVar, dataVar) {
281
- return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
282
- },
283
- upsertMerge(idVar, dataVar) {
284
- return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
285
- },
286
- updateMerge(idVar, dataVar) {
287
- return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
288
- },
289
- updateSet(idVar, keyDataVar) {
290
- return `UPDATE $${idVar} SET ${keyDataVar.map((kdv) => typeof kdv === "string" ? `${kdv} = $${kdv}` : "statement" in kdv ? kdv.statement : `${kdv.key} = $${kdv.variable}`).join(", ")}`;
291
- },
292
- delete(idVar) {
293
- return `DELETE $${idVar}`;
294
- },
295
- let(name, query) {
296
- return `LET $${name} = (${query})`;
297
- },
298
- createMutation(t, mutationIdVar, recordIdVar, dataVar, beforeRecordVar) {
299
- switch (t) {
300
- case "create": return dataVar ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}` : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
301
- case "update": {
302
- let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
303
- if (beforeRecordVar) stmt += `, beforeRecord = $${beforeRecordVar}`;
304
- return stmt;
305
- }
306
- case "delete": return `CREATE ONLY $${mutationIdVar} SET mutationType = 'delete', recordId = $${recordIdVar}`;
307
- }
308
- },
309
- returnObject(entries) {
310
- return `RETURN {${entries.map(({ key, variable }) => `${key}: $${variable}`).join(",")}}`;
311
- }
312
- };
313
-
314
- //#endregion
315
- //#region src/utils/parser.ts
316
- function cleanRecord(tableSchema, record) {
317
- const cleaned = {};
318
- for (const [key, value] of Object.entries(record)) if (key === "id" || key.startsWith("_00_") || key in tableSchema) cleaned[key] = value;
319
- return cleaned;
320
- }
321
- function parseParams(tableSchema, params) {
322
- const parsedParams = {};
323
- for (const [key, value] of Object.entries(params)) {
324
- const column = tableSchema[key];
325
- if (column && value !== void 0) parsedParams[key] = parseValue(key, column, value);
326
- }
327
- return parsedParams;
328
- }
329
- function parseValue(name, column, value) {
330
- if (column.recordId) {
331
- if (value instanceof RecordId$1) return value;
332
- if (typeof value === "string") return parseRecordIdString(value);
333
- throw new Error(`Invalid value for ${name}: ${value}`);
334
- }
335
- if (column.dateTime) {
336
- if (value instanceof Date) return value;
337
- if (value instanceof DateTime) return value.toDate();
338
- if (typeof value === "number" || typeof value === "string") return new Date(value);
339
- throw new Error(`Invalid value for ${name}: ${value}`);
340
- }
341
- return value;
342
- }
343
-
344
- //#endregion
345
- //#region src/utils/error-classification.ts
346
- const NETWORK_ERROR_PATTERNS = [
347
- "connection",
348
- "must be connected",
349
- "connectionunavailable",
350
- "timeout",
351
- "timed out",
352
- "websocket",
353
- "fetch failed",
354
- "disconnected",
355
- "socket",
356
- "network",
357
- "econnrefused",
358
- "econnreset",
359
- "enotfound",
360
- "epipe",
361
- "abort"
362
- ];
363
- function classifySyncError(error) {
364
- const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
365
- for (const pattern of NETWORK_ERROR_PATTERNS) if (message.includes(pattern)) return "network";
366
- return "application";
367
- }
368
-
369
- //#endregion
370
- //#region src/utils/index.ts
371
- const encodeRecordId = (recordId) => {
372
- return `${recordId.table.toString()}:${recordId.id}`;
373
- };
374
- const extractIdPart = (id) => {
375
- if (typeof id === "string") return id.split(":").slice(1).join(":");
376
- const idValue = id.id;
377
- if (typeof idValue === "string") return idValue;
378
- return String(idValue);
379
- };
380
- const extractTablePart = (id) => {
381
- if (typeof id === "string") return id.split(":")[0];
382
- return id.table.toString();
383
- };
384
- const parseRecordIdString = (id) => {
385
- const [table, ...idParts] = id.split(":");
386
- return new RecordId(table, idParts.join(":"));
387
- };
388
- function generateId() {
389
- return Uuid.v4().toString().replace(/-/g, "");
390
- }
391
- /**
392
- * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
393
- * releases expose it under `milliseconds` (current) or the older private
394
- * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
395
- */
396
- function durationMillis(duration) {
397
- const d = duration;
398
- return Number(d.milliseconds || d._milliseconds || 0);
399
- }
400
- /**
401
- * Parse duration string or Duration object to milliseconds
402
- */
403
- function parseDuration(duration) {
404
- if (duration instanceof Duration) {
405
- const ms = durationMillis(duration);
406
- if (ms) return ms;
407
- const str = duration.toString();
408
- if (str !== "[object Object]") return parseDuration(str);
409
- return 6e5;
410
- }
411
- if (typeof duration === "bigint") return Number(duration);
412
- if (typeof duration !== "string") return 6e5;
413
- const match = duration.match(/^(\d+)([smh])$/);
414
- if (!match) return 6e5;
415
- const val = Number.parseInt(match[1], 10);
416
- switch (match[2]) {
417
- case "s": return val * 1e3;
418
- case "h": return val * 36e5;
419
- default: return val * 6e4;
420
- }
421
- }
422
- async function fileToUint8Array(file) {
423
- const buffer = await file.arrayBuffer();
424
- return new Uint8Array(buffer);
425
- }
426
- /**
427
- * Convert plain text to simple HTML paragraphs.
428
- * Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
429
- */
430
- function textToHtml(text) {
431
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").split("\n").map((l) => `<p>${l || "<br>"}</p>`).join("");
432
- }
433
- /**
434
- * Helper for retrying DB operations with exponential backoff
435
- */
436
- async function withRetry(logger, operation, retries = 3, delayMs = 100) {
437
- let lastError;
438
- for (let i = 0; i < retries; i++) try {
439
- return await operation();
440
- } catch (err) {
441
- lastError = err;
442
- if (err?.message?.includes("Can not open transaction") || err?.message?.includes("transaction") || err?.message?.includes("Database is busy")) {
443
- const msg = err instanceof Error ? err.message : String(err);
444
- logger.warn({
445
- attempt: i + 1,
446
- retries,
447
- error: msg,
448
- Category: "sp00ky-client::utils::withRetry"
449
- }, "Retrying DB operation");
450
- await new Promise((res) => setTimeout(res, delayMs * (i + 1)));
451
- continue;
452
- }
453
- throw err;
454
- }
455
- throw lastError;
456
- }
457
-
458
500
  //#endregion
459
501
  //#region src/modules/ref-tables.ts
460
502
  /**
@@ -864,9 +906,31 @@ function deleteIndexedDb(name) {
864
906
 
865
907
  //#endregion
866
908
  //#region src/services/database/remote.ts
909
+ /** Defaults for {@link ReconnectConfig}. See that type for the rationale. */
910
+ const RECONNECT_DEFAULTS = {
911
+ attempts: -1,
912
+ retryDelayMax: 15e3,
913
+ heartbeatIntervalMs: 2e4,
914
+ heartbeatTimeoutMs: 1e4,
915
+ superviseRetryDelayMaxMs: 15e3
916
+ };
917
+ /** Fill in {@link RECONNECT_DEFAULTS} for every field the caller omitted. */
918
+ function resolveReconnectConfig(input) {
919
+ return {
920
+ ...RECONNECT_DEFAULTS,
921
+ ...input
922
+ };
923
+ }
867
924
  var RemoteDatabaseService = class extends AbstractDatabaseService {
868
925
  config;
869
926
  eventType = DatabaseEventTypes.RemoteQuery;
927
+ reconnectConfig;
928
+ /**
929
+ * In-flight `connect()`, so concurrent callers (boot + supervisor revive +
930
+ * an `online` event landing at the same moment) share one attempt instead of
931
+ * racing two sockets. Cleared on settle, so a later call always reconnects.
932
+ */
933
+ connecting = null;
870
934
  constructor(config, logger) {
871
935
  const events = createDatabaseEventSystem();
872
936
  super(new Surreal({ engines: applyDiagnostics(createRemoteEngines(), ({ key, type, phase, ...other }) => {
@@ -880,11 +944,58 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
880
944
  }, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
881
945
  }) }), logger, events);
882
946
  this.config = config;
947
+ this.reconnectConfig = resolveReconnectConfig(config.reconnect);
948
+ this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 6e4);
949
+ }
950
+ getConfig() {
951
+ return this.config;
952
+ }
953
+ /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
954
+ getReconnectConfig() {
955
+ return this.reconnectConfig;
956
+ }
957
+ /** Current transport state as reported by the SDK. */
958
+ getStatus() {
959
+ return this.client.status;
960
+ }
961
+ /**
962
+ * Observe transport events. Thin passthrough so callers (the supervisor,
963
+ * sync, CRDT) don't have to reach through `getClient()`.
964
+ */
965
+ subscribeConnection(event, cb) {
966
+ return this.client.subscribe(event, cb);
883
967
  }
884
- getConfig() {
885
- return this.config;
968
+ /**
969
+ * Tear the socket down on purpose. Used by the heartbeat watchdog when a
970
+ * socket stops answering but never closes: `close()` makes the SDK publish
971
+ * `disconnected`, which is what drives the supervisor's revive loop.
972
+ */
973
+ async forceClose() {
974
+ try {
975
+ await this.client.close();
976
+ } catch (err) {
977
+ this.logger.debug({
978
+ err,
979
+ Category: "sp00ky-client::RemoteDatabaseService::forceClose"
980
+ }, "forceClose failed; treating the socket as gone anyway");
981
+ }
886
982
  }
983
+ /**
984
+ * Open (or re-open) the remote connection.
985
+ *
986
+ * Safe to call repeatedly: concurrent calls share the in-flight attempt, and
987
+ * a call after a `disconnected` builds a fresh socket. `use()` and
988
+ * `authenticate()` are re-applied here for the cold path; the SDK also
989
+ * replays them itself on its own internal reconnects.
990
+ */
887
991
  async connect() {
992
+ if (this.connecting) return this.connecting;
993
+ this.connecting = this.doConnect().finally(() => {
994
+ this.connecting = null;
995
+ });
996
+ return this.connecting;
997
+ }
998
+ async doConnect() {
888
999
  const { endpoint, token, namespace, database } = this.getConfig();
889
1000
  if (endpoint) {
890
1001
  this.logger.info({
@@ -894,7 +1005,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
894
1005
  Category: "sp00ky-client::RemoteDatabaseService::connect"
895
1006
  }, "Connecting to remote database");
896
1007
  try {
897
- await this.client.connect(endpoint);
1008
+ await this.client.connect(endpoint, { reconnect: {
1009
+ enabled: true,
1010
+ attempts: this.reconnectConfig.attempts,
1011
+ retryDelay: 1e3,
1012
+ retryDelayMax: this.reconnectConfig.retryDelayMax,
1013
+ retryDelayMultiplier: 2,
1014
+ retryDelayJitter: .1
1015
+ } });
898
1016
  await this.client.use({
899
1017
  namespace,
900
1018
  database
@@ -927,6 +1045,276 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
927
1045
  }
928
1046
  };
929
1047
 
1048
+ //#endregion
1049
+ //#region src/services/database/connection-supervisor.ts
1050
+ /**
1051
+ * Keeps the remote WebSocket alive for the whole life of the page.
1052
+ *
1053
+ * The SurrealDB SDK reconnects on its own after a socket `close`, but that
1054
+ * covers only one of three ways the connection dies:
1055
+ *
1056
+ * 1. **Socket closes, SDK recovers.** Handled entirely by the SDK. This
1057
+ * supervisor only observes it (to report `reconnecting` upward).
1058
+ * 2. **Socket closes, SDK gives up.** With `attempts: -1` this shouldn't happen
1059
+ * from exhaustion — but the SDK also terminates the engine permanently when
1060
+ * its post-reconnect handshake throws (it re-runs `version()`, `use()`,
1061
+ * `authenticate()` on every reconnect and closes the engine on any error).
1062
+ * One transient hiccup there would otherwise kill the page's connection for
1063
+ * good. The revive loop re-opens from scratch.
1064
+ * 3. **Socket never closes at all.** A half-open connection: the peer is gone
1065
+ * (NAT timeout, wifi switch, laptop sleep) but no FIN ever arrives, so
1066
+ * `readyState` stays OPEN and the SDK's own 30s ping — fire-and-forget, no
1067
+ * response deadline — never notices. Nothing ever fires a `close` event, so
1068
+ * nothing ever triggers a reconnect. The heartbeat detects this and forces
1069
+ * the teardown that case 2's loop then repairs.
1070
+ *
1071
+ * Plus wake triggers: coming back `online` or un-hiding the tab probes
1072
+ * immediately rather than waiting out a backoff that was scheduled while the
1073
+ * network was known-down.
1074
+ */
1075
+ var ConnectionSupervisor = class ConnectionSupervisor {
1076
+ logger;
1077
+ config;
1078
+ state = "disconnected";
1079
+ subscribers = /* @__PURE__ */ new Set();
1080
+ started = false;
1081
+ disposed = false;
1082
+ heartbeatTimer = null;
1083
+ heartbeatInFlight = false;
1084
+ reviveTimer = null;
1085
+ reviveAttempts = 0;
1086
+ reviving = false;
1087
+ /**
1088
+ * Set while the browser reports itself offline. Retrying a socket against a
1089
+ * down interface only burns backoff, so the loop parks until `online` fires.
1090
+ */
1091
+ suspended = false;
1092
+ teardown = [];
1093
+ static REVIVE_BASE_MS = 1e3;
1094
+ constructor(remote, logger, config) {
1095
+ this.remote = remote;
1096
+ this.logger = logger.child({ service: "ConnectionSupervisor" });
1097
+ this.config = config ?? remote.getReconnectConfig();
1098
+ }
1099
+ /** Latest observed transport state. */
1100
+ get connection() {
1101
+ return this.state;
1102
+ }
1103
+ /**
1104
+ * Observe transport state. Fires immediately with the current value and again
1105
+ * on every change. Returns an unsubscribe.
1106
+ */
1107
+ subscribe(cb) {
1108
+ cb(this.state);
1109
+ this.subscribers.add(cb);
1110
+ return () => {
1111
+ this.subscribers.delete(cb);
1112
+ };
1113
+ }
1114
+ /**
1115
+ * Begin supervising. Call once, after the initial {@link
1116
+ * RemoteDatabaseService.connect}. Idempotent.
1117
+ */
1118
+ start() {
1119
+ if (this.started || this.disposed) return;
1120
+ this.started = true;
1121
+ this.setState(this.remote.getStatus());
1122
+ this.teardown.push(this.remote.subscribeConnection("connecting", () => this.setState("connecting")), this.remote.subscribeConnection("reconnecting", () => {
1123
+ this.setState("reconnecting");
1124
+ this.stopHeartbeat();
1125
+ }), this.remote.subscribeConnection("connected", () => {
1126
+ this.reviveAttempts = 0;
1127
+ this.clearReviveTimer();
1128
+ this.setState("connected");
1129
+ this.startHeartbeat();
1130
+ }), this.remote.subscribeConnection("disconnected", () => {
1131
+ this.setState("disconnected");
1132
+ this.stopHeartbeat();
1133
+ this.scheduleRevive();
1134
+ }), this.remote.subscribeConnection("error", (err) => {
1135
+ this.logger.debug({
1136
+ err,
1137
+ Category: "sp00ky-client::ConnectionSupervisor::error"
1138
+ }, "Transport error");
1139
+ }));
1140
+ this.installWakeTriggers();
1141
+ if (this.state === "connected") this.startHeartbeat();
1142
+ else this.scheduleRevive();
1143
+ }
1144
+ /** Stop all timers and listeners. Safe to call more than once. */
1145
+ dispose() {
1146
+ this.disposed = true;
1147
+ this.started = false;
1148
+ this.stopHeartbeat();
1149
+ this.clearReviveTimer();
1150
+ for (const off of this.teardown) try {
1151
+ off();
1152
+ } catch {}
1153
+ this.teardown = [];
1154
+ this.subscribers.clear();
1155
+ }
1156
+ setState(next) {
1157
+ if (this.state === next) return;
1158
+ this.state = next;
1159
+ this.logger.info({
1160
+ state: next,
1161
+ Category: "sp00ky-client::ConnectionSupervisor::state"
1162
+ }, "Connection state changed");
1163
+ for (const cb of this.subscribers) try {
1164
+ cb(next);
1165
+ } catch (err) {
1166
+ this.logger.debug({
1167
+ err,
1168
+ Category: "sp00ky-client::ConnectionSupervisor::state"
1169
+ }, "Connection subscriber threw");
1170
+ }
1171
+ }
1172
+ clearReviveTimer() {
1173
+ if (this.reviveTimer !== null) {
1174
+ clearTimeout(this.reviveTimer);
1175
+ this.reviveTimer = null;
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Queue the next `connect()` attempt on exponential backoff, capped at
1180
+ * `superviseRetryDelayMaxMs`. Never gives up — the page is expected to
1181
+ * outlive any outage.
1182
+ */
1183
+ scheduleRevive() {
1184
+ if (this.disposed || this.suspended) return;
1185
+ if (this.reviveTimer !== null || this.reviving) return;
1186
+ const delay = Math.min(this.config.superviseRetryDelayMaxMs, ConnectionSupervisor.REVIVE_BASE_MS * 2 ** this.reviveAttempts);
1187
+ this.reviveTimer = setTimeout(() => {
1188
+ this.reviveTimer = null;
1189
+ this.revive();
1190
+ }, delay);
1191
+ }
1192
+ async revive() {
1193
+ if (this.disposed || this.suspended || this.reviving) return;
1194
+ if (this.remote.getStatus() === "connected") {
1195
+ this.reviveAttempts = 0;
1196
+ return;
1197
+ }
1198
+ this.reviving = true;
1199
+ this.reviveAttempts++;
1200
+ this.setState("reconnecting");
1201
+ this.logger.info({
1202
+ attempt: this.reviveAttempts,
1203
+ Category: "sp00ky-client::ConnectionSupervisor::revive"
1204
+ }, "Re-opening the remote connection");
1205
+ try {
1206
+ await this.remote.connect();
1207
+ } catch (err) {
1208
+ this.logger.warn({
1209
+ err,
1210
+ attempt: this.reviveAttempts,
1211
+ Category: "sp00ky-client::ConnectionSupervisor::revive"
1212
+ }, "Reconnect attempt failed; will retry");
1213
+ } finally {
1214
+ this.reviving = false;
1215
+ }
1216
+ if (this.remote.getStatus() !== "connected") this.scheduleRevive();
1217
+ }
1218
+ stopHeartbeat() {
1219
+ if (this.heartbeatTimer !== null) {
1220
+ clearTimeout(this.heartbeatTimer);
1221
+ this.heartbeatTimer = null;
1222
+ }
1223
+ }
1224
+ startHeartbeat() {
1225
+ this.stopHeartbeat();
1226
+ if (this.disposed || this.suspended) return;
1227
+ if (!(this.config.heartbeatIntervalMs > 0)) return;
1228
+ this.heartbeatTimer = setTimeout(() => void this.beat(), this.config.heartbeatIntervalMs);
1229
+ }
1230
+ /**
1231
+ * Probe the server end-to-end. Deliberately goes through
1232
+ * `remote.query` — the same serialized queue every other remote call uses —
1233
+ * so a queue wedged behind a stuck RPC also fails the heartbeat instead of
1234
+ * being invisible to it.
1235
+ */
1236
+ async beat() {
1237
+ this.heartbeatTimer = null;
1238
+ if (this.disposed || this.suspended) return;
1239
+ if (this.remote.getStatus() !== "connected") return;
1240
+ if (this.heartbeatInFlight) {
1241
+ this.startHeartbeat();
1242
+ return;
1243
+ }
1244
+ this.heartbeatInFlight = true;
1245
+ try {
1246
+ await withTimeout(this.remote.query("RETURN true"), this.config.heartbeatTimeoutMs, `Heartbeat timed out after ${this.config.heartbeatTimeoutMs}ms`);
1247
+ this.startHeartbeat();
1248
+ } catch (err) {
1249
+ this.logger.warn({
1250
+ err,
1251
+ Category: "sp00ky-client::ConnectionSupervisor::heartbeat"
1252
+ }, "Heartbeat failed; tearing the socket down to force a reconnect");
1253
+ await this.remote.forceClose();
1254
+ if (this.remote.getStatus() !== "connected") this.scheduleRevive();
1255
+ } finally {
1256
+ this.heartbeatInFlight = false;
1257
+ }
1258
+ }
1259
+ /**
1260
+ * A restored network or an un-hidden tab is the strongest available hint that
1261
+ * a reconnect will now succeed, so probe immediately instead of waiting out a
1262
+ * backoff scheduled under worse conditions.
1263
+ */
1264
+ installWakeTriggers() {
1265
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
1266
+ const onOnline = () => {
1267
+ this.suspended = false;
1268
+ this.wake("online");
1269
+ };
1270
+ const onOffline = () => {
1271
+ this.logger.info({ Category: "sp00ky-client::ConnectionSupervisor::offline" }, "Browser reports offline; parking reconnects until online");
1272
+ this.suspended = true;
1273
+ this.stopHeartbeat();
1274
+ this.clearReviveTimer();
1275
+ this.setState("disconnected");
1276
+ };
1277
+ window.addEventListener("online", onOnline);
1278
+ window.addEventListener("offline", onOffline);
1279
+ this.teardown.push(() => window.removeEventListener("online", onOnline), () => window.removeEventListener("offline", onOffline));
1280
+ const onPageShow = () => this.wake("pageshow");
1281
+ window.addEventListener("pageshow", onPageShow);
1282
+ this.teardown.push(() => window.removeEventListener("pageshow", onPageShow));
1283
+ }
1284
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
1285
+ const onVisibility = () => {
1286
+ if (document.visibilityState !== "visible") return;
1287
+ this.wake("visibilitychange");
1288
+ };
1289
+ document.addEventListener("visibilitychange", onVisibility);
1290
+ this.teardown.push(() => document.removeEventListener("visibilitychange", onVisibility));
1291
+ }
1292
+ }
1293
+ /**
1294
+ * Reset the backoff and act on whichever problem is present: reconnect if the
1295
+ * socket is gone, otherwise probe it (it may be half-open — which is exactly
1296
+ * what a sleep/wake cycle produces).
1297
+ */
1298
+ wake(reason) {
1299
+ if (this.disposed || this.suspended) return;
1300
+ this.logger.debug({
1301
+ reason,
1302
+ state: this.state,
1303
+ Category: "sp00ky-client::ConnectionSupervisor::wake"
1304
+ }, "Wake trigger; probing the connection");
1305
+ this.reviveAttempts = 0;
1306
+ if (this.remote.getStatus() === "connected") {
1307
+ this.stopHeartbeat();
1308
+ this.beat();
1309
+ return;
1310
+ }
1311
+ if (this.remote.getStatus() === "disconnected") {
1312
+ this.clearReviveTimer();
1313
+ this.revive();
1314
+ }
1315
+ }
1316
+ };
1317
+
930
1318
  //#endregion
931
1319
  //#region src/services/logger/index.ts
932
1320
  function createLogger(level = "info", transmit) {
@@ -965,7 +1353,7 @@ var LocalMigrator = class {
965
1353
  return;
966
1354
  }
967
1355
  await this.recreateDatabase(database);
968
- const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
1356
+ const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
969
1357
  const statements = this.splitStatements(fullSchema);
970
1358
  for (let i = 0; i < statements.length; i++) {
971
1359
  const statement = statements[i];
@@ -1634,6 +2022,7 @@ const SYSTEM_TABLES = [
1634
2022
  "_00_stream_processor_state",
1635
2023
  "_00_query",
1636
2024
  "_00_preload",
2025
+ "_00_window",
1637
2026
  "_00_schema",
1638
2027
  "_00_pending_mutations",
1639
2028
  "_00_user_feature",
@@ -2466,6 +2855,53 @@ function createLocalEngine(choice, config, logger, opts = {}) {
2466
2855
  */
2467
2856
  function buildWindowMaterializationPlan(plan, ids) {
2468
2857
  if (plan.offset === void 0 || plan.offset <= 0) return null;
2858
+ return buildIdSetPlan(plan, ids);
2859
+ }
2860
+ /**
2861
+ * Raw-SurrealQL counterpart of {@link buildIdSetPlan}, for queries that arrived
2862
+ * without a plan. Same rewrite as {@link buildWindowMaterialization} without the
2863
+ * offset guard: keep the projection and top-level `ORDER BY`, replace the source
2864
+ * with the bound id-set.
2865
+ */
2866
+ function buildIdSetSurql(surql, idsParam = "__win") {
2867
+ const kw = scanTopLevelClauses(surql);
2868
+ if (kw.fromIndex === null) return null;
2869
+ return { query: renderIdSetSelect(surql, kw, idsParam) };
2870
+ }
2871
+ /** Shared body of the two id-set surql rewrites: keep the projection and the
2872
+ * top-level `ORDER BY`, replace the source with the bound id-set. */
2873
+ function renderIdSetSelect(surql, kw, idsParam) {
2874
+ const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
2875
+ let orderBy = "";
2876
+ const orderByIndex = kw.orderByIndex;
2877
+ if (orderByIndex !== null) {
2878
+ const ends = [
2879
+ kw.limitIndex,
2880
+ kw.startIndex,
2881
+ kw.semicolonIndex,
2882
+ surql.length
2883
+ ].filter((n) => n !== null && n > orderByIndex);
2884
+ orderBy = " " + surql.slice(orderByIndex, Math.min(...ends)).trim();
2885
+ }
2886
+ return `${selectClause} FROM $${idsParam}${orderBy}`;
2887
+ }
2888
+ /**
2889
+ * Restrict any plan's base rows to an explicit id-set, regardless of offset.
2890
+ *
2891
+ * Same rewrite as {@link buildWindowMaterializationPlan} without the offset
2892
+ * guard, for rendering a query from its authoritative membership list rather
2893
+ * than by re-running its predicate.
2894
+ *
2895
+ * `where` is dropped deliberately: the id-set already IS the answer to the
2896
+ * predicate as the server evaluated it, and re-applying the `WHERE` locally is
2897
+ * exactly the bug — a row that left the window keeps its (stale, never
2898
+ * re-fetched) local body, so it keeps matching and keeps rendering.
2899
+ * `limit`/`offset` go for the same reason: both are already baked into which ids
2900
+ * are in the set, so the set itself is the bound. (Both engines ignore them on
2901
+ * the `ids` path regardless — only `select` and `orderBy` are honored there.)
2902
+ * `orderBy` is kept so display order stays deterministic.
2903
+ */
2904
+ function buildIdSetPlan(plan, ids) {
2469
2905
  return {
2470
2906
  ...plan,
2471
2907
  ids,
@@ -2478,19 +2914,7 @@ function buildWindowMaterialization(surql, idsParam = "__win") {
2478
2914
  const kw = scanTopLevelClauses(surql);
2479
2915
  if (kw.startValue === null || kw.startValue <= 0) return null;
2480
2916
  if (kw.fromIndex === null) return null;
2481
- const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
2482
- let orderBy = "";
2483
- if (kw.orderByIndex !== null) {
2484
- const ends = [
2485
- kw.limitIndex,
2486
- kw.startIndex,
2487
- kw.semicolonIndex,
2488
- surql.length
2489
- ].filter((n) => n !== null && n > kw.orderByIndex);
2490
- const end = Math.min(...ends);
2491
- orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
2492
- }
2493
- return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
2917
+ return { query: renderIdSetSelect(surql, kw, idsParam) };
2494
2918
  }
2495
2919
  function scanTopLevelClauses(sql) {
2496
2920
  const out = {
@@ -2751,7 +3175,10 @@ var DataModule = class {
2751
3175
  hash,
2752
3176
  Category: "sp00ky-client::DataModule::query"
2753
3177
  }, "Query Initialization: not found, creating new query");
2754
- const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName, plan);
3178
+ const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName, plan, await this.calculateMembershipKey({
3179
+ surql: surqlString,
3180
+ params
3181
+ }));
2755
3182
  this.pendingQueries.set(hash, promise);
2756
3183
  try {
2757
3184
  await promise;
@@ -2880,34 +3307,108 @@ var DataModule = class {
2880
3307
  this.pendingStreamUpdates.delete(queryHash);
2881
3308
  await this.processStreamUpdate(pending);
2882
3309
  }
3310
+ /**
3311
+ * Materialize a query's result rows from the local store.
3312
+ *
3313
+ * A query's rows are its MEMBERSHIP — the id-set the server put in
3314
+ * `_00_list_ref` (`remoteArray`) — not "every local body that matches the
3315
+ * WHERE". Those two disagree, and the disagreement was the bug: when a row
3316
+ * leaves a query's window but still exists upstream, `handleRemovedRecords`
3317
+ * keeps its local body and never re-fetches it, so a predicate re-scan finds
3318
+ * that stale body still matching and keeps rendering the row. Selecting the
3319
+ * id-set directly is also the only correct thing for a windowed query, where
3320
+ * re-applying `START m` against the shared local store skips the window's own
3321
+ * rows entirely (sparse windowing) and returns nothing.
3322
+ *
3323
+ * The rendered set is:
3324
+ *
3325
+ * (membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes
3326
+ *
3327
+ * The middle term keeps optimistic writes visible without re-admitting stale
3328
+ * rows. Every local write is fed to the SSP (`cache.saveBatch` →
3329
+ * `ingestMany`), so `localArray` answers "does this row match the predicate
3330
+ * per LOCAL truth". A pending write that moves a row into the window is in
3331
+ * `localArray` and shows; one that moves a row out is absent and does not; a
3332
+ * stale body the server dropped has no pending write at all, so it stays out.
3333
+ * `pendingDeletes` covers the reverse lag — the server still lists a row whose
3334
+ * DELETE is sitting in our outbox.
3335
+ *
3336
+ * Falls back to the predicate scan only when membership has never been
3337
+ * established (a query first run on this device), so an offline first paint
3338
+ * still shows something.
3339
+ */
2883
3340
  async materializeRecords(queryState, sspArray) {
2884
3341
  const t0 = performance.now();
2885
- const plan = queryState.config.plan;
2886
- const windowMat = buildWindowMaterialization(queryState.config.surql);
3342
+ const records = await this.materializeFromConfig(queryState.config, sspArray);
3343
+ this.recordPhase(queryState, "localFetch", performance.now() - t0);
3344
+ return records;
3345
+ }
3346
+ /**
3347
+ * The materialization itself, without the DevTools timing wrapper. Split out so
3348
+ * cold-start seeding can use it before a `QueryState` exists.
3349
+ */
3350
+ async materializeFromConfig(config, sspArray) {
3351
+ const plan = config.plan;
3352
+ const membership = this.resolveMembership(config, sspArray);
2887
3353
  let records;
2888
- if (windowMat) {
2889
- const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
2890
- if (plan) {
2891
- const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? {
2892
- ...plan,
2893
- ids: winIds
2894
- };
2895
- records = await this.local.select(winPlan, queryState.config.params);
2896
- } else {
2897
- const [rows] = await this.local.query(windowMat.query, {
2898
- ...queryState.config.params,
2899
- __win: winIds
2900
- });
2901
- records = rows || [];
3354
+ if (membership) {
3355
+ const ids = await this.buildRenderIds(config, membership, sspArray);
3356
+ if (plan) records = await this.local.select(buildIdSetPlan(plan, ids), config.params);
3357
+ else {
3358
+ const idSetSurql = buildIdSetSurql(config.surql);
3359
+ if (idSetSurql) {
3360
+ const [rows] = await this.local.query(idSetSurql.query, {
3361
+ ...config.params,
3362
+ __win: ids
3363
+ });
3364
+ records = rows || [];
3365
+ } else {
3366
+ const [rows] = await this.local.query(config.surql, config.params);
3367
+ records = rows || [];
3368
+ }
2902
3369
  }
2903
- } else if (plan) records = await this.local.select(plan, queryState.config.params);
3370
+ } else if (plan) records = await this.local.select(plan, config.params);
2904
3371
  else {
2905
- const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
3372
+ const [rows] = await this.local.query(config.surql, config.params);
2906
3373
  records = rows || [];
2907
3374
  }
2908
- this.recordPhase(queryState, "localFetch", performance.now() - t0);
2909
3375
  return records;
2910
3376
  }
3377
+ /**
3378
+ * The authoritative membership list to render from, or `null` when membership
3379
+ * has never been established and the caller must fall back to a scan.
3380
+ *
3381
+ * A windowed query has no usable fallback — re-running its `START m` locally
3382
+ * returns the wrong rows — so it renders from whatever id-set is on hand
3383
+ * (SSP's included) rather than degrading to a scan. That is the pre-existing
3384
+ * behavior for windows and is preserved.
3385
+ */
3386
+ resolveMembership(config, sspArray) {
3387
+ if (config.membershipKnown) return config.remoteArray ?? [];
3388
+ if (buildWindowMaterialization(config.surql) !== null) return config.remoteArray?.length && config.remoteArray || sspArray?.length && sspArray || config.localArray || [];
3389
+ return null;
3390
+ }
3391
+ /** Apply the pending-write union and pending-delete subtraction, and map to
3392
+ * RecordIds for the engines' id-set path. */
3393
+ async buildRenderIds(config, membership, sspArray) {
3394
+ const { writes, deletes } = await this.getPendingRecordIds();
3395
+ const ordered = [];
3396
+ const seen = /* @__PURE__ */ new Set();
3397
+ for (const [id] of membership) {
3398
+ if (deletes.has(id) || seen.has(id)) continue;
3399
+ seen.add(id);
3400
+ ordered.push(id);
3401
+ }
3402
+ if (writes.size > 0) {
3403
+ const localView = sspArray?.length && sspArray || config.localArray || [];
3404
+ for (const [id] of localView) {
3405
+ if (!writes.has(id) || deletes.has(id) || seen.has(id)) continue;
3406
+ seen.add(id);
3407
+ ordered.push(id);
3408
+ }
3409
+ }
3410
+ return ordered.map((id) => parseRecordIdString(id));
3411
+ }
2911
3412
  async processStreamUpdate(update) {
2912
3413
  const { queryHash, localArray, materializationTimeMs } = update;
2913
3414
  const queryState = this.activeQueries.get(queryHash);
@@ -3143,6 +3644,7 @@ var DataModule = class {
3143
3644
  await this.buildAndSaveCacheBatch(tableName, rows);
3144
3645
  if (epoch !== this.local.epoch) return;
3145
3646
  queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
3647
+ queryState.config.membershipKnown = true;
3146
3648
  queryState.records = await this.materializeRecords(queryState);
3147
3649
  const subscribers = this.subscriptions.get(hash);
3148
3650
  if (subscribers) for (const cb of subscribers) cb(queryState.records);
@@ -3205,6 +3707,72 @@ var DataModule = class {
3205
3707
  rowCount
3206
3708
  }, "replace");
3207
3709
  }
3710
+ /**
3711
+ * Read the durable membership row, or `null` if this query has never had
3712
+ * authoritative membership on this device. Any read error is treated as
3713
+ * "unknown" so a broken row degrades to the predicate scan rather than
3714
+ * rendering an empty list.
3715
+ */
3716
+ async getWindowMembership(key) {
3717
+ try {
3718
+ const row = await this.local.getById("_00_window", new RecordId("_00_window", key));
3719
+ if (!row || typeof row !== "object") return null;
3720
+ const ids = row.ids;
3721
+ return Array.isArray(ids) ? ids : null;
3722
+ } catch {
3723
+ return null;
3724
+ }
3725
+ }
3726
+ /** Persist the durable membership row. Best-effort: callers must not fail a
3727
+ * sync round because the mirror write failed. */
3728
+ async writeWindowMembership(key, ids) {
3729
+ try {
3730
+ await this.local.upsert("_00_window", new RecordId("_00_window", key), {
3731
+ ids,
3732
+ updatedAt: Date.now()
3733
+ }, "replace");
3734
+ } catch (err) {
3735
+ this.logger.debug({
3736
+ err,
3737
+ key,
3738
+ Category: "sp00ky-client::DataModule::writeWindowMembership"
3739
+ }, "Failed to persist window membership; it will be re-derived on next register");
3740
+ }
3741
+ }
3742
+ /**
3743
+ * Record ids with a mutation still in the outbox, split by direction.
3744
+ *
3745
+ * Both halves feed {@link materializeRecords}: `writes` keeps optimistic
3746
+ * creates/updates visible before the server has acknowledged them, and
3747
+ * `deletes` suppresses rows the server still lists because our DELETE hasn't
3748
+ * been processed yet. Reading `_00_pending_mutations` (rather than tracking
3749
+ * ids in memory) is what makes both survive a reload.
3750
+ *
3751
+ * On failure returns empty sets: membership alone then decides, which can
3752
+ * briefly hide an optimistic write but never resurrects a deleted row.
3753
+ */
3754
+ async getPendingRecordIds() {
3755
+ const writes = /* @__PURE__ */ new Set();
3756
+ const deletes = /* @__PURE__ */ new Set();
3757
+ try {
3758
+ const [rows] = await this.local.query("SELECT recordId, mutationType FROM _00_pending_mutations");
3759
+ for (const row of rows ?? []) {
3760
+ if (!row?.recordId) continue;
3761
+ const id = encodeRecordId(row.recordId);
3762
+ if (row.mutationType === "delete") deletes.add(id);
3763
+ else writes.add(id);
3764
+ }
3765
+ } catch (err) {
3766
+ this.logger.warn({
3767
+ err,
3768
+ Category: "sp00ky-client::DataModule::getPendingRecordIds"
3769
+ }, "Failed to read pending mutations; optimistic writes may be briefly hidden");
3770
+ }
3771
+ return {
3772
+ writes,
3773
+ deletes
3774
+ };
3775
+ }
3208
3776
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
3209
3777
  hasSubscribers(hash) {
3210
3778
  return (this.subscriptions.get(hash)?.size ?? 0) > 0;
@@ -3294,6 +3862,8 @@ var DataModule = class {
3294
3862
  }
3295
3863
  const epoch = this.local.epoch;
3296
3864
  queryState.config.remoteArray = remoteArray;
3865
+ queryState.config.membershipKnown = true;
3866
+ if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
3297
3867
  try {
3298
3868
  await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
3299
3869
  id: queryState.config.id,
@@ -3341,6 +3911,14 @@ var DataModule = class {
3341
3911
  config.localArray = [];
3342
3912
  config.remoteArray = [];
3343
3913
  config.subqueryRemoteArray = void 0;
3914
+ config.membershipKnown = false;
3915
+ if (config.membershipKey) {
3916
+ const durable = await this.getWindowMembership(config.membershipKey);
3917
+ if (durable) {
3918
+ config.remoteArray = durable;
3919
+ config.membershipKnown = true;
3920
+ }
3921
+ }
3344
3922
  queryState.hydrated = false;
3345
3923
  queryState.syncNotified = false;
3346
3924
  queryState.records = [];
@@ -3661,14 +4239,15 @@ var DataModule = class {
3661
4239
  }
3662
4240
  }
3663
4241
  }
3664
- async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName, plan) {
4242
+ async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName, plan, membershipKey) {
3665
4243
  const queryState = await this.createNewQuery({
3666
4244
  recordId,
3667
4245
  surql: surqlString,
3668
4246
  params,
3669
4247
  ttl,
3670
4248
  tableName,
3671
- plan
4249
+ plan,
4250
+ membershipKey
3672
4251
  });
3673
4252
  const t0 = performance.now();
3674
4253
  const { localArray, registrationTimings } = this.cache.registerQuery({
@@ -3728,7 +4307,7 @@ var DataModule = class {
3728
4307
  }, "Query registered");
3729
4308
  return hash;
3730
4309
  }
3731
- async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName, plan }) {
4310
+ async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName, plan, membershipKey }) {
3732
4311
  const tableSchema = this.schema.tables.find((t) => t.name === tableName) ?? (String(tableName).startsWith("_00_") ? {
3733
4312
  name: tableName,
3734
4313
  columns: {}
@@ -3758,10 +4337,26 @@ var DataModule = class {
3758
4337
  ...configRecord,
3759
4338
  id: recordId,
3760
4339
  plan,
3761
- params: parseParams(tableSchema.columns, configRecord.params)
4340
+ params: parseParams(tableSchema.columns, configRecord.params),
4341
+ membershipKey
3762
4342
  };
4343
+ if (membershipKey && !config.remoteArray?.length) {
4344
+ const durable = await this.getWindowMembership(membershipKey);
4345
+ if (durable) {
4346
+ config.remoteArray = durable;
4347
+ config.membershipKnown = true;
4348
+ }
4349
+ } else if (config.remoteArray?.length) config.membershipKnown = true;
3763
4350
  let records = [];
3764
- if (buildWindowMaterialization(surqlString) === null) try {
4351
+ if (config.membershipKnown) try {
4352
+ records = await this.materializeFromConfig(config);
4353
+ } catch (err) {
4354
+ this.logger.warn({
4355
+ err,
4356
+ Category: "sp00ky-client::DataModule::createNewQuery"
4357
+ }, "Failed to seed records from durable membership");
4358
+ }
4359
+ else if (buildWindowMaterialization(surqlString) === null) try {
3765
4360
  records = (plan ? await this.local.select(plan, params) : (await this.local.query(surqlString, params))[0]) || [];
3766
4361
  } catch (err) {
3767
4362
  this.logger.warn({
@@ -3793,10 +4388,25 @@ var DataModule = class {
3793
4388
  };
3794
4389
  }
3795
4390
  async calculateHash(data) {
3796
- const content = JSON.stringify({
4391
+ return this.sha256(JSON.stringify({
3797
4392
  ...data,
3798
4393
  sessionId: this.sessionId
3799
- });
4394
+ }));
4395
+ }
4396
+ /**
4397
+ * Session-independent counterpart of {@link calculateHash}: the key for a
4398
+ * query's durable `_00_window` membership row.
4399
+ *
4400
+ * Deliberately the SAME inputs minus the `session::id()` salt, so the two keys
4401
+ * can never drift apart. The salt is right for `_00_query` (two tabs must not
4402
+ * fight over one row) and wrong for membership, which has to be recognizable
4403
+ * after a reload — a reload mints a new session id, and offline the salt is
4404
+ * `''`, so a salted key can never match what the previous session wrote.
4405
+ */
4406
+ async calculateMembershipKey(data) {
4407
+ return this.sha256(JSON.stringify(data));
4408
+ }
4409
+ async sha256(content) {
3800
4410
  const msgBuffer = new TextEncoder().encode(content);
3801
4411
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
3802
4412
  return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -4263,6 +4873,16 @@ function diffRecordVersionArray(local, remote) {
4263
4873
  removed: removed.map(parseRecordIdString)
4264
4874
  };
4265
4875
  }
4876
+ /**
4877
+ * Applies a RecordVersionDiff to a RecordVersionArray and returns a new sorted array.
4878
+ */
4879
+ function applyRecordVersionDiff(current, diff) {
4880
+ const currentMap = new Map(current);
4881
+ for (const id of diff.removed) currentMap.delete(encodeRecordId(id));
4882
+ for (const item of diff.added) currentMap.set(encodeRecordId(item.id), item.version);
4883
+ for (const item of diff.updated) currentMap.set(encodeRecordId(item.id), item.version);
4884
+ return Array.from(currentMap).toSorted((a, b) => a[0].localeCompare(b[0]));
4885
+ }
4266
4886
  function createDiffFromDbOp(op, recordId, version, versions) {
4267
4887
  const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
4268
4888
  if (old && old[1] >= version) return {
@@ -4650,7 +5270,12 @@ var Sp00kySync = class Sp00kySync {
4650
5270
  return this.syncEngine.events;
4651
5271
  }
4652
5272
  scheduler;
4653
- wasDisconnected = false;
5273
+ /**
5274
+ * Set by any event that means the socket we registered on is gone, so the
5275
+ * next `connected` knows it must re-subscribe rather than treat itself as the
5276
+ * initial connect. See {@link subscribeToReconnect}.
5277
+ */
5278
+ needsResubscribe = false;
4654
5279
  events = createSyncEventSystem();
4655
5280
  currentUserId = null;
4656
5281
  tabRole = "solo";
@@ -4698,6 +5323,17 @@ var Sp00kySync = class Sp00kySync {
4698
5323
  selfHealAttempts = 0;
4699
5324
  static SELF_HEAL_BASE_MS = 2e3;
4700
5325
  static SELF_HEAL_MAX_MS = 3e4;
5326
+ /**
5327
+ * Transport supervisor, when one was supplied. Sync only reads state from it;
5328
+ * it never drives reconnects itself.
5329
+ */
5330
+ connectionSupervisor;
5331
+ /**
5332
+ * Mirror of the supervisor's state. Defaults to `connected` so a client
5333
+ * constructed without a supervisor (tests, embedders) reports the same health
5334
+ * shape it always has rather than a permanent false "disconnected".
5335
+ */
5336
+ connectionState = "connected";
4701
5337
  /** Current sync-health snapshot. */
4702
5338
  get syncHealth() {
4703
5339
  return {
@@ -4705,7 +5341,8 @@ var Sp00kySync = class Sp00kySync {
4705
5341
  consecutiveFailures: this.consecutiveSyncFailures,
4706
5342
  kind: this.syncHealthStatus === "degraded" ? this.lastSyncErrorKind : void 0,
4707
5343
  error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0,
4708
- everConnected: this.hasSyncedOnce
5344
+ everConnected: this.hasSyncedOnce,
5345
+ connection: this.connectionState
4709
5346
  };
4710
5347
  }
4711
5348
  /**
@@ -4722,6 +5359,24 @@ var Sp00kySync = class Sp00kySync {
4722
5359
  this.events.emit(SyncEventTypes.SyncHealthChanged, this.syncHealth);
4723
5360
  }
4724
5361
  /**
5362
+ * Mirror the supervisor's transport state into {@link SyncHealth} and emit on
5363
+ * every change, so a UI can react to a dropped socket immediately instead of
5364
+ * waiting for `degradeAfterFailures` failed rounds. `status` is untouched:
5365
+ * a brief reconnect is not a degradation.
5366
+ *
5367
+ * No explicit unsubscribe: the supervisor is owned by the same client and
5368
+ * drops all subscribers in its own `dispose()`, which `Sp00kyClient.close()`
5369
+ * calls first.
5370
+ */
5371
+ subscribeToConnectionState() {
5372
+ if (!this.connectionSupervisor) return;
5373
+ this.connectionSupervisor.subscribe((state) => {
5374
+ if (this.connectionState === state) return;
5375
+ this.connectionState = state;
5376
+ this.emitSyncHealth();
5377
+ });
5378
+ }
5379
+ /**
4725
5380
  * Fed by the scheduler once per drained sync round. Individual failures are
4726
5381
  * absorbed by the queue's retry; only a run of `degradeAfterFailures`
4727
5382
  * consecutive failures flips the status to `degraded`, and the next clean
@@ -4823,6 +5478,7 @@ var Sp00kySync = class Sp00kySync {
4823
5478
  this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
4824
5479
  this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
4825
5480
  this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
5481
+ this.connectionSupervisor = options?.connectionSupervisor;
4826
5482
  }
4827
5483
  /**
4828
5484
  * Initializes the synchronization system.
@@ -4834,6 +5490,7 @@ var Sp00kySync = class Sp00kySync {
4834
5490
  this.isInit = true;
4835
5491
  await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
4836
5492
  this.subscribeToReconnect();
5493
+ this.subscribeToConnectionState();
4837
5494
  this.scheduler.syncUp();
4838
5495
  this.scheduler.syncDown();
4839
5496
  if (this.anonLiveEnabled && !this.currentUserId) {
@@ -5196,7 +5853,7 @@ var Sp00kySync = class Sp00kySync {
5196
5853
  this.liveQueryUnsubscribe = null;
5197
5854
  }
5198
5855
  if (this.currentLiveQueryUuid !== null) {
5199
- try {
5856
+ if (this.remote.getStatus() === "connected") try {
5200
5857
  await this.remote.query("KILL $u", { u: this.currentLiveQueryUuid });
5201
5858
  } catch (err) {
5202
5859
  this.logger.debug({
@@ -5211,15 +5868,39 @@ var Sp00kySync = class Sp00kySync {
5211
5868
  await this.killRefLiveQuery();
5212
5869
  await this.startRefLiveQueries();
5213
5870
  }
5871
+ /**
5872
+ * Drop local LIVE bookkeeping without issuing a `KILL`.
5873
+ *
5874
+ * Called when the socket dies. The server-side subscription is scoped to that
5875
+ * WebSocket session and died with it, so there is nothing left to kill — and
5876
+ * by the time the reconnect handler runs, the client reports `connected`
5877
+ * again, which would otherwise send a `KILL` for a stale uuid on the *new*
5878
+ * session and hold up the restart queued behind it.
5879
+ */
5880
+ invalidateRefLiveQuery() {
5881
+ if (this.liveQueryUnsubscribe) {
5882
+ try {
5883
+ this.liveQueryUnsubscribe();
5884
+ } catch {}
5885
+ this.liveQueryUnsubscribe = null;
5886
+ }
5887
+ this.currentLiveQueryUuid = null;
5888
+ }
5214
5889
  subscribeToReconnect() {
5215
5890
  const client = this.remote.getClient();
5216
5891
  client.subscribe("disconnected", () => {
5217
- this.wasDisconnected = true;
5892
+ this.needsResubscribe = true;
5893
+ this.invalidateRefLiveQuery();
5218
5894
  this.logger.info({ Category: "sp00ky-client::Sp00kySync::onDisconnect" }, "Remote disconnected");
5219
5895
  });
5896
+ client.subscribe("reconnecting", () => {
5897
+ this.needsResubscribe = true;
5898
+ this.invalidateRefLiveQuery();
5899
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnecting" }, "Remote socket dropped; awaiting reconnect");
5900
+ });
5220
5901
  client.subscribe("connected", () => {
5221
- if (!this.wasDisconnected) return;
5222
- this.wasDisconnected = false;
5902
+ if (!this.needsResubscribe) return;
5903
+ this.needsResubscribe = false;
5223
5904
  this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
5224
5905
  for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
5225
5906
  type: "register",
@@ -5295,7 +5976,12 @@ var Sp00kySync = class Sp00kySync {
5295
5976
  }, "Live update is being processed");
5296
5977
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
5297
5978
  const hash = extractIdPart(existing.config.id);
5979
+ if (existing.config.membershipKnown && (diff.removed.length || diff.added.length)) {
5980
+ const next = applyRecordVersionDiff(existing.config.remoteArray ?? [], diff);
5981
+ if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) await this.dataModule.updateQueryRemoteArray(hash, next);
5982
+ }
5298
5983
  await this.runSyncForQuery(hash, diff);
5984
+ if (diff.removed.length > 0 && diff.added.length === 0 && diff.updated.length === 0) await this.dataModule.notifyQuerySynced(hash);
5299
5985
  }
5300
5986
  /**
5301
5987
  * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
@@ -5360,19 +6046,7 @@ var Sp00kySync = class Sp00kySync {
5360
6046
  * retries rather than rolling the mutation back.
5361
6047
  */
5362
6048
  withPushTimeout(promise, label) {
5363
- if (!(this.pushTimeoutMs > 0)) return promise;
5364
- return new Promise((resolve, reject) => {
5365
- const timer = setTimeout(() => {
5366
- reject(/* @__PURE__ */ new Error(`Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`));
5367
- }, this.pushTimeoutMs);
5368
- promise.then((value) => {
5369
- clearTimeout(timer);
5370
- resolve(value);
5371
- }, (err) => {
5372
- clearTimeout(timer);
5373
- reject(err);
5374
- });
5375
- });
6049
+ return withTimeout(promise, this.pushTimeoutMs, `Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`);
5376
6050
  }
5377
6051
  async processUpEvent(event) {
5378
6052
  this.logger.debug({
@@ -5546,16 +6220,7 @@ var Sp00kySync = class Sp00kySync {
5546
6220
  * would otherwise resurrect a just-deleted record.
5547
6221
  */
5548
6222
  async getPendingDeleteIds() {
5549
- try {
5550
- const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
5551
- return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
5552
- } catch (err) {
5553
- this.logger.warn({
5554
- err,
5555
- Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
5556
- }, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
5557
- return /* @__PURE__ */ new Set();
5558
- }
6223
+ return (await this.dataModule.getPendingRecordIds()).deletes;
5559
6224
  }
5560
6225
  /**
5561
6226
  * Enqueues a list of mutations (up events) to be sent to the remote.
@@ -5818,8 +6483,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
5818
6483
 
5819
6484
  //#endregion
5820
6485
  //#region src/modules/devtools/index.ts
5821
- const CORE_VERSION = "0.0.1-canary.161";
5822
- const WASM_VERSION = "0.0.1-canary.161";
6486
+ const CORE_VERSION = "0.0.1-canary.162";
6487
+ const WASM_VERSION = "0.0.1-canary.162";
5823
6488
  const SURREAL_VERSION = "3.0.3";
5824
6489
  var DevToolsService = class DevToolsService {
5825
6490
  eventsHistory = [];
@@ -7474,6 +8139,9 @@ var CrdtManager = class {
7474
8139
  fields = /* @__PURE__ */ new Map();
7475
8140
  liveByTable = /* @__PURE__ */ new Map();
7476
8141
  pendingLive = /* @__PURE__ */ new Map();
8142
+ staleTables = /* @__PURE__ */ new Set();
8143
+ connectionGeneration = 0;
8144
+ connectionUnsubscribes = [];
7477
8145
  logger;
7478
8146
  sessionId = "";
7479
8147
  constructor(schema, local, remote, logger, debounceMs = 500) {
@@ -7482,6 +8150,49 @@ var CrdtManager = class {
7482
8150
  this.remote = remote;
7483
8151
  this.debounceMs = debounceMs;
7484
8152
  this.logger = logger.child({ service: "CrdtManager" });
8153
+ this.subscribeToReconnect();
8154
+ }
8155
+ /**
8156
+ * Re-establish table LIVEs after a socket drop.
8157
+ *
8158
+ * A LIVE subscription lives and dies with its WebSocket session, and
8159
+ * `ensureTableSubscription` is memoized on `liveByTable` — so without this,
8160
+ * the first reconnect leaves CRDT realtime permanently dead: the map still
8161
+ * holds a uuid for a subscription the server has forgotten, so every later
8162
+ * `open()` short-circuits and no LIVE is ever re-issued.
8163
+ *
8164
+ * Both drop events matter: the SDK publishes `reconnecting` (not
8165
+ * `disconnected`) when it intends to recover on its own, and `disconnected`
8166
+ * only once it has given up.
8167
+ */
8168
+ subscribeToReconnect() {
8169
+ const onDrop = () => {
8170
+ this.connectionGeneration++;
8171
+ for (const table of this.liveByTable.keys()) this.staleTables.add(table);
8172
+ for (const table of this.pendingLive.keys()) this.staleTables.add(table);
8173
+ this.liveByTable.clear();
8174
+ };
8175
+ this.connectionUnsubscribes.push(this.remote.subscribeConnection("reconnecting", onDrop), this.remote.subscribeConnection("disconnected", onDrop), this.remote.subscribeConnection("connected", () => {
8176
+ const tables = Array.from(this.staleTables);
8177
+ this.staleTables.clear();
8178
+ for (const table of tables) {
8179
+ if (!this.hasOpenFieldFor(table)) continue;
8180
+ this.ensureTableSubscription(table);
8181
+ }
8182
+ }));
8183
+ }
8184
+ /** Stop observing transport events. Separate from {@link closeAll}, which also
8185
+ * runs on a bucket switch where the manager keeps being used. */
8186
+ dispose() {
8187
+ for (const off of this.connectionUnsubscribes) try {
8188
+ off();
8189
+ } catch {}
8190
+ this.connectionUnsubscribes = [];
8191
+ this.staleTables.clear();
8192
+ }
8193
+ hasOpenFieldFor(table) {
8194
+ const prefix = `${table}:`;
8195
+ return Array.from(this.fields.keys()).some((k) => k.startsWith(prefix));
7485
8196
  }
7486
8197
  /** Set the session id that scopes this client's cursor entries. Must be
7487
8198
  * called before `open()` for cursors to be pushed under a stable key.
@@ -7539,8 +8250,10 @@ var CrdtManager = class {
7539
8250
  crdtField.stopSync();
7540
8251
  this.fields.delete(key);
7541
8252
  }
7542
- const tablePrefix = `${table}:`;
7543
- if (!Array.from(this.fields.keys()).some((k) => k.startsWith(tablePrefix))) this.killTableSubscription(table);
8253
+ if (!this.hasOpenFieldFor(table)) {
8254
+ this.killTableSubscription(table);
8255
+ this.staleTables.delete(table);
8256
+ }
7544
8257
  this.logger.debug({
7545
8258
  key,
7546
8259
  Category: "sp00ky-client::CrdtManager::close"
@@ -7555,21 +8268,38 @@ var CrdtManager = class {
7555
8268
  for (const [_, field] of this.fields) field.stopSync(options);
7556
8269
  this.fields.clear();
7557
8270
  for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
8271
+ this.staleTables.clear();
7558
8272
  }
7559
8273
  /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
7560
8274
  * every open CrdtField on `table`. */
7561
8275
  async ensureTableSubscription(table) {
7562
8276
  if (this.liveByTable.has(table)) return;
8277
+ const generation = this.connectionGeneration;
7563
8278
  const pending = this.pendingLive.get(table);
7564
- if (pending) return pending;
8279
+ if (pending) {
8280
+ if (pending.generation === generation) return pending.promise;
8281
+ await pending.promise.catch(() => {});
8282
+ if (this.liveByTable.has(table)) return;
8283
+ if (generation !== this.connectionGeneration) return;
8284
+ const successor = this.pendingLive.get(table);
8285
+ if (successor && successor.generation === generation) return successor.promise;
8286
+ }
7565
8287
  const start = (async () => {
7566
8288
  try {
7567
8289
  const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
8290
+ if (generation !== this.connectionGeneration) {
8291
+ this.logger.debug({
8292
+ table,
8293
+ Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
8294
+ }, "Socket dropped while registering LIVE; discarding it");
8295
+ return;
8296
+ }
7568
8297
  (await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
7569
8298
  if (message.action === "KILLED") return;
7570
8299
  if (message.action !== "CREATE" && message.action !== "UPDATE") return;
7571
8300
  this.dispatchRow(table, message.value);
7572
8301
  });
8302
+ if (generation !== this.connectionGeneration) return;
7573
8303
  this.liveByTable.set(table, uuid);
7574
8304
  this.logger.info({
7575
8305
  table,
@@ -7583,7 +8313,10 @@ var CrdtManager = class {
7583
8313
  }, "Failed to start LIVE SELECT");
7584
8314
  }
7585
8315
  })();
7586
- this.pendingLive.set(table, start);
8316
+ this.pendingLive.set(table, {
8317
+ promise: start,
8318
+ generation
8319
+ });
7587
8320
  try {
7588
8321
  await start;
7589
8322
  } finally {
@@ -8914,6 +9647,7 @@ function writeBootBucketHint(bucketId) {
8914
9647
  var Sp00kyClient = class {
8915
9648
  local;
8916
9649
  remote;
9650
+ connectionSupervisor;
8917
9651
  persistenceClient;
8918
9652
  migrator;
8919
9653
  cache;
@@ -8995,6 +9729,7 @@ var Sp00kyClient = class {
8995
9729
  const tabsSupport = detectSharedTabsSupport(this.config);
8996
9730
  this.local = createLocalEngine(this.config.localEngine, this.config.database, logger, { shared: tabsSupport.supported });
8997
9731
  this.remote = new RemoteDatabaseService(this.config.database, logger);
9732
+ this.connectionSupervisor = new ConnectionSupervisor(this.remote, logger);
8998
9733
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
8999
9734
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
9000
9735
  else this.persistenceClient = config.persistenceClient;
@@ -9011,7 +9746,9 @@ var Sp00kyClient = class {
9011
9746
  this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, {
9012
9747
  refSyncIntervalMs: this.config.refSyncIntervalMs,
9013
9748
  anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
9014
- degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3
9749
+ degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
9750
+ pushTimeoutMs: this.config.pushTimeoutMs,
9751
+ connectionSupervisor: this.connectionSupervisor
9015
9752
  });
9016
9753
  this.featureFlags = new FeatureFlagModule({
9017
9754
  dataModule: this.dataModule,
@@ -9063,7 +9800,7 @@ var Sp00kyClient = class {
9063
9800
  return new TabsCoordinator({
9064
9801
  tabId,
9065
9802
  fingerprint: computeTabsFingerprint({
9066
- coreVersion: "0.0.1-canary.161",
9803
+ coreVersion: "0.0.1-canary.162",
9067
9804
  schemaHash: hash53(this.config.schemaSurql),
9068
9805
  endpoint: this.config.database.endpoint ?? "",
9069
9806
  namespace: this.config.database.namespace,
@@ -9203,6 +9940,7 @@ var Sp00kyClient = class {
9203
9940
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
9204
9941
  }
9205
9942
  await this.remote.connect();
9943
+ this.connectionSupervisor.start();
9206
9944
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
9207
9945
  this.streamProcessor.setStateKeySuffix(bootBucket);
9208
9946
  await this.streamProcessor.init();
@@ -9353,9 +10091,11 @@ var Sp00kyClient = class {
9353
10091
  }, "Local bucket switch complete");
9354
10092
  }
9355
10093
  async close() {
10094
+ this.connectionSupervisor.dispose();
9356
10095
  await this.featureFlags.closeAll();
9357
10096
  await this.appReleases.closeAll();
9358
10097
  this.crdtManager.closeAll();
10098
+ this.crdtManager.dispose();
9359
10099
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
9360
10100
  await this.local.close();
9361
10101
  await this.remote.close();