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

View File

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

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

View File

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

View File

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

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.

View File

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

View File

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