Run your Prisma code on Zanith
One file changes. Fourteen hundred call sites don't. The compat layer is a PrismaClient-shaped client backed by the Zanith engine — and the cheapest rollback in the category, because reverting is un-editing db.ts.
The one-file swap
new ZanithPrismaClient({ adapter }) behaves like new PrismaClient(...) where it matters structurally: the constructor is synchronous and does no I/O, the connection happens lazily on the first operation, and $extends chains attach at module scope. With no schema configured, the schema is introspected from the live database — no codegen, no .zn files, nothing to keep in sync.
import { PgAdapter } from 'zanith/adapters/pg';import { ZanithPrismaClient } from 'zanith/compat/prisma';import type { PrismaClient } from '../generated/prisma/client.js'; // types only const basePrisma = new ZanithPrismaClient({ adapter: new PgAdapter({ connectionString: ENV.DATABASE_URL, sslMode: isLocal ? 'disable' : 'require', pool: { max: 20 }, }), // No schema given → introspect the live database on first use.}); // Your extensions keep working — query interceptors chain as before.const extended = basePrisma .$extends(createWorkflowEventExtension(basePrisma)) .$extends(createAuditExtension(basePrisma)); // Keep your generated Prisma types for the type surface.const prisma: PrismaClient = extended as unknown as PrismaClient;export { prisma, basePrisma };Call sites stay untouched — including the awkward ones. Composite unique keys, nested writes, select trees, transactions in both forms:
await prisma.peopleDesignation.upsert({ where: { org_id_designation_code: { org_id: orgId, designation_code: code }, }, create: { designation_id: uuidv4(), org_id: orgId, ...payload }, update: { ...payload, updated_by_id: userId },});What translates — and the honesty contract
The translated surface covers what production codebases actually call. Everything outside it throws ZanithCompatError naming the construct — nothing is silently approximated. A compat layer that is quietly 95% right is how data goes wrong; this one is loudly honest instead.
| Surface | Status |
|---|---|
| CRUD incl. *OrThrow / createMany / updateMany / deleteMany, composite unique wheres | translated |
| Select trees: relations inside select, per-relation projection, _count (incl. filtered) | translated |
| Filters: operators, mode: 'insensitive', to-one relation predicates (is/isNot), some/every/none, array ops (has/hasSome/hasEvery/isEmpty) | translated |
| Nested writes: connect / create / connectOrCreate / update / disconnect; many-to-many junction writes | translated |
| $transaction (interactive + lazy array form), $queryRaw / $executeRaw (+Unsafe), include-on-write, atomic { increment } | translated |
| Error codes: P2002 / P2003 / P2011 / P2025 / P2034 on a shape-compatible PrismaClientKnownRequestError | translated |
| $extends query components (interceptors) | translated |
| Nested set / delete, nested-of-nested payloads, JSON path filters, full-text search filter, isolationLevel, $extends result/model/client | loud refusal — named error |
Migrate gradually — or never
The native engine is one property away. Rewrite hot paths to the Zanith API at your own pace while everything else keeps speaking Prisma; during the transition, Prisma can even keep owning your migrations — Zanith introspects the live schema at boot and never fights over DDL until you hand it over.
// Prisma-shaped today:const rows = await prisma.order.findMany({ where: { status: 'paid' } }); // Native Zanith tomorrow — same client, same connection:const native = await prisma.$zanith.order.findMany({ where: { status: 'paid' },});And because the swap is one file, the rollback is too: revert db.ts and you are back on @prisma/client. No other engine migration offers an exit that cheap — generated clients structurally can't.