@xnetjs/sqlite 0.0.3 → 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.
@@ -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, k as SQLiteNodeBatchApplyInput, l as SQLiteNodeBatchApplyResult } from '../types-DMrj3K4o.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,12 +13,38 @@ import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult, k as SQL
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;
16
35
  resetStorage(config: SQLiteConfig): Promise<void>;
17
36
  close(): Promise<void>;
18
37
  isOpen(): boolean;
19
38
  query<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T[]>;
20
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[][]>;
21
48
  run(sql: string, params?: SQLValue[]): Promise<RunResult>;
22
49
  exec(sql: string): Promise<void>;
23
50
  transaction(operations: Array<{
@@ -27,6 +54,13 @@ declare class SQLiteWorkerHandler {
27
54
  applyNodeBatch(input: SQLiteNodeBatchApplyInput): Promise<SQLiteNodeBatchApplyResult>;
28
55
  getSchemaVersion(): Promise<number>;
29
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>;
30
64
  getDatabaseSize(): Promise<number>;
31
65
  getStorageMode(): Promise<'opfs' | 'memory'>;
32
66
  /**
@@ -38,6 +72,16 @@ declare class SQLiteWorkerHandler {
38
72
  * `WebSQLiteProxy.createMessagePort()` and transferred to the peer.
39
73
  */
40
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;
41
85
  }
42
86
 
43
87
  export { SQLiteWorkerHandler };
@@ -1,12 +1,38 @@
1
+ import {
2
+ WorkerScheduler,
3
+ firstOpGapFields
4
+ } from "../chunk-5HC5V73T.js";
1
5
  import {
2
6
  createWebSQLiteAdapter,
3
7
  resetWebSQLiteOpfsStorage
4
- } from "../chunk-DCY4LAUD.js";
8
+ } from "../chunk-BBZDKLA3.js";
9
+ import "../chunk-SV475UL5.js";
10
+ import {
11
+ bootLogMessage
12
+ } from "../chunk-CGI2YBZY.js";
5
13
  import "../chunk-CBU263LI.js";
6
- import "../chunk-NVD7G7DZ.js";
14
+ import "../chunk-W3L4FXU5.js";
7
15
 
8
16
  // src/adapters/web-worker.ts
9
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
+ }
10
36
  function isDebugEnabled() {
11
37
  return typeof self !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem("xnet:sqlite:debug") === "true";
12
38
  }
@@ -18,13 +44,93 @@ function log(...args) {
18
44
  log("[SQLiteWorker] Worker script loaded, Comlink imported");
19
45
  var SQLiteWorkerHandler = class {
20
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
+ });
21
77
  async open(config) {
22
78
  log("[SQLiteWorkerHandler] open() called with config:", config);
23
79
  if (this.adapter) {
24
80
  throw new Error("Database already open");
25
81
  }
82
+ this.bootDebug = config.bootDebug ?? false;
26
83
  this.adapter = await createWebSQLiteAdapter(config);
84
+ this.openedAtMs = nowMs();
27
85
  log("[SQLiteWorkerHandler] open() completed");
86
+ if (this.bootDebug) {
87
+ this.logOpenPhases();
88
+ await this.logDbStats();
89
+ }
90
+ }
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
+ }
28
134
  }
29
135
  async resetStorage(config) {
30
136
  if (this.adapter) {
@@ -34,41 +140,101 @@ var SQLiteWorkerHandler = class {
34
140
  await resetWebSQLiteOpfsStorage(config);
35
141
  }
36
142
  async close() {
37
- if (this.adapter) {
38
- await this.adapter.close();
39
- this.adapter = null;
40
- }
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
+ );
41
155
  }
42
156
  isOpen() {
43
157
  return this.adapter?.isOpen() ?? false;
44
158
  }
45
159
  async query(sql, params) {
46
160
  if (!this.adapter) throw new Error("Database not open");
47
- 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
+ );
48
168
  }
49
169
  async queryOne(sql, params) {
50
170
  if (!this.adapter) throw new Error("Database not open");
51
- 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
+ );
52
196
  }
53
197
  async run(sql, params) {
54
198
  if (!this.adapter) throw new Error("Database not open");
55
- 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
+ );
56
206
  }
57
207
  async exec(sql) {
58
208
  if (!this.adapter) throw new Error("Database not open");
59
- 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
+ );
60
216
  }
