# `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` streamed one tenant at a time so large rosters don't force a full-set load. - `count()` — active-only count, intentionally separate from `list` so sources can shortcut to `COUNT(*) WHERE status='active'` when supported. - `lookup(id)` — by-id retrieval that returns `null` for unknown ids (never throws); the only way to reach `archived` tenants. - `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 ```ts import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider"; async function fanOutCron(provider: TenantProvider, run: (id: TenantId) => Promise) { for await (const tenant of provider.list()) { await run(tenant.id); } } ``` ## Usage: Advanced ```ts 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, 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` — active-by-default; pass `{ includeSuspended: true }` to include suspended; archived never yielded. - `count(): Promise` — active-only count. - `lookup(tenantId: TenantId): Promise` — never throws on missing; MAY throw `TENANT_PROVIDER.SOURCE_UNAVAILABLE` on infrastructure failure. - `dispose(): Promise` — idempotent; calling more than once MUST NOT throw. ## Implementation Notes `list` returns `AsyncIterable` rather than `Promise` 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.