@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.d.ts +294 -4
- package/dist/index.js +1053 -295
- 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/queue/queue-up.forwarded.test.ts +27 -0
- package/src/modules/sync/queue/queue-up.ts +24 -5
- 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);
|
|
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
|
-
|
|
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("");
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
5204
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5804
|
-
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";
|
|
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
|
-
|
|
7525
|
-
|
|
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)
|
|
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,
|
|
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.
|
|
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();
|