61
217
  async transaction(operations) {
62
218
  if (!this.adapter) throw new Error("Database not open");
63
- await this.adapter.transaction(async () => {
64
- for (const op of operations) {
65
- await this.adapter.run(op.sql, op.params);
66
- }
67
- });
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
+ );
68
229
  }
69
230
  async applyNodeBatch(input) {
70
231
  if (!this.adapter) throw new Error("Database not open");
71
- return this.adapter.applyNodeBatch(input);
232
+ return this.scheduler.schedule(
233
+ "write",
234
+ () => this.adapter.applyNodeBatch(input),
235
+ void 0,
236
+ "applyNodeBatch"
237
+ );
72
238
  }
73
239
  async getSchemaVersion() {
74
240
  if (!this.adapter) throw new Error("Database not open");
@@ -76,7 +242,28 @@ var SQLiteWorkerHandler = class {
76
242
  }
77
243
  async vacuum() {
78
244
  if (!this.adapter) throw new Error("Database not open");
79
- 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();
80
267
  }
81
268
  async getDatabaseSize() {
82
269
  if (!this.adapter) throw new Error("Database not open");
@@ -96,8 +283,36 @@ var SQLiteWorkerHandler = class {
96
283
  */
97
284
  connectPort(port) {
98
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
+ });
99
313
  }
100
314
  };
315
+ var ABANDONED_TRANSACTION_IDLE_MS = 5e3;
101
316
  var handler = new SQLiteWorkerHandler();
102
317
  log("[SQLiteWorker] Handler instance created");
103
318
  log("[SQLiteWorker] Exposing handler via Comlink...");
@@ -1,5 +1,5 @@
1
- import { S as SQLiteAdapter, P as PreparedStatement } from '../adapter-t2aqQ__n.js';
2
- import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult, k as SQLiteNodeBatchApplyInput, l as SQLiteNodeBatchApplyResult } from '../types-DMrj3K4o.js';
1
+ import { S as SQLiteAdapter, P as PreparedStatement } from '../adapter-imtxkmXZ.js';
2
+ 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';
3
3
 
4
4
  /**
5
5
  * @xnetjs/sqlite - Web SQLite adapter using @sqlite.org/sqlite-wasm
@@ -8,6 +8,30 @@ import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult, k as SQL
8
8
  * Must run in a Web Worker for OPFS access.
9
9
  */
10
10
 
11
+ /**
12
+ * Per-phase timing of {@link WebSQLiteAdapter.open}. Every prior cold-open
13
+ * exploration (0204→0249) timed the layer the stall was *in*, so the cost kept
14
+ * hopping to the next un-timed layer; by the 0253 capture both `queueMs` and
15
+ * `execMs` read 0 and the ~17 s lived entirely in this `open()` window, which no
16
+ * timer bracketed. These fields split it so the dominant sub-phase is named in
17
+ * one boot-log line. All durations are milliseconds, rounded.
18
+ */
19
+ interface OpenPhaseTimings {
20
+ /** Dynamic `import('@sqlite.org/sqlite-wasm')` (bundle parse/download). */
21
+ wasmImportMs: number;
22
+ /** `sqlite3InitModule()` — WASM instantiate. */
23
+ wasmInitMs: number;
24
+ /** `installOpfsSAHPoolVfs()` — acquires the pool's sync access handles; INCLUDES any lock-retry backoff. */
25
+ vfsInstallMs: number;
26
+ /** `reserveMinimumCapacity()` — may acquire more handles / grow the pool. */
27
+ reserveCapacityMs: number;
28
+ /** `new OpfsSAHPoolDb()` — open the database file in the pool. */
29
+ dbOpenMs: number;
30
+ /** All post-open `PRAGMA` settings (page_size/cache/mmap/journal/…). */
31
+ pragmasMs: number;
32
+ /** Whole `open()` span (import → last pragma). */
33
+ totalOpenMs: number;
34
+ }
11
35
  /**
12
36
  * Remove xNet's OPFS-backed SQLite storage for the supplied database path.
13
37
  *
@@ -40,13 +64,53 @@ declare class WebSQLiteAdapter implements SQLiteAdapter {
40
64
  private poolUtil;
41
65
  private _config;
42
66
  private inTransaction;
67
+ /**
68
+ * Monotonic time of the open transaction's last operation. Lets the worker
69
+ * host distinguish a LIVE manual transaction from one abandoned by a client
70
+ * that died between BEGIN and COMMIT (PowerSync-style lease recovery, 0263).
71
+ */
72
+ private lastTransactionActivityAt;
43
73
  private storageMode;
