From 58ec1c08ea120a4f8d15e08a65160b1ebe75f266 Mon Sep 17 00:00:00 2001 From: Ryan Gapac Date: Tue, 29 Sep 2026 08:28:28 +0800 Subject: [PATCH 1/3] fix: open the learning database through bun:sqlite when node:sqlite is missing Pi's release binaries are Bun --compile executables, where import("node:sqlite") fails with "No such built-in module: node:sqlite", so holds.db never opened and learning was off for everyone running a release binary. node:sqlite stays the first choice and its path is unchanged. Only when that import fails and the process runs on Bun does src/sqlite-adapter.ts open the file through bun:sqlite, wrapped to the DatabaseSync subset learning uses: get() turns Bun's null miss into undefined, exec()'s result object is dropped, and positional ? parameters pass through (learning binds no named keys, so the $name prefixes never meet). With neither module loading, learning still turns off behind one warning, which now names both modules. tests/sqlite-adapter.test.ts runs everywhere against a fake with Bun's measured shape and, under Bun (npm run test:bun), against real bun:sqlite together with the learning tests. tests/learning-driver.test.ts runs two children on Node: one proves the Node path still picks node:sqlite, the other blocks node:sqlite and proves the single both-modules warning and the NOOP handle. --- CHANGELOG.md | 4 +- package.json | 1 + src/bun-sqlite.d.ts | 35 ++++++++ src/learning.ts | 53 ++++++++++--- src/sqlite-adapter.ts | 110 ++++++++++++++++++++++++++ tests/learning-driver.test.ts | 102 ++++++++++++++++++++++++ tests/sqlite-adapter.test.ts | 145 ++++++++++++++++++++++++++++++++++ 7 files changed, 438 insertions(+), 12 deletions(-) create mode 100644 src/bun-sqlite.d.ts create mode 100644 src/sqlite-adapter.ts create mode 100644 tests/learning-driver.test.ts create mode 100644 tests/sqlite-adapter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5103a72..6f7ba14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ How to keep this current: add the entry in the same pull request as the change, ## Unreleased - +### Fixed + +- Learning features work when Pi runs on Bun. Pi's release binaries (`pi-linux-x64.tar.gz` and the rest) are Bun `--compile` executables in which `import("node:sqlite")` fails with `No such built-in module: node:sqlite`, so `holds.db` never opened and learning was off for every release-binary user. `node:sqlite` is still tried first and is unchanged; only when that import fails and the process runs on Bun does `src/sqlite-adapter.ts` open the database through `bun:sqlite` behind the `DatabaseSync` subset learning uses. With neither module loading, learning stays off behind one warning that now names both modules. ## 0.74.0 diff --git a/package.json b/package.json index 49ae808..a7f28ed 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "node --import tsx --test tests/*.test.ts", + "test:bun": "bun test tests/sqlite-adapter.test.ts tests/learning.test.ts", "test:live": "node --env-file-if-exists=.env scripts/live-smoke.mjs", "dev:pi": "node scripts/dev-pi.mjs", "preview": "node scripts/render-preview.mjs", diff --git a/src/bun-sqlite.d.ts b/src/bun-sqlite.d.ts new file mode 100644 index 0000000..fdcc67f --- /dev/null +++ b/src/bun-sqlite.d.ts @@ -0,0 +1,35 @@ +/** + * Local types for the optional `bun:sqlite` fallback, so the TypeScript build needs no new + * dependency. Only the members `src/sqlite-adapter.ts` touches are declared; Bun ships its own + * types with its runtime. Measured on Bun 1.4.2: `exec()` answers a result object instead of + * void, `get()` answers `null` for a miss instead of undefined, and `prepare()` has no + * `pragma()` counterpart (PRAGMAs go through `exec()`). + */ +declare module "bun:sqlite" { + export interface StatementResultingChanges { + changes: number | bigint; + lastInsertRowid: number | bigint; + } + + export class Statement { + run(...params: unknown[]): StatementResultingChanges; + get(...params: unknown[]): Record | null; + all(...params: unknown[]): Record[]; + } + + export interface DatabaseOptions { + /** Refuse to create a file that does not exist. Defaults to `true`. */ + create?: boolean; + /** Open read-only. */ + readonly?: boolean; + /** Require named parameters without the `$` prefix. Defaults to `false`, which accepts both. */ + strict?: boolean; + } + + export class Database { + constructor(path: string, options?: DatabaseOptions); + exec(sql: string): unknown; + prepare(sql: string): Statement; + close(): void; + } +} diff --git a/src/learning.ts b/src/learning.ts index 024ed6a..1b4ccf8 100644 --- a/src/learning.ts +++ b/src/learning.ts @@ -4,15 +4,18 @@ import { createHash } from "crypto"; import { mkdirSync } from "fs"; import { dirname, join } from "path"; -import type { DatabaseSync } from "node:sqlite"; +import { isBunRuntime, openBunSqlite, SqliteUnavailableError } from "./sqlite-adapter.js"; +import type { SqliteDb, SqliteDriver } from "./sqlite-adapter.js"; import type { HostDirs } from "./host-dirs.js"; import { defaultHostDirs } from "./host-dirs.js"; import { userConfigPath } from "./config.js"; import { redact } from "./redact.js"; import type { CallScores } from "./holds.js"; -const dbs = new Map>(); +const dbs = new Map>(); let sqliteAvailable: boolean | undefined; +/** Which module opened the learning database; undefined while learning is off. */ +let driver: SqliteDriver | undefined; // --- Schema (shared constant) --- @@ -46,20 +49,42 @@ const NOOP_DB = { exec() {}, prepare() { return { run() { return { changes: 0, lastInsertRowid: 0 }; }, get() { return undefined; }, all() { return []; } }; }, pragma() {}, -} as unknown as DatabaseSync; +} as unknown as SqliteDb; + +/** Learning stays off: one warning names both candidate modules so the report says what to look at. */ +function disableLearning(detail: unknown): SqliteDb { + sqliteAvailable = false; + console.warn("pi-warden: node:sqlite unavailable, bun:sqlite unavailable, learning features disabled:", detail); + return NOOP_DB; +} + +/** Pi's release binaries are Bun --compile executables, where node:sqlite may not be a built-in + * module while bun:sqlite always is. On Bun the database opens through the adapter; with neither + * module loading, learning stays off behind one warning. */ +async function openBunFallback(dbPath: string, nodeFailure: unknown): Promise { + if (!isBunRuntime()) return disableLearning(nodeFailure); + try { + const db = await openBunSqlite(dbPath); + sqliteAvailable = true; + driver = "bun:sqlite"; + return db; + } catch (err) { + if (err instanceof SqliteUnavailableError) return disableLearning(err); + // bun:sqlite did load (this is Bun), so only this path failed, as on the node:sqlite path. + sqliteAvailable = true; + console.warn(`pi-warden: could not open ${dbPath}:`, err); + return NOOP_DB; + } +} /** Open one connection; a failure is remembered per path so later calls neither warn again nor retry forever. */ -async function openDb(dbPath: string): Promise { - let opened: DatabaseSync | undefined; +async function openDb(dbPath: string): Promise { + let opened: SqliteDb | undefined; try { // Static import cannot work: node:sqlite is flagged experimental and loads lazily so a missing // or broken build of it disables learning features instead of failing the whole process. const sqlite = await import("node:sqlite").catch(err => ({ importFailed: err as unknown })); - if ("importFailed" in sqlite) { - sqliteAvailable = false; - console.warn("pi-warden: node:sqlite unavailable, learning features disabled:", sqlite.importFailed); - return NOOP_DB; - } + if ("importFailed" in sqlite) return await openBunFallback(dbPath, sqlite.importFailed); const { DatabaseSync } = sqlite; sqliteAvailable = true; // DatabaseSync does not create parent directories; on a fresh machine the folder may not exist yet. @@ -67,6 +92,7 @@ async function openDb(dbPath: string): Promise { opened = new DatabaseSync(dbPath); opened.exec("PRAGMA journal_mode = WAL"); opened.exec("PRAGMA busy_timeout = 10000"); + driver = "node:sqlite"; return opened; } catch (err) { // Import failures are handled above; this is a path open or PRAGMA failure for this path only. @@ -79,7 +105,7 @@ async function openDb(dbPath: string): Promise { } /** One connection per resolved path; concurrent first calls share one open. */ -async function getDb(dirs: HostDirs = defaultHostDirs()): Promise { +async function getDb(dirs: HostDirs = defaultHostDirs()): Promise { const dbPath = process.env.PI_WARDEN_DB ?? join(dirname(userConfigPath(dirs)), "holds.db"); if (sqliteAvailable === false) return NOOP_DB; let opening = dbs.get(dbPath); @@ -87,6 +113,11 @@ async function getDb(dirs: HostDirs = defaultHostDirs()): Promise return opening; } +/** Which module opened the learning database: "node:sqlite", "bun:sqlite", or undefined while learning is off. */ +export function sqliteDriver(): SqliteDriver | undefined { + return driver; +} + export async function initSchema(retentionDays = 365, dirs: HostDirs = defaultHostDirs()): Promise { try { const d = await getDb(dirs); diff --git a/src/sqlite-adapter.ts b/src/sqlite-adapter.ts new file mode 100644 index 0000000..63bb731 --- /dev/null +++ b/src/sqlite-adapter.ts @@ -0,0 +1,110 @@ +// src/sqlite-adapter.ts - bun:sqlite fallback for the learning database +// +// node:sqlite stays the first choice and is not touched here. Pi's release binaries are Bun +// --compile executables in which node:sqlite may not be a built-in module, so learning needs a +// second way in: bun:sqlite, wrapped to answer like the DatabaseSync subset learning calls. One +// module owns the difference, so src/learning.ts keeps a single code path for both drivers. + +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +/** The driver that opened the learning database. */ +export type SqliteDriver = "node:sqlite" | "bun:sqlite"; + +/** Bindable values. Learning binds only positional `?` placeholders, never named keys, so the + * named-parameter prefixes never meet: Bun's default (non-strict) mode already accepts both a + * bare `name` and a `$name` key, and the adapter does not open in strict mode. */ +export type SqliteValue = null | number | bigint | string | ArrayBufferView; + +export type SqliteRow = Record; + +export interface SqliteRunResult { + changes: number | bigint; + lastInsertRowid: number | bigint; +} + +export interface SqliteStatement { + run(...params: SqliteValue[]): SqliteRunResult; + get(...params: SqliteValue[]): SqliteRow | undefined; + all(...params: SqliteValue[]): SqliteRow[]; +} + +/** The DatabaseSync subset learning uses: schema and PRAGMAs through exec, rows through prepare. */ +export interface SqliteDb { + exec(sql: string): void; + prepare(sql: string): SqliteStatement; + close(): void; +} + +/** bun:sqlite's statement shape, as the adapter reads it. */ +export interface BunSqliteStatement { + run(...params: unknown[]): SqliteRunResult; + get(...params: unknown[]): SqliteRow | null; + all(...params: unknown[]): SqliteRow[]; +} + +/** bun:sqlite's handle shape, as the adapter reads it. */ +export interface BunSqliteHandle { + exec(sql: string): unknown; + prepare(sql: string): BunSqliteStatement; + close(): void; +} + +/** bun:sqlite could not be loaded. The caller reports "neither module" and learning stays off. */ +export class SqliteUnavailableError extends Error { + constructor(moduleName: string, cause: unknown) { + super(`${moduleName} could not be loaded`, { cause }); + } +} + +/** True when this process is Bun, the only runtime where bun:sqlite can be loaded. */ +export function isBunRuntime(): boolean { + return typeof process.versions.bun === "string" || typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; +} + +/** + * Open the learning database through bun:sqlite. Throws SqliteUnavailableError when the module + * does not load; any other error is a path open or PRAGMA failure for this path only, and the + * half-open handle is closed first, as the node:sqlite path does. + */ +export async function openBunSqlite(dbPath: string): Promise { + let bunSqlite: typeof import("bun:sqlite"); + try { + bunSqlite = await import("bun:sqlite"); + } catch (err) { + throw new SqliteUnavailableError("bun:sqlite", err); + } + // bun:sqlite does not create parent directories either; on a fresh machine the folder is missing. + mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 }); + const db = wrapBunDatabase(new bunSqlite.Database(dbPath)); + try { + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA busy_timeout = 10000"); + } catch (err) { + try { db.close(); } catch { /* already closed */ } + throw err; + } + return db; +} + +/** Present a bun:sqlite handle as the DatabaseSync subset learning uses. */ +export function wrapBunDatabase(db: BunSqliteHandle): SqliteDb { + return { + exec(sql) { + db.exec(sql); + }, + prepare(sql) { + const stmt = db.prepare(sql); + return { + run: (...params) => stmt.run(...params), + // Bun answers a miss with null where node:sqlite answers undefined. Learning reads no row + // as undefined, so the adapter hands back the node answer. + get: (...params) => stmt.get(...params) ?? undefined, + all: (...params) => stmt.all(...params), + }; + }, + close() { + db.close(); + }, + }; +} diff --git a/tests/learning-driver.test.ts b/tests/learning-driver.test.ts new file mode 100644 index 0000000..ddcb3c8 --- /dev/null +++ b/tests/learning-driver.test.ts @@ -0,0 +1,102 @@ +/** + * Which driver the learning database actually opens with, observed from a child process so the + * module-level connection cache and warnings start fresh. Both children run on Node: the Bun + * runtime is covered by tests/sqlite-adapter.test.ts, and Bun cannot be made to refuse node:sqlite + * here (Bun 1.4.2 loads it even from a --compile binary), so this file's children only run under + * Node, where `npm test` runs them. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); + +const hookSource = ` +export async function resolve(specifier, context, next) { + if (specifier === "node:sqlite") throw new Error("blocked node:sqlite for this test"); + return next(specifier, context); +} +`; + +const recordHoldCall = (projectRoot: string) => `await learning.recordHold({ + timestamp: Date.now(), + projectRoot: ${JSON.stringify(projectRoot)}, + tool: "bash", + commandPreview: "npm test", + scores: { irreversible: 0.5, reasons: ["irreversible 0.5"] }, + level: "allow", + held: true, + reasons: ["irreversible 0.5"], +});`; + +/** Run a child that loads learning.ts and prints one parsed RESULT line. */ +async function childResult(child: string, temp: string): Promise<{ stdout: string; stderr: string; data: Record }> { + const { stdout, stderr } = await run(process.execPath, ["--import", "tsx", "--input-type=module", "-e", child], { + cwd: root, + env: { ...process.env, PI_WARDEN_DB: join(temp, "holds.db"), PI_CODING_AGENT_DIR: temp }, + encoding: "utf8", + }); + const line = stdout.split("\n").find(row => row.startsWith("RESULT ")); + assert.ok(line, "child printed a result line; stdout was: " + stdout); + return { stdout, stderr, data: JSON.parse(line.slice("RESULT ".length)) as Record }; +} + +const onBun = typeof process.versions.bun === "string"; + +if (!onBun) { + test("the Node path opens the learning database with node:sqlite", async () => { + const temp = mkdtempSync(join(tmpdir(), "pi-warden-driver-")); + try { + const child = ` +const learning = await import("./src/learning.ts"); +await learning.initSchema(0); +const id = ${recordHoldCall("/driver/node/project")} +const rows = await learning.queryHoldsForProject("/driver/node/project"); +console.log("RESULT " + JSON.stringify({ driver: learning.sqliteDriver() ?? null, id, rows: rows.length })); +`; + const { data } = await childResult(child, temp); + assert.equal(data.driver, "node:sqlite", "node:sqlite is tried first and loads on Node"); + assert.ok((data.id as number) > 0, "recordHold returned a real id, not the NOOP id"); + assert.equal(data.rows, 1, "the row was written to SQLite"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + test("with node:sqlite unresolvable and no Bun, learning turns off behind one warning naming both modules", async () => { + const temp = mkdtempSync(join(tmpdir(), "pi-warden-driver-")); + try { + const child = ` +import { register } from "node:module"; +register("data:text/javascript," + encodeURIComponent(${JSON.stringify(hookSource)})); +const warnings = []; +const originalWarn = console.warn; +console.warn = (...args) => warnings.push(args.map(String).join(" ")); +const learning = await import("./src/learning.ts"); +await learning.initSchema(0); +const id = ${recordHoldCall("/driver/blocked/project")} +const rows = await learning.queryHoldsForProject("/driver/blocked/project"); +console.warn = originalWarn; +console.log("RESULT " + JSON.stringify({ driver: learning.sqliteDriver() ?? null, id, rows: rows.length, warnings })); +`; + const { stderr, data } = await childResult(child, temp); + const warnings = data.warnings as string[]; + assert.equal(warnings.length, 1, "exactly one warning, from the failed open"); + assert.match(warnings[0]!, /node:sqlite/, "the warning names node:sqlite"); + assert.match(warnings[0]!, /bun:sqlite/, "the warning names bun:sqlite"); + assert.match(warnings[0]!, /learning features disabled/, "the warning says learning is off"); + assert.equal(stderr.includes("pi-warden:"), false, "nothing else warned to stderr"); + assert.equal(data.driver, null, "no driver opened"); + assert.equal(data.id, 0, "recordHold answers the NOOP id instead of failing"); + assert.equal(data.rows, 0, "queries answer nothing instead of failing"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +} diff --git a/tests/sqlite-adapter.test.ts b/tests/sqlite-adapter.test.ts new file mode 100644 index 0000000..e890093 --- /dev/null +++ b/tests/sqlite-adapter.test.ts @@ -0,0 +1,145 @@ +/** + * The bun:sqlite adapter: the DatabaseSync subset learning uses, behind Bun's API shape. + * + * The shape below was measured on Bun 1.4.2 with a real `bun:sqlite` database: `exec()` answers a + * result object instead of void, `get()` answers `null` for a miss instead of undefined, `run()` + * answers `{ changes, lastInsertRowid }` as numbers, and there is no `pragma()` method, so PRAGMAs + * go through `exec()`. The fake runs everywhere; the `bun:sqlite` module itself is exercised only + * when the suite runs under Bun (`npm run test:bun`). + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isBunRuntime, openBunSqlite, wrapBunDatabase, SqliteUnavailableError } from "../src/sqlite-adapter.js"; +import { HOLDS_SCHEMA } from "../src/learning.js"; + +const onBun = typeof process.versions.bun === "string"; + +class FakeBunStatement { + readonly bound: unknown[][] = []; + constructor(private readonly rows: Record[], private readonly changes = 1, private readonly lastInsertRowid = 7) {} + run(...params: unknown[]) { this.bound.push(params); return { changes: this.changes, lastInsertRowid: this.lastInsertRowid }; } + get(...params: unknown[]) { this.bound.push(params); return this.rows[0] ?? null; } + all(...params: unknown[]) { this.bound.push(params); return this.rows; } +} + +class FakeBunDatabase { + readonly executed: string[] = []; + readonly statements = new Map(); + closed = false; + constructor(private readonly rows: Record[] = []) {} + exec(sql: string) { this.executed.push(sql); return { changes: 0, lastInsertRowid: 0 }; } + prepare(sql: string) { + let statement = this.statements.get(sql); + if (!statement) this.statements.set(sql, statement = new FakeBunStatement(this.rows)); + return statement; + } + close() { this.closed = true; } +} + +/** The adapter on a fake handle with Bun's shape. */ +function adapt(rows: Record[] = []): { db: ReturnType; fake: FakeBunDatabase } { + const fake = new FakeBunDatabase(rows); + return { db: wrapBunDatabase(fake), fake }; +} + +test("exec runs Bun's SQL and answers nothing, like node:sqlite", () => { + const { db, fake } = adapt(); + const returned = db.exec("PRAGMA busy_timeout = 10000"); + assert.equal(returned, undefined, "exec answers void even though Bun answers a result object"); + assert.deepEqual(fake.executed, ["PRAGMA busy_timeout = 10000"], "the SQL reaches Bun unchanged"); +}); + +test("get turns Bun's null miss into undefined", () => { + const { db } = adapt(); + assert.equal(db.prepare("SELECT id FROM holds WHERE id = ?").get(999), undefined, "a miss answers undefined, as node:sqlite does"); +}); + +test("get passes Bun's row through", () => { + const { db } = adapt([{ id: 3, held: 1, outcome: "approved" }]); + assert.deepEqual(db.prepare("SELECT id, held, outcome FROM holds WHERE id = ?").get(3), { id: 3, held: 1, outcome: "approved" }); +}); + +test("all passes Bun's rows through", () => { + const { db } = adapt([{ id: 1 }, { id: 2 }]); + assert.deepEqual(db.prepare("SELECT id FROM holds ORDER BY id").all(), [{ id: 1 }, { id: 2 }], "an array of rows, empty array for no rows"); + assert.deepEqual(adapt().db.prepare("SELECT id FROM holds").all(), [], "no rows answers an empty array"); +}); + +test("run keeps Bun's changes and lastInsertRowid usable", () => { + const { db } = adapt(); + const result = db.prepare("INSERT INTO holds (timestamp) VALUES (?)").run(Date.now()); + assert.deepEqual(result, { changes: 1, lastInsertRowid: 7 }, "the same keys node:sqlite answers"); + assert.ok(result.changes > 0, "initSchema reads changes as a number"); + assert.equal(Number(result.lastInsertRowid), 7, "recordHold reads Number(lastInsertRowid)"); +}); + +test("positional parameters reach Bun's statement unchanged", () => { + const { db, fake } = adapt(); + const sql = "SELECT id FROM holds WHERE project_root = ? AND held = ?"; + db.prepare(sql).all("/test/project", 1); + assert.deepEqual(fake.statements.get(sql)?.bound, [["/test/project", 1]], "learning binds only positional ? placeholders"); +}); + +test("close delegates to Bun's handle", () => { + const { db, fake } = adapt(); + db.close(); + assert.equal(fake.closed, true, "Bun's close() is called"); +}); + +// On Bun there is nothing to refuse: bun:sqlite loads there, so this half runs on Node only. +if (!onBun) { + test("the adapter refuses to open on a runtime without bun:sqlite", async () => { + assert.equal(isBunRuntime(), false, "Node does not report itself as Bun"); + const dir = mkdtempSync(join(tmpdir(), "pi-warden-nobun-")); + try { + const file = join(dir, "nested", "holds.db"); + await assert.rejects( + () => openBunSqlite(file), + (err: unknown) => err instanceof SqliteUnavailableError && /bun:sqlite/.test((err as Error).message), + "openBunSqlite reports the module it could not load", + ); + assert.equal(existsSync(join(dir, "nested")), false, "the import fails before anything is written to disk"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +} + +if (onBun) { + test("this runtime is Bun", () => { + assert.equal(isBunRuntime(), true, "process.versions.bun is set"); + }); + + test("openBunSqlite opens a real database with the schema, pragmas, and rows learning needs", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-warden-bun-")); + try { + const file = join(dir, "nested", "holds.db"); + const db = await openBunSqlite(file); + assert.ok(existsSync(file), "the adapter created the missing parent directory"); + db.exec(HOLDS_SCHEMA); + + const mode = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string }; + assert.equal(mode.journal_mode, "wal", "journal_mode = WAL is set, as on the node:sqlite path"); + const busy = db.prepare("PRAGMA busy_timeout").get() as { timeout: number }; + assert.equal(busy.timeout, 10000, "busy_timeout = 10000 is set, as on the node:sqlite path"); + + const inserted = db.prepare( + "INSERT INTO holds (timestamp, project_root, tool, signature_hash, scores, level, held, reasons) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ).run(Date.now(), "/bun/project", "bash", "hash", JSON.stringify({ irreversible: 0.5, reasons: [] }), "confirm", 1, JSON.stringify(["irreversible 0.5"])); + const id = Number(inserted.lastInsertRowid); + assert.ok(id > 0, "recordHold's id path works"); + + assert.deepEqual(db.prepare("SELECT tool, held FROM holds WHERE id = ?").get(id), { tool: "bash", held: 1 }, "row values match node:sqlite's"); + assert.equal(db.prepare("SELECT tool FROM holds WHERE id = ?").get(999_999), undefined, "a miss answers undefined through the adapter"); + assert.equal(db.prepare("SELECT tool FROM holds WHERE id > ?").all(999_999).length, 0, "no rows answers an empty array"); + assert.equal(db.prepare("SELECT COUNT(*) AS total FROM holds").get()?.total, 1, "aggregate queries answer a row, as holdStats reads them"); + + db.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +} From 751aa026b4865ceac5d6047cea20e2fb4e7283a3 Mon Sep 17 00:00:00 2001 From: Ryan Gapac Date: Tue, 29 Sep 2026 08:32:23 +0800 Subject: [PATCH 2/3] docs: fix the bun:sqlite create option comment Bun's `create` option creates the file when it is missing; it defaults to true, so the old comment described the opposite of what the flag does. --- src/bun-sqlite.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bun-sqlite.d.ts b/src/bun-sqlite.d.ts index fdcc67f..068be03 100644 --- a/src/bun-sqlite.d.ts +++ b/src/bun-sqlite.d.ts @@ -18,7 +18,7 @@ declare module "bun:sqlite" { } export interface DatabaseOptions { - /** Refuse to create a file that does not exist. Defaults to `true`. */ + /** Create the file when it does not exist. Defaults to `true`. */ create?: boolean; /** Open read-only. */ readonly?: boolean; From ce0f15cf4e03ffb7393eec9f36a58abe24c139b3 Mon Sep 17 00:00:00 2001 From: Ryan Gapac Date: Tue, 29 Sep 2026 08:34:14 +0800 Subject: [PATCH 3/3] chore: version bump to 0.74.1, finalize CHANGELOG --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7ba14..3e84713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ How to keep this current: add the entry in the same pull request as the change, ## Unreleased + + +## 0.74.1 + ### Fixed - Learning features work when Pi runs on Bun. Pi's release binaries (`pi-linux-x64.tar.gz` and the rest) are Bun `--compile` executables in which `import("node:sqlite")` fails with `No such built-in module: node:sqlite`, so `holds.db` never opened and learning was off for every release-binary user. `node:sqlite` is still tried first and is unchanged; only when that import fails and the process runs on Bun does `src/sqlite-adapter.ts` open the database through `bun:sqlite` behind the `DatabaseSync` subset learning uses. With neither module loading, learning stays off behind one warning that now names both modules. diff --git a/package.json b/package.json index a7f28ed..379afee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-warden", - "version": "0.74.0", + "version": "0.74.1", "description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.", "type": "module", "license": "MIT",