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';

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

This plugin adds a typed traceId arg to findMany, a withDeleted() state toggle, a forceDelete() helper that bypasses plugins, and a transform that hides soft-deleted rows:

import { definePlugin } from 'better-drizzle';

export const traceAndVisibility = definePlugin({
	id: '@example/trace-and-visibility',
	name: 'Trace And Visibility',
	description: 'Adds trace metadata and a withDeleted state flag.',
	operationArgs: {
		findMany: {
			traceId: undefined as string | undefined,
		},
	},
	extendModel({ client, model }) {
		if (!model.hasColumn('deletedAt')) return;

		return {
			withDeleted() {
				return client.$withState({ withDeleted: true });
			},
			forceDelete(id: number) {
				return client.$withoutPlugins().delete({ where: { id } } as never);
			},
		};
	},
	hooks: {
		beforeQuery(ctx) {
			if (ctx.action === 'findMany' && ctx.args.traceId) {
				console.log('trace', ctx.args.traceId);
			}
		},
	},
	transform(operation) {
		if (!operation.model.hasColumn('deletedAt')) return operation;
		if (operation.state.withDeleted) return operation;
		if (
			operation.kind !== 'findMany' &&
			operation.kind !== 'findFirst' &&
			operation.kind !== 'count'
		) {
			return operation;
		}

		operation.where = (
			operation.where
				? { AND: [operation.where, { deletedAt: null }] }
				: { deletedAt: null }
		) as typeof operation.where;

		return operation;
	},
});

Using it — the traceId arg and withDeleted() helper are both typed:

const client = better(db, { schema, plugins: [traceAndVisibility] });

const visible = await client.users.findMany({ traceId: 'req_123' });
const all = await client.users.withDeleted().findMany({ traceId: 'req_123' });

Typed operation args

operationArgs adds fields to specific operations, keyed by operation kind. Their types flow from the delegate call all the way into your transforms and hooks — this is how the soft-delete plugin exposes deleted and mode in a fully typed way.

operationArgs: {
	findMany: { deleted: 'without' as 'with' | 'without' | 'only' },
	delete: { mode: 'soft' as 'soft' | 'hard' },
},

Supported operation keys cover the full delegate/client surface, including:

  • reads: findMany, findFirst, findOne, findUnique, count, exists, paginate
  • writes: create, createMany, update, updateMany, delete, deleteMany, upsert, upsertMany

That matters for plugins like timestamps, soft delete, or custom bulk-write policies that need to extend upsertMany as well as the simpler CRUD methods.

Transforms

A transform receives the operation and returns a (possibly modified) operation — or undefined to skip it. Common uses: rewriting where for visibility, injecting data, or enforcing a tenant filter. Read operation.kind, operation.where, operation.data, operation.state, and operation.model to decide.

For create-shaped operations, that includes upsert and upsertMany, which lets plugins modify batch upsert payloads and conflict-update strategies in a typed way.

Setup and fail-fast requirements

setup() is the place for one-time validation and registering hooks/transforms via ctx.addHook / ctx.addTransform. You can also declare hard requirements so an incompatible model fails at bootstrap rather than at runtime:

definePlugin({
	id: 'requires-deleted-at',
	config: {
		dialects: ['pg', 'sqlite'],
		requires: {
			columns: [{ column: 'deletedAt' }],
		},
	},
});

Client and model extensions

  • extendClient(ctx) returns an object merged onto the client — new top-level methods.
  • extendModel(ctx) returns an object merged onto each delegate — model-specific helpers (like restore() on soft-deletable tables). Return nothing to skip a model.

Both receive the dialect, schema, model registry, and a bound client/delegate.

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