Multi-tenancy that refuses to leak
Fail-closed, TypeScript-first multi-tenancy for Node.js. No valid tenant context, no data - it throws instead of leaking.
// Tenant identity rides AsyncLocalStorage.
await manager.runWithTenant({ id: "acme" }, async () => {
// scoped to acme - automatically
const orders = await db.order.findMany();
});
// Outside a tenant scope? It fails closed.
await db.order.findMany();
// ✗ throws TenantContextError - never an unscoped read
Works with the stack you already use
The failure mode
A forgotten WHERE should be a crash, not a data breach
The dangerous bug in multi-tenancy is the one that doesn’t throw. A missing tenant filter returns data - silently - and you find out from a support ticket. TenancyJS makes the safe path the default: no valid context, no data.
How tenant context works →// Tenant identity rides AsyncLocalStorage.
await manager.runWithTenant({ id: "acme" }, async () => {
// scoped to acme - automatically
const orders = await db.order.findMany();
});
// Outside a tenant scope? It fails closed.
await db.order.findMany();
// ✗ throws TenantContextError - never an unscoped read
How you use it
Three moving parts, one scoped client
Pick a strategy, plug in your ORM adapter and framework integration - they compose, and none of them need to know about each other.
Set the tenant per request
Your framework integration (Express, Next.js, NestJS, AdonisJS) resolves who the request belongs to and opens a tenant scope.
Query through the scoped client
Inside the scope, your ORM adapter hands you a client that's already filtered to the tenant. No tenantId threading, no manual WHERE.
Outside a scope, it fails closed
No valid tenant context? Tenant-aware access throws at the boundary instead of returning another tenant's rows. The safe path is the default.
Three strategies, one contract
Pick your isolation. Keep your code.
The same tenant contract drives all three. Every supported cell is backed by a two-tenant adversarial test on a real database.
Single database
Shared tables keyed by tenant_id, enforced by forced Postgres RLS or query-scoping. The lightest footprint.
Schema per tenant
One Postgres schema per tenant via transaction-local search_path, with an optional per-tenant role for database-enforced isolation.
Database per tenant
A separate database per tenant, routed through a bounded, single-flight connection cache. Hard isolation, no noisy neighbours.
Honesty over faith
Guarantees, not vibes
Fail-closed, always
No valid tenant context means an error at the boundary - never a silent read of unscoped data.
Proven, not claimed
A capability is marked "supported" only after a real two-tenant adversarial test on a real database.
Bring your own store
Your registry, hardened at the boundary - a buggy store can't hand back the wrong tenant.
Cleanup always runs
Transaction-scoped context is torn down on every path, including errors. No leaked scope between requests.
Own one thing, completely.
Tenant identity is not authorization - your app still owns auth. TenancyJS makes sure one tenant’s data never becomes another’s.