@spooky-sync/core 0.0.1-canary.160 → 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);
883
949
  }
884
950
  getConfig() {
885
951
  return this.config;
886
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);
967
+ }
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
+ }
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("");
@@ -3912,7 +4522,7 @@ var UpQueue = class {
3912
4522
  Category: "sp00ky-client::UpQueue::discardUnreplayable"
3913
4523
  }, "Discarding an unsendable pending mutation");
3914
4524
  try {
3915
- await this.local.query(`DELETE $mutation_id`, { mutation_id: parseRecordIdString(mutationId) });
4525
+ await this.local.query(`DELETE $mutation_id`, { mutation_id: parseStoredRecordId(mutationId) });
3916
4526
  } catch (error) {
3917
4527
  this.logger.error({
3918
4528
  error,
@@ -4090,6 +4700,24 @@ var UpQueue = class {
4090
4700
  function encodeUpEventId(event) {
4091
4701
  return encodeRecordId(event.mutation_id);
4092
4702
  }
4703
+ /**
4704
+ * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
4705
+ * escaping.
4706
+ *
4707
+ * Outbox ids contain `_` and `-`, so the store hands them back in escaped
4708
+ * display form (`_00_pending_mutations:⟨1785…_0005_c960…⟩`). `parseRecordIdString`
4709
+ * keeps that verbatim, and re-encoding then escapes the brackets AGAIN, so a
4710
+ * `DELETE $mutation_id` built from a stored id targets
4711
+ * `_00_pending_mutations:⟨⟨1785…\⟩⟩` and matches nothing. Every mutation
4712
+ * replayed from the store was therefore un-deletable: its row survived a
4713
+ * SUCCESSFUL push and got re-sent on the next boot.
4714
+ */
4715
+ function parseStoredRecordId(id) {
4716
+ const [table, ...rest] = id.split(":");
4717
+ let raw = rest.join(":");
4718
+ if (raw.startsWith("⟨") && raw.endsWith("⟩")) raw = raw.slice(1, -1);
4719
+ return new RecordId(table, raw);
4720
+ }
4093
4721
  /** Materialize one `_00_pending_mutations` row into an UpEvent. */
4094
4722
  function rowToUpEvent(r, logger) {
4095
4723
  switch (r.mutationType) {
@@ -4097,21 +4725,21 @@ function rowToUpEvent(r, logger) {
4097
4725
  if (r.data === void 0 || r.data === null) return null;
4098
4726
  return {
4099
4727
  type: "create",
4100
- mutation_id: parseRecordIdString(r.id),
4728
+ mutation_id: parseStoredRecordId(r.id),
4101
4729
  record_id: parseRecordIdString(r.recordId),
4102
4730
  data: r.data,
4103
4731
  tableName: extractTablePart(r.recordId)
4104
4732
  };
4105
4733
  case "update": return {
4106
4734
  type: "update",
4107
- mutation_id: parseRecordIdString(r.id),
4735
+ mutation_id: parseStoredRecordId(r.id),
4108
4736
  record_id: parseRecordIdString(r.recordId),
4109
4737
  data: r.data,
4110
4738
  beforeRecord: r.beforeRecord
4111
4739
  };
4112
4740
  case "delete": return {
4113
4741
  type: "delete",
4114
- mutation_id: parseRecordIdString(r.id),
4742
+ mutation_id: parseStoredRecordId(r.id),
4115
4743
  record_id: parseRecordIdString(r.recordId)
4116
4744
  };
4117
4745
  default:
@@ -4245,6 +4873,16 @@ function diffRecordVersionArray(local, remote) {
4245
4873
  removed: removed.map(parseRecordIdString)
4246
4874
  };
4247
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
+ }
4248
4886
  function createDiffFromDbOp(op, recordId, version, versions) {
4249
4887
  const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
4250
4888
  if (old && old[1] >= version) return {
@@ -4632,7 +5270,12 @@ var Sp00kySync = class Sp00kySync {
4632
5270
  return this.syncEngine.events;
4633
5271
  }
4634
5272
  scheduler;
4635
- 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;
4636
5279
  events = createSyncEventSystem();
4637
5280
  currentUserId = null;
4638
5281
  tabRole = "solo";
@@ -4680,6 +5323,17 @@ var Sp00kySync = class Sp00kySync {
4680
5323
  selfHealAttempts = 0;
4681
5324
  static SELF_HEAL_BASE_MS = 2e3;
4682
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";
4683
5337
  /** Current sync-health snapshot. */
4684
5338
  get syncHealth() {
4685
5339
  return {
@@ -4687,7 +5341,8 @@ var Sp00kySync = class Sp00kySync {
4687
5341
  consecutiveFailures: this.consecutiveSyncFailures,
4688
5342
  kind: this.syncHealthStatus === "degraded" ? this.lastSyncErrorKind : void 0,
4689
5343
  error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0,
4690
- everConnected: this.hasSyncedOnce
5344
+ everConnected: this.hasSyncedOnce,
5345
+ connection: this.connectionState
4691
5346
  };
4692
5347
  }
4693
5348
  /**
@@ -4704,6 +5359,24 @@ var Sp00kySync = class Sp00kySync {
4704
5359
  this.events.emit(SyncEventTypes.SyncHealthChanged, this.syncHealth);
4705
5360
  }
4706
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
+ /**
4707
5380
  * Fed by the scheduler once per drained sync round. Individual failures are
4708
5381
  * absorbed by the queue's retry; only a run of `degradeAfterFailures`
4709
5382
  * consecutive failures flips the status to `degraded`, and the next clean
@@ -4805,6 +5478,7 @@ var Sp00kySync = class Sp00kySync {
4805
5478
  this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
4806
5479
  this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
4807
5480
  this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
5481
+ this.connectionSupervisor = options?.connectionSupervisor;
4808
5482
  }
4809
5483
  /**
4810
5484
  * Initializes the synchronization system.
@@ -4816,6 +5490,7 @@ var Sp00kySync = class Sp00kySync {
4816
5490
  this.isInit = true;
4817
5491
  await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
4818
5492
  this.subscribeToReconnect();
5493
+ this.subscribeToConnectionState();
4819
5494
  this.scheduler.syncUp();
4820
5495
  this.scheduler.syncDown();
4821
5496
  if (this.anonLiveEnabled && !this.currentUserId) {
@@ -5178,7 +5853,7 @@ var Sp00kySync = class Sp00kySync {
5178
5853
  this.liveQueryUnsubscribe = null;
5179
5854
  }
5180
5855
  if (this.currentLiveQueryUuid !== null) {
5181
- try {
5856
+ if (this.remote.getStatus() === "connected") try {
5182
5857
  await this.remote.query("KILL $u", { u: this.currentLiveQueryUuid });
5183
5858
  } catch (err) {
5184
5859
  this.logger.debug({
@@ -5193,15 +5868,39 @@ var Sp00kySync = class Sp00kySync {
5193
5868
  await this.killRefLiveQuery();
5194
5869
  await this.startRefLiveQueries();
5195
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
+ }
5196
5889
  subscribeToReconnect() {
5197
5890
  const client = this.remote.getClient();
5198
5891
  client.subscribe("disconnected", () => {
5199
- this.wasDisconnected = true;
5892
+ this.needsResubscribe = true;
5893
+ this.invalidateRefLiveQuery();
5200
5894
  this.logger.info({ Category: "sp00ky-client::Sp00kySync::onDisconnect" }, "Remote disconnected");
5201
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
+ });
5202
5901
  client.subscribe("connected", () => {
5203
- if (!this.wasDisconnected) return;
5204
- this.wasDisconnected = false;
5902
+ if (!this.needsResubscribe) return;
5903
+ this.needsResubscribe = false;
5205
5904
  this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
5206
5905
  for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
5207
5906
  type: "register",
@@ -5277,7 +5976,12 @@ var Sp00kySync = class Sp00kySync {
5277
5976
  }, "Live update is being processed");
5278
5977
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
5279
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
+ }
5280
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);
5281
5985
  }
5282
5986
  /**
5283
5987
  * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
@@ -5342,19 +6046,7 @@ var Sp00kySync = class Sp00kySync {
5342
6046
  * retries rather than rolling the mutation back.
5343
6047
  */
5344
6048
  withPushTimeout(promise, label) {
5345
- if (!(this.pushTimeoutMs > 0)) return promise;
5346
- return new Promise((resolve, reject) => {
5347
- const timer = setTimeout(() => {
5348
- reject(/* @__PURE__ */ new Error(`Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`));
5349
- }, this.pushTimeoutMs);
5350
- promise.then((value) => {
5351
- clearTimeout(timer);
5352
- resolve(value);
5353
- }, (err) => {
5354
- clearTimeout(timer);
5355
- reject(err);
5356
- });
5357
- });
6049
+ return withTimeout(promise, this.pushTimeoutMs, `Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`);
5358
6050
  }
5359
6051
  async processUpEvent(event) {
5360
6052
  this.logger.debug({
@@ -5528,16 +6220,7 @@ var Sp00kySync = class Sp00kySync {
5528
6220
  * would otherwise resurrect a just-deleted record.
5529
6221
  */
5530
6222
  async getPendingDeleteIds() {
5531
- try {
5532
- const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
5533
- return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
5534
- } catch (err) {
5535
- this.logger.warn({
5536
- err,
5537
- Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
5538
- }, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
5539
- return /* @__PURE__ */ new Set();
5540
- }
6223
+ return (await this.dataModule.getPendingRecordIds()).deletes;
5541
6224
  }
5542
6225
  /**
5543
6226
  * Enqueues a list of mutations (up events) to be sent to the remote.
@@ -5800,8 +6483,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
5800
6483
 
5801
6484
  //#endregion
5802
6485
  //#region src/modules/devtools/index.ts
5803
- const CORE_VERSION = "0.0.1-canary.160";
5804
- const WASM_VERSION = "0.0.1-canary.160";
6486
+ const CORE_VERSION = "0.0.1-canary.162";
6487
+ const WASM_VERSION = "0.0.1-canary.162";
5805
6488
  const SURREAL_VERSION = "3.0.3";
5806
6489
  var DevToolsService = class DevToolsService {
5807
6490
  eventsHistory = [];
@@ -7456,6 +8139,9 @@ var CrdtManager = class {
7456
8139
  fields = /* @__PURE__ */ new Map();
7457
8140
  liveByTable = /* @__PURE__ */ new Map();
7458
8141
  pendingLive = /* @__PURE__ */ new Map();
8142
+ staleTables = /* @__PURE__ */ new Set();
8143
+ connectionGeneration = 0;
8144
+ connectionUnsubscribes = [];
7459
8145
  logger;
7460
8146
  sessionId = "";
7461
8147
  constructor(schema, local, remote, logger, debounceMs = 500) {
@@ -7464,6 +8150,49 @@ var CrdtManager = class {
7464
8150
  this.remote = remote;
7465
8151
  this.debounceMs = debounceMs;
7466
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));
7467
8196
  }
7468
8197
  /** Set the session id that scopes this client's cursor entries. Must be
7469
8198
  * called before `open()` for cursors to be pushed under a stable key.
@@ -7521,8 +8250,10 @@ var CrdtManager = class {
7521
8250
  crdtField.stopSync();
7522
8251
  this.fields.delete(key);
7523
8252
  }
7524
- const tablePrefix = `${table}:`;
7525
- 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
+ }
7526
8257
  this.logger.debug({
7527
8258
  key,
7528
8259
  Category: "sp00ky-client::CrdtManager::close"
@@ -7537,21 +8268,38 @@ var CrdtManager = class {
7537
8268
  for (const [_, field] of this.fields) field.stopSync(options);
7538
8269
  this.fields.clear();
7539
8270
  for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
8271
+ this.staleTables.clear();
7540
8272
  }
7541
8273
  /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
7542
8274
  * every open CrdtField on `table`. */
7543
8275
  async ensureTableSubscription(table) {
7544
8276
  if (this.liveByTable.has(table)) return;
8277
+ const generation = this.connectionGeneration;
7545
8278
  const pending = this.pendingLive.get(table);
7546
- 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
+ }
7547
8287
  const start = (async () => {
7548
8288
  try {
7549
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
+ }
7550
8297
  (await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
7551
8298
  if (message.action === "KILLED") return;
7552
8299
  if (message.action !== "CREATE" && message.action !== "UPDATE") return;
7553
8300
  this.dispatchRow(table, message.value);
7554
8301
  });
8302
+ if (generation !== this.connectionGeneration) return;
7555
8303
  this.liveByTable.set(table, uuid);
7556
8304
  this.logger.info({
7557
8305
  table,
@@ -7565,7 +8313,10 @@ var CrdtManager = class {
7565
8313
  }, "Failed to start LIVE SELECT");
7566
8314
  }
7567
8315
  })();
7568
- this.pendingLive.set(table, start);
8316
+ this.pendingLive.set(table, {
8317
+ promise: start,
8318
+ generation
8319
+ });
7569
8320
  try {
7570
8321
  await start;
7571
8322
  } finally {
@@ -8896,6 +9647,7 @@ function writeBootBucketHint(bucketId) {
8896
9647
  var Sp00kyClient = class {
8897
9648
  local;
8898
9649
  remote;
9650
+ connectionSupervisor;
8899
9651
  persistenceClient;
8900
9652
  migrator;
8901
9653
  cache;
@@ -8977,6 +9729,7 @@ var Sp00kyClient = class {
8977
9729
  const tabsSupport = detectSharedTabsSupport(this.config);
8978
9730
  this.local = createLocalEngine(this.config.localEngine, this.config.database, logger, { shared: tabsSupport.supported });
8979
9731
  this.remote = new RemoteDatabaseService(this.config.database, logger);
9732
+ this.connectionSupervisor = new ConnectionSupervisor(this.remote, logger);
8980
9733
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
8981
9734
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
8982
9735
  else this.persistenceClient = config.persistenceClient;
@@ -8993,7 +9746,9 @@ var Sp00kyClient = class {
8993
9746
  this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, {
8994
9747
  refSyncIntervalMs: this.config.refSyncIntervalMs,
8995
9748
  anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
8996
- 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
8997
9752
  });
8998
9753
  this.featureFlags = new FeatureFlagModule({
8999
9754
  dataModule: this.dataModule,
@@ -9045,7 +9800,7 @@ var Sp00kyClient = class {
9045
9800
  return new TabsCoordinator({
9046
9801
  tabId,
9047
9802
  fingerprint: computeTabsFingerprint({
9048
- coreVersion: "0.0.1-canary.160",
9803
+ coreVersion: "0.0.1-canary.162",
9049
9804
  schemaHash: hash53(this.config.schemaSurql),
9050
9805
  endpoint: this.config.database.endpoint ?? "",
9051
9806
  namespace: this.config.database.namespace,
@@ -9185,6 +9940,7 @@ var Sp00kyClient = class {
9185
9940
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
9186
9941
  }
9187
9942
  await this.remote.connect();
9943
+ this.connectionSupervisor.start();
9188
9944
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
9189
9945
  this.streamProcessor.setStateKeySuffix(bootBucket);
9190
9946
  await this.streamProcessor.init();
@@ -9335,9 +10091,11 @@ var Sp00kyClient = class {
9335
10091
  }, "Local bucket switch complete");
9336
10092
  }
9337
10093
  async close() {
10094
+ this.connectionSupervisor.dispose();
9338
10095
  await this.featureFlags.closeAll();
9339
10096
  await this.appReleases.closeAll();
9340
10097
  this.crdtManager.closeAll();
10098
+ this.crdtManager.dispose();
9341
10099
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
9342
10100
  await this.local.close();
9343
10101
  await this.remote.close();