Files
2026-05-27 23:29:53 +01:00

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 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

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 throw TENANT_PROVIDER.SOURCE_UNAVAILABLE on 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.