Tenant context
How tenant identity flows through your app on AsyncLocalStorage - and why it fails closed.
Tenant context is the heart of TenancyJS. It answers one question at every layer of your app: which
tenant is this code running for? - without you threading a tenantId through every function.
AsyncLocalStorage
The TenancyManager stores the active tenant in Node's AsyncLocalStorage. When you open a scope with
runWithTenant, every async operation inside it - awaits, callbacks, ORM queries - sees the same
tenant, with no manual plumbing.
await manager.runWithTenant({ id: "acme" }, async () => {
manager.getTenant(); // { id: "acme" }
await doWork(); // still "acme", however deep the call stack
});You rarely call runWithTenant yourself in app code - your integration opens the
scope per request. You reach for it directly in scripts, jobs, and tests.
Fail-closed
This is the core guarantee. A tenant-aware adapter used without a valid context does not return unscoped data - it throws.
// No active scope:
await db.order.findMany();
// ✗ TenantContextError - refuses to run unscopedThe safe default is "refuse," not "return everything." A missing context surfaces as a loud error at the boundary, not a silent leak three hops later.
Central context
Some work is legitimately cross-tenant - a superadmin dashboard, a billing rollup, a nightly job across all tenants. For that, open an explicit central scope. It's deliberate and visible in the code, never an accidental fallback.
await manager.runInCentralContext(async () => {
// operates across tenants - by explicit choice
});An unknown, suspended, or ambiguous tenant is never silently promoted to central context. If you want cross-tenant behaviour you have to ask for it by name.
Reading the current tenant
manager.getTenant(); // the tenant, or null in central scope
manager.getContext(); // { mode: "tenant", tenant } | { mode: "central" } | undefinedgetContext() returns undefined when there's no active scope at all - useful for guarding code that
may run both inside and outside a request.
Lifecycle & cleanup
Adapters run inside a transaction scoped to the tenant, and that scope is torn down on every path - success or error. There's no context left dangling between requests.
For setup and teardown around each tenant scope - priming a cache, opening a per-tenant resource - the
manager supports bootstrappers. A bootstrapper is an object with a unique id, a bootstrap, and a
revert; you register them on the manager. bootstrap runs on scope entry (in order), revert on
teardown (reverse order) - and revert always runs if its bootstrap ran, even when the scope fails, so
you never leak a half-open resource.
import { TenancyManager, type TenancyBootstrapper } from "tenancyjs-core";
const cache: TenancyBootstrapper<Tenant> = {
id: "cache",
bootstrap: (ctx) => openCacheFor(ctx.tenant.id), // prime the per-tenant resource
revert: (ctx) => closeCacheFor(ctx.tenant.id), // always runs on teardown
};
const manager = new TenancyManager<Tenant>({ bootstrappers: [cache] });You can also subscribe to the scope lifecycle with manager.on(event, listener) - it returns an
unsubscribe function. The events fire in order: tenancy.initializing → (bootstrappers) →
tenancy.initialized → your work → tenancy.ending → (reverts) → tenancy.ended. The ending and
ended listeners run even when the scope failed.
const off = manager.on("tenancy.initialized", (ctx) => {
metrics.increment("tenant.enter", { tenant: ctx.tenant.id });
});
// later: off();Nesting a scope for a different tenant inside an active tenant scope is rejected - it's almost always a bug, and allowing it would be a subtle way to cross tenants. Open central scope explicitly if you need to step outside the current tenant.