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 |
Install
Section titled “Install”bun add @theoven/db @theoven/db-drizzleimport { 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 })))What it does
Section titled “What it does”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)))Endpoints
Section titled “Endpoints”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.
Configuration
Section titled “Configuration”| 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.
Transactions
Section titled “Transactions”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.
One transaction per request
Section titled “One transaction per request”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.
Or one transaction per route
Section titled “Or one transaction per route”A dependency scopes it to exactly the routes that name it, instead of a path prefix:
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 }})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.
What it creates
Section titled “What it creates”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:
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 }),)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.
Checking health yourself
Section titled “Checking health yourself”healthPath mounts an endpoint. checkHealth is the same check, for when you want it inside one
of your own:
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.
Capabilities
Section titled “Capabilities”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.
How it is verified
Section titled “How it is verified”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.
Writing a provider
Section titled “Writing a provider”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.
Limitations
Section titled “Limitations”- 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.