better-drizzle
Plugins

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

RuleFires whenOptions
noDeleteManyWithoutWheredeleteMany has no wherescope
noUpdateManyWithoutWhereupdateMany has no wherescope
noDeleteWithoutWheredelete has no wherescope
noUpdateWithoutWhereupdate has no wherescope
noEmptyWherewhere 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

RuleFires whenOptions
noUnboundedFindManyfindMany has neither an allowed bound nor a whereallowWithWhere, allowWithLimit, allowWithTake, allowSmallStaticModels, scope (all flags default to false)
requireExplicitLimitthe operation has no take/limitoperations (default ['findMany']), scope
maxLimitthe resolved limit is above valuevalue (required), applyTo (default ['findMany', 'paginate', 'cursor']), scope
requireOrderByForLimitany read with take/limit has no orderByscope
requireOrderByForPaginationpaginate has no orderByscope
requireOrderByForCursorcursor has no orderByscope
requireStableOrderByForCursorcursor orders by columns that don't include idrequirePrimaryKeyInOrderBy (must be true for the rule to fire), allowAppendPrimaryKey, scope
maxIncludeDepthinclude nests deeper than valuevalue (required), scope
maxIncludeRelationsinclude loads more than value relations in total, nested ones includedvalue (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

RuleFires whenOptions
requireTransactionForLocka read uses lock outside a transactionscope
noLockWithIncludea read uses lock together with a non-empty includescope
noInvalidLockCombinationlock sets both skipLocked and noWaitscope
requireOrderByForSkipLockedlock.skipLocked is used without orderByscope
requireLimitForSkipLockedlock.skipLocked is used without a limitscope

Raw SQL

RuleFires whenOptions
noRawUnsafe$rawUnsafe is callednone
requireRawCommentthe raw call has no comment, or it is shorter than minLengthminLength (default 1)
requireRawTimeoutthe raw call has no timeoutMs, or it is above defaultTimeoutMs or maxTimeoutMsdefaultTimeoutMs, maxTimeoutMs
noRawMutationthe SQL starts with insert, update, delete, replace, create, alter, drop, or truncateallow: statement prefixes to allow
noRawWithoutTransactionthe raw call runs outside a transactiononlyMutations (default false)
requireTimeoutForLongRunningOperationa $raw, $rawUnsafe, or $executeRaw call has no timeoutMs, or it is above maxTimeoutMsoperations (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' });
RuleFires whenOptions
requireTenantContextthe tenant key is missing, on any operationcontextKey (default 'tenantId'), allowSystem: skip when meta.system is truthy, scope
noTenantBypassWithoutSystemthe call passes bypassTenant: true without a system flag in metasystemContextKey (default 'system')
noTenantColumnOverridedata sets the tenant column to a value different from the current tenanttenantColumn (default 'tenantId'), contextKey (defaults to tenantColumn), scope
requireTenantOnCreatea tenant is set but a create, createMany, upsert, or upsertMany row has no tenantIdscope
requireTenantOnUpdatea tenant is set but an update, updateMany, updateEach, or upsert has no wherescope
requireTenantOnDeletea tenant is set but a delete or deleteMany has no wherescope

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

RuleFires whenOptions
noSensitiveSelectselect picks a sensitive fieldallowWithSensitive, withSensitiveArg (default 'withSensitive'), scope
requireExplicitSensitiveAccessReasona sensitive field is selected with withSensitive: true but no reasonwithSensitiveArg (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.

RuleFires whenOptions
noHardDeleteOnSoftDeleteModeldelete is called with mode: 'hard'allowWithReason, allowWithContextKey: skip when this meta key is truthy, scope
requireHardDeleteReasondelete is called with mode: 'hard' and no reasonreasonArg (default 'reason'), scope
noQueryDeletedWithoutExplicitOptIna call reads deleted rows (deleted: 'with' or 'only') without a reasonrequireReason (must be true for the rule to fire), reasonArg (default 'reason'), scope
noSystemModeWithoutReasonthe call passes system: true without a reasonreasonArg (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 (reason or your reasonArg): the call arguments first, then meta, then the transaction context. Pass them through meta to stay type-safe: { meta: { reason: 'GDPR export' } }.
  • withSensitive, bypassTenant, and system: only the call arguments. The core delegate types don't declare these fields, so you need another plugin that declares them through operationArgs, or a cast.
  • Tenant and system keys: meta first, then the transaction context.

On this page