Connect from Bun (embedded)
The embedded path links the native engine in-process and drives it over the C ABI through bun:ffi. The @twilldb/bun wrapper hides the raw pointers behind a small typed surface — open, exec, query, prepare, transaction, and branch — at function-call latency, with deterministic resource cleanup.
Load the library
The wrapper auto-discovers the built native library from target/release then target/debug. Build it once before running anything that imports @twilldb/bun; the loader binds whatever it finds, so a stale binary runs silently if you forget to rebuild after an engine change.
cargo build -p twill-engine --release # → target/release/libengine.{a,so,dylib}
cd clients/bun && bun install
To point at a specific build (a non-standard target dir, a packaged artifact, or a CI path), set TWILLDB_ENGINE_PATH to the absolute library path; it overrides auto-discovery.
TWILLDB_ENGINE_PATH=/abs/path/libengine.so bun test
Rebuild after engine or ABI changes
The wrapper pins an expected ABI version. After any change that touches the C ABI or engine behaviour, rebuild the release libengine before running Bun — otherwise you are testing a stale binary against the new wrapper.
Open a database
open(url) returns a Database handle. The URL scheme selects the durable storage backend — file:// for pure-embedded, s3:///r2:///gs:// for disaggregated. An unknown scheme or a storage-init failure throws an EngineError; nothing is silently defaulted.
import { open } from "@twilldb/bun";
using db = open("file://./local.db"); // pure-embedded, zero network
// using db = open("s3://my-bucket/app"); // disaggregated; creds from the environment
The using declaration disposes the handle at scope exit (see Resource management). Bucket credentials always come from the environment, never from code.
Run statements: exec
exec(sql) runs a single statement with no result set and returns the number of rows affected. Use it for DDL and for writes whose rows you do not need back.
db.exec(`CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)`);
const affected = db.exec("INSERT INTO notes VALUES (1, 'hello')");
console.log(affected); // 1
DDL is autocommit-only
CREATE/DROP TABLE run only in autocommit; inside an explicit transaction() they fail with a transaction error. Row DML (INSERT/UPDATE/DELETE) is fully transactional. See the SQL reference for the supported surface.
Query and parameter binding
query(sql, params?) runs a query and buffers every row into an array. Each row is a plain object keyed by column name; all values come back as strings, and a SQL NULL comes back as JavaScript null. Cast on the application side if you need numbers or dates.
const rows = db.query("SELECT id, body FROM notes");
// [{ id: "1", body: "hello" }] ← values are strings; NULL → null
Pass positional parameters with ? placeholders and a value array. This is the safe path — values never touch the SQL text — and is what you should use for any user-supplied input.
db.query("INSERT INTO notes VALUES (?, ?)", [2, "from a param"]);
const some = db.query(
"SELECT id, body FROM notes WHERE id = ?",
[2],
);
Parameter types
A Param is one of the following. Each is encoded with a one-character type tag before it crosses the FFI boundary.
| JS type | Encoded as | Note |
|---|---|---|
number (integer) | integer | Whole numbers bind as integers. |
number (fractional) | float | Non-integer numbers bind as floats. |
bigint | integer | For values beyond the safe integer range. |
boolean | integer (0/1) | true → 1, false → 0. |
string | string | Bound verbatim. |
Uint8Array | blob | Carried base64-encoded across the ABI. |
number[] | vector | A numeric vector (joined as a comma list). |
null | null | Binds SQL NULL. |
Prepared statements
prepare(sql) compiles a statement once and returns a reusable Statement. Reuse it across many bind/execute cycles to skip re-parsing on the hot path. The methods take positional bind values as arguments:
- MAY
all(...params)— execute and buffer every row into an array. - MAY
get(...params)— execute and return the first row, orundefined. - MAY
run(...params)— execute a write and return rows affected.
using insert = db.prepare("INSERT INTO notes VALUES (?, ?)");
for (const [id, body] of [[10, "a"], [11, "b"], [12, "c"]]) {
insert.run(id, body); // reused, re-bound each call
}
using byId = db.prepare("SELECT id, body FROM notes WHERE id = ?");
const one = byId.get(11); // { id: "11", body: "b" } | undefined
const many = byId.all(10); // [{ id: "10", body: "a" }]
query(sql, params) is itself a one-shot prepared statement under the hood; reach for an explicit prepare when the same SQL runs repeatedly.
Transactions
db.transaction(fn) wraps fn in BEGIN … COMMIT. On a normal return it commits and returns fn's value; if fn throws, it rolls back and re-throws the original error. The commit blocks until the WAL is durable — the call does not return until the write is crash-safe.
const id = db.transaction((tx) => {
tx.exec("INSERT INTO notes VALUES (20, 'tx-a')");
tx.exec("INSERT INTO notes VALUES (21, 'tx-b')");
return 21; // returned from transaction()
}); // ← COMMIT returns only after WAL fsync
// A throw inside the body rolls back and re-throws:
db.transaction((tx) => {
tx.exec("INSERT INTO notes VALUES (22, 'never')");
throw new Error("abort"); // ROLLBACK, then this error propagates
});
DDL inside a transaction is rejected
Keep CREATE/DROP TABLE out of the transaction body — they run in autocommit only and will throw a transaction error inside transaction().
Resource management
Both Database and Statement own native resources and implement Symbol.dispose, so a using declaration releases them deterministically at scope exit — no GC dependency. You can also release explicitly:
- SHOULD prefer
using db = open(...)/using stmt = db.prepare(...)so cleanup is automatic and exception-safe. - MAY call
db.close()/stmt.finalize()when you need to release before scope exit. Using a closed handle throws a misuse error.
{
using db = open("file://./local.db");
// ... use db ...
} // db is disposed here
// or explicitly:
const db2 = open("file://./other.db");
try { /* ... */ } finally { db2.close(); }
Errors, retries, and the commit LSN
Failures throw an EngineError carrying the numeric engine status and a retryable flag. retryable is true for a write–write conflict (ERR_CONFLICT, first-committer-wins lost) or a transient storage error (ERR_STORAGE) — retry the transaction in those cases. Other statuses (SQL errors, misuse) are not worth retrying.
import { EngineError } from "@twilldb/bun";
function withRetry<R>(fn: () => R, attempts = 3): R {
for (let i = 0; ; i++) {
try {
return fn();
} catch (e) {
if (e instanceof EngineError && e.retryable && i < attempts - 1) continue;
throw e;
}
}
}
withRetry(() => db.transaction((tx) => tx.exec("UPDATE notes SET body='x' WHERE id=1")));
db.lastLsn returns the commit LSN of the last commit on this connection as a bigint — the monotonic, durable commit point you just reached. It is also the fork point a branch is taken at.
db.transaction((tx) => tx.exec("INSERT INTO notes VALUES (30, 'lsn')"));
console.log(db.lastLsn); // e.g. 873n
Branching
db.branch(name) creates an instant copy-on-write fork at this connection's current committed LSN and returns a new Database bound to the branch. The branch sees the base's committed data but writes in isolation — neither the base nor a sibling ever observes a branch's writes, and creating one copies no pages. Close the returned handle when done.
using preview = db.branch("preview");
preview.exec("INSERT INTO notes VALUES (40, 'branch-only')");
db.query("SELECT id FROM notes WHERE id = 40"); // [] — base unaffected
preview.query("SELECT id FROM notes WHERE id = 40"); // [{ id: "40" }]
Branch scope
Branch-of-branch and branching inside an open transaction are rejected (the call throws). See Branching for the isolation rules and lifecycle.