@xnetjs/sqlite 0.0.2 → 0.1.0
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-I7yAV6iu.d.ts → adapter-imtxkmXZ.d.ts} +48 -1
- package/dist/adapters/electron.d.ts +70 -6
- package/dist/adapters/electron.js +662 -36
- package/dist/adapters/expo.d.ts +6 -2
- package/dist/adapters/expo.js +34 -6
- 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 +71 -3
- package/dist/adapters/web-proxy.js +554 -39
- 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 +56 -1
- package/dist/adapters/web-worker.js +253 -14
- package/dist/adapters/web.d.ts +90 -3
- package/dist/adapters/web.js +10 -4
- package/dist/browser-support.d.ts +65 -1
- package/dist/browser-support.js +15 -3
- package/dist/chunk-5HC5V73T.js +191 -0
- package/dist/chunk-BBZDKLA3.js +727 -0
- package/dist/chunk-CBU263LI.js +30 -0
- package/dist/chunk-CGI2YBZY.js +16 -0
- package/dist/chunk-S6MT6KCI.js +279 -0
- package/dist/chunk-SV475UL5.js +44 -0
- package/dist/chunk-W3L4FXU5.js +415 -0
- package/dist/index.d.ts +111 -8
- package/dist/index.js +219 -130
- 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/chunk-BXYZU3OL.js +0 -245
- package/dist/chunk-HIREU5S5.js +0 -193
- package/dist/chunk-ZRR5D2OD.js +0 -140
- package/dist/schema-CjkXTqxn.d.ts +0 -35
- package/dist/types-C_aHfRDF.d.ts +0 -42
|
@@ -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
|
}
|
|
@@ -8,14 +212,46 @@ function log(...args) {
|
|
|
8
212
|
console.log(...args);
|
|
9
213
|
}
|
|
10
214
|
}
|
|
215
|
+
function createEmptyOperationStats() {
|
|
216
|
+
return {
|
|
217
|
+
queryCount: 0,
|
|
218
|
+
queryOneCount: 0,
|
|
219
|
+
runCount: 0,
|
|
220
|
+
execCount: 0,
|
|
221
|
+
transactionCount: 0,
|
|
222
|
+
transactionBatchCount: 0,
|
|
223
|
+
transactionBatchOperationCount: 0,
|
|
224
|
+
workerRequestCount: 0
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function cloneOperationStats(stats) {
|
|
228
|
+
return { ...stats };
|
|
229
|
+
}
|
|
230
|
+
function estimateNodeBatchSqlOperations(input) {
|
|
231
|
+
const indexOperations = input.indexMode === "defer-schema" ? 0 : input.nodes.length + input.scalarIndexRows.length + input.ftsNodeIds.length + input.ftsRows.length + input.affectedSchemaIds.length;
|
|
232
|
+
return input.nodes.length * 2 + input.properties.length + input.changes.length + indexOperations + 1;
|
|
233
|
+
}
|
|
11
234
|
var WebSQLiteProxy = class {
|
|
12
235
|
worker = null;
|
|
13
236
|
proxy = null;
|
|
14
237
|
_config = null;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
238
|
+
inTransaction = false;
|
|
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();
|
|
254
|
+
createWorkerProxy() {
|
|
19
255
|
log("[WebSQLiteProxy] Creating worker...");
|
|
20
256
|
this.worker = new Worker(new URL("./web-worker.js", import.meta.url), { type: "module" });
|
|
21
257
|
this.worker.onerror = (event) => {
|
|
@@ -24,19 +260,221 @@ var WebSQLiteProxy = class {
|
|
|
24
260
|
this.worker.onmessageerror = (event) => {
|
|
25
261
|
console.error("[WebSQLiteProxy] Worker message error:", event);
|
|
26
262
|
};
|
|
263
|
+
this.worker.addEventListener("message", (event) => {
|
|
264
|
+
const bootLogArgs = readBootLogArgs(event.data);
|
|
265
|
+
if (bootLogArgs) {
|
|
266
|
+
console.info(...bootLogArgs);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
27
269
|
log("[WebSQLiteProxy] Worker created, wrapping with Comlink...");
|
|
28
270
|
this.proxy = Comlink.wrap(this.worker);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
271
|
+
return this.proxy;
|
|
272
|
+
}
|
|
273
|
+
async open(config) {
|
|
274
|
+
if (this.worker || this.proxy) {
|
|
275
|
+
throw new Error("Already open. Call close() first.");
|
|
276
|
+
}
|
|
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
|
+
}
|
|
33
320
|
);
|
|
34
|
-
await Promise.race([openPromise, timeoutPromise]);
|
|
35
321
|
log("[WebSQLiteProxy] proxy.open() completed");
|
|
36
|
-
|
|
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
|
+
);
|
|
450
|
+
}
|
|
451
|
+
async resetStorage(config) {
|
|
452
|
+
if (this.worker) {
|
|
453
|
+
throw new Error("Already open. Call close() first.");
|
|
454
|
+
}
|
|
455
|
+
const proxy = this.createWorkerProxy();
|
|
456
|
+
const resetPromise = proxy.resetStorage(config);
|
|
457
|
+
this.operationStats.workerRequestCount += 1;
|
|
458
|
+
const timeoutPromise = new Promise(
|
|
459
|
+
(_, reject) => setTimeout(() => reject(new Error("Worker storage reset timeout after 15s")), 15e3)
|
|
460
|
+
);
|
|
461
|
+
try {
|
|
462
|
+
await Promise.race([resetPromise, timeoutPromise]);
|
|
463
|
+
} finally {
|
|
464
|
+
await this.close();
|
|
465
|
+
}
|
|
37
466
|
}
|
|
38
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;
|
|
39
476
|
if (this.proxy) {
|
|
477
|
+
this.operationStats.workerRequestCount += 1;
|
|
40
478
|
await this.proxy.close();
|
|
41
479
|
this.proxy = null;
|
|
42
480
|
}
|
|
@@ -44,28 +482,39 @@ var WebSQLiteProxy = class {
|
|
|
44
482
|
this.worker.terminate();
|
|
45
483
|
this.worker = null;
|
|
46
484
|
}
|
|
485
|
+
this.teardownMultiTab();
|
|
47
486
|
this._config = null;
|
|
487
|
+
this.inTransaction = false;
|
|
48
488
|
}
|
|
49
489
|
isOpen() {
|
|
50
490
|
return this.proxy !== null;
|
|
51
491
|
}
|
|
52
492
|
async query(sql, params) {
|
|
53
|
-
|
|
54
|
-
const result = await this.proxy.query(sql, params);
|
|
493
|
+
this.operationStats.queryCount += 1;
|
|
494
|
+
const result = await this.rpc(true, (proxy) => proxy.query(sql, params));
|
|
55
495
|
return result;
|
|
56
496
|
}
|
|
57
497
|
async queryOne(sql, params) {
|
|
58
|
-
|
|
59
|
-
const result = await this.proxy.queryOne(sql, params);
|
|
498
|
+
this.operationStats.queryOneCount += 1;
|
|
499
|
+
const result = await this.rpc(true, (proxy) => proxy.queryOne(sql, params));
|
|
60
500
|
return result;
|
|
61
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
|
+
}
|
|
62
511
|
async run(sql, params) {
|
|
63
|
-
|
|
64
|
-
return this.proxy.run(sql, params);
|
|
512
|
+
this.operationStats.runCount += 1;
|
|
513
|
+
return this.rpc(false, (proxy) => proxy.run(sql, params));
|
|
65
514
|
}
|
|
66
515
|
async exec(sql) {
|
|
67
|
-
|
|
68
|
-
return this.proxy.exec(sql);
|
|
516
|
+
this.operationStats.execCount += 1;
|
|
517
|
+
return this.rpc(false, (proxy) => proxy.exec(sql));
|
|
69
518
|
}
|
|
70
519
|
async transaction(_fn) {
|
|
71
520
|
throw new Error("Complex transactions not supported in proxy. Use transactionBatch() instead.");
|
|
@@ -83,34 +532,55 @@ var WebSQLiteProxy = class {
|
|
|
83
532
|
* ```
|
|
84
533
|
*/
|
|
85
534
|
async transactionBatch(operations) {
|
|
86
|
-
|
|
87
|
-
|
|
535
|
+
this.operationStats.transactionBatchCount += 1;
|
|
536
|
+
this.operationStats.transactionBatchOperationCount += operations.length;
|
|
537
|
+
await this.rpc(false, (proxy) => proxy.transaction(operations));
|
|
538
|
+
}
|
|
539
|
+
async applyNodeBatch(input) {
|
|
540
|
+
this.operationStats.transactionBatchCount += 1;
|
|
541
|
+
this.operationStats.transactionBatchOperationCount += estimateNodeBatchSqlOperations(input);
|
|
542
|
+
return this.rpc(false, (proxy) => proxy.applyNodeBatch(input));
|
|
88
543
|
}
|
|
89
544
|
async beginTransaction() {
|
|
90
|
-
if (
|
|
91
|
-
|
|
545
|
+
if (this.inTransaction) {
|
|
546
|
+
throw new Error("Transaction already in progress");
|
|
547
|
+
}
|
|
548
|
+
this.operationStats.execCount += 1;
|
|
549
|
+
await this.rpc(false, (proxy) => proxy.exec("BEGIN IMMEDIATE"));
|
|
550
|
+
this.inTransaction = true;
|
|
92
551
|
}
|
|
93
552
|
async commit() {
|
|
94
|
-
if (!this.
|
|
95
|
-
|
|
553
|
+
if (!this.inTransaction) {
|
|
554
|
+
throw new Error("No transaction in progress");
|
|
555
|
+
}
|
|
556
|
+
this.operationStats.execCount += 1;
|
|
557
|
+
await this.rpc(false, (proxy) => proxy.exec("COMMIT"));
|
|
558
|
+
this.inTransaction = false;
|
|
96
559
|
}
|
|
97
560
|
async rollback() {
|
|
98
|
-
if (!this.
|
|
99
|
-
|
|
561
|
+
if (!this.inTransaction) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this.operationStats.execCount += 1;
|
|
565
|
+
await this.rpc(false, (proxy) => proxy.exec("ROLLBACK"));
|
|
566
|
+
this.inTransaction = false;
|
|
100
567
|
}
|
|
101
568
|
async prepare(_sql) {
|
|
102
569
|
throw new Error("Prepared statements not supported in proxy. Use query() or run() directly.");
|
|
103
570
|
}
|
|
104
571
|
async getSchemaVersion() {
|
|
105
|
-
|
|
106
|
-
return this.proxy.getSchemaVersion();
|
|
572
|
+
this.operationStats.queryOneCount += 1;
|
|
573
|
+
return this.rpc(true, (proxy) => proxy.getSchemaVersion());
|
|
107
574
|
}
|
|
108
575
|
async setSchemaVersion(version) {
|
|
109
|
-
|
|
110
|
-
await this.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
576
|
+
this.operationStats.runCount += 1;
|
|
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
|
+
);
|
|
114
584
|
}
|
|
115
585
|
async applySchema(version, sql) {
|
|
116
586
|
const currentVersion = await this.getSchemaVersion();
|
|
@@ -120,21 +590,23 @@ var WebSQLiteProxy = class {
|
|
|
120
590
|
return true;
|
|
121
591
|
}
|
|
122
592
|
async getDatabaseSize() {
|
|
123
|
-
|
|
124
|
-
return this.proxy.getDatabaseSize();
|
|
593
|
+
return this.rpc(true, (proxy) => proxy.getDatabaseSize());
|
|
125
594
|
}
|
|
126
595
|
async vacuum() {
|
|
596
|
+
return this.rpc(false, (proxy) => proxy.vacuum());
|
|
597
|
+
}
|
|
598
|
+
async incrementalVacuum(maxPages) {
|
|
127
599
|
if (!this.proxy) throw new Error("Database not open");
|
|
128
|
-
|
|
600
|
+
this.operationStats.workerRequestCount += 1;
|
|
601
|
+
return this.proxy.incrementalVacuum(maxPages);
|
|
129
602
|
}
|
|
130
603
|
async checkpoint() {
|
|
131
604
|
return 0;
|
|
132
605
|
}
|
|
133
606
|
async getStorageMode() {
|
|
134
|
-
if (!this.proxy) throw new Error("Database not open");
|
|
135
607
|
try {
|
|
136
608
|
log("[WebSQLiteProxy] Calling proxy.getStorageMode()...");
|
|
137
|
-
const mode = await this.proxy.getStorageMode();
|
|
609
|
+
const mode = await this.rpc(true, (proxy) => proxy.getStorageMode());
|
|
138
610
|
log("[WebSQLiteProxy] getStorageMode() returned:", mode);
|
|
139
611
|
return mode;
|
|
140
612
|
} catch (err) {
|
|
@@ -142,13 +614,56 @@ var WebSQLiteProxy = class {
|
|
|
142
614
|
throw err;
|
|
143
615
|
}
|
|
144
616
|
}
|
|
617
|
+
/** This tab's multi-tab role: 'leader', 'follower', or 'single-tab'. */
|
|
618
|
+
getTabRole() {
|
|
619
|
+
return this.role;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Create a MessagePort connected directly to the SQLite worker.
|
|
623
|
+
*
|
|
624
|
+
* The returned port speaks the same Comlink `SQLiteWorkerHandler`
|
|
625
|
+
* protocol as this proxy and can be transferred to another worker
|
|
626
|
+
* (e.g. the data worker) so its storage calls skip the main thread.
|
|
627
|
+
*/
|
|
628
|
+
async createMessagePort() {
|
|
629
|
+
const channel = new MessageChannel();
|
|
630
|
+
await this.rpc(
|
|
631
|
+
false,
|
|
632
|
+
(proxy) => proxy.connectPort(Comlink.transfer(channel.port1, [channel.port1]))
|
|
633
|
+
);
|
|
634
|
+
return channel.port2;
|
|
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
|
+
}
|
|
649
|
+
getOperationStats() {
|
|
650
|
+
return cloneOperationStats(this.operationStats);
|
|
651
|
+
}
|
|
652
|
+
resetOperationStats() {
|
|
653
|
+
this.operationStats = createEmptyOperationStats();
|
|
654
|
+
}
|
|
145
655
|
};
|
|
146
656
|
async function createWebSQLiteProxy(config) {
|
|
147
657
|
const proxy = new WebSQLiteProxy();
|
|
148
658
|
await proxy.open(config);
|
|
149
659
|
return proxy;
|
|
150
660
|
}
|
|
661
|
+
async function resetWebSQLiteStorage(config) {
|
|
662
|
+
const proxy = new WebSQLiteProxy();
|
|
663
|
+
await proxy.resetStorage(config);
|
|
664
|
+
}
|
|
151
665
|
export {
|
|
152
666
|
WebSQLiteProxy,
|
|
153
|
-
createWebSQLiteProxy
|
|
667
|
+
createWebSQLiteProxy,
|
|
668
|
+
resetWebSQLiteStorage
|
|
154
669
|
};
|