Rules
Runtime guardrails for raw SQL, destructive writes, pagination, locks, and tenant context.
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 install better-drizzlepnpm add better-drizzleyarn add better-drizzlebun add better-drizzleUsage
Start from a preset and override what you need:
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:
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 |
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.
Prop
Type
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()issafe()plus limits on includes and raw timeouts, sensitive-field checks, and a required tenant context.strict()isrecommended()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. Those settings have no effect today.
Preset options in detail
Some preset entries set options beyond the level:
noEmptyWherechecks onlyupdate,updateMany,delete, anddeleteMany. It treats a missingwhereand an emptyAND: []/OR: []as empty.noUnboundedFindManyallows afindManythat hastakeorlimit.safe()andrecommended()also allow it when there is awhere;strict()does not.maxLimitapplies tofindMany,paginate, andcursor.requireStableOrderByForCursoris set withrequirePrimaryKeyInOrderBy: false, so it never fires in any preset. SetrequirePrimaryKeyInOrderBy: trueto use it.requireRawTimeoutusesdefaultTimeoutMs: 5000andmaxTimeoutMs: 30000.noRawMutationallows statements that start withcreate extensionorrefresh materialized view.requireTenantContextusesallowSystem: true.noSensitiveSelectallows the select when the call passeswithSensitive: true.noHardDeleteOnSoftDeleteModelallows a hard delete when a reason is given.noQueryDeletedWithoutExplicitOptIninstrict()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.
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 everyextendsentry.
The merge is shallow here too. undefined, null, and false entries are skipped, so you can add a config conditionally:
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.
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:
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>;
};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:
onViolationreceives every violation.reporter.warnreceives warnings, unlesswarnOnViolation: false.reporter.errorreceives errors, and then the plugin throws, unlessthrowOnError: 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.
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
The full list of rules, their triggers, and options is in the rule reference.