switchroom 0.21.3 → 0.21.4

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.
@@ -45,15 +45,34 @@ import { redact } from './secret-detect/redact.js'
45
45
  * The only Database APIs we touch are constructor(path, opts), exec,
46
46
  * prepare, transaction, close — all stable across bun:sqlite versions.
47
47
  */
48
+ type SqliteStatement = {
49
+ run(...params: unknown[]): unknown
50
+ all(...params: unknown[]): unknown[]
51
+ get(...params: unknown[]): unknown
52
+ /**
53
+ * Release the underlying `sqlite3_stmt` NOW. This is the deterministic
54
+ * counterpart to waiting for GC: an un-finalized statement is exactly what
55
+ * makes bun:sqlite's close a deferred no-op, so {@link reopenHistory}
56
+ * finalizes every cached statement before closing. Optional in the type
57
+ * because a test double need not implement it.
58
+ */
59
+ finalize?(): void
60
+ }
48
61
  type SqliteDatabase = {
49
62
  exec(sql: string): void
50
- prepare(sql: string): {
51
- run(...params: unknown[]): unknown
52
- all(...params: unknown[]): unknown[]
53
- get(...params: unknown[]): unknown
54
- }
63
+ prepare(sql: string): SqliteStatement
55
64
  transaction(fn: (...args: unknown[]) => unknown): (...args: unknown[]) => unknown
56
- close(): void
65
+ /**
66
+ * `throwOnError` is bun:sqlite's "hard close" switch. The default
67
+ * (`close()`) is a SOFT close that silently defers the real
68
+ * `sqlite3_close` while any un-finalized `Statement` still references the
69
+ * connection. `close(true)` instead THROWS (`database is locked`) when the
70
+ * close did not actually happen, which is the only way to know. Every
71
+ * statement this module creates goes through {@link prep} and is explicitly
72
+ * finalized before the close, so the close is deterministic rather than
73
+ * GC-dependent. Load-bearing for {@link reopenHistory}; see the note there.
74
+ */
75
+ close(throwOnError?: boolean): void
57
76
  }
58
77
  type SqliteDatabaseConstructor = new (path: string, opts?: { create?: boolean }) => SqliteDatabase
59
78
 
@@ -174,6 +193,79 @@ const MAX_LIMIT = 50
174
193
  let db: SqliteDatabase | null = null
175
194
  let dbPath: string | null = null
176
195
 
