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.
  5. Write scopingupdateMany / deleteMany and the fluent update(t).where(...) / delete(t).where(...) builders get the same predicate AND-merged onto their WHERE, 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() 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).

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 tenantId is stamped from the subject, but one that sets it is not silently substituted — a legitimate cross-tenant write exists. assertOwnTenant is the early, typed check for a handler that means to USE a claimed input.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: TenantMismatch on the mutation descriptor so the rpc layer surfaces the rejection typed. It checks a claimed tenantId — 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/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