What we're building

The notes API — GET /notes and POST /notes — on Elysia, with body validation. Prerequisites: bun add @twilldb/bun elysia and the shared db.ts from the overview.

Step 1 — register the handle with decorate

You can import the shared db directly, but Elysia's idiom is to decorate the context so handlers receive it as ctx.db. This keeps handlers decoupled and makes testing easy (swap the decorated value).

// app.ts
import { Elysia, t } from "elysia";
import { db } from "./db";

const app = new Elysia()
  .decorate("db", db);

Breakdown: decorate("db", db) attaches the one shared handle to every request context. It's the same instance for all requests — decorate registers a value once, it does not create one per request.

Step 2 — a read route

app.get("/notes", ({ db }) =>
  db.query("SELECT * FROM notes ORDER BY id DESC LIMIT 50"),
);

Breakdown: the handler destructures db from the context. Returning the array directly is enough — Elysia serializes it to JSON. Queries are synchronous, so no await.

Step 3 — a validated write route

Elysia's t schema validates the body before your handler runs, so by the time you reach the insert, body.body is guaranteed to be a string.

app.post(
  "/notes",
  ({ db, body }) => {
    db.query("INSERT INTO notes (body, ts) VALUES (?, ?)", [
      body.body,
      new Date().toISOString(),
    ]);
    return { ok: true };
  },
  {
    body: t.Object({ body: t.String({ minLength: 1 }) }),
  },
);

app.listen(3000);

Breakdown:

  • The third argument's body schema is the validation gate — a malformed request is rejected with a 422 automatically, before the handler.
  • The validated value is still bound through ? placeholders — validation checks shape, parameter binding prevents injection. You want both.
  • app.listen(3000) starts the Bun server.

Step 4 — transactions and error mapping

Use db.transaction() for atomic multi-statement writes, and Elysia's onError to translate engine errors.

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

app
  .onError(({ error, set }) => {
    if (error instanceof EngineError) {
      set.status = error.retryable ? 409 : 400;
      return { error: error.message };
    }
    set.status = 500;
    return { error: "internal" };
  })
  .post(
    "/transfer",
    ({ db, body }) => {
      db.transaction((tx) => {
        tx.query("UPDATE accounts SET balance = balance - ? WHERE id = ?", [body.amount, body.from]);
        tx.query("UPDATE accounts SET balance = balance + ? WHERE id = ?", [body.amount, body.to]);
      });
      return { ok: true };
    },
    { body: t.Object({ from: t.Number(), to: t.Number(), amount: t.Number() }) },
  );

Breakdown: a throw inside the transaction callback rolls back the engine and surfaces in onError; EngineError.retryable distinguishes a retryable write conflict (409) from a hard error (400).

Next

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