ZenDB

Define Zod tables.
Write raw SQL.
Get typed objects.

The missing link between SQL and typed data β€” a thin, forward-only layer over Zod and your SQL driver.

npm install @b9g/zen zod better-sqlite3

What ZenDB is not

Not a query builder

You write SQL directly with tagged templates β€” no .where().orderBy().limit() chains. Helpers handle the tedious parts without hiding or limiting your queries.

Not an ORM

Tables aren't classes. They're Zod-powered singletons that validate writes, generate DDL, and deduplicate joined data. No lazy loading, no session-wide identity map.

Not a startup

ZenDB is an open-source library, not a venture-backed SaaS. There will never be a managed instance or a β€œZen Studio” β€” just a thin wrapper around Zod and your SQL driver.

Everything you need, nothing you don't

Typed CRUD

Insert, update, and query return fully-typed rows inferred straight from your Zod schema β€” including DB-computed defaults via RETURNING.

Normalized references

all() deduplicates rows by primary key and resolves references() into real object graphs β€” forward and reverse β€” with zero N+1 queries.

Idempotent migrations

IndexedDB-style upgradeneeded events with safe, additive-only helpers: ensureTable, ensureConstraints, copyColumn.

SQLite Β· Postgres Β· MySQL

One template API across three dialects. Drivers build native placeholders (? / $1) β€” no SQL parsing, no lock-in.

Zod validation on writes

Every insert and update is validated against your schema. Reads are never re-validated, so queries stay fast.

Raw SQL, always

JOINs, CTEs, window functions, dialect-specific syntax β€” write whatever SQL you need. Normalization is driven by table metadata, not query shape.

Quick start

Define tables, open the database, and query. That's the whole loop.

import {z, table, Database} from "@b9g/zen";
import SQLiteDriver from "@b9g/zen/sqlite";

// 1. Define tables with Zod schema
const Users = table("users", {
  id: z.string().uuid().db.primary().db.auto(),
  email: z.string().email().db.unique(),
  name: z.string(),
});

const Posts = table("posts", {
  id: z.string().uuid().db.primary().db.auto(),
  authorId: z.string().uuid().db.references(Users, "author"),
  title: z.string(),
  published: z.boolean().db.inserted(() => false),
});

// 2. Open the database and create tables
const db = new Database(new SQLiteDriver("file:app.db"));
db.addEventListener("upgradeneeded", (e) => {
  e.waitUntil(db.ensureTable(Users).then(() => db.ensureTable(Posts)));
});
await db.open(1);

// 3. Insert with validation β€” id is auto-generated
const user = await db.insert(Users, {email: "alice@example.com", name: "Alice"});

// 4. Write raw SQL, get typed & normalized objects back
const posts = await db.all([Posts, Users])`
  JOIN "users" ON ${Users.on(Posts)}
  WHERE ${Posts.cols.published} = ${true}
`;

posts[0].author?.name; // "Alice" β€” resolved from the JOIN, fully typed
Read the guides β†’