node-postgres
pg · 8.x
the default. Pool-aware. Battle-tested.
import path
zanith/adapters/pgZanith 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.
reads .zanith at boot
in-memory structure
AST → parameterized SQL
pluggable 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 · 11.x
sync I/O — useful for tests and edge runtimes.
import path
zanith/adapters/postgresThe 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
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.
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.