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 +182 -0
- package/README.md +4 -2
- package/dist/flight-recorder.d.ts +3 -0
- package/dist/flight-recorder.js +22 -20
- package/dist/job-store.js +6 -12
- package/dist/sqlite-driver.d.ts +16 -0
- package/dist/sqlite-driver.js +149 -0
- package/npm-shrinkwrap.json +60 -3250
- package/package.json +4 -5
- package/socket.yml +20 -11
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
|
-
| **
|
|
1184
|
-
| **Dependency ownership** | A handful of small transitive packages (e.g. `
|
|
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);
|
package/dist/flight-recorder.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { chmodSync
|
|
1
|
+
import { chmodSync } from "fs";
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import {
|
|
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
|
-
|
|
84
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
244
|
-
|
|
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 (!
|
|
248
|
-
|
|
244
|
+
if (!this.readOnlyDb) {
|
|
245
|
+
this.readOnlyDb = openReadOnly(this.dbPath);
|
|
249
246
|
}
|
|
250
|
-
return
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
+
}
|