Embedded with Elysia
Elysia is a Bun-native framework with a built-in schema/validation system. The integration is the same shared handle as everywhere else — the Elysia-specific part is registering it with decorate so it's available as ctx.db, and validating request bodies declaratively.
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
bodyschema is the validation gate — a malformed request is rejected with a422automatically, 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).