@xnetjs/sqlite 0.0.3 → 0.1.1
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/{adapter-t2aqQ__n.d.ts → adapter-imtxkmXZ.d.ts} +22 -1
- package/dist/adapters/electron.d.ts +66 -6
- package/dist/adapters/electron.js +629 -47
- package/dist/adapters/expo.d.ts +6 -2
- package/dist/adapters/expo.js +8 -1
- package/dist/adapters/memory.d.ts +6 -2
- package/dist/adapters/memory.js +8 -1
- package/dist/adapters/reader-thread.d.ts +78 -0
- package/dist/adapters/reader-thread.js +57 -0
- package/dist/adapters/web-proxy.d.ts +54 -2
- package/dist/adapters/web-proxy.js +458 -55
- package/dist/adapters/web-router-worker.d.ts +76 -0
- package/dist/adapters/web-router-worker.js +91 -0
- package/dist/adapters/web-worker.d.ts +45 -1
- package/dist/adapters/web-worker.js +232 -17
- package/dist/adapters/web.d.ts +82 -3
- package/dist/adapters/web.js +7 -4
- package/dist/browser-support.d.ts +10 -1
- package/dist/browser-support.js +5 -1
- package/dist/chunk-5HC5V73T.js +191 -0
- package/dist/{chunk-DCY4LAUD.js → chunk-BBZDKLA3.js} +246 -13
- package/dist/chunk-CGI2YBZY.js +16 -0
- package/dist/{chunk-2OK46ZBU.js → chunk-S6MT6KCI.js} +22 -1
- package/dist/chunk-SV475UL5.js +44 -0
- package/dist/{chunk-NVD7G7DZ.js → chunk-W3L4FXU5.js} +11 -2
- package/dist/index.d.ts +93 -8
- package/dist/index.js +92 -10
- package/dist/schema-C4gufY3B.d.ts +35 -0
- package/dist/types-BTabr_VP.d.ts +225 -0
- package/dist/worker-scheduler-D04DqMmR.d.ts +56 -0
- package/package.json +5 -1
- package/dist/schema-BEgIA-rZ.d.ts +0 -35
- package/dist/types-DMrj3K4o.d.ts +0 -132
|
@@ -1,5 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readBootLogArgs
|
|
3
|
+
} from "../chunk-CGI2YBZY.js";
|
|
4
|
+
|
|
1
5
|
// src/adapters/web-proxy.ts
|
|
2
6
|
import * as Comlink from "comlink";
|
|
7
|
+
|
|
8
|
+
// src/adapters/open-retry.ts
|
|
9
|
+
async function openWithTimeoutRetry(makeAttempt, options = {}) {
|
|
10
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
11
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
12
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
13
|
+
const timeoutSeconds = Math.round(timeoutMs / 1e3);
|
|
14
|
+
let lastErr;
|
|
15
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
16
|
+
const controls = makeAttempt(attempt);
|
|
17
|
+
let timer;
|
|
18
|
+
const timeout = new Promise((_, reject) => {
|
|
19
|
+
timer = setTimeout(
|
|
20
|
+
() => reject(
|
|
21
|
+
new Error(
|
|
22
|
+
`Worker initialization timeout after ${timeoutSeconds}s (attempt ${attempt}/${maxAttempts})`
|
|
23
|
+
)
|
|
24
|
+
),
|
|
25
|
+
timeoutMs
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
await Promise.race([controls.open, timeout]);
|
|
30
|
+
return;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
lastErr = err;
|
|
33
|
+
controls.abandon();
|
|
34
|
+
if (attempt >= maxAttempts) break;
|
|
35
|
+
options.onRetry?.(attempt, err);
|
|
36
|
+
await sleep(250 * attempt);
|
|
37
|
+
} finally {
|
|
38
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw lastErr;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/adapters/web-leader.ts
|
|
45
|
+
var DB_LEADER_LOCK_NAME = "xnet-sqlite-db-leader";
|
|
46
|
+
var FOLLOWER_CONNECT_TIMEOUT_MS = 15e3;
|
|
47
|
+
var FOLLOWER_RETRY_DELAY_MS = 250;
|
|
48
|
+
var LeaderLostError = class extends Error {
|
|
49
|
+
constructor() {
|
|
50
|
+
super(
|
|
51
|
+
"SQLite leader tab closed while this call was in flight. Idempotent reads retry automatically; writes must be re-submitted."
|
|
52
|
+
);
|
|
53
|
+
this.name = "LeaderLostError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
function isMultiTabSupported() {
|
|
57
|
+
return typeof navigator !== "undefined" && typeof navigator.locks !== "undefined" && typeof globalThis.SharedWorker === "function";
|
|
58
|
+
}
|
|
59
|
+
function acquireTabRole(locks, onPromoted, lockName = DB_LEADER_LOCK_NAME) {
|
|
60
|
+
const held = { release: null };
|
|
61
|
+
const abort = new AbortController();
|
|
62
|
+
const hold = () => new Promise((resolve) => {
|
|
63
|
+
held.release = resolve;
|
|
64
|
+
});
|
|
65
|
+
const release = () => {
|
|
66
|
+
if (held.release) held.release();
|
|
67
|
+
else abort.abort();
|
|
68
|
+
};
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
locks.request(lockName, { mode: "exclusive", ifAvailable: true }, (lock) => {
|
|
71
|
+
if (lock === null) {
|
|
72
|
+
resolve({ role: "follower", release });
|
|
73
|
+
void locks.request(lockName, { mode: "exclusive", signal: abort.signal }, () => {
|
|
74
|
+
onPromoted();
|
|
75
|
+
return hold();
|
|
76
|
+
}).catch(() => {
|
|
77
|
+
});
|
|
78
|
+
return void 0;
|
|
79
|
+
}
|
|
80
|
+
resolve({ role: "leader", release });
|
|
81
|
+
return hold();
|
|
82
|
+
}).catch(reject);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function serveLeaderPorts(router, mintPort) {
|
|
86
|
+
const unsubscribe = router.onMessage((message) => {
|
|
87
|
+
const msg = message;
|
|
88
|
+
if (msg.t !== "mint-db-port" || typeof msg.requestId !== "string") return;
|
|
89
|
+
void mintPort().then((port) => {
|
|
90
|
+
router.post({ t: "db-port", requestId: msg.requestId }, [port]);
|
|
91
|
+
}).catch((err) => {
|
|
92
|
+
router.post({
|
|
93
|
+
t: "db-port-failed",
|
|
94
|
+
requestId: msg.requestId,
|
|
95
|
+
error: err instanceof Error ? err.message : String(err)
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
router.post({ t: "leader-ready" });
|
|
100
|
+
return unsubscribe;
|
|
101
|
+
}
|
|
102
|
+
async function requestDbPort(router, options) {
|
|
103
|
+
const timeoutMs = options?.timeoutMs ?? FOLLOWER_CONNECT_TIMEOUT_MS;
|
|
104
|
+
const retryDelayMs = options?.retryDelayMs ?? FOLLOWER_RETRY_DELAY_MS;
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
let attempt = 0;
|
|
107
|
+
for (; ; ) {
|
|
108
|
+
attempt += 1;
|
|
109
|
+
const requestId = `port-${attempt}-${Math.random().toString(36).slice(2)}`;
|
|
110
|
+
const result = await new Promise((resolve) => {
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
cleanup();
|
|
113
|
+
resolve("retry");
|
|
114
|
+
}, retryDelayMs * 4);
|
|
115
|
+
const cleanup = router.onMessage((message, ports) => {
|
|
116
|
+
const msg = message;
|
|
117
|
+
if (msg.requestId !== requestId) return;
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
cleanup();
|
|
120
|
+
if (msg.t === "db-port" && ports[0]) resolve(ports[0]);
|
|
121
|
+
else if (msg.t === "no-leader") resolve("retry");
|
|
122
|
+
else resolve("failed");
|
|
123
|
+
});
|
|
124
|
+
router.post({ t: "request-db-port", requestId });
|
|
125
|
+
});
|
|
126
|
+
if (result !== "retry" && result !== "failed") {
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
if (result === "failed" || Date.now() + retryDelayMs > deadline) {
|
|
130
|
+
throw new Error("Timed out waiting for the SQLite leader tab to share a database port");
|
|
131
|
+
}
|
|
132
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
var FollowerCallGuard = class {
|
|
136
|
+
pending = /* @__PURE__ */ new Set();
|
|
137
|
+
lost = false;
|
|
138
|
+
/**
|
|
139
|
+
* Run `op`, racing it against leader loss. `retry` (reads only — they're
|
|
140
|
+
* idempotent) re-runs the op once via `whenReconnected` after a loss.
|
|
141
|
+
*/
|
|
142
|
+
async run(op, opts = {}) {
|
|
143
|
+
if (this.lost && !opts.retry) throw new LeaderLostError();
|
|
144
|
+
let rejectPending;
|
|
145
|
+
const lossSignal = new Promise((_, reject) => {
|
|
146
|
+
rejectPending = reject;
|
|
147
|
+
this.pending.add(reject);
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
return await Promise.race([op(), lossSignal]);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err instanceof LeaderLostError && opts.retry) {
|
|
153
|
+
return opts.retry();
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
} finally {
|
|
157
|
+
this.pending.delete(rejectPending);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Reject every in-flight call. Safe to call more than once. */
|
|
161
|
+
markLeaderLost() {
|
|
162
|
+
this.lost = true;
|
|
163
|
+
for (const reject of this.pending) {
|
|
164
|
+
reject(new LeaderLostError());
|
|
165
|
+
}
|
|
166
|
+
this.pending.clear();
|
|
167
|
+
}
|
|
168
|
+
/** The connection was re-established (this tab promoted or re-attached). */
|
|
169
|
+
markReconnected() {
|
|
170
|
+
this.lost = false;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
function wrapRouterPort(port) {
|
|
174
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
175
|
+
port.onmessage = (event) => {
|
|
176
|
+
for (const listener of [...listeners]) {
|
|
177
|
+
listener(event.data, event.ports);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
port.start?.();
|
|
181
|
+
return {
|
|
182
|
+
post(message, transfer2) {
|
|
183
|
+
if (transfer2 && transfer2.length > 0) port.postMessage(message, transfer2);
|
|
184
|
+
else port.postMessage(message);
|
|
185
|
+
},
|
|
186
|
+
onMessage(listener) {
|
|
187
|
+
listeners.add(listener);
|
|
188
|
+
return () => listeners.delete(listener);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function connectRouter() {
|
|
193
|
+
if (!isMultiTabSupported()) return null;
|
|
194
|
+
try {
|
|
195
|
+
const worker = new SharedWorker(new URL("./web-router-worker.js", import.meta.url), {
|
|
196
|
+
type: "module",
|
|
197
|
+
name: "xnet-sqlite-router"
|
|
198
|
+
});
|
|
199
|
+
return wrapRouterPort(worker.port);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.warn("[WebSQLiteLeader] SharedWorker router unavailable:", err);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/adapters/web-proxy.ts
|
|
3
207
|
function isDebugEnabled() {
|
|
4
208
|
return typeof localStorage !== "undefined" && localStorage.getItem("xnet:sqlite:debug") === "true";
|
|
5
209
|
}
|
|
@@ -33,6 +237,20 @@ var WebSQLiteProxy = class {
|
|
|
33
237
|
_config = null;
|
|
34
238
|
inTransaction = false;
|
|
35
239
|
operationStats = createEmptyOperationStats();
|
|
240
|
+
// ─── Multi-tab leadership (exploration 0263) ────────────────────────────
|
|
241
|
+
/** 'single-tab' = the pre-0263 per-tab worker path (or multiTab: false). */
|
|
242
|
+
role = "single-tab";
|
|
243
|
+
roleHandle = null;
|
|
244
|
+
router = null;
|
|
245
|
+
unsubscribeLeaderServe = null;
|
|
246
|
+
unsubscribeRouterEvents = null;
|
|
247
|
+
/** The Comlink port into the LEADER's SQLite worker (follower role only). */
|
|
248
|
+
followerPort = null;
|
|
249
|
+
guard = new FollowerCallGuard();
|
|
250
|
+
/** Resolvers waiting for the connection to come back after leader loss. */
|
|
251
|
+
reconnectWaiters = [];
|
|
252
|
+
/** Serializes role transitions (promotion vs. reattach can race). */
|
|
253
|
+
transition = Promise.resolve();
|
|
36
254
|
createWorkerProxy() {
|
|
37
255
|
log("[WebSQLiteProxy] Creating worker...");
|
|
38
256
|
this.worker = new Worker(new URL("./web-worker.js", import.meta.url), { type: "module" });
|
|
@@ -42,24 +260,193 @@ var WebSQLiteProxy = class {
|
|
|
42
260
|
this.worker.onmessageerror = (event) => {
|
|
43
261
|
console.error("[WebSQLiteProxy] Worker message error:", event);
|
|
44
262
|
};
|
|
263
|
+
this.worker.addEventListener("message", (event) => {
|
|
264
|
+
const bootLogArgs = readBootLogArgs(event.data);
|
|
265
|
+
if (bootLogArgs) {
|
|
266
|
+
console.info(...bootLogArgs);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
45
269
|
log("[WebSQLiteProxy] Worker created, wrapping with Comlink...");
|
|
46
270
|
this.proxy = Comlink.wrap(this.worker);
|
|
47
271
|
return this.proxy;
|
|
48
272
|
}
|
|
49
273
|
async open(config) {
|
|
50
|
-
if (this.worker) {
|
|
274
|
+
if (this.worker || this.proxy) {
|
|
51
275
|
throw new Error("Already open. Call close() first.");
|
|
52
276
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
277
|
+
if (config.multiTab !== false && isMultiTabSupported()) {
|
|
278
|
+
try {
|
|
279
|
+
await this.openMultiTab(config);
|
|
280
|
+
this._config = config;
|
|
281
|
+
return;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.warn(
|
|
284
|
+
"[WebSQLiteProxy] Multi-tab routing failed \u2014 falling back to single-tab open:",
|
|
285
|
+
err
|
|
286
|
+
);
|
|
287
|
+
this.teardownMultiTab();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await this.openSingleTab(config);
|
|
291
|
+
this._config = config;
|
|
292
|
+
}
|
|
293
|
+
async openSingleTab(config) {
|
|
294
|
+
await openWithTimeoutRetry(
|
|
295
|
+
() => {
|
|
296
|
+
const proxy = this.createWorkerProxy();
|
|
297
|
+
log("[WebSQLiteProxy] Calling proxy.open()...");
|
|
298
|
+
this.operationStats.workerRequestCount += 1;
|
|
299
|
+
return {
|
|
300
|
+
open: proxy.open(config),
|
|
301
|
+
// (createWorkerProxy() set this.worker, but the top `if (this.worker)
|
|
302
|
+
// throw` guard narrowed it to null here — re-widen the read.)
|
|
303
|
+
abandon: () => {
|
|
304
|
+
const worker = this.worker;
|
|
305
|
+
worker?.terminate();
|
|
306
|
+
this.worker = null;
|
|
307
|
+
this.proxy = null;
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
timeoutMs: config.openTimeoutMs ?? 15e3,
|
|
313
|
+
onRetry: (attempt, err) => {
|
|
314
|
+
console.warn(
|
|
315
|
+
`[WebSQLiteProxy] SQLite worker open timed out (attempt ${attempt}); terminated the stuck worker to release its OPFS handle and retrying with a fresh worker (this usually clears handle contention left by a prior boot).`,
|
|
316
|
+
err
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
59
320
|
);
|
|
60
|
-
await Promise.race([openPromise, timeoutPromise]);
|
|
61
321
|
log("[WebSQLiteProxy] proxy.open() completed");
|
|
62
|
-
|
|
322
|
+
}
|
|
323
|
+
// ─── Multi-tab open / transitions (exploration 0263) ────────────────────
|
|
324
|
+
async openMultiTab(config) {
|
|
325
|
+
const router = connectRouter();
|
|
326
|
+
if (!router) {
|
|
327
|
+
throw new Error("SharedWorker router unavailable");
|
|
328
|
+
}
|
|
329
|
+
this.router = router;
|
|
330
|
+
const locks = navigator.locks;
|
|
331
|
+
this.roleHandle = await acquireTabRole(locks, () => {
|
|
332
|
+
this.enqueueTransition(() => this.promoteToLeader());
|
|
333
|
+
});
|
|
334
|
+
this.role = this.roleHandle.role;
|
|
335
|
+
if (this.role === "leader") {
|
|
336
|
+
await this.openSingleTab(config);
|
|
337
|
+
this.becomeLeaderOnRouter();
|
|
338
|
+
} else {
|
|
339
|
+
this.unsubscribeRouterEvents = router.onMessage((message) => {
|
|
340
|
+
const msg = message;
|
|
341
|
+
if (msg.t === "leader-changed" && this.role === "follower") {
|
|
342
|
+
this.enqueueTransition(() => this.reattachFollower());
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
await this.attachToLeader();
|
|
346
|
+
}
|
|
347
|
+
console.info("[xNet] sqlite tab role", this.role);
|
|
348
|
+
}
|
|
349
|
+
becomeLeaderOnRouter() {
|
|
350
|
+
if (!this.router) return;
|
|
351
|
+
this.unsubscribeLeaderServe = serveLeaderPorts(this.router, () => this.createMessagePort());
|
|
352
|
+
}
|
|
353
|
+
async attachToLeader() {
|
|
354
|
+
if (!this.router) throw new Error("router not connected");
|
|
355
|
+
const port = await requestDbPort(this.router);
|
|
356
|
+
this.followerPort = port;
|
|
357
|
+
this.proxy = Comlink.wrap(port);
|
|
358
|
+
const isOpen = await this.guard.run(() => this.proxy.isOpen());
|
|
359
|
+
if (!isOpen) {
|
|
360
|
+
throw new Error("leader database is not open");
|
|
361
|
+
}
|
|
362
|
+
this.guard.markReconnected();
|
|
363
|
+
this.notifyReconnected();
|
|
364
|
+
}
|
|
365
|
+
/** This tab won the leadership lock after the previous leader went away. */
|
|
366
|
+
async promoteToLeader() {
|
|
367
|
+
if (this.role !== "follower" || !this._config) return;
|
|
368
|
+
log("[WebSQLiteProxy] Promoted to SQLite leader \u2014 opening own worker");
|
|
369
|
+
this.guard.markLeaderLost();
|
|
370
|
+
this.followerPort?.close();
|
|
371
|
+
this.followerPort = null;
|
|
372
|
+
this.proxy = null;
|
|
373
|
+
this.unsubscribeRouterEvents?.();
|
|
374
|
+
this.unsubscribeRouterEvents = null;
|
|
375
|
+
try {
|
|
376
|
+
await this.openSingleTab(this._config);
|
|
377
|
+
this.role = "leader";
|
|
378
|
+
this.becomeLeaderOnRouter();
|
|
379
|
+
this.guard.markReconnected();
|
|
380
|
+
this.notifyReconnected();
|
|
381
|
+
console.info("[xNet] sqlite tab role", this.role, "(promoted)");
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.error("[WebSQLiteProxy] Leader promotion failed:", err);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Another tab became leader; drop the dead port and fetch a fresh one. */
|
|
387
|
+
async reattachFollower() {
|
|
388
|
+
if (this.role !== "follower" || !this.router) return;
|
|
389
|
+
this.guard.markLeaderLost();
|
|
390
|
+
this.followerPort?.close();
|
|
391
|
+
this.followerPort = null;
|
|
392
|
+
this.proxy = null;
|
|
393
|
+
try {
|
|
394
|
+
await this.attachToLeader();
|
|
395
|
+
log("[WebSQLiteProxy] Reattached to new SQLite leader");
|
|
396
|
+
} catch (err) {
|
|
397
|
+
console.error("[WebSQLiteProxy] Reattach to new leader failed:", err);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
enqueueTransition(fn) {
|
|
401
|
+
this.transition = this.transition.then(fn, fn);
|
|
402
|
+
}
|
|
403
|
+
notifyReconnected() {
|
|
404
|
+
const waiters = this.reconnectWaiters;
|
|
405
|
+
this.reconnectWaiters = [];
|
|
406
|
+
for (const resolve of waiters) resolve();
|
|
407
|
+
}
|
|
408
|
+
whenReconnected() {
|
|
409
|
+
if (this.proxy) return Promise.resolve();
|
|
410
|
+
return new Promise((resolve) => {
|
|
411
|
+
this.reconnectWaiters.push(resolve);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
teardownMultiTab() {
|
|
415
|
+
this.unsubscribeLeaderServe?.();
|
|
416
|
+
this.unsubscribeLeaderServe = null;
|
|
417
|
+
this.unsubscribeRouterEvents?.();
|
|
418
|
+
this.unsubscribeRouterEvents = null;
|
|
419
|
+
this.roleHandle?.release();
|
|
420
|
+
this.roleHandle = null;
|
|
421
|
+
this.followerPort?.close();
|
|
422
|
+
this.followerPort = null;
|
|
423
|
+
if (this.role === "follower") this.proxy = null;
|
|
424
|
+
this.router = null;
|
|
425
|
+
this.role = "single-tab";
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Run a worker RPC with follower protections: leader loss REJECTS in-flight
|
|
429
|
+
* calls immediately instead of hanging, and `retryable` (idempotent read)
|
|
430
|
+
* calls transparently re-issue once the connection is re-established —
|
|
431
|
+
* against this tab's own worker if it was promoted, or the new leader's.
|
|
432
|
+
*/
|
|
433
|
+
rpc(retryable, op) {
|
|
434
|
+
const exec = async () => {
|
|
435
|
+
const proxy = this.proxy;
|
|
436
|
+
if (!proxy) throw new Error("Database not open");
|
|
437
|
+
this.operationStats.workerRequestCount += 1;
|
|
438
|
+
return op(proxy);
|
|
439
|
+
};
|
|
440
|
+
if (this.role !== "follower") return exec();
|
|
441
|
+
return this.guard.run(
|
|
442
|
+
exec,
|
|
443
|
+
retryable ? {
|
|
444
|
+
retry: async () => {
|
|
445
|
+
await this.whenReconnected();
|
|
446
|
+
return exec();
|
|
447
|
+
}
|
|
448
|
+
} : {}
|
|
449
|
+
);
|
|
63
450
|
}
|
|
64
451
|
async resetStorage(config) {
|
|
65
452
|
if (this.worker) {
|
|
@@ -78,6 +465,14 @@ var WebSQLiteProxy = class {
|
|
|
78
465
|
}
|
|
79
466
|
}
|
|
80
467
|
async close() {
|
|
468
|
+
if (this.role === "follower") {
|
|
469
|
+
this.teardownMultiTab();
|
|
470
|
+
this._config = null;
|
|
471
|
+
this.inTransaction = false;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.unsubscribeLeaderServe?.();
|
|
475
|
+
this.unsubscribeLeaderServe = null;
|
|
81
476
|
if (this.proxy) {
|
|
82
477
|
this.operationStats.workerRequestCount += 1;
|
|
83
478
|
await this.proxy.close();
|
|
@@ -87,6 +482,7 @@ var WebSQLiteProxy = class {
|
|
|
87
482
|
this.worker.terminate();
|
|
88
483
|
this.worker = null;
|
|
89
484
|
}
|
|
485
|
+
this.teardownMultiTab();
|
|
90
486
|
this._config = null;
|
|
91
487
|
this.inTransaction = false;
|
|
92
488
|
}
|
|
@@ -94,30 +490,31 @@ var WebSQLiteProxy = class {
|
|
|
94
490
|
return this.proxy !== null;
|
|
95
491
|
}
|
|
96
492
|
async query(sql, params) {
|
|
97
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
98
493
|
this.operationStats.queryCount += 1;
|
|
99
|
-
this.
|
|
100
|
-
const result = await this.proxy.query(sql, params);
|
|
494
|
+
const result = await this.rpc(true, (proxy) => proxy.query(sql, params));
|
|
101
495
|
return result;
|
|
102
496
|
}
|
|
103
497
|
async queryOne(sql, params) {
|
|
104
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
105
498
|
this.operationStats.queryOneCount += 1;
|
|
106
|
-
this.
|
|
107
|
-
const result = await this.proxy.queryOne(sql, params);
|
|
499
|
+
const result = await this.rpc(true, (proxy) => proxy.queryOne(sql, params));
|
|
108
500
|
return result;
|
|
109
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Execute several reads in ONE worker round-trip. N queries previously cost
|
|
504
|
+
* N postMessage round-trips; a batch costs one (exploration 0263).
|
|
505
|
+
*/
|
|
506
|
+
async queryBatch(reads) {
|
|
507
|
+
if (reads.length === 0) return [];
|
|
508
|
+
this.operationStats.queryCount += reads.length;
|
|
509
|
+
return this.rpc(true, (proxy) => proxy.queryBatch(reads));
|
|
510
|
+
}
|
|
110
511
|
async run(sql, params) {
|
|
111
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
112
512
|
this.operationStats.runCount += 1;
|
|
113
|
-
this.
|
|
114
|
-
return this.proxy.run(sql, params);
|
|
513
|
+
return this.rpc(false, (proxy) => proxy.run(sql, params));
|
|
115
514
|
}
|
|
116
515
|
async exec(sql) {
|
|
117
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
118
516
|
this.operationStats.execCount += 1;
|
|
119
|
-
this.
|
|
120
|
-
return this.proxy.exec(sql);
|
|
517
|
+
return this.rpc(false, (proxy) => proxy.exec(sql));
|
|
121
518
|
}
|
|
122
519
|
async transaction(_fn) {
|
|
123
520
|
throw new Error("Complex transactions not supported in proxy. Use transactionBatch() instead.");
|
|
@@ -135,66 +532,55 @@ var WebSQLiteProxy = class {
|
|
|
135
532
|
* ```
|
|
136
533
|
*/
|
|
137
534
|
async transactionBatch(operations) {
|
|
138
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
139
535
|
this.operationStats.transactionBatchCount += 1;
|
|
140
536
|
this.operationStats.transactionBatchOperationCount += operations.length;
|
|
141
|
-
this.
|
|
142
|
-
await this.proxy.transaction(operations);
|
|
537
|
+
await this.rpc(false, (proxy) => proxy.transaction(operations));
|
|
143
538
|
}
|
|
144
539
|
async applyNodeBatch(input) {
|
|
145
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
146
540
|
this.operationStats.transactionBatchCount += 1;
|
|
147
541
|
this.operationStats.transactionBatchOperationCount += estimateNodeBatchSqlOperations(input);
|
|
148
|
-
this.
|
|
149
|
-
return this.proxy.applyNodeBatch(input);
|
|
542
|
+
return this.rpc(false, (proxy) => proxy.applyNodeBatch(input));
|
|
150
543
|
}
|
|
151
544
|
async beginTransaction() {
|
|
152
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
153
545
|
if (this.inTransaction) {
|
|
154
546
|
throw new Error("Transaction already in progress");
|
|
155
547
|
}
|
|
156
548
|
this.operationStats.execCount += 1;
|
|
157
|
-
this.
|
|
158
|
-
await this.proxy.exec("BEGIN IMMEDIATE");
|
|
549
|
+
await this.rpc(false, (proxy) => proxy.exec("BEGIN IMMEDIATE"));
|
|
159
550
|
this.inTransaction = true;
|
|
160
551
|
}
|
|
161
552
|
async commit() {
|
|
162
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
163
553
|
if (!this.inTransaction) {
|
|
164
554
|
throw new Error("No transaction in progress");
|
|
165
555
|
}
|
|
166
556
|
this.operationStats.execCount += 1;
|
|
167
|
-
this.
|
|
168
|
-
await this.proxy.exec("COMMIT");
|
|
557
|
+
await this.rpc(false, (proxy) => proxy.exec("COMMIT"));
|
|
169
558
|
this.inTransaction = false;
|
|
170
559
|
}
|
|
171
560
|
async rollback() {
|
|
172
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
173
561
|
if (!this.inTransaction) {
|
|
174
562
|
return;
|
|
175
563
|
}
|
|
176
564
|
this.operationStats.execCount += 1;
|
|
177
|
-
this.
|
|
178
|
-
await this.proxy.exec("ROLLBACK");
|
|
565
|
+
await this.rpc(false, (proxy) => proxy.exec("ROLLBACK"));
|
|
179
566
|
this.inTransaction = false;
|
|
180
567
|
}
|
|
181
568
|
async prepare(_sql) {
|
|
182
569
|
throw new Error("Prepared statements not supported in proxy. Use query() or run() directly.");
|
|
183
570
|
}
|
|
184
571
|
async getSchemaVersion() {
|
|
185
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
186
572
|
this.operationStats.queryOneCount += 1;
|
|
187
|
-
this.
|
|
188
|
-
return this.proxy.getSchemaVersion();
|
|
573
|
+
return this.rpc(true, (proxy) => proxy.getSchemaVersion());
|
|
189
574
|
}
|
|
190
575
|
async setSchemaVersion(version) {
|
|
191
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
192
576
|
this.operationStats.runCount += 1;
|
|
193
|
-
this.
|
|
194
|
-
|
|
195
|
-
version,
|
|
196
|
-
|
|
197
|
-
|
|
577
|
+
await this.rpc(
|
|
578
|
+
false,
|
|
579
|
+
(proxy) => proxy.run("INSERT INTO _schema_version (version, applied_at) VALUES (?, ?)", [
|
|
580
|
+
version,
|
|
581
|
+
Date.now()
|
|
582
|
+
])
|
|
583
|
+
);
|
|
198
584
|
}
|
|
199
585
|
async applySchema(version, sql) {
|
|
200
586
|
const currentVersion = await this.getSchemaVersion();
|
|
@@ -204,24 +590,23 @@ var WebSQLiteProxy = class {
|
|
|
204
590
|
return true;
|
|
205
591
|
}
|
|
206
592
|
async getDatabaseSize() {
|
|
207
|
-
|
|
208
|
-
this.operationStats.workerRequestCount += 1;
|
|
209
|
-
return this.proxy.getDatabaseSize();
|
|
593
|
+
return this.rpc(true, (proxy) => proxy.getDatabaseSize());
|
|
210
594
|
}
|
|
211
595
|
async vacuum() {
|
|
596
|
+
return this.rpc(false, (proxy) => proxy.vacuum());
|
|
597
|
+
}
|
|
598
|
+
async incrementalVacuum(maxPages) {
|
|
212
599
|
if (!this.proxy) throw new Error("Database not open");
|
|
213
600
|
this.operationStats.workerRequestCount += 1;
|
|
214
|
-
return this.proxy.
|
|
601
|
+
return this.proxy.incrementalVacuum(maxPages);
|
|
215
602
|
}
|
|
216
603
|
async checkpoint() {
|
|
217
604
|
return 0;
|
|
218
605
|
}
|
|
219
606
|
async getStorageMode() {
|
|
220
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
221
607
|
try {
|
|
222
608
|
log("[WebSQLiteProxy] Calling proxy.getStorageMode()...");
|
|
223
|
-
this.
|
|
224
|
-
const mode = await this.proxy.getStorageMode();
|
|
609
|
+
const mode = await this.rpc(true, (proxy) => proxy.getStorageMode());
|
|
225
610
|
log("[WebSQLiteProxy] getStorageMode() returned:", mode);
|
|
226
611
|
return mode;
|
|
227
612
|
} catch (err) {
|
|
@@ -229,6 +614,10 @@ var WebSQLiteProxy = class {
|
|
|
229
614
|
throw err;
|
|
230
615
|
}
|
|
231
616
|
}
|
|
617
|
+
/** This tab's multi-tab role: 'leader', 'follower', or 'single-tab'. */
|
|
618
|
+
getTabRole() {
|
|
619
|
+
return this.role;
|
|
620
|
+
}
|
|
232
621
|
/**
|
|
233
622
|
* Create a MessagePort connected directly to the SQLite worker.
|
|
234
623
|
*
|
|
@@ -237,12 +626,26 @@ var WebSQLiteProxy = class {
|
|
|
237
626
|
* (e.g. the data worker) so its storage calls skip the main thread.
|
|
238
627
|
*/
|
|
239
628
|
async createMessagePort() {
|
|
240
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
241
629
|
const channel = new MessageChannel();
|
|
242
|
-
this.
|
|
243
|
-
|
|
630
|
+
await this.rpc(
|
|
631
|
+
false,
|
|
632
|
+
(proxy) => proxy.connectPort(Comlink.transfer(channel.port1, [channel.port1]))
|
|
633
|
+
);
|
|
244
634
|
return channel.port2;
|
|
245
635
|
}
|
|
636
|
+
/**
|
|
637
|
+
* Worker-side scheduler stats: per-lane p50/p95 queue+exec latency and
|
|
638
|
+
* coalesce hits (exploration 0263). Complements getOperationStats(), which
|
|
639
|
+
* counts main-thread RPCs — together they give "how many round-trips" and
|
|
640
|
+
* "how long each op spent in the worker".
|
|
641
|
+
*/
|
|
642
|
+
async getSchedulerOpStats() {
|
|
643
|
+
return this.rpc(true, (proxy) => proxy.getSchedulerOpStats());
|
|
644
|
+
}
|
|
645
|
+
/** Zero the worker-side scheduler stats for a focused measurement. */
|
|
646
|
+
async resetSchedulerOpStats() {
|
|
647
|
+
return this.rpc(false, (proxy) => proxy.resetSchedulerOpStats());
|
|
648
|
+
}
|
|
246
649
|
getOperationStats() {
|
|
247
650
|
return cloneOperationStats(this.operationStats);
|
|
248
651
|
}
|