zanith
Why Zanith · 01 — The thesisOpen source · MIT

Your schema is the runtime.

Every major ORM today generates code from your schema. Zanith doesn't. Your schema is parsed once at runtime into a graph — queries, types, and validation flow from that graph directly. No generate. No rebuild.

Three adapters ship today — pg, postgres.js, and SQLite. MySQL is on the roadmap for v0.4. The architecture is database-agnostic; the adapter set is what scales.

Engine pulselive

Models

1,000

In flight

47

Queries today

1,204,910

parsergraphcompileradapter
22.9ms0.73ms2.4µs<5µs

Runtime graph

schema.zanith

1,000 models · introspected at boot

active
ZANITHschema.zanith
model User {
id Int @id @default(autoincrement())
email String @unique
createdAt DateTime @default(now())
}

Recent compiled queries

  • nowSELECT id, email FROM users WHERE …1.2µs
  • 1sSELECT … FROM posts JOIN users …17.2µs
  • 2sINSERT INTO sessions VALUES …2.8µs
  • 3sUPDATE users SET role = $1 WHERE …2.1µs
Adapters3 shipped · 1 on the way
pg · shippedpostgres.js · shippedsqlite · shippedmysql · v0.4

02 — The cost

Every codegen ORM is fast at zero models. None of them is fast at a thousand.

The Day-1 experience is great. Pick an ORM, sketch a few models, run generate, ship. The trouble is what compounds — quietly, predictably, along four axes.

Cost vs. schema sizerelative · log-ish

Legend

  • generated client size
  • TypeScript compile
  • hot-reload latency
  • deploy block
  • zanith

Curves are illustrative — they encode the *shape* of the cost, not sourced numbers. Specific competitor benchmarks appear once we have published comparisons to cite.

  • Generated client size

    01

    grows linearly with schema. Each model adds a class, methods, relation accessors, and types.

  • TypeScript compilation

    02

    grows with the generated client. Above a few hundred models the compiler starts to feel it; above a thousand it crashes for some teams.

  • Hot-reload latency

    03

    grows with watch-time regeneration. The faster you iterate on the schema, the more the regeneration step blocks you.

  • Deploy pipeline

    04

    blocks on the generation step. Every deploy waits for a fresh client to be produced, regardless of whether the schema actually changed.

The cost compounds. The deeper you go, the more the schema-grow architecture becomes the application's biggest liability.

03 — Bring your Prisma codedrop-in · reversible

Already on Prisma? Change one file. Keep everything else.

ZanithPrismaClient is a drop-in for PrismaClient — same constructor, same methods, your $extendschains intact. The schema is introspected live, so there's nothing to generate.

db.ts · before
1
import { PrismaClient } from '@prisma/client';
2
import { PrismaPg } from '@prisma/adapter-pg';
3
 
4
const prisma = new PrismaClient({
5
  adapter: new PrismaPg({ connectionString }),
6
});
7
 
8
export { prisma }; // 1,400 call sites import this
swap ↓
db.ts · after · zanith
1
import { ZanithPrismaClient } from 'zanith/compat/prisma';
2
import { PgAdapter } from 'zanith/adapters/pg';
3
 
4
const prisma = new ZanithPrismaClient({
5
  adapter: new PgAdapter({ connectionString }),
6
});
7
 
8
export { prisma }; // unchanged — still 1,400 call sites
1,400 call sites · unchanged$extends chains · intactzero schema · live introspectionrollback · revert one file

Know before you swap

The scanner reads your code first.

Run zanith compat scanbefore you touch a line. It counts every call site and names anything the layer won't translate — file:line, with severity. Whatever it can't do, it refuses loudly. It never guesses.

the one-file swap, documented
the doctor · zanith compat scan

04 — The engine

Schema as runtime data.

Zanith reads the schema at startup, parses it once into a graph in memory, and uses that graph directly. Queries compile to parameterized SQL on the way out. Types come from TypeScript inference over the schema, not from a generated .d.ts file.

Four layers, joined on one runtime substrate. None of them runs at build time.

