dsh-context-mode 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSING.md +37 -0
  2. package/README.md +40 -14
  3. package/lib/types/cjk.d.ts +54 -0
  4. package/lib/types/cjk.d.ts.map +1 -0
  5. package/lib/types/cjk.js +64 -0
  6. package/lib/types/index.d.ts.map +1 -1
  7. package/lib/types/index.js +71 -22
  8. package/lib/types/output-containment.d.ts +35 -0
  9. package/lib/types/output-containment.d.ts.map +1 -0
  10. package/lib/types/output-containment.js +103 -0
  11. package/lib/types/routing.d.ts +3 -1
  12. package/lib/types/routing.d.ts.map +1 -1
  13. package/lib/types/routing.js +81 -6
  14. package/lib/types/session-memory.d.ts.map +1 -1
  15. package/lib/types/session-memory.js +14 -3
  16. package/package.json +9 -5
  17. package/skills/context-mode/SKILL.md +104 -11
  18. package/vendor/context-mode/LICENSE +94 -0
  19. package/vendor/context-mode/server.bundle.mjs +1126 -0
  20. package/vendor/context-mode/src/cli.ts +2040 -0
  21. package/vendor/context-mode/src/db-base.ts +617 -0
  22. package/vendor/context-mode/src/executor.ts +785 -0
  23. package/vendor/context-mode/src/exit-classify.ts +33 -0
  24. package/vendor/context-mode/src/fetch-cache.ts +15 -0
  25. package/vendor/context-mode/src/lifecycle.ts +305 -0
  26. package/vendor/context-mode/src/platform/client-map.ts +45 -0
  27. package/vendor/context-mode/src/platform/detect.ts +645 -0
  28. package/vendor/context-mode/src/platform/dsh.ts +206 -0
  29. package/vendor/context-mode/src/platform/types.ts +503 -0
  30. package/vendor/context-mode/src/runPool.ts +81 -0
  31. package/vendor/context-mode/src/runtime.ts +765 -0
  32. package/vendor/context-mode/src/search/auto-memory.ts +200 -0
  33. package/vendor/context-mode/src/search/ctx-search-schema.ts +143 -0
  34. package/vendor/context-mode/src/search/flood-guard.ts +111 -0
  35. package/vendor/context-mode/src/search/unified.ts +176 -0
  36. package/vendor/context-mode/src/security.ts +889 -0
  37. package/vendor/context-mode/src/server.ts +4991 -0
  38. package/vendor/context-mode/src/session/analytics.ts +3085 -0
  39. package/vendor/context-mode/src/session/db.ts +1726 -0
  40. package/vendor/context-mode/src/session/error-classifier.ts +392 -0
  41. package/vendor/context-mode/src/session/event-emit.ts +132 -0
  42. package/vendor/context-mode/src/session/extract.ts +2958 -0
  43. package/vendor/context-mode/src/session/index.ts +130 -0
  44. package/vendor/context-mode/src/session/model-prices.json +429 -0
  45. package/vendor/context-mode/src/session/persist-tool-calls.ts +128 -0
  46. package/vendor/context-mode/src/session/pricing.ts +191 -0
  47. package/vendor/context-mode/src/session/project-attribution.ts +309 -0
  48. package/vendor/context-mode/src/session/purge.ts +338 -0
  49. package/vendor/context-mode/src/session/retrieval-marker.ts +65 -0
  50. package/vendor/context-mode/src/session/snapshot.ts +577 -0
  51. package/vendor/context-mode/src/store-directory.ts +290 -0
  52. package/vendor/context-mode/src/store.ts +2071 -0
  53. package/vendor/context-mode/src/truncate.ts +154 -0
  54. package/vendor/context-mode/src/types.ts +147 -0
  55. package/vendor/context-mode/src/util/claude-config.ts +95 -0
  56. package/vendor/context-mode/src/util/hook-config.ts +78 -0
  57. package/vendor/context-mode/src/util/jsonc.ts +70 -0
  58. package/vendor/context-mode/src/util/plugin-cache-integrity.ts +167 -0
  59. package/vendor/context-mode/src/util/project-dir.ts +347 -0
  60. package/vendor/context-mode/src/util/sibling-mcp.ts +228 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * db-base — Reusable SQLite infrastructure for context-mode packages.
