# Rules

> Runtime guardrails for raw SQL, destructive writes, pagination, locks, and tenant context.

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

Use `better-drizzle/rules` to block unsafe database calls at runtime (unfiltered deletes, unbounded reads, raw SQL without a reason). It inspects every repository, raw SQL, and transaction call before it runs, and turns unsafe patterns into warnings or errors. You don't have to wrap each call yourself.

## Install

**npm**

```bash
npm install better-drizzle
```

**pnpm**

```bash
pnpm add better-drizzle
```

**yarn**

```bash
yarn add better-drizzle
```

**bun**

```bash
bun add better-drizzle
```

## Usage

Start from a preset and override what you need:

```ts
import { better } from 'better-drizzle';
import { rules, safe } from 'better-drizzle/rules';

const client = better(db, {
	plugins: [
		rules(
			safe({
				maxLimit: { level: 'error', value: 200 },
			}),
		),
	],
});
```

You can also list rules by hand. Any rule you leave out is off:

```ts
rules({
	noRawUnsafe: true,
	noUpdateManyWithoutWhere: true,
	requireOrderByForCursor: true,
	maxLimit: { level: 'warn', value: 500 },
});
```

## Rule settings

Every rule accepts the same setting shapes:

| Setting                  | Meaning                                                           |
| ------------------------ | ----------------------------------------------------------------- |
| `true`                   | `'error'` with default options                                    |
| `false` or `'off'`       | disabled                                                          |
| `'warn'`                 | reported, the operation still runs                                |
| `'error'`                | reported, then the operation throws                               |
| `{ level?, ...options }` | rule-specific options; `level` defaults to `'error'` when omitted |

```ts
rules({
	noRawUnsafe: true,
	noUnboundedFindMany: 'warn',
	requireRawTimeout: { level: 'error', maxTimeoutMs: 30_000 },
	maxLimit: { value: 500 }, // level defaults to 'error'
});
```

## Plugin options

These options control how violations are handled. They sit next to the rules in the same object.

```js
{
  	enabled: {
  		description: 'Global switch. When false, the plugin stays installed but no rule is evaluated.',
  		type: 'boolean',
  		default: 'true',
  	},
  	throwOnError: {
  		description: 'Whether error-level violations throw a BetterDrizzleError. When false they are only reported.',
  		type: 'boolean',
  		default: 'true',
  	},
  	warnOnViolation: {
  		description: 'Whether warn-level violations are sent to reporter.warn. When false, warnings only reach onViolation.',
  		type: 'boolean',
  		default: 'true',
  	},
  	reporter: {
  		description: 'Receives violations by level. Use it to plug in pino, winston, or your monitoring client.',
  		type: '{ warn?(violation): void; error?(violation): void }',
  	},
  	onViolation: {
  		description: 'Called for every violation, whatever its level, before reporter and before throwing. May be async.',
  		type: '(violation: RulesViolation) => void | Promise<void>',
  	},
  }
```

## Presets

The package exports three presets. Each builds on the previous one:

* `safe()` blocks destructive writes, unsafe raw SQL, and broken lock or cursor usage.
* `recommended()` is `safe()` plus limits on includes and raw timeouts, sensitive-field checks, and a required tenant context.
* `strict()` is `recommended()` with higher levels, lower limits, and the tenant, reason, and ordering rules.

> **recommended() and strict() require a tenant context:** Both presets enable `requireTenantContext` at `'error'`. Every operation, including raw SQL and transactions, then throws unless `tenantId` is set through `$tenant()` or `meta` (for example through `db.$withContext({ tenantId })`), or `meta.system` is truthy. If your app is not multi-tenant, turn it off: `recommended({ requireTenantContext: false })`.

This table shows the level each preset sets. `off` means the preset does not enable the rule.

