better-drizzle
Plugins

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

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, { 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.
tenant-scope.ts
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 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:

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, 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; 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 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, generated, hasColumn(), primaryKey, relations
isColumnExists(model, column)true when the model exists and has the column
dialect'pg', 'mysql', or 'sqlite'
schemathe relations config from defineRelations(...), keyed by table key: { table, name, relations } per entry. Iterate models for column metadata
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'), 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:

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, or a generated.models override column
PLUGIN_REQUIRED_COLUMN_TYPEa required column exists but its columnType / dataType does not match type, or generated.accepts rejects an override column
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.

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.

On this page