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's body is passed as data, never spliced into the SQL string.
  • export default app lets Bun serve it directly with bun run ./app.ts (Bun reads the default export's fetch).

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.

Next

Twill DB documentation · Licensed under BUSL-1.1. · Author