@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
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @xnetjs/sqlite - SharedWorker router for multi-tab SQLite (exploration 0263)
3
+ *
4
+ * A deliberately tiny message ferry, NOT a database host: SharedWorkers cannot
5
+ * use OPFS sync access handles, so SQLite itself stays in the leader tab's
6
+ * dedicated worker. This router only (a) remembers which tab is the current
7
+ * leader and (b) ferries `MessagePort`s from the leader to follower tabs — the
8
+ * same role the SharedWorker plays in Notion's and wa-sqlite's (discussion #81)
9
+ * multi-tab architectures.
10
+ *
11
+ * Leadership itself is decided by `navigator.locks` in the tabs (web-leader.ts);
12
+ * the router never elects anyone. When a tab announces `leader-ready` it simply
13
+ * replaces the previous leader registration and followers are told to
14
+ * re-request their DB port.
15
+ *
16
+ * The message handler is exported as a pure function over an explicit state
17
+ * object so it can be unit-tested in Node without a real SharedWorker
18
+ * (mirroring the reader-thread.ts pattern).
19
+ */
20
+ /** A follower's request for a DB port, waiting on the leader's response. */
21
+ interface PendingPortRequest {
22
+ requesterPort: RouterClientPort;
23
+ requestId: string;
24
+ }
25
+ /** The subset of MessagePort the router needs (test seam). */
26
+ interface RouterClientPort {
27
+ postMessage(message: unknown, transfer?: Transferable[]): void;
28
+ }
29
+ /** Messages a tab sends to the router. */
30
+ type RouterInbound = {
31
+ t: 'leader-ready';
32
+ } | {
33
+ t: 'request-db-port';
34
+ requestId: string;
35
+ } | {
36
+ t: 'db-port';
37
+ requestId: string;
38
+ } | {
39
+ t: 'db-port-failed';
40
+ requestId: string;
41
+ error: string;
42
+ };
43
+ /** Messages the router sends to a tab. */
44
+ type RouterOutbound = {
45
+ t: 'mint-db-port';
46
+ requestId: string;
47
+ } | {
48
+ t: 'db-port';
49
+ requestId: string;
50
+ } | {
51
+ t: 'db-port-failed';
52
+ requestId: string;
53
+ error: string;
54
+ } | {
55
+ t: 'no-leader';
56
+ requestId: string;
57
+ } | {
58
+ t: 'leader-changed';
59
+ };
60
+ interface RouterState {
61
+ leader: RouterClientPort | null;
62
+ /** Every connected tab port, for leader-changed broadcasts. */
63
+ clients: Set<RouterClientPort>;
64
+ /** Port requests forwarded to the leader, awaiting its minted port. */
65
+ pending: Map<string, PendingPortRequest>;
66
+ }
67
+ declare function createRouterState(): RouterState;
68
+ /** Register a newly connected tab port. */
69
+ declare function addRouterClient(state: RouterState, port: RouterClientPort): void;
70
+ /**
71
+ * Dispatch one inbound message. `ports` carries any transferred MessagePorts
72
+ * (the leader's minted DB port rides `db-port` messages).
73
+ */
74
+ declare function handleRouterMessage(state: RouterState, sender: RouterClientPort, message: RouterInbound, ports?: readonly Transferable[]): void;
75
+
76
+ export { type RouterClientPort, type RouterInbound, type RouterOutbound, type RouterState, addRouterClient, createRouterState, handleRouterMessage };
@@ -0,0 +1,91 @@
1
+ // src/adapters/web-router-worker.ts
2
+ function createRouterState() {
3
+ return { leader: null, clients: /* @__PURE__ */ new Set(), pending: /* @__PURE__ */ new Map() };
4
+ }
5
+ function addRouterClient(state, port) {
6
+ state.clients.add(port);
7
+ }
8
+ function handleRouterMessage(state, sender, message, ports = []) {
9
+ switch (message.t) {
10
+ case "leader-ready": {
11
+ const previous = state.leader;
12
+ state.leader = sender;
13
+ if (previous !== null && previous !== sender) {
14
+ for (const client of state.clients) {
15
+ if (client !== sender) {
16
+ safePost(client, { t: "leader-changed" });
17
+ }
18
+ }
19
+ }
20
+ return;
21
+ }
22
+ case "request-db-port": {
23
+ if (!state.leader) {
24
+ safePost(sender, { t: "no-leader", requestId: message.requestId });
25
+ return;
26
+ }
27
+ state.pending.set(message.requestId, { requesterPort: sender, requestId: message.requestId });
28
+ safePost(state.leader, {
29
+ t: "mint-db-port",
30
+ requestId: message.requestId
31
+ });
32
+ return;
33
+ }
34
+ case "db-port": {
35
+ const pending = state.pending.get(message.requestId);
36
+ if (!pending) return;
37
+ state.pending.delete(message.requestId);
38
+ const [port] = ports;
39
+ if (port === void 0) {
40
+ safePost(pending.requesterPort, {
41
+ t: "db-port-failed",
42
+ requestId: message.requestId,
43
+ error: "leader response carried no port"
44
+ });
45
+ return;
46
+ }
47
+ pending.requesterPort.postMessage(
48
+ { t: "db-port", requestId: message.requestId },
49
+ [port]
50
+ );
51
+ return;
52
+ }
53
+ case "db-port-failed": {
54
+ const pending = state.pending.get(message.requestId);
55
+ if (!pending) return;
56
+ state.pending.delete(message.requestId);
57
+ safePost(pending.requesterPort, {
58
+ t: "db-port-failed",
59
+ requestId: message.requestId,
60
+ error: message.error
61
+ });
62
+ return;
63
+ }
64
+ }
65
+ }
66
+ function safePost(port, message) {
67
+ try {
68
+ port.postMessage(message);
69
+ } catch {
70
+ }
71
+ }
72
+ function bootstrapRouter() {
73
+ const scope = globalThis;
74
+ if (typeof scope.SharedWorkerGlobalScope === "undefined") return;
75
+ const state = createRouterState();
76
+ scope.onconnect = (event) => {
77
+ const port = event.ports[0];
78
+ if (!port) return;
79
+ addRouterClient(state, port);
80
+ port.onmessage = (msg) => {
81
+ handleRouterMessage(state, port, msg.data, msg.ports);
82
+ };
83
+ port.start?.();
84
+ };
85
+ }
86
+ bootstrapRouter();
87
+ export {
88
+ addRouterClient,
89
+ createRouterState,
90
+ handleRouterMessage
91
+ };
@@ -1,4 +1,5 @@
1
- import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult } from '../types-C_aHfRDF.js';
1
+ import { b as SQLiteConfig, a as SQLRow, S as SQLValue, c as SQLBatchRead, R as RunResult, l as SQLiteNodeBatchApplyInput, m as SQLiteNodeBatchApplyResult } from '../types-BTabr_VP.js';
2
+ import { S as SchedulerSnapshot, a as SchedulerOpStats } from '../worker-scheduler-D04DqMmR.js';
2
3
 