74
+ /**
75
+ * Per-phase timing of the last {@link open} call, or null until it runs.
76
+ * Read by the worker host to emit `[xNet] sqlite open phases` (exploration
77
+ * 0253). Diagnostic only — never affects behaviour.
78
+ */
79
+ private openPhaseTimings;
80
+ /** ms spent in `applySchema()` during {@link createWebSQLiteAdapter}, for the open-phases line. */
81
+ schemaApplyMs: number;
82
+ /** How many times the OPFS lock-retry backoff fired during the last {@link open} (0 = first try). */
83
+ openRetryAttempts: number;
84
+ /**
85
+ * Prepared-statement LRU for the hot query/run path (exploration 0263).
86
+ * `db.exec()` re-parses the SQL on every call; the hot path repeats a small
87
+ * statement set, so cached `oo1.Stmt` handles skip the parse+prepare cost.
88
+ * Cleared (finalizing all handles) on {@link exec} — the DDL/script path —
89
+ * and on {@link close}.
90
+ */
91
+ private stmts;
92
+ /** Per-phase timing of the last {@link open}, or null if it hasn't run. */
93
+ getOpenPhaseTimings(): OpenPhaseTimings | null;
44
94
  open(config: SQLiteConfig): Promise<void>;
45
95
  close(): Promise<void>;
46
96
  isOpen(): boolean;
47
97
  getStorageMode(): 'opfs' | 'memory';
98
+ /** True while a manual BEGIN…COMMIT/ROLLBACK transaction is open. */
99
+ isInTransaction(): boolean;
100
+ /** ms since the open transaction last executed an operation (0 when none). */
101
+ transactionIdleMs(): number;
102
+ /** Stamp transaction liveness — called by ops that run inside one. */
103
+ private touchTransaction;
104
+ /**
105
+ * Fetch (or prepare and cache) the statement for `sql`, or null when the SQL
106
+ * must bypass the cache: `db.prepare()` compiles only the FIRST statement of
107
+ * a multi-statement string, whereas `db.exec()` runs them all — serving such
108
+ * SQL from the cache would silently drop statements.
109
+ */
110
+ private getCachedStmt;
48
111
  query<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T[]>;
49
112
  queryOne<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T | null>;
113
+ queryBatch(reads: SQLBatchRead[]): Promise<SQLRow[][]>;
50
114
  run(sql: string, params?: SQLValue[]): Promise<RunResult>;
51
115
  exec(sql: string): Promise<void>;
52
116
  private execSync;
@@ -61,12 +125,27 @@ declare class WebSQLiteAdapter implements SQLiteAdapter {
61
125
  applySchema(version: number, sql: string): Promise<boolean>;
62
126
  getDatabaseSize(): Promise<number>;
63
127
  vacuum(): Promise<void>;
128
+ incrementalVacuum(maxPages?: number): Promise<number>;
64
129
  checkpoint(): Promise<number>;
65
130
  private ensureOpen;
66
131
  }
132
+ /**
133
+ * Step `PRAGMA incremental_vacuum` to completion (or `maxPages`), returning
134
+ * the number of pages freed. SQLite frees ONE page per `sqlite3_step` of this
135
+ * pragma; the oo1 `exec` path steps a statement only once when it collects no
136
+ * rows, so `exec('PRAGMA incremental_vacuum')` silently freed a single page
137
+ * per call — the compaction reclaim was a near-no-op (caught by the local
138
+ * bloat-seed validation, 2026-07-05). Exported for the engine-level test.
139
+ */
140
+ declare function stepIncrementalVacuumToCompletion(db: {
141
+ prepare(sql: string): {
142
+ step(): boolean;
143
+ finalize(): unknown;
144
+ };
145
+ }, maxPages?: number): number;
67
146
  /**
68
147
  * Create a WebSQLiteAdapter with schema applied.
69
148
  */
70
149
  declare function createWebSQLiteAdapter(config: SQLiteConfig): Promise<WebSQLiteAdapter>;
71
150
 
72
- export { WebSQLiteAdapter, createWebSQLiteAdapter, resetWebSQLiteOpfsStorage };
151
+ export { type OpenPhaseTimings, WebSQLiteAdapter, createWebSQLiteAdapter, resetWebSQLiteOpfsStorage, stepIncrementalVacuumToCompletion };