Skip to content

basalt / tenancy/src / Tenancy

Class: Tenancy ​

Defined in: tenancy/src/index.ts:124

Constructors ​

Constructor ​

> new Tenancy(options): Tenancy

Defined in: tenancy/src/index.ts:138

new Tenancy({ source, resolvers, ... }) — see TenancyOptions.

Parameters ​

options ​

TenancyOptions

Returns ​

Tenancy

Constructor ​

> new Tenancy(source, resolvers, hooks?, onProvision?, provisionMode?, onDeprovision?, canonicalDomain?, validateTenantId?, resolution?): Tenancy

Defined in: tenancy/src/index.ts:143

The positional form, kept for compatibility. Prefer the options object: nine positional arguments are easy to misalign.

Parameters ​

source ​

TenantSource

resolvers ​

TenantResolver[]

hooks? ​

HookBus

onProvision? ​

(tenant) => void | Promise<void>

provisionMode? ​

"inline" | "deferred"

onDeprovision? ​

(tenant) => void | Promise<void>

canonicalDomain? ​

(tenant) => string | undefined

validateTenantId? ​

(id) => boolean

resolution? ​
onConflict? ​

ResolverConflictPolicy

Returns ​

Tenancy

Methods ​

create() ​

> create(tenant): Promise<Tenant>

Defined in: tenancy/src/index.ts:231

Registers a tenant and brings its storage into existence.

Use this rather than source.create() directly. The source only persists the record; a tenant whose row exists but whose schema does not is immediately routable by subdomainResolver/domainResolver and fails on its very first request with a raw database error. Going through here runs onProvision before anyone can reach it.

onProvision runs INSIDE the new tenant's context, like onMigrate and onSeed, so ctx().tenant and any tenant-scoped client resolve correctly. Entering the context opens no connection by itself, so provisioning work on an admin connection (CREATE SCHEMA) is still fine.

If provisioning throws, the error propagates and tenancy:created does not fire — but the tenant record has already been written, because the source persisted it first. That half-state is not rolled back: deleting is not something every TenantSource can do, and a failed delete on top of a failed provision loses the evidence. Provisioning is expected to be idempotent so that a retry finishes the job — and that retry is provision(id), not a second create().

An existing id is refused with TenantAlreadyExistsError (409), whatever its status: nothing is written, no hook fires, onProvision does not run. Overwriting was never what a caller meant. The durable sources' save is an upsert that replaces the whole record, so a double-submitted signup used to erase the tenant's owner, reactivate a suspended account and re-run provisioning over live data. Use the source's save() for an intentional update.

Parameters ​

tenant ​

Tenant

Returns ​

Promise<Tenant>


current() ​

> current(): Tenant | undefined

Defined in: tenancy/src/index.ts:193

The tenant of the active context, if any.

Returns ​

Tenant | undefined


destroy() ​

> destroy(id, options?): Promise<void>

Defined in: tenancy/src/index.ts:335

Removes a tenant: its storage first, then its record.

The order is the whole design. Three steps, and each one is where it is because the alternative loses something:

  1. Mark deleting. The resolver stops serving the tenant before anything is torn down. Dropping a schema out from under live requests produces errors nobody can interpret, from a tenant that looked healthy a second earlier.
  2. Run onDeprovision inside the tenant's context, like onProvision, so a tenant-scoped client resolves to the storage being removed rather than to whatever the caller happened to be in.
  3. Delete the record last. The record is the only thing naming that storage. Delete it first and a failed teardown leaves a schema nobody can find, which is exactly the state a half-finished signup used to leave behind — the reason this method exists.

If step 2 throws, the record survives, marked deleting, and the error propagates: the evidence is kept and a retry can finish the job. force removes the record anyway — for when the storage is already gone by other means. It is a deliberate way to orphan storage, so it is never the default.

Parameters ​

id ​

string

options? ​
force? ​

boolean

Returns ​

Promise<void>


find() ​

> find(id): Promise<Tenant | null>

Defined in: tenancy/src/index.ts:197

Parameters ​

id ​

string

Returns ​

Promise<Tenant | null>


forEach() ​

> forEach(fn, options?): Promise<void>

Defined in: tenancy/src/index.ts:482

Runs fn once per tenant, with bounded concurrency — bulk maintenance.

Parameters ​

fn ​

(tenant) => void | Promise<void>

options? ​
concurrency? ​

number

Returns ​

Promise<void>


provision() ​

> provision(tenantOrId): Promise<Tenant>

Defined in: tenancy/src/index.ts:291

Runs onProvision for a tenant and flips its status to ready — or failed, then rethrows.

Public because background provisioning happens in ANOTHER PROCESS. A job handler cannot receive a closure from the process that created the tenant, so the worker re-enters here with the id and the app's own onProvision:

ts
defineJob({
  name: 'tenant.provision',
  handle: ({ id }) => ctx().container.get(TENANCY).provision(id),
})

Idempotent by requirement, not by construction: onProvision may run again after a failure, so write it with CREATE SCHEMA IF NOT EXISTS and migrate deploy.

Parameters ​

tenantOrId ​

string | Tenant

Returns ​

Promise<Tenant>


resolve() ​

> resolve(request): Promise<Tenant | null>

Defined in: tenancy/src/index.ts:412

Identifies the request's tenant.

Authoritative resolvers first (subdomain, domain, route — anything the platform controls), in list order; the first ref that loads a tenant wins. If any of them named a tenant and none loaded, the answer is no tenant: the request does not fall through to a client-controlled resolver, so nosuch.app.com + x-tenant-id: globex is not globex. Only when no authoritative resolver named anything are the fallbacks (header, unmarked custom resolvers) tried, in list order.

A ref whose id fails the tenant-id grammar, or whose domain is not a hostname, counts as not found — it never reaches the source.

With onConflict: 'error' every resolver runs and two refs that load DIFFERENT tenants throw TenantResolutionConflictError (400).

Parameters ​

request ​

ResolutionRequest

Returns ​

Promise<Tenant | null>


run() ​

> run<T>(tenantOrId, fn): Promise<T>

Defined in: tenancy/src/index.ts:468

Runs fn inside the tenant's context (preserving the surrounding context) and emits 'tenancy:switched'.

The id — given, or on the tenant object — must pass the tenant-id grammar (InvalidTenantIdError, 400): it becomes a namespace segment in every tenant-scoped package, so a hand-built { id: '../x' } must never become the context tenant.

Type Parameters ​

T ​

T

Parameters ​

tenantOrId ​

string | Tenant

fn ​

() => T | Promise<T>

Returns ​

Promise<T>

Released under the MIT License.