3
4
  /**
4
5
  * @xnetjs/sqlite - Web Worker entry point for SQLite WASM
@@ -12,21 +13,75 @@ import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult } from '.
12
13
  */
13
14
  declare class SQLiteWorkerHandler {
14
15
  private adapter;
16
+ private bootDebug;
17
+ private openedAtMs;
18
+ private firstOpReported;
19
+ private readonly scheduler;
15
20
  open(config: SQLiteConfig): Promise<void>;
21
+ /**
22
+ * Emit the open-phase split (exploration 0253). Until now the whole open lived
23
+ * in the boot-timeline's opaque `wasm` bucket (init:start → sqlite:open); this
24
+ * breaks it into wasm import/init, OPFS VFS install (+ lock retries), capacity
25
+ * reserve, db open, pragmas and schema apply — so the dominant sub-phase of a
26
+ * slow open is named in one line. Never throws.
27
+ */
28
+ private logOpenPhases;
29
+ /**
30
+ * One-shot DB stats at open (exploration 0229): file size, page/freelist
31
+ * counts, storage mode. File size is the key signal for the bloat hypothesis
32
+ * (a giant pre-0227 presence blob inflating cold-read cost). Never throws.
33
+ */
34
+ private logDbStats;
35
+ resetStorage(config: SQLiteConfig): Promise<void>;
16
36
  close(): Promise<void>;
17
37
  isOpen(): boolean;
18
38
  query<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T[]>;
19
39
  queryOne<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T | null>;
40
+ /**
41
+ * Execute a batch of reads as ONE scheduled job — one RPC, one queue slot —
42
+ * instead of one round-trip per query (exploration 0263). The batch runs on
43
+ * the interactive lane uncoalesced: its members are heterogeneous, so a
44
+ * batch-level coalesce key would rarely hit, and member-level coalescing
45
+ * would let other jobs interleave mid-batch.
46
+ */
47
+ queryBatch(reads: SQLBatchRead[]): Promise<SQLRow[][]>;
20
48
  run(sql: string, params?: SQLValue[]): Promise<RunResult>;
21
49
  exec(sql: string): Promise<void>;
22
50
  transaction(operations: Array<{
23
51
  sql: string;
24
52
  params?: SQLValue[];
25
53
  }>): Promise<void>;
54
+ applyNodeBatch(input: SQLiteNodeBatchApplyInput): Promise<SQLiteNodeBatchApplyResult>;
26
55
  getSchemaVersion(): Promise<number>;
27
56
  vacuum(): Promise<void>;
57
+ incrementalVacuum(maxPages?: number): Promise<number>;
58
+ /** Current scheduler queue depths (diagnostics / the perf panel). */
59
+ getSchedulerSnapshot(): Promise<SchedulerSnapshot>;
60
+ /** Cumulative per-lane op latency percentiles + coalesce hits (0263). */
61
+ getSchedulerOpStats(): Promise<SchedulerOpStats>;
62
+ /** Zero the op-stats counters for a focused before/after measurement. */
63
+ resetSchedulerOpStats(): Promise<void>;
28
64
  getDatabaseSize(): Promise<number>;
29
65
  getStorageMode(): Promise<'opfs' | 'memory'>;
66
+ /**
67
+ * Expose this handler on an additional MessagePort.
68
+ *
69
+ * Lets another worker (e.g. the data worker from @xnetjs/data-bridge)
70
+ * talk to this SQLite worker directly without routing every storage call
71
+ * through the main thread. The port is created on the main thread with
72
+ * `WebSQLiteProxy.createMessagePort()` and transferred to the peer.
73
+ */
74
+ connectPort(port: MessagePort): void;
75
+ /**
76
+ * PowerSync-style lease recovery (exploration 0263): a client that died
77
+ * between a manual BEGIN and its COMMIT leaves the shared connection stuck
78
+ * in a transaction, wedging every other client's writes. When a NEW client
79
+ * connects, roll back any transaction that has been idle long enough that
80
+ * its owner is clearly gone. Batch APIs (`transaction`, `applyNodeBatch`)
81
+ * are single-RPC and can never be abandoned — this only covers the manual
82
+ * BEGIN…COMMIT path.
83
+ */
84
+ private scheduleAbandonedTransactionRecovery;
30
85
  }
