Rule reference
Every rule the rules plugin enforces, when it fires, and its options.
Look up a rule here when a violation names it or when you tune a preset. Setup, presets, and handlers are on the rules page.
Every rule below is enforced today. Defaults are the values used when you configure a rule without that option.
Destructive writes
| Rule | Fires when | Options |
|---|---|---|
noDeleteManyWithoutWhere | deleteMany has no where | scope |
noUpdateManyWithoutWhere | updateMany has no where | scope |
noDeleteWithoutWhere | delete has no where | scope |
noUpdateWithoutWhere | update has no where | scope |
noEmptyWhere | where is {}, or missing, or an empty AND/OR (depending on options) | operations (default: all), treatUndefinedAsEmpty (default true), treatEmptyAndOrAsEmpty (default false), scope |
The *WithoutWhere rules only catch a missing where. Pair them with noEmptyWhere to also catch where: {}.
With no operations set, noEmptyWhere checks every operation, and a missing where counts as empty. A plain findMany() or count() then violates it. Set operations to limit it to writes, as the presets do.
Reads and pagination
| Rule | Fires when | Options |
|---|---|---|
noUnboundedFindMany | findMany has neither an allowed bound nor a where | allowWithWhere, allowWithLimit, allowWithTake, allowSmallStaticModels, scope (all flags default to false) |
requireExplicitLimit | the operation has no take/limit | operations (default ['findMany']), scope |
maxLimit | the resolved limit is above value | value (required), applyTo (default ['findMany', 'paginate', 'cursor']), scope |
requireOrderByForLimit | any read with take/limit has no orderBy | scope |
requireOrderByForPagination | paginate has no orderBy | scope |
requireOrderByForCursor | cursor has no orderBy | scope |
requireStableOrderByForCursor | cursor orders by columns that don't include id | requirePrimaryKeyInOrderBy (must be true for the rule to fire), allowAppendPrimaryKey, scope |
maxIncludeDepth | include nests deeper than value | value (required), scope |
maxIncludeRelations | include loads more than value relations in total, nested ones included | value (required), scope |
The resolved limit is take or limit. With noUnboundedFindMany, allowWithLimit accepts either take or limit, and allowSmallStaticModels currently behaves like allowWithWhere.
Row locks
| Rule | Fires when | Options |
|---|---|---|
requireTransactionForLock | a read uses lock outside a transaction | scope |
noLockWithInclude | a read uses lock together with a non-empty include | scope |
noInvalidLockCombination | lock sets both skipLocked and noWait | scope |
requireOrderByForSkipLocked | lock.skipLocked is used without orderBy | scope |
requireLimitForSkipLocked | lock.skipLocked is used without a limit | scope |
Raw SQL
| Rule | Fires when | Options |
|---|---|---|
noRawUnsafe | $rawUnsafe is called | none |
requireRawComment | the raw call has no comment, or it is shorter than minLength | minLength (default 1) |
requireRawTimeout | the raw call has no timeoutMs, or it is above defaultTimeoutMs or maxTimeoutMs | defaultTimeoutMs, maxTimeoutMs |
noRawMutation | the SQL starts with insert, update, delete, replace, create, alter, drop, or truncate | allow: statement prefixes to allow |
noRawWithoutTransaction | the raw call runs outside a transaction | onlyMutations (default false) |
requireTimeoutForLongRunningOperation | a $raw, $rawUnsafe, or $executeRaw call has no timeoutMs, or it is above maxTimeoutMs | operations (default ['raw', 'executeRaw', 'explain']), defaultTimeoutMs, maxTimeoutMs |
await client.$raw(sql`select * from reports where id = ${id}`, {
comment: 'monthly report lookup',
timeoutMs: 2000,
});The raw rules check $raw, $rawUnsafe, and $executeRaw, except noRawUnsafe, which only fires on $rawUnsafe. Because $executeRaw exists for writes, noRawMutation flags every mutation sent through it; use allow for the statements you expect.
requireRawTimeout reports a timeout above defaultTimeoutMs at the rule's own level. When the rule is 'error', defaultTimeoutMs works as the real ceiling.
Multi-tenant safety
Tenant rules read the tenant from the merged operation meta first, then from the transaction context.
const tenantClient = client.$withContext({ tenantId: session.tenantId });
await tenantClient.orders.findMany({ where: { status: 'open' } });
// Background jobs that must cross tenants:
const systemClient = client.$withContext({ system: true, reason: 'nightly billing' });| Rule | Fires when | Options |
|---|---|---|
requireTenantContext | the tenant key is missing, on any operation | contextKey (default 'tenantId'), allowSystem: skip when meta.system is truthy, scope |
noTenantBypassWithoutSystem | the call passes bypassTenant: true without a system flag in meta | systemContextKey (default 'system') |
noTenantColumnOverride | data sets the tenant column to a value different from the current tenant | tenantColumn (default 'tenantId'), contextKey (defaults to tenantColumn), scope |
requireTenantOnCreate | a tenant is set but a create, createMany, upsert, or upsertMany row has no tenantId | scope |
requireTenantOnUpdate | a tenant is set but an update, updateMany, updateEach, or upsert has no where | scope |
requireTenantOnDelete | a tenant is set but a delete or deleteMany has no where | scope |
The three requireTenantOn* rules always look for the tenantId key and column; they don't read contextKey. They don't add the tenant filter for you either. They only check that the payload has the column, or that the write has a where.
Sensitive fields
| Rule | Fires when | Options |
|---|---|---|
noSensitiveSelect | select picks a sensitive field | allowWithSensitive, withSensitiveArg (default 'withSensitive'), scope |
requireExplicitSensitiveAccessReason | a sensitive field is selected with withSensitive: true but no reason | withSensitiveArg (default 'withSensitive'), reasonArg (default 'reason'), scope |
The sensitive field names are fixed: password, passwordHash, secret, secrets, token, tokens, apiKey, apiKeys, and ssn. Only top-level select keys set to true are checked.
Soft delete and system mode
These rules pair with better-drizzle/soft-delete, which adds the mode and deleted call arguments.
| Rule | Fires when | Options |
|---|---|---|
noHardDeleteOnSoftDeleteModel | delete is called with mode: 'hard' | allowWithReason, allowWithContextKey: skip when this meta key is truthy, scope |
requireHardDeleteReason | delete is called with mode: 'hard' and no reason | reasonArg (default 'reason'), scope |
noQueryDeletedWithoutExplicitOptIn | a call reads deleted rows (deleted: 'with' or 'only') without a reason | requireReason (must be true for the rule to fire), reasonArg (default 'reason'), scope |
noSystemModeWithoutReason | the call passes system: true without a reason | reasonArg (default 'reason') |
Only delete is checked for hard deletes; deleteMany is not.
Where opt-in flags are read
Rules look for their flags in these places:
- Reasons (
reasonor yourreasonArg): the call arguments first, thenmeta, then the transactioncontext. Pass them throughmetato stay type-safe:{ meta: { reason: 'GDPR export' } }. withSensitive,bypassTenant, andsystem: only the call arguments. The core delegate types don't declare these fields, so you need another plugin that declares them throughoperationArgs, or a cast.- Tenant and system keys:
metafirst, then the transactioncontext.