Add Drizzle ORM
The previous tutorial wired your Worker to Neon Postgres through
Hyperdrive. Now we’ll layer Drizzle ORM on top: typed schemas,
typed queries, and — most usefully — a Drizzle.Schema resource
that regenerates migration SQL programmatically on every deploy
and lets Neon.Branch apply them transactionally.
Define the schema
Section titled “Define the schema”Drizzle schemas are plain TypeScript modules. Create
src/schema.ts:
import { integer, pgTable, serial, text, timestamp,} from "drizzle-orm/pg-core";
export const Users = pgTable("users", { id: serial("id").primaryKey(), email: text("email").notNull().unique(), name: text("name").notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(),});
export const Posts = pgTable("posts", { id: serial("id").primaryKey(), userId: integer("user_id") .notNull() .references(() => Users.id, { onDelete: "cascade" }), title: text("title").notNull(), body: text("body").notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(),});Add the Drizzle provider
Section titled “Add the Drizzle provider”Drizzle.Schema is registered through its own providers() layer
— a build-time provider that owns your migrations directory:
import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Neon from "alchemy/Neon";import * as Layer from "effect/Layer";
export default Alchemy.Stack( "MyStack", { providers: Layer.mergeAll(Cloudflare.providers(), Neon.providers()), providers: Layer.mergeAll( Cloudflare.providers(), Drizzle.providers(), Neon.providers(), ), state: Alchemy.localState(), }, // ...);The provider has no required credentials — it just needs
drizzle-kit installed (declared as an optional peer of alchemy).
Add drizzle-orm, @effect/sql-pg, and pg as runtime deps and
drizzle-kit + @types/pg as dev deps:
bun add drizzle-orm@^1.0.0-rc.1 @effect/sql-pg pgbun add -d drizzle-kit@^1.0.0-rc.1 @types/pgnpm install drizzle-orm@^1.0.0-rc.1 @effect/sql-pg pgnpm install -D drizzle-kit@^1.0.0-rc.1 @types/pgpnpm add drizzle-orm@^1.0.0-rc.1 @effect/sql-pg pgpnpm add -D drizzle-kit@^1.0.0-rc.1 @types/pgyarn add drizzle-orm@^1.0.0-rc.1 @effect/sql-pg pgyarn add -D drizzle-kit@^1.0.0-rc.1 @types/pgAdd Drizzle.Schema to your Db effect
Section titled “Add Drizzle.Schema to your Db effect”Inline the schema resource directly into NeonDb — its out
output becomes the input to Neon.Branch’s migrationsDir, so
alchemy automatically schedules Drizzle.Schema before the
branch resource each deploy:
import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Neon from "alchemy/Neon";import * as Effect from "effect/Effect";
export const NeonDb = Effect.gen(function* () { const schema = yield* Drizzle.Schema("app-schema", { schema: "./src/schema.ts", out: "./migrations", });
const project = yield* Neon.Project("app-db", { region: "aws-us-east-1" }); const branch = yield* Neon.Branch("app-branch", { project }); const branch = yield* Neon.Branch("app-branch", { project, migrationsDir: schema.out, }); return { project, branch }; return { project, branch, schema };});On every bun alchemy deploy, the provider:
- Loads
./src/schema.tsvia dynamicimport(). - Calls
drizzle-kit/api-postgres’sgenerateDrizzleJsonagainst the schema andgenerateMigrationagainst the previous snapshot under./migrations. - If anything changed, writes a new
migrations/<timestamp>_migration/{migration.sql, snapshot.json}directory. Neon.Branchthen runs every pending.sqlfile transactionally against the branch’s primary database.
No drizzle-kit generate step in your CI — the deploy owns it.
Open the connection with Drizzle.postgres
Section titled “Open the connection with Drizzle.postgres”Drizzle.postgres takes Hyperdrive’s connection string and returns a
typed EffectPgDatabase. Bind it once at init and use it directly
inside fetch — no manual Client setup, no Effect.promise(...)
wrappers around queries:
import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Hyperdrive } from "./Db.ts";import { Users } from "./schema.ts";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url, compatibility: { // node-postgres needs Node.js APIs to run inside a Worker. flags: ["nodejs_compat"], }, }, Effect.gen(function* () { const hd = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); const db = yield* Drizzle.postgres(hd.connectionString);
return { fetch: Effect.gen(function* () { const users = yield* db.select().from(Users); return yield* HttpServerResponse.json(users); }), }; }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),) {}A few things to call out:
db.select().from(Users)is anEffect. Youyield*it directly. The full drizzle/effect-postgres builder is supported (select,insert,update,delete,with, transactions).- The pool is built lazily on the first query and memoized on the
ExecutionContext, so it’s created at most once per execution — afetch/queue/scheduledevent or a Workflow run — and reused by every query in that execution. It’s torn down when the execution’s scope closes. nodejs_compatis required becausepg(node-postgres) powers the underlying transport.
Deploy
Section titled “Deploy”bun alchemy deployThe first deploy regenerates ./migrations from your schema (since
no snapshot exists yet) and applies the resulting CREATE TABLE
statements to your branch. Hit your Worker URL and you should see:
[]Define relations for typed db.query
Section titled “Define relations for typed db.query”The basic client supports the SQL builder (select, insert, etc.)
but not the relational query API. To unlock db.query.* with typed
with clauses, declare your relations using defineRelations and
export them from src/schema.ts:
import { defineRelations } from "drizzle-orm"; import { integer, pgTable, serial, text, timestamp, } from "drizzle-orm/pg-core";
export const Users = pgTable("users", { /* ... */ }); export const Posts = pgTable("posts", { /* ... */ });
export const relations = defineRelations({ Users, Posts }, (t) => ({ Users: { posts: t.many.Posts(), }, Posts: { user: t.one.Users({ from: t.Posts.userId, to: t.Users.id, }), },}));defineRelations is purely a type-level + metadata wiring step — it
doesn’t touch the generated migration SQL. The foreign key was
already declared on Posts.userId.references(...).
Pass relations to Drizzle.postgres
Section titled “Pass relations to Drizzle.postgres”Hand the relations object to Drizzle.postgres as its second
argument. The returned db now exposes a typed db.query namespace
keyed by your table names:
import { Hyperdrive } from "./Db.ts";import { Users } from "./schema.ts";import { relations, Users } from "./schema.ts";
Effect.gen(function* () { const hd = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); const db = yield* Drizzle.postgres(hd.connectionString); const db = yield* Drizzle.postgres(hd.connectionString, { relations, });
return { fetch: Effect.gen(function* () { const users = yield* db.select().from(Users); return yield* HttpServerResponse.json(users); const user = yield* db.query.Users.findFirst({ where: { id: 1 }, with: { posts: true }, }); return yield* HttpServerResponse.json({ user }); }), }; })db.query.Users.findFirst is fully typed: where accepts a typed
predicate object keyed by Users columns, and with: { posts: true }
is only valid because relations declared Users.posts. The result
type includes posts: Post[] automatically — no manual joins.
Iterate on the schema
Section titled “Iterate on the schema”Add a column or a table to src/schema.ts and run
bun alchemy deploy again. The provider:
- Diffs the new schema against the latest snapshot.
- Writes a new migration directory with just the delta SQL.
Neon.Branchnotices the new file, runs it inside a transaction, and records it in theneon_migrationstracking table so it’s not re-applied.
Roll back simply by reverting your schema change and redeploying —
or by spinning up a Neon.Branch that forks from a point-in-time
LSN before the migration.
Use PlanetScale instead
Section titled “Use PlanetScale instead”Everything above works the same with PlanetScale —
swap the Neon resources in src/Db.ts for their PlanetScale
counterparts and point Hyperdrive at the branch’s credentials.
PlanetScale offers both Postgres and MySQL databases (see
setup for credentials).
Register Planetscale.providers() alongside the Drizzle provider:
import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Neon from "alchemy/Neon";import * as Planetscale from "alchemy/Planetscale";import * as Layer from "effect/Layer";
providers: Layer.mergeAll( Cloudflare.providers(), Drizzle.providers(), Neon.providers(), Planetscale.providers(), ),Then rebuild src/Db.ts around a PlanetScale Postgres database,
branch, and role. Drizzle.Schema is unchanged, and
Planetscale.PostgresBranch accepts the same migrationsDir
contract Neon.Branch did — it scans the directory and applies
pending migrations transactionally on every deploy:
import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Planetscale from "alchemy/Planetscale";import * as Effect from "effect/Effect";
export const PlanetscaleDb = Effect.gen(function* () { const schema = yield* Drizzle.Schema("app-schema", { schema: "./src/schema.ts", out: "./migrations", });
const database = yield* Planetscale.PostgresDatabase("app-db", { region: { slug: "us-east" }, clusterSize: "PS_10", });
const branch = yield* Planetscale.PostgresBranch("app-branch", { database, migrationsDir: schema.out, });
const role = yield* Planetscale.PostgresRole("app-role", { database, branch, inheritedRoles: ["postgres"], });
return { database, branch, role, schema };});
export const Hyperdrive: Effect.Effect< Cloudflare.Hyperdrive.Connection, never, any> = Effect.gen(function* () { const { role } = yield* PlanetscaleDb; return yield* Cloudflare.Hyperdrive.Connection("app-hyperdrive", { origin: role.origin, caching: { disabled: true }, });});Planetscale.PostgresRole mints the credentials — its origin
output feeds Hyperdrive the host, port, user, and password in one
object. The Worker code needs no changes at all:
Drizzle.postgres(conn.connectionString, { relations }) speaks the
Postgres wire protocol through Hyperdrive regardless of who hosts
the database, so the schema, relations, and every query from the
walkthrough above carry over verbatim.
The MySQL flavor changes three things: the schema dialect, how
migrations are generated, and the runtime client. Start with the
schema — same tables, mysql-core column builders:
import { defineRelations } from "drizzle-orm";import { int, mysqlTable, timestamp, varchar } from "drizzle-orm/mysql-core";
export const Users = mysqlTable("users", { id: int("id").primaryKey().autoincrement(), email: varchar("email", { length: 255 }).notNull().unique(), name: varchar("name", { length: 255 }).notNull(), createdAt: timestamp("created_at").notNull().defaultNow(),});(Posts and the defineRelations block follow the same shape as
the Postgres version.)
Migrations are generated with drizzle-kit generate and checked in,
rather than regenerated by a Drizzle.Schema resource on deploy.
Add a drizzle.config.ts:
import { defineConfig } from "drizzle-kit";
export default defineConfig({ schema: "./src/schema.ts", out: "./migrations", dialect: "mysql",});Run bun drizzle-kit generate whenever the schema changes — the
branch resource picks up whatever .sql files it finds under
./migrations, so there’s no Drizzle.providers() layer to
register in alchemy.run.ts (just Cloudflare.providers() and
Planetscale.providers()).
src/Db.ts uses the MySQL resource trio — database, branch, and a
Planetscale.MySQLPassword in place of the Postgres role:
import * as Cloudflare from "alchemy/Cloudflare";import * as Planetscale from "alchemy/Planetscale";import * as Effect from "effect/Effect";
export const PlanetscaleDb = Effect.gen(function* () { const database = yield* Planetscale.MySQLDatabase("app-db", { region: { slug: "us-east" }, clusterSize: "PS_10", });
const branch = yield* Planetscale.MySQLBranch("app-branch", { database, isProduction: false, migrationsDir: "./migrations", });
const password = yield* Planetscale.MySQLPassword("app-password", { database, branch, role: "readwriter", });
return { database, branch, password };});
export const Hyperdrive: Effect.Effect< Cloudflare.Hyperdrive.Connection, never, any> = Effect.gen(function* () { const { password } = yield* PlanetscaleDb; return yield* Cloudflare.Hyperdrive.Connection("app-hyperdrive", { origin: password.origin, caching: { disabled: true }, });});At runtime there’s no Drizzle.mysql helper — use
drizzle-orm/mysql2 directly (swap the pg dependency for
mysql2). The client is created per request and closed when the
response is done:
import * as Cloudflare from "alchemy/Cloudflare";import { drizzle } from "drizzle-orm/mysql2";import * as Effect from "effect/Effect";import * as Redacted from "effect/Redacted";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Hyperdrive } from "./Db.ts";import * as schema from "./schema.ts";import { relations, Users } from "./schema.ts";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url, compatibility: { flags: ["nodejs_compat"], }, }, Effect.gen(function* () { const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive);
return { fetch: Effect.gen(function* () { const connectionString = yield* conn.connectionString; const db = drizzle({ connection: { uri: Redacted.value(connectionString), // mysql2's text/binary parsers JIT via `new Function(...)`, // which Cloudflare Workers' isolate disallows. disableEval: true, }, schema, relations, mode: "default", });
const close = Effect.tryPromise({ try: () => db.$client.end(), catch: (cause) => cause, }).pipe(Effect.catch(() => Effect.void));
return yield* Effect.gen(function* () { const users = yield* Effect.tryPromise({ try: () => db.select().from(Users), catch: (cause) => cause, }); return yield* HttpServerResponse.json({ users }); }).pipe(Effect.ensuring(close)); }), }; }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),) {}Two MySQL-specific details: disableEval: true is required because
mysql2’s parsers compile via new Function(...), which the Workers
isolate blocks; and queries go through Effect.tryPromise because
the plain mysql2 driver returns Promises — there’s no effect-native
integration like @effect/sql-pg on this path.
For long-lived shared databases with per-PR branches on top, the
same tiering pattern from the
branch-from-shared-database guide
applies to PlanetScale — use Planetscale.PostgresDatabase.ref /
Planetscale.MySQLDatabase.ref to reference a database owned by
another stage.
Where to from here
Section titled “Where to from here”Your Worker now has typed Postgres queries through Drizzle, an edge-pooled connection through Hyperdrive, automatically-generated migrations, and per-deploy state validated against your TypeScript schema. That’s the full database story for the Cloudflare track — combine it freely with the Durable Objects, Workflows, AI Gateway, and Container primitives from earlier tutorials.
- PlanetScale provider — databases, branches, roles, and passwords as resources
- Neon provider — projects, branches, and migrations
- Drizzle hub — where
Drizzle.Schema’s generation and diff semantics live, including Migrations as resources