31
86
 
32
87
  export { SQLiteWorkerHandler };
@@ -1,10 +1,38 @@
1
1
  import {
2
- createWebSQLiteAdapter
3
- } from "../chunk-BXYZU3OL.js";
4
- import "../chunk-HIREU5S5.js";
2
+ WorkerScheduler,
3
+ firstOpGapFields
4
+ } from "../chunk-5HC5V73T.js";
5
+ import {
6
+ createWebSQLiteAdapter,
7
+ resetWebSQLiteOpfsStorage
8
+ } from "../chunk-BBZDKLA3.js";
9
+ import "../chunk-SV475UL5.js";
10
+ import {
11
+ bootLogMessage
12
+ } from "../chunk-CGI2YBZY.js";
13
+ import "../chunk-CBU263LI.js";
14
+ import "../chunk-W3L4FXU5.js";
5
15
 
6
16
  // src/adapters/web-worker.ts
7
17
  import * as Comlink from "comlink";
18
+ function readKey(op, sql, params) {
19
+ return `${op}\0${sql}\0${params ? JSON.stringify(params) : ""}`;
20
+ }
21
+ function emitBootLog(...args) {
22
+ console.info(...args);
23
+ try {
24
+ const scope = self;
25
+ scope.postMessage(bootLogMessage(args));
26
+ } catch {
27
+ }
28
+ }
29
+ function sqlDetail(sql) {
30
+ const flat = sql.replace(/\s+/g, " ").trim();
31
+ return flat.length > 160 ? `${flat.slice(0, 157)}\u2026` : flat;
32
+ }
33
+ function nowMs() {
34
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
35
+ }
8
36
  function isDebugEnabled() {
9
37
  return typeof self !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem("xnet:sqlite:debug") === "true";
10
38
  }
