Skip to content

db-mongoose

Package @theoven/db-mongoose
Adds to context ctx.db — a Mongoose Connection
Endpoints none
Creates files none
Creates tables only the collections your models declare
Status shipped
Terminal window
bun add @theoven/db @theoven/db-mongoose mongoose
src/app.ts
import { createApp, env } from '@theoven/core'
import { db } from '@theoven/db'
import { mongooseDb } from '@theoven/db-mongoose'
export const app = createApp().use(db(mongooseDb({ url: env.string('MONGO_URL') })))
src/routes/users/index.get.ts
import { userSchema } from '../../models'
export default async ({ db }) => db.model('User', userSchema).find()

Connects Mongoose to MongoDB, verifies it at boot with a real ping, and closes it on shutdown. ctx.db is a Mongoose Connection — models come off it, and every Mongoose feature is reachable because nothing is wrapped (D16).

It is also the adapter that proves the database contract is a contract; see What this adapter is for.

None by default. The db brick mounts one health endpoint if you ask for it:

db(mongooseDb({ url: env.string('MONGO_URL') }), { healthPath: '/healthz' })
Method Path Purpose Auth
GET whatever you pass as healthPath pings the server; 200 healthy, 503 not none — guard it yourself

ctx.db is a Mongoose Connection, created with createConnection — not the mongoose default export everyone reaches for first.

Models come off it: ctx.db.model('User', schema). The difference matters in three places. Two Oven apps in one process do not fight over global state. A test suite gets its own connection instead of sharing one with whatever ran before it. And app.close() genuinely closes this app’s connection rather than a global one something else may still be using.

Option Default Purpose
url — required; mongodb://…
connection — passed to Mongoose unchanged

Everything Mongoose can be told about pooling, timeouts and TLS goes in connection. Restating those options here would mean falling behind Mongoose’s own within a release.

mongooseDb({
url: env.string('MONGO_URL'),
connection: { maxPoolSize: 20, serverSelectionTimeoutMS: 5000 },
})

Define schemas once and compile models off ctx.db:

src/models.ts
import { Schema } from 'mongoose'
export const userSchema = new Schema({
email: { type: String, required: true, unique: true },
name: String,
createdAt: { type: Date, default: Date.now },
})
src/routes/users/index.post.ts
import { z } from 'zod'
import { userSchema } from '../../models'
export const body = z.object({ email: z.email(), name: z.string() })
export default async ({ db, body }) => db.model('User', userSchema).create(body)
src/routes/users/[id].get.ts
import { z } from 'zod'
import { userSchema } from '../../models'
export const params = z.object({ id: z.string() })
export default async ({ db, params }) => {
const user = await db.model('User', userSchema).findById(params.id).lean()
if (!user) throw new NotFound(`No user ${params.id}.`)
return user
}

db.model() is idempotent for a given name and connection, so calling it per request is cheap — Mongoose returns the already-compiled model.

Aggregations, populate, change streams, bulkWrite, indexes: all Mongoose’s, all available, none of them wrapped.

Files: none.

Collections: only what your models declare, created by Mongo on first write. This brick migrates nothing.

The db brick’s health path issues a real ping to the server. readyState would be cheaper and would report healthy for a connection whose server had gone away — a health check that cannot fail is not a health check.

This is the one place the adapter does not fit the shared contract, and it is worth explaining rather than hiding.

// This throws for Mongoose, naming the provider.
await transaction(ctx.db, async (tx) => { /* … */ })
// This is the form to use.
await ctx.db.transaction(async (session) => {
await Order.create([order], { session })
await Inventory.updateOne(query, update, { session })
})

Every SQL provider scopes a transaction to a client: Drizzle hands you a tx and every query through it is inside the transaction. Mongoose scopes a transaction to a session, attached to each query individually — there is no session-scoped Connection to hand you.

We could fake one by proxying the connection and every model it returns. It would work for find and save, and quietly not work for aggregate, bulkWrite, watch and whatever else you reached for. A transaction that silently covers some of your writes is worse than none, so the provider declares the truth and transaction() refuses.

The same applies to transactional() — it fails loudly on a Mongo app rather than pretending.

Beyond running Mongo: it is the second, structurally different implementation that tests whether the database contract is real. One adapter proves nothing — a contract shaped around a single ORM will fit that ORM perfectly and nothing else.

Three of the four contract methods fit Mongoose unchanged. The fourth did not, and the contract did not bend to accommodate it: transaction is optional, and a provider that lacks it says so. That is the outcome the split was designed for.

connect, health, close ✓
transaction ✗ — declared absent; transaction() and transactional() refuse loudly
Pooling, TLS, timeouts ✓ via connection, passed to Mongoose unchanged
Migrations ✗ — no oven db support; schema lives in your models

What fails at boot: a missing url is refused at construction, and an unreachable server fails during boot rather than on the first query.

The suite drives a real MongoDB: connect, a typed ctx.db that is genuinely a Mongoose Connection, models compiled off it, the health endpoint answering from a live ping, and a clean close on shutdown.

The most useful assertions are the negative ones — that transaction() throws for this provider and names it, and that transactional() fails loudly on a Mongo app instead of wrapping nothing. A contract violation that stays silent is the failure mode this whole split exists to avoid.

  • No portable transactions — see above.
  • Mongoose 8, not 9. Mongoose 9 pulls bson@7, which calls a node:v8 API Bun has not implemented (isBuildingSnapshot); importing it fails outright. Still missing as of Bun 1.4, so the peer range stays ^8.0.0 until it lands.
  • No migration commands. oven db covers Drizzle and Prisma; Mongo’s schema lives in your models, and there is no equivalent worth wrapping.
  • Integration tests run in CI, not on your laptop, against a real mongo:8 service. Locally they skip unless MONGO_URL is set, and say so rather than passing silently.