node-postgres
pg · 8.x
the default. Pool-aware. Battle-tested.
import path
zanith/adapters/pgHow it works · 01 — Overview
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.
reads .zanith
in-memory structure
AST → SQL
wire driver
.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.
model User { id Int @id @default(autoincrement()) email String @unique name String? createdAt DateTime @default(now())}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.
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.
What the graph stores
at 1,000-model scale
Models
named records
Fields
scalars + relations
Relations
1:1 / 1:N / M:N
Enums
literal sets
Indexes
single + composite
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.
db.user.findMany({ where: { email: { contains: '@example.com' }, role: { in: ['ADMIN', 'EDITOR'] }, }, orderBy: { createdAt: 'desc' }, take: 10,});SELECT id, email, role, created_atFROM usersWHERE email ILIKE $1 AND role IN ($2, $3)ORDER BY created_at DESCLIMIT 10; -- $1 = '%@example.com%'-- $2 = 'ADMIN'-- $3 = 'EDITOR'Safety · why it matters
User input never reaches the SQL string. Bound parameters are sent to the driver as a separate array.
Safety · why it matters
Postgres types — strings, numbers, dates, JSONB — are serialized by the driver, not by us.
Safety · why it matters
Raw SQL is a tagged template; values are still bound, never spliced into the SQL string.
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.
pg · 8.x
the default. Pool-aware. Battle-tested.
import path
zanith/adapters/pgpostgres · 3.x
the lighter alternative. Fewer features, faster startup.
import path
zanith/adapters/postgresmysql2 · —
on the roadmap. Same compiler, different SQL dialect path.
better-sqlite3 · —
on the roadmap. Sync I/O — useful for tests and edge runtimes.
The interface · 5 methods
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.
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;}06 — One query, end to end
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.
Typed call lands in ModelAPI. Args validated against the schema graph at compile-time.
Graph returns the `User` node and its field/relation metadata. No DB hit; everything is in memory.
Build expression nodes for each filter, join planner attaches included relations.
Tree walked to produce SQL plus a parameter array. No string concatenation of values, ever.
Engine hands the query off. From here on, time is the database's, not Zanith's.
Every microsecond above this line is the database's. Every microsecond below it, we own.