196
+ /**
197
+ * Module-level prepared-statement cache — the DETERMINISTIC half of
198
+ * {@link reopenHistory}.
199
+ *
200
+ * WHY THIS EXISTS (it is not a performance cache, though it is also that)
201
+ * ----------------------------------------------------------------------
202
+ * bun:sqlite's `close()` is a soft close and `close(true)` throws for as long
203
+ * as ANY `Statement` created from the connection is still un-finalized. Every
204
+ * statement in this module used to be created per-call (`requireDb().prepare(…)`)
205
+ * and dropped on the floor, so nothing in the process held a reference we could
206
+ * finalize — the only way to release them was to wait for the JS GC to collect
207
+ * the wrappers. `Bun.gc(true)` was therefore load-bearing AND non-deterministic:
208
+ * measured on bun 1.3.13, a single `gc(true)` + `close(true)` pair failed 13/20
209
+ * times against an empty heap and 30/30 with an arithmetic loop between the last
210
+ * write and the gc. A recovery path that works "most of the time" is not a
211
+ * recovery path.
212
+ *
213
+ * With every statement routed through {@link prep}, the module OWNS a reference
214
+ * to each live statement and can call `.finalize()` on it explicitly. The close
215
+ * then succeeds because nothing is outstanding — no collection required, no
216
+ * heap-shape dependence.
217
+ *
218
+ * Keyed by SQL text. The set of distinct SQL strings this module can produce is
219
+ * small and structural (a handful of `WHERE` shapes built from booleans), not
220
+ * user-derived — parameters are always bound, never interpolated. {@link MAX_CACHED_STATEMENTS}
221
+ * is defence-in-depth against a future caller that builds SQL in a loop.
222
+ */
223
+ const stmtCache = new Map<string, SqliteStatement>()
224
+
225
+ /** Cap on live cached statements; oldest is finalized and evicted past this. */
226
+ const MAX_CACHED_STATEMENTS = 128
227
+
228
+ /**
229
+ * Prepare (or reuse) a statement against the current connection.
230
+ *
231
+ * Every SQL string in this module goes through here. Do NOT call
232
+ * `db.prepare(...)` directly: a statement outside the cache is one
233
+ * {@link reopenHistory} cannot finalize, which silently re-introduces the
234
+ * GC dependence this cache exists to remove.
235
+ */
236
+ function prep(sql: string): SqliteStatement {
237
+ const cached = stmtCache.get(sql)
238
+ if (cached != null) return cached
239
+ const stmt = requireDb().prepare(sql)
240
+ if (stmtCache.size >= MAX_CACHED_STATEMENTS) {
241
+ // Map iteration is insertion-ordered, so this evicts the oldest entry.
242
+ const oldestKey = stmtCache.keys().next().value
243
+ if (oldestKey != null) {
244
+ const oldest = stmtCache.get(oldestKey)
245
+ stmtCache.delete(oldestKey)
246
+ try { oldest?.finalize?.() } catch { /* already gone; nothing to release */ }
247
+ }
248
+ }
249
+ stmtCache.set(sql, stmt)
250
+ return stmt
251
+ }
252
+
253
+ /**
254
+ * Finalize and forget every cached statement.
255
+ *
256
+ * MUST run before any `close()` of the connection they were prepared against,
257
+ * and MUST leave the cache empty: a statement prepared against the OLD
258
+ * connection would otherwise be handed out after the reopen and operate on a
259
+ * dead handle. Individual `finalize()` failures are swallowed — the close is
260
+ * the authority on whether the release actually happened.
261
+ */
262
+ function finalizeCachedStatements(): void {
263
+ for (const stmt of stmtCache.values()) {
264
+ try { stmt.finalize?.() } catch { /* best-effort; close(true) reports the truth */ }
265
+ }
266
+ stmtCache.clear()
267
+ }
268
+
177
269
  /**
178
270
  * Loud, unconditional failure logging for the history writer.
179
271
  *
@@ -300,9 +392,9 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
300
392
  // (`thread_id IS NULL` / `thread_id = ?`) is unchanged.
301
393
  const LOGICAL_KEY_INDEX = 'idx_messages_logical_key'
302
394
  const logicalKeyIndexExists =
303
- db
304
- .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`)
305
- .get(LOGICAL_KEY_INDEX) != null
395
+ prep(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`).get(
396
+ LOGICAL_KEY_INDEX,
397
+ ) != null
306
398
  if (!logicalKeyIndexExists) {
307
399
  // De-dupe rows an earlier (pre-fix) build already appended, keeping the
308
400
  // NEWEST row per logical key (highest ts, then highest rowid = the last
@@ -346,7 +438,7 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
346
438
 
347
439
  if (retentionDays > 0) {
348
440
  const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
349
- db.prepare('DELETE FROM messages WHERE ts < ?').run(cutoff)
441
+ prep('DELETE FROM messages WHERE ts < ?').run(cutoff)
350
442
  }
351
443
 
352
444
  // Boot-time writer self-check (2026-07-16 incident hardening). "history
@@ -391,15 +483,15 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
391
483
  const sentinelId = Date.now()
392
484
  try {
393
485
  // Clear any stale sentinel from a prior crashed self-check first.
394
- db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
395
- db.prepare(
486
+ prep('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
487
+ prep(
396
488
  `INSERT OR REPLACE INTO messages
397
489
  (chat_id, thread_id, message_id, role, ts, text)
398
490
  VALUES (?, NULL, ?, 'assistant', ?, ?)`,
399
491
  ).run(SENTINEL_CHAT, sentinelId, Math.floor(Date.now() / 1000), 'selfcheck')
400
- const row = db
401
- .prepare('SELECT text FROM messages WHERE chat_id = ? AND message_id = ?')
402
- .get(SENTINEL_CHAT, sentinelId) as { text?: string } | undefined
492
+ const row = prep(
493
+ 'SELECT text FROM messages WHERE chat_id = ? AND message_id = ?',
494
+ ).get(SENTINEL_CHAT, sentinelId) as { text?: string } | undefined
403
495
  if (row?.text !== 'selfcheck') {
404
496
  return { ok: false, error: 'sentinel row not read back after insert' }
405
497
  }
@@ -409,7 +501,7 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
409
501
  } finally {
410
502
  // Never leave the sentinel behind, even if the SELECT/assert path threw.
411
503
  try {
412
- db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
504
+ prep('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
413
505
  } catch {
414
506
  /* best-effort cleanup */
415
507
  }
@@ -424,21 +516,40 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
424
516
  * empty instead of throwing. Do NOT use for writes — every write path goes
