Global Filters

A global filter is a WHERE predicate Turbine merges into every query on a table, reads, mutations, and the relation subqueries that target it, so you never repeat it at the call site. Two patterns motivate it:

  • Soft delete, hide rows where deletedAt is set, everywhere, without threading where: { deletedAt: null } through every query.
  • Multi-tenancy, scope every query to the current tenant, evaluated per request.

Global filters are configured once on the client and are AND-merged into the compiled WHERE. Values are always parameterized.

Configuration#

Pass globalFilters to the client, keyed by table accessor (db.<table>). Each value is a WhereClause, or a function that returns one:

import { turbine } from './generated/turbine';
 
const db = turbine({
  connectionString: process.env.DATABASE_URL,
  globalFilters: {
    // Soft delete, static filter
    posts: { deletedAt: null },
    users: { deletedAt: null },
 
    // Multi-tenancy, evaluated per query build
    orders: () => ({ tenantId: currentTenant() }),
  },
});

With this in place, db.posts.findMany() compiles to ... WHERE "deleted_at" IS NULL, no where needed.

const posts = await db.posts.findMany();
// SELECT ... FROM "posts" WHERE "deleted_at" IS NULL
 
const active = await db.users.findMany({ where: { role: 'admin' } });
// SELECT ... FROM "users" WHERE "role" = $1 AND "deleted_at" IS NULL

Function filters, per-request tenancy#

A function filter is evaluated every time a query is built, so a closure over per-request state produces a request-scoped filter. This is the clean way to enforce tenant isolation:

const db = turbine({
  connectionString: process.env.DATABASE_URL,
  globalFilters: {
    orders: () => ({ tenantId: getCurrentTenantId() }),
  },
});
 
// Later, inside a request handler where getCurrentTenantId() returns 't-42':
const orders = await db.orders.findMany();
// SELECT ... FROM "orders" WHERE "tenant_id" = $1   -- params: ['t-42']

The filter's shape participates in the SQL cache, and its values are re-collected on every build, so two requests with different tenant ids reuse the same cached SQL but bind their own parameters. A filter that changes shape (say, { deletedAt: null } one call and { tenantId: 't' } the next) never collides on a single cache entry.

Filters flow into relations#

The reason global filters are more than a call-site helper: they apply to relation subqueries too. A filter on posts restricts posts wherever it appears, including when loaded through a parent's with clause.

// globalFilters: { posts: { deletedAt: null } }
const users = await db.users.findMany({
  with: { posts: true },
});
// The correlated posts subquery ANDs "deleted_at" IS NULL -
// soft-deleted posts never appear under any user.

This coverage is complete. A target-table filter is applied to:

  • The with relation subquery (both the default join strategy and the batched strategy)
  • Relation filters, some, every, none, and belongsTo is
  • Relation _count and relation orderBy: { posts: { _count: 'desc' } }

So a soft-delete filter on posts means a user's post _count counts only live posts, and where: { posts: { some: {...} } } only matches live posts, consistently, with no extra code.

Mutations and the empty-where guard#

Global filters apply to update, updateMany, delete, deleteMany, and the conflict-UPDATE of upsert. create and createMany are never filtered. You're inserting a new row, there's nothing to scope.

One deliberate interaction: a global filter does not satisfy the empty-where guard. The guard checks the user-supplied where, so an unguarded mass mutation is still refused even when a filter is configured:

// globalFilters: { users: { deletedAt: null } }
await db.users.deleteMany({ where: {} });
// ❌ ValidationError (TURBINE_E003), the filter does NOT make this safe.
 
await db.users.deleteMany({ where: { orgId: 1 } });
// ✅ Deletes org-1 users, AND-merged with "deleted_at" IS NULL.

The filter still compiles into the WHERE, it just can't turn {} into an allowed statement.

Opting out, skipGlobalFilters#

Any read or mutation accepts skipGlobalFilters to bypass filters for that call. It is a privilege option: it is unlocked by the UNSAFE symbol and by nothing else.

import { UNSAFE } from 'turbine-orm';
 