@@ -16,46 +44,197 @@ function log(...args) {
16
44
  log("[SQLiteWorker] Worker script loaded, Comlink imported");
17
45
  var SQLiteWorkerHandler = class {
18
46
  adapter = null;
47
+ // Boot diagnostics flag (exploration 0229) — workers have no localStorage, so
48
+ // the main thread passes this in via the open config.
49
+ bootDebug = false;
50
+ // Monotonic time `open()` finished (exploration 0253). Lets the first scheduled
51
+ // op report how long the worker sat idle after open() before any op arrived
52
+ // (`idleBeforeFirstOpMs`) — the upstream/transport wait that queueMs/execMs both
53
+ // miss, and where the 17 s relocated once it left `execMs`.
54
+ openedAtMs = 0;
55
+ // One-shot latch so only the FIRST op after open carries the gap measurements.
56
+ firstOpReported = false;
57
+ // All storage work funnels through one priority scheduler so a queued write
58
+ // burst can never starve an interactive read (exploration 0228). The same
59
+ // handler instance is exposed on every port (main thread + data worker), so
60
+ // this one scheduler orders work globally across all clients. When boot debug
61
+ // is on, each op logs its queue-wait vs execution time — the split that
62
+ // finally localizes the boot stall to a single operation (exploration 0229).
63
+ scheduler = new WorkerScheduler((report) => {
64
+ if (!this.bootDebug) return;
65
+ const firstOp = !this.firstOpReported && this.openedAtMs > 0;
66
+ if (firstOp) this.firstOpReported = true;
67
+ emitBootLog("[xNet] sqlite op", report.label ?? report.lane, {
68
+ lane: report.lane,
69
+ // The SQL text (params omitted) names which op a long execMs belongs to —
70
+ // the missing field that kept the cold-open stall unidentified (0249).
71
+ ...report.detail ? { sql: report.detail } : {},
72
+ queueMs: Math.round(report.queueMs),
73
+ execMs: Math.round(report.execMs),
74
+ ...firstOp ? firstOpGapFields(report, this.openedAtMs) : {}
75
+ });
76
+ });
19
77
  async open(config) {
20
78
  log("[SQLiteWorkerHandler] open() called with config:", config);
21
79
  if (this.adapter) {
22
80
  throw new Error("Database already open");
23
81
  }
82
+ this.bootDebug = config.bootDebug ?? false;
24
83
  this.adapter = await createWebSQLiteAdapter(config);
84
+ this.openedAtMs = nowMs();
25
85
  log("[SQLiteWorkerHandler] open() completed");
86
+ if (this.bootDebug) {
87
+ this.logOpenPhases();
88
+ await this.logDbStats();
89
+ }
26
90
  }
27
- async close() {
91
+ /**
92
+ * Emit the open-phase split (exploration 0253). Until now the whole open lived
93
+ * in the boot-timeline's opaque `wasm` bucket (init:start → sqlite:open); this
94
+ * breaks it into wasm import/init, OPFS VFS install (+ lock retries), capacity
95
+ * reserve, db open, pragmas and schema apply — so the dominant sub-phase of a
96
+ * slow open is named in one line. Never throws.
97
+ */
98
+ logOpenPhases() {
99
+ if (!this.adapter) return;
100
+ try {
101
+ const phases = this.adapter.getOpenPhaseTimings();
102
+ if (!phases) return;
103
+ emitBootLog("[xNet] sqlite open phases", {
104
+ ...phases,
105
+ schemaApplyMs: this.adapter.schemaApplyMs,
106
+ retryAttempts: this.adapter.openRetryAttempts,
107
+ mode: this.adapter.getStorageMode()
108
+ });
109
+ } catch {
110
+ }
111
+ }
112
+ /**
113
+ * One-shot DB stats at open (exploration 0229): file size, page/freelist
114
+ * counts, storage mode. File size is the key signal for the bloat hypothesis
115
+ * (a giant pre-0227 presence blob inflating cold-read cost). Never throws.
116
+ */
117
+ async logDbStats() {
118
+ if (!this.adapter) return;
119
+ try {
120
+ const [bytes, mode, pageCount, freelist] = await Promise.all([
121
+ this.adapter.getDatabaseSize(),
122
+ this.adapter.getStorageMode(),
123
+ this.adapter.queryOne("PRAGMA page_count"),
124
+ this.adapter.queryOne("PRAGMA freelist_count")
125
+ ]);
126
+ emitBootLog("[xNet] db stats @ open", {
127
+ bytes,
128
+ mode,
129
+ pageCount: pageCount?.page_count,
130
+ freelistCount: freelist?.freelist_count
131
+ });
132
+ } catch {
133
+ }
134
+ }
135
+ async resetStorage(config) {
28
136
  if (this.adapter) {
29
137
  await this.adapter.close();
30
138
  this.adapter = null;
31
139
  }
140
+ await resetWebSQLiteOpfsStorage(config);
141
+ }
142
+ async close() {
143
+ if (!this.adapter) return;
144
+ return this.scheduler.schedule(
145
+ "write",
146
+ async () => {
147
+ if (this.adapter) {
148
+ await this.adapter.close();
149
+ this.adapter = null;
150
+ }
151
+ },
152
+ void 0,
153
+ "close"
154
+ );
32
155
  }
33
156
  isOpen() {
34
157
  return this.adapter?.isOpen() ?? false;
35
158
  }
36
159
  async query(sql, params) {
37
160
  if (!this.adapter) throw new Error("Database not open");
38
- return this.adapter.query(sql, params);
161
+ return this.scheduler.schedule(
162
+ "interactive",
163
+ () => this.adapter.query(sql, params),
164
+ readKey("query", sql, params),
165
+ "query",
166
+ sqlDetail(sql)
167
+ );
39
168
  }
40
169
  async queryOne(sql, params) {
41
170
  if (!this.adapter) throw new Error("Database not open");
42
- return this.adapter.queryOne(sql, params);
171
+ return this.scheduler.schedule(
172
+ "interactive",
173
+ () => this.adapter.queryOne(sql, params),
174
+ readKey("queryOne", sql, params),
175
+ "queryOne",
176
+ sqlDetail(sql)
177
+ );
178
+ }
179
+ /**
180
+ * Execute a batch of reads as ONE scheduled job — one RPC, one queue slot —
181
+ * instead of one round-trip per query (exploration 0263). The batch runs on
182
+ * the interactive lane uncoalesced: its members are heterogeneous, so a
183
+ * batch-level coalesce key would rarely hit, and member-level coalescing
184
+ * would let other jobs interleave mid-batch.
185
+ */
186
+ async queryBatch(reads) {
187
+ if (!this.adapter) throw new Error("Database not open");
188
+ if (reads.length === 0) return [];
189
+ return this.scheduler.schedule(
190
+ "interactive",
191
+ () => this.adapter.queryBatch(reads),
192
+ void 0,
193
+ "queryBatch",
194
+ `${reads.length} reads: ${sqlDetail(reads[0].sql)}`
195
+ );
43
196
  }
44
197
  async run(sql, params) {
45
198
  if (!this.adapter) throw new Error("Database not open");
46
- return this.adapter.run(sql, params);
199
+ return this.scheduler.schedule(
200
+ "write",
201
+ () => this.adapter.run(sql, params),
202
+ void 0,
203
+ "run",
204
+ sqlDetail(sql)
205
+ );
47
206
  }
48
207
  async exec(sql) {
49
208
  if (!this.adapter) throw new Error("Database not open");
50
- return this.adapter.exec(sql);
209
+ return this.scheduler.schedule(
210
+ "write",
211
+ () => this.adapter.exec(sql),
212
+ void 0,
213
+ "exec",
214
+ sqlDetail(sql)
215
+ );
51
216
  }
52
217
  async transaction(operations) {
53
218
  if (!this.adapter) throw new Error("Database not open");
54
- await this.adapter.transaction(async () => {
55
- for (const op of operations) {
56
- await this.adapter.run(op.sql, op.params);
57
- }
58
- });
219
+ return this.scheduler.schedule(
220
+ "write",
221
+ () => this.adapter.transaction(async () => {
222
+ for (const op of operations) {
223
+ await this.adapter.run(op.sql, op.params);
224
+ }
225
+ }),
226
+ void 0,
227
+ "transaction"
228
+ );
229
+ }
230
+ async applyNodeBatch(input) {
231
+ if (!this.adapter) throw new Error("Database not open");
232
+ return this.scheduler.schedule(
233
+ "write",
234
+ () => this.adapter.applyNodeBatch(input),
235
+ void 0,
236
+ "applyNodeBatch"
237
+ );
59
238
  }
60
239
  async getSchemaVersion() {
61
240
  if (!this.adapter) throw new Error("Database not open");
@@ -63,7 +242,28 @@ var SQLiteWorkerHandler = class {
63
242
  }
64
243
  async vacuum() {
65
244
  if (!this.adapter) throw new Error("Database not open");
66
- return this.adapter.vacuum();
245
+ return this.scheduler.schedule("write", () => this.adapter.vacuum(), void 0, "vacuum");
246
+ }
247
+ async incrementalVacuum(maxPages) {
248
+ if (!this.adapter) throw new Error("Database not open");
249
+ return this.scheduler.schedule(
250
+ "write",
251
+ () => this.adapter.incrementalVacuum(maxPages),
252
+ void 0,
253
+ "incremental_vacuum"
254
+ );
255
+ }
256
+ /** Current scheduler queue depths (diagnostics / the perf panel). */
257
+ async getSchedulerSnapshot() {
258
+ return this.scheduler.snapshot();
259
+ }
260
+ /** Cumulative per-lane op latency percentiles + coalesce hits (0263). */
261
+ async getSchedulerOpStats() {
262
+ return this.scheduler.opStats();
263
+ }
264
+ /** Zero the op-stats counters for a focused before/after measurement. */
265
+ async resetSchedulerOpStats() {
266
+ this.scheduler.resetOpStats();
67
267
  }
68
268
  async getDatabaseSize() {
69
269
  if (!this.adapter) throw new Error("Database not open");
@@ -73,7 +273,46 @@ var SQLiteWorkerHandler = class {
73
273
  if (!this.adapter) throw new Error("Database not open");
74
274
  return this.adapter.getStorageMode();
75
275
  }
276
+ /**
277
+ * Expose this handler on an additional MessagePort.
278
+ *
279
+ * Lets another worker (e.g. the data worker from @xnetjs/data-bridge)
280
+ * talk to this SQLite worker directly without routing every storage call
281
+ * through the main thread. The port is created on the main thread with
282
+ * `WebSQLiteProxy.createMessagePort()` and transferred to the peer.
283
+ */
284
+ connectPort(port) {
285
+ Comlink.expose(this, port);
286
+ this.scheduleAbandonedTransactionRecovery();
287
+ }
288
+ /**
289
+ * PowerSync-style lease recovery (exploration 0263): a client that died
290
+ * between a manual BEGIN and its COMMIT leaves the shared connection stuck
291
+ * in a transaction, wedging every other client's writes. When a NEW client
292
+ * connects, roll back any transaction that has been idle long enough that
293
+ * its owner is clearly gone. Batch APIs (`transaction`, `applyNodeBatch`)
294
+ * are single-RPC and can never be abandoned — this only covers the manual
295
+ * BEGIN…COMMIT path.
296
+ */
297
+ scheduleAbandonedTransactionRecovery() {
298
+ void this.scheduler.schedule(
299
+ "write",
300
+ async () => {
301
+ const adapter = this.adapter;
302
+ if (!adapter || !adapter.isInTransaction()) return;
303
+ if (adapter.transactionIdleMs() < ABANDONED_TRANSACTION_IDLE_MS) return;
304
+ console.warn(
305
+ "[SQLiteWorkerHandler] Rolling back an abandoned transaction \u2014 a client likely died between BEGIN and COMMIT (0263 lease recovery)."
306
+ );
307
+ await adapter.rollback();
308
+ },
309
+ void 0,
310
+ "txn-recovery"
311
+ ).catch(() => {
312
+ });
313
+ }
76
314
  };
315
+ var ABANDONED_TRANSACTION_IDLE_MS = 5e3;
77
316
  var handler = new SQLiteWorkerHandler();
78
317
  log("[SQLiteWorker] Handler instance created");
79
318
  log("[SQLiteWorker] Exposing handler via Comlink...");