Writing a plugin
definePlugin - typed operation args, transforms, lifecycle hooks, client and model extensions, and plugin state.
Write a plugin when the same behavior (stamping columns, filtering rows, logging, validation) should apply to every call without repeating it. definePlugin(...) infers types for hooks, transforms, extensions, and state.
This page covers the structure. Plugin hooks covers hooks, intercepts, and their context; scopes and generated columns covers model scopes, generated columns, and type resolvers.
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, { 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(...):
import { better } from 'better-drizzle';
import { tenantScope } from './tenant-scope';
// `projects` stores the tenant in `organizationId`
const client = better(db, {
plugins: [tenantScope({ column: 'organizationId' })],
});
// once per request, e.g. in middleware
const tenantClient = client.$withContext({ tenantId: session.tenantId });
await tenantClient.projects.findMany(); // ... WHERE organization_id = ?
await tenantClient.projects.updateMany({ data: { archived: true } }); // this tenant's rows only
await tenantClient.projects.create({
data: { name: 'Roadmap', organizationId: 'org_other' }, // replaced by the session tenant
});
await client.projects.findMany(); // throws: projects.findMany() needs meta.tenantId.
await client.$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:
definePlugin({
id: 'soft-delete',
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; declare a model scope to filter every level.
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, generated, hasColumn(), primaryKey, relations |
isColumnExists(model, column) | true when the model exists and has the column |
dialect | 'pg', 'mysql', or 'sqlite' |
schema | the relations config from defineRelations(...), keyed by table key: { table, name, relations } per entry. Iterate models for column metadata |
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'), its full dataType, or one part of it whenever the column exists. In Drizzle 1.x dataType is "<type> <constraint>" (such as 'object date', 'number int32', or 'string uuid'), so 'date', 'object date', and 'string' all match the corresponding columns.
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, or a generated.models override column |
PLUGIN_REQUIRED_COLUMN_TYPE | a required column exists but its columnType / dataType does not match type, or generated.accepts rejects an override column |
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.
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. It also drops model scopes, the tenant scope included; pass { keepScopes: true } to skip hooks and transforms but keep them.
An escape hatch, not a default
$withoutPlugins() is powerful, but the point of plugins is a consistent
normal path. Reach for it deliberately.