Skip to content

db-drizzle

Package @theoven/db-drizzle
Adds to context ctx.db — the Drizzle instance, typed from your schema
Endpoints none
Creates files none directly; oven db generate writes migrations
Creates tables only what your own schema declares
Status shipped
Terminal window
bun add @theoven/db @theoven/db-drizzle drizzle-orm
src/app.ts
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 })))

Connects Drizzle to SQLite or Postgres, verifies the connection at boot, and closes it on shutdown. ctx.db is the Drizzle instance itself — not a wrapper, not a proxy. Your queries are Drizzle queries, which is also why a coding model already knows how to write them (D16).

The brick’s job is only the part every application repeats and gets subtly wrong: connecting once, failing loudly at boot rather than on the first request, transactions that actually roll back, and a clean shutdown.

None by default. The db brick can mount one health endpoint if you ask:

db(drizzleSqlite({ schema }), { healthPath: '/healthz' })
Method Path Purpose Auth
GET whatever you pass as healthPath runs select 1; 200 healthy, 503 not none — guard it yourself

Off unless asked for, because a brick that silently adds a route is one that appears unexplained in oven routes, and health endpoints are something people want to place and protect themselves.

bun:sqlite and Drizzle are not alternatives — Drizzle runs on bun:sqlite, so this is Bun’s own driver underneath.

The reason the default is Drizzle rather than raw SQL is portability. Raw SQL strings are SQLite-dialect-bound, so a raw default means moving to Postgres rewrites every query. With Drizzle, moving is one line:

// before
db(drizzleSqlite({ url: './data.db', schema }))
// after — every query unchanged
db(drizzlePostgres({ url: env.url('DATABASE_URL'), schema }))
Option Default Purpose
url ./data.db file path, or :memory:
schema — your Drizzle schema, which is what types ctx.db
logger false log every statement
tune true apply the pragmas below

tune sets journal_mode = WAL, synchronous = NORMAL, busy_timeout = 5000 and foreign_keys = ON. Without WAL, SQLite serialises readers behind a writer, and a web application meets this as sporadic SQLITE_BUSY under exactly the concurrency it was built for.

Option Default Purpose
url — connection string; read it from the environment
schema — your Drizzle schema
max 10 pooled connections

The connection URL is deliberately kept out of the provider’s name, because provider names reach logs and a connection string carries a password.

ctx.db is Drizzle. Everything you know about Drizzle applies, and nothing here is Oven-specific:

src/routes/users/index.get.ts
import { desc } from 'drizzle-orm'
import { users } from '../../schema'
export default async ({ db }) => db.select().from(users).orderBy(desc(users.createdAt)).limit(20)
src/routes/users/[id].get.ts
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { users } from '../../schema'
export const params = z.object({ id: z.uuid() })
export default async ({ db, params }) => {
const [user] = await db.select().from(users).where(eq(users.id, params.id))
if (!user) throw new NotFound(`No user ${params.id}.`)
return user
}

Relational queries, prepared statements, db.query.*, partial selects — all of it is Drizzle’s, untouched.

Use transaction() from @theoven/db, not Drizzle’s own on SQLite — see the warning below:

import { transaction } from '@theoven/db'
await transaction(ctx.db, async (tx) => {
await tx.insert(orders).values(order)
await tx.update(stock).set({ count: remaining }).where(eq(stock.sku, order.sku))
// Anything thrown here rolls both statements back, awaits included.
})

To wrap a whole request instead, register the middleware — a request that throws writes nothing:

import { transactional } from '@theoven/db'
app.use(transactional())

Files: the SQLite database file at url (default ./data.db) when it does not exist — .gitignore it. Postgres creates nothing locally.

Tables: only what your own schema declares. This brick never migrates anything on its own.

Generate and apply migrations with drizzle-kit — see Limitations on oven db.

Something may need a client before the app exists — auth-basic builds its store at construction, so it takes one. Let the brick adopt yours rather than opening a second:

const sqlite = new Database('./data.db')
const client = drizzle(sqlite, { schema })
const app = createApp()
.use(db(drizzleSqlite({ client: sqlite, schema })))
.use(auth(basicAuth({ db: client, secret })))

Two connections to a file work and waste a handle. Two connections to :memory: are two separate databases — which shows up as no such table on a schema you can watch being created, and is a genuinely confusing hour.

An adopted connection is not closed on shutdown: it was not the brick’s to open.

connect, health, close ✓ both drivers
transaction ✓ both — SQLite via explicit begin/commit/rollback, and serialised
Adopting an existing client ✓ SQLite (client)
Connection pooling Postgres only (max); SQLite is one connection by design
Migrations ✗ — drizzle-kit’s job, not this brick’s
MySQL ✗ — no entry point yet

What fails at boot: an unreachable database, or a SQLite path that cannot be opened. With checkOnBoot (on by default) that is one failed restart instead of a deploy that looks green and 500s on its first request.

SQLite runs on every bun test: booting in memory and from a file, data surviving a restart, a bad path refused at boot, insert/select through ctx.db, and that the client really is Drizzle’s API rather than a wrapper. Health is checked live, and the mounted endpoint too.

Transactions get the most attention, because this is where the provider does something of its own: commit, rollback on failure, and — kept as an explicit test — a demonstration that Drizzle’s own bun-sqlite transaction does not roll back an async failure, so the difference stays a documented fact rather than folklore. The transactional() middleware is tested for rollback on a failed request, commit on a successful one, path scoping, and doing nothing when no database is registered. Tuning is asserted by reading the pragmas back, on and off.

Postgres runs the same shape — connect, typed ctx.db, async rollback, commit, request-scoped transaction, pool release — but only when POSTGRES_URL is set. Without it the suite skips with a printed notice, and it has not run on the author’s machine; CI provides the server.

  • MySQL is untested. Drizzle supports it; this provider has no MySQL entry point yet.
  • oven db generate is not wired up. The CLI reports that the command needs this package; migrations are run with drizzle-kit directly for now.
  • One connection per app. Read replicas and multiple databases are not yet supported.