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. - Write scoping —
updateMany/deleteManyand the fluentupdate(t).where(...)/delete(t).where(...)builders get the same predicate AND-merged onto theirWHERE, and a keyed-by-id write (update(t, id, patch),delete(t, id),hardDelete,patchJson) resolves its target row inside the caller's tenant before writing — see What it does NOT do.
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).
Keyed writes resolve inside your tenant
ctx.store.update(table, id, patch), delete(table, id), hardDelete(table, id) and patchJson(table, id, ...) address a row by primary key. On a tenant() table the runtime resolves that key inside subject.tenantId before writing, so an id that came straight from request input cannot reach another tenant's row:
export default async (input, ctx) => {
// input.id is client-supplied. Another tenant's id → TenantRowNotFound.
return ctx.store.update('notes', input.id, { title: input.title })
}When the row is not in your tenant the call fails with TenantRowNotFound (@voltro/runtime) rather than returning null / false. Declare it in the descriptor's error: to surface it typed at the client.
The error is raised identically whether the row is missing or belongs to another tenant, and carries nothing that separates them — reporting the two differently would let a caller probe for row ids in other tenants. Do not try to recover the distinction; there is nothing on the wire to recover it from, on purpose.
Not affected: subjects with no tenant at all — a schedule firing, a resumed workflow, a *.subscribe.ts handler — still span tenants by design, and so does a write through the raw store.
What it does NOT do
Decide which tenant an insert claims. An insert that omits
tenantIdis stamped from the subject, but one that sets it is not silently substituted — a legitimate cross-tenant write exists.assertOwnTenantis the early, typed check for a handler that means to USE a claimedinput.tenantId: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. It checks a claimedtenantId— a mutation whose input carries none never reaches it, which is why it is no longer what stands between you and a cross-tenant write.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