better-drizzle
Plugins

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

FieldPurpose
idstable unique identifier (required)
name / version / descriptionmetadata surfaced to setup and extensions
configsupported dialects and model requires (fail-fast checks)
operationArgsextra typed arguments added to delegate methods
setup(ctx)runs once at bootstrap - validation, hook/transform registration
hookslifecycle 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 tenantId column, such as plans, are left alone.
  • Reads, updates, and deletes get tenantId = <current tenant> added to their where.
  • 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 job

Transactions 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, cursor
  • data for create, createMany, upsertMany, update, updateMany, and updateEach
  • data.create and data.update for upsert

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.

HookRuns forReturn value
beforeCreatecreate, createMany, upsert, upsertManyany value except undefined replaces data
beforeUpdateupdate, updateMany, updateEachany value except undefined replaces data
beforeDeletedelete, deleteManyany value except undefined skips the database call and becomes the result
beforeQueryfindMany, findFirst, findOne, findUnique, count, exists, paginate, cursorany value except undefined skips the database call and becomes the result
afterCreate / afterUpdate / afterDelete / afterQuerysame kinds as the matching before hookignored
beforeTransaction, afterTransactionCommit, afterTransactionRollback, onTransactionErrortransaction(...)ignored
beforeRaw, afterRaw, onRawError$raw, $executeRaw, $rawUnsafeignored

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:

  1. plugin before hooks, in plugin order
  2. plugin transforms, in plugin order
  3. client before hook
  4. the database call, skipped when a beforeDelete or beforeQuery returned a value
  5. client after hook
  6. 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 memberPurpose
addHook(hooks)register a hooks object, same shape as hooks
addTransform(fn)register a transform, same shape as transform
modelsmodel 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'
schemathe full Drizzle schema
pluginthis 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:

CodeCause
PLUGIN_DUPLICATE_IDtwo plugins share an id
PLUGIN_DIALECT_UNSUPPORTEDthe dialect is not in config.dialects
PLUGIN_REQUIRED_COLUMN_MISSINGa model lacks a required (non-optional) config.requires.columns column
PLUGIN_REQUIRED_COLUMN_TYPEa required column exists but its columnType / dataType does not match type
PLUGIN_OPERATION_ARG_CONFLICTtwo plugins declare the same operation arg on one operation
PLUGIN_EXTENSION_CONFLICTan 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. ctx has client, db, dialect, models, plugin, and schema.
  • extendModel(ctx) returns an object merged onto each delegate - model-specific helpers (like restore() on soft-deletable tables). ctx has client (the delegate), db, dialect, model, plugin, and schema. 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>({ ... });
ParameterTypes
Optionsthe options field
ClientExtensionwhat extendClient returns
ModelExtensionwhat extendModel returns
Statestate in hooks and transforms
OperationArgsthe operationArgs map
ModelExtensionResolvera 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.

On this page