zenstack-plugin-dev — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited zenstack-plugin-dev (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Plugins are a preview feature and may change in future releases.
A ZenStack plugin can extend the toolkit at three levels, independently or together:
@password) and functions to theschema language.
zen generate to emit artifacts from the schema.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.
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.
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 operationsFires 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 SQLThe 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·deleteLifecycle 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.
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();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 } });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}`,
},
},
},
});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.
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))
}The docs' worked example combines all three levels into one plugin package:
plugin.zmodel defines @password + bcryptHasher(); the app schema applies@password(hasher: bcryptHasher(10)) to a field.
CliPlugin.generate walks the AST and emits a "password fields" report when thereport option is set.
RuntimePlugin with onKyselyQuery uses a Kysely OperationNodeTransformer todetect @password fields (via schema.models[...].fields[...].attributes) and hash values on INSERT/UPDATE before 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 queryArgs schemas to match ZenStack's bundled version.Full ZenStack documentation for this topic is bundled under references/:
onKyselyQuery SQL transformationonQuery ORM interceptionplugin block reference~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.