The tenant() mixin
What tenant() adds — the tenantId reference, the auto-index, the read scoping — and how it composes with other mixins.
The tenant() mixin is the lever that turns a normal table into a tenant-scoped one. This page covers exactly what it does.
What it adds
import { table, id, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'
export const notes = table('notes', {
id: id(),
title: text(),
}).with(tenant())tenant() takes no arguments. It returns a MixinDefinition you apply with .with(...) — never spread it into the field object. The mixin contributes:
- A
tenantIdcolumn —reference(requireTenants()), an FK into your app'stenantstable (not a bare text column). - An auto-index on
tenantId(indexes: [{ fields: ['tenantId'] }]). The name is auto-generated as<tableName>_tenantId_idx. - Read scoping — the runtime AND-merges
WHERE tenantId = ctx.subject.tenantIdinto every subscription against this table. - Insert auto-fill — when an insert's row payload omits
tenantId, the runtime stamps it from the request subject.
The mixin's stable id is voltro/tenant. The execution lives in the runtime's wrapStoreWithMixinBehaviour (write side) and the CLI's applyTenantScope (read side) — both key off that id. The mixin source is voltro/packages/plugin-multitenancy/src/mixin.ts.
tenant() requires audit()
tenant() transitively requires audit() — every tenant-scoped row is also a who/when-stamped artefact in the audit trail. The dependency resolver dedupes if you apply both explicitly, so .with(tenant()) alone is enough.
Composing with other mixins
import { table, id, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'
import { audit } from '@voltro/plugin-audit'
import { softDelete } from '@voltro/plugin-soft-delete'
export const notes = table('notes', {
id: id(),
title: text(),
}).with(softDelete(), tenant(), audit()) // tenant() pulls in audit() anywayThe behaviors compose. A read against notes:
- Filters by
tenantId(fromtenant()) - ALSO filters out
deletedAt IS NOT NULL(fromsoftDelete()) - Returns the audit columns alongside
Order in the .with(...) chain doesn't matter for these — mixin read predicates are AND-merged.
The auto-fill behaviour
// Mutation:
ctx.store.insert('notes', { title: 'Hello' })If you DON'T pass tenantId, the runtime auto-fills it from ctx.subject.tenantId. The row is created in the caller's tenant. This is the safe default — it's hard to accidentally create a cross-tenant row.
When you DO pass an explicit tenantId (an admin writing into another tenant), the framework does NOT silently substitute the subject's value — silent substitution is a footgun. Guard the write with assertOwnTenant (see below); a genuine cross-tenant write runs as the system subject via runAsSystem (see Edge cases).
What it does NOT do
Auto-guard writes. Read scoping is automatic; mutations receive user-supplied input including
tenantId. A client authenticated as tenant A can posttenantId: 'B'and the row lands in B without a guard. CallassertOwnTenantat the top of the executor:import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy' export default async (input, ctx) => { assertOwnTenant(input.tenantId, ctx.request.subject) // throws TenantMismatch on spoof return ctx.store.insert('notes', input) }Declare
error: TenantMismatchon the mutation descriptor so the rpc layer surfaces the rejection typed.Apply to raw SQL. A hand-written
@effect/sqlquery bypasses the mixin. Write the filter yourself.
Performance considerations
The auto-injected tenantId = $1 filter is fast — the mixin's single-column index covers it. For high-cardinality tables (events, logs, audits), add a composite index with tenantId as the leftmost column on the actual hot query:
table('messages', {
id: id(),
channelId: text(),
body: text(),
createdAt: timestamp().default('now'),
})
.with(tenant())
.index('messages_tenant_channel_created',
['tenantId', 'channelId', { col: 'createdAt', order: 'desc' }])Now WHERE tenantId = $1 AND channelId = $2 ORDER BY createdAt DESC LIMIT 50 is served from the index. Indexes are declared at the table level — there is no column-level .index() modifier.
When NOT to use the mixin
- Truly global tables — feature flags, system config, audit retention policies. These don't belong to any single tenant. Leave them un-mixin'd.
- Cross-tenant aggregates — usage reports, cross-tenant leaderboards, admin dashboards. Run the read as the
systemsubject viarunAsSystem(see Edge cases); a system subject hastenantId: nullby construction and the AND-merge is skipped.
The mixin is opt-in per table. You declare it for the tables that should be scoped + leave the rest free.
See also
- Overview — the read/write asymmetry model
- Edge cases — cross-tenant reads, anonymous subjects, storage isolation