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:

  1. A tenantId columnreference(requireTenants()), an FK into your app's tenants table (not a bare text column).
  2. An auto-index on tenantId (indexes: [{ fields: ['tenantId'] }]). The name is auto-generated as <tableName>_tenantId_idx.
  3. Read scoping — the runtime AND-merges WHERE tenantId = ctx.subject.tenantId into every subscription against this table.
  4. 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() anyway

The behaviors compose. A read against notes:

  • Filters by tenantId (from tenant())
  • ALSO filters out deletedAt IS NOT NULL (from softDelete())
  • 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 post tenantId: 'B' and the row lands in B without a guard. Call assertOwnTenant at 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: TenantMismatch on the mutation descriptor so the rpc layer surfaces the rejection typed.

  • Apply to raw SQL. A hand-written @effect/sql query 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 system subject via runAsSystem (see Edge cases); a system subject has tenantId: null by 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