diff --git a/.agent/notes/vm-friction.md b/.agent/notes/vm-friction.md new file mode 100644 index 000000000..97cfece19 --- /dev/null +++ b/.agent/notes/vm-friction.md @@ -0,0 +1,29 @@ +# VM Friction Log + +Tracks behaviors in the agent-os VM that differ from a standard POSIX/Node.js system. + +--- + +## `node:sqlite` not available on Bun + +**Deviation**: `node:sqlite` is a Node.js-only experimental built-in (requires Node >= 22.5.0). Bun provides `bun:sqlite` instead with a different API surface. + +**Root cause**: The VM's SQLite bindings proxy host-side SQLite into the VM. The original implementation hard-coded `require("node:sqlite")` at the module top level, which crashed on any runtime without it. + +**Fix**: `sqlite-bindings.ts` now lazy-loads the SQLite module and auto-selects `node:sqlite` or `bun:sqlite` based on runtime detection (`process.versions.bun`). A `BunStatementAdapter`/`BunDatabaseAdapter` layer normalizes the bun:sqlite API to match the node:sqlite shape. + +**Remaining differences on Bun**: +- `setReadBigInts()` is a no-op (Bun uses `safeIntegers` at DB level). +- `columns()` returns `[{ name }]` only (no `table`/`type`/`database` fields). +- Database constructor options aren't translated (`readOnly` vs `readonly`). +- `get()` return value normalized: Bun returns `null` for no rows, node:sqlite returns `undefined`. Adapter normalizes to `undefined`. + +--- + +## SQLite bindings use temp-file sync + +**Deviation**: When VM code opens a file-backed SQLite database, the kernel VFS file is copied to a host temp directory, opened with host SQLite, and synced back on mutations. This means the database is not truly "in the VM" -- it lives on the host filesystem temporarily. + +**Root cause**: The secure-exec kernel's VFS doesn't support SQLite's file locking and mmap requirements natively. The bindings work around this by proxying through host SQLite. + +**Fix exists**: No. This is a fundamental architecture limitation. A proper fix would be an in-VM SQLite compiled to WASM (the `registry/software/sqlite3` package exists but is for the CLI tool, not the library). diff --git a/.agent/todo/remove-sqlite-bindings.md b/.agent/todo/remove-sqlite-bindings.md index e0498f6b4..f6affb278 100644 --- a/.agent/todo/remove-sqlite-bindings.md +++ b/.agent/todo/remove-sqlite-bindings.md @@ -1,5 +1,19 @@ # Remove sqlite-bindings.ts -The `packages/core/src/sqlite-bindings.ts` file provides SQLite database access inside the VM by proxying to host-side Node.js SQLite. It has pre-existing type errors and the approach (temp files synced between host and VM) is fragile. +The `packages/core/src/sqlite-bindings.ts` file provides SQLite database access inside the VM by proxying to host-side SQLite. The approach (temp files synced between host and VM) is fragile. Consider replacing with a proper in-VM SQLite implementation or removing if no longer needed. + +## Current state (2026-04-03) + +The file was refactored in `fix/lazy-sqlite-bun-compat` to: +- **Lazy-load** the SQLite module on first `AgentOs.create()` instead of at import time (was crashing Bun). +- **Support both runtimes**: `node:sqlite` on Node.js, `bun:sqlite` on Bun via an adapter layer. +- **Promise-cached** module loading (no race conditions on concurrent calls). +- Pre-existing type errors were resolved by introducing internal `SqliteDatabase`/`SqliteStatement`/`SqliteModule` interfaces. + +### Known adapter limitations (bun:sqlite) +- `setReadBigInts()` is a no-op — Bun uses `safeIntegers` at the database level, not per-statement. +- `setAllowBareNamedParameters()` / `setAllowUnknownNamedParameters()` are no-ops — Bun's `strict` mode covers similar ground at the database level. +- `columns()` returns `[{ name }]` only — Bun's `columnNames` string array doesn't include `column`, `table`, `database`, or `type` fields that node:sqlite provides. +- Constructor options aren't translated between runtimes (`readOnly` vs `readonly`). diff --git a/CLAUDE.md b/CLAUDE.md index 879e10645..13ed2ff9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,7 @@ The registry software packages depend on `@rivet-dev/agent-os-registry-types` (i ## Dependencies - **secure-exec** is published on npm as `secure-exec`, `@secure-exec/core`, `@secure-exec/nodejs`, `@secure-exec/v8`, etc. Pinned at `^0.2.1`. +- **SQLite host bindings** — The VM proxies SQLite through the host runtime. On Node.js, requires `node:sqlite` (Node >= 22.5.0 with `--experimental-sqlite`, or Node >= 23.4.0 where it is stable). On Bun, uses `bun:sqlite` (>= 1.0.0) via an adapter layer that normalizes to the `node:sqlite` API shape. The module is lazy-loaded on first `AgentOs.create()` — importing `@rivet-dev/agent-os-core` never eagerly loads SQLite. - **Rivet repo** — A modifiable copy lives at `~/r-aos`. Use this when you need to make changes to the Rivet codebase. - Mount host `node_modules` read-only for agent packages (pi-acp, etc.) @@ -146,6 +147,7 @@ Each agent type needs: - Kernel child_process.spawn can't resolve bare commands from PATH (e.g., `pi`). Use `PI_ACP_PI_COMMAND` env var to point to the `.js` entry directly. The Node runtime resolves `.js`/`.mjs`/`.cjs` file paths as node scripts. - `kernel.readFile()` does NOT see the ModuleAccessFileSystem overlay — read host files directly with `readFileSync` for package.json resolution - Native ELF binaries cannot execute in the VM — the kernel's command resolver only handles `.js`/`.mjs`/`.cjs` scripts and WASM commands. `child_process.spawnSync` returns `{ status: 1, stderr: "ENOENT: command not found" }` for native binaries. +- **bun:sqlite adapter parity gaps** — When running on Bun, the SQLite bindings use an adapter layer with known behavioral differences: `setReadBigInts()` is a no-op (Bun handles big integers at the database level via `safeIntegers`), `columns()` returns `[{ name }]` only (no `table`, `type`, `database`, or `column` fields), and `get()` normalizes Bun's `null`-for-no-rows to `undefined` to match `node:sqlite`. Database constructor options are not translated between runtimes (`readOnly` vs `readonly`). ### Debugging Policy diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 5b3f546c7..9949555fd 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -9,11 +9,11 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { + sep as hostPathSeparator, join, posix as posixPath, relative as relativeHostPath, resolve as resolveHostPath, - sep as hostPathSeparator, } from "node:path"; import { allowAll, @@ -34,8 +34,8 @@ import { import { type ToolKit, validateToolkits } from "./host-tools.js"; import { generateToolReference } from "./host-tools-prompt.js"; import { - startHostToolsServer, type HostToolsServer, + startHostToolsServer, } from "./host-tools-server.js"; import { createShimFilesystem, @@ -92,21 +92,33 @@ export interface AgentRegistryEntry { installed: boolean; } +import { createWasmVmRuntime } from "@rivet-dev/agent-os-posix"; +import { createPythonRuntime } from "@rivet-dev/agent-os-python"; import { createNodeHostNetworkAdapter, createNodeRuntime, } from "@secure-exec/nodejs"; -import { createPythonRuntime } from "@rivet-dev/agent-os-python"; -import { createWasmVmRuntime } from "@rivet-dev/agent-os-posix"; import { AcpClient } from "./acp-client.js"; +import { AGENT_CONFIGS, type AgentConfig, type AgentType } from "./agents.js"; +import { getHostDirBackendMeta } from "./backends/host-dir-backend.js"; import { createBootstrapAwareFilesystem, getBaseEnvironment, getBaseFilesystemEntries, } from "./base-filesystem.js"; +import { CronManager } from "./cron/cron-manager.js"; +import type { ScheduleDriver } from "./cron/schedule-driver.js"; +import { TimerScheduleDriver } from "./cron/timer-driver.js"; +import type { + CronEvent, + CronEventHandler, + CronJob, + CronJobInfo, + CronJobOptions, +} from "./cron/types.js"; import { - snapshotVirtualFilesystem, type FilesystemEntry, + snapshotVirtualFilesystem, } from "./filesystem-snapshot.js"; import { createDefaultRootLowerInput, @@ -117,40 +129,28 @@ import { type RootSnapshotExport, type SnapshotLayerHandle, } from "./layers.js"; -import { AGENT_CONFIGS, type AgentConfig, type AgentType } from "./agents.js"; -import { getHostDirBackendMeta } from "./backends/host-dir-backend.js"; +import { getOsInstructions } from "./os-instructions.js"; import { + processSoftware, type SoftwareInput, type SoftwareRoot, - processSoftware, } from "./packages.js"; -import { CronManager } from "./cron/cron-manager.js"; -import type { ScheduleDriver } from "./cron/schedule-driver.js"; -import { TimerScheduleDriver } from "./cron/timer-driver.js"; -import type { - CronEvent, - CronEventHandler, - CronJob, - CronJobInfo, - CronJobOptions, -} from "./cron/types.js"; -import { getOsInstructions } from "./os-instructions.js"; +import type { JsonRpcRequest, JsonRpcResponse } from "./protocol.js"; import { - Session, - type SessionInitData, type AgentCapabilities, type AgentInfo, type GetEventsOptions, type PermissionReply, + type PermissionRequestHandler, type SequencedEvent, + Session, type SessionConfigOption, type SessionEventHandler, + type SessionInitData, type SessionModeState, - type PermissionRequestHandler, } from "./session.js"; -import type { JsonRpcRequest, JsonRpcResponse } from "./protocol.js"; -import { createStdoutLineIterable } from "./stdout-lines.js"; import { createSqliteBindings } from "./sqlite-bindings.js"; +import { createStdoutLineIterable } from "./stdout-lines.js"; interface HostMountInfo { vmPath: string; @@ -290,7 +290,9 @@ export interface SpawnedProcessInfo { exitCode: number | null; } -function isOverlayMountConfig(config: MountConfig): config is OverlayMountConfig { +function isOverlayMountConfig( + config: MountConfig, +): config is OverlayMountConfig { return "filesystem" in config; } @@ -343,11 +345,11 @@ function isWasmBinaryFile(path: string): boolean { try { const header = readFileSync(path); return ( - header.length >= 4 - && header[0] === 0x00 - && header[1] === 0x61 - && header[2] === 0x73 - && header[3] === 0x6d + header.length >= 4 && + header[0] === 0x00 && + header[1] === 0x61 && + header[2] === 0x73 && + header[3] === 0x6d ); } catch { return false; @@ -392,7 +394,9 @@ function collectBootstrapWasmCommands(commandDirs: string[]): string[] { return commands; } -function collectConfiguredLowerPaths(config?: RootFilesystemConfig): Set { +function collectConfiguredLowerPaths( + config?: RootFilesystemConfig, +): Set { const paths = new Set(); for (const lower of config?.lowers ?? []) { @@ -453,7 +457,9 @@ function createKernelBootstrapLower( }); } - const uniqueCommands = [...new Set(commandNames)].sort((a, b) => a.localeCompare(b)); + const uniqueCommands = [...new Set(commandNames)].sort((a, b) => + a.localeCompare(b), + ); for (const command of uniqueCommands) { const stubPath = `/bin/${command}`; if (existingPaths.has(stubPath)) { @@ -496,22 +502,25 @@ async function createRootFilesystem( } const lowers = await Promise.all( - lowerInputs.map((lower) => rootStore.importSnapshot( - lower.kind === "bundled-base-filesystem" - ? createDefaultRootLowerInput() - : lower, - )), + lowerInputs.map((lower) => + rootStore.importSnapshot( + lower.kind === "bundled-base-filesystem" + ? createDefaultRootLowerInput() + : lower, + ), + ), ); - const rootView = normalizedConfig.mode === "read-only" - ? rootStore.createOverlayFilesystem({ - mode: "read-only", - lowers, - }) - : rootStore.createOverlayFilesystem({ - upper: await rootStore.createWritableLayer(), - lowers, - }); + const rootView = + normalizedConfig.mode === "read-only" + ? rootStore.createOverlayFilesystem({ + mode: "read-only", + lowers, + }) + : rootStore.createOverlayFilesystem({ + upper: await rootStore.createWritableLayer(), + lowers, + }); if (normalizedConfig.mode === "read-only") { return { @@ -540,32 +549,35 @@ async function resolveMounts( return []; } - return Promise.all(mounts.map(async (mount) => { - if (!isOverlayMountConfig(mount)) { + return Promise.all( + mounts.map(async (mount) => { + if (!isOverlayMountConfig(mount)) { + return { + path: mount.path, + fs: mount.driver, + readOnly: mount.readOnly, + }; + } + + const mode = mount.filesystem.mode ?? "ephemeral"; + const fs = + mode === "read-only" + ? mount.filesystem.store.createOverlayFilesystem({ + mode: "read-only", + lowers: mount.filesystem.lowers, + }) + : mount.filesystem.store.createOverlayFilesystem({ + upper: await mount.filesystem.store.createWritableLayer(), + lowers: mount.filesystem.lowers, + }); + return { path: mount.path, - fs: mount.driver, - readOnly: mount.readOnly, + fs, + readOnly: mode === "read-only", }; - } - - const mode = mount.filesystem.mode ?? "ephemeral"; - const fs = mode === "read-only" - ? mount.filesystem.store.createOverlayFilesystem({ - mode: "read-only", - lowers: mount.filesystem.lowers, - }) - : mount.filesystem.store.createOverlayFilesystem({ - upper: await mount.filesystem.store.createWritableLayer(), - lowers: mount.filesystem.lowers, - }); - - return { - path: mount.path, - fs, - readOnly: mode === "read-only", - }; - })); + }), + ); } export class AgentOs { @@ -625,19 +637,13 @@ export class AgentOs { // Process software descriptors first so the root lower can include the // exact command stubs Secure Exec will register during boot. const processed = processSoftware(options?.software ?? []); - const bootstrapLower = createKernelBootstrapLower( - options?.rootFilesystem, - [ - ...collectBootstrapWasmCommands(processed.commandDirs), - ...NODE_RUNTIME_BOOTSTRAP_COMMANDS, - ...PYTHON_RUNTIME_BOOTSTRAP_COMMANDS, - ], - ); - const { - filesystem, - finishKernelBootstrap, - rootView, - } = await createRootFilesystem(options?.rootFilesystem, bootstrapLower); + const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem, [ + ...collectBootstrapWasmCommands(processed.commandDirs), + ...NODE_RUNTIME_BOOTSTRAP_COMMANDS, + ...PYTHON_RUNTIME_BOOTSTRAP_COMMANDS, + ]); + const { filesystem, finishKernelBootstrap, rootView } = + await createRootFilesystem(options?.rootFilesystem, bootstrapLower); const hostNetworkAdapter = createNodeHostNetworkAdapter(); const moduleAccessCwd = options?.moduleAccessCwd ?? process.cwd(); @@ -708,20 +714,21 @@ export class AgentOs { createWasmVmRuntime( processed.commandDirs.length > 0 ? { - commandDirs: processed.commandDirs, - permissions: processed.commandPermissions, - } + commandDirs: processed.commandDirs, + permissions: processed.commandPermissions, + } : undefined, ), ); await kernel.mount( createNodeRuntime({ - bindings: createSqliteBindings(kernel), + bindings: await createSqliteBindings(kernel), loopbackExemptPorts, moduleAccessCwd, - packageRoots: processed.softwareRoots.length > 0 - ? processed.softwareRoots - : undefined, + packageRoots: + processed.softwareRoots.length > 0 + ? processed.softwareRoots + : undefined, }), ); await kernel.mount(createPythonRuntime()); @@ -853,10 +860,7 @@ export class AgentOs { } /** Subscribe to process exit. Returns an unsubscribe function. */ - onProcessExit( - pid: number, - handler: (exitCode: number) => void, - ): () => void { + onProcessExit(pid: number, handler: (exitCode: number) => void): () => void { const entry = this._processes.get(pid); if (!entry) throw new Error(`Process not found: ${pid}`); // If already exited, call immediately. @@ -935,10 +939,7 @@ export class AgentOs { try { this._assertSafeAbsolutePath(entry.path); // Create parent directories as needed - const parentDir = entry.path.substring( - 0, - entry.path.lastIndexOf("/"), - ); + const parentDir = entry.path.substring(0, entry.path.lastIndexOf("/")); if (parentDir) { await this._mkdirp(parentDir); } @@ -986,10 +987,7 @@ export class AgentOs { } } - async mkdir( - path: string, - options?: { recursive?: boolean }, - ): Promise { + async mkdir(path: string, options?: { recursive?: boolean }): Promise { if (options?.recursive) { return this._mkdirp(path); } @@ -1024,8 +1022,7 @@ export class AgentOs { if (name === "." || name === "..") continue; if (exclude?.has(name)) continue; - const fullPath = - dirPath === "/" ? `/${name}` : `${dirPath}/${name}`; + const fullPath = dirPath === "/" ? `/${name}` : `${dirPath}/${name}`; const s = await this.kernel.stat(fullPath); if (s.isSymbolicLink) { @@ -1072,7 +1069,11 @@ export class AgentOs { ); } - mountFs(path: string, driver: VirtualFileSystem, options?: { readOnly?: boolean }): void { + mountFs( + path: string, + driver: VirtualFileSystem, + options?: { readOnly?: boolean }, + ): void { this._assertSafeAbsolutePath(path); this.kernel.mountFs(path, driver, { readOnly: options?.readOnly }); } @@ -1093,10 +1094,7 @@ export class AgentOs { await this.delete(from, { recursive: true }); } - async delete( - path: string, - options?: { recursive?: boolean }, - ): Promise { + async delete(path: string, options?: { recursive?: boolean }): Promise { this._assertSafeAbsolutePath(path); const s = await this.kernel.stat(path); if (s.isDirectory) { @@ -1188,10 +1186,7 @@ export class AgentOs { if (!relativePath) { return mount.hostPath; } - return join( - mount.hostPath, - ...relativePath.split("/").filter(Boolean), - ); + return join(mount.hostPath, ...relativePath.split("/").filter(Boolean)); } } return null; @@ -1202,9 +1197,7 @@ export class AgentOs { for (const mount of this._hostMounts) { if ( normalizedHostPath === mount.hostPath || - normalizedHostPath.startsWith( - `${mount.hostPath}${hostPathSeparator}`, - ) + normalizedHostPath.startsWith(`${mount.hostPath}${hostPathSeparator}`) ) { const relativePath = relativeHostPath( mount.hostPath, @@ -1243,7 +1236,9 @@ export class AgentOs { return; } - while (Buffer.byteLength(terminal.output, "utf8") > terminal.outputByteLimit) { + while ( + Buffer.byteLength(terminal.output, "utf8") > terminal.outputByteLimit + ) { terminal.output = terminal.output.slice(1); terminal.truncated = true; } @@ -1252,11 +1247,10 @@ export class AgentOs { private async _handleInboundAcpRequest( request: JsonRpcRequest, ): Promise<{ result?: unknown } | null> { - const params = ( + const params = request.params && typeof request.params === "object" ? (request.params as Record) - : {} - ); + : {}; switch (request.method) { case "fs/read_text_file": { @@ -1315,10 +1309,8 @@ export class AgentOs { (entry as { value: string }).value, ]; }) - .filter( - ( - entry, - ): entry is [string, string] => Array.isArray(entry), + .filter((entry): entry is [string, string] => + Array.isArray(entry), ), ) : undefined; @@ -1522,41 +1514,43 @@ export class AgentOs { ...Object.keys(AGENT_CONFIGS), ]); - return [...allIds].map((id) => { - const config = this._resolveAgentConfig(id); - if (!config) return null; - - let installed = false; - try { - // Check package roots first, then CWD-based node_modules. - const vmPrefix = `/root/node_modules/${config.acpAdapter}`; - let hostPkgJsonPath: string | null = null; - for (const root of this._softwareRoots) { - if (root.vmPath === vmPrefix) { - hostPkgJsonPath = join(root.hostPath, "package.json"); - break; + return [...allIds] + .map((id) => { + const config = this._resolveAgentConfig(id); + if (!config) return null; + + let installed = false; + try { + // Check package roots first, then CWD-based node_modules. + const vmPrefix = `/root/node_modules/${config.acpAdapter}`; + let hostPkgJsonPath: string | null = null; + for (const root of this._softwareRoots) { + if (root.vmPath === vmPrefix) { + hostPkgJsonPath = join(root.hostPath, "package.json"); + break; + } } + if (!hostPkgJsonPath) { + hostPkgJsonPath = join( + this._moduleAccessCwd, + "node_modules", + config.acpAdapter, + "package.json", + ); + } + readFileSync(hostPkgJsonPath); + installed = true; + } catch { + // Package not installed } - if (!hostPkgJsonPath) { - hostPkgJsonPath = join( - this._moduleAccessCwd, - "node_modules", - config.acpAdapter, - "package.json", - ); - } - readFileSync(hostPkgJsonPath); - installed = true; - } catch { - // Package not installed - } - return { - id: id as AgentType, - acpAdapter: config.acpAdapter, - agentPackage: config.agentPackage, - installed, - }; - }).filter((entry): entry is AgentRegistryEntry => entry !== null); + return { + id: id as AgentType, + acpAdapter: config.acpAdapter, + agentPackage: config.agentPackage, + installed, + }; + }) + .filter((entry): entry is AgentRegistryEntry => entry !== null); } private _deriveSessionConfigOptions( @@ -1664,8 +1658,8 @@ export class AgentOs { // Create stdout line iterable wired via onStdout callback const { iterable, onStdout } = createStdoutLineIterable(); const launchArgs = [...(config.launchArgs ?? []), ...extraArgs]; - let launchEnv = { ...config.defaultEnv, ...extraEnv, ...options?.env }; - let sessionCwd = options?.cwd ?? "/home/user"; + const launchEnv = { ...config.defaultEnv, ...extraEnv, ...options?.env }; + const sessionCwd = options?.cwd ?? "/home/user"; const binPath = this._resolveAdapterBin(config.acpAdapter); const pid = this.spawn("node", [binPath, ...launchArgs], { streamStdin: true, @@ -1759,24 +1753,18 @@ export class AgentOs { ]; } - const session = new Session( - client, - sessionId, - agentType, - initData, - () => { - for (const [terminalId, terminal] of this._acpTerminals) { - if (terminal.sessionId !== sessionId) { - continue; - } - if (this.getProcess(terminal.pid).exitCode === null) { - this.killProcess(terminal.pid); - } - this._acpTerminals.delete(terminalId); + const session = new Session(client, sessionId, agentType, initData, () => { + for (const [terminalId, terminal] of this._acpTerminals) { + if (terminal.sessionId !== sessionId) { + continue; } - this._sessions.delete(sessionId); - }, - ); + if (this.getProcess(terminal.pid).exitCode === null) { + this.killProcess(terminal.pid); + } + this._acpTerminals.delete(terminalId); + } + this._sessions.delete(sessionId); + }); this._sessions.set(sessionId, session); return { sessionId }; @@ -1817,9 +1805,7 @@ export class AgentOs { } if (!binEntry) { - throw new Error( - `No bin entry found in ${adapterPackage}/package.json`, - ); + throw new Error(`No bin entry found in ${adapterPackage}/package.json`); } return `${vmPrefix}/${binEntry}`; @@ -1870,10 +1856,7 @@ export class AgentOs { /** Send a prompt to the agent and wait for the final response. * Returns the raw JSON-RPC response and the accumulated agent text. */ - async prompt( - sessionId: string, - text: string, - ): Promise { + async prompt(sessionId: string, text: string): Promise { const session = this._requireSession(sessionId); // Collect streamed text while the prompt is running @@ -1981,10 +1964,7 @@ export class AgentOs { } /** Subscribe to session/update notifications for a session. Returns an unsubscribe function. */ - onSessionEvent( - sessionId: string, - handler: SessionEventHandler, - ): () => void { + onSessionEvent(sessionId: string, handler: SessionEventHandler): () => void { const session = this._requireSession(sessionId); session.onSessionEvent(handler); return () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d2b67126d..5ec4f9593 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,9 +1,5 @@ // @rivet-dev/agent-os -export { - createInMemoryFileSystem, - KernelError, -} from "@secure-exec/core"; export type { NetworkAccessRequest, OpenShellOptions, @@ -15,6 +11,10 @@ export type { VirtualFileSystem, VirtualStat, } from "@secure-exec/core"; +export { + createInMemoryFileSystem, + KernelError, +} from "@secure-exec/core"; export type { NotificationHandler } from "./acp-client.js"; export { AcpClient } from "./acp-client.js"; export type { @@ -25,11 +25,11 @@ export type { BatchWriteResult, CreateSessionOptions, DirEntry, - OverlayMountConfig, McpServerConfig, McpServerConfigLocal, McpServerConfigRemote, MountConfig, + OverlayMountConfig, PlainMountConfig, ProcessTreeNode, ReaddirRecursiveOptions, @@ -45,36 +45,10 @@ export type { PrepareInstructionsOptions, } from "./agents.js"; export { AGENT_CONFIGS } from "./agents.js"; -export type { - AgentSoftwareDescriptor, - AnySoftwareDescriptor, - SoftwareContext, - SoftwareDescriptor, - SoftwareInput, - SoftwareRoot, - ToolSoftwareDescriptor, - WasmCommandDirDescriptor, - WasmCommandSoftwareDescriptor, -} from "./packages.js"; -export { defineSoftware } from "./packages.js"; export type { HostDirBackendOptions } from "./backends/host-dir-backend.js"; export { createHostDirBackend } from "./backends/host-dir-backend.js"; export type { OverlayBackendOptions } from "./backends/overlay-backend.js"; export { createOverlayBackend } from "./backends/overlay-backend.js"; -export type { - FilesystemSnapshotExport, - LayerHandle, - LayerStore, - OverlayFilesystemMode, - RootSnapshotExport, - SnapshotImportSource, - SnapshotLayerHandle, - WritableLayerHandle, -} from "./layers.js"; -export { - createInMemoryLayerStore, - createSnapshotExport, -} from "./layers.js"; export type { CronAction, CronEvent, @@ -88,21 +62,52 @@ export type { } from "./cron/index.js"; export { CronManager, TimerScheduleDriver } from "./cron/index.js"; export type { HostTool, ToolExample, ToolKit } from "./host-tools.js"; -export { hostTool, toolKit, validateToolkits, MAX_TOOL_DESCRIPTION_LENGTH } from "./host-tools.js"; -export { generateToolReference } from "./host-tools-prompt.js"; +export { + hostTool, + MAX_TOOL_DESCRIPTION_LENGTH, + toolKit, + validateToolkits, +} from "./host-tools.js"; +export type { FieldInfo } from "./host-tools-argv.js"; export { camelToKebab, getZodDescription, getZodEnumValues, parseArgv, } from "./host-tools-argv.js"; -export type { FieldInfo } from "./host-tools-argv.js"; +export { generateToolReference } from "./host-tools-prompt.js"; export { createShimFilesystem, generateMasterShim, generateToolkitShim, } from "./host-tools-shims.js"; +export type { + FilesystemSnapshotExport, + LayerHandle, + LayerStore, + OverlayFilesystemMode, + RootSnapshotExport, + SnapshotImportSource, + SnapshotLayerHandle, + WritableLayerHandle, +} from "./layers.js"; +export { + createInMemoryLayerStore, + createSnapshotExport, +} from "./layers.js"; export { getOsInstructions } from "./os-instructions.js"; +export type { + AgentSoftwareDescriptor, + AnySoftwareDescriptor, + SoftwareContext, + SoftwareDescriptor, + SoftwareInput, + SoftwareRoot, + ToolSoftwareDescriptor, + WasmCommandDirDescriptor, + WasmCommandSoftwareDescriptor, +} from "./packages.js"; +export { defineSoftware } from "./packages.js"; export type { JsonRpcError, JsonRpcNotification, @@ -129,4 +134,13 @@ export type { SessionMode, SessionModeState, } from "./session.js"; +export type { + SqliteDatabase, + SqliteModule, + SqliteStatement, +} from "./sqlite-bindings.js"; +export { + createSqliteBindings, + createSqliteBindingsFromModule, +} from "./sqlite-bindings.js"; export { createStdoutLineIterable } from "./stdout-lines.js"; diff --git a/packages/core/src/sqlite-bindings.ts b/packages/core/src/sqlite-bindings.ts index bdcec3b46..84b4958ac 100644 --- a/packages/core/src/sqlite-bindings.ts +++ b/packages/core/src/sqlite-bindings.ts @@ -1,45 +1,259 @@ import { Buffer } from "node:buffer"; import { existsSync, - mkdtempSync, mkdirSync, + mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; -import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { - dirname as hostDirname, - join, - posix as posixPath, -} from "node:path"; +import { dirname as hostDirname, join, posix as posixPath } from "node:path"; import type { Kernel } from "@secure-exec/core"; import type { BindingTree } from "@secure-exec/nodejs"; -const require = createRequire(import.meta.url); -const sqliteBuiltin = require("node:sqlite") as { +// --------------------------------------------------------------------------- +// Internal SQLite abstraction – matches the node:sqlite shape consumed below. +// Both node:sqlite and bun:sqlite are adapted to this common interface so the +// binding tree logic is runtime-agnostic. +// --------------------------------------------------------------------------- + +/** Unified database handle (mirrors node:sqlite DatabaseSync). */ +export interface SqliteDatabase { + close(): void; + exec(sql: string): void; + location(): string | null; + prepare(sql: string): SqliteStatement; +} + +/** Unified prepared-statement handle (mirrors node:sqlite StatementSync). */ +export interface SqliteStatement { + run(...params: unknown[]): unknown; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown; + iterate(...params: unknown[]): Iterable; + columns(): unknown; + finalize?(): void; + setReturnArrays(enabled: boolean): void; + setReadBigInts(enabled: boolean): void; + setAllowBareNamedParameters(enabled: boolean): void; + setAllowUnknownNamedParameters(enabled: boolean): void; +} + +/** Module-level SQLite provider. */ +export interface SqliteModule { DatabaseSync: new ( path?: string, options?: Record, - ) => { - close(): void; - exec(sql: string): void; - location(): string | null; - prepare(sql: string): { - run(...params: unknown[]): unknown; - get(...params: unknown[]): unknown; - all(...params: unknown[]): unknown; - iterate(...params: unknown[]): Iterable; - columns(): unknown; - setReturnArrays(enabled: boolean): void; - setReadBigInts(enabled: boolean): void; - setAllowBareNamedParameters(enabled: boolean): void; - setAllowUnknownNamedParameters(enabled: boolean): void; - }; - }; + ) => SqliteDatabase; constants?: Record; -}; +} + +// --------------------------------------------------------------------------- +// Runtime detection +// --------------------------------------------------------------------------- + +/** Returns `true` when running under Bun (detected via `process.versions.bun`). */ +export function isBunRuntime(): boolean { + return typeof process !== "undefined" && !!process.versions?.bun; +} + +function isNodeRuntime(): boolean { + return typeof process !== "undefined" && !!process.versions?.node; +} + +function describeRuntime(): string { + if (isBunRuntime()) return `Bun ${process.versions.bun}`; + if (isNodeRuntime()) return `Node.js ${process.versions.node}`; + return "unknown runtime"; +} + +// --------------------------------------------------------------------------- +// bun:sqlite adapter +// +// Wraps bun:sqlite's Database/Statement to match the node:sqlite +// DatabaseSync/StatementSync shape expected by the binding tree. +// --------------------------------------------------------------------------- + +/** @internal — exported for testing only */ +export function createBunSqliteAdapter( + BunDatabase: any, + bunConstants?: Record, +): SqliteModule { + class BunStatementAdapter implements SqliteStatement { + private _stmt: any; + private _returnArrays = false; + + constructor(stmt: any) { + this._stmt = stmt; + } + + run(...params: unknown[]): unknown { + // bun:sqlite Statement.run() returns { changes, lastInsertRowid } + // which matches the node:sqlite shape — use it directly. + return this._stmt.run(...params); + } + + get(...params: unknown[]): unknown { + if (this._returnArrays) { + const rows = this._stmt.values(...params); + return rows[0] ?? undefined; + } + // bun:sqlite may return null for "no row" while node:sqlite returns + // undefined. Normalize to undefined so the wire format is consistent. + return this._stmt.get(...params) ?? undefined; + } + + all(...params: unknown[]): unknown { + if (this._returnArrays) { + return this._stmt.values(...params); + } + return this._stmt.all(...params); + } + + iterate(...params: unknown[]): Iterable { + // Prefer native iterate() if available (bun:sqlite supports it via + // the @@iterator protocol), otherwise fall back to collecting all rows. + if (typeof this._stmt.iterate === "function") { + return this._stmt.iterate(...params); + } + const rows = this.all(...params); + return Array.isArray(rows) ? rows : []; + } + + columns(): unknown { + // node:sqlite returns [{ name, column, table, ... }]; bun:sqlite + // only exposes an array of column name strings. + const names: string[] = this._stmt.columnNames ?? []; + return names.map((name: string) => ({ name })); + } + + setReturnArrays(enabled: boolean): void { + this._returnArrays = enabled; + } + + finalize(): void { + if (typeof this._stmt.finalize === "function") { + this._stmt.finalize(); + } + } + + // No per-statement equivalents in bun:sqlite — safe no-ops. + setReadBigInts(_enabled: boolean): void {} + setAllowBareNamedParameters(_enabled: boolean): void {} + setAllowUnknownNamedParameters(_enabled: boolean): void {} + } + + class BunDatabaseAdapter implements SqliteDatabase { + private _db: any; + + constructor(path?: string, options?: Record) { + this._db = new BunDatabase(path ?? ":memory:", options); + } + + close(): void { + this._db.close(); + } + + exec(sql: string): void { + this._db.exec(sql); + } + + location(): string | null { + const f = this._db.filename; + // Normalize to null for in-memory databases to match node:sqlite behavior. + return f === ":memory:" || f === "" || f == null ? null : f; + } + + prepare(sql: string): SqliteStatement { + return new BunStatementAdapter(this._db.prepare(sql)); + } + } + + return { + DatabaseSync: BunDatabaseAdapter as unknown as SqliteModule["DatabaseSync"], + constants: bunConstants ?? {}, + }; +} + +async function loadBunSqliteModule(): Promise { + // Dynamic import keeps bun:sqlite out of the Node.js module graph. + // Use a variable so TypeScript does not attempt to resolve the module. + const bunSqliteId = "bun:sqlite"; + const bunSqlite: any = await import(bunSqliteId); + const BunDatabase = bunSqlite.Database ?? bunSqlite.default?.Database; + + if (!BunDatabase) { + const bunVersion = process.versions?.bun ?? "unknown"; + throw new Error( + `bun:sqlite loaded but the Database class was not found (Bun ${bunVersion}). ` + + `agent-os requires Bun >= 1.0.0 with bun:sqlite support. ` + + `Upgrade with: curl -fsSL https://bun.sh/install | bash`, + ); + } + + // Forward bun:sqlite constants (e.g. SQLITE_FCNTL_PERSIST_WAL) so + // VM-side code can use them via the binding tree's meta.constants(). + const bunConstants = + bunSqlite.constants ?? bunSqlite.default?.constants ?? {}; + + return createBunSqliteAdapter(BunDatabase, bunConstants); +} + +// --------------------------------------------------------------------------- +// node:sqlite loader +// --------------------------------------------------------------------------- + +async function loadNodeSqliteModule(): Promise { + try { + const { createRequire } = await import("node:module"); + const esmRequire = createRequire(import.meta.url); + return esmRequire("node:sqlite") as SqliteModule; + } catch (error) { + const nodeVersion = process.versions?.node ?? "unknown"; + throw new Error( + `node:sqlite is not available (Node.js ${nodeVersion} detected). ` + + `agent-os requires Node.js >= 22.5.0 with the --experimental-sqlite flag, ` + + `or Node.js >= 23.4.0 where it is stable. ` + + `Alternatively, use Bun (>= 1.0.0) which provides bun:sqlite natively.`, + { cause: error }, + ); + } +} + +// --------------------------------------------------------------------------- +// Lazy loader (cached per process) +// --------------------------------------------------------------------------- + +let _cachedModulePromise: Promise | null = null; + +/** @internal — exported for testing only */ +export function loadSqliteModule(): Promise { + if (!_cachedModulePromise) { + if (isBunRuntime()) { + _cachedModulePromise = loadBunSqliteModule(); + } else if (isNodeRuntime()) { + _cachedModulePromise = loadNodeSqliteModule(); + } else { + _cachedModulePromise = Promise.reject( + new Error( + `SQLite bindings are not available on ${describeRuntime()}. ` + + `agent-os requires Node.js >= 22.5.0 (with --experimental-sqlite) or Bun >= 1.0.0.`, + ), + ); + } + } + return _cachedModulePromise; +} + +/** @internal — reset the cached module (for testing only) */ +export function _resetSqliteModuleCache(): void { + _cachedModulePromise = null; +} + +// --------------------------------------------------------------------------- +// Value encoding/decoding +// --------------------------------------------------------------------------- type EncodedSqliteValue = | null @@ -132,6 +346,10 @@ function decodeSqliteValue(value: unknown): T { return value as T; } +// --------------------------------------------------------------------------- +// SQL classification helpers +// --------------------------------------------------------------------------- + function isTransactionalSql(sql: string): boolean { return /^\s*(begin|commit|rollback|savepoint|release\s+savepoint)\b/i.test( sql, @@ -147,7 +365,26 @@ function isMutatingSql(sql: string): boolean { ); } -export function createSqliteBindings(kernel: Kernel): BindingTree { +// --------------------------------------------------------------------------- +// Binding tree factory — accepts a pre-loaded module for testability +// --------------------------------------------------------------------------- + +/** + * Create the SQLite binding tree from an already-loaded SQLite module. + * + * This is the injectable/testable variant of {@link createSqliteBindings}. It + * accepts a pre-resolved `SqliteModule` so tests can inject mocks and custom + * backends can supply alternative SQLite implementations (e.g. `better-sqlite3` + * wrapped to match the interface). + * + * @param kernel - The secure-exec kernel (used for VFS sync of file-backed databases). + * @param sqliteModule - A resolved SQLite module conforming to the `SqliteModule` interface. + * @returns A `BindingTree` with the `sqlite` namespace (database, statement, meta). + */ +export function createSqliteBindingsFromModule( + kernel: Kernel, + sqliteModule: SqliteModule, +): BindingTree { let nextDatabaseId = 1; let nextStatementId = 1; const tempRoot = mkdtempSync(join(tmpdir(), "agentos-sqlite-")); @@ -155,7 +392,7 @@ export function createSqliteBindings(kernel: Kernel): BindingTree { const databases = new Map< number, { - db: InstanceType; + db: SqliteDatabase; statementIds: Set; hostPath: string | null; vmPath: string | null; @@ -168,14 +405,21 @@ export function createSqliteBindings(kernel: Kernel): BindingTree { { dbId: number; sql: string; - stmt: ReturnType["prepare"]>; + stmt: SqliteStatement; } >(); function getDatabase(id: number) { const record = databases.get(id); if (!record) { - throw new Error(`sqlite database handle not found: ${id}`); + const openIds = [...databases.keys()]; + throw new Error( + `sqlite database handle ${id} not found. ` + + `The database may have already been closed. ` + + (openIds.length > 0 + ? `Open handles: [${openIds.join(", ")}].` + : `No databases are currently open.`), + ); } return record; } @@ -183,7 +427,15 @@ export function createSqliteBindings(kernel: Kernel): BindingTree { function getStatement(id: number) { const record = statements.get(id); if (!record) { - throw new Error(`sqlite statement handle not found: ${id}`); + const activeIds = [...statements.keys()]; + throw new Error( + `sqlite statement handle ${id} not found. ` + + `The statement may have been finalized, or its parent database was closed ` + + `(closing a database invalidates all its prepared statements). ` + + (activeIds.length > 0 + ? `Active handles: [${activeIds.join(", ")}].` + : `No statements are currently active.`), + ); } return record; } @@ -231,7 +483,7 @@ export function createSqliteBindings(kernel: Kernel): BindingTree { } async function syncDatabase(record: { - db: InstanceType; + db: SqliteDatabase; hostPath: string | null; vmPath: string | null; dirty: boolean; @@ -289,84 +541,92 @@ export function createSqliteBindings(kernel: Kernel): BindingTree { sqlite: { meta: { constants(..._args: unknown[]) { - return encodeSqliteValue(sqliteBuiltin.constants ?? {}); + return encodeSqliteValue(sqliteModule.constants ?? {}); }, }, database: { open(...args: unknown[]) { return (async () => { - const [pathArg, optionsArg] = args; - const path = - typeof pathArg === "string" ? pathArg : undefined; - const normalizedOptions = - optionsArg == null - ? undefined - : (decodeSqliteValue(optionsArg) as Record< - string, - unknown - >); - let db: InstanceType; - const id = nextDatabaseId++; - const vmPath = - path && path !== ":memory:" ? path : null; - const hostPath = - vmPath !== null - ? join(tempRoot, `${id}.sqlite`) - : null; - try { - if (hostPath && vmPath) { - if (await kernel.exists(vmPath)) { - mkdirSync(hostDirname(hostPath), { recursive: true }); - writeFileSync( - hostPath, - Buffer.from(await kernel.readFile(vmPath)), - ); + const [pathArg, optionsArg] = args; + const path = typeof pathArg === "string" ? pathArg : undefined; + const normalizedOptions = + optionsArg == null + ? undefined + : (decodeSqliteValue(optionsArg) as Record); + let db: SqliteDatabase; + const id = nextDatabaseId++; + const vmPath = path && path !== ":memory:" ? path : null; + const hostPath = + vmPath !== null ? join(tempRoot, `${id}.sqlite`) : null; + try { + if (hostPath && vmPath) { + if (await kernel.exists(vmPath)) { + mkdirSync(hostDirname(hostPath), { recursive: true }); + writeFileSync( + hostPath, + Buffer.from(await kernel.readFile(vmPath)), + ); + } + } + db = + normalizedOptions === undefined + ? new sqliteModule.DatabaseSync( + hostPath ?? path ?? ":memory:", + ) + : new sqliteModule.DatabaseSync( + hostPath ?? path ?? ":memory:", + normalizedOptions, + ); + } catch (error) { + const details = + error instanceof Error + ? (error.stack ?? error.message) + : JSON.stringify(error); + const vmDisplay = path ?? ":memory:"; + let hint = ""; + if (details.includes("ENOENT")) { + hint = " The parent directory may not exist."; + } else if ( + details.includes("EACCES") || + details.includes("permission") + ) { + hint = " Permission denied — check that the path is writable."; + } else if (details.includes("not a database")) { + hint = " The file exists but is not a valid SQLite database."; } + throw new Error( + `Failed to open SQLite database "${vmDisplay}": ${details}${hint}`, + ); } - db = normalizedOptions === undefined - ? new sqliteBuiltin.DatabaseSync(hostPath ?? path ?? ":memory:") - : new sqliteBuiltin.DatabaseSync( - hostPath ?? path ?? ":memory:", - normalizedOptions, - ); - } catch (error) { - const details = - error instanceof Error - ? error.stack ?? error.message - : JSON.stringify(error); - throw new Error( - `sqlite database open failed for ${path ?? ":memory:"}: ${details}`, - ); - } - databases.set(id, { - db, - statementIds: new Set(), - hostPath, - vmPath, - dirty: false, - transactionDepth: 0, - }); - return id; + databases.set(id, { + db, + statementIds: new Set(), + hostPath, + vmPath, + dirty: false, + transactionDepth: 0, + }); + return id; })(); }, close(...args: unknown[]) { return (async () => { - const [idArg] = args; - const id = Number(idArg); - await closeDatabase(id); - return null; + const [idArg] = args; + const id = Number(idArg); + await closeDatabase(id); + return null; })(); }, exec(...args: unknown[]) { return (async () => { - const [idArg, sqlArg] = args; - const id = Number(idArg); - const sql = String(sqlArg ?? ""); - const record = getDatabase(id); - record.db.exec(sql); - markMutation(record, sql); - await syncDatabase(record); - return null; + const [idArg, sqlArg] = args; + const id = Number(idArg); + const sql = String(sqlArg ?? ""); + const record = getDatabase(id); + record.db.exec(sql); + markMutation(record, sql); + await syncDatabase(record); + return null; })(); }, prepare(...args: unknown[]) { @@ -460,6 +720,7 @@ export function createSqliteBindings(kernel: Kernel): BindingTree { const [idArg] = args; const id = Number(idArg); const record = getStatement(id); + record.stmt.finalize?.(); const db = databases.get(record.dbId); db?.statementIds.delete(id); statements.delete(id); @@ -469,3 +730,24 @@ export function createSqliteBindings(kernel: Kernel): BindingTree { }, }; } + +// --------------------------------------------------------------------------- +// Public API — lazily loads the SQLite module on first call +// --------------------------------------------------------------------------- + +/** + * Create the SQLite binding tree for the secure-exec Node runtime. + * + * Lazily loads the host SQLite module (`node:sqlite` on Node.js, `bun:sqlite` + * on Bun) on first call, then caches the module for subsequent invocations. + * The returned binding tree is passed to `createNodeRuntime({ bindings })`. + * + * @param kernel - The secure-exec kernel (used for VFS sync of file-backed databases). + * @returns A `BindingTree` with the `sqlite` namespace (database, statement, meta). + */ +export async function createSqliteBindings( + kernel: Kernel, +): Promise { + const sqliteModule = await loadSqliteModule(); + return createSqliteBindingsFromModule(kernel, sqliteModule); +} diff --git a/packages/core/tests/sqlite-bindings.test.ts b/packages/core/tests/sqlite-bindings.test.ts new file mode 100644 index 000000000..914e6aa35 --- /dev/null +++ b/packages/core/tests/sqlite-bindings.test.ts @@ -0,0 +1,875 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + _resetSqliteModuleCache, + createBunSqliteAdapter, + createSqliteBindingsFromModule, + isBunRuntime, + loadSqliteModule, + type SqliteDatabase, + type SqliteModule, + type SqliteStatement, +} from "../src/sqlite-bindings.js"; + +// --------------------------------------------------------------------------- +// Shared test fixtures +// --------------------------------------------------------------------------- + +function createMockKernel() { + const files = new Map(); + const dirs = new Set(["/", "/tmp"]); + + return { + files, + dirs, + exists: vi.fn(async (path: string) => files.has(path) || dirs.has(path)), + mkdir: vi.fn(async (path: string) => { + dirs.add(path); + }), + readFile: vi.fn(async (path: string) => { + const data = files.get(path); + if (!data) throw new Error(`ENOENT: ${path}`); + return data; + }), + writeFile: vi.fn(async (path: string, data: Uint8Array | string) => { + files.set( + path, + typeof data === "string" ? new TextEncoder().encode(data) : data, + ); + }), + }; +} + +// --------------------------------------------------------------------------- +// 1. No eager import — the whole point of this refactor +// --------------------------------------------------------------------------- + +describe("lazy loading", () => { + afterEach(() => { + _resetSqliteModuleCache(); + }); + + test("importing sqlite-bindings.ts does not eagerly require node:sqlite", () => { + expect(isBunRuntime).toBeTypeOf("function"); + expect(createSqliteBindingsFromModule).toBeTypeOf("function"); + }); + + test("loadSqliteModule returns a valid SqliteModule on Node.js", async () => { + const mod = await loadSqliteModule(); + expect(mod).toBeDefined(); + expect(mod.DatabaseSync).toBeTypeOf("function"); + expect( + typeof mod.constants === "object" || mod.constants === undefined, + ).toBe(true); + }); + + test("loadSqliteModule caches the result across calls", async () => { + const mod1 = await loadSqliteModule(); + const mod2 = await loadSqliteModule(); + expect(mod1).toBe(mod2); + }); + + test("_resetSqliteModuleCache forces a fresh load", async () => { + const mod1 = await loadSqliteModule(); + _resetSqliteModuleCache(); + const mod2 = await loadSqliteModule(); + expect(mod1.DatabaseSync).toBeTypeOf("function"); + expect(mod2.DatabaseSync).toBeTypeOf("function"); + }); + + test("concurrent loadSqliteModule calls do not race", async () => { + const [mod1, mod2] = await Promise.all([ + loadSqliteModule(), + loadSqliteModule(), + ]); + expect(mod1).toBe(mod2); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Runtime detection +// --------------------------------------------------------------------------- + +describe("isBunRuntime", () => { + test("returns false on Node.js", () => { + expect(isBunRuntime()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Bun adapter via mock +// --------------------------------------------------------------------------- + +describe("createBunSqliteAdapter", () => { + function createMockBunDatabase() { + const rows = [ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]; + + class MockStatement { + _sql: string; + columnNames: string[]; + _runParams: unknown[][] = []; + + constructor(sql: string) { + this._sql = sql; + this.columnNames = ["id", "name"]; + } + + run(...params: unknown[]) { + this._runParams.push(params); + return { changes: 1, lastInsertRowid: 42 }; + } + + get(..._params: unknown[]) { + return rows[0]; + } + + all(..._params: unknown[]) { + return [...rows]; + } + + values(..._params: unknown[]) { + return rows.map((r) => [r.id, r.name]); + } + + finalize() {} + } + + class MockDatabase { + filename: string; + _closed = false; + _execLog: string[] = []; + + constructor(path?: string, _options?: Record) { + this.filename = path ?? ":memory:"; + } + + close() { + this._closed = true; + } + + exec(sql: string) { + this._execLog.push(sql); + } + + prepare(sql: string) { + return new MockStatement(sql); + } + } + + return MockDatabase; + } + + let adapter: SqliteModule; + + beforeEach(() => { + adapter = createBunSqliteAdapter(createMockBunDatabase()); + }); + + test("DatabaseSync constructor creates a database", () => { + const db = new adapter.DatabaseSync(":memory:"); + expect(db).toBeDefined(); + }); + + test("db.location() returns the filename for file-backed databases", () => { + const db = new adapter.DatabaseSync("/tmp/test.db"); + expect(db.location()).toBe("/tmp/test.db"); + }); + + test("db.location() returns null for memory databases", () => { + const db = new adapter.DatabaseSync(); + expect(db.location()).toBeNull(); + }); + + test("db.location() returns null for explicit :memory: path", () => { + const db = new adapter.DatabaseSync(":memory:"); + expect(db.location()).toBeNull(); + }); + + test("db.close() releases the database connection without error", () => { + const db = new adapter.DatabaseSync(":memory:"); + db.close(); + }); + + test("db.exec() runs arbitrary SQL without returning a result", () => { + const db = new adapter.DatabaseSync(":memory:"); + db.exec("CREATE TABLE t (id INTEGER)"); + }); + + test("db.prepare() compiles SQL into a reusable statement handle", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + expect(stmt).toBeDefined(); + }); + + test("stmt.run() returns { changes, lastInsertRowid } from bun:sqlite", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("INSERT INTO t VALUES (3, 'charlie')"); + const result = stmt.run() as any; + expect(result).toEqual({ changes: 1, lastInsertRowid: 42 }); + }); + + test("stmt.get() returns a single row as object", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + const row = stmt.get() as any; + expect(row).toEqual({ id: 1, name: "alice" }); + }); + + test("stmt.all() returns all rows as objects", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + const rows = stmt.all() as any[]; + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ id: 1, name: "alice" }); + expect(rows[1]).toEqual({ id: 2, name: "bob" }); + }); + + test("stmt.iterate() returns an iterable of rows", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + const rows = [...stmt.iterate()] as any[]; + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ id: 1, name: "alice" }); + }); + + test("stmt.columns() returns array of {name} objects", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + const cols = stmt.columns() as any[]; + expect(cols).toEqual([{ name: "id" }, { name: "name" }]); + }); + + test("results switch from objects to arrays when array mode is enabled", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + stmt.setReturnArrays(true); + + const row = stmt.get() as any; + expect(Array.isArray(row)).toBe(true); + expect(row).toEqual([1, "alice"]); + + const allRows = stmt.all() as any[]; + expect(allRows).toEqual([ + [1, "alice"], + [2, "bob"], + ]); + }); + + test("get() returns undefined in array mode when no rows exist", () => { + function createEmptyMock() { + class S { + columnNames: string[] = []; + run() { + return { changes: 0, lastInsertRowid: 0 }; + } + get() { + return undefined; + } + all() { + return []; + } + values() { + return []; + } + finalize() {} + } + class D { + filename: string; + constructor(p?: string) { + this.filename = p ?? ":memory:"; + } + close() {} + exec() {} + prepare() { + return new S(); + } + } + return D; + } + const a = createBunSqliteAdapter(createEmptyMock()); + const db = new a.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT 1 WHERE 0"); + stmt.setReturnArrays(true); + expect(stmt.get()).toBeUndefined(); + }); + + test("array mode disables when setReturnArrays(false) is called", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + stmt.setReturnArrays(true); + stmt.setReturnArrays(false); + const row = stmt.get() as any; + expect(row).toEqual({ id: 1, name: "alice" }); + }); + + test("stmt.finalize() delegates to underlying statement", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + expect(() => stmt.finalize?.()).not.toThrow(); + }); + + test("stmt.setReadBigInts is a no-op (does not throw)", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + expect(() => stmt.setReadBigInts(true)).not.toThrow(); + }); + + test("stmt.setAllowBareNamedParameters is a no-op", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + expect(() => stmt.setAllowBareNamedParameters(true)).not.toThrow(); + }); + + test("stmt.setAllowUnknownNamedParameters is a no-op", () => { + const db = new adapter.DatabaseSync(":memory:"); + const stmt = db.prepare("SELECT * FROM t"); + expect(() => stmt.setAllowUnknownNamedParameters(true)).not.toThrow(); + }); + + test("constants defaults to empty object when not provided", () => { + const a = createBunSqliteAdapter(createMockBunDatabase()); + expect(a.constants).toEqual({}); + }); + + test("constants forwards bun:sqlite constants when provided", () => { + const mockConstants = { SQLITE_FCNTL_PERSIST_WAL: 10 }; + const a = createBunSqliteAdapter(createMockBunDatabase(), mockConstants); + expect(a.constants).toEqual({ SQLITE_FCNTL_PERSIST_WAL: 10 }); + }); +}); + +// --------------------------------------------------------------------------- +// 4. createSqliteBindingsFromModule — binding tree with mock SqliteModule +// --------------------------------------------------------------------------- + +describe("createSqliteBindingsFromModule", () => { + function createMockSqliteModule(): SqliteModule { + class MockStatement implements SqliteStatement { + _sql: string; + _returnArrays = false; + _rows: Record[] = []; + _runResult = { changes: 0, lastInsertRowid: 0 }; + + constructor(sql: string) { + this._sql = sql; + } + run(..._params: unknown[]) { + return this._runResult; + } + get(..._params: unknown[]) { + if (this._returnArrays) { + const r = this._rows[0]; + return r ? Object.values(r) : undefined; + } + return this._rows[0] ?? undefined; + } + all(..._params: unknown[]) { + if (this._returnArrays) return this._rows.map((r) => Object.values(r)); + return [...this._rows]; + } + iterate(..._params: unknown[]): Iterable { + return this.all(..._params) as unknown[]; + } + columns() { + if (this._rows.length > 0) + return Object.keys(this._rows[0]).map((n) => ({ name: n })); + return []; + } + finalize() {} + setReturnArrays(e: boolean) { + this._returnArrays = e; + } + setReadBigInts(_e: boolean) {} + setAllowBareNamedParameters(_e: boolean) {} + setAllowUnknownNamedParameters(_e: boolean) {} + } + + class MockDatabase implements SqliteDatabase { + _path: string; + _closed = false; + constructor(path?: string, _opts?: Record) { + this._path = path ?? ":memory:"; + } + close() { + this._closed = true; + } + exec(_sql: string) {} + location() { + return this._path === ":memory:" ? null : this._path; + } + prepare(sql: string) { + return new MockStatement(sql); + } + } + + return { + DatabaseSync: MockDatabase as unknown as SqliteModule["DatabaseSync"], + constants: { SQLITE_OK: 0 }, + }; + } + + test("exposes database, statement, and meta namespaces on the binding tree", () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + expect(bindings).toBeDefined(); + expect(bindings.sqlite).toBeDefined(); + const sqlite = bindings.sqlite as any; + expect(sqlite.meta).toBeDefined(); + expect(sqlite.database).toBeDefined(); + expect(sqlite.statement).toBeDefined(); + }); + + test("meta.constants returns the module constants", () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + expect(sqlite.meta.constants()).toEqual({ SQLITE_OK: 0 }); + }); + + test("open returns a numeric handle, close invalidates it", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + expect(typeof dbId).toBe("number"); + expect(dbId).toBeGreaterThan(0); + expect(await sqlite.database.close(dbId)).toBeNull(); + }); + + test("open with no args creates an in-memory database", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(); + expect(typeof dbId).toBe("number"); + await sqlite.database.close(dbId); + }); + + test("location returns vmPath for file-backed databases", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open("/tmp/test.db"); + expect(sqlite.database.location(dbId)).toBe("/tmp/test.db"); + await sqlite.database.close(dbId); + }); + + test("location returns null for memory databases", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + expect(sqlite.database.location(dbId)).toBeNull(); + await sqlite.database.close(dbId); + }); + + test("exec runs SQL and returns null", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + expect( + await sqlite.database.exec(dbId, "CREATE TABLE t (id INTEGER)"), + ).toBeNull(); + await sqlite.database.close(dbId); + }); + + test("prepare returns a statement handle", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + const stmtId = sqlite.database.prepare(dbId, "SELECT 1"); + expect(typeof stmtId).toBe("number"); + expect(stmtId).toBeGreaterThan(0); + await sqlite.database.close(dbId); + }); + + test("statement.run returns encoded result", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + const stmtId = sqlite.database.prepare(dbId, "INSERT INTO t VALUES (1)"); + const result = await sqlite.statement.run(stmtId, []); + expect(result).toEqual({ changes: 0, lastInsertRowid: 0 }); + await sqlite.database.close(dbId); + }); + + test("finalize invalidates the statement handle", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + const stmtId = sqlite.database.prepare(dbId, "SELECT 1"); + expect(sqlite.statement.finalize(stmtId)).toBeNull(); + expect(() => sqlite.statement.get(stmtId, [])).toThrow( + /statement handle.*not found/, + ); + await sqlite.database.close(dbId); + }); + + test("configuration setters accept values without error", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + const stmtId = sqlite.database.prepare(dbId, "SELECT 1"); + expect(sqlite.statement.setReturnArrays(stmtId, true)).toBeNull(); + expect(sqlite.statement.setReadBigInts(stmtId, true)).toBeNull(); + expect( + sqlite.statement.setAllowBareNamedParameters(stmtId, true), + ).toBeNull(); + expect( + sqlite.statement.setAllowUnknownNamedParameters(stmtId, true), + ).toBeNull(); + await sqlite.database.close(dbId); + }); + + test("closing a database invalidates its statements", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + const stmtId = sqlite.database.prepare(dbId, "SELECT 1"); + await sqlite.database.close(dbId); + expect(() => sqlite.statement.get(stmtId, [])).toThrow( + /statement handle.*not found.*parent database/, + ); + }); + + test("each open returns a unique handle", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const db1 = await sqlite.database.open(":memory:"); + const db2 = await sqlite.database.open(":memory:"); + expect(db1).not.toBe(db2); + await sqlite.database.close(db1); + await sqlite.database.close(db2); + }); + + test("invalid database id error includes diagnostic context", () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + expect(() => sqlite.database.location(999)).toThrow( + /database handle 999 not found.*may have already been closed/, + ); + }); + + test("invalid statement id error includes diagnostic context", () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + expect(() => sqlite.statement.get(999, [])).toThrow( + /statement handle 999 not found.*finalized.*parent database/, + ); + }); + + test("exec on invalid database id rejects with context", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + await expect(sqlite.database.exec(999, "SELECT 1")).rejects.toThrow( + /database handle 999 not found/, + ); + }); + + test("prepare on invalid database id throws with context", () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + expect(() => sqlite.database.prepare(999, "SELECT 1")).toThrow( + /database handle 999 not found/, + ); + }); + + test("double-close lists no remaining open handles", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + await sqlite.database.close(dbId); + await expect(sqlite.database.close(dbId)).rejects.toThrow( + /database handle.*not found.*No databases are currently open/, + ); + }); + + test("finalized statement run rejects with diagnostic guidance", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + const stmtId = sqlite.database.prepare(dbId, "SELECT 1"); + sqlite.statement.finalize(stmtId); + await expect(sqlite.statement.run(stmtId, [])).rejects.toThrow( + /statement handle.*not found.*finalized/, + ); + await sqlite.database.close(dbId); + }); + + test("exec with mutating SQL on memory DB does not trigger VFS sync", async () => { + const kernel = createMockKernel(); + const mod = createMockSqliteModule(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open(":memory:"); + await sqlite.database.exec(dbId, "INSERT INTO t VALUES (1)"); + expect(kernel.writeFile).not.toHaveBeenCalled(); + await sqlite.database.close(dbId); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Node.js integration — real node:sqlite end-to-end +// --------------------------------------------------------------------------- + +describe("node:sqlite integration", () => { + afterEach(() => { + _resetSqliteModuleCache(); + }); + + test("full CRUD with real node:sqlite", async () => { + const mod = await loadSqliteModule(); + const kernel = createMockKernel(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + + const dbId = await sqlite.database.open(":memory:"); + await sqlite.database.exec( + dbId, + "CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)", + ); + + const insertId = sqlite.database.prepare( + dbId, + "INSERT INTO test (id, value) VALUES (?, ?)", + ); + const insertResult = await sqlite.statement.run(insertId, [1, "hello"]); + expect((insertResult as any).changes).toBe(1); + + const selectId = sqlite.database.prepare( + dbId, + "SELECT * FROM test WHERE id = ?", + ); + expect(sqlite.statement.get(selectId, [1])).toEqual({ + id: 1, + value: "hello", + }); + + const allId = sqlite.database.prepare(dbId, "SELECT * FROM test"); + expect(sqlite.statement.all(allId, [])).toEqual([ + { id: 1, value: "hello" }, + ]); + expect(sqlite.statement.iterate(allId, [])).toEqual([ + { id: 1, value: "hello" }, + ]); + + const cols = sqlite.statement.columns(selectId); + expect(cols).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "id" }), + expect.objectContaining({ name: "value" }), + ]), + ); + + sqlite.statement.finalize(insertId); + sqlite.statement.finalize(selectId); + sqlite.statement.finalize(allId); + await sqlite.database.close(dbId); + }); + + test("bigint encoding round-trip preserves large values", async () => { + const mod = await loadSqliteModule(); + const kernel = createMockKernel(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + + const dbId = await sqlite.database.open(":memory:"); + await sqlite.database.exec(dbId, "CREATE TABLE big (val INTEGER)"); + const insertId = sqlite.database.prepare( + dbId, + "INSERT INTO big VALUES (?)", + ); + await sqlite.statement.run(insertId, [ + { __agentosSqliteType: "bigint", value: "9007199254740993" }, + ]); + + const selectId = sqlite.database.prepare(dbId, "SELECT * FROM big"); + sqlite.statement.setReadBigInts(selectId, true); + const row = sqlite.statement.get(selectId, []) as any; + expect(row).toBeDefined(); + expect(row.val).toBeDefined(); + + sqlite.statement.finalize(insertId); + sqlite.statement.finalize(selectId); + await sqlite.database.close(dbId); + }); + + test("Uint8Array encoding round-trip preserves binary data", async () => { + const mod = await loadSqliteModule(); + const kernel = createMockKernel(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + + const dbId = await sqlite.database.open(":memory:"); + await sqlite.database.exec(dbId, "CREATE TABLE blobs (data BLOB)"); + const insertId = sqlite.database.prepare( + dbId, + "INSERT INTO blobs VALUES (?)", + ); + await sqlite.statement.run(insertId, [ + { + __agentosSqliteType: "uint8array", + value: Buffer.from("binary data test").toString("base64"), + }, + ]); + + const selectId = sqlite.database.prepare(dbId, "SELECT * FROM blobs"); + const row = sqlite.statement.get(selectId, []) as any; + expect(row).toBeDefined(); + expect(row.data).toBeDefined(); + + sqlite.statement.finalize(insertId); + sqlite.statement.finalize(selectId); + await sqlite.database.close(dbId); + }); + + test("transaction tracking with BEGIN/COMMIT", async () => { + const mod = await loadSqliteModule(); + const kernel = createMockKernel(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + + const dbId = await sqlite.database.open(":memory:"); + await sqlite.database.exec( + dbId, + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)", + ); + await sqlite.database.exec(dbId, "BEGIN"); + const insertId = sqlite.database.prepare( + dbId, + "INSERT INTO items (name) VALUES (?)", + ); + await sqlite.statement.run(insertId, ["item1"]); + await sqlite.statement.run(insertId, ["item2"]); + await sqlite.statement.run(insertId, ["item3"]); + await sqlite.database.exec(dbId, "COMMIT"); + + const selectId = sqlite.database.prepare( + dbId, + "SELECT COUNT(*) as cnt FROM items", + ); + expect((sqlite.statement.get(selectId, []) as any).cnt).toBe(3); + + sqlite.statement.finalize(insertId); + sqlite.statement.finalize(selectId); + await sqlite.database.close(dbId); + }); + + test("VFS sync writes to kernel on mutation outside transaction", async () => { + const mod = await loadSqliteModule(); + const kernel = createMockKernel(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open("/tmp/sync-test.db"); + await sqlite.database.exec( + dbId, + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);", + ); + expect(kernel.writeFile).toHaveBeenCalledWith( + "/tmp/sync-test.db", + expect.any(Uint8Array), + ); + await sqlite.database.close(dbId); + }); + + test("VFS sync deferred during transactions", async () => { + const mod = await loadSqliteModule(); + const kernel = createMockKernel(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open("/tmp/txn-test.db"); + await sqlite.database.exec(dbId, "CREATE TABLE t (id INTEGER)"); + kernel.writeFile.mockClear(); + + await sqlite.database.exec(dbId, "BEGIN"); + const insertId = sqlite.database.prepare(dbId, "INSERT INTO t VALUES (?)"); + await sqlite.statement.run(insertId, [1]); + await sqlite.statement.run(insertId, [2]); + const callsDuringTxn = kernel.writeFile.mock.calls.length; + await sqlite.database.exec(dbId, "COMMIT"); + expect(kernel.writeFile.mock.calls.length).toBeGreaterThan(callsDuringTxn); + + sqlite.statement.finalize(insertId); + await sqlite.database.close(dbId); + }); + + test("SELECT does not trigger VFS sync", async () => { + const mod = await loadSqliteModule(); + const kernel = createMockKernel(); + const bindings = createSqliteBindingsFromModule(kernel as any, mod); + const sqlite = bindings.sqlite as any; + const dbId = await sqlite.database.open("/tmp/readonly-test.db"); + await sqlite.database.exec( + dbId, + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);", + ); + kernel.writeFile.mockClear(); + await sqlite.database.exec(dbId, "SELECT * FROM t"); + expect(kernel.writeFile).not.toHaveBeenCalled(); + await sqlite.database.close(dbId); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Public API — createSqliteBindings (the actual entry point) +// --------------------------------------------------------------------------- + +describe("createSqliteBindings (public API)", () => { + afterEach(() => { + _resetSqliteModuleCache(); + }); + + test("returns a working binding tree via the public async API", async () => { + const { createSqliteBindings } = await import("../src/sqlite-bindings.js"); + const kernel = createMockKernel(); + const bindings = await createSqliteBindings(kernel as any); + const sqlite = bindings.sqlite as any; + + const dbId = await sqlite.database.open(":memory:"); + await sqlite.database.exec(dbId, "CREATE TABLE test (val TEXT)"); + const stmtId = sqlite.database.prepare(dbId, "INSERT INTO test VALUES (?)"); + await sqlite.statement.run(stmtId, ["hello from public API"]); + const selectId = sqlite.database.prepare(dbId, "SELECT * FROM test"); + expect(sqlite.statement.get(selectId, [])).toEqual({ + val: "hello from public API", + }); + + sqlite.statement.finalize(stmtId); + sqlite.statement.finalize(selectId); + await sqlite.database.close(dbId); + }); +});