Multi-tenancy & request context
Thread tenant and request metadata through hooks, plugins, and transactions.
better-drizzle does not impose a multitenancy model. For isolation, the tenant plugin scopes every query to a tenant. This page covers the other half: threading tenant and request metadata through hooks, plugins, and transactions for logging and tracing.
Attach metadata with meta
Every operation accepts a meta object. Use it to carry correlation and tenant context:
await client.users.findMany({
where: { active: true },
meta: {
requestId: 'req_123',
tenantId: 'tenant_42',
userId: 'admin_7',
},
});Read it in hooks:
const client = better(db, {
hooks: {
beforeQuery(ctx) {
console.log(ctx.meta?.tenantId, ctx.meta?.requestId);
},
},
});Scope default metadata with $withContext(...)
Passing meta on every call works, but gets repetitive when the same context applies to many operations. $withContext clones the client with default metadata that is automatically merged into every subsequent operation:
const scoped = client.$withContext({
requestId: 'req_123',
tenantId: 'tenant_42',
});
// All operations on `scoped` carry the metadata automatically
await scoped.users.findMany({
where: { active: true },
});Per-call meta still wins on key conflicts:
await scoped.users.create({
data: { email: 'alice@example.com', name: 'Alice' },
meta: {
requestId: 'req_override',
userId: 'admin_7',
},
});The returned client is a clone - the original client is unaffected. Nested $withContext calls stack, and the scoped metadata is inherited into transactions.
Transaction context
Transactions accept a context object, readable by hooks and plugins as transactionContext:
await client.transaction(
async (tx) => tx.users.findMany(),
{ context: { requestId: 'req_123', tenantId: 'tenant_42' } },
);Enforcing tenant isolation
meta and $withContext are for observation: they never filter rows. To enforce that every query is scoped to a tenant, use the tenant plugin:
import { tenant } from 'better-drizzle/tenant';
const client = better(db, {
plugins: [tenant({ columns: 'organizationId', global: ['plans'] })],
});
// per request
const scoped = client.$tenant({ organizationId: currentTenant });
await scoped.projects.findMany({ include: { tasks: true } });The plugin filters through core model scopes, so the tenant predicate reaches relation loads, relation filters, count, relation sorts, writes, upserts, and the lookups behind connect, disconnect, and set. It stamps tenant columns on create, fails closed without a tenant, denies raw SQL on scoped clients by default, and can set PostgreSQL row-level security settings.
Combine both: $tenant() scopes the data, and $withContext() (or meta) carries the request ids your hooks log.
const scoped = client
.$tenant({ organizationId: currentTenant })
.$withContext({ requestId, userId });Root-only transforms are not isolation
A plugin transform that adds
{ tenantId } to operation.where only changes the root query. Relation
loads, relation filters, count, relation writes, and raw SQL run without
it, and a call without the tenant runs unfiltered. Use the tenant plugin, or
declare a model scope in your
own plugin, when tenants must never see each other's data.
Where this fits
- tenant-aware audit logging
- request tracing with correlation IDs
- tenant isolation with the tenant plugin