Multi-tenancy

The tenant() schema mixin (auto-scope reads + auto-fill writes), the assertOwnTenant write-guard, and the typed TenantMismatch error.

@voltro/plugin-multitenancy is the first-party multi-tenancy primitive. It has two surfaces: a schema mixin (tenant()) and a write-time guard (assertOwnTenant + the typed TenantMismatch error).

Status: ✓ shipped.

The tenant() mixin

Add it to any table whose rows belong to a tenant. It contributes a tenantId reference (to the app's tenants table) and tells the runtime to auto-scope reads + auto-fill writes:

import { table, id, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'

export const projects = table('projects', {
  id:   id(),
  name: text(),
}).with(tenant())

tenant() transitively requires audit() — composing it pulls in the createdAt / updatedAt / createdBy / updatedBy columns too. The mixin is also available on the ./mixin subpath (@voltro/plugin-multitenancy/mixin) for projects that don't want the full barrel.

What the runtime does for a tenant()-marked table:

  • Reads are auto-scoped. The runtime AND-merges eq('tenantId', subject.tenantId) into every subscription predicate against the table — tenant A never sees tenant B's rows, and a write in tenant A never wakes a subscription in tenant B.
  • Writes are auto-filled. On insert, tenantId is stamped from ctx.request.subject.tenantId when the caller didn't pass it explicitly. The wrapper never overrides a value the caller did pass.

The write-guard — assertOwnTenant

Read isolation is automatic. Writes are not — a mutation receives raw, user-supplied input including tenantId, so without an explicit check a client authenticated as tenant A could submit tenantId: 'B' and land the row in B's data. Guard every tenant-scoped mutation:

import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'

const execute = async (input: { tenantId: string }, ctx) => {
  assertOwnTenant(input.tenantId, ctx.request.subject)
  // safe to use input.tenantId for the write
}

assertOwnTenant(inputTenantId, subject) throws TenantMismatch when inputTenantId doesn't equal the subject's tenantId. Anonymous subjects have no tenant scope at all, so the guard always throws for them — anonymous + tenant-scoped writes need an apiKey / serviceAccount subject instead.

The typed error — TenantMismatch

TenantMismatch is a Schema.TaggedError carrying inputTenantId + subjectTenantId (empty string for anonymous subjects). Declare it on the mutation's error: schema so the rpc layer surfaces the rejection typed. Import it from the browser-safe @voltro/plugin-multitenancy/guard subpath in the descriptor (*.mutation.ts) — the package root also re-exports the schema mixin, which pulls @voltro/database into the client rpcGroup bundle (a browser-safety violation):

import { defineMutation } from '@voltro/protocol'
import { TenantMismatch } from '@voltro/plugin-multitenancy/guard'
import { Schema } from 'effect'

export const createProject = defineMutation({
  name:   'projects.create',
  input:  Schema.Struct({ tenantId: Schema.String, name: Schema.String }),
  output: Schema.Struct({ id: Schema.String }),
  error:  TenantMismatch,
  target: { table: 'projects', op: 'insert' },
})

The client then pattern-matches on error._tag === 'TenantMismatch'.

Why it's a separate package

Multi-tenancy is a product decision, not a transport-protocol primitive — keeping it out of @voltro/protocol lets apps that don't need tenancy skip the dependency entirely, while @voltro/protocol stays focused on the wire (Subject, AuthMiddleware).

Data residency — pin a tenant to a region

For regulated / EU buyers ("EU data stays in the EU"), pin each tenant to a home region and route its request-scoped store there. This composes namespace isolation (a tenant's rows live in their own namespace) with region routing — declare the tenant→home mapping plus which regions this deployment serves:

import { setResidencyConfig } from '@voltro/database'

setResidencyConfig({
  homes: [
    { tenantId: 'acme_eu',   region: 'eu-west-1' },
    { tenantId: 'globex_us', region: 'us-east-1', connectionKey: 'us_primary' },
  ],
  servableRegions: ['eu-west-1'],   // this deployment serves only EU
})

The serve pipeline binds the store per request via bindResidentStore(subject, config, stores), where stores is the per-region DataStore handles this deployment holds. It returns { store, placement }placement being { region, namespace, connectionKey? }, the tenant's rows in its home region.

It fails closed at every fork — no mapped home (or an anonymous caller) throws TenantResidencyUnresolved; a tenant homed in a region this deployment doesn't serve throws TenantRegionUnavailable (the gateway routes that request to the home region's deployment instead). There is no default store: a US deployment can never return a store for an EU-homed tenant. Per-region infra (the actual stores + connection strings via the secrets backend) is yours to provision; v1 routes the primary store — cross-region analytics across tenants is a separate concern.