zanith

How it works · 01 — Overview

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 in order, with the actual cost of each step measured in microseconds.

4 layers · joined in-processno build stepno codegenschema = runtimev0.1 · stable
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 adapter2 shipped · 2 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.

adapterplanned

SQLite

better-sqlite3 ·

on the roadmap. Sync I/O — useful for tests and edge runtimes.

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

One call, four layers, about seven microseconds.

Below is a single findMany call, traced through every layer of the engine. The cumulative number on the right is the engine's overhead — what we add on top of the database's actual execution time.

  1. 01 · ENTRYcumulative · 0.4µs

    db.user.findMany(args)

    Typed call lands in ModelAPI. Args validated against the schema graph at compile-time.

    step cost~0.4µs
  2. 02 · GRAPHcumulative · 0.7µs

    lookup model · resolve relations

    Graph returns the `User` node and its field/relation metadata. No DB hit; everything is in memory.

    step cost~0.3µs
  3. 03 · COMPILER · BUILDcumulative · 1.3µs

    expression tree from where/orderBy/take

    Build expression nodes for each filter, join planner attaches included relations.

    step cost~0.6µs
  4. 04 · COMPILER · EMITcumulative · 2.4µs

    AST → parameterized SQL string

    Tree walked to produce SQL plus a parameter array. No string concatenation of values, ever.

    step cost~1.1µs
  5. 05 · ADAPTERcumulative · <7.4µs

    send to driver · pg.query()

    Engine hands the query off. From here on, time is the database's, not Zanith's.

    step cost<5µs

Every microsecond above this line is the database's. Every microsecond below it, we own.