first commit
This commit is contained in:
51
docs/api-stack/api/api-tenant-provider/README.md
Normal file
51
docs/api-stack/api/api-tenant-provider/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @suaveplan/api-tenant-provider - Abstract `TenantProvider` contract for the Suaveplan API plugin system. Enumerates the universe of tenants known to a host so per-tenant cron / queued jobs (manifest `perTenant: true`) and admin tooling can fan out one invocation per tenant. Ships the interface, the `TenantRecord` Zod schema, the `ListOpts` pagination shape, the `CapabilityId` augmentation contributing `"tenant.provider"`, the frozen `TENANT_PROVIDER_CODES` error registry, and a reusable `runTenantProviderConformance(factory)` harness — no concrete source (in-memory, DB, directory-service adapters land in sibling packages).
|
||||
|
||||
Abstract `TenantProvider` contract for the Suaveplan API plugin
|
||||
system. Enumerates the universe of tenants known to a host so
|
||||
per-tenant cron / queued jobs (manifest `perTenant: true`) and admin
|
||||
tooling can fan out one invocation per tenant.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [capability-id](./src/capability-id/capability-id.md) — `@suaveplan/api-types/capabilities` exports `CapabilityId`, the open union derived from the `CapabilityIdRegistry` inte…
|
||||
- [capability-id](./src/capability-id/capability-id.md) — `@suaveplan/api-types/capabilities` exports `CapabilityId`, the open union derived from the `CapabilityIdRegistry` inte…
|
||||
- [chain](./src/chain/chain.md) — The chain module closes the gap between the inventory-side contract (`TenantProvider`, owned by this package) and the p…
|
||||
- [chain](./src/chain/chain.md) — The chain module closes the gap between the inventory-side contract (`TenantProvider`, owned by this package) and the p…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/api-tenant-provider` raises every failure through a single `SuaveplanError` subclass — `TenantProviderError…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/api-tenant-provider` raises every failure through a single `SuaveplanError` subclass — `TenantProviderError…
|
||||
- [pagination](./src/pagination/pagination.md) — `TenantProvider.list(opts?)` is `AsyncIterable<TenantRecord>` — callers iterate one tenant at a time without caring whe…
|
||||
- [pagination](./src/pagination/pagination.md) — `TenantProvider.list(opts?)` is `AsyncIterable<TenantRecord>` — callers iterate one tenant at a time without caring whe…
|
||||
- [provider](./src/provider/provider.md) — `TenantProvider` is the abstract contract every concrete tenant source implements. The API plugin host consumes it for…
|
||||
- [provider](./src/provider/provider.md) — `TenantProvider` is the abstract contract every concrete tenant source implements. The API plugin host consumes it for…
|
||||
- [tenant-record](./src/tenant-record/tenant-record.md) — A `TenantRecord` is one row from the host's tenant inventory. Every concrete `TenantProvider` — whether it reads from m…
|
||||
- [tenant-record](./src/tenant-record/tenant-record.md) — A `TenantRecord` is one row from the host's tenant inventory. Every concrete `TenantProvider` — whether it reads from m…
|
||||
- [testing](./src/testing/conformance.md) — Every concrete `TenantProvider` — the in-memory reference impl (`api-tenant-provider-memory`, ), the host-DB-bac…
|
||||
- [conformance](./src/testing/conformance.md) — Every concrete `TenantProvider` — the in-memory reference impl (`api-tenant-provider-memory`, ), the host-DB-bac…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What ships
|
||||
|
||||
| Subpath | Module | Doc |
|
||||
|---|---|---|
|
||||
| `./provider` | `TenantProvider` interface — `list / count / lookup / dispose` | [`src/provider/provider.md`](src/provider/provider.md) |
|
||||
| `./tenant-record` | `TenantRecord` + `tenantRecordSchema` (strict Zod) | [`src/tenant-record/tenant-record.md`](src/tenant-record/tenant-record.md) |
|
||||
| `./pagination` | `ListOpts`, `encodeCursor` / `decodeCursor` | [`src/pagination/pagination.md`](src/pagination/pagination.md) |
|
||||
| `./capability-id` | `TENANT_PROVIDER_CAPABILITY_ID` + widened `CapabilityId` | [`src/capability-id/capability-id.md`](src/capability-id/capability-id.md) |
|
||||
| `./error-codes` | `TENANT_PROVIDER_CODES` + `TenantProviderError` | [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) |
|
||||
| `./testing` | `runTenantProviderConformance(opts)` | [`src/testing/conformance.md`](src/testing/conformance.md) |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Concrete sources — `api-tenant-provider-memory` (), DB-backed sources are deployment-specific.
|
||||
- Tenant CREATE / UPDATE / DELETE — that's an admin concern.
|
||||
- Per-request tenant extraction — that's `TenantResolver` in `@suaveplan/api-types/tenant-resolver` + `@suaveplan/api-tenant-resolver-*` adapters.
|
||||
|
||||
## Why a separate package
|
||||
|
||||
`TenantProvider` answers "what tenants does this host know about" — distinct from `TenantResolver`, which answers "which tenant is making this request right now". Both contracts coexist in the api-stack but live in separate packages so adapters depend on the narrower one they need. Per design doc §5.4, the per-tenant job scheduler depends only on `TenantProvider`; HTTP middleware depends only on `TenantResolver`.
|
||||
|
||||
## Stage
|
||||
|
||||
Contracts of the API plugin system umbrella.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# `"tenant.provider"` — capability id
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-types/capabilities` exports `CapabilityId`, the open
|
||||
union derived from the `CapabilityIdRegistry` interface. As of the
|
||||
Stage-1 coherence fixes the foundation registry includes
|
||||
`"tenant.provider"` directly, so this package no longer maintains a
|
||||
widening alias — the runtime constant lives here because the
|
||||
`TenantProvider` interface lives here, and host code needs a stable
|
||||
sentinel without re-typing the literal everywhere.
|
||||
|
||||
The module re-exports the canonical `CapabilityId` from api-types for
|
||||
source compatibility with consumers that historically imported it from
|
||||
this package.
|
||||
|
||||
## Features
|
||||
|
||||
- Runtime constant `TENANT_PROVIDER_CAPABILITY_ID = "tenant.provider"`.
|
||||
- Type alias `TenantProviderCapabilityId = typeof TENANT_PROVIDER_CAPABILITY_ID`.
|
||||
- Re-export of `CapabilityId` from `@suaveplan/api-types/capabilities` — source-compatible with callers that imported the widened alias previously.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TENANT_PROVIDER_CAPABILITY_ID } from "@suaveplan/api-tenant-provider/capability-id";
|
||||
|
||||
if (manifest.requires?.includes(TENANT_PROVIDER_CAPABILITY_ID)) {
|
||||
// The plugin opted into per-tenant fan-out.
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
type CapabilityId,
|
||||
TENANT_PROVIDER_CAPABILITY_ID,
|
||||
} from "@suaveplan/api-tenant-provider/capability-id";
|
||||
|
||||
// A capability-checker that accepts both foundation and tenant-provider ids.
|
||||
function describeCapability(id: CapabilityId): string {
|
||||
switch (id) {
|
||||
case TENANT_PROVIDER_CAPABILITY_ID:
|
||||
return "tenant enumeration (per-tenant cron fan-out)";
|
||||
case "db":
|
||||
return "tenant-scoped database adapter";
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const TENANT_PROVIDER_CAPABILITY_ID = "tenant.provider" as const`.
|
||||
- `type TenantProviderCapabilityId = "tenant.provider"`.
|
||||
- `type CapabilityId` — re-export of the canonical union from `@suaveplan/api-types/capabilities`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Foundation `CapabilityId` is `keyof CapabilityIdRegistry`, and the
|
||||
foundation registry already contributes `"tenant.provider"` directly,
|
||||
so a separate widening alias here would be redundant. Any third-party
|
||||
package adding its own capability id should extend
|
||||
`CapabilityIdRegistry` from `@suaveplan/api-types/capabilities` via
|
||||
`declare module` rather than re-introducing a widened type union — the
|
||||
registry pattern is the documented extension point.
|
||||
|
||||
The constant is `as const` so the value type narrows to the exact
|
||||
string literal `"tenant.provider"`, which is what makes
|
||||
`expectTypeOf<typeof TENANT_PROVIDER_CAPABILITY_ID>().toEqualTypeOf<TenantProviderCapabilityId>()`
|
||||
hold at the type level.
|
||||
128
docs/api-stack/api/api-tenant-provider/src/chain/chain.md
Normal file
128
docs/api-stack/api/api-tenant-provider/src/chain/chain.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# `chain` — `TenantResolverChain` factory backed by a `TenantProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The chain module closes the gap between the inventory-side contract
|
||||
(`TenantProvider`, owned by this package) and the per-request contract
|
||||
(`TenantResolver` / `TenantResolverChain`, owned by
|
||||
`@suaveplan/api-types/tenant-resolver`). Hosts that already have a
|
||||
`TenantProvider` instance no longer need to hand-roll the
|
||||
`header -> provider.lookup -> TenantContext` chain at the boundary of
|
||||
every fixture or server file — they import
|
||||
`tenantResolverChainFromProvider(provider, opts?)` and wire the result
|
||||
straight into `HostAdapters.tenant`.
|
||||
|
||||
`recordToContext` is exposed alongside so downstream packages
|
||||
(`api-tenant-provider-postgres`, custom resolvers) can map a
|
||||
`TenantRecord` to a `TenantContext` without reimplementing the four-line
|
||||
projection (and crucially without re-introducing the `slug = String(id)`
|
||||
synthesis bug that motivated Gap #2).
|
||||
|
||||
## Features
|
||||
|
||||
- Three built-in strategies tried in fixed order: `X-Tenant-Id` header
|
||||
(configurable via `headerName`), JWT claim (when
|
||||
`jwtClaimExtractor` is supplied), and subdomain (when `subdomainBase`
|
||||
is supplied — e.g. `subdomainBase: "example.com"` matches
|
||||
`tenant-1.example.com`).
|
||||
- First match wins; misses (no matching strategy or `provider.lookup`
|
||||
returns `null`) advance to the next strategy.
|
||||
- Optional LRU memoisation on tenant-id -> `TenantContext` keyed by
|
||||
`{ maxEntries, ttlMs }`.
|
||||
- Zero `slug` synthesis — slug comes off the inventory row directly via
|
||||
`recordToContext`, which is the canonical projection.
|
||||
- Pure functions plus a single closure-scoped cache; no module-level
|
||||
state. Safe to instantiate one chain per host.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createMemoryTenantProvider } from "@suaveplan/api-tenant-provider-memory";
|
||||
import { tenantResolverChainFromProvider } from "@suaveplan/api-tenant-provider/chain";
|
||||
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [
|
||||
{
|
||||
id: "t1",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
createdAt: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const chain = tenantResolverChainFromProvider(provider);
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/", { headers: { "X-Tenant-Id": "t1" } })
|
||||
);
|
||||
// => { id: "t1", slug: "acme" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { tenantResolverChainFromProvider } from "@suaveplan/api-tenant-provider/chain";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
|
||||
declare const provider: import("@suaveplan/api-tenant-provider/provider").TenantProvider;
|
||||
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
headerName: "X-Custom-Tenant",
|
||||
subdomainBase: "example.com",
|
||||
jwtClaimExtractor: (req) => {
|
||||
const principal = (req as unknown as { principal?: Principal | null })
|
||||
.principal;
|
||||
return principal?.tenantId ?? null;
|
||||
},
|
||||
cache: { maxEntries: 1024, ttlMs: 60_000 },
|
||||
});
|
||||
|
||||
// `chain.resolvers` exposes the configured order — useful for diagnostics
|
||||
// and for adapter-level instrumentation that wants to record which
|
||||
// resolver hit.
|
||||
for (const r of chain.resolvers) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(r.id);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function recordToContext(record: TenantRecord): TenantContext` — drops
|
||||
`status`/`createdAt`, preserves `id`/`slug`/optional `displayName`/optional
|
||||
`metadata`.
|
||||
- `type JwtClaimExtractor = (request: Request) => string | null` — pulls a
|
||||
tenant-id-shaped claim out of a request; returning `null` abstains.
|
||||
- `interface ChainCacheOptions { readonly maxEntries: number; readonly ttlMs: number }`.
|
||||
- `interface TenantResolverChainFromProviderOptions { headerName?, subdomainBase?, jwtClaimExtractor?, cache? }`.
|
||||
- `function tenantResolverChainFromProvider(provider, opts?): TenantResolverChain` —
|
||||
returns `{ resolvers, resolve(req) }` matching the
|
||||
`@suaveplan/api-types/tenant-resolver` contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The chain order is fixed by the factory because mixing strategies via a
|
||||
freely-ordered array invites caller-side bugs (subdomain-before-header
|
||||
flips precedence at scale). Hosts that need a different order compose
|
||||
their own `TenantResolverChain` directly — the same record-to-context
|
||||
projection (`recordToContext`) is exposed for that case.
|
||||
|
||||
Subdomain matching is conservative: the leading segment must be a single
|
||||
DNS label (no dots), the host suffix must equal `.${base}`, and an exact
|
||||
match against the base alone yields `null` (so `example.com` -> no
|
||||
tenant, but `acme.example.com` -> `acme`).
|
||||
|
||||
LRU eviction is implemented over a plain `Map`. `Map`'s insertion-order
|
||||
semantics give O(1) oldest-entry removal via `keys().next().value` plus
|
||||
`delete`. The cache stores `TenantContext | null` so a miss is still
|
||||
memoised — repeated lookups of an unknown tenant do not re-hit the
|
||||
provider until the TTL expires.
|
||||
|
||||
The factory never throws — every strategy resolves to `null` on miss.
|
||||
Misconfiguration that should fail loudly (e.g. an empty `subdomainBase`)
|
||||
must be surfaced by the caller; the chain is intentionally a
|
||||
fail-soft pipeline so a bad strategy doesn't blackhole every request.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `TENANT_PROVIDER_CODES` — stable error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-tenant-provider` raises every failure through a single
|
||||
`SuaveplanError` subclass — `TenantProviderError` — and every instance
|
||||
carries a code from the frozen `TENANT_PROVIDER_CODES` registry. The
|
||||
registry exists so consumers can branch on a stable string literal
|
||||
(`error.code === "TENANT_PROVIDER.INVALID_CURSOR"`) without coupling
|
||||
to the package's class hierarchy or sniffing message text.
|
||||
|
||||
Because tenant enumeration is consumed by very different concerns
|
||||
(per-tenant cron fan-out, admin dashboards, background reconciliation,
|
||||
cache warmers), a small set of well-named codes is more useful than a
|
||||
large catalogue: callers either retry on `SOURCE_UNAVAILABLE`, fix
|
||||
caller bugs on `INVALID_CURSOR`, or treat the absent record as expected
|
||||
on `TENANT_NOT_FOUND`.
|
||||
|
||||
## Features
|
||||
|
||||
- `defineCodes("TENANT_PROVIDER", [...])` produces a frozen registry; new entries are an OpenSpec change.
|
||||
- `TenantProviderError extends SuaveplanError<TenantProviderErrorCode>` so `instanceof TenantProviderError` narrows the `code` field.
|
||||
- Three codes: `TENANT_NOT_FOUND`, `SOURCE_UNAVAILABLE`, `INVALID_CURSOR`.
|
||||
- Inherits cause-chain, redaction, OTel span-event adapter from `@suaveplan/error`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
TENANT_PROVIDER_CODES,
|
||||
TenantProviderError,
|
||||
} from "@suaveplan/api-tenant-provider/error-codes";
|
||||
|
||||
throw new TenantProviderError(
|
||||
TENANT_PROVIDER_CODES.TENANT_NOT_FOUND,
|
||||
"tenant t-42 is not registered",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
TenantProviderError,
|
||||
TENANT_PROVIDER_CODES,
|
||||
} from "@suaveplan/api-tenant-provider/error-codes";
|
||||
|
||||
try {
|
||||
for await (const t of provider.list({ cursor })) {
|
||||
// ...
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof TenantProviderError &&
|
||||
err.code === TENANT_PROVIDER_CODES.INVALID_CURSOR
|
||||
) {
|
||||
// The caller passed a cursor not produced by `list()` — reset
|
||||
// and start a fresh iteration instead of crashing the job.
|
||||
for await (const t of provider.list()) {
|
||||
// ...
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_PROVIDER_CODES`: `Readonly<{ TENANT_NOT_FOUND: "TENANT_PROVIDER.TENANT_NOT_FOUND"; SOURCE_UNAVAILABLE: "TENANT_PROVIDER.SOURCE_UNAVAILABLE"; INVALID_CURSOR: "TENANT_PROVIDER.INVALID_CURSOR" }>`.
|
||||
- `TenantProviderErrorCode`: the union of those three string literals.
|
||||
- `class TenantProviderError extends SuaveplanError<TenantProviderErrorCode>` with `constructor(code, message, options?)` and public `readonly tenantProviderCode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are defined via `defineCodes("TENANT_PROVIDER", [...] as const)`
|
||||
so the `TENANT_PROVIDER.` prefix is forwarded by the registry helper —
|
||||
the array entries are bare names. The resulting object is frozen at
|
||||
construction and the type is widened to the union of every namespaced
|
||||
literal. The `tenantProviderCode` field on the error mirrors `code`
|
||||
for ergonomic narrowing in consumer code that already imports the
|
||||
class but not the code union.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `ListOpts` + opaque cursor encoding
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TenantProvider.list(opts?)` is `AsyncIterable<TenantRecord>` — callers
|
||||
iterate one tenant at a time without caring whether the underlying
|
||||
source is paging behind the scenes. `ListOpts` is the small option
|
||||
bundle that lets callers tune that behavior: page-size hint,
|
||||
include-suspended toggle, and an opaque cursor for resumption.
|
||||
|
||||
This module ships the type, the strict-mode Zod schema, and the
|
||||
canonical base64url-JSON cursor encode/decode helpers used by the
|
||||
in-memory reference implementation (Stage 4). Concrete sources are
|
||||
free to use a different cursor scheme — the contract only requires
|
||||
that a cursor produced by a `list()` call decodes back to a valid
|
||||
state when passed to the same provider's `list({ cursor })`.
|
||||
|
||||
## Features
|
||||
|
||||
- `pageSize` — positive integer hint; sources MAY honor or ignore.
|
||||
- `includeSuspended` — lifts the active-only default; never surfaces `archived`.
|
||||
- `cursor` — opaque string produced by a previous `list()` call.
|
||||
- `listOptsSchema` — strict-mode Zod; unknown keys rejected.
|
||||
- `encodeCursor` / `decodeCursor` — base64url JSON round-trip with structural validation; rejects malformed input with `TENANT_PROVIDER.INVALID_CURSOR`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { encodeCursor, decodeCursor } from "@suaveplan/api-tenant-provider/pagination";
|
||||
|
||||
const opaque = encodeCursor({ offset: 100, version: 1 });
|
||||
const state = decodeCursor(opaque);
|
||||
console.log(state.offset); // 100
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { ListOpts } from "@suaveplan/api-tenant-provider/pagination";
|
||||
import {
|
||||
TenantProviderError,
|
||||
TENANT_PROVIDER_CODES,
|
||||
} from "@suaveplan/api-tenant-provider/error-codes";
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
} from "@suaveplan/api-tenant-provider/pagination";
|
||||
|
||||
// A provider author's `list` implementation, sketched.
|
||||
async function* list(opts: ListOpts = {}): AsyncIterable<TenantRecord> {
|
||||
const pageSize = opts.pageSize ?? 50;
|
||||
let offset = 0;
|
||||
if (opts.cursor) {
|
||||
const decoded = decodeCursor(opts.cursor);
|
||||
offset = decoded.offset;
|
||||
}
|
||||
while (true) {
|
||||
const page = await fetchPage(offset, pageSize);
|
||||
for (const row of page) yield row;
|
||||
if (page.length < pageSize) return;
|
||||
offset += page.length;
|
||||
// emit a resume cursor next time `list({ cursor })` is called
|
||||
void encodeCursor({ offset, version: 1 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ListOpts { readonly pageSize?: number; readonly includeSuspended?: boolean; readonly cursor?: string }`.
|
||||
- `const listOptsSchema: z.ZodType<ListOpts>` — strict-mode; `pageSize` must be a positive integer; `cursor` must be non-empty.
|
||||
- `interface CursorState { readonly offset: number; readonly version: 1 }`.
|
||||
- `function encodeCursor(state: CursorState): string` — base64url-JSON.
|
||||
- `function decodeCursor(cursor: string): CursorState` — throws `TenantProviderError("TENANT_PROVIDER.INVALID_CURSOR", ...)` on any decode failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical cursor encoding is `base64url(JSON.stringify(state))`.
|
||||
This is deliberately readable: a developer reading a log line can
|
||||
base64url-decode the cursor and see the offset. Adversarial use
|
||||
(forging a cursor to skip authorization) is not a threat the provider
|
||||
contract defends against — the request still has to pass every
|
||||
downstream tenant-scoping check, and the provider's `list` itself is
|
||||
not a public endpoint.
|
||||
|
||||
`decodeCursor` catches both the `base64url` decode and the `JSON.parse`
|
||||
failure modes, normalising every failure to a single
|
||||
`TENANT_PROVIDER.INVALID_CURSOR` error so consumers branch on a stable
|
||||
code.
|
||||
125
docs/api-stack/api/api-tenant-provider/src/provider/provider.md
Normal file
125
docs/api-stack/api/api-tenant-provider/src/provider/provider.md
Normal 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.
|
||||
@@ -0,0 +1,95 @@
|
||||
# `TenantRecord` — the canonical tenant row
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A `TenantRecord` is one row from the host's tenant inventory. Every
|
||||
concrete `TenantProvider` — whether it reads from memory, a database,
|
||||
or a directory service — surfaces tenants as `TenantRecord` values, so
|
||||
downstream consumers (the cron scheduler, admin tooling, per-tenant
|
||||
cache warmers) can iterate the inventory without knowing how it is
|
||||
stored.
|
||||
|
||||
The Zod schema `tenantRecordSchema` lives next to the type so external
|
||||
sources (DB rows, JSON config, remote API responses) can be validated
|
||||
at the boundary. Strict mode is enabled so silent schema drift in the
|
||||
source — a column rename, an extra JSON property — surfaces as a Zod
|
||||
issue at parse time rather than as undefined behavior deep in the
|
||||
host.
|
||||
|
||||
## Features
|
||||
|
||||
- `id` is the branded `TenantId` from `@suaveplan/api-types/tenant` — plain `string` cannot be silently substituted.
|
||||
- `status` is the discriminated union `"active" | "suspended" | "archived"`; the runtime tuple `TENANT_STATUSES` exposes the same values.
|
||||
- `createdAt` is an ISO-8601 datetime string with required offset, parsed via Zod's `datetime({ offset: true })`.
|
||||
- `displayName` is optional and, when present, MUST be non-empty.
|
||||
- Strict-mode Zod — unknown keys produce a parse failure.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { tenantRecordSchema } from "@suaveplan/api-tenant-provider/tenant-record";
|
||||
|
||||
const record = tenantRecordSchema.parse({
|
||||
id: "t-42",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T12:00:00.000Z",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
TENANT_STATUSES,
|
||||
type TenantRecord,
|
||||
tenantRecordSchema,
|
||||
} from "@suaveplan/api-tenant-provider/tenant-record";
|
||||
|
||||
// Bulk-validate rows from a DB query, collecting per-row errors instead
|
||||
// of failing the entire batch.
|
||||
function parseRows(rows: readonly unknown[]): {
|
||||
ok: readonly TenantRecord[];
|
||||
failed: readonly { index: number; issues: string[] }[];
|
||||
} {
|
||||
const ok: TenantRecord[] = [];
|
||||
const failed: { index: number; issues: string[] }[] = [];
|
||||
rows.forEach((row, index) => {
|
||||
const result = tenantRecordSchema.safeParse(row);
|
||||
if (result.success) ok.push(result.data);
|
||||
else
|
||||
failed.push({
|
||||
index,
|
||||
issues: result.error.issues.map((i) => i.message),
|
||||
});
|
||||
});
|
||||
return { ok, failed };
|
||||
}
|
||||
|
||||
// `TENANT_STATUSES` is a tuple — use it for UI form options.
|
||||
const statusOptions = TENANT_STATUSES.map((s) => ({ value: s, label: s }));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TenantRecord { readonly id: TenantId; readonly displayName?: string; readonly status: TenantStatus; readonly createdAt: string }`.
|
||||
- `type TenantStatus = "active" | "suspended" | "archived"`.
|
||||
- `const TENANT_STATUSES = ["active", "suspended", "archived"] as const`.
|
||||
- `const tenantRecordSchema: z.ZodType<TenantRecord>` — strict-mode Zod schema; rejects unknown keys; `id` accepted as non-empty string and re-cast to `TenantId`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The brand on `TenantId` lives in `@suaveplan/api-types` and is enforced
|
||||
purely at the type level — there is no runtime tag — so the schema
|
||||
parses `id` as a plain `string().min(1)` and re-casts via a
|
||||
`transform`. Sources that need stronger runtime validation (UUID,
|
||||
slug-pattern, etc.) layer their own `refine` on top.
|
||||
|
||||
The `createdAt` field uses Zod's RFC-3339 datetime parser with `offset:
|
||||
true`, which means callers MUST include a timezone offset (`Z` or
|
||||
`+HH:MM`). This matches the wire format used everywhere else in the
|
||||
project (see `@suaveplan/datetime`) and avoids the ambiguous
|
||||
"interpret-as-local" failure mode that floating timestamps invite.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `runTenantProviderConformance` — reusable conformance harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every concrete `TenantProvider` — the in-memory reference impl
|
||||
(`api-tenant-provider-memory`, ), the host-DB-backed adapter,
|
||||
any directory-service adapter — MUST pass the same behavioural suite.
|
||||
Putting the suite here, importable as `runTenantProviderConformance`,
|
||||
guarantees that adding a new provider source is a one-line job in the
|
||||
adapter's test file and that every adapter agrees on the contract's
|
||||
edge cases (active-only default, includeSuspended, lookup-returns-null,
|
||||
cursor invalidation, dispose idempotency).
|
||||
|
||||
The harness uses `describe` / `it` / `expect` from
|
||||
`@suaveplan/testing/runner` so it works under both the Vitest default
|
||||
runner and the Bun-conditional runner (rule 11).
|
||||
|
||||
## Features
|
||||
|
||||
- Eight tests covering `list` default filter, `includeSuspended`, `count`, `lookup`-returns-record, `lookup`-returns-null, pagination round-trip, invalid-cursor, and dispose idempotency.
|
||||
- `factory(seed)` callback constructs a fresh provider per test — no shared singleton, no leaked state.
|
||||
- Compatible with both Vitest and Bun via `@suaveplan/testing/runner`.
|
||||
- Every `it(...)` carries `{ timeout: 5000 }` per CLAUDE.md.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// In api-tenant-provider-memory/src/memory.test.ts (Stage 4):
|
||||
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
|
||||
import { MemoryTenantProvider } from "./memory.js";
|
||||
|
||||
runTenantProviderConformance({
|
||||
label: "in-memory",
|
||||
factory: (seed) => new MemoryTenantProvider(seed),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
|
||||
import { SurrealTenantProvider } from "./surreal.js";
|
||||
import { startSurrealdb } from "@suaveplan/testing-integration-surrealdb";
|
||||
|
||||
// Spin up a real SurrealDB container per provider instance — the
|
||||
// factory can be async, so heavyweight fixtures are first-class.
|
||||
runTenantProviderConformance({
|
||||
label: "surrealdb",
|
||||
factory: async (seed) => {
|
||||
const db = await startSurrealdb();
|
||||
const provider = new SurrealTenantProvider(db);
|
||||
for (const record of seed) await provider.put(record);
|
||||
// Wrap dispose so the container also stops when the test ends.
|
||||
const originalDispose = provider.dispose.bind(provider);
|
||||
provider.dispose = async () => {
|
||||
await originalDispose();
|
||||
await db.stop();
|
||||
};
|
||||
return provider;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TenantProviderConformanceOptions { readonly label: string; readonly factory: (seed: readonly TenantRecord[]) => Promise<TenantProvider> | TenantProvider }`.
|
||||
- `function runTenantProviderConformance(options: TenantProviderConformanceOptions): void` — registers a `describe` block with the documented tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness is intentionally minimal: it does not test telemetry,
|
||||
metrics, or implementation-specific properties — those belong in the
|
||||
adapter's own test file. It only pins the cross-adapter contract that
|
||||
every consumer of `TenantProvider` relies on.
|
||||
|
||||
The `factory` callback receives a `readonly TenantRecord[]` seed and
|
||||
MUST return a provider in which `list({ includeSuspended: true })`
|
||||
yields exactly the active and suspended subset of `seed` (deterministic
|
||||
order is not required — assertions normalise via sort).
|
||||
|
||||
`dispose` idempotency is tested by calling `dispose()` twice and
|
||||
asserting the second call resolves. Adapters whose `dispose` reasonably
|
||||
fails on double-call must reset internal state on the first call to
|
||||
make the second a no-op.
|
||||
Reference in New Issue
Block a user