first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
# `TenantProvider` — abstract tenant-enumeration contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```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<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.