@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,12 +1,15 @@
1
1
  import {
2
2
  WebSQLiteAdapter,
3
3
  createWebSQLiteAdapter,
4
- resetWebSQLiteOpfsStorage
5
- } from "../chunk-DCY4LAUD.js";
4
+ resetWebSQLiteOpfsStorage,
5
+ stepIncrementalVacuumToCompletion
6
+ } from "../chunk-BBZDKLA3.js";
7
+ import "../chunk-SV475UL5.js";
6
8
  import "../chunk-CBU263LI.js";
7
- import "../chunk-NVD7G7DZ.js";
9
+ import "../chunk-W3L4FXU5.js";
8
10
  export {
9
11
  WebSQLiteAdapter,
10
12
  createWebSQLiteAdapter,
11
- resetWebSQLiteOpfsStorage
13
+ resetWebSQLiteOpfsStorage,
14
+ stepIncrementalVacuumToCompletion
12
15
  };
@@ -118,5 +118,14 @@ declare function requestPersistentStorage(options?: PersistentStorageRequestOpti
118
118
  * ```
119
119
  */
120
120
  declare function showUnsupportedBrowserMessage(reason: string): void;
121
+ /**
122
+ * Record that this session opened on the non-durable `:memory:` fallback
123
+ * (another tab/worker held the OPFS handles, or OPFS is unavailable).
124
+ * Multi-tab leadership routing (0263) should drive this count to ~zero —
125
+ * the counter is how we verify that in the field. Returns the new total.
126
+ */
127
+ declare function recordMemoryFallbackSession(): number;
128
+ /** Total sessions on this device that fell back to `:memory:` storage. */
129
+ declare function getMemoryFallbackSessionCount(): number;
121
130
 
122
- export { type BrowserSupport, type PersistentStorageRequestOptions, type PersistentStorageStatus, checkBrowserSupport, checkPersistentStorage, isSilentPersistRequestSafe, requestPersistentStorage, showUnsupportedBrowserMessage, watchPersistentStoragePermission };
131
+ export { type BrowserSupport, type PersistentStorageRequestOptions, type PersistentStorageStatus, checkBrowserSupport, checkPersistentStorage, getMemoryFallbackSessionCount, isSilentPersistRequestSafe, recordMemoryFallbackSession, requestPersistentStorage, showUnsupportedBrowserMessage, watchPersistentStoragePermission };
@@ -1,15 +1,19 @@
1
1
  import {
2
2
  checkBrowserSupport,
3
3
  checkPersistentStorage,
4
+ getMemoryFallbackSessionCount,
4
5
  isSilentPersistRequestSafe,
6
+ recordMemoryFallbackSession,
5
7
  requestPersistentStorage,
6
8
  showUnsupportedBrowserMessage,
7
9
  watchPersistentStoragePermission
8
- } from "./chunk-2OK46ZBU.js";
10
+ } from "./chunk-S6MT6KCI.js";
9
11
  export {
10
12
  checkBrowserSupport,
11
13
  checkPersistentStorage,
14
+ getMemoryFallbackSessionCount,
12
15
  isSilentPersistRequestSafe,
16
+ recordMemoryFallbackSession,
13
17
  requestPersistentStorage,
14
18
  showUnsupportedBrowserMessage,
15
19
  watchPersistentStoragePermission
@@ -0,0 +1,191 @@
1
+ // src/adapters/worker-scheduler.ts
2
+ var LANE_ORDER = ["interactive", "bulk", "write"];
3
+ function nowMs() {
4
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
5
+ }
6
+ function firstOpGapFields(report, openedAtMs) {
7
+ return {
8
+ firstOpAfterOpen: true,
9
+ idleBeforeFirstOpMs: Math.round(report.enqueuedAt - openedAtMs),
10
+ sinceOpenMs: Math.round(report.startedAt - openedAtMs)
11
+ };
12
+ }
13
+ function percentileMs(samples, p) {
14
+ if (samples.length === 0) return 0;
15
+ const sorted = [...samples].sort((a, b) => a - b);
16
+ const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
17
+ return Math.round(sorted[rank]);
18
+ }
19
+ var SCHEDULER_STATS_SAMPLE_CAP = 512;
20
+ var LaneStatsAccumulator = class {
21
+ ops = 0;
22
+ maxExecMs = 0;
23
+ queueSamples = [];
24
+ execSamples = [];
25
+ cursor = 0;
26
+ record(queueMs, execMs) {
27
+ this.ops += 1;
28
+ if (execMs > this.maxExecMs) this.maxExecMs = execMs;
29
+ if (this.queueSamples.length < SCHEDULER_STATS_SAMPLE_CAP) {
30
+ this.queueSamples.push(queueMs);
31
+ this.execSamples.push(execMs);
32
+ } else {
33
+ this.queueSamples[this.cursor] = queueMs;
34
+ this.execSamples[this.cursor] = execMs;
35
+ this.cursor = (this.cursor + 1) % SCHEDULER_STATS_SAMPLE_CAP;
36
+ }
37
+ }
38
+ stats() {
39
+ return {
40
+ ops: this.ops,
41
+ queueP50Ms: percentileMs(this.queueSamples, 50),
42
+ queueP95Ms: percentileMs(this.queueSamples, 95),
43
+ execP50Ms: percentileMs(this.execSamples, 50),
44
+ execP95Ms: percentileMs(this.execSamples, 95),
45
+ maxExecMs: Math.round(this.maxExecMs)
46
+ };
47
+ }
48
+ reset() {
49
+ this.ops = 0;
50
+ this.maxExecMs = 0;
51
+ this.queueSamples.length = 0;
52
+ this.execSamples.length = 0;
53
+ this.cursor = 0;
54
+ }
55
+ };
56
+ var WorkerScheduler = class {
57
+ /**
58
+ * @param onOp optional per-operation timing reporter (boot diagnostics, 0229).
59
+ */
60
+ constructor(onOp) {
61
+ this.onOp = onOp;
62
+ }
63
+ queues = {
64
+ interactive: [],
65
+ bulk: [],
66
+ write: []
67
+ };
68
+ /** In-flight + queued reads keyed by `coalesceKey`, to collapse duplicates. */
69
+ coalesced = /* @__PURE__ */ new Map();
70
+ running = false;
71
+ /** Aggregated op timing per lane (exploration 0263). */
72
+ laneStats = {
73
+ interactive: new LaneStatsAccumulator(),
74
+ bulk: new LaneStatsAccumulator(),
75
+ write: new LaneStatsAccumulator()
76
+ };
77
+ coalescedHits = 0;
78
+ /**
79
+ * Enqueue `fn` on `lane`. Returns a promise that settles with `fn`'s result.
80
+ *
81
+ * When `coalesceKey` is provided (reads only — they're idempotent), an
82
+ * identical key that is already queued or in flight returns the SAME promise
83
+ * instead of enqueuing a second execution. The key is dropped once that
84
+ * execution settles, so a later identical read re-runs against fresh data.
85
+ */
86
+ schedule(lane, fn, coalesceKey, label, detail) {
87
+ if (coalesceKey !== void 0) {
88
+ const inflight = this.coalesced.get(coalesceKey);
89
+ if (inflight) {
90
+ this.coalescedHits += 1;
91
+ return inflight;
92
+ }
93
+ }
94
+ const promise = new Promise((resolve, reject) => {
95
+ this.queues[lane].push({
96
+ run: fn,
97
+ resolve,
98
+ reject,
99
+ lane,
100
+ label,
101
+ detail,
102
+ enqueuedAt: nowMs()
103
+ });
104
+ });
105
+ if (coalesceKey !== void 0) {
106
+ this.coalesced.set(coalesceKey, promise);
107
+ const clear = () => {
108
+ if (this.coalesced.get(coalesceKey) === promise) this.coalesced.delete(coalesceKey);
109
+ };
110
+ promise.then(clear, clear);
111
+ }
112
+ void this.pump();
113
+ return promise;
114
+ }
115
+ /** Current queue depths and in-flight flag. */
116
+ snapshot() {
117
+ return {
118
+ interactive: this.queues.interactive.length,
119
+ bulk: this.queues.bulk.length,
120
+ write: this.queues.write.length,
121
+ inFlight: this.running
122
+ };
123
+ }
124
+ /** Cumulative op counts, coalesce hits, and per-lane latency percentiles. */
125
+ opStats() {
126
+ const lanes = {
127
+ interactive: this.laneStats.interactive.stats(),
128
+ bulk: this.laneStats.bulk.stats(),
129
+ write: this.laneStats.write.stats()
130
+ };
131
+ return {
132
+ ops: lanes.interactive.ops + lanes.bulk.ops + lanes.write.ops,
133
+ coalescedHits: this.coalescedHits,
134
+ lanes
135
+ };
136
+ }
137
+ /** Zero the op-stats counters for a focused before/after measurement. */
138
+ resetOpStats() {
139
+ this.coalescedHits = 0;
140
+ for (const lane of LANE_ORDER) {
141
+ this.laneStats[lane].reset();
142
+ }
143
+ }
144
+ /** Pull the next job, highest-priority non-empty lane first (FIFO within a lane). */
145
+ next() {
146
+ for (const lane of LANE_ORDER) {
147
+ const job = this.queues[lane].shift();
148
+ if (job) return job;
149
+ }
150
+ return void 0;
151
+ }
152
+ /** Drain the queues one job at a time. Re-entrant-safe via the `running` latch. */
153
+ async pump() {
154
+ if (this.running) return;
155
+ this.running = true;
156
+ try {
157
+ let job;
158
+ while (job = this.next()) {
159
+ const startedAt = nowMs();
160
+ try {
161
+ job.resolve(await job.run());
162
+ } catch (err) {
163
+ job.reject(err);
164
+ } finally {
165
+ const endedAt = nowMs();
166
+ const queueMs = startedAt - job.enqueuedAt;
167
+ const execMs = endedAt - startedAt;
168
+ this.laneStats[job.lane].record(queueMs, execMs);
169
+ if (this.onOp) {
170
+ this.onOp({
171
+ lane: job.lane,
172
+ label: job.label,
173
+ detail: job.detail,
174
+ queueMs,
175
+ execMs,
176
+ enqueuedAt: job.enqueuedAt,
177
+ startedAt
178
+ });
179
+ }
180
+ }
181
+ }
182
+ } finally {
183
+ this.running = false;
184
+ }
185
+ }
186
+ };
187
+
188
+ export {
189
+ firstOpGapFields,
190
+ WorkerScheduler
191
+ };
@@ -1,10 +1,13 @@
1
+ import {
2
+ detectOpfsCapability
3
+ } from "./chunk-SV475UL5.js";
1
4
  import {
2
5
  isSQLiteCorruptionError
3
6
  } from "./chunk-CBU263LI.js";
4
7
  import {
5
8
  SCHEMA_DDL,
6
9
  SCHEMA_VERSION
7
- } from "./chunk-NVD7G7DZ.js";
10
+ } from "./chunk-W3L4FXU5.js";
8
11
 
9
12
  // src/adapters/opfs-retry.ts
10
13
  function isOpfsLockError(err) {
@@ -31,6 +34,68 @@ async function withOpfsLockRetry(fn, options = {}) {
31
34
  throw lastErr;
32
35
  }
33
36
 
37
+ // src/adapters/stmt-cache.ts
38
+ var DEFAULT_STMT_CACHE_CAPACITY = 64;
39
+ function hasInteriorSemicolon(sql) {
40
+ const trimmed = sql.replace(/[\s;]+$/, "");
41
+ return trimmed.includes(";");
42
+ }
43
+ var StmtCache = class {
44
+ constructor(capacity = DEFAULT_STMT_CACHE_CAPACITY) {
45
+ this.capacity = capacity;
46
+ if (capacity < 1) {
47
+ throw new Error("StmtCache capacity must be >= 1");
48
+ }
49
+ }
50
+ entries = /* @__PURE__ */ new Map();
51
+ get size() {
52
+ return this.entries.size;
53
+ }
54
+ /** Look up a cached statement, refreshing its recency. */
55
+ get(sql) {
56
+ const stmt = this.entries.get(sql);
57
+ if (stmt !== void 0) {
58
+ this.entries.delete(sql);
59
+ this.entries.set(sql, stmt);
60
+ }
61
+ return stmt;
62
+ }
63
+ /** Insert a statement, finalizing the least-recently-used one if over capacity. */
64
+ set(sql, stmt) {
65
+ const existing = this.entries.get(sql);
66
+ if (existing !== void 0 && existing !== stmt) {
67
+ this.safeFinalize(existing);
68
+ }
69
+ this.entries.delete(sql);
70
+ this.entries.set(sql, stmt);
71
+ while (this.entries.size > this.capacity) {
72
+ const oldest = this.entries.keys().next().value;
73
+ const evicted = this.entries.get(oldest);
74
+ this.entries.delete(oldest);
75
+ if (evicted !== void 0) {
76
+ this.safeFinalize(evicted);
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Finalize and drop every cached statement. Called on DDL (`exec`) — where a
82
+ * dropped table would otherwise leave poisoned handles — and on `close()`.
83
+ */
84
+ clear() {
85
+ for (const stmt of this.entries.values()) {
86
+ this.safeFinalize(stmt);
87
+ }
88
+ this.entries.clear();
89
+ }
90
+ /** finalize() must never throw into a caller that is already unwinding. */
91
+ safeFinalize(stmt) {
92
+ try {
93
+ stmt.finalize();
94
+ } catch {
95
+ }
96
+ }
97
+ };
98
+
34
99
  // src/adapters/web.ts
35
100
  var WEB_SQLITE_VFS_NAME = "opfs-sahpool";
36
101
  var WEB_SQLITE_VFS_DIRECTORY = ".xnet-sqlite";
@@ -43,6 +108,9 @@ function log(...args) {
43
108
  console.log(...args);
44
109
  }
45
110
  }
111
+ function nowMs() {
112
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
113
+ }
46
114
  function getDatabasePath(config) {
47
115
  return config.path.startsWith("/") ? config.path : `/${config.path}`;
48
116
  }
@@ -89,18 +157,54 @@ var WebSQLiteAdapter = class {
89
157
  poolUtil = null;
90
158
  _config = null;
91
159
  inTransaction = false;
160
+ /**
161
+ * Monotonic time of the open transaction's last operation. Lets the worker
162
+ * host distinguish a LIVE manual transaction from one abandoned by a client
163
+ * that died between BEGIN and COMMIT (PowerSync-style lease recovery, 0263).
164
+ */
165
+ lastTransactionActivityAt = 0;
92
166
  storageMode = "memory";
167
+ /**
168
+ * Per-phase timing of the last {@link open} call, or null until it runs.
169
+ * Read by the worker host to emit `[xNet] sqlite open phases` (exploration
170
+ * 0253). Diagnostic only — never affects behaviour.
171
+ */
172
+ openPhaseTimings = null;
173
+ /** ms spent in `applySchema()` during {@link createWebSQLiteAdapter}, for the open-phases line. */
174
+ schemaApplyMs = 0;
175
+ /** How many times the OPFS lock-retry backoff fired during the last {@link open} (0 = first try). */
176
+ openRetryAttempts = 0;
177
+ /**
178
+ * Prepared-statement LRU for the hot query/run path (exploration 0263).
179
+ * `db.exec()` re-parses the SQL on every call; the hot path repeats a small
180
+ * statement set, so cached `oo1.Stmt` handles skip the parse+prepare cost.
181
+ * Cleared (finalizing all handles) on {@link exec} — the DDL/script path —
182
+ * and on {@link close}.
183
+ */
184
+ stmts = new StmtCache();
185
+ /** Per-phase timing of the last {@link open}, or null if it hasn't run. */
186
+ getOpenPhaseTimings() {
187
+ return this.openPhaseTimings ? { ...this.openPhaseTimings } : null;
188
+ }
93
189
  async open(config) {
94
190
  if (this.db !== null) {
95
191
  throw new Error("Database already open. Call close() first.");
96
192
  }
97
193
  log("[WebSQLiteAdapter] Starting open()...");
194
+ const openStartedAt = nowMs();
195
+ let afterImport = openStartedAt;
196
+ let afterInit = openStartedAt;
197
+ let afterVfsInstall = openStartedAt;
198
+ let afterReserveCapacity = openStartedAt;
199
+ let afterDbOpen = openStartedAt;
98
200
  log("[WebSQLiteAdapter] Importing sqlite-wasm...");
99
201
  const sqlite3InitModule = (await import("@sqlite.org/sqlite-wasm")).default;
100
202
  log("[WebSQLiteAdapter] sqlite-wasm imported");
203
+ afterImport = nowMs();
101
204
  log("[WebSQLiteAdapter] Initializing sqlite3 module...");
102
205
  this.sqlite3 = await sqlite3InitModule();
103
206
  log("[WebSQLiteAdapter] sqlite3 module initialized");
207
+ afterInit = nowMs();
104
208
  try {
105
209
  await withOpfsLockRetry(
106
210
  async () => {
@@ -113,17 +217,21 @@ var WebSQLiteAdapter = class {
113
217
  clearOnInit: false
114
218
  });
115
219
  log("[WebSQLiteAdapter] OPFS-SAHPool VFS installed");
220
+ afterVfsInstall = nowMs();
116
221
  log("[WebSQLiteAdapter] Reserving capacity...");
117
222
  await this.poolUtil.reserveMinimumCapacity(10);
118
223
  log("[WebSQLiteAdapter] Capacity reserved");
224
+ afterReserveCapacity = nowMs();
119
225
  const dbPath = getDatabasePath(config);
120
226
  log("[WebSQLiteAdapter] Opening database at", dbPath);
121
227
  this.db = new this.poolUtil.OpfsSAHPoolDb(dbPath, "c");
122
228
  this.storageMode = "opfs";
123
229
  log("[WebSQLiteAdapter] Database opened with OPFS-SAHPool");
230
+ afterDbOpen = nowMs();
124
231
  },
125
232
  {
126
233
  onRetry: (attempt, retryErr) => {
234
+ this.openRetryAttempts = attempt;
127
235
  console.warn(
128
236
  `[WebSQLiteAdapter] OPFS access handles are busy (attempt ${attempt}) \u2014 a previous tab/worker is likely still releasing them; retrying before any in-memory fallback.`,
129
237
  retryErr
@@ -132,7 +240,12 @@ var WebSQLiteAdapter = class {
132
240
  }
133
241
  );
134
242
  } catch (err) {
135
- console.warn("[WebSQLiteAdapter] OPFS-SAHPool not available, trying OPFS direct mode:", err);
243
+ const capability = detectOpfsCapability();
244
+ if (capability.mode === "async-opfs") {
245
+ console.info("[WebSQLiteAdapter] Sync access handles unavailable \u2014 " + capability.reason);
246
+ } else {
247
+ console.warn("[WebSQLiteAdapter] OPFS-SAHPool not available, trying OPFS direct mode:", err);
248
+ }
136
249
  const dbPath = getDatabasePath(config);
137
250
  const opfsDbCtor = this.sqlite3?.oo1?.OpfsDb;
138
251
  if (typeof opfsDbCtor === "function") {
@@ -157,6 +270,7 @@ var WebSQLiteAdapter = class {
157
270
  }
158
271
  }
159
272
  this._config = config;
273
+ const beforePragmas = nowMs();
160
274
  if (config.foreignKeys !== false) {
161
275
  this.execSync("PRAGMA foreign_keys = ON");
162
276
  }
@@ -170,16 +284,44 @@ var WebSQLiteAdapter = class {
170
284
  } catch (err) {
171
285
  log("[WebSQLiteAdapter] page_size pragma not applied:", err);
172
286
  }
287
+ try {
288
+ this.execSync("PRAGMA auto_vacuum = INCREMENTAL");
289
+ } catch (err) {
290
+ log("[WebSQLiteAdapter] auto_vacuum pragma not applied:", err);
291
+ }
173
292
  this.execSync("PRAGMA synchronous = NORMAL");
174
293
  this.execSync("PRAGMA cache_size = -262144");
294
+ try {
295
+ this.execSync("PRAGMA mmap_size = 268435456");
296
+ } catch (err) {
297
+ log("[WebSQLiteAdapter] mmap_size pragma not applied:", err);
298
+ }
175
299
  this.execSync("PRAGMA temp_store = MEMORY");
176
300
  try {
177
301
  this.execSync("PRAGMA journal_mode = TRUNCATE");
178
302
  } catch (err) {
179
303
  log("[WebSQLiteAdapter] journal_mode pragma not applied:", err);
180
304
  }
305
+ try {
306
+ this.execSync("PRAGMA analysis_limit = 400");
307
+ this.execSync("PRAGMA optimize = 0x10002");
308
+ } catch (err) {
309
+ log("[WebSQLiteAdapter] open-time optimize not applied:", err);
310
+ }
311
+ const afterPragmas = nowMs();
312
+ const round = (a, b) => Math.round(b - a);
313
+ this.openPhaseTimings = {
314
+ wasmImportMs: round(openStartedAt, afterImport),
315
+ wasmInitMs: round(afterImport, afterInit),
316
+ vfsInstallMs: round(afterInit, afterVfsInstall),
317
+ reserveCapacityMs: round(afterVfsInstall, afterReserveCapacity),
318
+ dbOpenMs: round(afterReserveCapacity, afterDbOpen),
319
+ pragmasMs: round(beforePragmas, afterPragmas),
320
+ totalOpenMs: round(openStartedAt, afterPragmas)
321
+ };
181
322
  }
182
323
  async close() {
324
+ this.stmts.clear();
183
325
  if (this.db) {
184
326
  try {
185
327
  this.execSync("PRAGMA optimize");
@@ -199,29 +341,98 @@ var WebSQLiteAdapter = class {
199
341
  getStorageMode() {
200
342
  return this.storageMode;
201
343
  }
344
+ /** True while a manual BEGIN…COMMIT/ROLLBACK transaction is open. */
345
+ isInTransaction() {
346
+ return this.inTransaction;
347
+ }
348
+ /** ms since the open transaction last executed an operation (0 when none). */
349
+ transactionIdleMs() {
350
+ return this.inTransaction ? nowMs() - this.lastTransactionActivityAt : 0;
351
+ }
352
+ /** Stamp transaction liveness — called by ops that run inside one. */
353
+ touchTransaction() {
354
+ if (this.inTransaction) {
355
+ this.lastTransactionActivityAt = nowMs();
356
+ }
357
+ }
358
+ /**
359
+ * Fetch (or prepare and cache) the statement for `sql`, or null when the SQL
360
+ * must bypass the cache: `db.prepare()` compiles only the FIRST statement of
361
+ * a multi-statement string, whereas `db.exec()` runs them all — serving such
362
+ * SQL from the cache would silently drop statements.
363
+ */
364
+ getCachedStmt(sql) {
365
+ if (hasInteriorSemicolon(sql)) {
366
+ return null;
367
+ }
368
+ let stmt = this.stmts.get(sql);
369
+ if (stmt === void 0) {
370
+ stmt = this.db.prepare(sql);
371
+ this.stmts.set(sql, stmt);
372
+ }
373
+ return stmt;
374
+ }
202
375
  async query(sql, params) {
203
376
  this.ensureOpen();
377
+ this.touchTransaction();
204
378
  const rows = [];
205
- this.db.exec({
206
- sql,
207
- bind: params,
208
- rowMode: "object",
209
- callback: (row) => {
210
- rows.push(row);
379
+ const stmt = this.getCachedStmt(sql);
380
+ if (stmt === null) {
381
+ this.db.exec({
382
+ sql,
383
+ bind: params,
384
+ rowMode: "object",
385
+ callback: (row) => {
386
+ rows.push(row);
387
+ }
388
+ });
389
+ return rows;
390
+ }
391
+ try {
392
+ if (params && params.length > 0) {
393
+ stmt.bind(params);
211
394
  }
212
- });
395
+ while (stmt.step()) {
396
+ rows.push(stmt.get({}));
397
+ }
398
+ } finally {
399
+ stmt.reset();
400
+ stmt.clearBindings();
401
+ }
213
402
  return rows;
214
403
  }
215
404
  async queryOne(sql, params) {
216
405
  const rows = await this.query(sql, params);
217
406
  return rows[0] ?? null;
218
407
  }
408
+ async queryBatch(reads) {
409
+ const results = [];
410
+ for (const read of reads) {
411
+ results.push(await this.query(read.sql, read.params));
412
+ }
413
+ return results;
414
+ }
219
415
  async run(sql, params) {
220
416
  this.ensureOpen();
221
- this.db.exec({
222
- sql,
223
- bind: params
224
- });
417
+ this.touchTransaction();
418
+ const stmt = this.getCachedStmt(sql);
419
+ if (stmt === null) {
420
+ this.db.exec({
421
+ sql,
422
+ bind: params
423
+ });
424
+ } else {
425
+ try {
426
+ if (params && params.length > 0) {
427
+ stmt.bind(params);
428
+ }
429
+ while (stmt.step()) {
430
+ }
431
+ } finally {
432
+ stmt.reset();
433
+ stmt.clearBindings();
434
+ }
435
+ }
225
436
  return {
226
437
  changes: this.sqlite3.capi.sqlite3_changes(this.db.pointer),
227
438
  lastInsertRowid: this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer)
@@ -229,6 +440,7 @@ var WebSQLiteAdapter = class {
229
440
  }
230
441
  async exec(sql) {
231
442
  this.ensureOpen();
443
+ this.stmts.clear();
232
444
  this.execSync(sql);
233
445
  }
234
446
  execSync(sql) {
@@ -391,6 +603,7 @@ var WebSQLiteAdapter = class {
391
603
  }
392
604
  this.execSync("BEGIN IMMEDIATE");
393
605
  this.inTransaction = true;
606
+ this.lastTransactionActivityAt = nowMs();
394
607
  }
395
608
  async commit() {
396
609
  if (!this.inTransaction) {
@@ -471,6 +684,10 @@ var WebSQLiteAdapter = class {
471
684
  async vacuum() {
472
685
  await this.exec("VACUUM");
473
686
  }
687
+ async incrementalVacuum(maxPages) {
688
+ this.ensureOpen();
689
+ return stepIncrementalVacuumToCompletion(this.db, maxPages);
690
+ }
474
691
  async checkpoint() {
475
692
  return 0;
476
693
  }
@@ -480,15 +697,31 @@ var WebSQLiteAdapter = class {
480
697
  }
481
698
  }
482
699
  };
700
+ function stepIncrementalVacuumToCompletion(db, maxPages) {
701
+ const limit = maxPages !== void 0 && maxPages > 0 ? Math.floor(maxPages) : Infinity;
702
+ const stmt = db.prepare("PRAGMA incremental_vacuum");
703
+ let freed = 0;
704
+ try {
705
+ while (freed < limit && stmt.step()) {
706
+ freed++;
707
+ }
708
+ } finally {
709
+ stmt.finalize();
710
+ }
711
+ return freed;
712
+ }
483
713
  async function createWebSQLiteAdapter(config) {
484
714
  const adapter = new WebSQLiteAdapter();
485
715
  await adapter.open(config);
716
+ const schemaStartedAt = nowMs();
486
717
  await adapter.applySchema(SCHEMA_VERSION, SCHEMA_DDL);
718
+ adapter.schemaApplyMs = Math.round(nowMs() - schemaStartedAt);
487
719
  return adapter;
488
720
  }
489
721
 
490
722
  export {
491
723
  resetWebSQLiteOpfsStorage,
492
724
  WebSQLiteAdapter,
725
+ stepIncrementalVacuumToCompletion,
493
726
  createWebSQLiteAdapter
494
727
  };
@@ -0,0 +1,16 @@
1
+ // src/adapters/boot-log-bridge.ts
2
+ var BOOT_LOG_KEY = "__xnetSqliteBootLog";
3
+ function bootLogMessage(args) {
4
+ return { [BOOT_LOG_KEY]: args };
5
+ }
6
+ function readBootLogArgs(data) {
7
+ if (typeof data === "object" && data !== null && Array.isArray(data[BOOT_LOG_KEY])) {
8
+ return data[BOOT_LOG_KEY];
9
+ }
10
+ return null;
11
+ }
12
+
13
+ export {
14
+ bootLogMessage,
15
+ readBootLogArgs
16
+ };
@@ -247,6 +247,25 @@ function escapeHtml(text) {
247
247
  div.textContent = text;
248
248
  return div.innerHTML;
249
249
  }
250
+ var MEMORY_FALLBACK_COUNT_KEY = "xnet:sqlite:memory-fallback-count";
251
+ function recordMemoryFallbackSession() {
252
+ try {
253
+ const count = getMemoryFallbackSessionCount() + 1;
254
+ localStorage.setItem(MEMORY_FALLBACK_COUNT_KEY, String(count));
255
+ return count;
256
+ } catch {
257
+ return 0;
258
+ }
259
+ }
260
+ function getMemoryFallbackSessionCount() {
261
+ try {
262
+ const raw = localStorage.getItem(MEMORY_FALLBACK_COUNT_KEY);
263
+ const parsed = raw === null ? 0 : Number.parseInt(raw, 10);
264
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
265
+ } catch {
266
+ return 0;
267
+ }
268
+ }
250
269
 
251
270
  export {
252
271
  checkBrowserSupport,
@@ -254,5 +273,7 @@ export {
254
273
  isSilentPersistRequestSafe,
255
274
  watchPersistentStoragePermission,
256
275
  requestPersistentStorage,
257
- showUnsupportedBrowserMessage
276
+ showUnsupportedBrowserMessage,
277
+ recordMemoryFallbackSession,
278
+ getMemoryFallbackSessionCount
258
279
  };