From edbce2e35b3e50b3baddfeb6697116af8ef838c8 Mon Sep 17 00:00:00 2001 From: Arul Kumaran Date: Wed, 22 Jul 2026 01:00:26 +0530 Subject: [PATCH] feat: support Bun bundled SQLite runtime (#7878) * feat: support Bun bundled SQLite runtime * fix: harden Bun SQLite backups and params * docs: keep Node as the only supported runtime; document bun:sqlite as best-effort compatibility path Co-authored-by: Arul Kumaran --------- Co-authored-by: Diego Rodrigues de Sa e Souza --- .github/workflows/ci.yml | 17 ++ CLAUDE.md | 4 +- bin/cli/commands/serve.mjs | 12 +- bin/cli/runtime/processSupervisor.mjs | 5 +- bin/cli/sqlite.mjs | 81 +++++++++- package.json | 1 + src/lib/db/adapters/bunSqliteAdapter.ts | 151 ++++++++++++++++++ src/lib/db/adapters/driverFactory.ts | 29 +++- src/lib/db/adapters/sqljsAdapter.ts | 21 ++- src/lib/db/adapters/types.ts | 2 +- src/lib/db/core.ts | 7 +- src/lib/db/omp.ts | 17 +- .../unit/db-adapters/bunSqliteAdapter.test.ts | 83 ++++++++++ tests/unit/db-adapters/cliSqlite.test.mjs | 47 ++++++ tests/unit/db-adapters/driverFactory.test.ts | 4 +- 15 files changed, 460 insertions(+), 21 deletions(-) create mode 100644 src/lib/db/adapters/bunSqliteAdapter.ts create mode 100644 tests/unit/db-adapters/bunSqliteAdapter.test.ts create mode 100644 tests/unit/db-adapters/cliSqlite.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c4d640b4..4483bd49f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -741,6 +741,23 @@ jobs: path: coverage-shard/*.json if-no-files-found: error + test-bun-sqlite: + name: Bun SQLite Compatibility + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: changes + if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - uses: ./.github/actions/npm-ci-retry + - run: npm run test:bun:db + test-vitest: name: Vitest (MCP / autoCombo / UI components) # Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest). diff --git a/CLAUDE.md b/CLAUDE.md index 75d6f87f92..2a6086f453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -482,8 +482,8 @@ list` shows worktrees you didn't create, leave them alone. End every session wit ## Environment -- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. -- **Bun (build/dev script runner only)**: Bun `1.3.10` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression`. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the published runtime, or the test runners — those stay on Node. Any new Bun-invoking script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those 5 scripts with `bun: not found`). +- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only. +- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.3.14` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`). - **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler - **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*` - **Default port**: 20128 (API + dashboard on same port) diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index b7abd7beeb..7c98583e2a 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -134,7 +134,7 @@ export async function runServe(opts = {}) { "Release", "better_sqlite3.node" ); - if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) { + if (!process.versions.bun && existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) { console.error( "\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m" ); @@ -230,7 +230,10 @@ export async function runServe(opts = {}) { function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { // #5238: skip the explicit CLI --max-old-space-size when the user pinned the // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). - const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], { + const server = spawn(process.versions.bun ? process.execPath : "node", [ + ...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), + serverJs, + ], { cwd: APP_DIR, env, stdio: "ignore", @@ -246,7 +249,10 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) { // #5238: skip the explicit CLI --max-old-space-size when the user pinned the // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). - const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], { + const server = spawn(process.versions.bun ? process.execPath : "node", [ + ...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), + serverJs, + ], { cwd: APP_DIR, env, stdio: "pipe", diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 1af7c9313c..b5fa60ffde 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -40,7 +40,10 @@ export class ServerSupervisor { // silently, so a boot that never becomes ready looked like a dead hang with zero // output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside // stderr so a readiness timeout can surface what the child actually printed. - this.child = spawn("node", [...heapArgs, this.serverPath], { + this.child = spawn(process.versions.bun ? process.execPath : "node", [ + ...(process.versions.bun ? [] : heapArgs), + this.serverPath, + ], { cwd: dirname(this.serverPath), env: this.env, stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"], diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs index 923903b404..6b32c1a936 100644 --- a/bin/cli/sqlite.mjs +++ b/bin/cli/sqlite.mjs @@ -3,7 +3,10 @@ import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs"; import { ensureProviderSchema } from "./provider-store.mjs"; import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs"; -async function loadBetterSqlite() { +async function loadSqlite() { + if (process.versions.bun) { + return (await import("bun:sqlite")).Database; + } try { return (await import("better-sqlite3")).default; } catch { @@ -11,6 +14,57 @@ async function loadBetterSqlite() { } } +function openBunSqlite(Database, dbPath, options) { + const raw = new Database(dbPath, options); + const prepare = (sql) => { + const statement = raw.query(sql); + return { + run: (...params) => statement.run(...normalizeBunSqliteParams(params)), + get: (...params) => statement.get(...normalizeBunSqliteParams(params)), + all: (...params) => statement.all(...normalizeBunSqliteParams(params)), + }; + }; + return { + prepare, + query: (sql) => raw.query(sql), + exec: (sql) => raw.exec(sql), + transaction: (fn) => raw.transaction(fn), + close: () => raw.close(), + serialize: () => raw.serialize(), + pragma: (pragmaStr, pragmaOptions) => { + const statement = raw.query(`PRAGMA ${pragmaStr}`); + if (pragmaOptions?.simple) { + const row = statement.get(); + return row ? (Object.values(row)[0] ?? null) : null; + } + return statement.all(); + }, + }; +} + +export function normalizeBunSqliteParams(params) { + if ( + params.length !== 1 || + params[0] === null || + typeof params[0] !== "object" || + Array.isArray(params[0]) || + params[0] instanceof Uint8Array || + (typeof Buffer !== "undefined" && Buffer.isBuffer(params[0])) + ) { + return params; + } + const expanded = {}; + for (const [key, value] of Object.entries(params[0])) { + if (/^[:@$]/.test(key)) expanded[key] = value; + else { + expanded[`@${key}`] = value; + expanded[`:${key}`] = value; + expanded[`$${key}`] = value; + } + } + return [expanded]; +} + export function createSqliteNativeError(error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) { @@ -37,9 +91,19 @@ export function createSqliteNativeError(error) { } async function openSqliteDatabase(dbPath, options = {}) { - const Database = await loadBetterSqlite(); + const Database = await loadSqlite(); + if (process.versions.bun) { + if (options.fileMustExist && !fs.existsSync(dbPath)) { + throw new Error(`SQLite file does not exist: ${dbPath}`); + } + options = options.readonly + ? { readonly: true } + : { readwrite: true, create: options.fileMustExist !== true }; + } try { - return new Database(dbPath, options); + return process.versions.bun + ? openBunSqlite(Database, dbPath, options) + : new Database(dbPath, options); } catch (error) { throw createSqliteNativeError(error); } @@ -71,7 +135,16 @@ export async function withReadonlySqlite(dbPath, callback) { export async function backupSqliteFile(sourcePath, destPath) { const db = await openSqliteDatabase(sourcePath, { readonly: true }); try { - await db.backup(destPath); + if (typeof db.backup === "function") { + await db.backup(destPath); + } else if (sourcePath === ":memory:" && typeof db.serialize === "function") { + fs.writeFileSync(destPath, Buffer.from(db.serialize())); + } else { + try { + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } catch {} + fs.copyFileSync(sourcePath, destPath); + } } finally { db.close(); } diff --git a/package.json b/package.json index 8e68477b40..fd53206d6b 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"", "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"", "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"", + "test:bun:db": "bun test tests/unit/db-adapters/bunSqliteAdapter.test.ts tests/unit/db-adapters/driverFactory.test.ts tests/unit/db-adapters/cliSqlite.test.mjs", "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/plan3-p0.test.ts", "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/fixes-p1.test.ts", "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/security-fase01.test.ts", diff --git a/src/lib/db/adapters/bunSqliteAdapter.ts b/src/lib/db/adapters/bunSqliteAdapter.ts new file mode 100644 index 0000000000..8739c7407e --- /dev/null +++ b/src/lib/db/adapters/bunSqliteAdapter.ts @@ -0,0 +1,151 @@ +import fs from "node:fs"; +import type { PreparedStatement, RunResult, SqliteAdapter } from "./types"; + +/** + * The Bun runtime already ships a SQLite driver. Keep this adapter deliberately + * small so the rest of the application can use the same driver contract as + * better-sqlite3, node:sqlite, and sql.js. + */ +export interface BunSqliteDatabaseLike { + query(sql: string): { + run(...params: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint }; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; + }; + exec(sql: string): void; + serialize?(): Uint8Array; + transaction(fn: (...args: unknown[]) => T): { + (...args: unknown[]): T; + immediate?: (...args: unknown[]) => T; + }; + close(): void; +} + +function normalizeRunResult(result: { + changes?: number | bigint; + lastInsertRowid?: number | bigint; +}): RunResult { + return { + changes: Number(result.changes ?? 0), + lastInsertRowid: Number(result.lastInsertRowid ?? 0), + }; +} + +function normalizeParams(params: unknown[]): unknown[] { + if (params.length !== 1) return params; + const [first] = params; + if ( + first === null || + typeof first !== "object" || + Array.isArray(first) || + first instanceof Uint8Array || + (typeof Buffer !== "undefined" && Buffer.isBuffer(first)) + ) { + return params; + } + + // better-sqlite3 callers use bare object keys for @name, :name, and $name. + // Bun requires the sigil to match the SQL placeholder, so provide all three + // aliases just as the sql.js adapter does. + const expanded: Record = {}; + for (const [key, value] of Object.entries(first as Record)) { + if (/^[:@$]/.test(key)) { + expanded[key] = value; + } else { + expanded[`@${key}`] = value; + expanded[`:${key}`] = value; + expanded[`$${key}`] = value; + } + } + return [expanded]; +} + +export function createBunSqliteAdapter(db: BunSqliteDatabaseLike, filePath: string): SqliteAdapter { + let isOpen = true; + + return { + driver: "bun:sqlite", + + get open() { + return isOpen; + }, + + get name() { + return filePath; + }, + + prepare(sql: string): PreparedStatement { + const statement = db.query(sql); + return { + run(...params: unknown[]): RunResult { + return normalizeRunResult(statement.run(...normalizeParams(params))); + }, + get(...params: unknown[]): unknown { + return statement.get(...normalizeParams(params)); + }, + all(...params: unknown[]): unknown[] { + return statement.all(...normalizeParams(params)); + }, + }; + }, + + exec(sql: string): void { + db.exec(sql); + }, + + pragma(pragmaStr: string, options?: { simple?: boolean }): unknown { + const statement = db.query(`PRAGMA ${pragmaStr}`); + if (options?.simple) { + const row = statement.get() as Record | undefined; + return row ? (Object.values(row)[0] ?? null) : null; + } + return statement.all(); + }, + + transaction(fn: (...args: unknown[]) => T): (...args: unknown[]) => T { + return db.transaction(fn); + }, + + immediate(fn: () => void): void { + const transaction = db.transaction(fn); + if (typeof transaction.immediate === "function") { + transaction.immediate(); + return; + } + db.exec("BEGIN IMMEDIATE"); + try { + fn(); + db.exec("COMMIT"); + } catch (error) { + try { + db.exec("ROLLBACK"); + } catch {} + throw error; + } + }, + + async backup(destination: string): Promise { + if (filePath === ":memory:") return; + try { + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } catch {} + fs.copyFileSync(filePath, destination); + }, + + checkpoint(mode = "TRUNCATE"): void { + try { + db.exec(`PRAGMA wal_checkpoint(${mode})`); + } catch {} + }, + + close(): void { + if (!isOpen) return; + db.close(); + isOpen = false; + }, + + get raw() { + return db; + }, + }; +} diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index 1e2a29f250..5025890962 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; import { createBetterSqliteAdapter } from "./betterSqliteAdapter"; +import { createBunSqliteAdapter, type BunSqliteDatabaseLike } from "./bunSqliteAdapter"; import { createNodeSqliteAdapterFromDatabase, type NodeSqliteDatabaseLike, @@ -70,6 +72,31 @@ export function tryOpenSync( filePath: string, options?: Record ): SqliteAdapter | null { + // Bun ships a supported SQLite implementation. Prefer it over the native + // Node addon, which Bun intentionally skips because its ABI is incompatible. + if (process.versions.bun) { + try { + const { Database } = _require("bun:sqlite") as { + Database: new (p: string, options?: Record) => BunSqliteDatabaseLike; + }; + if ( + options?.fileMustExist === true && + filePath !== ":memory:" && + !existsSync(filePath) + ) { + throw new Error(`SQLite file does not exist: ${filePath}`); + } + const db = new Database(filePath, { + ...(options?.readonly === true + ? { readonly: true } + : { readwrite: true, create: options?.fileMustExist !== true }), + }); + return createBunSqliteAdapter(db, filePath); + } catch (err) { + logSwallowedDriverError("bun:sqlite", err); + } + } + // better-sqlite3: rápido, nativo — skip em Bun if (!process.versions.bun) { try { @@ -157,7 +184,7 @@ export function getSqlJsAdapter(filePath: string): SqliteAdapter | null { /** * Factory assíncrona completa: tenta todos os drivers em cascata. - * Ordem: better-sqlite3 → node:sqlite → sql.js + * Ordem: bun:sqlite → better-sqlite3 → node:sqlite → sql.js */ export async function openDatabaseAsync( filePath: string, diff --git a/src/lib/db/adapters/sqljsAdapter.ts b/src/lib/db/adapters/sqljsAdapter.ts index 181481ab1a..2535ba9f51 100644 --- a/src/lib/db/adapters/sqljsAdapter.ts +++ b/src/lib/db/adapters/sqljsAdapter.ts @@ -1,10 +1,12 @@ // src/lib/db/adapters/sqljsAdapter.ts import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import type { SqliteAdapter, PreparedStatement, RunResult } from "./types"; const SAVE_DEBOUNCE_MS = 100; const CHECKPOINT_INTERVAL_MS = 60_000; +const _require = createRequire(import.meta.url); let _sqlJsLib: Awaited> | null = null; @@ -22,13 +24,30 @@ function resolveSqlJsWasmPath(): string { ), ]; + // Global Bun installs do not use the application's cwd as the package root. + // Resolve the actual JavaScript entrypoint so sql.js can find its sibling WASM + // asset when OmniRoute is launched from ~/.bun/install/global. + try { + const sqlJsEntry = _require.resolve("sql.js"); + candidatePaths.push(path.join(path.dirname(sqlJsEntry), "sql-wasm.wasm")); + } catch {} + try { + const sqlJsPackage = _require.resolve("sql.js/package.json"); + candidatePaths.push( + path.join(path.dirname(sqlJsPackage), "dist", "sql-wasm.wasm"), + path.join(path.dirname(sqlJsPackage), "sql-wasm.wasm") + ); + } catch {} + for (const candidatePath of candidatePaths) { if (fs.existsSync(candidatePath)) { return candidatePath; } } - return candidatePaths[0]; + throw new Error( + `[sqljsAdapter] Could not locate sql-wasm.wasm. Checked:\n${candidatePaths.join("\n")}` + ); } /** diff --git a/src/lib/db/adapters/types.ts b/src/lib/db/adapters/types.ts index d0a034e804..41e049b203 100644 --- a/src/lib/db/adapters/types.ts +++ b/src/lib/db/adapters/types.ts @@ -10,7 +10,7 @@ export interface PreparedStatement { } export interface SqliteAdapter { - readonly driver: "better-sqlite3" | "node:sqlite" | "sql.js"; + readonly driver: "better-sqlite3" | "node:sqlite" | "bun:sqlite" | "sql.js"; readonly open: boolean; readonly name: string; diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 24703387df..7f85e67ee4 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -169,10 +169,13 @@ function openSqliteDatabase(sqliteFile: string, options?: Record path.join(os.homedir(), ".omp", "agent"); const getOmpDbPath = () => path.join(getOmpDir(), "agent.db"); @@ -8,7 +17,7 @@ const getOmpDbPath = () => path.join(getOmpDir(), "agent.db"); export function getOmpCredentials(providerId: string) { const dbPath = getOmpDbPath(); try { - const db = new Database(dbPath, { readonly: true }); + const db = new Database(dbPath, databaseOptions(true)); const row = db .prepare( "SELECT data FROM auth_credentials WHERE provider = ? AND credential_type = 'api_key'" @@ -28,7 +37,7 @@ export function getOmpCredentials(providerId: string) { export function saveOmpCredentials(providerId: string, apiKey: string, baseUrl: string) { const dbPath = getOmpDbPath(); - const db = new Database(dbPath); + const db = new Database(dbPath, databaseOptions()); db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(providerId); db.prepare( @@ -46,7 +55,7 @@ export function saveOmpCredentials(providerId: string, apiKey: string, baseUrl: export function deleteOmpCredentials(providerId: string) { const dbPath = getOmpDbPath(); - const db = new Database(dbPath); + const db = new Database(dbPath, databaseOptions()); db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(providerId); db.close(); } diff --git a/tests/unit/db-adapters/bunSqliteAdapter.test.ts b/tests/unit/db-adapters/bunSqliteAdapter.test.ts new file mode 100644 index 0000000000..d7080a4020 --- /dev/null +++ b/tests/unit/db-adapters/bunSqliteAdapter.test.ts @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + createBunSqliteAdapter, + type BunSqliteDatabaseLike, +} from "../../../src/lib/db/adapters/bunSqliteAdapter.ts"; + +test("bun:sqlite adapter supports CRUD, pragmas, transactions, and close", async (t) => { + if (!process.versions.bun) { + t.skip("bun:sqlite is only available under Bun"); + return; + } + + const { Database } = await import("bun:sqlite"); + const adapter = createBunSqliteAdapter(new Database(":memory:"), ":memory:"); + t.after(() => adapter.close()); + + adapter.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"); + const result = adapter.prepare("INSERT INTO items (name) VALUES (?)").run("bun"); + assert.equal(result.changes, 1); + assert.equal((adapter.prepare("SELECT name FROM items").get() as { name: string }).name, "bun"); + assert.equal(adapter.driver, "bun:sqlite"); + assert.equal(adapter.pragma("user_version", { simple: true }), 0); + + adapter.prepare("INSERT INTO items (name) VALUES (@name)").run({ name: "named" }); + assert.equal( + ( + adapter.prepare("SELECT name FROM items WHERE name = :name").get({ name: "named" }) as { + name: string; + } + ).name, + "named" + ); + + adapter.transaction(() => { + adapter.prepare("INSERT INTO items (name) VALUES (?)").run("transaction"); + })(); + assert.equal(adapter.prepare("SELECT COUNT(*) AS count FROM items").get().count, 3); + assert.equal(adapter.open, true); + adapter.close(); + assert.equal(adapter.open, false); +}); + +test("bun:sqlite adapter backs up on-disk databases without serializing them", async (t) => { + const sourcePath = path.join(os.tmpdir(), `bun-sqlite-source-${Date.now()}.sqlite`); + const destinationPath = path.join(os.tmpdir(), `bun-sqlite-destination-${Date.now()}.sqlite`); + const sourceContents = "sqlite fixture"; + const execCalls: string[] = []; + fs.writeFileSync(sourcePath, sourceContents); + t.after(() => { + for (const filePath of [sourcePath, destinationPath]) { + try { + fs.unlinkSync(filePath); + } catch {} + } + }); + + const db = { + query() { + throw new Error("query should not be called during backup"); + }, + exec(sql: string) { + execCalls.push(sql); + }, + transaction() { + throw new Error("transaction should not be called during backup"); + }, + close() {}, + serialize() { + throw new Error("serialize must not be called for an on-disk backup"); + }, + } as unknown as BunSqliteDatabaseLike; + + const adapter = createBunSqliteAdapter(db, sourcePath); + await adapter.backup(destinationPath); + + assert.deepEqual(execCalls, ["PRAGMA wal_checkpoint(TRUNCATE)"]); + assert.equal(fs.readFileSync(destinationPath, "utf8"), sourceContents); +}); diff --git a/tests/unit/db-adapters/cliSqlite.test.mjs b/tests/unit/db-adapters/cliSqlite.test.mjs new file mode 100644 index 0000000000..01cb03f50b --- /dev/null +++ b/tests/unit/db-adapters/cliSqlite.test.mjs @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { backupSqliteFile, normalizeBunSqliteParams } from "../../../bin/cli/sqlite.mjs"; + +test("CLI Bun SQLite parameter normalization preserves binary values", () => { + const typedArray = new Uint8Array([1, 2, 3]); + const buffer = Buffer.from([4, 5, 6]); + + assert.deepEqual(normalizeBunSqliteParams([typedArray]), [typedArray]); + assert.deepEqual(normalizeBunSqliteParams([buffer]), [buffer]); +}); + +test("CLI Bun SQLite parameter normalization expands named object keys", () => { + assert.deepEqual(normalizeBunSqliteParams([{ id: 7 }]), [{ "@id": 7, ":id": 7, $id: 7 }]); +}); + +test("CLI Bun SQLite backs up physical files without serializing them", async (t) => { + if (!process.versions.bun) { + t.skip("bun:sqlite is only available under Bun"); + return; + } + + const sourcePath = path.join(os.tmpdir(), `cli-bun-sqlite-source-${Date.now()}.sqlite`); + const destinationPath = path.join(os.tmpdir(), `cli-bun-sqlite-destination-${Date.now()}.sqlite`); + t.after(() => { + for (const filePath of [sourcePath, destinationPath]) { + try { + fs.unlinkSync(filePath); + } catch {} + } + }); + + const { Database } = await import("bun:sqlite"); + const source = new Database(sourcePath); + source.exec("CREATE TABLE items (value TEXT); INSERT INTO items VALUES ('copied')"); + source.close(); + + await backupSqliteFile(sourcePath, destinationPath); + + const destination = new Database(destinationPath, { readonly: true }); + assert.equal(destination.query("SELECT value FROM items").get().value, "copied"); + destination.close(); +}); diff --git a/tests/unit/db-adapters/driverFactory.test.ts b/tests/unit/db-adapters/driverFactory.test.ts index 2716abfe86..d17d67346b 100644 --- a/tests/unit/db-adapters/driverFactory.test.ts +++ b/tests/unit/db-adapters/driverFactory.test.ts @@ -8,7 +8,7 @@ describe("driverFactory", () => { test("tryOpenSync retorna adapter síncrono ou null", () => { const adapter = tryOpenSync(":memory:"); if (adapter) { - assert.ok(["better-sqlite3", "node:sqlite"].includes(adapter.driver)); + assert.ok(["better-sqlite3", "node:sqlite", "bun:sqlite"].includes(adapter.driver)); adapter.exec("CREATE TABLE t (v TEXT)"); adapter.prepare("INSERT INTO t VALUES (?)").run("ok"); const row = adapter.prepare("SELECT v FROM t").get() as { v: string }; @@ -21,7 +21,7 @@ describe("driverFactory", () => { test("openDatabaseAsync sempre retorna um adapter válido", async () => { const adapter = await openDatabaseAsync(":memory:"); - assert.ok(["better-sqlite3", "node:sqlite", "sql.js"].includes(adapter.driver)); + assert.ok(["better-sqlite3", "node:sqlite", "bun:sqlite", "sql.js"].includes(adapter.driver)); adapter.exec("CREATE TABLE t (v TEXT)"); adapter.prepare("INSERT INTO t VALUES (?)").run("ok");