What we're building

A tiny notes API with two endpoints — GET /notes (list) and POST /notes (create) — backed by an embedded file:// database. Prerequisites: bun add @twilldb/bun and the shared db.ts module from the overview.

Step 1 — the shared handle

The database is opened once in its own module so every part of the app imports the same handle. Opening per request would be wrong: it's a long-lived resource, and one process should hold one handle.

// db.ts
import { open, type Database } from "@twilldb/bun";

export const db: Database = open(process.env.TWILLDB_URL ?? "file://./app.db");

db.exec(`CREATE TABLE IF NOT EXISTS notes (
  id   INTEGER PRIMARY KEY,
  body TEXT NOT NULL,
  ts   TEXT NOT NULL
)`);

Breakdown: open() picks the backend from the URL scheme (file:// here). db.exec() runs a statement that returns no rows; CREATE TABLE runs in autocommit, so the schema exists before the first request. IF NOT EXISTS makes boot idempotent across restarts.

Step 2 — the server

Bun.serve takes a single fetch(req) function. We import the shared handle and branch on method + path.

// server.ts
import { db } from "./db";

Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);

    // GET /notes — list the 50 most recent
    if (url.pathname === "/notes" && req.method === "GET") {
      const rows = db.query("SELECT * FROM notes ORDER BY id DESC LIMIT 50");
      return Response.json(rows);
    }

    // POST /notes — create one
    if (url.pathname === "/notes" && req.method === "POST") {
      const { body } = (await req.json()) as { body: string };
      if (!body) return new Response("body required", { status: 400 });
      db.query("INSERT INTO notes (body, ts) VALUES (?, ?)", [body, new Date().toISOString()]);
      return Response.json({ ok: true }, { status: 201 });
    }

    return new Response("not found", { status: 404 });
  },
});
console.log("listening on http://localhost:3000");

Breakdown:

  • db.query(sql) with no params buffers and returns all rows as an array of plain objects (column → value).
  • db.query(sql, params) binds positional ? placeholders to the array — this is how you pass user input safely. Never string-concatenate it into SQL.
  • Values come back as strings (or null); cast in your app if you need numbers/dates. Response.json(rows) serializes the array directly.

Step 3 — run it

bun run ./server.ts

# in another shell
curl localhost:3000/notes
curl -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"body":"hello"}'

Step 4 — transactions & error handling

For multi-statement writes that must be atomic, wrap them in db.transaction(). It commits durably on normal return and rolls back + rethrows on error — so you translate the throw into an HTTP status.

import { EngineError } from "@twilldb/bun";

try {
  db.transaction((tx) => {
    tx.query("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
    tx.query("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
  });
} catch (e) {
  if (e instanceof EngineError && e.retryable) {
    // write conflict or transient storage error — safe to retry
    return new Response("conflict, retry", { status: 409 });
  }
  throw e;
}

Breakdown: EngineError carries the numeric status and a retryable flag (true for write-conflict / transient storage errors). A conflict means another writer committed first — retrying the whole transaction is the correct response.

Clean shutdown

process.on("beforeExit", () => db.close());

Next

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