Runtime substrate · createZanith()
4 layers · joined in-process

PARSER

layer 01

reads .zanith files at app start

  • · lexer · token stream
  • · parser · CST → AST
  • · validator · invariants
22.9ms / 1000 models

GRAPH

layer 02

the runtime structure, in memory

  • · models · fields · enums
  • · relations · indexes · uniqueness
  • · type inference projection
0.73ms / 1000 lookups · 3.4MB

COMPILER

layer 03

AST → parameterized SQL on each query

  • · expression tree · where clauses
  • · join planner · projection
  • · parameter binding ($1, $2, …)
2.4µs / SELECT · 17.2µs / JOIN

ADAPTER

layer 04

pluggable wire driver

  • · shipped: pg · postgres.js · sqlite
  • · planned: mysql (v0.4)
  • · 5-method interface · drop-in
<5µs engine-side
parser · graph · compiler · adapterno build step · schema = runtime

What it looks like in code

A typed call on the model API, compiled to parameterized SQL. Inspectable, predictable, no magic on the wire.

example.ts → users.findMany
TSexample.ts
const users = await db.user.findMany({
where: { email: { contains: '@example.com' } },
orderBy: { createdAt: 'desc' },
take: 10,
});
typed against schema graphready
SQLcompiled.sql
SELECT id, email, name, created_at
FROM users
WHERE email ILIKE $1
ORDER BY created_at DESC
LIMIT 10;
parameters bound · never interpolatedready

On mobile the two blocks stack vertically; thebetween them is the compiler.

05 — What changesat 1000 models

What this looks like at a thousand models.

M01
22.9ms
schema compile
1000 models
src · scale.test.ts
M02
0.73ms
model lookups
1000 lookups
src · scale.test.ts
M03
3.4MB
graph footprint
1000 models
src · scale.test.ts
M04
88.97KB
ESM bundle
engine/dist/index.js
src · tsup

The schema-change scenario

Edit. Restart. Done.

Frame 01 · editorT+0s

Edit schema.zanith

ZNschema.zanith
users.ts
model User {
id Int @id @default(autoincrement())
email String @unique
+ phone String? @unique
name String?
}
Ln 4 · zanith DSL+1 · saved
Frame 02 · terminalT+22ms

App reparse

shell · zsh
~/zanith
$ pnpm dev
[zanith] schema.zanith — 4 fields → 5 fields
[zanith] graph rebuild · 22.9ms
[zanith] runtime ready · 1000 models
$
4 lines · 22.9ms totalready
Frame 03 · resultT+25ms

New field live

TSquery.ts
await db.user.findMany({
where: { phone: { startsWith: '+1' } },
});
compiles to2.4µs
SQLcompiled · parameters bound
SELECT id, email, phone FROM users
WHERE phone LIKE $1
1 query · 2 lines SQLexecuted

No generate. No regenerated client to commit. No watch process to fight. Twenty-five milliseconds, end-to-end.

Memory · 1000 models

3.4MB. Roughly the size of one user-uploaded photo.

The graph holds models, fields, enums, relations, indexes, and uniqueness constraints — all of it, in memory, for the lifetime of the process. A 500-model generated Prisma client commonly runs to tens of megabytes on disk before it's loaded; the runtime graph is an order of magnitude smaller.

Each cell ≈ 10KB · 340 of 1000 cells filled

The schema change is the deploy.

06 — The receiptslive in the test suite

The numbers above live in the test suite. Here is what running it actually prints.

vitest · engine/testv0.34.6 · run #1247
committed at every push · ci-green policy on main765/765 · fully green

benchmark file

schema scale

scale.test.ts

  • schema compile, 22.9ms
  • lookups, 0.73ms
  • memory, 3.4MB

benchmark file

per-op cost

execution.test.ts

  • SELECT compile, 2.4µs
  • JOIN compile, 17.2µs
  • insert, 2.8µs

benchmark file

integration

pipeline.test.ts

  • end-to-end build + compile
  • transaction rollback paths

The full breakdown — every figure, with comparison anchors, source files, and honest caveats — lives on the proof page.

Open the proof page