This page maps the Prisma and Drizzle operations you use every day to effect-orm. Its examples
use Postgres. SQLite and Cloudflare D1 (@maple-dev/effect-orm/sqlite) have the same builder,
RETURNING and ON CONFLICT; where a row differs on them, it says so, and
SQLite and Cloudflare D1 has the details. Each row says where effect-orm has the
operation today.
Two things change for every operation:
- A query is a value.
PG.from(...)builds it, andDb.runcompiles it for the database’s dialect, runs it, and decodes its rows. There is no client object with model methods. Db.runreturns anEffect. Errors are in its type, and theDatabaseit needs is in its requirements.
Operations
| Prisma | Drizzle | effect-orm |
|---|---|---|
findUnique({ where }) |
db.query.t.findFirst({ where }) |
Db.runOption(PG.from(T).select(...).where(...)): Option of the row, a failure for more than one; Db.runOne when it must exist (Cardinality) |
findFirst, findMany({ where, orderBy, take, skip }) |
db.select().from(t).where().orderBy().limit().offset() |
.where, .orderBy, .limit, .offset (Building queries) |
Many findUnique calls batched (the dataloader) |
none | Db.findById and Db.findAllBy: concurrent lookups by a key become one IN statement (Batching lookups) |
include / select a relation |
with: { posts: true } |
Db.findMany with nested with over Db.defineRelations, one statement per level (Relational reads); or innerJoin / leftJoin (Joins) |
_count: { select: { posts: true } } |
db.$count(posts, eq(...)) |
_count: { posts: true } in Db.findMany (Count relations); or leftJoin + PG.countDistinct + groupBy |
where: { posts: { some / none / every } } |
exists(...) |
r.posts.some(where), none, every in a findMany where (Filter by relations); or PG.exists, PG.inSubquery (Subquery conditions) |
create, createMany({ data }) |
db.insert(t).values([...]) |
PG.insertInto(T).values(row or rows), .returning() (Inserting rows) |
upsert |
.onConflictDoUpdate({ target, set }) |
.onConflictDoUpdate({ target, set }); set: PG.excluded([...]) or PG.excludedAll() copies the proposed row (On conflict) |
update, updateMany |
db.update(t).set().where() |
PG.update(T).set(...).where(...); a write with no where or allRows() does not compile (Updating and deleting) |
delete, deleteMany |
db.delete(t).where() |
PG.deleteFrom(T).where(...) |
$transaction(async (tx) => ...) |
db.transaction(async (tx) => ...) |
effect.pipe(Db.transaction()); no tx handle, nesting is a savepoint (Transactions). D1 has no interactive transactions: there Db.transaction fails with TransactionUnsupported |
$transaction([a, b]) |
db.batch([a, b]) |
Run them inside one Db.transaction. There is no single-round-trip batch, and no wrapper for D1’s batch yet (D1) |
$queryRaw, $executeRaw |
db.execute(sql`...`) |
Db.query(Db.sql`...`, schema), Db.execute(Db.sql`...`); every ${value} is bound |
SELECT ... FOR UPDATE by $queryRaw |
.for("update") |
.forUpdate(), .forNoKeyUpdate(), .forShare() on Postgres, .forUpdate() and .forShare() on MySQL (Building queries); SQLite has no row locks |
P2002 (unique violation) |
DrizzleQueryError and a cause.code check |
Db.refineConstraints fails with a tagged UniqueViolation (and ForeignKeyViolation, NotNullViolation, CheckViolation) to catch by tag (Constraint violations) |
cursor, skip: 1, take |
where(gt(t.id, cursor)) |
Db.paginate(query, { orderBy, limit, after }): a keyset page and the cursor for the next (Pagination) |
contains, mode: "insensitive" |
ilike(t.c, "%x%") |
$.c.contains("x"), startsWith, endsWith, with { insensitive: true }; user input is escaped, on every dialect (Text search) |
Json field, path filters |
jsonb(), sql`->>` |
PG.jsonb(schema) decodes the document with a Schema; PG.jsonPath($.c, "a", "b"), PG.jsonContains (@>) and PG.jsonSet (jsonb_set) are typed from it (JSON paths). Postgres only |
@default(uuid()), @default(now()) |
.defaultRandom(), .defaultNow() |
PG.column(type, { defaultExpr: "gen_random_uuid()" }), { defaultExpr: "now()" } |
@default(cuid()), @updatedAt |
.$defaultFn(), .$onUpdate() |
PG.column(type, { defaultFn: effect, onUpdate: effect }): Effects Db.run runs into each write (Client-side defaults) |
@map("org_id"), @@map |
text("org_id") |
PG.column(PG.text, { name: "org_id" }); the table name is the first argument of PG.table; casing: "snake_case" maps every key (Column names) |
@@unique, @@index, relations with onDelete |
unique(), index(), references() |
uniqueConstraints, indexes, foreignKeys on PG.table (Postgres migrations). SQLite.table declares keys and indexes, not foreign keys (SQLite) |
prisma db seed |
drizzle-seed |
Seed.insert(T, { count }) writes rows generated from the column Schemas, deterministic by seed (Seeding) |
prisma migrate dev / deploy |
drizzle-kit generate / migrate |
effect-orm generate, effect-orm migrate (Schema and migrations). A drizzle-kit folder can be adopted. SQLite has generate only; a drizzle-kit-compatible tool applies its folders (SQLite migrations) |
prisma db pull |
drizzle-kit pull |
effect-orm pull for ClickHouse only; Postgres and SQLite are not supported yet |
prisma migrate diff (drift) |
none | effect-orm verify --schema compares the database with the table definitions (Checking the definitions) |
prisma migrate status |
drizzle-kit check |
effect-orm status; effect-orm check validates the migrations folder, as drizzle-kit check does |
| A mocked client in unit tests | a mocked db |
Db.layerTest answers statements with no server (layerTest); or run on PGlite, as below, or in-memory SQLite |
The table above, as one program
This runs on PGlite, Postgres compiled to WASM, so it needs no server. Each comment names the
Prisma call that the next statement replaces. requireTenantScope is on, so every statement
must pin orgId, or Db.run refuses it.
import { PgliteClient } from "@effect/sql-pglite"
import { Effect, Layer, Schema } from "effect"
import * as Db from "@maple-dev/effect-orm/database"
import * as PG from "@maple-dev/effect-orm/postgres"
import * as S from "@maple-dev/effect-orm/schema"
const Profile = Schema.Struct({ city: Schema.String })
// model User { id String @id @default(uuid()); email String @unique; orgId String @map("org_id") ... }
export const Users = PG.table("users", {
columns: {
id: PG.column(PG.uuid, { defaultExpr: "gen_random_uuid()" }),
orgId: PG.column(PG.text, { name: "org_id" }),
email: PG.column(PG.text, { unique: true }),
profile: PG.jsonb(Profile),
updatedAt: PG.column(PG.timestamptz, { name: "updated_at", defaultExpr: "now()" }),
},
primaryKey: ["id"],
tenantColumn: "orgId",
})
export const Posts = PG.table("posts", {
columns: {
id: PG.int4,
orgId: PG.column(PG.text, { name: "org_id" }),
authorEmail: PG.column(PG.text, { name: "author_email" }),
published: PG.bool,
},
primaryKey: ["id"],
foreignKeys: [PG.foreignKey({ columns: ["authorEmail"], references: Users, foreignColumns: ["email"], onDelete: "cascade" })],
tenantColumn: "orgId",
})
const org = "org_1"
// prisma.user.findUnique({ where: { email } })
const findByEmail = (email: string) =>
Db.run(
PG.from(Users)
.select("id", "email")
.where(($) => [$.orgId.eq(org), $.email.eq(email)])
.limit(1),
).pipe(Effect.map((rows) => rows[0]))
export const program = Effect.gen(function* () {
for (const statement of S.renderPgSchema(yield* S.pgEntitiesOf([Users, Posts])))
yield* Db.execute({ sql: statement, parameters: [] })
// prisma.user.createMany({ data: [...] })
yield* Db.run(
PG.insertInto(Users).values([
{ orgId: org, email: "ada@example.com", profile: { city: "London" } },
{ orgId: org, email: "bob@example.com", profile: { city: "Paris" } },
]),
)
yield* Db.run(
PG.insertInto(Posts).values([
{ id: 1, orgId: org, authorEmail: "ada@example.com", published: true },
{ id: 2, orgId: org, authorEmail: "ada@example.com", published: false },
]),
)
const found = yield* findByEmail("ada@example.com")
// prisma.user.findMany({ where: { email: { contains: "EXAMPLE", mode: "insensitive" } }, orderBy, take })
const insensitive = yield* Db.run(
PG.from(Users)
.select("email")
.where(($) => [$.orgId.eq(org), $.email.ilike("%EXAMPLE%")])
.orderBy(["email", "asc"])
.limit(10),
)
// include: { _count: { select: { posts: true } } }
const counts = yield* Db.run(
PG.from(Users, "u")
.leftJoin(Posts, "p", (u, p) => u.email.eq(p.authorEmail).and(u.orgId.eq(p.orgId)))
.select(($) => ({ email: $.email, posts: PG.countDistinct($.p.id) }))
.where(($) => [$.orgId.eq(org)])
.groupBy("email")
.orderBy(["email", "asc"]),
)
// where: { posts: { some: { published: true } } }
const published = PG.from(Posts)
.select(($) => ({ author: $.authorEmail }))
.where(($) => [$.orgId.eq(org), $.published.eq(true)])
const withPublished = yield* Db.run(
PG.from(Users)
.select("email")
.where(($) => [$.orgId.eq(org), PG.inSubquery($.email, published)]),
)
// JSON field: select profile->>'city'
const cities = yield* Db.run(
PG.from(Users)
.select(($) => ({ email: $.email, city: PG.jsonText($.profile, "city") }))
.where(($) => [$.orgId.eq(org)])
.orderBy(["email", "asc"]),
)
// cursor pagination: findMany({ cursor: { email }, skip: 1, take: 1, orderBy: { email: "asc" } })
const page = yield* Db.run(
PG.from(Users)
.select("email")
.where(($) => [$.orgId.eq(org), $.email.gt("ada@example.com")])
.orderBy(["email", "asc"])
.limit(1),
)
// upsert
yield* Db.run(
PG.insertInto(Users)
.values({ orgId: org, email: "bob@example.com", profile: { city: "Rome" } })
.onConflictDoUpdate({
target: ["email"],
set: PG.excluded(["profile"]),
// The target is not tenant-keyed, so pin the row it would update to this tenant.
where: (existing) => existing.orgId.eq(org),
}),
)
// updateMany, with @updatedAt written by hand
const unpublished = yield* Db.run(
PG.update(Posts)
.set({ published: false })
.where(($) => [$.orgId.eq(org), $.authorEmail.eq("ada@example.com")])
.returning("id"),
)
yield* Db.run(
PG.update(Users)
.set(() => ({ updatedAt: PG.now() }))
.where(($) => [$.orgId.eq(org), $.email.eq("bob@example.com")]),
)
// P2002: create with a taken email
const duplicate = yield* Db.run(
PG.insertInto(Users).values({ orgId: org, email: "ada@example.com", profile: { city: "Oslo" } }),
).pipe(
Effect.as("created" as const),
Effect.catchTag("@maple-dev/effect-orm/DatabaseError", (error) =>
error.reason === "UniqueViolation" ? Effect.succeed("taken" as const) : Effect.fail(error),
),
)
// $transaction(async (tx) => ...) and delete
yield* Effect.gen(function* () {
yield* Db.run(PG.deleteFrom(Posts).where(($) => [$.orgId.eq(org), $.id.eq(2)]))
yield* Db.run(PG.deleteFrom(Users).where(($) => [$.orgId.eq(org), $.email.eq("bob@example.com")]))
}).pipe(Db.transaction())
// $queryRaw
const raw = yield* Db.query(
Db.sql`SELECT count(*)::int AS n FROM users WHERE org_id = ${org}`,
Schema.Struct({ n: Schema.Number }),
)
return {
found: found?.email,
insensitive: insensitive.map((row) => row.email),
counts,
withPublished: withPublished.map((row) => row.email),
cities,
page: page.map((row) => row.email),
unpublished: unpublished.map((row) => row.id),
duplicate,
usersLeft: raw[0]?.n,
}
})
const DatabaseLive = Db.layerSqlClient({ dialect: PG.postgresDialect, requireTenantScope: true }).pipe(
Layer.provideMerge(PgliteClient.layer({ postgresqlconf: "timezone = 'UTC'" })),
)
export const result = await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)))// result
{
found: "ada@example.com",
insensitive: ["ada@example.com", "bob@example.com"],
counts: [{ email: "ada@example.com", posts: 2 }, { email: "bob@example.com", posts: 0 }],
withPublished: ["ada@example.com"],
cities: [{ email: "ada@example.com", city: "London" }, { email: "bob@example.com", city: "Paris" }],
page: ["bob@example.com"],
unpublished: [1, 2],
duplicate: "taken",
usersLeft: 1,
}The posts join pins its tenant through u.orgId.eq(p.orgId) in the ON clause, so the query stays
single-tenant and a user without posts keeps a row. A filter on p.orgId in the WHERE would
drop that row, as it would in SQL.
What is different by design
- Effect services and Layers.
Databaseis a service. You build it withDb.layerSqlClientover any EffectSqlClient(@effect/sql-pg,@effect/sql-pglite,@effect/sql-clickhouse,@effect/sql-sqlite-node,@effect/sql-d1), and provide it once. A test swaps the client layer, not a mock. - Typed errors. A failed statement is a
DatabaseErrorwith the driver’sreasonand the server’ssqlState. COMMIT and ROLLBACK failures are their own errors. A helper markedDb.requireTransactiondoes not compile outsideDb.transaction, so a missing transaction is a type error, not a runtime surprise. - Schema-decoded rows. Each query derives a row Schema from its select list, and
Db.rundecodes every row with it. Ajsonb(schema)column is checked on read, and a value the column type cannot hold fails with a decode error instead of reaching your code. - Tenant scope proof. A table can declare its
tenantColumn. Every compiled query then reportssingle-tenant,cross-tenantoruntenanted, andrequireTenantScoperefuses across-tenantquery unless it says so withcrossTenant(). Prisma and Drizzle leave this to middleware or row-level security. See Tenant scoping.
A CAFE Worker
CAFE is Cloudflare, Alchemy, Foldkit and Effect. A Worker can use D1 through d1Dialect and
@effect/sql-d1 (Execute on D1), or reach Postgres through
Hyperdrive, as below. The handler below does not
know which client it runs on: it takes a function that builds a Database for each request.
import { Effect, Layer } from "effect"
import * as Db from "@maple-dev/effect-orm/database"
import * as PG from "@maple-dev/effect-orm/postgres"
export const Tours = PG.table("tours", {
columns: { id: PG.int4, orgId: PG.column(PG.text, { name: "org_id" }), unit: PG.text },
primaryKey: ["id"],
tenantColumn: "orgId",
})
// One request: read the org's tours. The org comes from the verified session in a real
// Worker; a header keeps the sketch short.
const listTours = (request: Request) =>
Effect.gen(function* () {
const org = request.headers.get("x-org-id")
if (org === null) return new Response("missing org", { status: 401 })
const tours = yield* Db.run(
PG.from(Tours)
.select("id", "unit")
.where(($) => [$.orgId.eq(org)])
.orderBy(["id", "asc"]),
)
return Response.json(tours)
})
// The Worker's fetch handler. `database` builds a Database for one request: on Workers a
// connection must not outlive the request that opened it.
export const makeWorker = <Env>(database: (env: Env) => Layer.Layer<Db.Database, unknown>) => ({
fetch: (request: Request, env: Env): Promise<Response> =>
listTours(request).pipe(
Effect.provide(database(env)),
Effect.catchCause(() => Effect.succeed(new Response("database error", { status: 500 }))),
Effect.runPromise,
),
})On Cloudflare, give it @effect/sql-pg over the Hyperdrive binding. The Worker needs the
nodejs_compat flag for the pg driver. This part is not typechecked here, because
@effect/sql-pg and the Workers types are not dependencies of this package:
import { PgClient } from "@effect/sql-pg"
import { Layer, Redacted } from "effect"
import * as Db from "@maple-dev/effect-orm/database"
import * as PG from "@maple-dev/effect-orm/postgres"
import { makeWorker } from "./cafe-worker"
export default makeWorker((env: { readonly HYPERDRIVE: Hyperdrive }) =>
Db.layerSqlClient({ dialect: PG.postgresDialect, requireTenantScope: true }).pipe(
Layer.provide(PgClient.layer({ url: Redacted.make(env.HYPERDRIVE.connectionString) })),
),
)The same handler runs locally on PGlite, with no network:
import { PGlite } from "@electric-sql/pglite"
import { PgliteClient } from "@effect/sql-pglite"
import { Layer } from "effect"
import * as Db from "@maple-dev/effect-orm/database"
import * as PG from "@maple-dev/effect-orm/postgres"
import { makeWorker } from "./cafe-worker"
// One PGlite instance plays the database behind Hyperdrive. Each request opens its own
// client on it, as the Worker does with the Hyperdrive connection string.
const origin = new PGlite()
await origin.exec(`
CREATE TABLE tours (id int4 PRIMARY KEY, org_id text NOT NULL, unit text NOT NULL);
INSERT INTO tours VALUES (1, 'org_1', '4B'), (2, 'org_2', '7A'), (3, 'org_1', '2C');
`)
const worker = makeWorker((env: { readonly origin: PGlite }) =>
Db.layerSqlClient({ dialect: PG.postgresDialect, requireTenantScope: true }).pipe(
Layer.provide(PgliteClient.layer({ liveClient: env.origin })),
),
)
const get = (headers: Record<string, string>) =>
worker.fetch(new Request("https://cafe.example/tours", { headers }), { origin })
export const org1 = await (await get({ "x-org-id": "org_1" })).json()
// [{ id: 1, unit: "4B" }, { id: 3, unit: "2C" }]
export const anonymous = (await get({})).status
// 401
await origin.close()The Alchemy stack (alchemy.run.ts with the Worker and its Hyperdrive or D1 binding), Durable
Object SQLite storage and a Foldkit UI are not in this guide yet.