4. A database
So far the routes have stored things in a Map. Time to make them real.
Add the brick
Section titled “Add the brick”bun add @theoven/db @theoven/db-drizzle drizzle-ormbun add -d drizzle-kitimport { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const notes = sqliteTable('notes', { id: text('id').primaryKey(), title: text('title').notNull(), body: text('body'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),})import { db } from '@theoven/db'import { drizzleSqlite } from '@theoven/db-drizzle'import * as schema from './schema'
export const app = createApp().use(db(drizzleSqlite({ url: './data.db', schema })))oven db generate # writes a migration from the schemaoven db migrate # applies itThat is the whole setup. No connection code, no pool to manage, no shutdown hook to remember.
ctx.db is Drizzle
Section titled “ctx.db is Drizzle”import { routesFor } from '@theoven/core'import type { app } from './app'
export const route = routesFor<typeof app>()Declare that once, and every route file can see what the app registered. Then:
import { desc } from 'drizzle-orm'import { z } from 'zod'import { route } from '../../route'import { notes } from '../../schema'
export default route( { query: z.object({ limit: z.coerce.number().int().max(100).default(20) }) }, (ctx) => ctx.db.select().from(notes).orderBy(desc(notes.createdAt)).limit(ctx.query.limit),)ctx.db is the Drizzle client, typed from your schema. Not a wrapper, not a repository, not
an Oven query API. Autocomplete on it is Drizzle’s autocomplete; an error from it is Drizzle’s
error; a question about it is answerable from Drizzle’s documentation.
Writing
Section titled “Writing”import { z } from 'zod'import { route } from '../../route'import { notes } from '../../schema'
export default route( { body: z.object({ title: z.string().min(1).max(200), body: z.string().optional() }) }, async (ctx) => { const [note] = await ctx.db .insert(notes) .values({ id: crypto.randomUUID(), title: ctx.body.title, body: ctx.body.body ?? null, createdAt: new Date(), }) .returning()
ctx.status = 201 return note },)Not found is thrown, not returned
Section titled “Not found is thrown, not returned”import { NotFound } from '@theoven/core'import { eq } from 'drizzle-orm'import { z } from 'zod'import { route } from '../../route'import { notes } from '../../schema'
export default route({ params: z.object({ id: z.uuid() }) }, async (ctx) => { const [note] = await ctx.db.select().from(notes).where(eq(notes.id, ctx.params.id)) if (!note) throw new NotFound(`No note with id ${ctx.params.id}.`) return note})A malformed id never reaches the query: z.uuid() rejects it as a 422 first. The handler only
ever runs on input that already type-checks at runtime.
Transactions
Section titled “Transactions”import { transaction } from '@theoven/db'
await transaction(ctx.db, async (tx) => { await tx.insert(notes).values(note) await tx.update(counters).set({ total: next })})Portable across providers. Drizzle’s own ctx.db.transaction(...) works too, and you should use
it when you want Drizzle’s options.
For a route where a half-written request is worse than a failed one, wrap the whole thing:
import { transactional } from '@theoven/db'
app.use('/admin', transactional())Moving to Postgres
Section titled “Moving to Postgres”import { drizzlePostgres } from '@theoven/db-drizzle'
export const app = createApp().use( db(drizzlePostgres({ url: env.string('DATABASE_URL'), schema })),)Change the import, change the line, change dialect in drizzle.config.ts. Every query above
is unchanged — that is the entire reason the default is Drizzle over bun:sqlite rather than
raw SQL. Raw SQL strings are dialect-bound, and a raw default would mean rewriting every query
the day you outgrow SQLite.
Prefer MongoDB? db-mongoose puts a Mongoose Connection on
ctx.db through the same contract.
Auth → — signup, login and guarded routes, without writing any of it.