Bun Integration
Bun is the reference host runtime because its first-class FFI and native-addon support let the same engine library serve the embedded path — bun:ffi calling engine.h in-process with no socket — and the server path — Bun's built-in Bun.sql Postgres client talking to engine-server — without rebuilding the engine.
Purpose & scope
This spec expands source §5 (Bun adoption path) into a buildable integration design. It defines exactly how a Bun (and, via NAPI, Node) application binds the engine in both deployment shapes the architecture supports:
- Path 1 — Embedded in-process
- The headline use case.
bun:ffibinds the engine's C ABI (02 — Engine Core'sengine.h) directly; the engine runs inside the Bun process. No server, no socket, no wire protocol — function-call latency on the hot path. - Path 2 — Server mode (Bun as client)
- Run
engine-server(Postgres wire protocol, 07). Bun connects with its built-inBun.sqlPostgres client — zero extra dependencies — for multi-client access and tools that expect Postgres.
It also specifies the @twilldb/bun wrapper package over the raw FFI symbols, the NAPI alternative for one package across Bun and Node, prebuilt-binary packaging and distribution, the normative requirements, failure modes, acceptance criteria, and open questions. The C ABI itself is owned by 02 — Engine Core; this page binds to it and MUST NOT redefine it.
Note — storage choice is config, not a rebuild
In both paths the storage backend is selected by the connection-string scheme passed to engine_open (or to the server): file:// → LocalFileStorage (pure-embedded, no network egress), s3:// / r2:// → the disaggregated ObjectStorage backend (04). The Bun-facing code is identical regardless of backend; only the URL changes.
Why Bun
The architecture's central claim is that embeddable and storage-disaggregated stop being contradictory once the storage seam is a pluggable backend rather than a server you connect to (source §0/§1). Bun is the host runtime that realizes both halves of that claim from one engine artifact:
- MUST reuse a single engine build across embedded and server stories:
bun:ffilinkslibengine.{dylib,so}in-process;engine-serveris the same library wrapped in a listener (source §2.A). No fork in the engine codebase per host. - SHOULD use the embedded path as the default — it is the fastest route to a working demo with zero infrastructure (source §5 rollout step 1).
| Bun capability | Used by | Why it matters here |
|---|---|---|
First-class FFI (bun:ffi) | Path 1 (embedded) | Binds the C ABI directly with JIT'd trampolines; no addon compile step, no socket. The engine runs inside the Bun process. |
| Native-addon (NAPI) support | NAPI alternative | One compiled addon runs in both Bun and Node — a single package across runtimes. |
Built-in Bun.sql Postgres client | Path 2 (server) | If the engine speaks the Postgres wire protocol, Bun talks to it with zero extra deps. |
| Single-process composition | Embedded apps | better-auth and other libraries share the in-process DB (source §10/§11) — no external services. |
Responsibilities & non-goals
Responsibilities
- MUST bind the stable C ABI from
engine.hviabun:ffifor the embedded path, and connect viaBun.sqlfor the server path. - MUST marshal JS strings to NUL-terminated C strings for every
const char*argument and copy out every borrowedconst char*return before the owning object advances or is freed. - MUST map engine status codes and
engine_last_errorinto typed JS errors, distinguishing retryable from terminal failures. - MUST free every engine-owned resource exactly once — handles via
engine_close, results viaengine_result_free, statements viaengine_finalize. - SHOULD expose an ergonomic, typed wrapper (
@twilldb/bun) so application code never touches raw pointers. - MAY ship a NAPI addon as an alternative distribution so one package serves Bun and Node.
Non-goals
- MUST NOT define or alter the C ABI — that contract is owned by 02 — Engine Core.
- MUST NOT implement SQL parsing, MVCC, WAL, caching, or storage in the binding layer; the binding is a thin, faithful shim over the engine.
- MUST NOT own the wire protocol, the pooler, or the server lifecycle — those are 07 — Server Mode.
- The WASM build is a separate distribution target, not covered here — see 11 — Deployment Targets.
Two integration paths at a glance
One engine artifact, two host integrations: FFI in-process for embedded; Postgres wire over a pooler for server mode.
| Path 1 — Embedded | Path 2 — Server | |
|---|---|---|
| Bun mechanism | bun:ffi + @twilldb/bun | built-in Bun.sql |
| Transport | direct function calls (no protocol) | Postgres wire protocol |
| Extra deps | prebuilt native lib for the platform | none (client is built into Bun) |
| Latency on hot path | function-call (µs over cache hits) | network round-trip + parse |
| Concurrency model | one engine per process | many clients via pooler (07) |
| Best for | single Bun app, edge, demos, branching tools | multi-client, Postgres-native tools, PostgREST |
Path 1 — Embedded via bun:ffi
The headline path. bun:ffi's dlopen maps the exported symbols of libengine.${suffix} into JS-callable functions. The connection string passed to engine_open selects the storage backend by scheme; the call sequence is identical for file:// and s3://.
Raw FFI binding
A faithful binding of the lifecycle + one-shot subset of the ABI. Note the use of Bun's suffix so one source file resolves the right platform extension, and cstring for the engine's NUL-terminated const char* returns.
import { dlopen, FFIType, suffix } from "bun:ffi";
// libengine.dylib (darwin) / libengine.so (linux) / libengine.dll (win)
const { symbols: lib, close: unloadLib } = dlopen(`libengine.${suffix}`, {
// ---- lifecycle ---------------------------------------------------
// url is a NUL-terminated cstring: "file://./local.db" or
// "s3://bucket/mydb?region=...". Returns an opaque handle, or null.
engine_open: { args: [FFIType.cstring], returns: FFIType.ptr },
engine_close: { args: [FFIType.ptr], returns: FFIType.void },
// ---- one-shot execution -----------------------------------------
// DDL/DML, no result set. Returns an EngineStatus code.
engine_exec: { args: [FFIType.ptr, FFIType.cstring], returns: FFIType.i32 },
// buffered query: writes an owned EngineResult* into *out (ptr-to-ptr).
engine_query: { args: [FFIType.ptr, FFIType.cstring, FFIType.ptr], returns: FFIType.i32 },
// ---- prepared statements ----------------------------------------
engine_prepare: { args: [FFIType.ptr, FFIType.cstring, FFIType.ptr], returns: FFIType.i32 },
engine_bind: { args: [FFIType.ptr, FFIType.i32, FFIType.cstring], returns: FFIType.i32 },
engine_step: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, // *done out
engine_finalize:{ args: [FFIType.ptr], returns: FFIType.i32 },
engine_reset: { args: [FFIType.ptr], returns: FFIType.i32 },
// ---- transactions ------------------------------------------------
engine_begin: { args: [FFIType.ptr], returns: FFIType.i32 },
engine_commit: { args: [FFIType.ptr], returns: FFIType.i32 }, // blocks until WAL durable
engine_rollback:{ args: [FFIType.ptr], returns: FFIType.i32 },
// ---- branching ---------------------------------------------------
// copy-on-write branch at current LSN; NEW handle on a NEW logical DB.
engine_branch: { args: [FFIType.ptr, FFIType.cstring], returns: FFIType.ptr },
// ---- result access (borrowed pointers into the result arena) -----
engine_result_rows: { args: [FFIType.ptr], returns: FFIType.i32 },
engine_result_cols: { args: [FFIType.ptr], returns: FFIType.i32 },
engine_result_colname: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.cstring },
engine_result_value: { args: [FFIType.ptr, FFIType.i32, FFIType.i32], returns: FFIType.cstring },
engine_column_value: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.cstring },
// ---- errors / metadata ------------------------------------------
engine_last_error: { args: [FFIType.ptr], returns: FFIType.cstring }, // borrowed
engine_changes: { args: [FFIType.ptr], returns: FFIType.i64 },
engine_last_lsn: { args: [FFIType.ptr], returns: FFIType.i64 },
// ---- freeing -----------------------------------------------------
engine_result_free: { args: [FFIType.ptr], returns: FFIType.void }, // idempotent on null
});
// helper: JS string -> NUL-terminated C string buffer
const cstr = (s: string) => Buffer.from(s + "\0", "utf8");
// open: storage backend chosen purely by the URL scheme.
const h = lib.engine_open(cstr("s3://bucket/mydb?region=us-east-1"));
// const h = lib.engine_open(cstr("file://./local.db")); // pure-embedded
if (h === null) throw new Error("engine_open failed (unknown scheme or storage error)");
Open returns NULL with no handle to query
When engine_open fails it returns NULL — there is no handle on which to call engine_last_error. The binding MUST surface a generic open error (likely an unknown URL scheme, which the engine rejects rather than silently defaulting, or a storage-init failure). Every other error is retrievable via engine_last_error(h).
String & buffer marshaling
- MUST pass every
const char*argument (URL, SQL, bound value) as a NUL-terminated buffer — a JS string without the trailing\0will read past its end in C. - MUST declare engine
const char*returns asFFIType.cstringso Bun reads up to the NUL;FFIType.ptrwould yield a raw address and require manual length handling. - MUST encode/decode as UTF-8 on both directions to match the engine's text representation.
- SHOULD reuse a single allocation per call site where hot, but MUST keep argument buffers alive for the entire duration of the FFI call.
Pointer lifetime & ownership
The ABI is opaque-handle and arena based; the binding mirrors that contract exactly (see 02 — Engine Core, ownership table):
- Handle (
engine_open/engine_branch) - Owned by the binding. Destroyed exactly once via
engine_close. A branch handle is a new logical database and is freed independently of its parent. - EngineResult* (out of
engine_query) - Owned by the binding; released exactly once with
engine_result_free. Every value/colname read from it is borrowed into its arena and becomes dangling the instant the result is freed — the wrapper MUST copy all values into JS before freeing. - Borrowed const char* (values, colnames, errors)
- Never freed by the binding. Valid only until the owning object advances or is freed; for
engine_column_value, only until the nextengine_step/engine_reset/engine_finalizeon that statement. Copy out immediately.
Reading a buffered result and the *out pointer
engine_query writes an owned EngineResult* into a caller-provided EngineResult**. In bun:ffi that is a pointer-to-pointer; allocate an 8-byte slot, pass its pointer, then read the engine pointer back out.
import { ptr, read, toArrayBuffer } from "bun:ffi";
const ENGINE_OK = 0; // see EngineStatus in engine.h (02 — Engine Core)
function query(h: Pointer, sql: string): Record<string, string>[] {
const outSlot = new BigUint64Array(1); // holds the EngineResult*
const status = lib.engine_query(h, cstr(sql), ptr(outSlot));
if (status !== ENGINE_OK) throw engineError(h, status);
const result = Number(outSlot[0]) as Pointer; // owned EngineResult*
try {
const rows = lib.engine_result_rows(result);
const cols = lib.engine_result_cols(result);
const names: string[] = [];
for (let c = 0; c < cols; c++) names.push(lib.engine_result_colname(result, c).toString());
const out: Record<string, string>[] = [];
for (let r = 0; r < rows; r++) {
const row: Record<string, string> = {};
for (let c = 0; c < cols; c++) {
// borrowed cstring -> copy into JS BEFORE we free the result
row[names[c]] = lib.engine_result_value(result, r, c).toString();
}
out.push(row);
}
return out;
} finally {
lib.engine_result_free(result); // exactly once; idempotent on null
}
}
Error retrieval
function engineError(h: Pointer, status: number): EngineError {
// engine_last_error returns a BORROWED, thread-local-per-handle cstring.
const msg = lib.engine_last_error(h).toString(); // copy out immediately
return new EngineError(status, msg || `engine status ${status}`);
}
- MUST read
engine_last_error(h)immediately after a non-OK status and copy it into a JS string before issuing any further call onh(the next call may overwrite it). - SHOULD map the numeric
EngineStatus(not the message text) to retryable/terminal categories, so retry policy never string-matches on prose.
Same code path, both backends
Nothing above branches on the storage backend. engine_open("file://...") and engine_open("s3://...") produce handles that are queried, prepared, transacted, and branched through identical FFI calls. The only observable difference is that an s3:// commit blocks longer (it pays the network/CAS round-trip; source §4 / 04) while a file:// commit is a local fsync.
TS wrapper package — @twilldb/bun
Application code SHOULD never touch raw pointers. @twilldb/bun wraps the FFI symbols in an ergonomic, typed API with structured errors and deterministic resource disposal. It maps one-to-one onto the ABI subset above.
Public API surface
// @twilldb/bun
export function open(url: string): Database;
export interface Database extends Disposable {
exec(sql: string): number; // rows affected (engine_changes)
query<T = Row>(sql: string, params?: Param[]): T[]; // buffered rows
prepare<T = Row>(sql: string): Statement<T>; // reusable prepared stmt
transaction<R>(fn: (tx: Database) => R): R; // BEGIN/COMMIT/ROLLBACK
branch(name: string): Database; // copy-on-write @ current LSN
readonly lastLsn: bigint; // engine_last_lsn
close(): void; // engine_close (idempotent)
[Symbol.dispose](): void; // = close()
}
export interface Statement<T = Row> extends Disposable {
all(...params: Param[]): T[]; // step to ENGINE_DONE, buffer
get(...params: Param[]): T | undefined; // first row or undefined
run(...params: Param[]): number; // rows affected
[Symbol.dispose](): void; // engine_finalize
}
export type Param = number | bigint | string | Uint8Array | null | number[]; // -> iN/fN/sN/bN/n/vN
export type Row = Record<string, string>;
export class EngineError extends Error {
readonly status: number; // EngineStatus code
readonly retryable: boolean; // CONFLICT / transient STORAGE
}
Intended usage
import { open } from "@twilldb/bun";
// `using` ⇒ db.close() runs deterministically at scope exit (Symbol.dispose)
using db = open("s3://bucket/mydb?region=us-east-1");
db.exec(`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)`);
// parameters are tagged and bound positionally (no string interpolation)
db.query("INSERT INTO notes (id, body) VALUES (?, ?)", [1, "hello"]);
const rows = db.query<{ id: string; body: string }>(
"SELECT id, body FROM notes WHERE id = ?", [1],
);
// prepared statement, reused across iterations; auto-finalized at scope end
using stmt = db.prepare("SELECT body FROM notes WHERE id = ?");
for (const id of [1, 2, 3]) {
const r = stmt.get(id);
if (r) console.log(r.body);
}
// transaction: commit on normal return, rollback on throw
db.transaction((tx) => {
tx.exec("UPDATE notes SET body = 'x' WHERE id = 1");
tx.exec("INSERT INTO notes (id, body) VALUES (2, 'y')");
}); // engine_commit() blocks here until the WAL is durable
// branch = instant clone over shared immutable layers (copy-on-write)
using preview = db.branch("pr-1234");
preview.exec("DELETE FROM notes WHERE id = 1"); // diverges from parent; cheap until written
Parameter mapping
JS values map onto the ABI's string-only typed bind encoding (02 — Engine Core, parameter encoding): a one-character type tag prefixes each value.
| JS type | Tag | Encoded example |
|---|---|---|
number (integer) | i int8 | 42 → "i42" |
number (float) / bigint | f float8 / i | 3.5 → "f3.5" |
string | s text | "hi" → "shi" |
Uint8Array | b base64-bytes | bytes → "b<base64>" |
null | n NULL | → "n" |
number[] | v vector | [0.1,0.2] → "v0.1,0.2" |
Error mapping & resource disposal
- MUST throw
EngineError(carrying the numericEngineStatusand the copiedengine_last_errormessage) on every non-OK status;NULLhandle/result returns map to a distinct open/query error. - MUST set
retryablefrom the status code (e.g.ENGINE_ERR_CONFLICT, transientENGINE_ERR_STORAGE) so callers retry without parsing prose. - MUST implement
Symbol.disposeonDatabase(→engine_close) andStatement(→engine_finalize) sousingguarantees release even on throw; results MUST be freed before the method returns. - MUST roll back the transaction (
engine_rollback) whenfnintransaction()throws, and re-throw; commit only on normal return. - SHOULD register a
FinalizationRegistryas a backstop for handles that escaped ausingscope, while still treating explicit disposal as the contract (GC timing is not durability). - SHOULD mark a disposed object and return
ENGINE_ERR_MISUSE-equivalent on use-after-dispose rather than dereferencing a freed pointer.
Note — transaction durability lives in commit
engine_commit blocks until the WAL is durably stored (source §8 durability rule). The wrapper MUST surface that blocking commit as the resolution point of transaction() and MUST NOT report success before it returns ENGINE_OK. Never ack a write from a buffer before durability.
NAPI alternative — one package across Bun and Node
The bun:ffi path is Bun-only. To ship a single package usable in both Bun and Node, compile a NAPI (Node-API) native addon over the same engine.h. NAPI is ABI-stable across Node versions and is supported by Bun, so one .node binary per platform/arch serves both runtimes (source §5).
bun:ffi | NAPI addon | |
|---|---|---|
| Runtimes | Bun only | Bun and Node |
| Build step | none (loads dylib at runtime) | compile per platform/arch |
| Marshaling | in JS (this spec) | in the addon (C/C++/Rust napi-rs) |
| Distribution | ship libengine.${suffix} | ship prebuilt .node binaries |
| Use when | Bun-native, fastest to ship | cross-runtime single package |
- MAY ship the NAPI addon instead of (or alongside) the raw-FFI package when a single Bun+Node package is required.
- SHOULD keep the public TS surface (
open/exec/query/transaction/prepare/branch) identical across the FFI and NAPI builds so callers are runtime-agnostic. - SHOULD implement the addon over the same
engine.h(e.g. vianapi-rs) so there is exactly one engine boundary regardless of binding mechanism.
Path 2 — Server mode (Bun as client)
When Bun is just a client, run engine-server (the same library wrapped in a Postgres wire-protocol listener; 07). Because Bun ships a built-in Bun.sql Postgres client, connecting requires zero extra dependencies.
import { SQL } from "bun";
// Connect through the pooler endpoint, not the engine-server directly.
const sql = new SQL("postgres://user@pooler-host:6432/mydb");
const rows = await sql`SELECT id, body FROM notes WHERE id = ${1}`;
// tagged-template params are sent as bind parameters (not interpolated).
await sql.begin(async (tx) => {
await tx`UPDATE notes SET body = 'x' WHERE id = ${1}`;
await tx`INSERT INTO notes (id, body) VALUES (${2}, ${'y'})`;
}); // COMMIT acked only after WAL is durable
- MUST connect via the Postgres wire protocol exposed by
engine-server; no engine-specific client is needed. - SHOULD place a pooler (PgBouncer / pgcat in transaction mode) in front to absorb serverless connection bursts; point
Bun.sqlat the pooler endpoint, not the engine directly. See 07 — Server Mode. - SHOULD use tagged-template parameters (or the explicit parameter form) rather than string interpolation, so values bind as protocol parameters.
- MUST NOT assume multi-writer concurrency per database — the single-writer-per-DB ceiling holds in server mode too (source §4); concurrency scales by many small databases, not many writers per DB.
Note — same engine, different listener
The engine binary is identical to the embedded one; only the wire-protocol listener differs (source §1/§5). A tool can move from embedded to server mode without an engine rebuild — and a Postgres-native tool such as PostgREST can point at engine-server directly (source §11 / 12).
Packaging & distribution
The embedded path requires the native engine library to be present at runtime for the host's platform/arch. Distribution mirrors the prevailing prebuilt-binary pattern: a thin JS package plus per-platform binary subpackages installed via optional dependencies.
Platform / arch matrix
| Target | Library file | Bun suffix | Distribution package |
|---|---|---|---|
darwin-arm64 | libengine.dylib | dylib | @twilldb/bun-darwin-arm64 |
darwin-x64 | libengine.dylib | dylib | @twilldb/bun-darwin-x64 |
linux-x64 | libengine.so | so | @twilldb/bun-linux-x64 |
linux-arm64 | libengine.so | so | @twilldb/bun-linux-arm64 |
win32-x64 | engine.dll | dll | @twilldb/bun-win32-x64 |
Suffix handling & resolution
- MUST use Bun's
suffixto form the library filename (libengine.${suffix}) so one source path resolves the correct extension per OS. - MUST resolve the binary from the installed per-platform subpackage for the current
process.platform+process.arch, and fail with a clear, actionable error when none matches (naming the missingplatform-archpackage). - SHOULD publish each platform binary as an
optionalDependenciesentry keyed byos/cpuso the package manager installs only the matching one. - SHOULD embed an ABI version constant in both
engine.hand the wrapper and verify it atdlopentime, failing fast on mismatch rather than calling a stale symbol. - MAY support an
TWILLDB_ENGINE_PATHenvironment override for local builds and unusual deployment layouts.
Install flow
bun add @twilldb/bun
│
▼
resolves optionalDependencies by { os, cpu }
│
├─ darwin-arm64 → @twilldb/bun-darwin-arm64 (libengine.dylib)
├─ linux-x64 → @twilldb/bun-linux-x64 (libengine.so)
└─ ... → (exactly one matches; others skipped)
│
▼
import { open } from "@twilldb/bun"
│
▼
dlopen(`libengine.${suffix}`) from the resolved subpackage
│
▼
verify ABI version constant → ready
A thin JS package selects exactly one prebuilt native binary at install time; dlopen + ABI check at first use.
WASM is a separate target
The WebAssembly build (for Cloudflare Workers + R2) is not a prebuilt native binary and is not distributed through this matrix — it is a distinct compilation target with its own constraints (single-threaded, no native FS, storage via the Worker binding). It is a port, not a recompile. See 11 — Deployment Targets.
Configuration
| Knob | Scope | Effect |
|---|---|---|
| connection URL scheme | both | file:// → local, no egress; s3:///r2:// → disaggregated. Selects the storage backend; same call path. |
TWILLDB_ENGINE_PATH | embedded | Override the resolved library path (local builds, custom layouts). |
binding (ffi / napi) | embedded | Which binding mechanism the package uses; napi for Bun+Node single package. |
| pooler endpoint | server | Bun.sql connection string points at the pooler (transaction mode), not the engine. |
maxConnections (Bun.sql) | server | Client-side pool size; coordinate with the upstream pooler limits (07). |
finalizationBackstop | wrapper | Enable/disable the GC-based handle backstop; explicit using/close remains the contract. |
Failure modes & edge cases
| Failure | Mechanism | Handling |
|---|---|---|
| ABI mismatch | Installed libengine predates/postdates the wrapper's expected symbol set or struct layout. | Verify an embedded ABI version constant at dlopen; fail fast with a clear "engine ABI vX, wrapper expects vY — upgrade @twilldb/bun or the binary" error. Never call a stale symbol — that is undefined behaviour. |
| Missing native library | No per-platform subpackage matched os/cpu (unsupported arch, pruned optional dep, manual copy). | Surface an actionable error naming the expected @twilldb/bun-<platform>-<arch> package and the TWILLDB_ENGINE_PATH override; do not attempt to load a wrong-arch binary. |
engine_open returns NULL | Unknown URL scheme (engine rejects rather than defaulting) or storage init failure. | Throw an open error; no handle exists to query, so the message is generic but states the URL scheme. Validate the scheme client-side first for a better message. |
| Use-after-free of borrowed value | JS keeps a value/colname pointer after the result/statement advanced or was freed. | Wrapper copies all borrowed cstrings into JS before freeing; never exposes raw pointers. Reading after dispose returns a misuse error, not a crash. |
| Double free / use-after-dispose | close()/finalize() called twice, or method on a disposed object. | engine_close/engine_result_free are idempotent on NULL; the wrapper marks disposed and returns a misuse error rather than re-freeing. |
| Panic across FFI | Engine internal error. | Engine wraps every export in catch_unwind → ENGINE_ERR_INTERNAL; the binding maps it to an EngineError. No unwind crosses the boundary (02). |
| Server connection storm | Many serverless invocations open Postgres connections at once. | Pooler in transaction mode absorbs the burst; Bun.sql targets the pooler, not the engine (07). |
| Commit appears to hang | engine_commit blocking on a slow S3/CAS round-trip. | Expected for s3:// — commit pays the durable-over-network cost (source §4). Surface as latency, never ack early; tune via group commit at the backend (04). |
Dependencies & existing pieces to start from
- C ABI (
engine.h) - The stable boundary owned by 02 — Engine Core. This binding is downstream of it; pin the ABI version.
bun:ffi- Bun's built-in FFI module:
dlopen,FFIType,suffix,ptr/read. No third-party dependency for the embedded path. Bun.sql- Bun's built-in Postgres client for the server path. Zero extra deps.
- NAPI /
napi-rs - For the cross-runtime addon over the same
engine.h(Bun + Node). - Pooler
- PgBouncer / pgcat (transaction mode) for server-mode bursts — configured in 07 — Server Mode.
Acceptance criteria / definition of done
- MUST open a
file://database viabun:ffi, runexec/query/prepared/transaction/branch, and close cleanly with no leaked handles, results, or statements (validated under a leak check). - MUST open the same code against an
s3://URL with no source changes and pass the same functional suite (storage chosen by scheme only). - MUST map every non-OK
EngineStatusto a typedEngineErrorcarrying the copiedengine_last_errormessage and a correctretryableflag. - MUST demonstrate deterministic disposal:
usingscopes and explicitclose()/finalize()release engine resources on both normal return and thrown exceptions;transaction()rolls back on throw. - MUST fail fast and legibly on ABI mismatch and on a missing platform binary (no stale-symbol call, no wrong-arch load).
- SHOULD connect via
Bun.sqlthrough a pooler toengine-serverand pass the same functional suite over the wire. - SHOULD publish prebuilt binaries for at least
darwin-arm64,darwin-x64,linux-x64, andlinux-arm64, installable viabun addwith correct optional-dependency resolution. - SHOULD keep the public TS surface identical across the FFI and NAPI builds (a runtime-agnostic conformance test).
Open questions & risks
- FFI call overhead vs micro-batching. For very chatty workloads, does per-call FFI trampoline cost warrant a batched submit API (multiple statements per crossing), or does
bun:ffi's JIT'd path make it negligible? Quantify against the embedded micro-loop in 09. - Streaming large results. The wrapper buffers an
EngineResult; for large scans, expose the cursor API (engine_step/engine_column_value) as an async iterator? Coordinate with the engine's streaming cursor question in 02. - GC vs determinism. How aggressively to lean on
FinalizationRegistryas a backstop without letting callers treat GC timing as resource management — durability and lifetime must stay explicit. - NAPI vs FFI as the default. Ship FFI-only first (Bun-native, simplest) and add NAPI later, or lead with NAPI for cross-runtime reach from day one? Affects the packaging story.
- Worker thread safety. Is one engine handle usable across Bun Worker threads, or strictly one handle per thread? The single-writer-per-DB ceiling suggests per-thread handles; confirm against the engine's threading model.
- WASM binding shape. The Workers build cannot use
bun:ffior a.nodeaddon; what is the JS↔WASM call shape and does the@twilldb/bunsurface survive unchanged? See 11.