@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.
Files changed (37) hide show
  1. package/dist/{adapter-I7yAV6iu.d.ts → adapter-imtxkmXZ.d.ts} +48 -1
  2. package/dist/adapters/electron.d.ts +70 -6
  3. package/dist/adapters/electron.js +662 -36
  4. package/dist/adapters/expo.d.ts +6 -2
  5. package/dist/adapters/expo.js +34 -6
  6. package/dist/adapters/memory.d.ts +6 -2
  7. package/dist/adapters/memory.js +8 -1
  8. package/dist/adapters/reader-thread.d.ts +78 -0
  9. package/dist/adapters/reader-thread.js +57 -0
  10. package/dist/adapters/web-proxy.d.ts +71 -3
  11. package/dist/adapters/web-proxy.js +554 -39
  12. package/dist/adapters/web-router-worker.d.ts +76 -0
  13. package/dist/adapters/web-router-worker.js +91 -0
  14. package/dist/adapters/web-worker.d.ts +56 -1
  15. package/dist/adapters/web-worker.js +253 -14
  16. package/dist/adapters/web.d.ts +90 -3
  17. package/dist/adapters/web.js +10 -4
  18. package/dist/browser-support.d.ts +65 -1
  19. package/dist/browser-support.js +15 -3
  20. package/dist/chunk-5HC5V73T.js +191 -0
  21. package/dist/chunk-BBZDKLA3.js +727 -0
  22. package/dist/chunk-CBU263LI.js +30 -0
  23. package/dist/chunk-CGI2YBZY.js +16 -0
  24. package/dist/chunk-S6MT6KCI.js +279 -0
  25. package/dist/chunk-SV475UL5.js +44 -0
  26. package/dist/chunk-W3L4FXU5.js +415 -0
  27. package/dist/index.d.ts +111 -8
  28. package/dist/index.js +219 -130
  29. package/dist/schema-C4gufY3B.d.ts +35 -0
  30. package/dist/types-BTabr_VP.d.ts +225 -0
  31. package/dist/worker-scheduler-D04DqMmR.d.ts +56 -0
  32. package/package.json +5 -1
  33. package/dist/chunk-BXYZU3OL.js +0 -245
  34. package/dist/chunk-HIREU5S5.js +0 -193
  35. package/dist/chunk-ZRR5D2OD.js +0 -140
  36. package/dist/schema-CjkXTqxn.d.ts +0 -35
  37. 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
- async open(config) {
16
- if (this.worker) {
17
- throw new Error("Already open. Call close() first.");
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
- log("[WebSQLiteProxy] Calling proxy.open()...");
30
- const openPromise = this.proxy.open(config);
31
- const timeoutPromise = new Promise(
32
- (_, reject) => setTimeout(() => reject(new Error("Worker initialization timeout after 15s")), 15e3)
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
- 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
+ );
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
- if (!this.proxy) throw new Error("Database not open");
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
- if (!this.proxy) throw new Error("Database not open");
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
- if (!this.proxy) throw new Error("Database not open");
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
- if (!this.proxy) throw new Error("Database not open");
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
- if (!this.proxy) throw new Error("Database not open");
87
- await this.proxy.transaction(operations);
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 (!this.proxy) throw new Error("Database not open");
91
- await this.proxy.exec("BEGIN IMMEDIATE");
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.proxy) throw new Error("Database not open");
95
- await this.proxy.exec("COMMIT");
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.proxy) throw new Error("Database not open");
99
- await this.proxy.exec("ROLLBACK");
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
- if (!this.proxy) throw new Error("Database not open");
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
- if (!this.proxy) throw new Error("Database not open");
110
- await this.proxy.run("INSERT INTO _schema_version (version, applied_at) VALUES (?, ?)", [
111
- version,
112
- Date.now()
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
- if (!this.proxy) throw new Error("Database not open");
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
- return this.proxy.vacuum();
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
  };