From 8d94e811308169ab70ea6392e52288b874fae80a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 17:48:35 +0000 Subject: [PATCH 1/8] feat(better-sqlite3): record queries made through better-sqlite3 Add a hook for the better-sqlite3 driver, alongside the existing hooks for sqlite3, mysql, pg and Prisma. Applications that use better-sqlite3 (directly or through Drizzle) recorded no SQL events before this change. The hook records Database.exec and Database.pragma, and Statement.run, get, all and iterate, as sql_query events with database_type "sqlite". Statements inside a transaction() are recorded too, including the BEGIN and COMMIT that better-sqlite3 prepares itself. A statement that throws is recorded as an exception and rethrown. Two places where this hook departs from the sqlite3 hook: - better-sqlite3 does not export its Statement class, so the Statement prototype is patched the first time prepare() returns a statement, rather than at module load. - iterate() returns rows lazily, so its return event is emitted when the iterator is exhausted, returned early, or throws. The hook hands back a plain iterator object that forwards to the native one, because the native iterator's methods cannot be called through a Proxy receiver. Everything in better-sqlite3 is synchronous, so no async context capture is needed. The test fixture pins better-sqlite3 ^11.10, the last major that still ships prebuilt binaries for Node 18, which the CI matrix runs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EgK3BVLCovf26kotqTnkau --- package.json | 1 + src/hooks/betterSqlite3.ts | 189 ++++++++++++ src/requireHook.ts | 2 + test/__snapshots__/betterSqlite3.test.ts.snap | 280 ++++++++++++++++++ test/betterSqlite3.test.ts | 6 + test/betterSqlite3/appmap.yml | 3 + test/betterSqlite3/index.js | 45 +++ test/betterSqlite3/package.json | 8 + yarn.lock | 19 ++ 9 files changed, 553 insertions(+) create mode 100644 src/hooks/betterSqlite3.ts create mode 100644 test/__snapshots__/betterSqlite3.test.ts.snap create mode 100644 test/betterSqlite3.test.ts create mode 100644 test/betterSqlite3/appmap.yml create mode 100644 test/betterSqlite3/index.js create mode 100644 test/betterSqlite3/package.json diff --git a/package.json b/package.json index c265220..d2ea541 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "test/mysql", "test/postgres", "test/sqlite", + "test/betterSqlite3", "test/next", "test/next16", "test/typescript-esm", diff --git a/src/hooks/betterSqlite3.ts b/src/hooks/betterSqlite3.ts new file mode 100644 index 0000000..6028b11 --- /dev/null +++ b/src/hooks/betterSqlite3.ts @@ -0,0 +1,189 @@ +import { getActiveRecordings, isActive } from "../recorder"; +import { getTime } from "../util/getTime"; + +// https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md +// +// better-sqlite3 exports its Database class. Statement is not exported, so its +// prototype is picked up from the first statement that `prepare` returns and +// patched once. Every method is synchronous, which keeps the recording simple: +// a sql_query call event before the call and a return (or exception) event +// right after it, on the same async context. + +type AnyFunction = (this: unknown, ...args: unknown[]) => unknown; +type SqlOf = (thisArg: unknown, args: unknown[]) => string | undefined; + +export default function betterSqlite3Hook(mod: unknown) { + if (typeof mod !== "function" || typeof mod.prototype !== "object" || mod.prototype === null) + return mod; + + const proto = mod.prototype as Record; + + if (typeof proto.exec === "function") + proto.exec = createRecordingProxy(proto.exec as AnyFunction, (_, args) => stringArg(args[0])); + + // pragma() does not go through prepare(), so it needs its own hook. + if (typeof proto.pragma === "function") + proto.pragma = createRecordingProxy(proto.pragma as AnyFunction, (_, args) => { + const pragma = stringArg(args[0]); + return pragma === undefined ? undefined : `PRAGMA ${pragma}`; + }); + + if (typeof proto.prepare === "function") + proto.prepare = createPrepareProxy(proto.prepare as AnyFunction); + + return mod; +} + +betterSqlite3Hook.applicable = function (id: string) { + return id === "better-sqlite3"; +}; + +const patchedStatementPrototypes = new WeakSet(); + +function createPrepareProxy(prepare: AnyFunction) { + return new Proxy(prepare, { + apply(target, thisArg, argArray: unknown[]) { + const statement: unknown = Reflect.apply(target, thisArg, argArray); + if (statement !== null && typeof statement === "object") patchStatementPrototype(statement); + return statement; + }, + }); +} + +function patchStatementPrototype(statement: object) { + const proto: unknown = Object.getPrototypeOf(statement); + if (proto === null || typeof proto !== "object" || patchedStatementPrototypes.has(proto)) return; + patchedStatementPrototypes.add(proto); + + const p = proto as Record; + const sqlOfStatement: SqlOf = (thisArg) => statementSource(thisArg); + for (const method of ["run", "get", "all"]) + if (typeof p[method] === "function") + p[method] = createRecordingProxy(p[method] as AnyFunction, sqlOfStatement); + if (typeof p.iterate === "function") + p.iterate = createIterateProxy(p.iterate as AnyFunction, sqlOfStatement); +} + +function stringArg(arg: unknown): string | undefined { + return typeof arg === "string" ? arg : undefined; +} + +function statementSource(statement: unknown): string | undefined { + if (statement !== null && typeof statement === "object" && "source" in statement) + return stringArg(statement.source); + return undefined; +} + +// Emits a sql_query call event, runs the method, and emits the return or +// exception event as soon as it comes back. +function createRecordingProxy(proxyTarget: T, sqlOf: SqlOf) { + return new Proxy(proxyTarget, { + apply(target, thisArg, argArray: unknown[]) { + const sql = sqlOf(thisArg, argArray); + // No SQL to report (for example, pragma() called with a non-string): + // short circuit to the original function and let it handle the arguments. + if (sql === undefined) return Reflect.apply(target, thisArg, argArray); + + const recordings = getActiveRecordings(); + const callEvents = recordings.map((recording) => recording.sqlQuery("sqlite", sql)); + const startTime = getTime(); + try { + const result: unknown = Reflect.apply(target, thisArg, argArray); + recordings.forEach( + (recording, idx) => + isActive(recording) && + recording.functionReturn(callEvents[idx].id, undefined, startTime), + ); + return result; + } catch (exn: unknown) { + recordings.forEach( + (recording, idx) => + isActive(recording) && recording.functionException(callEvents[idx].id, exn, startTime), + ); + throw exn; + } + }, + }); +} + +interface IteratorLike { + next(...args: unknown[]): IteratorResult; + return?(...args: unknown[]): IteratorResult; +} + +function isIteratorLike(obj: unknown): obj is IteratorLike { + return obj !== null && typeof obj === "object" && "next" in obj && typeof obj.next === "function"; +} + +// iterate() hands rows out lazily, so the query is only finished when the +// iterator is exhausted, returned early (a `break` in a for..of), or throws. +// The return event is emitted at that point. The native iterator's methods +// must be called on the native object, not on the proxy, so they are bound +// explicitly rather than reached through the proxy's receiver. +function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { + return new Proxy(iterate, { + apply(target, thisArg, argArray: unknown[]) { + const sql = sqlOf(thisArg, argArray); + if (sql === undefined) return Reflect.apply(target, thisArg, argArray); + + const recordings = getActiveRecordings(); + const callEvents = recordings.map((recording) => recording.sqlQuery("sqlite", sql)); + const startTime = getTime(); + + let iterator: unknown; + try { + iterator = Reflect.apply(target, thisArg, argArray); + } catch (exn: unknown) { + recordings.forEach( + (recording, idx) => + isActive(recording) && recording.functionException(callEvents[idx].id, exn, startTime), + ); + throw exn; + } + if (!isIteratorLike(iterator)) { + recordings.forEach( + (recording, idx) => + isActive(recording) && + recording.functionReturn(callEvents[idx].id, undefined, startTime), + ); + return iterator; + } + + let finished = false; + const finish = (exn?: unknown) => { + if (finished) return; + finished = true; + recordings.forEach((recording, idx) => { + if (!isActive(recording)) return; + if (exn === undefined) recording.functionReturn(callEvents[idx].id, undefined, startTime); + else recording.functionException(callEvents[idx].id, exn, startTime); + }); + }; + + const native = iterator; + const proxy: IteratorLike & Iterable = { + next(...args: unknown[]) { + try { + const result = native.next(...args); + if (result.done) finish(); + return result; + } catch (exn: unknown) { + finish(exn ?? new Error("iteration failed")); + throw exn; + } + }, + return(...args: unknown[]) { + try { + return native.return ? native.return(...args) : { done: true, value: undefined }; + } finally { + finish(); + } + }, + [Symbol.iterator]() { + return this as Iterator; + }, + }; + return proxy; + }, + }); +} diff --git a/src/requireHook.ts b/src/requireHook.ts index feabcb6..a37f637 100644 --- a/src/requireHook.ts +++ b/src/requireHook.ts @@ -1,3 +1,4 @@ +import betterSqlite3Hook from "./hooks/betterSqlite3"; import httpHook from "./hooks/http"; import mongoHook from "./hooks/mongo"; import mysqlHook from "./hooks/mysql"; @@ -18,6 +19,7 @@ const hooks: Hook[] = [ mysqlHook, pgHook, sqliteHook, + betterSqlite3Hook, prismaHook, librariesHook, ]; diff --git a/test/__snapshots__/betterSqlite3.test.ts.snap b/test/__snapshots__/betterSqlite3.test.ts.snap new file mode 100644 index 0000000..ef015f3 --- /dev/null +++ b/test/__snapshots__/betterSqlite3.test.ts.snap @@ -0,0 +1,280 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`mapping better-sqlite3 calls 1`] = ` +{ + "classMap": [ + { + "children": [ + { + "children": [ + { + "location": "index.js:5", + "name": "main", + "static": true, + "type": "function", + }, + ], + "name": "index", + "type": "class", + }, + ], + "name": "better-sqlite3-appmap-node-test", + "type": "package", + }, + ], + "events": [ + { + "defined_class": "index", + "event": "call", + "id": 1, + "lineno": 5, + "method_id": "main", + "parameters": [], + "path": "index.js", + "static": true, + "thread_id": 0, + }, + { + "event": "call", + "id": 2, + "sql_query": { + "database_type": "sqlite", + "sql": "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 3, + "parent_id": 2, + "thread_id": 0, + }, + { + "event": "call", + "id": 4, + "sql_query": { + "database_type": "sqlite", + "sql": "PRAGMA journal_mode", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 5, + "parent_id": 4, + "thread_id": 0, + }, + { + "event": "call", + "id": 6, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 7, + "parent_id": 6, + "thread_id": 0, + }, + { + "event": "call", + "id": 8, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 9, + "parent_id": 8, + "thread_id": 0, + }, + { + "event": "call", + "id": 10, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT name FROM people WHERE id = ?", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 11, + "parent_id": 10, + "thread_id": 0, + }, + { + "event": "call", + "id": 12, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT id, name FROM people ORDER BY id", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 13, + "parent_id": 12, + "thread_id": 0, + }, + { + "event": "call", + "id": 14, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT name FROM people ORDER BY id", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 15, + "parent_id": 14, + "thread_id": 0, + }, + { + "event": "call", + "id": 16, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT name FROM people ORDER BY id", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 17, + "parent_id": 16, + "thread_id": 0, + }, + { + "event": "call", + "id": 18, + "sql_query": { + "database_type": "sqlite", + "sql": "BEGIN", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 19, + "parent_id": 18, + "thread_id": 0, + }, + { + "event": "call", + "id": 20, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 21, + "parent_id": 20, + "thread_id": 0, + }, + { + "event": "call", + "id": 22, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 23, + "parent_id": 22, + "thread_id": 0, + }, + { + "event": "call", + "id": 24, + "sql_query": { + "database_type": "sqlite", + "sql": "COMMIT", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 25, + "parent_id": 24, + "thread_id": 0, + }, + { + "event": "call", + "id": 26, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (id, name) VALUES (1, 'duplicate')", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "SqliteError", + "message": "UNIQUE constraint failed: people.id", + "object_id": 1, + }, + ], + "id": 27, + "parent_id": 26, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 28, + "parent_id": 1, + "thread_id": 0, + }, + ], + "metadata": { + "app": "better-sqlite3-appmap-node-test", + "client": { + "name": "appmap-node", + "url": "https://github.com/getappmap/appmap-node", + "version": "test node-appmap version", + }, + "language": { + "engine": "Node.js", + "name": "javascript", + "version": "test node version", + }, + "name": "test process recording", + "recorder": { + "name": "process", + "type": "process", + }, + }, + "version": "1.12", +} +`; diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts new file mode 100644 index 0000000..1df91e5 --- /dev/null +++ b/test/betterSqlite3.test.ts @@ -0,0 +1,6 @@ +import { integrationTest, readAppmap, runAppmapNode } from "./helpers"; + +integrationTest("mapping better-sqlite3 calls", () => { + expect(runAppmapNode("index.js").status).toBe(0); + expect(readAppmap()).toMatchSnapshot(); +}); diff --git a/test/betterSqlite3/appmap.yml b/test/betterSqlite3/appmap.yml new file mode 100644 index 0000000..633aab3 --- /dev/null +++ b/test/betterSqlite3/appmap.yml @@ -0,0 +1,3 @@ +name: better-sqlite3-appmap-node-test +appmap_dir: tmp/appmap +language: javascript diff --git a/test/betterSqlite3/index.js b/test/betterSqlite3/index.js new file mode 100644 index 0000000..2376f62 --- /dev/null +++ b/test/betterSqlite3/index.js @@ -0,0 +1,45 @@ +const Database = require("better-sqlite3"); + +// better-sqlite3 is synchronous: every call below returns before the next line +// runs, so each sql_query event should be followed directly by its return. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.pragma("journal_mode"); + + // The same prepared statement run twice should produce two sql events. + const insert = db.prepare("INSERT INTO people (name) VALUES (?)"); + insert.run("alice"); + insert.run("bob"); + + db.prepare("SELECT name FROM people WHERE id = ?").get(1); + db.prepare("SELECT id, name FROM people ORDER BY id").all(); + + // iterate() finishes when the iterator is exhausted... + for (const row of db.prepare("SELECT name FROM people ORDER BY id").iterate()) { + console.log("row:", row.name); + } + // ...or when the loop leaves early. + for (const row of db.prepare("SELECT name FROM people ORDER BY id").iterate()) { + console.log("first row only:", row.name); + break; + } + + // Statements run inside a transaction are recorded like any other. + const insertMany = db.transaction((names) => { + for (const name of names) insert.run(name); + }); + insertMany(["carol", "dave"]); + + // A failing statement is recorded as an exception and still thrown. + try { + db.prepare("INSERT INTO people (id, name) VALUES (1, 'duplicate')").run(); + } catch (error) { + console.log("caught:", error.code); + } + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/package.json b/test/betterSqlite3/package.json new file mode 100644 index 0000000..f2cf157 --- /dev/null +++ b/test/betterSqlite3/package.json @@ -0,0 +1,8 @@ +{ + "name": "better-sqlite3-appmap-node-test", + "packageManager": "yarn@4.13.0", + "dependencies": { + "better-sqlite3": "^11.10.0" + }, + "private": true +} diff --git a/yarn.lock b/yarn.lock index f2fccb4..937bd93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4087,6 +4087,25 @@ __metadata: languageName: node linkType: hard +"better-sqlite3-appmap-node-test@workspace:test/betterSqlite3": + version: 0.0.0-use.local + resolution: "better-sqlite3-appmap-node-test@workspace:test/betterSqlite3" + dependencies: + better-sqlite3: "npm:^11.10.0" + languageName: unknown + linkType: soft + +"better-sqlite3@npm:^11.10.0": + version: 11.10.0 + resolution: "better-sqlite3@npm:11.10.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10/5e4c7437c4fe6033335a79c82974d7ab29f33c51c36f48b73e87e087d21578468575de1c56a7badd4f76f17255e25abefddaeacf018e5eeb9e0cb8d6e3e4a5e1 + languageName: node + linkType: hard + "bignumber.js@npm:9.0.0": version: 9.0.0 resolution: "bignumber.js@npm:9.0.0" From 57b3319b18ff741e61cadaa8862ccce6c98e5ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 21 Sep 2026 12:59:11 +0200 Subject: [PATCH 2/8] test(better-sqlite3): cover the iterator exception paths iterate() has two failure paths that no fixture exercised: the statement itself can refuse to iterate, and a row can fail to materialize once the iterator is already running. Only the first is a plain call-time throw; the second has to be recorded when next() throws, long after iterate() returned. Add both to the fixture. A user-defined function that throws on the second row makes next() fail mid-loop, and iterate() on an INSERT throws right away. Both are recorded as exceptions on the sql_query event. No behaviour change; the snapshot records what the hook already does. Assisted-by: Claude:claude-opus-5[1m] --- test/__snapshots__/betterSqlite3.test.ts.snap | 62 ++++++++++++++++--- test/betterSqlite3/index.js | 21 +++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/test/__snapshots__/betterSqlite3.test.ts.snap b/test/__snapshots__/betterSqlite3.test.ts.snap index ef015f3..a4f262d 100644 --- a/test/__snapshots__/betterSqlite3.test.ts.snap +++ b/test/__snapshots__/betterSqlite3.test.ts.snap @@ -167,13 +167,20 @@ exports[`mapping better-sqlite3 calls 1`] = ` "id": 18, "sql_query": { "database_type": "sqlite", - "sql": "BEGIN", + "sql": "SELECT boom(name) AS name FROM people ORDER BY id", }, "thread_id": 0, }, { "elapsed": 31.337, "event": "return", + "exceptions": [ + { + "class": "Error", + "message": "boom", + "object_id": 1, + }, + ], "id": 19, "parent_id": 18, "thread_id": 0, @@ -183,13 +190,20 @@ exports[`mapping better-sqlite3 calls 1`] = ` "id": 20, "sql_query": { "database_type": "sqlite", - "sql": "INSERT INTO people (name) VALUES (?)", + "sql": "INSERT INTO people (name) VALUES ('never inserted')", }, "thread_id": 0, }, { "elapsed": 31.337, "event": "return", + "exceptions": [ + { + "class": "TypeError", + "message": "This statement does not return data. Use run() instead", + "object_id": 2, + }, + ], "id": 21, "parent_id": 20, "thread_id": 0, @@ -199,7 +213,7 @@ exports[`mapping better-sqlite3 calls 1`] = ` "id": 22, "sql_query": { "database_type": "sqlite", - "sql": "INSERT INTO people (name) VALUES (?)", + "sql": "BEGIN", }, "thread_id": 0, }, @@ -215,7 +229,7 @@ exports[`mapping better-sqlite3 calls 1`] = ` "id": 24, "sql_query": { "database_type": "sqlite", - "sql": "COMMIT", + "sql": "INSERT INTO people (name) VALUES (?)", }, "thread_id": 0, }, @@ -229,6 +243,38 @@ exports[`mapping better-sqlite3 calls 1`] = ` { "event": "call", "id": 26, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 27, + "parent_id": 26, + "thread_id": 0, + }, + { + "event": "call", + "id": 28, + "sql_query": { + "database_type": "sqlite", + "sql": "COMMIT", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 29, + "parent_id": 28, + "thread_id": 0, + }, + { + "event": "call", + "id": 30, "sql_query": { "database_type": "sqlite", "sql": "INSERT INTO people (id, name) VALUES (1, 'duplicate')", @@ -242,17 +288,17 @@ exports[`mapping better-sqlite3 calls 1`] = ` { "class": "SqliteError", "message": "UNIQUE constraint failed: people.id", - "object_id": 1, + "object_id": 3, }, ], - "id": 27, - "parent_id": 26, + "id": 31, + "parent_id": 30, "thread_id": 0, }, { "elapsed": 31.337, "event": "return", - "id": 28, + "id": 32, "parent_id": 1, "thread_id": 0, }, diff --git a/test/betterSqlite3/index.js b/test/betterSqlite3/index.js index 2376f62..f54d2a9 100644 --- a/test/betterSqlite3/index.js +++ b/test/betterSqlite3/index.js @@ -26,6 +26,27 @@ function main() { break; } + // A row that fails to materialize makes next() throw; the query is recorded + // as an exception at that point, after the rows that did come through. + db.function("boom", (name) => { + if (name === "bob") throw new Error("boom"); + return name; + }); + try { + for (const row of db.prepare("SELECT boom(name) AS name FROM people ORDER BY id").iterate()) { + console.log("row before the failure:", row.name); + } + } catch (error) { + console.log("caught while iterating:", error.message); + } + + // iterate() itself throws when the statement returns no data. + try { + db.prepare("INSERT INTO people (name) VALUES ('never inserted')").iterate(); + } catch (error) { + console.log("caught from iterate():", error.message); + } + // Statements run inside a transaction are recorded like any other. const insertMany = db.transaction((names) => { for (const name of names) insert.run(name); From b17967a5d2825bd09287ece391b44a3ae0511c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 21 Sep 2026 13:00:27 +0200 Subject: [PATCH 3/8] fix(better-sqlite3): record a failed iterator cleanup as an exception The iterator's return() emitted the return event from a finally block, so a cleanup that threw was still recorded as a successful query. native.return() does throw: it goes through the same busy check as next(), so releasing an iterator from inside a user-defined function running in another query fails with a TypeError. Emit the event from the catch instead, with the exception that cleanup threw. While here, drop the `exn ?? new Error(...)` sentinel from the next() handler and box the failure passed to finish() instead. The sentinel only fired for a thrown undefined or null, and when it did it reported an exception the application never threw. It did nothing for a thrown string, which lands in the same place. Every other method in this hook already passes whatever was thrown straight to functionException. Assisted-by: Claude:claude-opus-5[1m] --- src/hooks/betterSqlite3.ts | 23 +++++++++++----- test/betterSqlite3.test.ts | 22 +++++++++++++++ test/betterSqlite3/iterateCleanupFailure.js | 30 +++++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 test/betterSqlite3/iterateCleanupFailure.js diff --git a/src/hooks/betterSqlite3.ts b/src/hooks/betterSqlite3.ts index 6028b11..702b214 100644 --- a/src/hooks/betterSqlite3.ts +++ b/src/hooks/betterSqlite3.ts @@ -150,13 +150,16 @@ function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { } let finished = false; - const finish = (exn?: unknown) => { + // The failure is passed boxed so that a thrown undefined still finishes + // the query as an exception rather than as a successful return. + const finish = (failure?: { exception: unknown }) => { if (finished) return; finished = true; recordings.forEach((recording, idx) => { if (!isActive(recording)) return; - if (exn === undefined) recording.functionReturn(callEvents[idx].id, undefined, startTime); - else recording.functionException(callEvents[idx].id, exn, startTime); + if (failure) + recording.functionException(callEvents[idx].id, failure.exception, startTime); + else recording.functionReturn(callEvents[idx].id, undefined, startTime); }); }; @@ -168,15 +171,23 @@ function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { if (result.done) finish(); return result; } catch (exn: unknown) { - finish(exn ?? new Error("iteration failed")); + finish({ exception: exn }); throw exn; } }, return(...args: unknown[]) { try { - return native.return ? native.return(...args) : { done: true, value: undefined }; - } finally { + const result = native.return + ? native.return(...args) + : { done: true, value: undefined }; finish(); + return result; + } catch (exn: unknown) { + // Cleaning up can fail too (better-sqlite3 refuses to release an + // iterator while the connection is busy). The query is over either + // way, but it did not end well. + finish({ exception: exn }); + throw exn; } }, [Symbol.iterator]() { diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts index 1df91e5..9e35bda 100644 --- a/test/betterSqlite3.test.ts +++ b/test/betterSqlite3.test.ts @@ -1,6 +1,28 @@ +import assert from "node:assert"; + +import type * as AppMap from "../src/AppMap"; import { integrationTest, readAppmap, runAppmapNode } from "./helpers"; integrationTest("mapping better-sqlite3 calls", () => { expect(runAppmapNode("index.js").status).toBe(0); expect(readAppmap()).toMatchSnapshot(); }); + +integrationTest("recording an iterator whose cleanup fails", () => { + expect(runAppmapNode("iterateCleanupFailure.js").status).toBe(0); + + const events = readAppmap().events ?? []; + const iterated = events.find( + (event): event is AppMap.SqlQueryEvent => + "sql_query" in event && event.sql_query.sql.startsWith("SELECT name"), + ); + assert(iterated); + + const settled = events.find( + (event): event is AppMap.FunctionReturnEvent => + "parent_id" in event && event.parent_id === iterated.id, + ); + expect(settled?.exceptions).toMatchObject([ + { class: "TypeError", message: expect.stringContaining("busy") as string }, + ]); +}); diff --git a/test/betterSqlite3/iterateCleanupFailure.js b/test/betterSqlite3/iterateCleanupFailure.js new file mode 100644 index 0000000..d5234db --- /dev/null +++ b/test/betterSqlite3/iterateCleanupFailure.js @@ -0,0 +1,30 @@ +const Database = require("better-sqlite3"); + +// better-sqlite3 refuses to release an iterator while the connection is busy, +// so iterator.return() throws if it is reached from inside a user-defined +// function running as part of another query. Contrived, but it is the only way +// to make cleanup fail: the query being iterated ends there, and it ends badly, +// so it should be recorded as an exception rather than a plain return. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.exec("INSERT INTO people (name) VALUES ('alice'), ('bob')"); + + let iterator; + db.function("cleanup", () => { + iterator.return(); + return "unreachable"; + }); + + iterator = db.prepare("SELECT name FROM people ORDER BY id").iterate(); + console.log("first row:", iterator.next().value.name); + + try { + db.prepare("SELECT cleanup() AS done").get(); + } catch (error) { + console.log("caught:", error.message); + } +} + +main(); From 401a40aaa9c9c0af1b10456ab357f8dbea58c9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 21 Sep 2026 13:16:35 +0200 Subject: [PATCH 4/8] fix(better-sqlite3): don't record a query once per require The require hook runs for every require of a module, cache hits included, so a second require re-wrapped exec() and pragma(), and every call then emitted one event per layer of wrapping. Statement methods were already safe: they go through a WeakSet of patched prototypes. Guard the module itself the same way, as the prisma hook does. Assisted-by: Claude:claude-opus-5[1m] --- src/hooks/betterSqlite3.ts | 8 ++++++++ test/betterSqlite3.test.ts | 15 +++++++++++++++ test/betterSqlite3/doubleRequire.js | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 test/betterSqlite3/doubleRequire.js diff --git a/src/hooks/betterSqlite3.ts b/src/hooks/betterSqlite3.ts index 702b214..73031ef 100644 --- a/src/hooks/betterSqlite3.ts +++ b/src/hooks/betterSqlite3.ts @@ -12,10 +12,18 @@ import { getTime } from "../util/getTime"; type AnyFunction = (this: unknown, ...args: unknown[]) => unknown; type SqlOf = (thisArg: unknown, args: unknown[]) => string | undefined; +const patchedModules = new WeakSet(); + export default function betterSqlite3Hook(mod: unknown) { if (typeof mod !== "function" || typeof mod.prototype !== "object" || mod.prototype === null) return mod; + // The require hook runs for every require of the module, cache hits + // included, so patching has to be idempotent: wrapping a wrapper would + // record one event per layer. + if (patchedModules.has(mod)) return mod; + patchedModules.add(mod); + const proto = mod.prototype as Record; if (typeof proto.exec === "function") diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts index 9e35bda..b813194 100644 --- a/test/betterSqlite3.test.ts +++ b/test/betterSqlite3.test.ts @@ -26,3 +26,18 @@ integrationTest("recording an iterator whose cleanup fails", () => { { class: "TypeError", message: expect.stringContaining("busy") as string }, ]); }); + +integrationTest("recording queries when the module is required more than once", () => { + expect(runAppmapNode("doubleRequire.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "PRAGMA journal_mode", + "INSERT INTO people (name) VALUES (?)", + ]); +}); + +function recordedQueries(): string[] { + return (readAppmap().events ?? []) + .filter((event): event is AppMap.SqlQueryEvent => "sql_query" in event) + .map((event) => event.sql_query.sql); +} diff --git a/test/betterSqlite3/doubleRequire.js b/test/betterSqlite3/doubleRequire.js new file mode 100644 index 0000000..167978b --- /dev/null +++ b/test/betterSqlite3/doubleRequire.js @@ -0,0 +1,18 @@ +const Database = require("better-sqlite3"); + +// A second require is a cache hit, but the require hook still runs for it. +// Patching must not stack: each query below is one query, however many times +// the application asked for the module. +require("better-sqlite3"); + +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.pragma("journal_mode"); + db.prepare("INSERT INTO people (name) VALUES (?)").run("alice"); + + db.close(); +} + +main(); From 60df124e5242eedd71890e0e8838ffe857443c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 21 Sep 2026 13:59:30 +0200 Subject: [PATCH 5/8] fix(better-sqlite3): record BEGIN, and a pragma only once Two symptoms of the same cause. The Statement prototype was patched when prepare() first returned a statement, so whether a query was recorded depended on what the application had done before it. A transaction started before any prepare() lost its BEGIN: the controller compiles BEGIN, COMMIT and ROLLBACK on the native database handle, never through Database.prototype.prepare, and runs BEGIN before the callback gets a chance to prepare anything. COMMIT then came out recorded and BEGIN did not. A pragma run after a prepare() was recorded twice: pragma() compiles a statement of its own and runs it through all(), so the pragma proxy and the patched Statement both reported it. The fixture only missed this because it ran its pragma before its first prepare(). Prime the Statement patch from pragma() and transaction() with a throwaway statement, and drop the pragma proxy: with the patch in place the inner statement reports the pragma itself, with the same SQL. The existing snapshot is unchanged. Assisted-by: Claude:claude-opus-5[1m] --- src/hooks/betterSqlite3.ts | 56 +++++++++++++++++++----- test/betterSqlite3.test.ts | 20 +++++++++ test/betterSqlite3/pragmaAfterPrepare.js | 20 +++++++++ test/betterSqlite3/transactionFirst.js | 20 +++++++++ 4 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 test/betterSqlite3/pragmaAfterPrepare.js create mode 100644 test/betterSqlite3/transactionFirst.js diff --git a/src/hooks/betterSqlite3.ts b/src/hooks/betterSqlite3.ts index 73031ef..4736916 100644 --- a/src/hooks/betterSqlite3.ts +++ b/src/hooks/betterSqlite3.ts @@ -5,7 +5,8 @@ import { getTime } from "../util/getTime"; // // better-sqlite3 exports its Database class. Statement is not exported, so its // prototype is picked up from the first statement that `prepare` returns and -// patched once. Every method is synchronous, which keeps the recording simple: +// patched once; methods that compile statements of their own prime that patch +// before they run. Every method is synchronous, which keeps recording simple: // a sql_query call event before the call and a return (or exception) event // right after it, on the same async context. @@ -29,15 +30,20 @@ export default function betterSqlite3Hook(mod: unknown) { if (typeof proto.exec === "function") proto.exec = createRecordingProxy(proto.exec as AnyFunction, (_, args) => stringArg(args[0])); - // pragma() does not go through prepare(), so it needs its own hook. - if (typeof proto.pragma === "function") - proto.pragma = createRecordingProxy(proto.pragma as AnyFunction, (_, args) => { - const pragma = stringArg(args[0]); - return pragma === undefined ? undefined : `PRAGMA ${pragma}`; - }); - - if (typeof proto.prepare === "function") - proto.prepare = createPrepareProxy(proto.prepare as AnyFunction); + if (typeof proto.prepare === "function") { + const prepare = proto.prepare as AnyFunction; + proto.prepare = createPrepareProxy(prepare); + + // pragma() and transaction() run statements that never pass through + // prepare(): pragma() compiles its own, and the transaction controller + // compiles BEGIN, COMMIT and ROLLBACK on the native database handle. Both + // are recorded by the Statement patch, but neither can install it, and + // BEGIN has already run by the time a transaction callback prepares + // anything. Prime the patch before either of them runs instead. + for (const method of ["pragma", "transaction"]) + if (typeof proto[method] === "function") + proto[method] = createPrimingProxy(proto[method] as AnyFunction, prepare); + } return mod; } @@ -58,6 +64,36 @@ function createPrepareProxy(prepare: AnyFunction) { }); } +const primedPrepares = new WeakSet(); + +// Runs `fn` with the Statement prototype already patched, so that statements +// `fn` compiles behind our back are recorded too. The prototype is shared by +// every statement of a module, so a single throwaway statement is enough to +// get hold of it, once. +function createPrimingProxy(fn: AnyFunction, prepare: AnyFunction) { + return new Proxy(fn, { + apply(target, thisArg, argArray: unknown[]) { + primeStatementPrototype(prepare, thisArg); + return Reflect.apply(target, thisArg, argArray); + }, + }); +} + +function primeStatementPrototype(prepare: AnyFunction, database: unknown) { + if (primedPrepares.has(prepare)) return; + try { + const statement: unknown = Reflect.apply(prepare, database, ["SELECT 1"]); + if (statement === null || typeof statement !== "object") return; + patchStatementPrototype(statement); + primedPrepares.add(prepare); + } catch { + // A connection that cannot compile even this is in no state to run + // anything else either, and it may not be the only connection around. + // Leave the prototype to the application's own prepare() call and try + // again on the next one. + } +} + function patchStatementPrototype(statement: object) { const proto: unknown = Object.getPrototypeOf(statement); if (proto === null || typeof proto !== "object" || patchedStatementPrototypes.has(proto)) return; diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts index b813194..bef10d7 100644 --- a/test/betterSqlite3.test.ts +++ b/test/betterSqlite3.test.ts @@ -36,6 +36,26 @@ integrationTest("recording queries when the module is required more than once", ]); }); +integrationTest("recording a transaction started before any prepared statement", () => { + expect(runAppmapNode("transactionFirst.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "BEGIN", + "INSERT INTO people (name) VALUES (?)", + "COMMIT", + ]); +}); + +integrationTest("recording a pragma run after a prepared statement", () => { + expect(runAppmapNode("pragmaAfterPrepare.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "SELECT count(*) AS n FROM people", + "PRAGMA journal_mode", + "PRAGMA user_version", + ]); +}); + function recordedQueries(): string[] { return (readAppmap().events ?? []) .filter((event): event is AppMap.SqlQueryEvent => "sql_query" in event) diff --git a/test/betterSqlite3/pragmaAfterPrepare.js b/test/betterSqlite3/pragmaAfterPrepare.js new file mode 100644 index 0000000..20b272c --- /dev/null +++ b/test/betterSqlite3/pragmaAfterPrepare.js @@ -0,0 +1,20 @@ +const Database = require("better-sqlite3"); + +// pragma() runs its query through a statement of its own. Once the Statement +// prototype has been patched -- which the prepare() below does -- that inner +// statement must not be recorded on top of the pragma itself. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.prepare("SELECT count(*) AS n FROM people").get(); + + db.pragma("journal_mode"); + // The simple form takes a different path inside better-sqlite3: pluck().get() + // rather than all(). + db.pragma("user_version", { simple: true }); + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/transactionFirst.js b/test/betterSqlite3/transactionFirst.js new file mode 100644 index 0000000..2bdb7b4 --- /dev/null +++ b/test/betterSqlite3/transactionFirst.js @@ -0,0 +1,20 @@ +const Database = require("better-sqlite3"); + +// The transaction controller compiles BEGIN, COMMIT and ROLLBACK on the native +// database handle, so they never pass through Database.prototype.prepare. When +// transaction() is the first thing an application does, BEGIN has already run +// by the time the callback prepares anything. It should be recorded anyway. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + + const insertMany = db.transaction((names) => { + for (const name of names) db.prepare("INSERT INTO people (name) VALUES (?)").run(name); + }); + insertMany(["alice"]); + + db.close(); +} + +main(); From 75fdf22e6232271aafc3ae7ea68cdbbc56b0a8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 21 Sep 2026 14:22:29 +0200 Subject: [PATCH 6/8] fix(better-sqlite3): keep the shape of a recorded iterator iterate() handed back a plain object with next, return and Symbol.iterator on it, which dropped everything else the native iterator carries -- notably `statement`, the frozen reference to the statement it came from, and the iterator's own identity. Forward to the native iterator through a proxy instead, dispatching the three methods that do the recording and passing everything else through. Methods reached through it are bound to the native object, which was the reason for the plain object in the first place: it is a native wrapper and cannot be unwrapped from a receiver that is not itself. `constructor` is excluded, being a class rather than a method -- binding renames it. Symbol.iterator returns the proxy rather than what the native one returns: handing for..of the native iterator would bypass recording altogether. The fixture also pins the finished latch: a spent iterator asked for another row, or settled again, records nothing further. Assisted-by: Claude:claude-opus-5[1m] --- src/hooks/betterSqlite3.ts | 73 +++++++++++++++++------------ test/betterSqlite3.test.ts | 9 ++++ test/betterSqlite3/iteratorShape.js | 35 ++++++++++++++ 3 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 test/betterSqlite3/iteratorShape.js diff --git a/src/hooks/betterSqlite3.ts b/src/hooks/betterSqlite3.ts index 4736916..6f11a09 100644 --- a/src/hooks/betterSqlite3.ts +++ b/src/hooks/betterSqlite3.ts @@ -208,36 +208,51 @@ function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { }; const native = iterator; - const proxy: IteratorLike & Iterable = { - next(...args: unknown[]) { - try { - const result = native.next(...args); - if (result.done) finish(); - return result; - } catch (exn: unknown) { - finish({ exception: exn }); - throw exn; - } - }, - return(...args: unknown[]) { - try { - const result = native.return - ? native.return(...args) - : { done: true, value: undefined }; - finish(); - return result; - } catch (exn: unknown) { - // Cleaning up can fail too (better-sqlite3 refuses to release an - // iterator while the connection is busy). The query is over either - // way, but it did not end well. - finish({ exception: exn }); - throw exn; - } - }, - [Symbol.iterator]() { - return this as Iterator; - }, + + const next = (...args: unknown[]): IteratorResult => { + try { + const result = native.next(...args); + if (result.done) finish(); + return result; + } catch (exn: unknown) { + finish({ exception: exn }); + throw exn; + } }; + + const settle = (...args: unknown[]): IteratorResult => { + try { + const result = native.return ? native.return(...args) : { done: true, value: undefined }; + finish(); + return result; + } catch (exn: unknown) { + // Cleaning up can fail too (better-sqlite3 refuses to release an + // iterator while the connection is busy). The query is over either + // way, but it did not end well. + finish({ exception: exn }); + throw exn; + } + }; + + // Everything not recorded here is forwarded to the native iterator, so + // that recording iterate() does not change the shape of what it returns: + // `statement`, which better-sqlite3 freezes onto the iterator, and the + // iterator's identity. Methods are bound to the native object, which + // cannot be unwrapped from a receiver that is not itself. + const proxy: IteratorLike = new Proxy(native, { + get(target, property) { + if (property === "next") return next; + if (property === "return") return settle; + // Handing for..of the native iterator would bypass recording. + if (property === Symbol.iterator) return () => proxy; + + const value: unknown = Reflect.get(target, property, target); + // `constructor` is a class rather than a method: binding it would + // rename it, and it does not need a receiver anyway. + if (property === "constructor" || typeof value !== "function") return value; + return value.bind(target) as unknown; + }, + }); return proxy; }, }); diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts index bef10d7..3fcb79a 100644 --- a/test/betterSqlite3.test.ts +++ b/test/betterSqlite3.test.ts @@ -56,6 +56,15 @@ integrationTest("recording a pragma run after a prepared statement", () => { ]); }); +integrationTest("leaving the shape of an iterator alone", () => { + expect(runAppmapNode("iteratorShape.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "INSERT INTO people (name) VALUES ('alice'), ('bob')", + "SELECT name FROM people ORDER BY id", + ]); +}); + function recordedQueries(): string[] { return (readAppmap().events ?? []) .filter((event): event is AppMap.SqlQueryEvent => "sql_query" in event) diff --git a/test/betterSqlite3/iteratorShape.js b/test/betterSqlite3/iteratorShape.js new file mode 100644 index 0000000..42f2def --- /dev/null +++ b/test/betterSqlite3/iteratorShape.js @@ -0,0 +1,35 @@ +const assert = require("node:assert"); + +const Database = require("better-sqlite3"); + +// Recording iterate() must not change what the iterator looks like to the +// application. better-sqlite3 hands back a StatementIterator carrying a frozen +// reference to the statement it came from, and anything reached through it +// still has to work. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.exec("INSERT INTO people (name) VALUES ('alice'), ('bob')"); + + const statement = db.prepare("SELECT name FROM people ORDER BY id"); + const iterator = statement.iterate(); + + assert.strictEqual(iterator.statement, statement); + assert.strictEqual(iterator.constructor.name, "StatementIterator"); + // for..of asks the iterator for itself, and must get something we record. + assert.strictEqual(iterator[Symbol.iterator](), iterator); + + const names = []; + for (const row of iterator) names.push(row.name); + assert.deepStrictEqual(names, ["alice", "bob"]); + + // Asking a spent iterator for more, or settling it again, is a no-op in + // better-sqlite3 -- and must not record the query a second time either. + assert.strictEqual(iterator.next().done, true); + assert.strictEqual(iterator.return().done, true); + + db.close(); +} + +main(); From 912c4536fd0f3398a045fcf393cb334ba13d3f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 21 Sep 2026 15:10:18 +0200 Subject: [PATCH 7/8] fix(better-sqlite3): only end a query when the iteration really ends Corrects the premise of b17967a. better-sqlite3's connection-busy check runs before it looks at the iterator at all and returns without cleaning up, so a call that fails that way leaves the iteration alive with its rows still to come -- and it is the only way return() can fail. Recording the query as failed there both reported a failure that had not happened and latched the recording shut, so the rest of the query went unrecorded. Ask the statement instead: better-sqlite3 keeps it locked for as long as an iterator holds it, so `busy` says whether the query is over. Finish only when it is; an iterator we cannot ask is treated as over, which keeps the events balanced. An iterator that is abandoned outright still leaves its call event open. That is left alone deliberately, and the reasoning is now next to the code: for..of always settles the iterator, and better-sqlite3 gives the iterator no finalizer, so an application that drops a live one has already stranded the statement and broken db.close() for itself. Assisted-by: Claude:claude-opus-5[1m] --- src/hooks/betterSqlite3.ts | 45 ++++++++++++++++----- test/betterSqlite3.test.ts | 45 +++++++++++++-------- test/betterSqlite3/iterateBusyFailure.js | 38 +++++++++++++++++ test/betterSqlite3/iterateCleanupFailure.js | 30 -------------- 4 files changed, 102 insertions(+), 56 deletions(-) create mode 100644 test/betterSqlite3/iterateBusyFailure.js delete mode 100644 test/betterSqlite3/iterateCleanupFailure.js diff --git a/src/hooks/betterSqlite3.ts b/src/hooks/betterSqlite3.ts index 6f11a09..7ec6016 100644 --- a/src/hooks/betterSqlite3.ts +++ b/src/hooks/betterSqlite3.ts @@ -153,17 +153,37 @@ function createRecordingProxy(proxyTarget: T, sqlOf: SqlO interface IteratorLike { next(...args: unknown[]): IteratorResult; return?(...args: unknown[]): IteratorResult; + // better-sqlite3 freezes the statement being iterated onto the iterator. + statement?: unknown; } function isIteratorLike(obj: unknown): obj is IteratorLike { return obj !== null && typeof obj === "object" && "next" in obj && typeof obj.next === "function"; } +// better-sqlite3 locks a statement for as long as an iterator is holding it, +// and only releases it when the iterator is cleaned up, so `busy` says whether +// there are still rows to come. An iterator we cannot ask is treated as over, +// which is what every other method reports anyway. +function stillIterating({ statement }: IteratorLike): boolean { + if (statement === null || typeof statement !== "object" || !("busy" in statement)) return false; + return statement.busy === true; +} + // iterate() hands rows out lazily, so the query is only finished when the // iterator is exhausted, returned early (a `break` in a for..of), or throws. -// The return event is emitted at that point. The native iterator's methods -// must be called on the native object, not on the proxy, so they are bound -// explicitly rather than reached through the proxy's receiver. +// The return event is emitted at that point. +// +// An iterator that is simply abandoned therefore leaves its call event +// unterminated, and that is left alone on purpose: for..of always settles the +// iterator, and an application that drops a live one has already broken +// itself. better-sqlite3 gives the iterator no finalizer, so the statement it +// holds is never released -- that statement stays unusable and db.close() +// throws from then on, with or without us watching. +// +// The native iterator's methods must be called on the native object, not on +// the proxy, so they are bound explicitly rather than reached through the +// proxy's receiver. function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { return new Proxy(iterate, { apply(target, thisArg, argArray: unknown[]) { @@ -209,14 +229,23 @@ function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { const native = iterator; + // A throw does not always end the iteration. better-sqlite3 refuses to + // touch an iterator while the connection is busy, and that check comes + // before it looks at the iterator at all, so the rows are still to come + // and the query is not over. It only really ends when the statement is + // released, which is what `busy` reports. + const overOn = (exn: unknown) => { + if (!stillIterating(native)) finish({ exception: exn }); + throw exn; + }; + const next = (...args: unknown[]): IteratorResult => { try { const result = native.next(...args); if (result.done) finish(); return result; } catch (exn: unknown) { - finish({ exception: exn }); - throw exn; + return overOn(exn); } }; @@ -226,11 +255,7 @@ function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { finish(); return result; } catch (exn: unknown) { - // Cleaning up can fail too (better-sqlite3 refuses to release an - // iterator while the connection is busy). The query is over either - // way, but it did not end well. - finish({ exception: exn }); - throw exn; + return overOn(exn); } }; diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts index 3fcb79a..a6f1250 100644 --- a/test/betterSqlite3.test.ts +++ b/test/betterSqlite3.test.ts @@ -8,21 +8,20 @@ integrationTest("mapping better-sqlite3 calls", () => { expect(readAppmap()).toMatchSnapshot(); }); -integrationTest("recording an iterator whose cleanup fails", () => { - expect(runAppmapNode("iterateCleanupFailure.js").status).toBe(0); +integrationTest("recording an iterator that outlives a failed call", () => { + expect(runAppmapNode("iterateBusyFailure.js").status).toBe(0); - const events = readAppmap().events ?? []; - const iterated = events.find( - (event): event is AppMap.SqlQueryEvent => - "sql_query" in event && event.sql_query.sql.startsWith("SELECT name"), - ); - assert(iterated); + // The iterated query is still recorded once, and it succeeded: the failure + // was the settle() call, which left the iteration running. + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "INSERT INTO people (name) VALUES ('alice'), ('bob'), ('carol')", + "SELECT name FROM people ORDER BY id", + "SELECT settle() AS done", + ]); - const settled = events.find( - (event): event is AppMap.FunctionReturnEvent => - "parent_id" in event && event.parent_id === iterated.id, - ); - expect(settled?.exceptions).toMatchObject([ + expect(outcomeOf("SELECT name FROM people ORDER BY id")?.exceptions).toBeUndefined(); + expect(outcomeOf("SELECT settle() AS done")?.exceptions).toMatchObject([ { class: "TypeError", message: expect.stringContaining("busy") as string }, ]); }); @@ -66,7 +65,21 @@ integrationTest("leaving the shape of an iterator alone", () => { }); function recordedQueries(): string[] { - return (readAppmap().events ?? []) - .filter((event): event is AppMap.SqlQueryEvent => "sql_query" in event) - .map((event) => event.sql_query.sql); + return queryEvents().map((event) => event.sql_query.sql); +} + +// The event that ends the given query, whether it returned or threw. +function outcomeOf(sql: string): AppMap.FunctionReturnEvent | undefined { + const query = queryEvents().find((event) => event.sql_query.sql === sql); + assert(query, `no query event for ${sql}`); + return (readAppmap().events ?? []).find( + (event): event is AppMap.FunctionReturnEvent => + "parent_id" in event && event.parent_id === query.id, + ); +} + +function queryEvents(): AppMap.SqlQueryEvent[] { + return (readAppmap().events ?? []).filter( + (event): event is AppMap.SqlQueryEvent => "sql_query" in event, + ); } diff --git a/test/betterSqlite3/iterateBusyFailure.js b/test/betterSqlite3/iterateBusyFailure.js new file mode 100644 index 0000000..8c3a1ff --- /dev/null +++ b/test/betterSqlite3/iterateBusyFailure.js @@ -0,0 +1,38 @@ +const assert = require("node:assert"); + +const Database = require("better-sqlite3"); + +// better-sqlite3 refuses to touch an iterator while the connection is busy, +// and that check comes before it looks at the iterator at all: nothing is +// released, and the rows are still to come. A call that fails this way must +// not end the recording of the query -- neither as a failure nor as a +// success -- because the query is still running. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.exec("INSERT INTO people (name) VALUES ('alice'), ('bob'), ('carol')"); + + let iterator; + db.function("settle", () => { + iterator.return(); + return "unreachable"; + }); + + iterator = db.prepare("SELECT name FROM people ORDER BY id").iterate(); + assert.strictEqual(iterator.next().value.name, "alice"); + + // Reached from inside a query of its own, so the connection is busy. + assert.throws(() => db.prepare("SELECT settle() AS done").get(), /busy/); + + // The failed settle left the iteration running: the rest of the rows still + // come through, and only then is the query over. + assert.deepStrictEqual( + [...iterator].map((row) => row.name), + ["bob", "carol"], + ); + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/iterateCleanupFailure.js b/test/betterSqlite3/iterateCleanupFailure.js deleted file mode 100644 index d5234db..0000000 --- a/test/betterSqlite3/iterateCleanupFailure.js +++ /dev/null @@ -1,30 +0,0 @@ -const Database = require("better-sqlite3"); - -// better-sqlite3 refuses to release an iterator while the connection is busy, -// so iterator.return() throws if it is reached from inside a user-defined -// function running as part of another query. Contrived, but it is the only way -// to make cleanup fail: the query being iterated ends there, and it ends badly, -// so it should be recorded as an exception rather than a plain return. -function main() { - const db = new Database(":memory:"); - - db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); - db.exec("INSERT INTO people (name) VALUES ('alice'), ('bob')"); - - let iterator; - db.function("cleanup", () => { - iterator.return(); - return "unreachable"; - }); - - iterator = db.prepare("SELECT name FROM people ORDER BY id").iterate(); - console.log("first row:", iterator.next().value.name); - - try { - db.prepare("SELECT cleanup() AS done").get(); - } catch (error) { - console.log("caught:", error.message); - } -} - -main(); From 54116ee5c19e13d7892301b62e84b8f07250f705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 21 Sep 2026 15:11:00 +0200 Subject: [PATCH 8/8] test(better-sqlite3): cover how a transaction ends The fixtures only ever committed. The transaction controller compiles a statement for every other way a transaction can end too -- ROLLBACK when the callback throws, and SAVEPOINT/RELEASE when one transaction runs inside another -- and none of them go through prepare(), so all of them depend on the Statement patch being primed before the controller is built. They are recorded correctly today. Pin the whole sequence so the priming cannot quietly stop covering them. Assisted-by: Claude:claude-opus-5[1m] --- test/betterSqlite3.test.ts | 22 +++++++++++++ test/betterSqlite3/transactionOutcomes.js | 40 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/betterSqlite3/transactionOutcomes.js diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts index a6f1250..e52c6f8 100644 --- a/test/betterSqlite3.test.ts +++ b/test/betterSqlite3.test.ts @@ -45,6 +45,28 @@ integrationTest("recording a transaction started before any prepared statement", ]); }); +integrationTest("recording how a transaction ends", () => { + expect(runAppmapNode("transactionOutcomes.js").status).toBe(0); + const insert = "INSERT INTO people (name) VALUES (?)"; + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + // A callback that throws rolls back. + "BEGIN", + insert, + "ROLLBACK", + // A transaction nested in another runs on a savepoint. The savepoint's + // name is better-sqlite3's own business, so only the statement is pinned. + "BEGIN", + insert, + expect.stringMatching(/^SAVEPOINT /) as string, + insert, + expect.stringMatching(/^RELEASE /) as string, + "COMMIT", + "SELECT name FROM people ORDER BY id", + ]); + expect(outcomeOf("ROLLBACK")?.exceptions).toBeUndefined(); +}); + integrationTest("recording a pragma run after a prepared statement", () => { expect(runAppmapNode("pragmaAfterPrepare.js").status).toBe(0); expect(recordedQueries()).toEqual([ diff --git a/test/betterSqlite3/transactionOutcomes.js b/test/betterSqlite3/transactionOutcomes.js new file mode 100644 index 0000000..2b26f74 --- /dev/null +++ b/test/betterSqlite3/transactionOutcomes.js @@ -0,0 +1,40 @@ +const assert = require("node:assert"); + +const Database = require("better-sqlite3"); + +// The transaction controller compiles a statement for every way a transaction +// can end, not just BEGIN and COMMIT: a callback that throws rolls back, and a +// transaction nested inside another runs on a savepoint. None of them go +// through prepare(), so all of them depend on the Statement patch being in +// place before the controller is built. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + const insert = db.prepare("INSERT INTO people (name) VALUES (?)"); + + const failing = db.transaction((name) => { + insert.run(name); + throw new Error("changed my mind"); + }); + assert.throws(() => failing("alice"), /changed my mind/); + + const inner = db.transaction((name) => insert.run(name)); + const outer = db.transaction((name) => { + insert.run(name); + inner(`${name} jr`); + }); + outer("bob"); + + assert.deepStrictEqual( + db + .prepare("SELECT name FROM people ORDER BY id") + .all() + .map((row) => row.name), + ["bob", "bob jr"], + ); + + db.close(); +} + +main();