# Writing a plugin

> definePlugin - typed operation args, transforms, lifecycle hooks, client and model extensions, and plugin state.

URL: https://better-drizzle.com/docs/plugins/writing-plugins

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](/docs/plugins/plugin-hooks) covers hooks, intercepts, and their context; [scopes and generated columns](/docs/plugins/plugin-scopes) covers model scopes, generated columns, and type resolvers.

```ts
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 `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`.

```ts title="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(...)`:

```ts
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`:

```ts
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](/docs/plugins/plugin-hooks#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](/docs/plugins/plugin-scopes#model-scopes) 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:

```ts
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. `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:

```ts
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:

```ts
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](/docs/plugins/plugin-scopes#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.
