Zenstack plugin dev
Agent skills for ZenStack, published to skills.sh — ZModel schema modeling, access control, querying, automatic CRUD service, migrations, and plugin development.
npx -y skills add zenstackhq/skills --skill zenstack-plugin-devAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Develop ZenStack V3 plugins. Use when building or declaring a plugin — custom ZModel attributes/functions, CLI code generators (CliPlugin with a generate callback for `zen generate`), or runtime plugins installed via $use/definePlugin (onQuery, onKyselyQuery, onEntityMutation hooks, extending the client, custom query args, computed result fields).
SKILL.md
9.7 KB, as published. Nobody here has run it
ZenStack V3 — Plugin Development
Plugins are a preview feature and may change in future releases.
A ZenStack plugin can extend the toolkit at three levels, independently or together:
- Schema (ZModel) — contribute custom attributes (e.g.
@password) and functions to the schema language. - CLI (code generation) — participate in
zen generateto emit artifacts from the schema. - Runtime (ORM) — install on the client to intercept queries, hook CRUD lifecycle events, transform SQL, add custom methods, or compute fields.
ZenStack's plugin replaces Prisma's generator. To declare/consume plugins in a schema see
zenstack-schema-modeling; this skill is about authoring them.
Packages: @zenstackhq/orm (runtime plugin types), @zenstackhq/sdk (CLI plugin types + ZModel
AST). Install the SDK as a dev dependency: npm install -D @zenstackhq/sdk.
Declaring a plugin in ZModel
plugin myPlugin {
provider = './my-plugin' // local .ts/.js/.mts/.mjs file, a folder with an index, or an npm package
output = './generated' // arbitrary key=value options, readable by the plugin
}
provider resolution order: local file path → folder with an index → npm package. CLI plugins must
be ESM; TypeScript providers are loaded via jiti. The built-in
@core/typescript plugin runs automatically; @core/prisma emits a Prisma schema.
Runtime plugins
A runtime plugin is an object implementing RuntimePlugin from @zenstackhq/orm. Define it three
ways; definePlugin gives the best type inference:
import { definePlugin, type RuntimePlugin } from '@zenstackhq/orm';
// plain object / definePlugin (recommended)
const plugin = definePlugin({ id: 'my-plugin', /* hooks… */ });
// or a class
class MyPlugin implements RuntimePlugin<typeof schema> {
id = 'my-plugin';
/* hooks… */
}
Install on the client (immutable — returns a new client, original unchanged); remove with
$unuse(id) / $unuseAll(). You can also pass plugins: [...] to the ZenStackClient constructor.
const db = new ZenStackClient(schema, { dialect });
const withPlugin = db.$use(plugin); // new client
const plain = withPlugin.$unuse('my-plugin'); // remove by id
// or: new ZenStackClient(schema, { dialect, plugins: [plugin] });
Every plugin needs a unique id. Add any combination of the hooks below.
onQuery — intercept ORM operations
Fires for ORM API calls only (findMany, create, …), not the query builder. Wrap, modify
args, short-circuit, or post-process results via proceed.
definePlugin({
id: 'timing',
onQuery: async ({ model, operation, args, proceed }) => {
const start = Date.now();
const result = await proceed(args);
console.log(`${model}.${operation} took ${Date.now() - start}ms`);
return result;
},
});
onKyselyQuery — intercept/transform SQL
The lowest-level hook: every DB access (ORM and query builder) flows through Kysely here, so you
can inspect or rewrite the SQL AST (OperationNode) before proceed. Good for soft-deletes,
injected filters, column transforms.
import type { OnKyselyQueryCallback } from '@zenstackhq/orm';
const hook: OnKyselyQueryCallback<typeof schema> = async ({ query, proceed }) => {
// inspect/transform `query` (a Kysely OperationNode), e.g. with an OperationNodeTransformer
return proceed(query);
};
definePlugin({ id: 'sql-rewrite', onKyselyQuery: hook });
onEntityMutation — before/after create·update·delete
Lifecycle hooks around entity mutations, with lazy loaders for the affected rows.
definePlugin({
id: 'audit',
onEntityMutation: {
runAfterMutationWithinTransaction: false, // default: run after the tx commits
async beforeEntityMutation({ model, action, loadBeforeMutationEntities }) {
// action: 'create' | 'update' | 'delete'
if (model === 'Post' && action === 'update') {
const before = await loadBeforeMutationEntities();
}
},
async afterEntityMutation({ model, action, loadAfterMutationEntities }) {
const after = await loadAfterMutationEntities();
},
},
});
Set runAfterMutationWithinTransaction: true to run the after-hook atomically inside the mutation's
transaction. Note: mutations triggered by DB cascades are not captured, and the loaders can be
expensive on bulk mutations.
Extending the client (client)
Add custom methods/properties under client. Names must be $-prefixed so they don't shadow
model accessors; grouping under one $namespace object is recommended.
const cacheDb = db.$use(definePlugin({
id: 'cache',
client: {
$cache: {
get stats() { return { hits: 0, misses: 0 }; },
async invalidate() { /* … */ },
},
},
}));
await cacheDb.$cache.invalidate();
Custom query args (queryArgs)
Accept extra options on ORM calls, validated with a Zod schema. Target $all, the
$create/$read/$update/$delete groups, or specific operations.
import { z } from 'zod';
definePlugin({
id: 'cache',
queryArgs: {
$read: z.object({ cache: z.strictObject({ ttl: z.number().positive().optional() }).optional() }),
},
onQuery: ({ args, proceed }) => proceed(args), // read (args as any).cache
});
// await db.user.findMany({ where: {...}, cache: { ttl: 60 } });
Computed result fields (result, v3.5.0+)
Add virtual fields computed after a query from declared needs fields.
definePlugin({
id: 'full-name',
result: {
user: {
fullName: {
needs: { firstName: true, lastName: true },
compute: (user) => `${user.firstName} ${user.lastName}`,
},
},
},
});
CLI plugins (code generation)
A CLI plugin is an ESM module whose default export is a CliPlugin with a generate callback.
It runs during zen generate, receiving the parsed ZModel AST.
import type { CliPlugin } from '@zenstackhq/sdk';
import { isDataModel, type InvocationExpr } from '@zenstackhq/sdk/ast';
import fs from 'node:fs';
const plugin: CliPlugin = {
name: 'Password Report',
// statusText is optional
generate: ({ model, defaultOutputPath, pluginOptions }) => {
if (pluginOptions['report'] !== true) return; // options come from the ZModel plugin block
let out = '# Password Fields\n\n';
for (const dm of model.declarations.filter(isDataModel)) {
for (const field of dm.fields) {
const attr = field.attributes.find((a) => a.decl.$refText === '@password');
if (attr) out += `- ${dm.name}.${field.name}\n`;
}
}
fs.writeFileSync(`${defaultOutputPath}/password-fields.md`, out, 'utf-8');
},
};
export default plugin;
generate receives { model, defaultOutputPath, pluginOptions } — the ZModel AST, the resolved
output directory (the output option or a default), and the options from the plugin block.
Schema plugins (custom attributes & functions)
Declare new ZModel attributes/functions in a .zmodel the plugin ships, then use them in the app
schema. @@@targetField constrains where an attribute applies.
// my-plugin/plugin.zmodel
attribute @password(hasher: Any) @@@targetField([StringField])
function bcryptHasher(rounds: Int): Any {}
// app schema
model User {
id Int @id @default(autoincrement())
password String @password(hasher: bcryptHasher(10))
}
End-to-end pattern: password hasher
The docs' worked example combines all three levels into one plugin package:
- Schema —
plugin.zmodeldefines@password+bcryptHasher(); the app schema applies@password(hasher: bcryptHasher(10))to a field. - CLI — a
CliPlugin.generatewalks the AST and emits a "password fields" report when thereportoption is set. - Runtime — a
RuntimePluginwithonKyselyQueryuses a KyselyOperationNodeTransformerto detect@passwordfields (viaschema.models[...].fields[...].attributes) and hash values onINSERT/UPDATEbefore they hit the database.
Install with db.$use(new PasswordHasherPlugin()) (or the plugins constructor option), then
npm install -D @zenstackhq/sdk and zen generate to run the CLI side.
Use Zod v4 for
queryArgsschemas to match ZenStack's bundled version.
Reference docs
Full ZenStack documentation for this topic is bundled under references/:
- plugin-dev.md — end-to-end plugin development guide
- runtime-plugins-overview.md — runtime plugin model
- extending-orm-client.md — client methods, query args, computed fields
- entity-mutation-hooks.md — before/after mutation hooks
- kysely-query-hooks.md —
onKyselyQuerySQL transformation - query-api-hooks.md —
onQueryORM interception - zmodel-plugin.md — declaring plugins in ZModel
- zmodel-plugin-reference.md —
pluginblock reference