| name | zenstack-v3 |
| description | ZenStack v3 schema-first ORM for TypeScript. Use when working with ZModel schemas, ZenStack ORM CRUD operations, access control policies (@@allow/@@deny), database migrations, server adapters (Next.js, Express), runtime plugins, computed fields, polymorphism, typed JSON, or migrating from Prisma/ZenStack v2. Triggers on: ZModel, ZenStack, zenstack, .zmodel files, @@allow, @@deny, auth(), PolicyPlugin, ZenStackClient, @zenstackhq/orm, zen generate, zen migrate.
|
ZenStack v3 — AI Coding Agent Skill
Critical Context
ZenStack v3 is a COMPLETE REWRITE. It removed Prisma as a runtime dependency and built
its own ORM on Kysely. The schema language remains a "Prisma superset"
but the runtime is entirely different.
Do NOT confuse with v2:
- v2 used
@zenstackhq/runtime → v3 uses @zenstackhq/orm
- v2 CLI was
zenstack package → v3 is @zenstackhq/cli
- v2 had
abstract model → v3 uses type + with (mixins)
- v2 used
enhance(prisma, ...) → v3 uses db.$use(new PolicyPlugin())
- v2 generated into node_modules → v3 generates to
zenstack/ folder
Supported databases: PostgreSQL, MySQL, SQLite only.
Quick Start
Installation
npm install @zenstackhq/schema @zenstackhq/orm
npm install --save-dev @zenstackhq/cli
npm install better-sqlite3
Minimal Schema
// zenstack/schema.zmodel
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
model User {
id Int @id @default(autoincrement())
email String @unique
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
}
Generate & Create Client
npx zen generate
import { ZenStackClient } from '@zenstackhq/orm';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import SQLite from 'better-sqlite3';
import { schema } from './zenstack/schema';
export const db = new ZenStackClient(schema, {
dialect: new SqliteDialect({ database: new SQLite('./dev.db') }),
});
Basic CRUD
const user = await db.user.create({
data: { email: 'alice@test.com', posts: { create: { title: 'Hello' } } },
include: { posts: true },
});
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
include: { author: true },
});
await db.post.update({
where: { id: 1 },
data: { published: true },
});
await db.post.delete({ where: { id: 1 } });
const [u, p] = await db.$transaction(async (tx) => {
const u = await tx.user.create({ data: { email: 'bob@test.com' } });
const p = await tx.post.create({ data: { title: 'Hi', authorId: u.id } });
return [u, p];
});
Access Control
Setup
npm install @zenstackhq/plugin-policy
plugin policy {
provider = '@zenstackhq/plugin-policy'
}
model Post {
id Int @id @default(autoincrement())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
@@deny('all', auth() == null)
@@allow('read', published)
@@allow('all', auth().id == authorId)
}
Runtime
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
const authDb = db.$use(new PolicyPlugin());
const userDb = authDb.$setAuth({ id: currentUserId });
Evaluation: deny wins → allow wins → denied by default.
Operations: create, read, update, post-update, delete, all (excludes post-update).
Key Functions
auth() — current user (type from @@auth model or User model)
check(relation, operation?) — delegate to relation's policies
now() — current datetime
- Collection predicates:
relation?[cond] (some), relation![cond] (every), relation^[cond] (none)
Schema Patterns
Mixins
type Timestamped {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post with Timestamped {
id Int @id @default(autoincrement())
title String
}
Polymorphism (MTI)
model Content {
id Int @id @default(autoincrement())
name String
type String
@@delegate(type)
}
model Post extends Content {
body String
}
model Video extends Content {
url String
}
Typed JSON
type Address {
street String
city String
zip Int
}
model User {
id Int @id
address Address @json
}
Computed Fields
model User {
id Int @id
postCount Int @computed
}
const db = new ZenStackClient(schema, {
dialect: ...,
computedFields: {
User: {
postCount: (eb) =>
eb.selectFrom('Post')
.whereRef('Post.authorId', '=', 'id')
.select(({ fn }) => fn.countAll<number>().as('count')),
},
},
});
Query Builder ($qb)
Escape hatch to full Kysely when ORM API isn't enough:
const result = await db.$qb
.selectFrom('User')
.leftJoin('Post', 'Post.authorId', 'User.id')
.select(['User.id', 'User.email', 'Post.title'])
.execute();
Mix into ORM filters with $expr:
await db.user.findMany({
where: {
$expr: (eb) =>
eb.selectFrom('Post')
.whereRef('Post.authorId', '=', 'User.id')
.select(({ fn }) => eb(fn.countAll(), '>=', 2).as('v'))
}
});
Server Adapters
Next.js (App Router)
import { NextRequestHandler } from '@zenstackhq/server/next';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { schema } from '~/zenstack/schema';
const handler = NextRequestHandler({
apiHandler: new RPCApiHandler({ schema }),
getClient: (req) => authDb.$setAuth(getSessionUser(req)),
useAppDir: true,
});
export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE };
Express.js
import { ZenStackMiddleware } from '@zenstackhq/server/express';
import { RPCApiHandler } from '@zenstackhq/server/api';
app.use('/api/model', ZenStackMiddleware({
apiHandler: new RPCApiHandler({ schema }),
getClient: (req) => authDb.$setAuth(getUser(req)),
}));
CLI Commands
| Command | Purpose |
|---|
zen generate | Compile ZModel → TypeScript |
zen db push | Push schema to DB (dev only) |
zen db pull | Introspect DB → ZModel |
zen migrate dev | Create + apply migration (dev) |
zen migrate deploy | Apply pending migrations (prod) |
zen migrate reset | Drop + reapply all (dev only) |
zen migrate status | Check migration status |
Plugins
Schema Plugins
plugin policy {
provider = '@zenstackhq/plugin-policy'
}
plugin prisma {
provider = '@core/prisma'
output = '../prisma/schema.prisma'
}
Runtime Plugins
const enhanced = db.$use({
id: 'my-plugin',
onQuery: {
$allModels: {
async $allOperations({ args, proceed }) {
console.log('query intercepted');
return proceed(args);
},
},
},
});
Hook types: Query API hooks, Entity mutation hooks, Kysely query hooks.
Reference Files
For detailed documentation, see:
references/schema-language.md — models, relations, enums, attributes, mixins, polymorphism, typed JSON
references/access-control.md — policies, auth(), field-level policies, runtime behavior
references/orm-api.md — CRUD, filters, transactions, computed fields, query builder
references/plugins-and-cli.md — plugin system, CLI commands, code generation
references/server-adapters.md — Next.js, Express, REST API, client-side hooks
references/migration.md — migrating from Prisma and from v2
Common Gotchas
- No Prisma at runtime — don't import from
@prisma/client
- Install DB driver yourself — ZenStack doesn't bundle drivers
$use() and $setAuth() return new clients — they're immutable
- Raw SQL bypasses access control —
$executeRaw, $queryRaw, sql tag
- Cascade deletes/triggers bypass access control — DB-internal operations
select and include are mutually exclusive — can't use both
post-update must be explicit — all doesn't include it
- v3 generates to
zenstack/ folder — not into node_modules
- Computed fields are DB-side — not client-side like Prisma extensions
- Only PostgreSQL, MySQL, SQLite — no MongoDB, CockroachDB, etc.
- Use multiple schema files — use a zmodel file per model/enum/type in a ./schema folder
- Zmodel files allow circular imports - don't worry about circular imports in zmodel