zanith

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.

TSdb.ts — the only file that changes
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:

TSan existing call site, unchanged
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.

The doctor (zanith compat scan) reports exactly which refusals your codebase would hit — before you swap anything.8 rows
SurfaceStatus
CRUD incl. *OrThrow / createMany / updateMany / deleteMany, composite unique wherestranslated
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 writestranslated
$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 PrismaClientKnownRequestErrortranslated
$extends query components (interceptors)translated
Nested set / delete, nested-of-nested payloads, JSON path filters, full-text search filter, isolationLevel, $extends result/model/clientloud 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.

TSthe escape hatch
// 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.