Using SQL ORMs with DaloyJS
DaloyJS is database-agnostic. Any SQL client that runs on your target runtime works, so pick the ORM or query layer that fits your team. Two primitives attach the client and tie cleanup to shutdown:
app.decorate("db", client)attaches a shared client to every handler'sstate.app.onClose(async () => client.disconnect())ties cleanup to graceful shutdown on the Node.js, Bun, and Deno adapters. Serverless and edge adapters never call it; see Serverless and edge lifecycle.
The recommended pattern
Wrap the database client in a setup function and call it once with the root app (not through app.register(), which would scope the decoration to that plugin's own routes). Handlers read it from state with full type-safety.
The sample uses Prisma ORM 7 with its generated client (see the Prisma guide for the schema and generator setup). Every other ORM follows the same shape.
Throwing NotFoundError (or any HttpError) renders an RFC 9457 problem+json body for you. Declare every status the route can produce in responses so it shows up in the OpenAPI contract and the typed client. A handler that returns a status missing from responses is turned into a 500 by the framework.
Pick your ORM
- Prisma: schema-first, mature migrations, great DX.
- Drizzle ORM: TypeScript-first, edge-friendly, SQL-like API.
- Kysely: type-safe SQL query builder with no model layer, plus HTTP and binding dialects for the edge.
- TypeORM: decorator-based entities for object-oriented teams.
- MikroORM: Data Mapper, Unit of Work, and Identity Map with first-class TypeScript.
- Sequelize: mature Active Record style models with broad SQL dialect support.
Need a platform client instead?
Supabase is not an ORM. It is a hosted Postgres platform with a fetch-based JavaScript client, auth, storage, realtime, and edge-friendly APIs. If that is the shape you need, use Supabase with DaloyJS.
- Supabase: platform client for hosted Postgres + auth via
@supabase/supabase-js.
Keep ORM and ODM separate
This section is intentionally SQL-focused. If you are using MongoDB or Couchbase, jump to the ODM overview and use Mongoose, the MongoDB driver, or Ottoman instead of forcing document models into an ORM-shaped abstraction.
Runtime compatibility cheat sheet
Vercel Functions (Node.js runtime), AWS Lambda, and Azure Functions all run your code on Node.js, so a library that works on Node.js usually works there too. The serverless columns say "Not verified" where we have not confirmed it end to end; the real constraint there is connection pooling, covered below.
| Data layer | Node.js | Bun | Deno | Vercel (Node) | AWS Lambda | Azure Functions | Cloudflare Workers |
|---|---|---|---|---|---|---|---|
| Prisma ORM 7 | Yes | Yes | Yes | Yes | Yes | Not verified | Yes, with a driver adapter and runtime = "workerd" |
| Drizzle ORM | Yes | Yes | Yes | Yes | Yes | Not verified | Yes (D1, Hyperdrive, HTTP drivers) |
| Kysely 0.29 | Yes | Not verified | Not verified | Not verified | Not verified | Not verified | Not verified (HTTP or binding dialects such as kysely-d1, kysely-neon) |
| TypeORM | Yes | Not verified | Not verified | Not verified | Not verified | Not verified | Not verified |
| MikroORM v7 | Yes | Not verified | Not verified | Not verified | Not verified | Not verified | Not verified |
| Sequelize | Yes | Not verified | Not verified | Not verified | Not verified | Not verified | No |
| Supabase JS | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
For Cloudflare Workers, prefer Drizzle or Supabase, or use Prisma with a driver adapter. MikroORM's JSR listings advertise Workers, Deno, and Bun support, but we have not tested it with DaloyJS. TypeORM and Sequelize are built around long-lived Node.js processes and are the safest bet on the Node.js adapter.
Typing the decorated client
Use the exported AppState augmentation point to make decorated clients available on state in every handler. Declare it in the module that creates the client (a regular .ts file the compiler always checks), not in a separate .d.ts file: declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.
Transactions
Don't open transactions in middleware. Open them inside the handler that owns the unit of work, so your contract response (success or error) maps cleanly onto commit / rollback. This example assumes Order and Inventory models in your Prisma schema and a route that declares 201.
Errors
Throw framework errors and they serialize as problem+json automatically: NotFoundError, ConflictError, and HttpError(status, { title, type?, detail? }) all render an RFC 9457 body with the right status. Keep the title generic. It is always sent to the client, while raw database messages can reveal table, column, and constraint names, so log those server-side instead.
See the error mapping table below for the full set of constraint codes.
Serverless and edge lifecycle
app.onClose() and app.onShutdown() only run when the Node.js, Bun, or Deno adapter drains the server. AWS Lambda, Vercel, Azure Functions, Cloudflare Workers, and Fastly never call them, so plan the connection lifecycle per platform:
- Lambda, Vercel Functions, Azure Functions. Create the client at module scope so warm invocations reuse it, and keep the pool tiny (
max: 1or close to it): every function instance holds its own pool, and a traffic spike multiplies instances. Put a pooler in front of the database, such as RDS Proxy for RDS and Aurora, or your provider's pooler (Neon, Supabase, PgBouncer). - Cloudflare Workers. A database socket cannot be reused across requests, so build the client per request in a hook instead of
app.decorate(). Route Postgres and MySQL through Hyperdrive, which keeps the real pool warm for you, or use an HTTP or binding driver (D1, Neon HTTP, PlanetScale). - Long-running servers (Node.js, Bun, Deno, containers). One pool per process, decorated once, closed in
onClose. Size it sopool max x instancesstays under the database's connection limit.
Production checklist
Readiness that checks the database
By default app.readinesscheck() reports plugin boot and drain state only. Pass checks to make it ping your database too, so the orchestrator pulls an instance whose database is unreachable. Each check receives an AbortSignal and runs concurrently with the others; the probe answers 503 if any check throws, rejects, returns false, or runs past checkTimeoutMs (default 2000). Keep the probe behind a token (or off the public internet) like the other built-in probes.
A failing probe still returns only { status: "not-ready" }: which check failed, and why, is logged server-side and never sent to the caller. Point liveness at app.healthcheck() and never at the database check: a database blip should drain traffic, not restart every container. See lifecycle and health.
Connection pooling per adapter
| Where it runs | Pool shape |
|---|---|
| Node.js, Bun, Deno, containers | One pool per process, decorated once, max sized against the database limit |
| Lambda, Vercel Functions, Azure Functions | Module-scope client, max: 1 to a few, a pooler (RDS Proxy, PgBouncer, Neon or Supabase pooler) in front |
| Cloudflare Workers | Client per request through Hyperdrive, or an HTTP / binding driver |
Transaction-mode poolers (PgBouncer in transaction mode, Supabase's transaction pooler) may not support prepared statements, depending on version and configuration; behind one, turn them off in the driver (for example prepare: false in postgres.js). Leave them on everywhere else, including Hyperdrive.
Migrations are a deploy step
- Run migrations from CI or a one-off release job (
prisma migrate deploy,drizzle-kit migrate, Kysely'sMigrator,typeorm migration:run,mikro-orm migration:up), never at app boot. Many instances booting at once race each other, and the runtime role should not need DDL privileges. - Use expand / contract for breaking changes: add the new column or table, deploy code that writes both, backfill, switch reads, then drop the old shape in a later release. Old and new instances run side by side during every deploy.
Multi-tenancy
Resolve the tenant once with tenancy() and scope every query by state.tenant. For defense in depth on Postgres, add row-level security and set the tenant inside the transaction so it cannot leak to the next request on a pooled connection:
Connect as a role that does not own the tables and does not have BYPASSRLS. See resource authorization for user-owned records.
Testing
Drive the real pipeline with app.request() against a dedicated test database (a container or a throwaway branch), never a shared dev database. Run migrations once before the suite, then either wrap each test in a transaction you roll back or truncate between tests. See Testing.
Pagination and cursor safety
Use paginationQuery() with keyset cursors. decodeCursor() caps the length and parses safely, but the decoded value is still client input: validate it with a Zod object of primitives before it reaches a where clause, otherwise a crafted cursor can smuggle operator objects into the filter (see SQL injection).
Idempotency and the outbox
For retried writes (payments, orders), put idempotency() on the route. When a write must also trigger a side effect (email, webhook, queue message), insert an outbox row in the same transaction as the business row and let a background job deliver it, so a crash between commit and send never loses or duplicates the effect.
Observability
Export traces and metrics with OTLP, keep ORM query logging off (or errors only) in production, and log database errors server-side with the request id instead of returning them to clients.
Error mapping
Map constraint violations to typed client errors and transient failures to 503. Codes are Postgres SQLSTATEs; Prisma surfaces them as P20xx codes.
| Failure | Postgres | Prisma | Status |
|---|---|---|---|
| Unique violation | 23505 | P2002 | 409 Conflict |
| Foreign key violation | 23503 | P2003 | 409 (or 422 when the client sent the missing reference) |
| Not-null violation | 23502 | P2011 | 422 (usually a gap in the request schema) |
| Record to update or delete not found | n/a | P2025 | 404 |
| Serialization failure, deadlock | 40001, 40P01 | P2034 | 503 with Retry-After (or retry the transaction) |
Use it as catch (err) { mapDbError(err); } and declare 409, 422, and 503 in the route's responses.