Drizzle ORM is a lightweight, TypeScript-native ORM with a SQL-like API. It runs everywhere DaloyJS does, including Cloudflare Workers, and infers result types directly from your schema.
Zod validates the request before your handler runs, Drizzle runs a SQL-like query off state.db with result types inferred from your schema, then the response schema checks the body on the way out.
Generate migrations in development and apply them as a deploy step, never at app boot (see migrations):
bash
pnpm drizzle-kit generatepnpm drizzle-kit migrate
3. Create a Drizzle plugin
src/db/drizzle.tsts
import postgres from "postgres";import { drizzle } from "drizzle-orm/postgres-js";import type { App } from "@daloyjs/core";import * as schema from "./schema.ts";// Add `prepare: false` only behind a transaction-mode pooler// (PgBouncer in transaction mode, Supabase's transaction pooler).const client = postgres(process.env.DATABASE_URL!, { max: 10 });export const db = drizzle({ client, schema });// Call with the root app, not through app.register(): a decoration made// inside a registered plugin is visible only to that plugin's own routes.export function drizzlePlugin(app: App) { app.decorate("db", db); app.onClose(async () => { await client.end({ timeout: 5 }); });}
onClose() runs on the Node.js, Bun, and Deno adapters only. On Lambda, Vercel, or Azure Functions keep max at 1 or close to it, and on Workers build the client per request; see Serverless and edge lifecycle.
4. Augment app state types
Add the declare module block to the same module that exports db, not to 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.
src/db/drizzle.tsts
// same module as the plugin abovedeclare module "@daloyjs/core" { interface AppState { db: typeof db; }}
5. Use it in routes
src/server.tsts
import { z } from "zod";import { eq } from "drizzle-orm";import { App, NotFoundError } from "@daloyjs/core";import { serve } from "@daloyjs/core/node";import { drizzlePlugin } from "./db/drizzle.ts";import { users } from "./db/schema.ts";const app = new App();drizzlePlugin(app);// Dates go over the wire as ISO strings: z.coerce.date() has no// representation in the generated OpenAPI document.const UserSchema = z.object({ id: z.uuid(), email: z.email(), name: z.string().nullable(), createdAt: z.iso.datetime(),});type UserRow = typeof users.$inferSelect;const toUser = (row: UserRow) => ({ ...row, createdAt: row.createdAt.toISOString() });app.get( "/users/:id", { operationId: "getUser", request: { params: z.object({ id: z.uuid() }) }, responses: { 200: { description: "Found", body: UserSchema }, 404: { description: "Not found" }, }, }, async ({ params, state }) => { const [user] = await state.db.select().from(users).where(eq(users.id, params.id)).limit(1); if (!user) throw new NotFoundError("User not found"); return { status: 200, body: toUser(user) }; },);app.post( "/users", { operationId: "createUser", request: { body: z.object({ email: z.email(), name: z.string().optional() }) }, responses: { 201: { description: "Created", body: UserSchema } }, }, async ({ body, state }) => { // Safe only because z.object() strips unknown keys (no mass assignment). const [created] = await state.db.insert(users).values(body).returning(); return { status: 201, body: toUser(created) }; },);await app.ready();serve(app, { port: 3000 });
Transactions
This example assumes orders and inventory tables in schema.ts and a route that declares 201. Throwing inside the callback rolls back.
Interactive db.transaction() needs a driver that holds a database session open. The Neon HTTP driver (drizzle-orm/neon-http) and Cloudflare D1 do not: use db.batch([...]) there, which runs the statements as one atomic batch, or switch to the WebSocket-based drizzle-orm/neon-serverless driver with a Pool when you need interactive transactions on Neon.
Edge runtimes
Drizzle is the easiest path to running DaloyJS against a real database on the edge. Pick a driver:
Cloudflare Workers + D1: drizzle-orm/d1
Cloudflare Workers + Postgres through Hyperdrive: drizzle-orm/postgres-js (or drizzle-orm/node-postgres) with a client created per request. Keep prepared statements on with Hyperdrive.
Neon (Postgres) over HTTP on any edge: drizzle-orm/neon-http
// Cloudflare Workers + D1import { env } from "cloudflare:workers";import { drizzle } from "drizzle-orm/d1";import { App } from "@daloyjs/core";import { toFetchHandler } from "@daloyjs/core/cloudflare";import * as schema from "./db/schema.ts";// `wrangler types` generates the global `Env` interface from your wrangler// config, which types `env.DB` as a D1 binding.const app = new App();// A D1 binding is not a socket, so one Drizzle instance can serve every// request. Decorate once, before the routes: calling decorate() inside the// Worker fetch handler throws on the second request. Drizzle's D1 docs pass// the binding as the first argument rather than as `{ client }`.app.decorate("db", drizzle(env.DB, { schema }));// ...register routes that read state.dbexport default toFetchHandler(app);