@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,159 @@
1
+ import {
2
+ WorkerScheduler
3
+ } from "../chunk-5HC5V73T.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";
11
+
12
+ // src/adapters/reader-pool.ts
13
+ function isHeavyRead(sql) {
14
+ return /\bMATCH\b|\bGROUP\s+BY\b|\bCOUNT\s*\(|\bSUM\s*\(|\bAVG\s*\(|\bjson_extract\s*\(|\bORDER\s+BY\b[\s\S]*\bLIMIT\b\s*\d{3,}/i.test(
15
+ sql
16
+ );
17
+ }
18
+ function resolveReaderPoolSize(size, dbPath, availableCores) {
19
+ if (dbPath === ":memory:" || dbPath === "") return 0;
20
+ if (size === void 0 || size === 0) return 0;
21
+ if (size === "auto") return Math.max(0, Math.min(4, availableCores - 2));
22
+ return Math.max(0, Math.floor(size));
23
+ }
24
+ var ReaderPool = class {
25
+ readers = [];
26
+ pending = /* @__PURE__ */ new Map();
27
+ seq = 0;
28
+ dispatched = 0;
29
+ failures = 0;
30
+ closed = false;
31
+ constructor(options) {
32
+ const create = options.createWorker;
33
+ for (let i = 0; i < options.size; i++) {
34
+ let worker;
35
+ try {
36
+ worker = create(i);
37
+ } catch {
38
+ this.failures++;
39
+ continue;
40
+ }
41
+ const reader = { index: i, worker, inFlight: 0, healthy: true };
42
+ worker.on("message", (msg) => this.onMessage(reader, msg));
43
+ worker.on("error", () => this.onReaderDown(reader));
44
+ worker.on("exit", (code) => {
45
+ if (code !== 0) this.onReaderDown(reader);
46
+ });
47
+ this.readers.push(reader);
48
+ }
49
+ }
50
+ /** Whether at least one reader can currently serve requests. */
51
+ isHealthy() {
52
+ return !this.closed && this.readers.some((r) => r.healthy);
53
+ }
54
+ /** Run a SELECT on the least-busy healthy reader. Rejects if none are healthy. */
55
+ query(sql, params) {
56
+ return this.dispatch({ op: "query", sql, params }).then((res) => {
57
+ if (res.ok && "rows" in res) return res.rows;
58
+ throw new Error(res.ok ? "reader returned no rows" : res.error);
59
+ });
60
+ }
61
+ /** Run a single-row SELECT on the least-busy healthy reader. */
62
+ queryOne(sql, params) {
63
+ return this.dispatch({ op: "queryOne", sql, params }).then((res) => {
64
+ if (res.ok && "row" in res) return res.row;
65
+ throw new Error(res.ok ? "reader returned no row" : res.error);
66
+ });
67
+ }
68
+ stats() {
69
+ return {
70
+ size: this.readers.length,
71
+ healthy: this.readers.filter((r) => r.healthy).length,
72
+ inFlight: this.pending.size,
73
+ dispatched: this.dispatched,
74
+ failures: this.failures
75
+ };
76
+ }
77
+ async close() {
78
+ this.closed = true;
79
+ for (const reader of this.readers) {
80
+ reader.healthy = false;
81
+ try {
82
+ await reader.worker.terminate();
83
+ } catch {
84
+ }
85
+ }
86
+ for (const [, p] of this.pending) {
87
+ p.reject(new Error("reader pool closed"));
88
+ }
89
+ this.pending.clear();
90
+ }
91
+ dispatch(req) {
92
+ const reader = this.pickLeastBusy();
93
+ if (!reader) return Promise.reject(new Error("no healthy reader available"));
94
+ const id = ++this.seq;
95
+ const message = { id, ...req };
96
+ reader.inFlight++;
97
+ this.dispatched++;
98
+ return new Promise((resolve, reject) => {
99
+ this.pending.set(id, { resolve, reject, reader });
100
+ try {
101
+ reader.worker.postMessage(message);
102
+ } catch (err) {
103
+ this.pending.delete(id);
104
+ reader.inFlight--;
105
+ this.onReaderDown(reader);
106
+ reject(err instanceof Error ? err : new Error(String(err)));
107
+ }
108
+ });
109
+ }
110
+ pickLeastBusy() {
111
+ let best = null;
112
+ for (const reader of this.readers) {
113
+ if (!reader.healthy) continue;
114
+ if (!best || reader.inFlight < best.inFlight) best = reader;
115
+ }
116
+ return best;
117
+ }
118
+ onMessage(reader, msg) {
119
+ if (msg.id === 0 && "ready" in msg) {
120
+ if (!msg.ready) this.onReaderDown(reader);
121
+ return;
122
+ }
123
+ const pending = this.pending.get(msg.id);
124
+ if (!pending) return;
125
+ this.pending.delete(msg.id);
126
+ reader.inFlight = Math.max(0, reader.inFlight - 1);
127
+ pending.resolve(msg);
128
+ }
129
+ onReaderDown(reader) {
130
+ if (!reader.healthy) return;
131
+ reader.healthy = false;
132
+ this.failures++;
133
+ for (const [id, p] of this.pending) {
134
+ if (p.reader === reader) {
135
+ this.pending.delete(id);
136
+ p.reject(new Error(`reader ${reader.index} is unavailable`));
137
+ }
138
+ }
139
+ }
140
+ };
141
+ function workerThreadsFactory(WorkerCtor, dbPath) {
142
+ const scriptUrl = new URL("./reader-thread.js", import.meta.url);
143
+ return () => new WorkerCtor(scriptUrl, { workerData: { dbPath } });
144
+ }
8
145
 
9
146
  // src/adapters/electron.ts
147
+ function readKey(op, sql, params) {
148
+ return `${op} ${sql} ${params ? JSON.stringify(params) : ""}`;
149
+ }
150
+ var APPLY_NODE_BATCH_CHUNK_ROWS = 250;
151
+ function nowMs() {
152
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
153
+ }
154
+ function yieldToEventLoop() {
155
+ return new Promise((resolve) => setImmediate(resolve));
156
+ }
10
157
  var DatabaseConstructor = null;
11
158
  async function getBetterSqlite3() {
12
159
  if (DatabaseConstructor) return DatabaseConstructor;
@@ -16,10 +163,19 @@ async function getBetterSqlite3() {
16
163
  }
17
164
  var ElectronSQLiteAdapter = class {
18
165
  db = null;
166
+ /** Optional read-only secondary connection (exploration 0230, Phase 0.5). */
167
+ readDb = null;
19
168
  config = null;
20
169
  inTransaction = false;
21
- // Cached prepared statements for performance
170
+ // Cached prepared statements for performance (per connection)
22
171
  statementCache = /* @__PURE__ */ new Map();
172
+ readStatementCache = /* @__PURE__ */ new Map();
173
+ /** Priority scheduler fronting the writer connection (exploration 0230). */
174
+ scheduler = null;
175
+ /** Read-only reader-thread pool for heavy parallel reads (Phase 1). */
176
+ readerPool = null;
177
+ /** Monotonic clock of the most recent commit, for read-your-writes routing. */
178
+ lastCommitAt = 0;
23
179
  async open(config) {
24
180
  if (this.db) {
25
181
  throw new Error("Database already open. Call close() first.");
@@ -41,10 +197,58 @@ var ElectronSQLiteAdapter = class {
41
197
  this.db.pragma("synchronous = NORMAL");
42
198
  this.db.pragma("cache_size = -64000");
43
199
  this.db.pragma("temp_store = MEMORY");
200
+ try {
201
+ this.db.pragma("analysis_limit = 400");
202
+ this.db.pragma("optimize = 0x10002");
203
+ } catch {
204
+ }
205
+ if (config.scheduler !== false) {
206
+ this.scheduler = new WorkerScheduler();
207
+ }
208
+ const fileBacked = config.path !== ":memory:" && config.path !== "";
209
+ if (config.readonlyReadConnection && fileBacked) {
210
+ try {
211
+ this.readDb = new Database(config.path, { readonly: true, fileMustExist: true });
212
+ this.readDb.pragma("busy_timeout = 5000");
213
+ this.readDb.pragma("cache_size = -32000");
214
+ this.readDb.pragma("temp_store = MEMORY");
215
+ } catch {
216
+ this.readDb = null;
217
+ }
218
+ }
219
+ if (fileBacked && config.readerPoolSize) {
220
+ try {
221
+ const os = await import("os");
222
+ const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
223
+ const poolSize = resolveReaderPoolSize(config.readerPoolSize, config.path, cores);
224
+ if (poolSize > 0) {
225
+ const { Worker } = await import("worker_threads");
226
+ this.readerPool = new ReaderPool({
227
+ dbPath: config.path,
228
+ size: poolSize,
229
+ createWorker: workerThreadsFactory(Worker, config.path)
230
+ });
231
+ }
232
+ } catch {
233
+ this.readerPool = null;
234
+ }
235
+ }
44
236
  }
45
237
  async close() {
46
238
  if (!this.db) return;
47
239
  this.statementCache.clear();
240
+ this.readStatementCache.clear();
241
+ if (this.readerPool) {
242
+ await this.readerPool.close();
243
+ this.readerPool = null;
244
+ }
245
+ if (this.readDb) {
246
+ try {
247
+ this.readDb.close();
248
+ } catch {
249
+ }
250
+ this.readDb = null;
251
+ }
48
252
  if (this.config?.walMode !== false) {
49
253
  try {
50
254
  this.db.pragma("wal_checkpoint(TRUNCATE)");
@@ -54,50 +258,79 @@ var ElectronSQLiteAdapter = class {
54
258
  this.db.close();
55
259
  this.db = null;
56
260
  this.config = null;
261
+ this.scheduler = null;
262
+ this.inTransaction = false;
57
263
  }
58
264
  isOpen() {
59
265
  return this.db !== null;
60
266
  }
61
267
  async query(sql, params) {
62
268
  this.ensureOpen();
63
- try {
64
- const stmt = this.getOrPrepare(sql);
65
- const rows = params ? stmt.all(...params) : stmt.all();
66
- return rows;
67
- } catch (err) {
68
- throw this.wrapError(err, sql);
269
+ if (this.inTransaction) return this.queryRaw(this.db, this.statementCache, sql, params);
270
+ if (this.shouldUsePool(sql)) {
271
+ try {
272
+ return await this.readerPool.query(sql, params);
273
+ } catch {
274
+ }
69
275
  }
276
+ const conn = this.readConnection();
277
+ if (this.scheduler) {
278
+ return this.scheduler.schedule(
279
+ "interactive",
280
+ async () => this.queryRaw(conn.db, conn.cache, sql, params),
281
+ readKey("query", sql, params),
282
+ "query"
283
+ );
284
+ }
285
+ return this.queryRaw(conn.db, conn.cache, sql, params);
286
+ }
287
+ async queryBatch(reads) {
288
+ const results = [];
289
+ for (const read of reads) {
290
+ results.push(await this.query(read.sql, read.params));
291
+ }
292
+ return results;
70
293
  }
71
294
  async queryOne(sql, params) {
72
295
  this.ensureOpen();
73
- try {
74
- const stmt = this.getOrPrepare(sql);
75
- const row = params ? stmt.get(...params) : stmt.get();
76
- return row ?? null;
77
- } catch (err) {
78
- throw this.wrapError(err, sql);
296
+ if (this.inTransaction) return this.queryOneRaw(this.db, this.statementCache, sql, params);
297
+ if (this.shouldUsePool(sql)) {
298
+ try {
299
+ return await this.readerPool.queryOne(sql, params);
300
+ } catch {
301
+ }
79
302
  }
303
+ const conn = this.readConnection();
304
+ if (this.scheduler) {
305
+ return this.scheduler.schedule(
306
+ "interactive",
307
+ async () => this.queryOneRaw(conn.db, conn.cache, sql, params),
308
+ readKey("queryOne", sql, params),
309
+ "queryOne"
310
+ );
311
+ }
312
+ return this.queryOneRaw(conn.db, conn.cache, sql, params);
80
313
  }
81
314
  async run(sql, params) {
82
315
  this.ensureOpen();
83
- try {
84
- const stmt = this.getOrPrepare(sql);
85
- const result = params ? stmt.run(...params) : stmt.run();
86
- return {
87
- changes: result.changes,
88
- lastInsertRowid: BigInt(result.lastInsertRowid)
89
- };
90
- } catch (err) {
91
- throw this.wrapError(err, sql);
316
+ if (this.inTransaction) return this.runRaw(sql, params);
317
+ if (this.scheduler) {
318
+ return this.scheduler.schedule(
319
+ "write",
320
+ async () => this.runRaw(sql, params),
321
+ void 0,
322
+ "run"
323
+ );
92
324
  }
325
+ return this.runRaw(sql, params);
93
326
  }
94
327
  async exec(sql) {
95
328
  this.ensureOpen();
96
- try {
97
- this.db.exec(sql);
98
- } catch (err) {
99
- throw this.wrapError(err, sql);
329
+ if (this.inTransaction) return this.execRaw(sql);
330
+ if (this.scheduler) {
331
+ return this.scheduler.schedule("write", async () => this.execRaw(sql), void 0, "exec");
100
332
  }
333
+ return this.execRaw(sql);
101
334
  }
102
335
  async transaction(fn) {
103
336
  this.ensureOpen();
@@ -122,25 +355,34 @@ var ElectronSQLiteAdapter = class {
122
355
  async transactionBatch(operations) {
123
356
  this.ensureOpen();
124
357
  if (operations.length === 0) return;
358
+ const apply = () => {
359
+ let currentSql = operations[0].sql;
360
+ try {
361
+ this.transactionSync(() => {
362
+ for (const operation of operations) {
363
+ currentSql = operation.sql;
364
+ const stmt = this.getOrPrepare(operation.sql);
365
+ if (operation.params) {
366
+ stmt.run(...operation.params);
367
+ } else {
368
+ stmt.run();
369
+ }
370
+ }
371
+ });
372
+ } catch (err) {
373
+ throw this.wrapError(err, currentSql);
374
+ }
375
+ this.lastCommitAt = nowMs();
376
+ };
125
377
  if (this.inTransaction) {
126
- throw new Error("Transaction already in progress");
378
+ apply();
379
+ return;
127
380
  }
128
- let currentSql = operations[0].sql;
129
- try {
130
- this.transactionSync(() => {
131
- for (const operation of operations) {
132
- currentSql = operation.sql;
133
- const stmt = this.getOrPrepare(operation.sql);
134
- if (operation.params) {
135
- stmt.run(...operation.params);
136
- } else {
137
- stmt.run();
138
- }
139
- }
140
- });
141
- } catch (err) {
142
- throw this.wrapError(err, currentSql);
381
+ if (this.scheduler) {
382
+ await this.scheduler.schedule("write", async () => apply(), void 0, "transactionBatch");
383
+ return;
143
384
  }
385
+ apply();
144
386
  }
145
387
  /**
146
388
  * Synchronous transaction for performance-critical batch operations.
@@ -152,6 +394,7 @@ var ElectronSQLiteAdapter = class {
152
394
  return txn();
153
395
  }
154
396
  async beginTransaction() {
397
+ this.ensureOpen();
155
398
  if (this.inTransaction) {
156
399
  throw new Error("Transaction already in progress");
157
400
  }
@@ -165,6 +408,7 @@ var ElectronSQLiteAdapter = class {
165
408
  try {
166
409
  this.db.exec("COMMIT");
167
410
  this.inTransaction = false;
411
+ this.lastCommitAt = nowMs();
168
412
  } catch (err) {
169
413
  if (isSQLiteCorruptionError(err)) {
170
414
  this.inTransaction = false;
@@ -259,24 +503,362 @@ var ElectronSQLiteAdapter = class {
259
503
  }
260
504
  async vacuum() {
261
505
  this.ensureOpen();
506
+ if (this.inTransaction) {
507
+ this.db.exec("VACUUM");
508
+ return;
509
+ }
510
+ if (this.scheduler) {
511
+ await this.scheduler.schedule(
512
+ "write",
513
+ async () => this.db.exec("VACUUM"),
514
+ void 0,
515
+ "vacuum"
516
+ );
517
+ return;
518
+ }
262
519
  this.db.exec("VACUUM");
263
520
  }
521
+ async incrementalVacuum(maxPages) {
522
+ this.ensureOpen();
523
+ const pragma = maxPages !== void 0 && maxPages > 0 ? `incremental_vacuum(${Math.floor(maxPages)})` : "incremental_vacuum";
524
+ const runIt = () => {
525
+ const before = this.db.pragma("freelist_count", { simple: true });
526
+ this.db.pragma(pragma);
527
+ const after = this.db.pragma("freelist_count", { simple: true });
528
+ return Math.max(0, before - after);
529
+ };
530
+ if (this.inTransaction) {
531
+ return runIt();
532
+ }
533
+ if (this.scheduler) {
534
+ return this.scheduler.schedule("write", async () => runIt(), void 0, "incremental_vacuum");
535
+ }
536
+ return runIt();
537
+ }
264
538
  async checkpoint() {
265
539
  return 0;
266
540
  }
267
541
  getStorageMode() {
268
542
  return "opfs";
269
543
  }
544
+ /**
545
+ * Apply a node-store batch with cooperative yielding (exploration 0230).
546
+ *
547
+ * A bulk import is the one synchronous op long enough to head-of-line block
548
+ * interactive reads on the data-process thread. This splits the batch into
549
+ * chunks, each its own exclusive transaction, and yields to the event loop
550
+ * between chunks so a queued interactive read (or a reader-pool read) can
551
+ * interleave. Whole-batch atomicity is traded for yield points — safe here
552
+ * because node-batch writes are idempotent LWW upserts.
553
+ */
554
+ async applyNodeBatch(input) {
555
+ this.ensureOpen();
556
+ const indexed = input.indexMode !== "defer-schema";
557
+ const invalidatedAt = Date.now();
558
+ const ops = [];
559
+ const nodeStmt = () => this.getOrPrepare(
560
+ `INSERT INTO nodes (id, schema_id, created_at, updated_at, created_by, deleted_at)
561
+ VALUES (?, ?, ?, ?, ?, ?)
562
+ ON CONFLICT(id) DO UPDATE SET
563
+ schema_id = excluded.schema_id,
564
+ updated_at = excluded.updated_at,
565
+ deleted_at = excluded.deleted_at`
566
+ );
567
+ for (const node of input.nodes) {
568
+ ops.push(
569
+ () => nodeStmt().run(
570
+ node.id,
571
+ node.schemaId,
572
+ node.createdAt,
573
+ node.updatedAt,
574
+ node.createdBy,
575
+ node.deletedAt
576
+ )
577
+ );
578
+ if (node.propertyKeys.length === 0) {
579
+ ops.push(
580
+ () => this.getOrPrepare("DELETE FROM node_properties WHERE node_id = ?").run(node.id)
581
+ );
582
+ } else {
583
+ const placeholders = node.propertyKeys.map(() => "?").join(", ");
584
+ ops.push(
585
+ () => this.getOrPrepare(
586
+ `DELETE FROM node_properties WHERE node_id = ? AND property_key NOT IN (${placeholders})`
587
+ ).run(node.id, ...node.propertyKeys)
588
+ );
589
+ }
590
+ }
591
+ for (const property of input.properties) {
592
+ ops.push(
593
+ () => this.getOrPrepare(
594
+ `INSERT INTO node_properties
595
+ (node_id, property_key, value, lamport_time, updated_by, updated_at)
596
+ VALUES (?, ?, ?, ?, ?, ?)
597
+ ON CONFLICT(node_id, property_key) DO UPDATE SET
598
+ value = excluded.value,
599
+ lamport_time = excluded.lamport_time,
600
+ updated_by = excluded.updated_by,
601
+ updated_at = excluded.updated_at
602
+ WHERE excluded.lamport_time > node_properties.lamport_time`
603
+ ).run(
604
+ property.nodeId,
605
+ property.propertyKey,
606
+ property.value,
607
+ property.lamportTime,
608
+ property.updatedBy,
609
+ property.updatedAt
610
+ )
611
+ );
612
+ }
613
+ if (indexed) {
614
+ for (const node of input.nodes) {
615
+ ops.push(
616
+ () => this.getOrPrepare("DELETE FROM node_property_scalars WHERE node_id = ?").run(node.id)
617
+ );
618
+ }
619
+ for (const row of input.scalarIndexRows) {
620
+ ops.push(
621
+ () => this.getOrPrepare(
622
+ `INSERT INTO node_property_scalars
623
+ (node_id, schema_id, property_key, value_type, value_text,
624
+ value_number, value_boolean, value_hash, updated_at, lamport_time)
625
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
626
+ ).run(
627
+ row.nodeId,
628
+ row.schemaId,
629
+ row.propertyKey,
630
+ row.valueType,
631
+ row.valueText,
632
+ row.valueNumber,
633
+ row.valueBoolean,
634
+ row.valueHash,
635
+ row.updatedAt,
636
+ row.lamportTime
637
+ )
638
+ );
639
+ }
640
+ for (const nodeId of input.ftsNodeIds) {
641
+ ops.push(() => this.getOrPrepare("DELETE FROM nodes_fts WHERE node_id = ?").run(nodeId));
642
+ }
643
+ for (const row of input.ftsRows) {
644
+ ops.push(
645
+ () => this.getOrPrepare("INSERT INTO nodes_fts (node_id, title, content) VALUES (?, ?, ?)").run(
646
+ row.nodeId,
647
+ row.title,
648
+ row.content
649
+ )
650
+ );
651
+ }
652
+ }
653
+ for (const change of input.changes) {
654
+ ops.push(
655
+ () => this.getOrPrepare(
656
+ `INSERT OR IGNORE INTO changes
657
+ (hash, node_id, payload, lamport_time, lamport_peer, wall_time, author, parent_hash, batch_id, signature)
658
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
659
+ ).run(
660
+ change.hash,
661
+ change.nodeId,
662
+ change.payload,
663
+ change.lamportTime,
664
+ change.lamportPeer,
665
+ change.wallTime,
666
+ change.author,
667
+ change.parentHash,
668
+ change.batchId,
669
+ change.signature
670
+ )
671
+ );
672
+ }
673
+ ops.push(
674
+ () => this.getOrPrepare(
675
+ `INSERT INTO sync_state (key, value) VALUES ('lastLamportTime', ?)
676
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
677
+ ).run(String(input.lastLamportTime))
678
+ );
679
+ if (indexed) {
680
+ for (const schemaId of input.affectedSchemaIds) {
681
+ ops.push(
682
+ () => this.getOrPrepare(
683
+ `UPDATE node_query_materializations
684
+ SET invalidated_at = ?
685
+ WHERE schema_id = ? AND invalidated_at IS NULL`
686
+ ).run(invalidatedAt, schemaId)
687
+ );
688
+ }
689
+ }
690
+ if (this.inTransaction) {
691
+ for (const op of ops) op();
692
+ } else {
693
+ for (let i = 0; i < ops.length; i += APPLY_NODE_BATCH_CHUNK_ROWS) {
694
+ const chunk = ops.slice(i, i + APPLY_NODE_BATCH_CHUNK_ROWS);
695
+ await this.runChunkTransaction(() => {
696
+ for (const op of chunk) op();
697
+ });
698
+ if (i + APPLY_NODE_BATCH_CHUNK_ROWS < ops.length) await yieldToEventLoop();
699
+ }
700
+ }
701
+ return {
702
+ nodeRowsWritten: input.nodes.length,
703
+ propertyRowsWritten: input.properties.length,
704
+ changeRowsWritten: input.changes.length,
705
+ scalarRowsWritten: input.scalarIndexRows.length,
706
+ ftsRowsWritten: input.ftsRows.length
707
+ };
708
+ }
709
+ /** Scheduler queue depths (diagnostics / desktop perf panel). */
710
+ getSchedulerSnapshot() {
711
+ return this.scheduler ? this.scheduler.snapshot() : null;
712
+ }
713
+ /** Reader-thread pool occupancy, or null when no pool is configured. */
714
+ getReaderPoolStats() {
715
+ return this.readerPool ? this.readerPool.stats() : null;
716
+ }
717
+ /**
718
+ * WAL growth: `-wal` sidecar size + page count. Long-lived readers can pin an
719
+ * old snapshot and hold back checkpointing; our one-shot reader selects don't,
720
+ * so this should stay bounded. Surfaced to the diagnostics seam (0230).
721
+ */
722
+ async getWalStats() {
723
+ if (!this.db || this.config?.walMode === false) return null;
724
+ try {
725
+ const path = this.config?.path;
726
+ let walBytes = 0;
727
+ if (path && path !== ":memory:") {
728
+ const { statSync } = await import("fs");
729
+ try {
730
+ walBytes = statSync(`${path}-wal`).size;
731
+ } catch {
732
+ walBytes = 0;
733
+ }
734
+ }
735
+ const page = this.db.pragma("page_count", { simple: true });
736
+ return { walBytes, pageCount: typeof page === "number" ? page : 0 };
737
+ } catch {
738
+ return null;
739
+ }
740
+ }
741
+ /** Run a passive WAL checkpoint; returns frames checkpointed (best-effort). */
742
+ async checkpointWal() {
743
+ if (!this.db || this.config?.walMode === false) return 0;
744
+ return this.scheduler ? this.scheduler.schedule("bulk", async () => this.runCheckpoint(), void 0, "checkpoint") : this.runCheckpoint();
745
+ }
746
+ runCheckpoint() {
747
+ try {
748
+ const result = this.db.pragma("wal_checkpoint(PASSIVE)");
749
+ return result?.[0]?.checkpointed ?? 0;
750
+ } catch {
751
+ return 0;
752
+ }
753
+ }
754
+ /** Combined point-in-time diagnostics for the desktop diagnostics seam. */
755
+ async getDiagnostics() {
756
+ return {
757
+ scheduler: this.getSchedulerSnapshot(),
758
+ readerPool: this.getReaderPoolStats(),
759
+ wal: await this.getWalStats(),
760
+ readonlyConnection: this.readDb !== null
761
+ };
762
+ }
270
763
  // ─── Helper Methods ─────────────────────────────────────────────────────
764
+ /** Whether a heavy, non-transactional read should be offloaded to the pool. */
765
+ shouldUsePool(sql) {
766
+ return this.readerPool !== null && this.readerPool.isHealthy() && isHeavyRead(sql) && !this.inReadYourWritesWindow();
767
+ }
768
+ /** Reads within the configured window after a commit route to the writer. */
769
+ inReadYourWritesWindow() {
770
+ const window = this.config?.readYourWritesWindowMs ?? 0;
771
+ if (window <= 0) return false;
772
+ return nowMs() - this.lastCommitAt < window;
773
+ }
774
+ /** Pick the connection (+ its statement cache) a plain read should use. */
775
+ readConnection() {
776
+ if (this.readDb && !this.inReadYourWritesWindow()) {
777
+ return { db: this.readDb, cache: this.readStatementCache };
778
+ }
779
+ return { db: this.db, cache: this.statementCache };
780
+ }
781
+ /**
782
+ * Run `apply` as a single atomic chunk transaction. Scheduled as one write-lane
783
+ * job so it is indivisible w.r.t. the scheduler — no queued read or write can
784
+ * interleave mid-chunk — while the surrounding `applyNodeBatch` yields *between*
785
+ * chunks. `apply` is fully synchronous (no awaits), so `inTransaction` is set
786
+ * only for its duration and is invisible to other async callers.
787
+ */
788
+ runChunkTransaction(apply) {
789
+ const exec = () => {
790
+ this.inTransaction = true;
791
+ try {
792
+ this.db.exec("BEGIN IMMEDIATE");
793
+ try {
794
+ apply();
795
+ this.db.exec("COMMIT");
796
+ this.lastCommitAt = nowMs();
797
+ } catch (err) {
798
+ try {
799
+ this.db.exec("ROLLBACK");
800
+ } catch {
801
+ }
802
+ throw err;
803
+ }
804
+ } finally {
805
+ this.inTransaction = false;
806
+ }
807
+ };
808
+ return this.scheduler ? this.scheduler.schedule("write", async () => exec(), void 0, "applyNodeBatch") : Promise.resolve().then(exec);
809
+ }
810
+ queryRaw(db, cache, sql, params) {
811
+ try {
812
+ const stmt = this.getOrPrepareOn(db, cache, sql);
813
+ const rows = params ? stmt.all(...params) : stmt.all();
814
+ return rows;
815
+ } catch (err) {
816
+ throw this.wrapError(err, sql);
817
+ }
818
+ }
819
+ queryOneRaw(db, cache, sql, params) {
820
+ try {
821
+ const stmt = this.getOrPrepareOn(db, cache, sql);
822
+ const row = params ? stmt.get(...params) : stmt.get();
823
+ return row ?? null;
824
+ } catch (err) {
825
+ throw this.wrapError(err, sql);
826
+ }
827
+ }
828
+ runRaw(sql, params) {
829
+ try {
830
+ const stmt = this.getOrPrepare(sql);
831
+ const result = params ? stmt.run(...params) : stmt.run();
832
+ this.lastCommitAt = nowMs();
833
+ return {
834
+ changes: result.changes,
835
+ lastInsertRowid: BigInt(result.lastInsertRowid)
836
+ };
837
+ } catch (err) {
838
+ throw this.wrapError(err, sql);
839
+ }
840
+ }
841
+ execRaw(sql) {
842
+ try {
843
+ this.db.exec(sql);
844
+ this.lastCommitAt = nowMs();
845
+ } catch (err) {
846
+ throw this.wrapError(err, sql);
847
+ }
848
+ }
271
849
  /**
272
- * Get a statement from cache or prepare it.
850
+ * Get a statement from cache or prepare it on the writer connection.
273
851
  * Statement caching significantly improves performance for repeated queries.
274
852
  */
275
853
  getOrPrepare(sql) {
276
- let stmt = this.statementCache.get(sql);
854
+ return this.getOrPrepareOn(this.db, this.statementCache, sql);
855
+ }
856
+ /** Statement cache keyed per connection (writer + read-only differ). */
857
+ getOrPrepareOn(db, cache, sql) {
858
+ let stmt = cache.get(sql);
277
859
  if (!stmt) {
278
- stmt = this.db.prepare(sql);
279
- this.statementCache.set(sql, stmt);
860
+ stmt = db.prepare(sql);
861
+ cache.set(sql, stmt);
280
862
  }
281
863
  return stmt;
282
864
  }