Every log may carry tenantId. Routing, integrity tips, RAM queues, and (in database_per_tenant) destination connections key off that field.
isTenantIdPresent treats empty and whitespace-only strings as missing.
requireTenantId
Default false (single-tenant / back-compat). When false and not database_per_tenant, construction emits limit / unsafe_default_require_tenant.
When true, fire / fireAsync / query require a present tenantId. fire emits drop / require_tenant_id; fireAsync and query throw:
tenantId is required when requireTenantId is truetenancy.mode === 'database_per_tenant' forces this on.
tenantId is never truncated. Oversize ids (maxTenantIdBytes, default 256 UTF-8 bytes) are dropped (fire) or rejected (fireAsync) — truncation would merge tenants.
ENTERPRISE_DEFAULTS sets { mode: 'durable', requireTenantId: true }.
Plugins may fill tenantId via getTenantId only when the caller omitted it. Resolve from an authenticated session or verified JWT. Never use a raw x-tenant-id header unless the gateway overwrites that header.
single_database
Default when tenancy is omitted. One destination adapter stores every tenant (typically a tenant_id column). query({ tenantId }) filters that adapter. Omitting tenantId (and not requiring it) queries unscoped (tenantId === null).
database_per_tenant
Each tenant gets its own adapter, created by adapterFactory from resolveConnection config. adapterFactory is required — the pool never does new adapter.constructor(...). Missing factory throws in the constructor:
database_per_tenant mode requires LogbunConfig.adapterFactoryconst audit = new AuditLogger({
namespace: 'api',
mode: 'durable',
reliability,
adapter: new TursoAdapter({
url: process.env.TURSO_URL!,
authToken: process.env.TURSO_TOKEN!,
}),
tenancy: {
mode: 'database_per_tenant',
resolveConnection: async (tenantId) => ({
url: `libsql://audit-${tenantId}.turso.io`,
authToken: process.env.TURSO_TOKEN!,
}),
knownTenantIds: () => listTenantIdsFromControlPlane(),
},
adapterFactory: (config) =>
new TursoAdapter({
url: String(config.url),
authToken: String(config.authToken),
}),
pool: { maxActiveConnections: 50 },
requireTenantId: true,
});pool.maxActiveConnections defaults to 50. Exhaustion throws pool_exhausted when every entry is pinned.
adapter.init() must be idempotent: the pool always calls it after adapterFactory.
Retention prune in this mode visits pool.listActiveTenantIds() plus tenancy.knownTenantIds() only. It does not prune the constructor adapter (the base adapter is not a tenant database). Provide knownTenantIds so cold tenants still get pruned.
ClickHouse is designed as a shared warehouse (PARTITION BY month, tenant_id column), not one database per tenant.
Query tenant semantics
On IAdapter.query:
tenantId |
Meaning |
|---|---|
null |
Unscoped (no tenant filter) |
'' |
Tenant-less / __global__ chain only (tenant_id IS NULL OR tenant_id = '') |
| any other string | That tenant |
Integrity restore uses '' for logs without a tenant. AuditLogger.query with omitted/undefined tenantId passes null unless requireTenantId is on. In database_per_tenant, a missing tenant throws; a present tenant pins the pooled adapter. Adapter query failures after a successful pin are not wrapped. Pool resolve / pin failures throw Failed to resolve tenant adapter for tenantId "…".