425
517
  * through the record* functions above so redaction and validity checks
426
518
  * cannot be bypassed.
519
+ *
520
+ * The returned `prepare` routes through the module statement cache rather than
521
+ * handing out the raw `Database`. That is deliberate: a statement prepared
522
+ * directly off the connection by an outside caller is one `reopenHistory`
523
+ * cannot finalize, and a single such statement is enough to make the hard close
524
+ * throw. Keeping the seam inside the cache keeps the reopen deterministic no
525
+ * matter who else reads.
427
526
  */
428
527
  export function getHistoryDbForBriefing(): {
429
528
  prepare(sql: string): { all(...params: unknown[]): unknown[] }
430
529
  } | null {
431
- return db
530
+ if (db == null) return null
531
+ return {
532
+ prepare(sql: string) {
533
+ return { all: (...params: unknown[]) => prep(sql).all(...params) }
534
+ },
535
+ }
432
536
  }
433
537
 
434
538
  /**
435
539
  * For tests — close the singleton and forget it. Production code never
436
540
  * needs this; the DB is held open for the lifetime of the process.
541
+ *
542
+ * Uses the SAME deterministic hard close as {@link reopenHistory}: a soft
543
+ * `close()` here leaked three fds per test (`h.db`, `-wal`, `-shm`), which is
544
+ * how the state-dir prefix guard in the sweep test came to look covered when it
545
+ * was not — the leaked fds from the previous test were doing the filtering.
437
546
  */
438
547
  export function _resetForTests(): void {
548
+ historyReopenFailure = null
439
549
  if (db != null) {
440
- db.close()
550
+ const current = db
441
551
  db = null
552
+ hardCloseDb(current)
442
553
  }
443
554
  }
444
555
 
