Tenants live in the schema.
Not in your code.

Multi-tenant SaaS is the default mode for almost every B2B product, and it's the place teams introduce their most expensive bugs: the leaked row, the cross-tenant write, the subscription that pushed customer A's data to customer B's browser. Voltro makes tenancy a schema-level invariant — one mixin, runtime-enforced everywhere reads and writes go.

projects.entity.ts
TypeScript
// database/projects.entity.ts
import { id, table, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'

export const projects = table('projects', {
  id:   id(),
  name: text(),
})
  .with(tenant())   // ← that's it
  .reactive()

// Now EVERY read against this table is auto-filtered by
// ctx.request.subject.tenantId. EVERY insert auto-stamps it.
// EVERY subscription is auto-scoped. Cross-tenant writes throw
// TenantScopeViolation before they hit the DB.

Tenancy enforcement, six places at once.

Adding .with(tenant()) to a table descriptor turns on all of these in lock-step. There's no path where one fires and the other doesn't — they're the same runtime invariant.

tenant() schema mixin

One line on the table descriptor. Adds tenantId column → tenants reference, runtime-scopes reads, auto-stamps writes, blocks cross-tenant access at the SQL layer.

Tenant-scoped subscriptions

The reactive matcher AND-merges the tenant predicate into every subscription. Writes from tenant A literally cannot wake up subscribers in tenant B — not because of polling, because of the matching algorithm.

assertOwnTenant() write guard

For mutations that take a tenantId in the input, a one-line guard pattern-matches it against the resolved Subject. Cross-tenant impersonation surfaces as a typed TenantMismatch error.

Per-tenant rate limits

rateLimitPlugin scopes buckets by subject OR by tenant OR by API key. A single noisy customer can't starve everyone else; a malicious tenant can't exhaust your global rate limit.

Per-tenant billing meters

The @voltro/plugin-billing entitlement system meters quota per tenant, not per subject. One Stripe account, one quota plan, many users — all enforced atomically at handler entry.

Per-tenant observability

OTel spans tag every operation with tenant.id. Traces are per-tenant. Logs are per-tenant. The dashboard surfaces per-tenant breakdowns of latency, throughput, and cost.

The runtime does the AND-merge for you.

projects.list.query.server.ts
TypeScript
// queries/projects.list.query.server.ts
const route = (_, ctx) =>
  database.projects.orderBy('createdAt', 'desc')
//                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// The runtime AND-merges eq('tenantId', subject.tenantId)
// into this query at execution time. You wrote 0 lines of
// tenant code. The wire payload to the client is filtered
// before it leaves the server.

Cross-tenant bugs are uniquely terrible.

A bug in the cart total is embarrassing. A bug that shows tenant A's data to tenant B is a compliance incident, a board email, and a customer in front of your VP of customer success. Voltro takes the "hand-rolled per project" pattern off the table — there's nothing to forget.

Two failure modes Voltro removes:

  • Missing WHERE clause. A custom query in some forgotten handler doesn't filter by tenant. Voltro: the runtime AND-merges the predicate at execution time. No way to forget.
  • Trusted input. A handler passes the client-supplied tenantId straight to the DB. Voltro: assertOwnTenant rejects mismatches with a typed error before the write lands.

Tenancy weaves through every other primitive.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.