| Rule                                    | `safe()`   | `recommended()` | `strict()`           |
| --------------------------------------- | ---------- | --------------- | -------------------- |
| `noDeleteManyWithoutWhere`              | error      | error           | error                |
| `noUpdateManyWithoutWhere`              | error      | error           | error                |
| `noDeleteWithoutWhere`                  | error      | error           | error                |
| `noUpdateWithoutWhere`                  | error      | error           | error                |
| `noEmptyWhere`                          | error      | error           | error                |
| `noUnboundedFindMany`                   | warn       | warn            | error                |
| `requireExplicitLimit`                  | off        | off             | warn                 |
| `maxLimit`                              | warn, 1000 | warn, 1000      | error, 500           |
| `requireOrderByForLimit`                | off        | off             | warn                 |
| `requireOrderByForPagination`           | off        | off             | warn                 |
| `requireOrderByForCursor`               | error      | error           | error                |
| `requireStableOrderByForCursor`         | off        | warn            | error                |
| `maxIncludeDepth`                       | off        | warn, 3         | error, 2             |
| `maxIncludeRelations`                   | off        | warn, 5         | error, 3             |
| `requireTransactionForLock`             | warn       | warn            | error                |
| `noLockWithInclude`                     | error      | error           | error                |
| `noInvalidLockCombination`              | error      | error           | error                |
| `requireOrderByForSkipLocked`           | off        | warn            | error                |
| `requireLimitForSkipLocked`             | error      | error           | error                |
| `noRawUnsafe`                           | error      | error           | error                |
| `requireQueryComment`                   | off        | off             | off                  |
| `requireRawComment`                     | off        | off             | warn, 8 chars        |
| `requireRawTimeout`                     | off        | warn            | error                |
| `noRawMutation`                         | off        | warn            | error                |
| `noRawWithoutTransaction`               | off        | off             | warn, mutations only |
| `requireTimeoutForLongRunningOperation` | off        | warn, max 60s   | error, max 30s       |
| `requireTenantContext`                  | off        | error           | error                |
| `noTenantBypassWithoutSystem`           | off        | off             | error                |
| `noTenantColumnOverride`                | off        | off             | error                |
| `requireTenantOnCreate`                 | off        | off             | error                |
| `requireTenantOnUpdate`                 | off        | off             | error                |
| `requireTenantOnDelete`                 | off        | off             | error                |
| `noSensitiveSelect`                     | off        | error           | error                |
| `requireExplicitSensitiveAccessReason`  | off        | off             | error                |
| `noHardDeleteOnSoftDeleteModel`         | off        | error           | error                |
| `requireHardDeleteReason`               | off        | off             | error                |
| `noQueryDeletedWithoutExplicitOptIn`    | off        | off             | warn                 |
| `noSystemModeWithoutReason`             | off        | warn            | error                |