@@ -458,7 +569,7 @@ export function _resetForTests(): void {
458
569
  export function checkpointWal(): boolean {
459
570
  if (db == null) return false
460
571
  try {
461
- db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').run()
572
+ prep('PRAGMA wal_checkpoint(TRUNCATE)').run()
462
573
  // Re-apply permissions after WAL truncation (SQLite may recreate -wal/-shm)
463
574
  if (dbPath) {
464
575
  for (const suffix of ['-shm', '-wal']) {
@@ -475,6 +586,152 @@ export function checkpointWal(): boolean {
475
586
  }
476
587
  }
477
588
 
589
+ /**
590
+ * Drop the current history handle and re-open the DB from its path.
591
+ *
592
+ * THE FAILURE MODE THIS RECOVERS
593
+ * ------------------------------
594
+ * A FOREIGN process rw-opens `history.db`, and on exit — as the last
595
+ * connection — checkpoints and UNLINKS `history.db-wal` / `-shm`. Our
596
+ * long-lived connection keeps the deleted inodes mapped and keeps writing into
597
+ * them; every INSERT still reports success and the rows are simply gone at the
598
+ * next restart. Signature: `/proc/<pid>/fd/N -> …/history.db-wal (deleted)`.
599
+ * A container restart heals it; this is that restart, in-process. Detection and
600
+ * the call site live in `gateway/orphaned-db-sweep.ts`.
601
+ *
602
+ * WHY `close()` IS NOT ENOUGH (measured, not assumed)
603
+ * ---------------------------------------------------
604
+ * bun:sqlite's default `close()` is a SOFT close: it defers the real
605
+ * `sqlite3_close` while any un-finalized `Statement` still holds the
606
+ * connection. Measured on bun 1.3.13: after a plain `db.close()` the process
607
+ * STILL held `h.db`, `h.db-wal (deleted)` and `h.db-shm (deleted)` in
608
+ * `/proc/self/fd`, and the reopen then produced a connection that LOOKS
609
+ * healthy — `new Database(...)` and `PRAGMA journal_mode = WAL` both SUCCEED —
610
+ * and throws on the first WRITE instead (`SQLITE_IOERR_SHORT_READ`, errno 522),
611
+ * because SQLite's unix VFS keeps per-inode WAL-index state alive for as long
612
+ * as any connection to that inode is open. A naive close-and-reopen therefore
613
+ * turns silent row loss into a TOTAL history outage, and does it at a point
614
+ * where the reopen has already "succeeded". (The same DB file opened from a
615
+ * SEPARATE process reads fine, which is how we know the on-disk DB is not
616
+ * corrupt and the poisoning is in-process.)
617
+ *
618
+ * So the close has to be a HARD one — {@link hardCloseDb}, in two steps that
619
+ * are both load-bearing:
620
+ * 1. FINALIZE every outstanding statement, explicitly. Every statement in
621
+ * this module is created through {@link prep} and held in `stmtCache`
622
+ * precisely so this step can exist: `finalizeCachedStatements()` releases
623
+ * each `sqlite3_stmt` synchronously. This is the determinism. The earlier
624
+ * version of this code had no cache and leaned on `Bun.gc(true)` to
625
+ * collect the per-call wrappers, which is a coin flip: 13/20 failures with
626
+ * an empty heap and 30/30 with an arithmetic loop before the gc.
627
+ * `Bun.gc(true)` is still called, but only as belt-and-braces for a
628
+ * statement some future caller creates outside the cache — correctness no
629
+ * longer depends on it, and the test suite proves that by asserting the
630
+ * reopen under the exact heap shape that made the gc-only version fail
631
+ * every time.
632
+ * 2. `close(true)`, which THROWS rather than silently deferring. If we cannot
633
+ * actually close we must NOT null `db` and pretend to have recovered — we
634
+ * rethrow so the sweep logs the failure and asks for a restart, leaving
635
+ * the (bad but working) old handle in place rather than a null one that
636
+ * fails every write.
637
+ *
638
+ * NO EXPLICIT SALVAGE CHECKPOINT
639
+ * ------------------------------
640
+ * We never issue `wal_checkpoint` through the orphaned handle. Once a new
641
+ * `-shm` exists on disk, our handle and any other connection are in DISJOINT
642
+ * locking domains and a checkpoint through the stale mapping can corrupt the
643
+ * main DB. Losing rows is strictly better than corrupting the ones that landed.
644
+ * `close()` does still run SQLite's IMPLICIT checkpoint-on-close through the
645
+ * old fds — a knowingly accepted residual, not an oversight: it is exactly what
646
+ * a clean container restart already does today, so this path is no more
647
+ * dangerous than the restart it replaces. (In the isolated case it also
648
+ * SALVAGES the orphaned rows; we do not rely on that, because the concurrent
649
+ * case cannot.)
650
+ *
651
+ * `initHistory` early-returns when `db != null`, so nulling is load-bearing —
652
+ * without it the reopen is a silent no-op and the orphaned handle keeps eating
653
+ * rows. The re-init re-runs the idempotent DDL/migrations, the chmod/ownership
654
+ * fixups, retention, and `verifyHistoryWritable()` — a free post-reopen proof
655
+ * that the new handle can actually persist a row.
656
+ *
657
+ * THREE FAILURE SHAPES, THREE DIFFERENT RESIDUAL STATES
658
+ * -----------------------------------------------------
659
+ * If the hard CLOSE throws, `db` is untouched: history is still lossy but
660
+ * functional, and the orphaned fds are still present so the sweep re-detects
661
+ * and retries on the next tick. If the close SUCCEEDS and the re-init throws,
662
+ * `db` is null and the orphaned fds are gone — the sweep's fd detector will
663
+ * never fire again — so that state is recorded in {@link historyReopenFailure}
664
+ * for the sweep to re-alarm on independently of detection. Without that flag a
665
+ * failed re-init is a permanent, silent history outage: strictly worse than the
666
+ * bug this whole path exists to fix. If the re-init succeeds but the SELF-CHECK
667
+ * fails, `db` is the new (readable, un-writable) handle: reads keep working,
668
+ * the flag is set, and the sweep keeps retrying — the state is dead for writes
669
+ * but there is no reason to also blind the read paths.
670
+ *
671
+ * Throws if the hard close, the re-init, or the post-reopen writer self-check
672
+ * fails. Callers must treat a throw as "history is still broken, restart
673
+ * required".
674
+ */
675
+ export function reopenHistory(stateDir: string, retentionDays = 30): void {
676
+ const current = db
677
+ if (current != null) {
678
+ // Deliberately NOT caught: a failed close means the fds are still open and
679
+ // writes through the reopened handle would fail. Propagate with `db` intact.
680
+ hardCloseDb(current)
681
+ }
682
+ db = null
683
+ try {
684
+ initHistory(stateDir, retentionDays)
685
+ // `initHistory` only WARNS when the self-check fails and returns normally,
686
+ // so without this the sweep would log "writes are durable again" on a full
687
+ // disk, right next to "WRITER SELF-CHECK FAILED". Prove it, don't claim it.
688
+ const check = verifyHistoryWritable()
689
+ if (!check.ok) {
690
+ throw new Error(`post-reopen writer self-check failed: ${check.error ?? 'unknown'}`)
691
+ }
692
+ } catch (err) {
693
+ historyReopenFailure = err instanceof Error ? err.message : String(err)
694
+ throw err
695
+ }
696
+ historyReopenFailure = null
697
+ }
698
+
699
+ /**
700
+ * Deterministically release a connection: finalize every statement this module
701
+ * owns, then hard-close.
702
+ *
703
+ * Exported for the sweep's own use and for tests that need to prove the close
704
+ * is real. `Bun.gc(true)` runs first as belt-and-braces for any statement
705
+ * created outside the cache (a future caller, or bun's own internal
706
+ * transaction statements); the finalize pass is what makes the close
707
+ * deterministic. `close(true)` is NOT caught — a deferred close is exactly the
708
+ * failure this function exists to surface.
709
+ */
710
+ export function hardCloseDb(handle: SqliteDatabase): void {
711
+ finalizeCachedStatements()
712
+ const gc = (globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc
713
+ if (typeof gc === 'function') {
714
+ try { gc(true) } catch { /* best-effort; close(true) below reports the truth */ }
715
+ }
716
+ handle.close(true)
717
+ }
718
+
719
+ /**
720
+ * Sticky record of a reopen that closed the old handle successfully and then
721
+ * failed to re-init. Non-null means `db` is null and every read/write path is
722
+ * dead — a state nothing else in the process can detect, because the orphaned
723
+ * fds that triggered the reopen are gone.
724
+ *
725
+ * Read by the orphaned-DB sweep on EVERY tick, independently of fd detection,
726
+ * so the outage keeps alarming and keeps being retried.
727
+ */
728
+ let historyReopenFailure: string | null = null
729
+
730
+ /** The sticky reopen-failure message, or null when history is healthy. */
731
+ export function getHistoryReopenFailure(): string | null {
732
+ return historyReopenFailure
733
+ }
734
+
478
735
  /**
479
736
  * Prune `messages` rows older than `retentionDays`. Used by the periodic
480
737
  * reaper (#1073) to catch the case where the gateway runs for weeks or
@@ -497,7 +754,7 @@ export function pruneMessagesOlderThanDays(
497
754
  if (db == null) return 0
498
755
  if (retentionDays <= 0) return 0
499
756
  const cutoffSec = (nowSec ?? Math.floor(Date.now() / 1000)) - retentionDays * 86400
500
- const stmt = db.prepare(`
757
+ const stmt = prep(`
501
758
  DELETE FROM messages
502
759
  WHERE rowid IN (
503
760
  SELECT rowid FROM messages WHERE ts < ? LIMIT ?
@@ -572,7 +829,7 @@ export function recordInbound(args: RecordInboundArgs): void {
572
829
  )
573
830
  return
574
831
  }
575
- const stmt = requireDb().prepare(`
832
+ const stmt = prep(`
576
833
  INSERT OR REPLACE INTO messages
577
834
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
578
835
  VALUES (?, ?, ?, 'user', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)
@@ -650,7 +907,7 @@ export function recordOutbound(args: RecordOutboundArgs): void {
650
907
  }
651
908
  if (validRows.length === 0) return
652
909
  const groupId = validRows[0]!.id
653
- const stmt = requireDb().prepare(`
910
+ const stmt = prep(`
654
911
  INSERT OR REPLACE INTO messages
655
912
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
656
913
  VALUES (?, ?, ?, 'assistant', NULL, NULL, ?, ?, ?, ?)
@@ -668,7 +925,7 @@ export function recordOutbound(args: RecordOutboundArgs): void {
668
925
  // (chat_id, message_id) — unique within a chat regardless of thread, the
669
926
  // same assumption `recordEdit` / `recordReaction` already make — makes the
670
927
  // promotion exact and is a no-op when no observer row exists.
671
- const dropSystem = requireDb().prepare(
928
+ const dropSystem = prep(
672
929
  `DELETE FROM messages WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
673
930
  )
674
931
  // bun:sqlite has a transaction() helper. Cheap insurance against partial
@@ -731,9 +988,8 @@ export function recordSystemOutbound(args: RecordSystemOutboundArgs): boolean {
731
988
  // blanket send observer, so a warn here would be one stderr line per API call.
732
989
  if (db == null) return false
733
990
  try {
734
- const res = requireDb()
735
- .prepare(`
736
- INSERT INTO messages
991
+ const res = prep(`
992
+ INSERT INTO messages
737
993
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, kind)
738
994
  SELECT ?, ?, ?, 'system', NULL, NULL, ?, ?, NULL, NULL, ?
739
995
  WHERE NOT EXISTS (
@@ -777,11 +1033,9 @@ export function updateSystemOutboundText(args: {
777
1033
  text: string
778
1034
  }): boolean {
779
1035
  try {
780
- const res = requireDb()
781
- .prepare(
782
- `UPDATE messages SET text = ? WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
783
- )
784
- .run(redact(args.text), args.chat_id, args.message_id) as { changes?: number }
1036
+ const res = prep(
1037
+ `UPDATE messages SET text = ? WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
1038
+ ).run(redact(args.text), args.chat_id, args.message_id) as { changes?: number }
785
1039
  return (res?.changes ?? 0) > 0
786
1040
  } catch {
787
1041
  return false
@@ -805,12 +1059,11 @@ interface RecordEditArgs {
805
1059
  * original send-time thread isn't known at edit time.
806
1060
  */
807
1061
  export function recordEdit(args: RecordEditArgs): void {
808
- requireDb()
809
- .prepare(`
810
- UPDATE messages
811
- SET text = ?
812
- WHERE chat_id = ? AND message_id = ?
813
- `)
1062
+ prep(`
1063
+ UPDATE messages
1064
+ SET text = ?
1065
+ WHERE chat_id = ? AND message_id = ?
1066
+ `)
814
1067
  // Same outbound chokepoint as recordOutbound — an edit must not
815
1068
  // reintroduce a raw secret into the stored row.
816
1069
  .run(redact(args.text), args.chat_id, args.message_id)
@@ -833,12 +1086,11 @@ export interface RecordReactionArgs {
833
1086
  * we match on (chat_id, message_id) and ignore thread_id — same as recordEdit.
834
1087
  */
835
1088
  export function recordReaction(args: RecordReactionArgs): void {
836
- requireDb()
837
- .prepare(`
838
- UPDATE messages
839
- SET user_reaction = ?
840
- WHERE chat_id = ? AND message_id = ?
841
- `)
1089
+ prep(`
1090
+ UPDATE messages
1091
+ SET user_reaction = ?
1092
+ WHERE chat_id = ? AND message_id = ?
1093
+ `)
842
1094
  .run(args.emoji, args.chat_id, args.message_id)
843
1095
  }
844
1096
 
@@ -857,11 +1109,10 @@ export interface DeleteFromHistoryArgs {
857
1109
  * we match on (chat_id, message_id) and ignore thread.
858
1110
  */
859
1111
  export function deleteFromHistory(args: DeleteFromHistoryArgs): void {
860
- requireDb()
861
- .prepare(`
862
- DELETE FROM messages
863
- WHERE chat_id = ? AND message_id = ?
864
- `)
1112
+ prep(`
1113
+ DELETE FROM messages
1114
+ WHERE chat_id = ? AND message_id = ?
1115
+ `)
865
1116
  .run(args.chat_id, args.message_id)
866
1117
  }
867
1118
 
@@ -913,7 +1164,7 @@ export function getLatestInboundMessageId(
913
1164
  }
914
1165
  }
915
1166
  sql += ' ORDER BY ts DESC, message_id DESC LIMIT 1'
916
- const row = requireDb().prepare(sql).get(...params as any[]) as
1167
+ const row = prep(sql).get(...params as any[]) as
917
1168
  | { message_id: number }
918
1169
  | undefined
919
1170
  return row?.message_id ?? null
@@ -947,11 +1198,17 @@ export function lookupMessageRoleAndText(
947
1198
  includeSystem?: boolean
948
1199
  },
949
1200
  ): { role: MessageRole; text: string; kind: string | null } | null {
1201
+ // History dead (a reopen that closed the old handle and failed to re-init) or
1202
+ // never initialised → "no such row", which is the answer this caller already
1203
+ // handles for a reaped message. A `requireDb()` throw would instead propagate
1204
+ // into the reaction-trigger handler; the same degrade
1205
+ // `hasOutboundDeliveredSince` / `hasOutboundWithText` already apply.
1206
+ if (db == null) return null
950
1207
  const sql =
951
1208
  `SELECT role, text, kind FROM messages WHERE chat_id = ? AND message_id = ?` +
952
1209
  (opts?.includeSystem === true ? '' : ` AND role <> 'system'`) +
953
1210
  ` LIMIT 1`
954
- const row = requireDb().prepare(sql).get(chatId, messageId) as
1211
+ const row = prep(sql).get(chatId, messageId) as
955
1212
  | { role: MessageRole; text: string | null; kind: string | null }
956
1213
  | undefined
957
1214
  if (!row) return null
@@ -963,11 +1220,9 @@ export function getRecentOutboundCount(
963
1220
  withinSeconds: number,
964
1221
  ): number {
965
1222
  const cutoff = Math.floor(Date.now() / 1000) - withinSeconds
966
- const row = requireDb()
967
- .prepare(
968
- 'SELECT COUNT(*) as cnt FROM messages WHERE chat_id = ? AND role = ? AND ts >= ?',
969
- )
970
- .get(chatId, 'assistant', cutoff) as { cnt: number } | undefined
1223
+ const row = prep(
1224
+ 'SELECT COUNT(*) as cnt FROM messages WHERE chat_id = ? AND role = ? AND ts >= ?',
1225
+ ).get(chatId, 'assistant', cutoff) as { cnt: number } | undefined
971
1226
  return row?.cnt ?? 0
972
1227
  }
973
1228
 
@@ -1041,9 +1296,9 @@ export function hasOutboundDeliveredSince(
1041
1296
  }
1042
1297
  }
1043
1298
  sql += ' LIMIT 1'
1044
- const row = requireDb()
1045
- .prepare(sql)
1046
- .get(...(params as [unknown, ...unknown[]])) as Record<string, unknown> | undefined
1299
+ const row = prep(sql).get(...(params as [unknown, ...unknown[]])) as
1300
+ | Record<string, unknown>
1301
+ | undefined
1047
1302
  return row != null
1048
1303
  } catch {
1049
1304
  return false
@@ -1121,9 +1376,9 @@ export function hasOutboundWithText(
1121
1376
  }
1122
1377
  // Newest first: a redelivery candidate's match is almost always recent.
1123
1378
  sql += ' ORDER BY ts DESC LIMIT 500'
1124
- const rows = requireDb()
1125
- .prepare(sql)
1126
- .all(...(params as [unknown, ...unknown[]])) as { text: string | null }[]
1379
+ const rows = prep(sql).all(...(params as [unknown, ...unknown[]])) as {
1380
+ text: string | null
1381
+ }[]
1127
1382
  for (const r of rows) {
1128
1383
  const hay = normalizeDeliveryText(r.text ?? '')
1129
1384
  if (hay.length === 0) continue
@@ -1169,6 +1424,17 @@ export function deliveryTextMatch(hay: string, needle: string): boolean {
1169
1424
  }
1170
1425
 
1171
1426
  export function query(opts: QueryOptions): RecordedMessage[] {
1427
+ // History dead or never initialised → no rows, the same result the caller
1428
+ // gets from a genuinely empty chat. `get_recent_messages` degrades to "no
1429
+ // history" instead of erroring the whole tool call; the operator-visible
1430
+ // signal for the dead case is the sweep's sticky alarm, not this throw.
1431
+ if (db == null) {
1432
+ warnHistory(
1433
+ 'query: history DB is not open — returning no rows. If a reopen failed, the ' +
1434
+ 'orphaned-db-sweep is alarming about it every tick; RESTART the gateway.',
1435
+ )
1436
+ return []
1437
+ }
1172
1438
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
1173
1439
  const params: unknown[] = [opts.chat_id]
1174
1440
  let sql = 'SELECT * FROM messages WHERE chat_id = ?'
@@ -1188,7 +1454,7 @@ export function query(opts: QueryOptions): RecordedMessage[] {
1188
1454
  }
1189
1455
  sql += ' ORDER BY ts DESC, message_id DESC LIMIT ?'
1190
1456
  params.push(limit)
1191
- const rows = requireDb().prepare(sql).all(...params as any[]) as RecordedMessage[]
1457
+ const rows = prep(sql).all(...params as any[]) as RecordedMessage[]
1192
1458
  // SELECT was DESC; flip to oldest-first for the caller.
1193
1459
  rows.reverse()
1194
1460
  return rows
@@ -72,8 +72,12 @@ function resolveSyncSqlite() {
72
72
  if (typeof globalThis.Bun !== 'undefined') {
73
73
  try {
74
74
  const { Database } = require('bun:sqlite')
75
- return function BunDatabaseSyncAdapter(p) {
76
- const d = new Database(p)
75
+ return function BunDatabaseSyncAdapter(p, opts) {
76
+ // Translate node:sqlite's `readOnly` to bun:sqlite's `readonly` so a
77
+ // read-only call site is read-only on BOTH bindings. See the
78
+ // `readBackgroundFlagSync` comment for why that matters.
79
+ // allow-rw-db-open: shared adapter — the writer call sites open RW through it
80
+ const d = new Database(p, opts?.readOnly ? { readonly: true } : undefined)
77
81
  return {
78
82
  exec: (sql) => d.exec(sql),
79
83
  prepare: (sql) => d.prepare(sql),
@@ -90,6 +94,8 @@ function resolveSyncSqlite() {
90
94
  * Calls cb(error | null) when the process exits.
91
95
  */
92
96
  function spawnSql(dbPath, sql, cb) {
97
+ // allow-rw-db-open: this is the tracker's WRITE path (INSERT/UPDATE) — it
98
+ // must be read-write. The SELECT path is `spawnSqlRead`, which is -readonly.
93
99
  const child = spawn('sqlite3', [dbPath, sql], { stdio: ['ignore', 'ignore', 'pipe'] })
94
100
  let stderr = ''
95
101
  child.stderr.on('data', (d) => { stderr += d })
@@ -108,7 +114,11 @@ function spawnSql(dbPath, sql, cb) {
108
114
  * Calls cb(error | null, stdout | null).
109
115
  */
110
116
  function spawnSqlRead(dbPath, sql, cb) {
111
- const child = spawn('sqlite3', [dbPath, sql], { stdio: ['ignore', 'pipe', 'pipe'] })
117
+ // `-readonly`: this hook is a FOREIGN process against a DB the gateway holds
118
+ // open. The sqlite3 CLI defaults to read-write, and a read-write handle that
119
+ // closes as the LAST connection checkpoints and UNLINKS the `-wal`/`-shm`
120
+ // sidecars, orphaning the gateway's mapped fds (#4595). This path only SELECTs.
121
+ const child = spawn('sqlite3', ['-readonly', dbPath, sql], { stdio: ['ignore', 'pipe', 'pipe'] })
112
122
  let stdout = ''
113
123
  let stderr = ''
114
124
  child.stdout.on('data', (d) => { stdout += d })
@@ -629,7 +639,12 @@ function readBackgroundFlagSync(dbPath, id) {
629
639
  const DatabaseSync = resolveSyncSqlite()
630
640
  if (DatabaseSync == null) return null
631
641
  try {
632
- const db = new DatabaseSync(dbPath)
642
+ // READ-ONLY: this hook is a FOREIGN process against a DB the gateway holds
643
+ // open. A read-write handle that closes as the LAST connection checkpoints
644
+ // and UNLINKS the `-wal`/`-shm` sidecars, orphaning the gateway's mapped
645
+ // fds — after which its writes silently land in deleted inodes (#4595).
646
+ // This function only ever SELECTs.
647
+ const db = new DatabaseSync(dbPath, { readOnly: true })
633
648
  const row = db.prepare('SELECT background FROM subagents WHERE id = ?').get(id)
634
649
  db.close()
635
650
  if (row == null) return null
@@ -123,8 +123,12 @@ function resolveSyncSqlite() {
123
123
  // Adapt bun:sqlite to the node:sqlite DatabaseSync surface used
124
124
  // below. bun's Database.prepare/run/get/all and exec are
125
125
  // sufficient — we only need the call-site shape.
126
- return function BunDatabaseSyncAdapter(p) {
127
- const d = new Database(p)
126
+ return function BunDatabaseSyncAdapter(p, opts) {
127
+ // Translate node:sqlite's `readOnly` to bun:sqlite's `readonly` so a
128
+ // read-only call site is read-only on BOTH bindings. See the
129
+ // `readBackgroundFlagSync` comment for why that matters.
130
+ // allow-rw-db-open: shared adapter — the writer call sites open RW through it
131
+ const d = new Database(p, opts?.readOnly ? { readonly: true } : undefined)
128
132
  return {
129
133
  exec: (sql) => d.exec(sql),
130
134
  prepare: (sql) => d.prepare(sql),
@@ -141,6 +145,8 @@ function resolveSyncSqlite() {
141
145
  * Calls cb(error | null) when the process exits.
142
146
  */
143
147
  function spawnSql(dbPath, sql, cb) {
148
+ // allow-rw-db-open: this is the tracker's WRITE path (INSERT/UPDATE) — it
149
+ // must be read-write. The SELECT path is `spawnSqlRead`, which is -readonly.
144
150
  const child = spawn('sqlite3', [dbPath, sql], { stdio: ['ignore', 'ignore', 'pipe'] })
145
151
  let stderr = ''
146
152
  child.stderr.on('data', (d) => { stderr += d })