@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.d.ts +294 -4
- package/dist/index.js +1033 -293
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +98 -1
- package/package.json +3 -3
- package/src/modules/crdt/crdt-hydration.test.ts +4 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +109 -7
- package/src/modules/data/data.membership.test.ts +297 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +291 -39
- package/src/modules/data/window-query.ts +53 -13
- package/src/modules/sync/sync.live-removal.test.ts +134 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.ts +143 -43
- package/src/services/database/connection-supervisor.test.ts +255 -0
- package/src/services/database/connection-supervisor.ts +351 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +21 -1
- package/src/services/database/index.ts +1 -0
- package/src/services/database/local-migrator.ts +1 -0
- package/src/services/database/remote.ts +99 -3
- package/src/services/database/sqlite-cache-engine.ts +1 -0
- package/src/sp00ky.ts +32 -1
- package/src/types.ts +99 -0
- package/src/utils/index.ts +38 -0
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, "&").replace(/</g, "<").replace(/>/g, ">").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, "&").replace(/</g, "<").replace(/>/g, ">").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
|
-
|
|
885
|
-
|
|
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
|
-
|
|
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
|
|
2886
|
-
|
|
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 (
|
|
2889
|
-
const
|
|
2890
|
-
if (plan)
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
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,
|
|
3370
|
+
} else if (plan) records = await this.local.select(plan, config.params);
|
|
2904
3371
|
else {
|
|
2905
|
-
const [rows] = await this.local.query(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
5222
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5822
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
|
|
7543
|
-
|
|
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)
|
|
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,
|
|
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.
|
|
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();
|