Embedded with Hono
Hono runs on Bun directly, so the engine is just a value your route handlers close over. This is the same notes API as the plain-Bun page, but with a real router — and it shows the idiomatic place to put the shared handle.
What we're building
A notes API — GET /notes and POST /notes — on Hono. Prerequisites: bun add @twilldb/bun hono and the shared db.ts from the overview.
Step 1 — import the shared handle
Hono handlers are closures, so the simplest correct pattern is to import the one shared db directly. It was opened once in db.ts; every handler uses that same instance.
// app.ts
import { Hono } from "hono";
import { db } from "./db";
const app = new Hono();
Breakdown: there's no per-request setup and no connection pool to manage — embedded means the "connection" is a function call into in-process memory. The handle is created when db.ts is first imported and lives for the process lifetime.
Step 2 — a read route
app.get("/notes", (c) => {
const rows = db.query("SELECT * FROM notes ORDER BY id DESC LIMIT 50");
return c.json(rows);
});
Breakdown: db.query(sql) returns all rows as an array of objects; c.json(rows) serializes it. No await is needed — embedded queries are synchronous function calls, not network promises.
Step 3 — a write route with a parameterized insert
app.post("/notes", async (c) => {
const { body } = await c.req.json<{ body: string }>();
if (!body) return c.json({ error: "body required" }, 400);
db.query("INSERT INTO notes (body, ts) VALUES (?, ?)", [body, new Date().toISOString()]);
return c.json({ ok: true }, 201);
});
export default app; // bun run ./app.ts
Breakdown:
c.req.json<T>()reads and types the request body.- The
?placeholders are bound positionally from the array — the user'sbodyis passed as data, never spliced into the SQL string. export default applets Bun serve it directly withbun run ./app.ts(Bun reads the default export'sfetch).
Step 4 — map engine errors with middleware
Centralize error translation so each handler stays clean. A write conflict (another writer committed first) is retryable and maps naturally to 409.
import { EngineError } from "@twilldb/bun";
app.onError((err, c) => {
if (err instanceof EngineError) {
if (err.retryable) return c.json({ error: "conflict, retry" }, 409);
return c.json({ error: err.message, status: err.status }, 400);
}
return c.json({ error: "internal" }, 500);
});
Breakdown: EngineError.retryable is true for write-conflict and transient storage errors. Anything thrown inside a db.transaction() callback also lands here, after the engine has already rolled back.
Step 5 — an atomic multi-statement write
app.post("/transfer", async (c) => {
const { from, to, amount } = await c.req.json<{ from: number; to: number; amount: number }>();
db.transaction((tx) => {
tx.query("UPDATE accounts SET balance = balance - ? WHERE id = ?", [amount, from]);
tx.query("UPDATE accounts SET balance = balance + ? WHERE id = ?", [amount, to]);
});
return c.json({ ok: true });
});
Breakdown: db.transaction(fn) wraps the two updates in BEGIN/COMMIT; the commit blocks until the WAL is durable, then returns. If either statement throws, the engine rolls back and the throw propagates to app.onError.