5.2 KiB
TenantProvider — abstract tenant-enumeration contract
(no submodules)
Purpose
TenantProvider is the abstract contract every concrete tenant source
implements. The API plugin host consumes it for per-tenant cron fan-out
(manifest perTenant: true jobs, design §5.4), admin tooling, per-tenant
cache warmers, and background reconciliation. Putting the abstraction
in its own package lets the host stay source-agnostic — the same plugin
binary runs against an in-memory list in tests, against the host
metadata DB in production, and against a directory service in
enterprise deployments.
It is intentionally distinct from TenantResolver (which extracts the
active tenant id from a single request). Resolvers answer "who is
making this call right now"; providers answer "what is the universe of
tenants this host knows about". Both contracts coexist in the
api-stack but live in separate packages so consumers can depend on the
narrower one they actually need.
Features
list(opts?)—AsyncIterable<TenantRecord>streamed one tenant at a time so large rosters don't force a full-set load.count()— active-only count, intentionally separate fromlistso sources can shortcut toCOUNT(*) WHERE status='active'when supported.lookup(id)— by-id retrieval that returnsnullfor unknown ids (never throws); the only way to reacharchivedtenants.dispose()— idempotent cleanup hook for DB connections, directory-service sockets, background pollers.- No concrete implementation in this package —
api-tenant-provider-memory(Stage 4) and DB-backed sources live in siblings.
Usage: Basic
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
async function fanOutCron(provider: TenantProvider, run: (id: TenantId) => Promise<void>) {
for await (const tenant of provider.list()) {
await run(tenant.id);
}
}
Usage: Advanced
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
import type { TenantRecord } from "@suaveplan/api-tenant-provider/tenant-record";
import {
TenantProviderError,
TENANT_PROVIDER_CODES,
} from "@suaveplan/api-tenant-provider/error-codes";
// Parallel cron fan-out with per-tenant timeout + structured error reporting.
async function fanOutWithTimeout(
provider: TenantProvider,
run: (record: TenantRecord) => Promise<void>,
options: { concurrency: number; perTenantTimeoutMs: number },
): Promise<{ ok: number; failed: { id: string; reason: string }[] }> {
const acc: TenantRecord[] = [];
for await (const t of provider.list({ includeSuspended: false })) acc.push(t);
const failed: { id: string; reason: string }[] = [];
let ok = 0;
const queue = [...acc];
const workers = Array.from({ length: options.concurrency }, async () => {
while (queue.length > 0) {
const t = queue.shift();
if (!t) return;
try {
await Promise.race([
run(t),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), options.perTenantTimeoutMs),
),
]);
ok += 1;
} catch (cause) {
if (
cause instanceof TenantProviderError &&
cause.code === TENANT_PROVIDER_CODES.SOURCE_UNAVAILABLE
) {
// re-enqueue once for a single retry
queue.push(t);
continue;
}
failed.push({
id: String(t.id),
reason: cause instanceof Error ? cause.message : String(cause),
});
}
}
});
await Promise.all(workers);
return { ok, failed };
}
API Reference
interface TenantProvider:list(opts?: ListOpts): AsyncIterable<TenantRecord>— active-by-default; pass{ includeSuspended: true }to include suspended; archived never yielded.count(): Promise<number>— active-only count.lookup(tenantId: TenantId): Promise<TenantRecord | null>— never throws on missing; MAY throwTENANT_PROVIDER.SOURCE_UNAVAILABLEon infrastructure failure.dispose(): Promise<void>— idempotent; calling more than once MUST NOT throw.
Implementation Notes
list returns AsyncIterable<TenantRecord> rather than
Promise<readonly TenantRecord[]> so sources can stream pages from a
DB cursor without materialising the full roster — important for
deployments with thousands of tenants. Callers that DO want the full
list one-shot can drain the iterable into an array; the iterable shape
does not preclude that.
count is its own method, not (await drain(list())).length, because
many sources can answer count in O(1) (a single COUNT(*) SQL row)
where draining the iterable is O(N). The contract leaves the choice
to the implementor and lets callers pick the cheaper operation.
dispose MUST be idempotent because the host's lifecycle FSM (design
§4) may call it during normal shutdown and again during forced
shutdown — both calls MUST be safe even if the provider is already
disposed.