Initial Commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user