Data residency
Pin each tenant's data to a HOME region and fail closed everywhere else — a deployment never serves or provisions a tenant homed in a region it doesn't hold, so a US deployment can never touch an EU-homed tenant's rows.
Data residency pins each tenant's data to a home region and makes every other deployment fail closed. A deployment declares which regions it can serve (it holds their stores); a request for a tenant homed elsewhere is refused, not served from a fallback — the gateway is expected to route it to the home region's deployment. So a US deployment can never read, bind, or provision an EU-homed tenant's data.
Residency never falls back to a default store. No home mapped → typed
TenantResidencyUnresolved. Home region not served here → typedTenantRegionUnavailable. Both fail closed; that's the whole point.
Declare it
Residency is declared in app.config.ts and wired by both boot paths — voltro dev and voltro serve open one store per servable region and route every
request through it. There is nothing to call from your own code.
export default defineApiApp({
tenancy: {
isolation: 'namespace',
residency: {
// The regions THIS deployment holds stores for.
servableRegions: ['eu-west'],
// region → the NAME of the env var holding that region's database URL.
regionUrlEnv: { 'eu-west': 'DB_URL_EU', 'us-east': 'DB_URL_US' },
// tenant → home region. An array, or a function resolving one at boot.
homes: [
{ tenantId: 'acme', region: 'eu-west' },
{ tenantId: 'globex', region: 'us-east' },
],
},
},
})regionUrlEnv takes a variable NAME, not a URL: a connection string is a
secret and app.config.ts is committed. Everything else about a region's store
— pool bounds, TLS, search_path, statement timeouts — is inherited from the
primary connection, so a region cannot silently run with different limits than
the deployment it belongs to.
homes may be a function (() => Promise<TenantHome[]>) if the mapping lives
in your own table or control plane. It is resolved once, at boot: adding a
tenant home needs a restart. That is deliberate — the alternative is a cache
with a staleness window on a decision whose entire value is that it is never
wrong.
It requires namespace isolation, and refuses to boot without it
residency without isolation: 'namespace' is a boot refusal, not a
warning. The region keeps regions apart; the namespace keeps tenants apart
inside a region. With only the first, every tenant in a region would share one
set of tables — the region boundary held and the tenant boundary dropped, which
looks like residency and is not.
The other boot refusals, all for the same reason (a deployment that looks like it enforces residency and does not is worse than one that will not start):
- a servable region with no
regionUrlEnventry; - a servable region whose env var is unset;
- an empty
servableRegions; - a tenant mapped to two different regions.
What a request gets
Every request resolves its subject's tenant → home region → that region's store, and only then binds the tenant's namespace inside it. Residency picks which database; the namespace picks which tenant's tables in it.
Three refusals, none of which falls back:
| Situation | Result |
|---|---|
| Subject has no resolvable tenant | TenantResidencyUnresolved |
| Tenant has no home mapped | TenantResidencyUnresolved |
| Tenant homed in a region this deployment does not serve | TenantRegionUnavailable, naming the region so a gateway can route it |
ctx.storeForTenant(id) resolves residency for that tenant, not the
caller's — so a handler acting on another tenant either reaches that tenant's
region or is refused. It is the seam background work must use: a schedule or a
workflow runs with no request and, under the system subject, no tenant, so
ctx.store on those paths is the primary store. A job that touches one tenant's
rows has to say which tenant, and storeForTenant is how it says so.
A transaction is never re-routed. When a mutation hands its transaction-scoped store to a nested call, that store is used as given — it already went through residency to exist, and moving writes off the connection holding the lock would be a worse failure than the one residency prevents.
Driving it yourself
The resolvers are exported for a control plane that owns the mapping itself —
setResidencyConfig, residentPlacement, bindResidentStore. The declaration
above calls the first two for you; reach for them directly only if you are
building the region topology outside the framework.
import { bindResidentStore } from '@voltro/database'
// stores: ReadonlyMap<region, StoreHandle>
const { store, placement } = bindResidentStore(subject, config, stores)
// placement = { region, namespace, connectionKey? }Provision a new resident tenant
provisionResidentTenant runs an injected provisioner against the tenant's HOME
store + namespace, behind the SAME fail-closed guards as binding:
import { provisionResidentTenant } from '@voltro/database'
await provisionResidentTenant(subject, config, stores, async (store, placement) => {
// e.g. provisionTenantNamespace(tables, placement.namespace, sqlLayer, dialect)
})
// A US deployment provisioning an EU-homed tenant → TenantRegionUnavailable.How it composes with the tenant() mixin
Residency is the physical placement (which region's store); the
tenant() mixin is the logical scope (the
WHERE tenantId = … filter within a store). They stack: residency routes the
request to the right region's store, then the mixin scopes the rows inside it.
Namespace isolation within a region uses the same resolveTenantNamespace the
mixin's physical-isolation mode uses.