llm-cli-gateway 1.17.8 → 2.0.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,188 @@ All notable changes to the llm-cli-gateway project.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [2.0.0] - 2026-06-04: node:sqlite migration — native module out of the prod graph
8
+
9
+ Major release. Persistence moves from the native `better-sqlite3` binding to
10
+ Node's built-in `node:sqlite` module behind a thin adapter. The entire
11
+ 1.17.6-1.17.8 supply-chain incident class — every one of which traced to
12
+ `better-sqlite3`'s install path (`prebuild-install → tar-fs → tar-stream`),
13
+ not its runtime — is now **structurally** gone: the production dependency
14
+ graph contains zero native modules, zero install scripts, and no
15
+ `prebuild-install`/`tar-fs`/`tar-stream` chain. Verified end to end against a
16
+ verdaccio registry reproduction (`scripts/verify-registry-install.sh`):
17
+ consumer tree reified at 94 packages (down from ~124 in 1.17.9), `npm ls`
18
+ exits 0, and no `better-sqlite3`/`tar-stream`/`prebuild-install` appears
19
+ anywhere in the consumer tree.
20
+
21
+ ### BREAKING
22
+
23
+ - **`engines.node` is now `>=24.4.0`** (was `>=20.0.0`). Node 20 is EOL
24
+ (April 2026). The 24.4 floor is required because `node:sqlite`'s
25
+ `allowBareNamedParameters` defaults to `true` only from Node 24.4 — the
26
+ persistence layer binds bare `{ id: ... }` objects to `@id` placeholders
27
+ throughout, and on 24.0-24.3 that would need a per-statement
28
+ `setAllowBareNamedParameters(true)` call. The adapter unit tests assert
29
+ bare-name binding works, so a regression in either direction is caught.
30
+
31
+ ### Added
32
+
33
+ - `src/sqlite-driver.ts`: thin adapter over `node:sqlite`'s `DatabaseSync`.
34
+ Exports `openDatabase`, `openReadOnly`, and a `GatewayDatabase` /
35
+ `GatewayStatement` surface (`exec`/`prepare`/`run`/`get`/`all`/
36
+ `withTransaction`/`close`). It is the ONLY production module that touches
37
+ `node:sqlite`; the release security audit hard-fails if any other
38
+ production module references it. Preserves the flight recorder's
39
+ graceful-degradation path (constructor failure → recorder disabled, gateway
40
+ still runs).
41
+ - Read-only `queryRequests` connection: `openReadOnly` opens the DB with
42
+ `{ readOnly: true }`, so write-disguised-as-read SQL fails at the SQLite
43
+ engine level (`SQLITE_READONLY`). This is **stronger** than the old
44
+ better-sqlite3 `stmt.readonly` JS-property check it replaces — enforcement
45
+ is at the engine, not in JavaScript — with one belt-and-braces guard: the
46
+ read-only connection also rejects `VACUUM`/`VACUUM INTO`, the one statement
47
+ that writes a new file to disk despite `{ readOnly: true }` (and that
48
+ `stmt.readonly` previously blocked). ATTACH-then-write and
49
+ `writable_schema` schema edits are already engine-rejected.
50
+ - Cross-engine WAL crash-recovery fixtures in both directions
51
+ (`src/__tests__/cross-engine-wal.test.ts`): a `better-sqlite3`-written DB
52
+ (SQLite 3.53.1) with live `-wal`/`-shm` from a simulated unclean stop is
53
+ opened and exercised under `node:sqlite` (3.51.3), and the reverse for the
54
+ rollback direction. These gate the "zero data migration" claim across the
55
+ engine-version skew.
56
+
57
+ ### Changed
58
+
59
+ - `better-sqlite3` **moved from `dependencies` to `devDependencies`** (same
60
+ `^12.10.0` range; `@types/better-sqlite3` stays in devDependencies). It is
61
+ retained at dev time deliberately: two suites seed legacy-schema DB files
62
+ with it (`src/__tests__/flight-recorder.test.ts`,
63
+ `src/__tests__/test-veracity-regressions-slice-kappa.test.ts`) to simulate
64
+ databases written by pre-2.0.0 gateways — that realism is the point, and it
65
+ makes them standing old-engine-writer → node:sqlite-reader coverage on every
66
+ CI run — and the cross-engine WAL fixtures need a better-sqlite3 writer.
67
+ Consumers never see it: devDependencies do not install transitively, and the
68
+ prod-only shrinkwrap excludes the whole subtree.
69
+ - `flight-recorder.ts` / `job-store.ts` now open SQLite through the adapter
70
+ (`openDatabase`/`openReadOnly`/`withTransaction`) instead of
71
+ `require("better-sqlite3")`. SQL, schema, migrations, and pragmas are
72
+ unchanged.
73
+ - `package.json#overrides`: the `tar-stream` pin is **removed** (the chain
74
+ that needed it is gone from the prod graph). The `type-is` and `content-type`
75
+ pins stay — unrelated to this chain.
76
+ - `scripts/release-security-audit.sh`: the `consumerAdvisory` carve-out is
77
+ **deleted** — blocked `tar-stream` versions are now hard-fail tripwires
78
+ everywhere (the chain no longer exists in any prod tree). The packed-consumer
79
+ policy now hard-fails on ANY `tar-stream` in the consumer tree (was an
80
+ advisory warning). The repo-lockfile tripwire skips dev-only entries so the
81
+ deliberate devDependency `tar-stream@2.2.0` does not false-fail, while still
82
+ hard-failing any blocked version that re-enters the prod graph. The
83
+ better-sqlite3 PRAGMA scan is repointed at the adapter: it now also asserts
84
+ `node:sqlite` is referenced only by `src/sqlite-driver.ts`.
85
+ - `scripts/pre-release.sh`: the better-sqlite3 native-binding sanity guard is
86
+ removed (the test suite exercises the binding as a devDep and fails loudly if
87
+ broken); the `npm ls tar-stream` step is replaced by an absence assertion
88
+ against the generated prod-only shrinkwrap
89
+ (`better-sqlite3`/`prebuild-install`/`tar-fs`/`tar-stream` must be absent).
90
+ - `scripts/verify-registry-install.sh`: assertions updated for 2.0.0 —
91
+ `tar-stream`/`better-sqlite3`/`prebuild-install` must be ABSENT from the
92
+ consumer tree; consumer `npm ls` must exit 0 (the out-of-range pin that
93
+ caused ELSPROBLEMS is gone); a `node:sqlite` runtime smoke
94
+ (`new DatabaseSync(':memory:')`) confirms the engine; and the reified package
95
+ count is asserted at 94 ±2.
96
+ - README, `socket.yml`, and `docs/personal-mcp/RELEASE_READINESS.md` updated to
97
+ reflect the node:sqlite reality (no native binding, no install scripts,
98
+ Node >=24.4.0, adapter-isolation audit replacing the PRAGMA-helper note).
99
+
100
+ ### Rollback
101
+
102
+ Reverting the 2.0.0 commit re-adds `better-sqlite3` to `dependencies`, the
103
+ `tar-stream` override, and the audit advisory carve-out. DB files are
104
+ compatible in both directions — exactly what the cross-engine WAL fixtures
105
+ prove (the rollback claim inherits that gate; it is not asserted
106
+ independently).
107
+
108
+ ## [1.17.9] - 2026-06-04: prod-only shrinkwrap + registry-fidelity verification
109
+
110
+ Patch release shipping a prod-only `npm-shrinkwrap.json` and correcting the
111
+ 1.17.8 record: registry installs **do** honour the published shrinkwrap (the
112
+ real distribution channel), so consumers of `npm install llm-cli-gateway`
113
+ already get the pinned `tar-stream@3.1.7`. The 1.17.8 changelog called the
114
+ shrinkwrap "inert today because of npm/cli#7977" — that was wrong. npm/cli#7977
115
+ covers a remote-registry edge case; what we actually reproduced on this host
116
+ (npm 11.12.1) is that **local-tarball** installs ignore a nested shrinkwrap
117
+ (npm/cli#5349/#5325 class), while registry installs honour it via the
118
+ packument's `hasShrinkwrap` flag. This release verifies the registry path end
119
+ to end with a verdaccio reproduction.
120
+
121
+ ### Added
122
+
123
+ - `scripts/make-prod-shrinkwrap.mjs`: deterministic generator that projects
124
+ `package-lock.json` into a prod-only `npm-shrinkwrap.json` — drops every
125
+ dev-only (`dev === true`) `packages` entry and deletes the root
126
+ `devDependencies` field. A byte-identical copy of the lockfile (1.17.8's
127
+ approach) reified all ~316 packages into consumer trees (npm/cli#4323); the
128
+ prod-only projection ships ~124 and eliminates the dev-dep bloat for registry
129
+ consumers. Output is byte-deterministic; the security audit regenerates and
130
+ compares for parity. `optional` (and any `devOptional`) entries are kept —
131
+ prod installs need them. The shrinkwrap is GENERATED at pack/publish time
132
+ and never committed: a committed npm-shrinkwrap.json is treated by
133
+ `npm ci`/`npm install` as the authoritative lockfile, and the prod-only
134
+ projection (no dev deps) breaks every dev/CI install with EUSAGE "lock
135
+ file out of sync" — discovered when the first 1.17.9 release attempt
136
+ failed all four `npm ci`-based workflows. `.gitignore` now covers it; the
137
+ CI, publish, and tag-release workflows generate it just before the
138
+ security audit / pack / publish steps.
139
+ - `scripts/verify-registry-install.sh`: registry-fidelity gate (run by
140
+ `scripts/pre-release.sh` and standalone). Publishes the current tree to an
141
+ ephemeral verdaccio, installs it into a fresh consumer dir, and asserts (a)
142
+ `tar-stream` resolves to `3.1.7` (shrinkwrap honoured), (b) no dev-dep markers
143
+ (`vitest`/`typescript`/`eslint`/`prettier`) in the consumer tree, (c) the
144
+ installed bin prints the expected version, (d) `better-sqlite3` loads from the
145
+ installed package (binding built through the pinned tar chain). The publish /
146
+ consumer-install / assertion flow runs entirely against throwaway temp dirs
147
+ (registry storage, npm cache, userconfig) and the localhost registry — the
148
+ package under test never reaches the public registry. One exception: the
149
+ verdaccio bootstrap itself (`npx --yes verdaccio`) resolves through the user's
150
+ normal npm config and npx cache (unavoidable for an ephemeral tool), touching
151
+ only verdaccio's own packages. Sets the packument's
152
+ `_hasShrinkwrap` flag to mirror what npmjs sets at publish (verdaccio does not
153
+ compute it), so the reproduction faithfully matches the real registry. Logs
154
+ the observed reified-package count (not hard-asserted in this release).
155
+
156
+ ### Changed
157
+
158
+ - `scripts/pre-release.sh` / `scripts/refresh-release-lockfile.sh`: replace
159
+ `cp package-lock.json npm-shrinkwrap.json` with
160
+ `node scripts/make-prod-shrinkwrap.mjs`; pre-release now also runs
161
+ `scripts/verify-registry-install.sh` after the shrinkwrap regeneration and the
162
+ release gate.
163
+ - `scripts/release-security-audit.sh`: the shrinkwrap parity gate no longer does
164
+ byte-identity against the lockfile (that no longer holds — the shrinkwrap is a
165
+ prod-only projection). It regenerates the expected projection from
166
+ `package-lock.json` via the same deterministic generator into a temp file and
167
+ `cmp -s` against the shipped `npm-shrinkwrap.json`.
168
+
169
+ ### Fixed (record correction)
170
+
171
+ - The 1.17.8 claim that the shipped shrinkwrap is "inert today because of
172
+ npm/cli#7977" was incorrect. Registry installs honour it (verified via the new
173
+ verdaccio reproduction); only **local-tarball** installs ignore it
174
+ (npm/cli#5349/#5325 class — our live repro). The packed-consumer-install
175
+ advisory in the audit is requalified accordingly: registry installs get
176
+ `tar-stream@3.1.7`, local-tarball installs still resolve `tar-stream@2.2.0`,
177
+ and the advisory (warn, not fail) stays until Phase B drops `better-sqlite3`
178
+ from the prod graph. The 1.17.8 entry itself is left unedited.
179
+
180
+ ### Known residuals
181
+
182
+ - Consumer `npm ls` exits ELSPROBLEMS: the pinned `tar-stream@3.1.7` sits
183
+ outside `tar-fs`'s `^2.1.4` range. Inherent to the out-of-range pin; disappears
184
+ in 2.0.0 (Phase B / node:sqlite) when the `better-sqlite3 → prebuild-install
185
+ → tar-fs` chain leaves the prod graph entirely.
186
+ - Local-tarball installs still resolve `tar-stream@2.2.0` (shrinkwrap ignored on
187
+ that path); the audit's advisory carve-out stays until Phase B.
188
+
7
189
  ## [1.17.8] - 2026-06-04: release-audit integrity fix + shrinkwrap groundwork
8
190
 
9
191
  Patch release fixing a masking bug in the release security audit and documenting
package/README.md CHANGED
@@ -214,6 +214,8 @@ Opt-in flags (all default off) live under `[cache_awareness]` in `~/.llm-cli-gat
214
214
 
215
215
  ## Prerequisites
216
216
 
217
+ **Node.js >= 24.4.0** is required (`engines.node` in `package.json`). The gateway uses Node's built-in `node:sqlite` module for persistence — there is no native binding to compile and no install scripts run. The 24.4 floor is where `allowBareNamedParameters` defaults to `true`, which the persistence layer relies on.
218
+
217
219
  Before using this gateway, you need to install the CLI tools you want to use:
218
220
 
219
221
  ### Claude Code CLI
@@ -1180,8 +1182,8 @@ If you're vetting `llm-cli-gateway` through [Socket](https://socket.dev/npm/pack
1180
1182
  | **Network access** | `src/http-transport.ts` opens an HTTP MCP transport when started via `npm run start:http`. `src/endpoint-exposure.ts` issues a HEAD probe to verify configured public/tunnel URLs. Socket also flagged `dist/upstream-contracts.js` in v1.17.2 from descriptive text, not a network call. | The transport binds to `127.0.0.1` by default and requires `LLM_GATEWAY_AUTH_TOKEN` to be set. The default stdio MCP entry point (`npm start`) opens no sockets. `src/upstream-contracts.ts` stores provider CLI metadata and imports no HTTP client APIs. |
1181
1183
  | **Shell access** | `src/executor.ts` uses `child_process.spawn(cmd, args, …)` to invoke the underlying LLM CLIs. | `spawn` is called with an argument array and **never** `shell: true`, so there is no shell interpolation path for caller input. The command name is restricted to an allow-list of known CLI binaries (`claude`, `codex`, `gemini`, `grok`, `vibe`). |
1182
1184
  | **Uses eval** | None in our source. Transitive: `@modelcontextprotocol/sdk` → `ajv@8` uses `new Function(...)` in `ajv/dist/compile/index.js` to compile JSON Schema validators. | This is ajv's standard codegen path. Only known schemas (defined in our source and the MCP SDK) flow into it; no caller-supplied data ever reaches the compiled function body. |
1183
- | **better-sqlite3 PRAGMA helper** | Transitive: `better-sqlite3/lib/methods/pragma.js` interpolates its caller-provided `source` into a `PRAGMA ${source}` statement. | We do not call `db.pragma()` from production source. Internal SQLite setup uses fixed literal `db.exec("PRAGMA ...")` statements, and `npm run security:audit` fails the release if production code reintroduces `.pragma()` calls. |
1184
- | **Dependency ownership** | A handful of small transitive packages (e.g. `bindings` via `better-sqlite3`, `media-typer` via `@modelcontextprotocol/sdk`) trip Socket's "unstable ownership" or "obfuscated code" heuristics. | These are pinned, well-known micro-deps in the Node ecosystem with no known issues. We pin direct override versions of `content-type` and `type-is` in `package.json#overrides`. Our previous direct dependency on `toml@3.0.0` (also single-maintainer, last released 2020) was replaced with the actively-maintained `smol-toml` to reduce inherited risk. |
1185
+ | **SQLite adapter isolation** | Persistence uses Node's built-in `node:sqlite` module (no native binding, no install scripts) through a single adapter, `src/sqlite-driver.ts`. | `node:sqlite` is touched by exactly one production module (the adapter); every other module talks to SQLite through its typed surface. We never call any `db.pragma()` helper (it does not exist on `node:sqlite`); SQLite setup uses fixed literal `db.exec("PRAGMA ...")` statements. `npm run security:audit` fails the release if production code references `node:sqlite` outside the adapter or reintroduces a `.pragma()` call. |
1186
+ | **Dependency ownership** | A handful of small transitive packages (e.g. `media-typer` via `@modelcontextprotocol/sdk`) trip Socket's "unstable ownership" or "obfuscated code" heuristics. | These are pinned, well-known micro-deps in the Node ecosystem with no known issues. We pin direct override versions of `content-type` and `type-is` in `package.json#overrides`. As of 2.0.0 the prod graph carries no native module (`better-sqlite3` moved to devDependencies; `node:sqlite` is built into Node), eliminating the entire `prebuild-install`/`tar-fs`/`tar-stream` install-time chain. Our earlier direct dependency on `toml@3.0.0` was replaced with `smol-toml`. |
1185
1187
 
1186
1188
  See [`socket.yml`](./socket.yml) for the same context in machine-readable form.
1187
1189
 
@@ -34,6 +34,9 @@ interface LoggerLike {
34
34
  export declare function resolveFlightRecorderDbPath(): string | null;
35
35
  export declare class FlightRecorder {
36
36
  private db;
37
+ private readOnlyDb;
38
+ private closed;
39
+ private readonly dbPath;
37
40
  private insertStartTxn;
38
41
  private updateCompleteTxn;
39
42
  constructor(dbPath: string);
@@ -1,10 +1,10 @@
1
- import { chmodSync, existsSync, mkdirSync } from "fs";
1
+ import { chmodSync } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
- import { createRequire } from "module";
4
+ import { openDatabase, openReadOnly } from "./sqlite-driver.js";
5
5
  const MAX_THINKING_BYTES = 1_000_000;
6
6
  function ensureRequestsCacheColumns(db) {
7
- const rows = db.prepare("PRAGMA table_info(requests)").all?.() ?? [];
7
+ const rows = db.prepare("PRAGMA table_info(requests)").all();
8
8
  const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
9
9
  if (!names.has("cache_read_tokens")) {
10
10
  db.exec("ALTER TABLE requests ADD COLUMN cache_read_tokens INTEGER");
@@ -14,7 +14,7 @@ function ensureRequestsCacheColumns(db) {
14
14
  }
15
15
  }
16
16
  function ensureStablePrefixColumns(db) {
17
- const rows = db.prepare("PRAGMA table_info(requests)").all?.() ?? [];
17
+ const rows = db.prepare("PRAGMA table_info(requests)").all();
18
18
  const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
19
19
  if (!names.has("stable_prefix_hash")) {
20
20
  db.exec("ALTER TABLE requests ADD COLUMN stable_prefix_hash TEXT");
@@ -25,7 +25,7 @@ function ensureStablePrefixColumns(db) {
25
25
  db.exec("CREATE INDEX IF NOT EXISTS idx_requests_stable_hash ON requests(stable_prefix_hash)");
26
26
  }
27
27
  function ensureCacheControlBlocksColumn(db) {
28
- const rows = db.prepare("PRAGMA table_info(requests)").all?.() ?? [];
28
+ const rows = db.prepare("PRAGMA table_info(requests)").all();
29
29
  const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
30
30
  if (!names.has("cache_control_blocks")) {
31
31
  db.exec("ALTER TABLE requests ADD COLUMN cache_control_blocks INTEGER");
@@ -77,16 +77,14 @@ function truncateThinkingBlocks(blocks) {
77
77
  }
78
78
  export class FlightRecorder {
79
79
  db;
80
+ readOnlyDb = null;
81
+ closed = false;
82
+ dbPath;
80
83
  insertStartTxn;
81
84
  updateCompleteTxn;
82
85
  constructor(dbPath) {
83
- const require = createRequire(import.meta.url);
84
- const BetterSqlite3 = require("better-sqlite3");
85
- const directory = path.dirname(dbPath);
86
- if (!existsSync(directory)) {
87
- mkdirSync(directory, { recursive: true });
88
- }
89
- this.db = new BetterSqlite3(dbPath);
86
+ this.dbPath = dbPath;
87
+ this.db = openDatabase(dbPath);
90
88
  this.db.exec("PRAGMA journal_mode = WAL");
91
89
  this.db.exec("PRAGMA foreign_keys = ON");
92
90
  this.db.exec(`
@@ -165,7 +163,7 @@ export class FlightRecorder {
165
163
  INSERT INTO gateway_metadata (request_id, async_job_id, status)
166
164
  VALUES (@request_id, @async_job_id, 'started')
167
165
  `);
168
- this.insertStartTxn = this.db.transaction((entry) => {
166
+ this.insertStartTxn = this.db.withTransaction((entry) => {
169
167
  insertRequest.run({
170
168
  id: entry.correlationId,
171
169
  cli: entry.cli,
@@ -206,7 +204,7 @@ export class FlightRecorder {
206
204
  status = @status
207
205
  WHERE request_id = @id AND status = 'started'
208
206
  `);
209
- this.updateCompleteTxn = this.db.transaction((correlationId, result) => {
207
+ this.updateCompleteTxn = this.db.withTransaction((correlationId, result) => {
210
208
  const thinkingBlocks = result.thinkingBlocks && result.thinkingBlocks.length > 0
211
209
  ? JSON.stringify(truncateThinkingBlocks(result.thinkingBlocks))
212
210
  : null;
@@ -240,18 +238,22 @@ export class FlightRecorder {
240
238
  this.updateCompleteTxn(correlationId, result);
241
239
  }
242
240
  queryRequests(sql, ...params) {
243
- const stmt = this.db.prepare(sql);
244
- if (stmt.readonly === false) {
245
- throw new Error("FlightRecorder.queryRequests refuses non-readonly SQL — use a transaction or a separate write surface for INSERT/UPDATE/DELETE.");
241
+ if (this.closed) {
242
+ throw new Error("flight recorder is closed");
246
243
  }
247
- if (!stmt.all) {
248
- return [];
244
+ if (!this.readOnlyDb) {
245
+ this.readOnlyDb = openReadOnly(this.dbPath);
249
246
  }
250
- return stmt.all(...params);
247
+ return this.readOnlyDb.prepare(sql).all(...params);
251
248
  }
252
249
  flush() {
253
250
  }
254
251
  close() {
252
+ this.closed = true;
253
+ if (this.readOnlyDb) {
254
+ this.readOnlyDb.close();
255
+ this.readOnlyDb = null;
256
+ }
255
257
  this.db.close();
256
258
  }
257
259
  }
package/dist/job-store.js CHANGED
@@ -1,8 +1,8 @@
1
- import { chmodSync, existsSync, mkdirSync } from "fs";
1
+ import { chmodSync } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { createHash } from "crypto";
5
- import { createRequire } from "module";
5
+ import { openDatabase } from "./sqlite-driver.js";
6
6
  import { noopLogger } from "./logger.js";
7
7
  export function resolveJobStoreDbPath() {
8
8
  const configured = process.env.LLM_GATEWAY_JOBS_DB ?? process.env.LLM_GATEWAY_LOGS_DB;
@@ -74,13 +74,7 @@ export class SqliteJobStore {
74
74
  deleteExpiredStmt;
75
75
  constructor(dbPath, logger = noopLogger, options = {}) {
76
76
  this.logger = logger;
77
- const require = createRequire(import.meta.url);
78
- const BetterSqlite3 = require("better-sqlite3");
79
- const directory = path.dirname(dbPath);
80
- if (!existsSync(directory)) {
81
- mkdirSync(directory, { recursive: true });
82
- }
83
- this.db = new BetterSqlite3(dbPath);
77
+ this.db = openDatabase(dbPath);
84
78
  this.db.exec("PRAGMA journal_mode = WAL");
85
79
  this.db.exec("PRAGMA synchronous = NORMAL");
86
80
  this.db.exec(`
@@ -211,7 +205,7 @@ export class SqliteJobStore {
211
205
  markOrphanedOnStartup() {
212
206
  const now = new Date().toISOString();
213
207
  const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
214
- const rows = (this.selectRunningOrphansStmt.all?.() ?? []);
208
+ const rows = this.selectRunningOrphansStmt.all();
215
209
  const orphaned = rows.map(row => ({
216
210
  id: row.id,
217
211
  correlationId: row.correlation_id,
@@ -221,12 +215,12 @@ export class SqliteJobStore {
221
215
  exitCode: row.exit_code,
222
216
  }));
223
217
  const result = this.markOrphanedStmt.run(now, expiresAt);
224
- return { count: result?.changes ?? 0, orphaned };
218
+ return { count: Number(result.changes), orphaned };
225
219
  }
226
220
  evictExpired() {
227
221
  const now = new Date().toISOString();
228
222
  const result = this.deleteExpiredStmt.run(now);
229
- return result?.changes ?? 0;
223
+ return Number(result.changes);
230
224
  }
231
225
  close() {
232
226
  try {
@@ -0,0 +1,16 @@
1
+ export interface GatewayStatement {
2
+ run(...args: unknown[]): {
3
+ changes: number;
4
+ lastInsertRowid: number | bigint;
5
+ };
6
+ get(...args: unknown[]): unknown;
7
+ all(...args: unknown[]): unknown[];
8
+ }
9
+ export interface GatewayDatabase {
10
+ exec(sql: string): void;
11
+ prepare(sql: string): GatewayStatement;
12
+ withTransaction<A extends unknown[]>(fn: (...args: A) => void): (...args: A) => void;
13
+ close(): void;
14
+ }
15
+ export declare function openDatabase(dbPath: string): GatewayDatabase;
16
+ export declare function openReadOnly(dbPath: string): GatewayDatabase;
@@ -0,0 +1,149 @@
1
+ import { existsSync, mkdirSync } from "fs";
2
+ import path from "path";
3
+ import { createRequire } from "module";
4
+ function loadNodeSqlite() {
5
+ const require = createRequire(import.meta.url);
6
+ return require("node:sqlite");
7
+ }
8
+ function wrapStatement(stmt) {
9
+ return {
10
+ run(...args) {
11
+ return stmt.run(...args);
12
+ },
13
+ get(...args) {
14
+ return stmt.get(...args);
15
+ },
16
+ all(...args) {
17
+ return stmt.all(...args);
18
+ },
19
+ };
20
+ }
21
+ function statementLeadingKeywords(sql) {
22
+ const keywords = [];
23
+ let i = 0;
24
+ const skipTrivia = () => {
25
+ for (;;) {
26
+ while (i < sql.length && /\s|;/.test(sql[i] ?? ""))
27
+ i++;
28
+ if (sql.startsWith("--", i)) {
29
+ i += 2;
30
+ while (i < sql.length && sql[i] !== "\n")
31
+ i++;
32
+ continue;
33
+ }
34
+ if (sql.startsWith("/*", i)) {
35
+ const end = sql.indexOf("*/", i + 2);
36
+ i = end === -1 ? sql.length : end + 2;
37
+ continue;
38
+ }
39
+ break;
40
+ }
41
+ };
42
+ const skipQuoted = (quote) => {
43
+ i++;
44
+ while (i < sql.length) {
45
+ if (sql[i] === quote) {
46
+ if (sql[i + 1] === quote) {
47
+ i += 2;
48
+ continue;
49
+ }
50
+ i++;
51
+ return;
52
+ }
53
+ i++;
54
+ }
55
+ };
56
+ while (i < sql.length) {
57
+ skipTrivia();
58
+ const m = /^[a-zA-Z]+/.exec(sql.slice(i));
59
+ if (m) {
60
+ keywords.push(m[0].toUpperCase());
61
+ }
62
+ while (i < sql.length && sql[i] !== ";") {
63
+ if (sql.startsWith("--", i)) {
64
+ i += 2;
65
+ while (i < sql.length && sql[i] !== "\n")
66
+ i++;
67
+ }
68
+ else if (sql.startsWith("/*", i)) {
69
+ const end = sql.indexOf("*/", i + 2);
70
+ i = end === -1 ? sql.length : end + 2;
71
+ }
72
+ else if (sql[i] === "'" || sql[i] === '"' || sql[i] === "`") {
73
+ skipQuoted(sql[i]);
74
+ }
75
+ else if (sql[i] === "[") {
76
+ i++;
77
+ while (i < sql.length && sql[i] !== "]")
78
+ i++;
79
+ if (i < sql.length)
80
+ i++;
81
+ }
82
+ else {
83
+ i++;
84
+ }
85
+ }
86
+ }
87
+ return keywords;
88
+ }
89
+ class GatewayDatabaseImpl {
90
+ db;
91
+ readOnly;
92
+ inTransaction = false;
93
+ constructor(db, readOnly = false) {
94
+ this.db = db;
95
+ this.readOnly = readOnly;
96
+ }
97
+ guardReadOnly(sql) {
98
+ if (this.readOnly && statementLeadingKeywords(sql).includes("VACUUM")) {
99
+ throw new Error("read-only connection rejects VACUUM (writes to disk despite readOnly)");
100
+ }
101
+ }
102
+ exec(sql) {
103
+ this.guardReadOnly(sql);
104
+ this.db.exec(sql);
105
+ }
106
+ prepare(sql) {
107
+ this.guardReadOnly(sql);
108
+ return wrapStatement(this.db.prepare(sql));
109
+ }
110
+ withTransaction(fn) {
111
+ return (...args) => {
112
+ if (this.inTransaction) {
113
+ throw new Error("nested transaction");
114
+ }
115
+ this.db.exec("BEGIN");
116
+ this.inTransaction = true;
117
+ try {
118
+ fn(...args);
119
+ this.db.exec("COMMIT");
120
+ }
121
+ catch (error) {
122
+ try {
123
+ this.db.exec("ROLLBACK");
124
+ }
125
+ catch {
126
+ }
127
+ throw error;
128
+ }
129
+ finally {
130
+ this.inTransaction = false;
131
+ }
132
+ };
133
+ }
134
+ close() {
135
+ this.db.close();
136
+ }
137
+ }
138
+ export function openDatabase(dbPath) {
139
+ const { DatabaseSync } = loadNodeSqlite();
140
+ const directory = path.dirname(dbPath);
141
+ if (!existsSync(directory)) {
142
+ mkdirSync(directory, { recursive: true });
143
+ }
144
+ return new GatewayDatabaseImpl(new DatabaseSync(dbPath));
145
+ }
146
+ export function openReadOnly(dbPath) {
147
+ const { DatabaseSync } = loadNodeSqlite();
148
+ return new GatewayDatabaseImpl(new DatabaseSync(dbPath, { readOnly: true }), true);
149
+ }