// See a soft-deleted row (admin tooling): skip every filter on this call
const all = await db.posts.findMany({ skipGlobalFilters: UNSAFE });
 
// Skip only the `posts` filter, keep others. The array LEADS with the sentinel.
const some = await db.users.findMany({
  with: { posts: true },
  skipGlobalFilters: [UNSAFE, 'posts'],
});

skipGlobalFilters: true and skipGlobalFilters: ['posts'] throw a ValidationError (TURBINE_E003). See Privilege options below for why, and for the upgrade path if you are passing true today.

Privilege options and the UNSAFE symbol#

Three query options remove a safety boundary rather than change a result: skipGlobalFilters (drops the tenant or soft-delete predicate), includePii (drops the PII projection), and allowFullTableScan (drops the empty-where guard on a mutation). Each of them is enabled only by a symbol exported from the package:

import { UNSAFE } from 'turbine-orm';

Why a symbol and not true#

Because all three are ordinary siblings of where on the same options object, and the idiomatic handler spreads a request body:

// The shape this closes. Do not ship this either way, but it used to be a breach.
app.get('/users', (req, res) => db.users.findMany({ ...req.body }));

A client posting {"where":{"name":"x"},"skipGlobalFilters":true} got the same statement minus the tenant predicate: the documented multi-tenancy mechanism, removed over the wire by the person it was there to contain. includePii: true unlocked the PII projection through the same hole, and allowFullTableScan: true disarmed the guard that stops an unqualified UPDATE or DELETE.

Typing the options boolean and writing "be careful" is not a fix. This is a mass-assignment shape, and mass assignment happens precisely because nobody enumerated the keys. JSON.parse cannot produce a symbol, and neither can a query string, a form body, or a structuredClone of parsed input. There is no untrusted-data path that puts UNSAFE on an args object at all, so the escalation stops being discouraged and becomes structurally impossible, which is the only version of the property that survives the next refactor.

The array form is policed exactly as hard as the bare one, because {"skipGlobalFilters":["users"]} is the same breach with one extra step. [UNSAFE, 'posts'] is accepted; ['posts'] is not.

Upgrading from true#

This is a breaking change on those three options, and it is deliberately loud rather than quiet.

BeforeAfter
skipGlobalFilters: trueskipGlobalFilters: UNSAFE
skipGlobalFilters: ['posts']skipGlobalFilters: [UNSAFE, 'posts']
includePii: trueincludePii: UNSAFE
allowFullTableScan: trueallowFullTableScan: UNSAFE

TypeScript finds every one of them for you. The option types are now Unsafe, so each row of that table is a compile error before it is a runtime error: type-check the project and the compiler hands you the complete list of call sites to change.

That includes the boolean shapes, which is the part worth reading twice. allowFullTableScan: false does not typecheck, and neither does allowFullTableScan: someFeatureFlag. A conditional call site is written by adding the key or not:

await db.sessions.deleteMany({
  where: {},
  ...(purgeEverything ? { allowFullTableScan: UNSAFE } : {}),
});

Two rules govern what happens at runtime, which is what an untyped call site hits (plain JavaScript, or args that arrive as any from a boundary):

  • false, null and undefined are accepted and mean "not enabled". They never throw, because none of them asks for the privilege. This is a courtesy to untyped callers, not a supported way to write the option in TypeScript, where the type already rules them out.
  • Everything else, true included, throws. Ignoring a stale true would trade an escalation bug for a silent-failure bug: your admin tool would quietly stop seeing soft-deleted rows, or quietly return objects with the PII columns missing, with no signal anywhere. The error names the option and the import, and ends with the line worth grepping for in an incident: "If you did not write this option, an untrusted object was spread into these query args."

If you use turbine-orm/prisma-compat, the same rule applies, because compat forwards these three options verbatim and core is the single judge.

See also#

  • API Reference, the where operators and the empty-where guard.
  • Read Replicas, filters apply identically to reads routed to a replica.
  • Transactions, RLS sessionContext for database-enforced multi-tenancy.