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 typeEncoded asNote
number (integer)integerWhole numbers bind as integers.
number (fractional)floatNon-integer numbers bind as floats.
bigintintegerFor values beyond the safe integer range.
booleaninteger (0/1)true1, false0.
stringstringBound verbatim.
Uint8ArrayblobCarried base64-encoded across the ABI.
number[]vectorA numeric vector (joined as a comma list).
nullnullBinds 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, or undefined.
  • 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 BEGINCOMMIT. 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.

Related

Twill DB documentation · Licensed under AGPL-3.0. · Author