Writing a plugin
definePlugin - typed operation args, transforms, lifecycle hooks, client and model extensions, and plugin state.
definePlugin(...) is the entry point for reusable behavior. It gives you full type inference for hooks, transforms, extensions, and state.
import { definePlugin } from 'better-drizzle/plugins';
export const myPlugin = definePlugin({
id: '@example/my-plugin',
name: 'My Plugin',
version: '1.0.0',
description: 'What it does, in one line.',
});Anatomy
| Field | Purpose |
|---|---|
id | stable unique identifier (required) |
name / version / description | metadata surfaced to setup and extensions |
config | supported dialects and model requires (fail-fast checks) |
operationArgs | extra typed arguments added to delegate methods |
setup(ctx) | runs once at bootstrap - validation, hook/transform registration |
hooks | lifecycle observers (CRUD, query, transaction, raw) |
transform(op) | mutate an operation before it executes |
extendClient(ctx) | add top-level client methods |
extendModel(ctx) | add per-delegate helper methods |
Lifecycle
Bootstrap
better(db, { schema, plugins }) initializes the runtime once.
Initialize in order
Each plugin is set up in array order; setup() runs once.
Build extensions
extendClient() and extendModel() add client and delegate APIs.
Per operation
For each call, registered transforms and hooks run against the bound delegate (or transaction client).
A realistic example
A multi-tenant app needs every query on tenant-owned tables to stay inside the current tenant. Doing that by hand means remembering tenantId in every where and every insert. A single missed filter leaks another customer's data.
This plugin does it in one place:
- Tables without a
tenantIdcolumn, such asplans, are left alone. - Reads, updates, and deletes get
tenantId = <current tenant>added to theirwhere. - Inserts and upserts get the current tenant written into the row, even if the caller passed another one.
- Updates cannot move a row to another tenant.
- A call without a tenant fails instead of running unscoped. System jobs opt out with
meta.system.
import { BetterDrizzleError, BetterDrizzleErrorCode } from 'better-drizzle';
import { definePlugin } from 'better-drizzle/plugins';
type Row = Record<string, unknown>;
// Operations whose `where` must be limited to the current tenant.
const SCOPED_KINDS = new Set([
'findMany', 'findFirst', 'findOne', 'findUnique',
'count', 'exists', 'paginate', 'cursor',
'update', 'updateMany', 'updateEach', 'delete', 'deleteMany',
]);
export const tenantScope = ({ column = 'tenantId' } = {}) =>
definePlugin({
id: '@acme/tenant-scope',
name: 'Tenant scope',
description: 'Scopes every query on tenant-owned tables to meta.tenantId.',
transform(operation) {
if (!operation.model.hasColumn(column)) return operation;
const { tenantId, system } = (operation.meta ?? {}) as {
tenantId?: string;
system?: boolean;
};
if (system) return operation;
if (tenantId === undefined)
throw new BetterDrizzleError({
code: BetterDrizzleErrorCode.OperationError,
message: `${operation.table}.${operation.kind}() needs meta.tenantId.`,
operation: operation.kind,
table: operation.table,
});
if (SCOPED_KINDS.has(operation.kind))
operation.where = (
operation.where
? { AND: [operation.where, { [column]: tenantId }] }
: { [column]: tenantId }
) as typeof operation.where;
switch (operation.kind) {
case 'create':
(operation.data as Row)[column] = tenantId;
break;
case 'createMany':
case 'upsertMany':
for (const row of operation.data as Row[]) row[column] = tenantId;
break;
case 'upsert':
(operation.data as { create: Row }).create[column] = tenantId;
break;
case 'update':
case 'updateMany':
delete (operation.data as Row)[column];
}
return operation;
},
});The tenant comes from meta, so set it once per request with $withContext(...):
const db = better(drizzleDb, { schema, plugins: [tenantScope()] });
// once per request, e.g. in middleware
const tenantDb = db.$withContext({ tenantId: session.tenantId });
await tenantDb.projects.findMany(); // ... WHERE tenant_id = ?
await tenantDb.projects.updateMany({ data: { archived: true } }); // this tenant's rows only
await tenantDb.projects.create({
data: { name: 'Roadmap', tenantId: body.tenantId }, // replaced by the session tenant
});
await db.projects.findMany(); // throws: projects.findMany() needs meta.tenantId.
await db.$withContext({ system: true }).projects.count(); // cross-tenant jobTransactions inherit the context, so tenantDb.transaction((tx) => ...) stays scoped too.
What the transform does not cover
Transforms see the root query only. Relations loaded through include or a
relation select are not filtered, so keep child rows consistent with their
parent's tenant or filter the relation explicitly. Raw SQL and
$withoutPlugins() also bypass the plugin.
Typed operation args
operationArgs adds fields to specific operations, keyed by operation kind. Their types flow from the delegate call into your transforms and hooks. This is how the soft-delete plugin exposes deleted and mode:
operationArgs: {
findMany: { deleted: undefined as 'with' | 'without' | 'only' | undefined },
delete: { mode: undefined as 'soft' | 'hard' | undefined },
},Only the types matter. The values are placeholders and are never read at runtime. Supported keys:
- reads:
findMany,findFirst,findOne,findUnique,count,exists,paginate,cursor - writes:
create,createMany,update,updateMany,updateEach,delete,deleteMany,upsert,upsertMany
Each field name belongs to one plugin per operation. If two plugins declare the same key on the same operation, bootstrap fails with PLUGIN_OPERATION_ARG_CONFLICT.
Transforms
transform(operation) runs for every operation kind, after the plugin's before hooks. It receives the live operation input (the same fields as a hook context, without client and result). Mutate it, or return an object whose fields are merged back onto it. Returning undefined leaves the operation unchanged. It does not skip the call.
These fields are written back to the call's args after all transforms run:
where,select,include,orderBy,take,skip,cursordataforcreate,createMany,upsertMany,update,updateMany, andupdateEachdata.createanddata.updateforupsert
Other args, such as upsertMany.update, can be changed through operation.args, which is a shallow copy of the caller's args.
Transforms see the root query only. Relations loaded through include or a relation select are not transformed.
Hooks
hooks (or ctx.addHook(...) in setup()) registers lifecycle hooks. Every hook can be async.
| Hook | Runs for | Return value |
|---|---|---|
beforeCreate | create, createMany, upsert, upsertMany | any value except undefined replaces data |
beforeUpdate | update, updateMany, updateEach | any value except undefined replaces data |
beforeDelete | delete, deleteMany | any value except undefined skips the database call and becomes the result |
beforeQuery | findMany, findFirst, findOne, findUnique, count, exists, paginate, cursor | any value except undefined skips the database call and becomes the result |
afterCreate / afterUpdate / afterDelete / afterQuery | same kinds as the matching before hook | ignored |
beforeTransaction, afterTransactionCommit, afterTransactionRollback, onTransactionError | transaction(...) | ignored |
beforeRaw, afterRaw, onRawError | $raw, $executeRaw, $rawUnsafe | ignored |
upsert runs the create hooks only, with kind: 'upsert' and data: { create, update }. Plugins have no onError hook. Use onError in client hooks for that.
definePlugin({
id: '@acme/slugs',
hooks: {
beforeCreate(ctx) {
if (ctx.kind !== 'create' || !ctx.model.hasColumn('slug')) return;
const data = ctx.data as Record<string, unknown>;
return { ...data, slug: String(data.title).toLowerCase() };
},
beforeQuery(ctx) {
// short-circuit: count() never hits the database
if (ctx.kind === 'count' && ctx.state.cachedCount !== undefined)
return ctx.state.cachedCount as number;
},
},
});Execution order
For one delegate call:
- plugin before hooks, in plugin order
- plugin transforms, in plugin order
- client before hook
- the database call, skipped when a
beforeDeleteorbeforeQueryreturned a value - client after hook
- plugin after hooks, in plugin order
Every before hook runs even after one returns an override. The last override wins.
Hook contexts are copies
Each hook receives a shallow copy of the operation input. Reassigning
ctx.where inside a hook has no effect. Return new data from
beforeCreate / beforeUpdate, or use a transform to rewrite where,
select, and the other query fields.
Hook context
CRUD and query hooks (and transforms) receive:
Prop
Type
Transaction hooks receive client (the transaction client), attempt, depth, name, comment, meta, transactionOptions, transactionContext, dialect, models, schema, db, options, isInTransaction: true, afterCommit, and afterRollback. afterTransactionRollback adds reason, and onTransactionError adds error.
Raw hooks receive action ('raw' | 'executeRaw' | 'rawUnsafe'), query (rendered SQL text), sql, rawOptions, name, comment, timeoutMs, signal, map, meta, result (in afterRaw), error (in onRawError), plus dialect, schema, db, options, isInTransaction, transaction, transactionContext, afterCommit, and afterRollback.
Client hooks run before plugin hooks for transaction and raw events.
Setup and fail-fast requirements
setup(ctx) runs once per better(...) call, in plugin order. Use it for one-time validation and to register hooks and transforms.
ctx member | Purpose |
|---|---|
addHook(hooks) | register a hooks object, same shape as hooks |
addTransform(fn) | register a transform, same shape as transform |
models | model registry keyed by schema key: name, dbName, columns, hasColumn() |
isColumnExists(model, column) | true when the model exists and has the column |
dialect | 'pg', 'mysql', or 'sqlite' |
schema | the full Drizzle schema |
plugin | this plugin's id, name, version, description, and options |
config declares requirements that are checked at bootstrap, before setup() runs:
definePlugin({
id: 'requires-deleted-at',
config: {
dialects: ['pg', 'sqlite'],
requires: {
columns: [{ column: 'deletedAt' }],
},
},
});Each requires.columns entry takes column (required), optional, and type. The check runs against every model in the schema. With optional: true, models without the column pass; use model.hasColumn(...) in hooks and transforms to skip them. type is matched against the column's Drizzle columnType (such as 'PgTimestamp') or dataType (such as 'date' or 'string') whenever the column exists.
better(...) throws a BetterDrizzleError with one of these codes when a plugin is invalid:
| Code | Cause |
|---|---|
PLUGIN_DUPLICATE_ID | two plugins share an id |
PLUGIN_DIALECT_UNSUPPORTED | the dialect is not in config.dialects |
PLUGIN_REQUIRED_COLUMN_MISSING | a model lacks a required (non-optional) config.requires.columns column |
PLUGIN_REQUIRED_COLUMN_TYPE | a required column exists but its columnType / dataType does not match type |
PLUGIN_OPERATION_ARG_CONFLICT | two plugins declare the same operation arg on one operation |
PLUGIN_EXTENSION_CONFLICT | an extension key collides with an existing client or delegate key |
Client and model extensions
extendClient(ctx)returns an object merged onto the client - new top-level methods.ctxhasclient,db,dialect,models,plugin, andschema.extendModel(ctx)returns an object merged onto each delegate - model-specific helpers (likerestore()on soft-deletable tables).ctxhasclient(the delegate),db,dialect,model,plugin, andschema. Return nothing to skip a model.
Extensions cannot replace existing keys. A collision fails with PLUGIN_EXTENSION_CONFLICT.
definePlugin generics
definePlugin(...) infers everything from the object you pass. Set the generics explicitly only when inference is not enough:
definePlugin<Options, ClientExtension, ModelExtension, State, OperationArgs, ModelExtensionResolver>({ ... });| Parameter | Types |
|---|---|
Options | the options field |
ClientExtension | what extendClient returns |
ModelExtension | what extendModel returns |
State | state in hooks and transforms |
OperationArgs | the operationArgs map |
ModelExtensionResolver | a generic function type for model extensions that depend on the current table (the Zod plugin uses it for $zod) |
Bypassing plugins
Two delegate escape hatches are especially useful from inside plugins - and from application code:
$withState(state)
Returns a cloned delegate with merged plugin state. Use it for a per-call flag without permanently changing the base delegate:
const repo = client.users.$withState({ withDeleted: true });
const users = await repo.findMany({ orderBy: [{ id: 'asc' }] });A transform can then read it via operation.state.withDeleted.
$withoutPlugins()
Returns a cloned delegate that bypasses all plugin transforms and hooks:
await client.users.$withoutPlugins().delete({ where: { id: 1 } });Good for forcing a hard delete beneath a soft-delete plugin, repair scripts, or framework glue that must intentionally skip cross-cutting transforms.
An escape hatch, not a default
$withoutPlugins() is powerful, but the point of plugins is a consistent
normal path. Reach for it deliberately.