3
+ *
4
+ * Provides lazy-loading of better-sqlite3, WAL pragma setup, prepared
5
+ * statement caching interface, and DB file cleanup helpers. Both
6
+ * ContentStore and SessionDB build on top of these primitives.
7
+ */
8
+
9
+ import type DatabaseConstructor from "better-sqlite3";
10
+ import type { Database as DatabaseInstance } from "better-sqlite3";
11
+ import { createRequire } from "node:module";
12
+ import { existsSync, unlinkSync, renameSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ // v1.0.130 — `acquireDbLock` + `locking_mode = EXCLUSIVE` were REMOVED.
16
+ // See docs/adr/0001-sessiondb-multi-writer.md for the architectural
17
+ // rationale. The short version: SessionDB is multi-writer-safe and the
18
+ // process-identity invariants the lockfile tried to enforce belong in
19
+ // the process layer (sibling-mcp), not the DB layer. WAL + busy_timeout
20
+ // + withRetry handle the actual concurrency safely.
21
+
22
+ // ─────────────────────────────────────────────────────────
23
+ // Types
24
+ // ─────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Explicit interface for cached prepared statements that accept varying
28
+ * parameter counts. better-sqlite3's generic `Statement` collapses under
29
+ * `ReturnType` to a single-param signature, so we define our own.
30
+ */
31
+ export interface PreparedStatement {
32
+ run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
33
+ get(...params: unknown[]): unknown;
34
+ all(...params: unknown[]): unknown[];
35
+ iterate(...params: unknown[]): IterableIterator<unknown>;
36
+ }
37
+
38
+ // ─────────────────────────────────────────────────────────
39
+ // bun:sqlite adapter (#45)
40
+ // ─────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Wraps a bun:sqlite Database to provide better-sqlite3-compatible API.
44
+ * Bridges: .pragma(), multi-statement .exec(), .get() null→undefined.
45
+ */
46
+ export class BunSQLiteAdapter {
47
+ #raw: any;
48
+
49
+ constructor(rawDb: any) {
50
+ this.#raw = rawDb;
51
+ }
52
+
53
+ pragma(source: string): any {
54
+ const stmt = this.#raw.prepare(`PRAGMA ${source}`);
55
+ const rows = stmt.all();
56
+ if (!rows || rows.length === 0) return undefined;
57
+ // Multi-row pragmas (table_xinfo, etc.) → return array
58
+ if (rows.length > 1) return rows;
59
+ // Single-row: extract scalar value (e.g. journal_mode = "wal")
60
+ const values = Object.values(rows[0] as Record<string, unknown>);
61
+ return values.length === 1 ? values[0] : rows[0];
62
+ }
63
+
64
+ exec(sql: string): any {
65
+ // bun:sqlite .exec() is single-statement only.
66
+ // Split multi-statement SQL respecting string literals (don't split on ; inside quotes).
67
+ let current = "";
68
+ let inString: string | null = null;
69
+ for (let i = 0; i < sql.length; i++) {
70
+ const ch = sql[i];
71
+ if (inString) {
72
+ current += ch;
73
+ if (ch === inString) inString = null;
74
+ } else if (ch === "'" || ch === '"') {
75
+ current += ch;
76
+ inString = ch;
77
+ } else if (ch === ";") {
78
+ const trimmed = current.trim();
79
+ if (trimmed) this.#raw.prepare(trimmed).run();
80
+ current = "";
81
+ } else {
82
+ current += ch;
83
+ }
84
+ }
85
+ const trimmed = current.trim();
86
+ if (trimmed) this.#raw.prepare(trimmed).run();
87
+ return this;
88
+ }
89
+
90
+ prepare(sql: string): any {
91
+ const stmt = this.#raw.prepare(sql);
92
+ return {
93
+ run: (...args: unknown[]) => stmt.run(...args),
94
+ get: (...args: unknown[]) => {
95
+ const r = stmt.get(...args);
96
+ return r === null ? undefined : r;
97
+ },
98
+ all: (...args: unknown[]) => stmt.all(...args),
99
+ iterate: (...args: unknown[]) => stmt.iterate(...args),
100
+ };
101
+ }
102
+
103
+ transaction(fn: (...args: any[]) => any): any {
104
+ return this.#raw.transaction(fn);
105
+ }
106
+
107
+ close(): void {
108
+ this.#raw.close();
109
+ }
110
+ }
111
+
112
+ // ─────────────────────────────────────────────────────────
113
+ // node:sqlite adapter (#228)
114
+ // ─────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Wraps node:sqlite's DatabaseSync to provide better-sqlite3-compatible API.
118
+ * Bridges: .pragma(), .transaction(). Everything else is passthrough.
119
+ * Eliminates native addon SIGSEGV on Linux (nodejs/node#62515).
120
+ */
121
+ export class NodeSQLiteAdapter {
122
+ #raw: any; // DatabaseSync instance
123
+
124
+ constructor(rawDb: any) {
125
+ this.#raw = rawDb;
126
+ }
127
+
128
+ pragma(source: string): any {
129
+ // "journal_mode = WAL" → PRAGMA journal_mode = WAL
130
+ // "table_xinfo(session_events)" → PRAGMA table_xinfo(session_events)
131
+ // "wal_checkpoint(TRUNCATE)" → PRAGMA wal_checkpoint(TRUNCATE)
132
+ const stmt = this.#raw.prepare(`PRAGMA ${source}`);
133
+ const rows = stmt.all();
134
+ if (!rows || rows.length === 0) return undefined;
135
+ if (rows.length > 1) return rows;
136
+ const values = Object.values(rows[0] as Record<string, unknown>);
137
+ return values.length === 1 ? values[0] : rows[0];
138
+ }
139
+
140
+ exec(sql: string): any {
141
+ // node:sqlite's exec() supports multi-statement natively
142
+ this.#raw.exec(sql);
143
+ return this;
144
+ }
145
+
146
+ prepare(sql: string): any {
147
+ const stmt = this.#raw.prepare(sql);
148
+ return {
149
+ run: (...args: unknown[]) => stmt.run(...args),
150
+ get: (...args: unknown[]) => stmt.get(...args),
151
+ all: (...args: unknown[]) => stmt.all(...args),
152
+ iterate: (...args: unknown[]) => {
153
+ // node:sqlite uses Symbol.iterator on StatementSync, not .iterate()
154
+ // Check if iterate exists, otherwise use Symbol.iterator
155
+ if (typeof stmt.iterate === 'function') {
156
+ return stmt.iterate(...args);
157
+ }
158
+ // Fallback: use all() to create an iterator
159
+ const rows = stmt.all(...args);
160
+ return rows[Symbol.iterator]();
161
+ },
162
+ };
163
+ }
164
+
165
+ transaction(fn: (...args: any[]) => any): any {
166
+ // node:sqlite has no transaction() method — manual BEGIN/COMMIT/ROLLBACK
167
+ return (...args: any[]) => {
168
+ this.#raw.exec("BEGIN");
169
+ try {
170
+ const result = fn(...args);
171
+ this.#raw.exec("COMMIT");
172
+ return result;
173
+ } catch (err) {
174
+ this.#raw.exec("ROLLBACK");
175
+ throw err;
176
+ }
177
+ };
178
+ }
179
+
180
+ close(): void {
181
+ this.#raw.close();
182
+ }
183
+ }
184
+
185
+ // ─────────────────────────────────────────────────────────
186
+ // Lazy loader
187
+ // ─────────────────────────────────────────────────────────
188
+
189
+ let _Database: typeof DatabaseConstructor | null = null;
190
+
191
+ /**
192
+ * Probe whether the supplied node:sqlite DatabaseSync constructor links a
193
+ * SQLite build that includes the FTS5 module. Some Node.js Linux builds
194
+ * (e.g. v22.14.0 on Ubuntu) ship node:sqlite without FTS5 even though the
195
+ * import succeeds, which silently breaks ctx_search/ctx_batch_execute and
196
+ * the doctor's FTS5 check (issue #461).
197
+ *
198
+ * Returns true only when a `CREATE VIRTUAL TABLE … USING fts5(x)` statement
199
+ * succeeds. Always returns false on any failure (constructor throw, missing
200
+ * module, etc.) so the caller can fall through to better-sqlite3, whose
201
+ * bundled SQLite always ships with FTS5.
202
+ */
203
+ export function nodeSqliteHasFts5(DatabaseSync: any): boolean {
204
+ let probe: any = null;
205
+ try {
206
+ probe = new DatabaseSync(":memory:");
207
+ probe.exec("CREATE VIRTUAL TABLE __fts5_probe USING fts5(x)");
208
+ return true;
209
+ } catch {
210
+ return false;
211
+ } finally {
212
+ try { probe?.close(); } catch { /* probe never opened or already closed */ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Returns true when the current runtime ships a built-in SQLite binding:
218
+ * - Bun has `bun:sqlite` always
219
+ * - Node has `node:sqlite` since 22.5 (no flag since 22.13)
220
+ *
221
+ * Mirrors the helper in hooks/ensure-deps.mjs:61. Exported so the platform
222
+ * gate in loadDatabase() can be unit-tested without spawning a child
223
+ * process. `versionsOverride` and `bunOverride` are injection points for
224
+ * tests — production callers pass nothing.
225
+ *
226
+ * Widening the gate from `process.platform === "linux"` to this helper is
227
+ * required for Node 26 on macOS arm64 (#551): Node 26 removed
228
+ * `info.This()` from V8 PropertyCallbackInfo, breaking better-sqlite3
229
+ * 12.9.0's native compile. Using node:sqlite sidesteps the native addon
230
+ * entirely on every platform that has it.
231
+ */
232
+ export function hasModernSqlite(
233
+ versionsOverride?: NodeJS.ProcessVersions,
234
+ bunOverride?: unknown,
235
+ ): boolean {
236
+ const bun = bunOverride !== undefined ? bunOverride : (globalThis as any).Bun;
237
+ if (typeof bun !== "undefined" && bun !== null) return true;
238
+ const versions = versionsOverride ?? process.versions;
239
+ const [majorStr, minorStr] = (versions.node ?? "0.0.0").split(".");
240
+ const major = Number(majorStr);
241
+ const minor = Number(minorStr);
242
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return false;
243
+ return major > 22 || (major === 22 && minor >= 5);
244
+ }
245
+
246
+ /**
247
+ * Lazy-load the SQLite driver for the current runtime.
248
+ * Bun → bun:sqlite via BunSQLiteAdapter (issue #45).
249
+ * Modern Node (>= 22.5) → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461, #551).
250
+ * Other Node (or modern Node without FTS5) → better-sqlite3 (native addon).
251
+ */
252
+ export function loadDatabase(): typeof DatabaseConstructor {
253
+ if (!_Database) {
254
+ const require = createRequire(import.meta.url);
255
+
256
+ if ((globalThis as any).Bun) {
257
+ // Bun runtime — use bun:sqlite directly.
258
+ // Array.join() prevents esbuild from resolving the specifier at bundle time.
259
+ const BunDB = require(["bun", "sqlite"].join(":")).Database;
260
+ _Database = function BunDatabaseFactory(path: string, opts?: any) {
261
+ const raw = new BunDB(path, {
262
+ readonly: opts?.readonly,
263
+ create: true,
264
+ });
265
+ const adapter = new BunSQLiteAdapter(raw);
266
+ // Propagate busy_timeout — better-sqlite3 does this via constructor
267
+ // option but bun:sqlite does not, so we set it via pragma (#243)
268
+ if (opts?.timeout) {
269
+ adapter.pragma(`busy_timeout = ${opts.timeout}`);
270
+ }
271
+ return adapter;
272
+ } as any;
273
+ } else if (hasModernSqlite()) {
274
+ // Any Node >= 22.5 — try node:sqlite to avoid the native addon path
275
+ // entirely. Historically this was Linux-only (avoiding the Linux
276
+ // SIGSEGV per nodejs/node#62515, #228), but Node 26 also broke
277
+ // better-sqlite3's native compile on macOS arm64 by removing
278
+ // V8 `info.This()` (#551). The built-in `node:sqlite` ships its
279
+ // own SQLite, so it sidesteps both issues at once.
280
+ //
281
+ // Probe FTS5 support before committing — some Node builds ship
282
+ // node:sqlite without FTS5, which would silently break ctx_search
283
+ // (#461). The probe runs at most once per process (cached via
284
+ // _Database below), so the cost of an in-memory DatabaseSync is
285
+ // negligible.
286
+ let DatabaseSync: any = null;
287
+ try {
288
+ // Array.join() prevents esbuild from resolving the specifier at bundle time
289
+ // (mirrors the bun:sqlite branch above).
290
+ ({ DatabaseSync } = require(["node", "sqlite"].join(":")));
291
+ } catch {
292
+ DatabaseSync = null;
293
+ }
294
+ if (DatabaseSync && nodeSqliteHasFts5(DatabaseSync)) {
295
+ _Database = function NodeDatabaseFactory(path: string, opts?: any) {
296
+ const raw = new DatabaseSync(path, {
297
+ readOnly: opts?.readonly ?? false,
298
+ });
299
+ const adapter = new NodeSQLiteAdapter(raw);
300
+ // Propagate busy_timeout — node:sqlite's DatabaseSync constructor
301
+ // silently ignores `{ timeout }` (unlike better-sqlite3's native
302
+ // C++ constructor), so we set it via PRAGMA, mirroring the Bun
303
+ // branch above. Without this, the default is 0 and the first
304
+ // write contention surfaces as immediate `SQLITE_BUSY`/`database
305
+ // is locked` — defeating the 30s grace `withRetry()` is built
306
+ // around. See issue #642 and ADR-0001 (multi-writer contract).
307
+ if (opts?.timeout) {
308
+ adapter.pragma(`busy_timeout = ${opts.timeout}`);
309
+ }
310
+ return adapter;
311
+ } as any;
312
+ } else {
313
+ // node:sqlite missing or built without FTS5 — fall through to
314
+ // better-sqlite3. Trade-off: on Node 26 + macOS this may now hit
315
+ // the V8 ABI break (#551). A visible crash on the rare
316
+ // unstable build is preferable to silent "no such module: fts5"
317
+ // on every ctx_search call.
318
+ _Database = require("better-sqlite3") as typeof DatabaseConstructor;
319
+ }
320
+ } else {
321
+ // Old Node (< 22.5) without bun:sqlite — fall back to better-sqlite3.
322
+ _Database = require("better-sqlite3") as typeof DatabaseConstructor;
323
+ }
324
+ }
325
+ return _Database!;
326
+ }
327
+
328
+ // ─────────────────────────────────────────────────────────
329
+ // WAL setup
330
+ // ─────────────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Apply WAL mode and NORMAL synchronous pragma to a database instance.
334
+ * Should be called immediately after opening a new database connection.
335
+ *
336
+ * WAL mode provides:
337
+ * - Concurrent readers while a write is in progress
338
+ * - Dramatically faster writes (no full-page sync on each commit)
339
+ * NORMAL synchronous is safe under WAL and avoids an extra fsync per
340
+ * transaction.
341
+ */
342
+ export function applyWALPragmas(db: DatabaseInstance): void {
343
+ db.pragma("journal_mode = WAL");
344
+ db.pragma("synchronous = NORMAL");
345
+ // Memory-map the DB file for read-heavy FTS5 search workloads.
346
+ // Eliminates read() syscalls — the kernel serves pages directly from
347
+ // the page cache. 256MB is a safe upper bound (SQLite only maps up to
348
+ // the actual file size). Falls back gracefully on platforms where mmap
349
+ // is unavailable or restricted.
350
+ try { db.pragma("mmap_size = 268435456"); } catch { /* unsupported runtime */ }
351
+ // NOTE: `locking_mode = EXCLUSIVE` is intentionally NOT applied here.
352
+ // ALL DBs built on this helper — ContentStore (FTS5 shared knowledge
353
+ // base) AND SessionDB (per-project events) — are multi-writer-safe by
354
+ // contract. WAL + busy_timeout + the withRetry() wrapper below handle
355
+ // SQLITE_BUSY natively. EXCLUSIVE locking is opt-out, never opt-in
356
+ // from a base class shared by multi-writer consumers.
357
+ // See docs/adr/0001-sessiondb-multi-writer.md for the v1.0.130 ADR.
358
+ }
359
+
360
+ // ─────────────────────────────────────────────────────────
361
+ // DB file helpers
362
+ // ─────────────────────────────────────────────────────────
363
+
364
+ /**
365
+ * Remove orphaned WAL/SHM files when the main DB file doesn't exist.
366
+ * On Windows, stale -wal/-shm files from crashed processes cause
367
+ * "file is not a database" errors when creating a fresh DB.
368
+ */
369
+ export function cleanOrphanedWALFiles(dbPath: string): void {
370
+ if (!existsSync(dbPath)) {
371
+ for (const suffix of ["-wal", "-shm"]) {
372
+ try { unlinkSync(dbPath + suffix); } catch { /* ignore */ }
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Delete all three SQLite files for a given db path (main, WAL, SHM).
379
+ * Silently ignores individual deletion errors so a partial cleanup
380
+ * does not abort the rest.
381
+ */
382
+ export function deleteDBFiles(dbPath: string): void {
383
+ for (const suffix of ["", "-wal", "-shm"]) {
384
+ try {
385
+ unlinkSync(dbPath + suffix);
386
+ } catch {
387
+ // ignore — file may not exist
388
+ }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Safely close a database connection. Swallows errors so callers can
394
+ * always call this in a finally/cleanup path without try/catch.
395
+ */
396
+ export function closeDB(db: DatabaseInstance): void {
397
+ try {
398
+ // Checkpoint WAL before close to prevent contention on restart (#103)
399
+ db.pragma("wal_checkpoint(TRUNCATE)");
400
+ } catch { /* WAL may not be active */ }
401
+ try {
402
+ db.close();
403
+ } catch {
404
+ // ignore
405
+ }
406
+ }
407
+
408
+ // ─────────────────────────────────────────────────────────
409
+ // Default path helper
410
+ // ─────────────────────────────────────────────────────────
411
+
412
+ /**
413
+ * Return the default per-process DB path for context-mode databases.
414
+ * Uses the OS temp directory and embeds the current PID so multiple
415
+ * server instances never share a file.
416
+ */
417
+ export function defaultDBPath(prefix: string = "context-mode"): string {
418
+ return join(tmpdir(), `${prefix}-${process.pid}.db`);
419
+ }
420
+
421
+ // ─────────────────────────────────────────────────────────
422
+ // Retry helper
423
+ // ─────────────────────────────────────────────────────────
424
+
425
+ /**
426
+ * Retry a DB operation with exponential backoff on SQLITE_BUSY errors.
427
+ * Catches errors containing "SQLITE_BUSY" or "database is locked" and
428
+ * retries up to 3 times with delays: 100ms, 500ms, 2000ms.
429
+ * If all retries fail, throws a descriptive error.
430
+ * Pass custom delays for testing (e.g., [0, 0, 0] to skip waits).
431
+ */
432
+ export function withRetry<T>(fn: () => T, delays: number[] = [100, 500, 2000]): T {
433
+ let lastError: Error | undefined;
434
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
435
+ try {
436
+ return fn();
437
+ } catch (err: unknown) {
438
+ const msg = err instanceof Error ? err.message : String(err);
439
+ if (!msg.includes("SQLITE_BUSY") && !msg.includes("database is locked")) {
440
+ throw err;
441
+ }
442
+ lastError = err instanceof Error ? err : new Error(msg);
443
+ if (attempt < delays.length) {
444
+ const delay = delays[attempt];
445
+ const start = Date.now();
446
+ while (Date.now() - start < delay) { /* busy-wait for sync retry */ }
447
+ }
448
+ }
449
+ }
450
+ throw new Error(
451
+ `SQLITE_BUSY: database is locked after ${delays.length} retries. ` +
452
+ `Original error: ${lastError?.message}`
453
+ );
454
+ }
455
+
456
+ // ─────────────────────────────────────────────────────────
457
+ // Corrupt DB recovery (#244)
458
+ // ─────────────────────────────────────────────────────────
459
+
460
+ /**
461
+ * Detect SQLite corruption errors that warrant a rename-and-recreate.
462
+ * Matches SQLITE_CORRUPT, SQLITE_NOTADB, and their human-readable equivalents.
463
+ */
464
+ export function isSQLiteCorruptionError(msg: string): boolean {
465
+ return (
466
+ msg.includes("SQLITE_CORRUPT") ||
467
+ msg.includes("SQLITE_NOTADB") ||
468
+ msg.includes("database disk image is malformed") ||
469
+ msg.includes("file is not a database")
470
+ );
471
+ }
472
+
473
+ /**
474
+ * Rename a corrupt DB and its WAL/SHM files so a fresh DB can be created.
475
+ * Best-effort — individual rename failures are silently ignored.
476
+ */
477
+ export function renameCorruptDB(dbPath: string): void {
478
+ const ts = Date.now();
479
+ for (const suffix of ["", "-wal", "-shm"]) {
480
+ try {
481
+ renameSync(dbPath + suffix, `${dbPath}${suffix}.corrupt-${ts}`);
482
+ } catch { /* file may not exist */ }
483
+ }
484
+ }
485
+
486
+ // ─────────────────────────────────────────────────────────
487
+ // Base class
488
+ // ─────────────────────────────────────────────────────────
489
+
490
+ /**
491
+ * SQLiteBase — minimal base class that handles open/close/cleanup lifecycle.
492
+ *
493
+ * Subclasses call `super(dbPath)` to open the database with WAL pragmas
494
+ * applied, then implement `initSchema()` and `prepareStatements()`.
495
+ *
496
+ * The `db` getter exposes the raw `DatabaseInstance` to subclasses only.
497
+ */
498
+ /**
499
+ * Track all live DatabaseInstance objects so we can close them on process exit.
500
+ * Prevents better-sqlite3 segfaults caused by V8 garbage-collecting Database
501
+ * objects after the native addon context is already torn down.
502
+ *
503
+ * Uses a global symbol so the set and exit handler survive vitest's module
504
+ * re-imports within the same fork process (ESM isolate mode clears
505
+ * module-level state but globalThis persists).
506
+ */
507
+ // v1.0.130 — symbol name bumped because the value type reverted from
508
+ // Map<DatabaseInstance, string> (v1.0.128 lockfile pairing) back to
509
+ // Set<DatabaseInstance>. A persistent global slot from a v1.0.128 or
510
+ // v1.0.129 module would deserialize as the wrong shape and crash the
511
+ // exit hook iteration.
512
+ const _kLiveDBs = Symbol.for("__context_mode_live_dbs_v3__");
513
+ const _liveDBs: Set<DatabaseInstance> = (() => {
514
+ const g = globalThis as Record<symbol, Set<DatabaseInstance> | undefined>;
515
+ if (!g[_kLiveDBs]) {
516
+ g[_kLiveDBs] = new Set<DatabaseInstance>();
517
+ process.on("exit", () => {
518
+ for (const db of g[_kLiveDBs]!) {
519
+ closeDB(db);
520
+ }
521
+ g[_kLiveDBs]!.clear();
522
+ });
523
+ }
524
+ return g[_kLiveDBs]!;
525
+ })();
526
+
527
+ export abstract class SQLiteBase {
528
+ readonly #dbPath: string;
529
+ readonly #db: DatabaseInstance;
530
+
531
+ /**
532
+ * Open (or create) a SQLite DB at `dbPath`.
533
+ *
534
+ * v1.0.130 — multi-writer is the contract. ALL SQLiteBase consumers
535
+ * (SessionDB, ContentStore) may open the same on-disk dbPath from
536
+ * multiple processes simultaneously — that is the legitimate multi-
537
+ * window UX shape and the WAL handles it natively. SQLITE_BUSY on
538
+ * write contention is absorbed by `withRetry()` below (busy_timeout
539
+ * = 30000ms inside `new Database(...)`).
540
+ *
541
+ * v1.0.128 introduced a single-writer guard here as a defense against
542
+ * #560. That defense was an over-correction — the actual root causes
543
+ * of #560 were #559 (zombie MCP child accumulation) and #561 (Pi
544
+ * misdetection writing to the wrong DB path), both fixed in v1.0.128
545
+ * + v1.0.129. The single-writer guard broke legitimate multi-window
546
+ * users; v1.0.130 rolls it out. See
547
+ * docs/adr/0001-sessiondb-multi-writer.md and the v1.0.130 INVARIANT
548
+ * block in tests/util/db-base-platform-gate.test.ts for the
549
+ * regression-proof anchor (source-pin + behavioural).
550
+ */
551
+ constructor(dbPath: string) {
552
+ const Database = loadDatabase();
553
+ this.#dbPath = dbPath;
554
+ cleanOrphanedWALFiles(dbPath);
555
+ let db: DatabaseInstance;
556
+ try {
557
+ db = new Database(dbPath, { timeout: 30000 });
558
+ applyWALPragmas(db);
559
+ } catch (err) {
560
+ const msg = err instanceof Error ? err.message : String(err);
561
+ if (isSQLiteCorruptionError(msg)) {
562
+ renameCorruptDB(dbPath);
563
+ cleanOrphanedWALFiles(dbPath);
564
+ try {
565
+ db = new Database(dbPath, { timeout: 30000 });
566
+ applyWALPragmas(db);
567
+ } catch (retryErr) {
568
+ throw new Error(
569
+ `Failed to create fresh DB after renaming corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`
570
+ );
571
+ }
572
+ } else {
573
+ throw err;
574
+ }
575
+ }
576
+ this.#db = db;
577
+ _liveDBs.add(this.#db);
578
+ this.initSchema();
579
+ this.prepareStatements();
580
+ }
581
+
582
+ /** Called once after WAL pragmas are applied. Subclasses run CREATE TABLE/VIRTUAL TABLE here. */
583
+ protected abstract initSchema(): void;
584
+
585
+ /** Called once after schema init. Subclasses compile and cache their prepared statements here. */
586
+ protected abstract prepareStatements(): void;
587
+
588
+ /** Raw database instance — available to subclasses only. */
589
+ protected get db(): DatabaseInstance {
590
+ return this.#db;
591
+ }
592
+
593
+ /** The path this database was opened from. */
594
+ get dbPath(): string {
595
+ return this.#dbPath;
596
+ }
597
+
598
+ /** Close the database connection without deleting files. */
599
+ close(): void {
600
+ _liveDBs.delete(this.#db);
601
+ closeDB(this.#db);
602
+ }
603
+
604
+ protected withRetry<T>(fn: () => T): T {
605
+ return withRetry(fn);
606
+ }
607
+
608
+ /**
609
+ * Close the connection and delete all associated DB files (main, WAL, SHM).
610
+ * Call on process exit or at end of session lifecycle.
611
+ */
612
+ cleanup(): void {
613
+ _liveDBs.delete(this.#db);
614
+ closeDB(this.#db);
615
+ deleteDBFiles(this.#dbPath);
616
+ }
617
+ }