Skip to content

db

Package @theoven/db
Adds to context ctx.db — the native client
Endpoints none, unless you ask for a health path
Creates files none
Creates tables none
Status shipped
Terminal window
bun add @theoven/db @theoven/db-drizzle
src/app.ts
import { createApp } from '@theoven/core'
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 once at boot, verifies the connection, and closes it cleanly on shutdown. That is the whole job.

ctx.db is the native client — the Drizzle instance, the PrismaClient — with nothing of ours wrapped around it:

app.get('/users', (ctx) => ctx.db.select().from(users).where(eq(users.active, true)))

None by default. A health endpoint is opt-in:

db(provider, { healthPath: '/_health/db' })
Method Path Purpose Auth
GET whatever you set 200 healthy, 503 not none

It answers 503 rather than a 200 carrying { healthy: false }, because a load balancer reads the status line. It is off by default because a brick that silently adds routes is one that shows up unexplained in oven routes.

Option Default Purpose
healthPath false mount a health endpoint
checkOnBoot true verify the connection at boot rather than on the first query

checkOnBoot is on because a misconfigured database that fails at boot costs one restart, while the same mistake found on the first request costs a deploy that looked green.

import { transaction } from '@theoven/db'
await transaction(ctx.db, async (tx) => {
await tx.insert(orders).values(order)
await tx.update(inventory).set({ count: remaining })
})

Portable across providers. You can equally use the ORM’s own form — ctx.db.transaction(...), ctx.db.$transaction(...) — and should, when you want that ORM’s options.

A provider without transaction support refuses rather than running the work unwrapped. Work that silently escapes its transaction is the failure nobody notices until the data is wrong. db-mongoose is the one that does this, and its page explains why — Mongoose scopes a transaction to a session, not to a client.

For endpoints where a half-written request is worse than a failed one, wrap the whole thing:

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

ctx.db becomes the transaction-scoped client for the duration, so handlers need no change. A request that throws — from a handler, from validation, from another middleware inside it — rolls back everything it wrote.

Added to an app with no database brick, it does nothing rather than failing — so the line is safe to leave in a template.

A dependency scopes it to exactly the routes that name it, instead of a path prefix:

src/deps.ts
import { dependency } from '@theoven/core'
import { transaction } from '@theoven/db'
export const tx = dependency('tx', async function* (ctx) {
const handle = await begin(ctx.db)
try {
yield handle
await handle.commit()
} catch (error) {
await handle.rollback()
throw error
}
})
src/routes/orders/index.post.ts
export default route({ deps: { tx }, body: orderSchema }, async (ctx) => {
await ctx.deps.tx.insert(orders).values(ctx.body)
await ctx.deps.tx.update(stock).set({ count: left })
return { ok: true }
})

Which to reach for:

transactional() a tx dependency
Scope a path prefix the routes that declare it
Handler changes none — ctx.db is scoped use ctx.deps.tx
Visible in the route ✗ ✓, it is in the signature
Nested with other setup ✗ ✓, dependencies compose

transactional() stays the lighter option when a whole prefix writes. The dependency is better when only some routes do, because a reader can see which ones from the route itself.

Files: none. Tables: none. This brick migrates nothing and writes nothing — it manages a connection. What the adapter under it creates is on that adapter’s page: db-drizzle, db-mongoose.

Pick an adapter, register it, and query natively:

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 }), { healthPath: '/_health/db', checkOnBoot: true }),
)
src/routes/users/index.get.ts
import { eq } from 'drizzle-orm'
import { users } from '../../schema'
export default async ({ db }) => db.select().from(users).where(eq(users.active, true))

A transaction that rolls back correctly, including across await:

import { transaction } from '@theoven/db'
await transaction(ctx.db, async (tx) => {
await tx.insert(orders).values(order)
await tx.update(stock).set({ count: left }).where(eq(stock.sku, order.sku))
})

Or per request, so a handler that throws writes nothing:

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

transaction() throws — naming the provider — on an adapter that declares no transaction support, rather than running your work unwrapped. db-mongoose is the case that exists.

healthPath mounts an endpoint. checkHealth is the same check, for when you want it inside one of your own:

src/routes/health.get.ts
import { checkHealth } from '@theoven/db'
export default async (ctx) => {
const database = await checkHealth(ctx.db)
ctx.status = database ? 200 : 503
return { status: database ? 'healthy' : 'degraded', database }
}

It issues a real query — select 1, or a ping on Mongo — rather than asking whether a pool object exists. A health check that cannot fail is not a health check, and one that only inspects a client reports healthy for a connection whose server has gone away.

Returns false rather than throwing, so a health endpoint answers 503 instead of 500. A load balancer reads the status line either way, but a 500 looks like a bug in the endpoint rather than a statement about the database.

connect, health, close required of every provider
transaction optional — declared per provider, and refused loudly when absent
Health endpoint opt-in via healthPath
Boot-time connection check on by default
Query API ✗ deliberately — ctx.db is the native client (D16)
Migrations ✗ — your ORM’s tooling
Several databases in one app ✗ — one connection per app today

What fails at boot: an unreachable database when checkOnBoot is on, and a provider whose health returns false — reported with the provider’s name rather than a stack trace from inside a driver.

The contract itself is tested against a fake provider — the right tool here, because what is under test is the brick’s own behaviour: that connect runs once, that a failed boot check throws with the provider named, that healthPath answers 503 when unhealthy, that close runs on shutdown, and that transaction() refuses a provider that declares none.

That the contract is genuinely portable is proven elsewhere, by two structurally different adapters passing it: db-drizzle (SQL, client-scoped transactions) and db-mongoose (documents, session-scoped, no portable transaction). One adapter would have proved nothing — a contract shaped around a single ORM fits that ORM and nothing else.

Four methods:

import type { DatabaseProvider } from '@theoven/db'
export function myProvider(options): DatabaseProvider<MyClient> {
return {
name: 'my-orm:postgres',
connect: () => createClient(options),
health: async (client) => { await client.query('select 1'); return true },
close: (client) => client.end(),
transaction: (client, work) => client.transaction(work), // optional
}
}

health should issue a real query rather than reporting whether a pool object exists. A health check that cannot fail is not a health check.

  • Switching ORMs still means rewriting queries. The contract makes lifecycle portable, not query syntax — deliberately (D16).
  • One database per app today. Multiple named connections are not yet supported.