Multi-tenancy
The tenant() schema mixin (auto-scoped reads, auto-filled inserts, tenant-resolved keyed 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. - Inserts 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, and it refuses the insert outright when the subject has no tenant. - Set-based writes are auto-scoped.
updateMany/deleteManyand the fluentupdate(t).where(...)/delete(t).where(...)builders get the sameeq('tenantId', …)AND-merged onto theirWHERE, so a tenant-blind predicate is confined rather than executed as written. - Keyed-by-id writes are resolved inside the tenant.
update(t, id, patch),delete(t, id),hardDelete(t, id)andpatchJson(t, id, …)address a row by primary key, so the runtime resolves that key withinsubject.tenantIdfirst and fails withTenantRowNotFound(@voltro/runtime) when there is no such row there. The error is raised identically whether the row is missing or belongs to another tenant — reporting the two differently would let a caller probe for row ids in other tenants.
The write-guard — assertOwnTenant
Isolation itself is enforced by the runtime on every path above. What assertOwnTenant covers is the one question the framework deliberately does not answer for you: a mutation whose input carries an explicit tenantId it intends to USE. Without a check, a client authenticated as tenant A could submit tenantId: 'B' and your handler would happily read that claim. Guard such a 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.
It checks a claimed input.tenantId, so a mutation whose input carries none never reaches it. That is why it is an early, typed convenience and not the boundary — the boundary is the store enforcement listed above.
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',
guards: [{ scope: 'projects:write' }], // WHO may call; TenantMismatch bounds WHICH tenant
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.