zanith
How it works · 01 — OverviewOpen source · MIT

Four layers, joined in-process. Zero build steps.

Zanith is small enough to read in an afternoon. The runtime is a pipeline: parser graph compiler adapter. None of it runs at build time. This page walks each layer, then traces one real query through all four — live.

4 layers · joined in-processno build stepno codegenschema = runtimev0.2 · 765/765 green
Layer 01 · the parsercost · 22.9ms / 1000 models

The parser reads .zanith files at app start. Once.

Built on Chevrotain. Lexer tokenizes the source, parser produces a concrete syntax tree, visitor lowers it to an AST. Validation runs in the same pass — duplicate models, dangling relations, unknown types are caught before the graph is ever built.

ZANITHschema.zanith
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
createdAt DateTime @default(now())
}
source · 6 linesready
ASTschema.ast6 nodes
Schema·1 model · 4 fields
Model·name=User · table=users
Field·id : Int · @id · default=autoincrement()
Field·email : String · @unique
Field·name : String?
Field·createdAt : DateTime · default=now()
chevrotain · validatedpassed

What the parser does not do: emit any files, write to disk, or persist anything. The output of this layer lives in memory and is consumed directly by the graph layer.

Layer 02 · the graph3.4MB · 1000 models

The graph is the runtime structure. Models, fields, relations — in memory.

The graph holds everything the engine needs to resolve a query without touching disk. Every model is a node, every relation is an edge with a foreign key on one side. Lookups are sub-millisecond.

Schema graph · sample3 models · 2 relations
Usermodel · 01
  • idInt@id
  • emailString@unique
  • createdAtDateTime
  • postsPost[]rel
Postmodel · 02
  • idInt@id
  • titleString
  • authorIdIntfk
  • commentsComment[]rel
Commentmodel · 03
  • idInt@id
  • bodyString
  • postIdIntfk
relation edgefkforeign key

What the graph stores

at 1,000-model scale

  • Models

    named records

    1,000
  • Fields

    scalars + relations

    8,400
  • Relations

    1:1 / 1:N / M:N

    1,686
  • Enums

    literal sets

    110
  • Indexes

    single + composite

    320
Layer 03 · the compiler2.4µs / SELECT

Queries compile to parameterized SQL on each call.

The compiler runs three steps: build an expression tree from the call arguments, plan joins and projections against the graph, then serialize the AST to a parameterized SQL string. Parameters are always bound — never interpolated.

TS01 · call
db.user.findMany({
where: {
email: { contains: '@example.com' },
role: { in: ['ADMIN', 'EDITOR'] },
},
orderBy: { createdAt: 'desc' },
take: 10,
});
model API · typedready
AST02 · expression treein-memory
SELECT
from·users
fields·id, email, role, …
where·AND
ILIKE·email, $1
IN·role, [$2, $3]
orderBy·createdAt DESC
limit·10
3 params · planned~1.2µs
SQL03 · compiled
SELECT id, email, role, created_at
FROM users
WHERE email ILIKE $1
AND role IN ($2, $3)
ORDER BY created_at DESC
LIMIT 10;
 
-- $1 = '%@example.com%'
-- $2 = 'ADMIN'
-- $3 = 'EDITOR'
parameters bound · safeready

Safety · why it matters

No interpolation

User input never reaches the SQL string. Bound parameters are sent to the driver as a separate array.

Safety · why it matters

No string-quoting hacks

Postgres types — strings, numbers, dates, JSONB — are serialized by the driver, not by us.

Safety · why it matters

No raw escape paths

Raw SQL is a tagged template; values are still bound, never spliced into the SQL string.

Layer 04 · the adapter3 shipped · 1 planned

Adapters are pluggable wire drivers.

Five-method interface: connect, disconnect, execute, transaction, and a debug hook. Drop-in replacements ship in the package; writing your own takes about a hundred lines.

adaptershipped

node-postgres

pg · 8.x

the default. Pool-aware. Battle-tested.

import path

zanith/adapters/pg
adaptershipped

postgres.js

postgres · 3.x

the lighter alternative. Fewer features, faster startup.

import path

zanith/adapters/postgres
adapterplanned

MySQL / MariaDB

mysql2 ·

on the roadmap. Same compiler, different SQL dialect path.

adaptershipped

SQLite

better-sqlite3 · 11.x

sync I/O — useful for tests and edge runtimes.

import path

zanith/adapters/postgres

The interface · 5 methods

Roll your own adapter in ~100 lines.

If you have a database driver and a function that converts a SQL string + parameter array into rows, you can write a Zanith adapter. The compiler doesn't know or care which driver ends up on the wire.

TSDatabaseAdapter.ts
export interface DatabaseAdapter {
connect(): Promise<void>;
disconnect(): Promise<void>;
execute<T>(query: CompiledQuery): Promise<T[]>;
transaction<R>(
fn: (tx: TransactionClient) => Promise<R>
): Promise<R>;
// optional: debug hook for the engine to call
onQuery?(q: CompiledQuery, ms: number): void;
}
zanith/adapter/typesready

06 — One query, end to end

Watch one call become SQL. About seven microseconds, in five steps.

A single findMany, traced through every layer — with the real intermediate representation at each step. Let it play, or click a stage to scrub. The number on the right is the engine's overhead, on top of the database's own time.

ENTRYtyped call lands in ModelAPI
db.user.findMany({
  where: { email: { contains: 'a', mode: 'insensitive' } },
  orderBy: { createdAt: 'desc' },
  take: 10,
})
 
// args type-checked against the graph — no runtime cost

cumulative overhead

0.4µs

this step · ~0.4µs

1 / 5 stages

auto-playing

Every microsecond above the handoff is the database's. Every microsecond below it, we own — and we measure.