The presets also set some rules that are [not enforced yet](#not-enforced-yet). Those settings have no effect today.

### Preset options in detail

Some preset entries set options beyond the level:

* `noEmptyWhere` checks only `update`, `updateMany`, `delete`, and `deleteMany`. It treats a missing `where` and an empty `AND: []` / `OR: []` as empty.
* `noUnboundedFindMany` allows a `findMany` that has `take` or `limit`. `safe()` and `recommended()` also allow it when there is a `where`; `strict()` does not.
* `maxLimit` applies to `findMany`, `paginate`, and `cursor`.
* `requireStableOrderByForCursor` is set with `requirePrimaryKeyInOrderBy: false`, so it never fires in any preset. Set `requirePrimaryKeyInOrderBy: true` to use it.
* `requireRawTimeout` uses `defaultTimeoutMs: 5000` and `maxTimeoutMs: 30000`.
* `noRawMutation` allows statements that start with `create extension` or `refresh materialized view`.
* `requireTenantContext` uses `allowSystem: true`.
* `noSensitiveSelect` allows the select when the call passes `withSensitive: true`.
* `noHardDeleteOnSoftDeleteModel` allows a hard delete when a reason is given.
* `noQueryDeletedWithoutExplicitOptIn` in `strict()` requires a reason when a call reads deleted rows.

### Overriding a preset

Every preset takes an overrides object. The merge is shallow: an override replaces the preset's whole setting for that rule, it does not merge into it.

```ts
recommended({
	// Replaces the preset's { level: 'warn', value: 1000, applyTo: [...] }.
	// level is now 'error' and applyTo falls back to the rule defaults.
	maxLimit: { value: 200 },
	requireTenantContext: false,
});
```

To combine several configs yourself, use `merge`. It takes an options object:

* `extends`: one config or an array of configs, applied in order.
* `rules`: overrides applied last, on top of every `extends` entry.

The merge is shallow here too. `undefined`, `null`, and `false` entries are skipped, so you can add a config conditionally:

```ts
import { merge, rules, safe } from 'better-drizzle/rules';

rules(
	merge({
		extends: [safe(), process.env.NODE_ENV === 'production' && { throwOnError: false }],
		rules: { noRawMutation: 'warn' },
	}),
);
```

## Scoping rules to models

Most model rules accept `models` and `ignoreModels`. Names are matched against the schema table key.

```ts
rules({
	noUnboundedFindMany: {
		level: 'error',
		ignoreModels: ['countries', 'currencies'],
	},
	requireTenantContext: {
		models: ['invoices', 'customers'],
	},
});
```

The scope type also has an `operations` field, but only `noEmptyWhere`, `requireExplicitLimit`, and `requireTimeoutForLongRunningOperation` read it. `maxLimit` uses `applyTo` instead. Raw SQL and transactions have no model, so `models` and `ignoreModels` never exclude them.

## Violations and errors

Each violation reaches your handlers as a `RulesViolation`:

```ts
import type { RulesOperation } from 'better-drizzle/rules';

type RulesViolation = {
	rule: string; // e.g. 'noDeleteManyWithoutWhere'
	level: 'warn' | 'error';
	message: string;
	operation?: RulesOperation; // 'findMany', 'raw', 'transaction', ...
	model?: string;
	path?: readonly string[]; // e.g. ['where'] or ['select', 'password']
	query?: { where?; data?; select?; include?; orderBy?; limit?; lock? };
	context?: Record<string, unknown>;
	meta?: Record<string, unknown>;
};
```

```ts
rules({
	...recommended(),
	reporter: {
		warn: (v) => logger.warn({ rule: v.rule, model: v.model }, v.message),
		error: (v) => logger.error({ rule: v.rule, model: v.model }, v.message),
	},
	onViolation: (v) => metrics.increment('db.rule_violation', { rule: v.rule }),
});
```

Handlers run in this order:

1. `onViolation` receives every violation.
2. `reporter.warn` receives warnings, unless `warnOnViolation: false`.
3. `reporter.error` receives errors, and then the plugin throws, unless `throwOnError: false`.

The thrown error is a `BetterDrizzleError` with code `OperationError`. Its `message` is the violation message, and `details` holds `rule`, `level`, `operation`, `model`, `path`, `query`, and `meta`. The operation stops at the first error-level violation.

```ts
import { BetterDrizzleError } from 'better-drizzle';

try {
	await client.users.deleteMany({});
} catch (error) {
	if (error instanceof BetterDrizzleError && error.details?.rule === 'noDeleteManyWithoutWhere') {
		// handle the blocked write
	}
}
```

## Edge cases

**Rules that are not enforced yet**

The types, and some presets, accept the rules below, but the plugin does not evaluate them yet. Setting them has no effect. They are reserved so configs keep working once support lands.

* `noSensitiveFieldsInLogs`
* `requireRestoreReason`
* `noWithoutPlugins`, `noPluginBypassWithoutReason`
* `requireAuditContext`, `noAuditLogForIgnoredModelWrite`
* `maxNestedWriteDepth`, `requireTransactionForNestedWrite`
* `requireUniqueWhereForConnect`, `requireUniqueWhereForConnectOrCreate`, `noAmbiguousNestedRelation`
* `requireWhereForAggregate`, `requireLimitForGroupBy`, `maxGroupByLimit`, `requireOrderByForGroupByLimit`
* `noDynamicPreparedShape`, `noPreparedNameConflict`
* `noUnsupportedDialectFeature`, `noSilentDialectFallback`

**What the plugin can and cannot see**

The plugin runs only at runtime, through `before*` hooks, and reads only what the hook payload contains. If a rule can't be evaluated from that payload, it is skipped instead of guessed. Rules run on the root query only; `where`, `orderBy`, or `take` inside a nested `include` are not checked.

For editor and CI feedback on the parts that can be checked statically, see the [ESLint plugin](/docs/plugins/eslint).

The full list of rules, their triggers, and options is in the [rule reference](/docs/plugins/rules-reference).
