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,
tenantIdis stamped fromctx.request.subject.tenantIdwhen 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.