Data
Databases on Rhythm
Rhythm ships no database layer on purpose — the kernel's provider lifecycle is already the right shape for one. Four recipes, one identical pattern.
On this page
A recipe, not a package#
There is no @rhythmjs/data. A database connection is just a resource with a lifetime, and provide(factory, dispose) on the kernel already manages lifetimes: open once at setup, share through the provider graph, close on teardown. Every ORM plugs into that the same way, so this section documents the pattern once and then shows it four times — Drizzle, Prisma, and MikroORM on PostgreSQL, plus the native MongoDB driver. Each page is backed by a complete runnable app in rhythmjs/examples, all serving the same notes CRUD API.
The provider pair#
Each recipe's src/database.ts exports a createDatabase factory and a closeDatabase dispose hook. The factory may be async (MikroORM's init, Mongo's connect); it resolves once when the module first runs, and teardown disposes providers in reverse registration order.
export const appModule = new Rhythm<RhythmHttpContext>({ name: "app", type: "module" })
.use(filter())
.provide(createDatabase, closeDatabase)
.provide(({ db }) => ({ notesService: createNotesService(db) }))
.use(notesController.middleware())
.use(() => {
throw new HttpError(404, "Route not found");
});This module is the whole app in every recipe — only the provider name changes (db, prisma, orm). The entrypoint pairs it with a graceful shutdown so connections actually close:
// src/main.ts
const server = Bun.serve({ port, fetch: toFetchHandler(appModule) });
async function shutdown(): Promise<void> {
await server.stop();
await appModule.teardown();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);When a handle is needed at teardown but nowhere else — Mongo's MongoClient behind its Db, the raw Bun.SQL client behind a drizzle instance — the factory returns it under a hash-key like "#client". Hash-prefixed keys are stripped from the request context and the provider graph, but the dispose hook receives the value whole.
The service factory#
Persistence lives in one factory — createNotesService(db) — registered as a second provider that receives the connection from the graph. The service owns every query and answers in plain terms: list, get (a note or nothing), create, update (the new note or nothing), remove (a boolean). ORM types stop at this boundary: Prisma's P2025 becomes null, Mongo's ObjectId documents become id: string DTOs, and controllers stay identical across all four stacks.
Contracts at the edges#
Request and response shapes are zod schemas in src/notes/notes.schema.ts, enforced by @rhythmjs/middleware: validate("body", …) types the parsed body onto ctx.valid.body, intercept(…) checks every 2xx response against the contract (and strips unknown fields on the way out), and the filter() boundary renders thrown HttpErrors — and anything unexpected — as JSON failures.
// src/notes/notes.controller.ts
export const notesController = new RhythmRouter<NotesContext>({ prefix: "/api/notes" })
.use(intercept(notesResponseSchema))
.get("/", async (ctx) => {
ctx.json(await ctx.notesService.list());
})
.get("/:id", async (ctx) => {
const note = await ctx.notesService.get(ctx.params.id);
if (!note) throw new HttpError(404, "Note not found");
ctx.json(note);
})
.post("/", validate("body", createNoteSchema), async (ctx) => {
ctx.json(await ctx.notesService.create(ctx.valid.body), 201);
});Migrations#
Schema changes are checked-in files applied by an explicit db:migrate script, never by the app at boot. Each ORM keeps its own convention: Drizzle generates SQL into drizzle/ with drizzle-kit, Prisma keeps prisma/migrations/ applied by migrate deploy, MikroORM runs Migration classes from src/migrations/ through its Migrator, and MongoDB — schemaless — has no migration step at all, just index creation in the database factory.
The recipes#
Beyond the shared shape, each page demonstrates one thing its stack does differently:
- Drizzle: Bun's native
SQLclass as the driver — no driver dependency — and the hash-key trick for the raw client. - Prisma: a generated client as a provider, and mapping
P2025error codes to plain not-found answers. - MikroORM: decorator-free
defineEntityentities and a forkedEntityManagerper operation. - MongoDB: no ORM at all — documents mapped to DTOs at the service boundary, with zod as the only schema.