@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.
@@ -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
- const proxy = this.createWorkerProxy();
54
- log("[WebSQLiteProxy] Calling proxy.open()...");
55
- const openPromise = proxy.open(config);
56
- this.operationStats.workerRequestCount += 1;
57
- const timeoutPromise = new Promise(
58
- (_, reject) => setTimeout(() => reject(new Error("Worker initialization timeout after 15s")), 15e3)
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
- this._config = config;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
194
- await this.proxy.run("INSERT INTO _schema_version (version, applied_at) VALUES (?, ?)", [
195
- version,
196
- Date.now()
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
- if (!this.proxy) throw new Error("Database not open");
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.vacuum();
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.operationStats.workerRequestCount += 1;
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.operationStats.workerRequestCount += 1;
243
- await this.proxy.connectPort(Comlink.transfer(channel.port1, [channel.port1]));
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
  }