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

146 lines
6.8 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: api-tenant-provider-memory
**Package**: `@suaveplan/api-tenant-provider-memory`
**Status**: SHIPPED (2026-05-26)
## Requirements
### Requirement: Enumerate tenants from an in-memory store
The package SHALL implement `TenantProvider` from
`@suaveplan/api-tenant-provider/provider` using an insertion-ordered
`Map<TenantId, TenantRecord>` as the backing store. Initial state comes
from `createMemoryTenantProvider({ tenants? })`; runtime mutation flows
through out-of-band `add` / `remove` / `clear` admin helpers exposed on
the returned object.
#### Scenario: list yields active tenants in insertion order
- **WHEN** the provider is constructed with `tenants: [A_active, B_active, C_active]`
- **THEN** iterating `list()` yields A, B, C in that order
#### Scenario: list filters archived and conditionally surfaces suspended
- **WHEN** the seed contains active, suspended, and archived records
- **THEN** `list()` yields only active, `list({ includeSuspended: true })` yields active + suspended, and `archived` is never yielded by `list`
#### Scenario: lookup returns archived records
- **WHEN** `lookup(id)` is called for an `archived` tenant
- **THEN** the archived record is returned (not null)
#### Scenario: dispose is idempotent and clears state
- **WHEN** `dispose()` is called twice
- **THEN** the second call resolves without throwing AND subsequent `count()` returns 0 AND `lookup(any)` returns null
**Verified by:** `src/provider/provider.test.ts::"seeds in insertion order"` AND `src/provider/provider.test.ts::"count is active-only"` AND `src/provider/provider.test.ts::"lookup returns archived records"` AND `src/provider/provider.test.ts::"dispose drops state and is idempotent"` AND the `runTenantProviderConformance({ label: "memory", ... })` invocation in the same file.
---
### Requirement: Pagination via canonical cursor codec
`list()` SHALL honour `ListOpts.pageSize` (clamps the single iteration's
yield count) and `ListOpts.cursor` (resumes from a saved offset, using
the `encodeCursor` / `decodeCursor` codec shipped by
`@suaveplan/api-tenant-provider/pagination`). Invalid cursors raise
`TENANT_PROVIDER.INVALID_CURSOR` (from the abstract contract package),
not a memory-adapter code.
#### Scenario: pageSize limits a single iteration
- **WHEN** `list({ pageSize: 4 })` runs over a 10-tenant active seed
- **THEN** exactly 4 tenants are yielded
#### Scenario: cursor resumes from offset
- **WHEN** an `encodeCursor({ offset: 2, version: 1 })` is passed to `list()` over a 5-tenant active seed
- **THEN** the iteration yields tenants 2, 3, 4 only
#### Scenario: invalid cursor raises TENANT_PROVIDER.INVALID_CURSOR
- **WHEN** `list({ cursor: "not-a-cursor" })` is iterated
- **THEN** the iteration rejects with a `TenantProviderError` whose code is `TENANT_PROVIDER.INVALID_CURSOR`
**Verified by:** `src/provider/provider.test.ts::"pageSize limits one iteration"` AND `src/provider/provider.test.ts::"cursor resumes from offset"` AND the conformance harness invariant `"invalid cursor throws TENANT_PROVIDER.INVALID_CURSOR"`.
---
### Requirement: Boundary validation through tenantRecordSchema
Every record entering the backing store SHALL be parsed through
`tenantRecordSchema` from `@suaveplan/api-tenant-provider/tenant-record`.
As of the foundation-fix-gap-2-tenant-chain change, `tenantRecordSchema`
requires a non-empty `slug` and accepts an optional `metadata` bag —
callers MUST supply `slug` on every record entering the store. Schema
failures (including missing/empty `slug`) raise
`TENANT_PROVIDER_MEMORY.INVALID_RECORD`.
#### Scenario: seed with malformed record is rejected
- **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "", slug: "x", status: "active", ... }] })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
#### Scenario: seed with missing slug is rejected
- **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "t1", status: "active", createdAt }] })` (no slug) is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
#### Scenario: add() validates input
- **WHEN** `add({ id: "", slug: "x", ... })` is called with an empty id
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
#### Scenario: invalid options shape rejected
- **WHEN** `createMemoryTenantProvider({ tenants: "not-an-array" })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
**Verified by:** `src/provider/provider.test.ts::"rejects malformed seed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"add rejects malformed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"rejects invalid options shape"`.
---
### Requirement: Mutation admin helpers
The returned `MemoryTenantProvider` SHALL expose `add(record)`,
`remove(tenantId)`, and `clear()` as out-of-band admin helpers (not on
the abstract `TenantProvider` contract). `add` raises
`TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT` on a colliding id; `remove`
raises `TENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND` for unknown ids.
`clear()` always succeeds.
#### Scenario: add rejects duplicate id
- **WHEN** the provider seed already contains `id="acme"` AND `add({ id: "acme", ... })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT`
#### Scenario: remove of unknown id
- **WHEN** `remove("nope")` is called against an empty provider
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND`
#### Scenario: seed with duplicate ids rejected
- **WHEN** `createMemoryTenantProvider({ tenants: [A, A] })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT`
**Verified by:** `src/provider/provider.test.ts::"add appends a new tenant"` AND `src/provider/provider.test.ts::"add rejects duplicate id (DUPLICATE_TENANT)"` AND `src/provider/provider.test.ts::"remove drops a tenant"` AND `src/provider/provider.test.ts::"remove of unknown id throws TENANT_NOT_FOUND"` AND `src/provider/provider.test.ts::"clear empties the store"` AND `src/provider/provider.test.ts::"rejects duplicate id in seed"`.
---
### Requirement: Conformance with the abstract contract
The package SHALL pass `runTenantProviderConformance` from
`@suaveplan/api-tenant-provider/testing` with `label: "memory"`.
#### Scenario: conformance suite is invoked at module-load time
- **WHEN** the test file imports and calls `runTenantProviderConformance({ label: "memory", factory: (seed) => createMemoryTenantProvider({ tenants: seed }) })`
- **THEN** every invariant of the abstract contract passes (8 cases)
**Verified by:** the top-level call in `src/provider/provider.test.ts`.
<!-- markdownlint-enable MD024 -->