0.1.0-beta is out - read the docs

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.

$npm install tenancyjs-core@beta
orders.ts
// 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

ExpressNext.jsNestJSAdonisJSPrismaKnexLucidTypeORMSequelizeMongoose

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 →
orders.ts
// 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.

01

Set the tenant per request

Your framework integration (Express, Next.js, NestJS, AdonisJS) resolves who the request belongs to and opens a tenant scope.

app.use(tenancyMiddleware({ resolver }));
02

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.

const orders = await db.order.findMany();
03

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.

await db.order.findMany(); // ✗ throws

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.

01 · row-level

Single database

Shared tables keyed by tenant_id, enforced by forced Postgres RLS or query-scoping. The lightest footprint.

Knex · Lucid · Prisma · TypeORM · Sequelize · Mongoose
02 · search_path

Schema per tenant

One Postgres schema per tenant via transaction-local search_path, with an optional per-tenant role for database-enforced isolation.

Knex · Lucid · Prisma · TypeORM · Sequelize
03 · cache-routed

Database per tenant

A separate database per tenant, routed through a bounded, single-flight connection cache. Hard isolation, no noisy neighbours.

Knex · Lucid · Prisma · TypeORM · Sequelize · Mongoose

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.