6.8 KiB
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, andarchivedis never yielded bylist
Scenario: lookup returns archived records
- WHEN
lookup(id)is called for anarchivedtenant - 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 ANDlookup(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 tolist()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
TenantProviderErrorwhose code isTENANT_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
TenantProviderMemoryErrorwith codeTENANT_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
TenantProviderMemoryErrorwith codeTENANT_PROVIDER_MEMORY.INVALID_RECORD
Scenario: add() validates input
- WHEN
add({ id: "", slug: "x", ... })is called with an empty id - THEN the call throws
TenantProviderMemoryErrorwith codeTENANT_PROVIDER_MEMORY.INVALID_RECORD
Scenario: invalid options shape rejected
- WHEN
createMemoryTenantProvider({ tenants: "not-an-array" })is called - THEN the call throws
TenantProviderMemoryErrorwith codeTENANT_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"ANDadd({ id: "acme", ... })is called - THEN the call throws
TenantProviderMemoryErrorwith codeTENANT_PROVIDER_MEMORY.DUPLICATE_TENANT
Scenario: remove of unknown id
- WHEN
remove("nope")is called against an empty provider - THEN the call throws
TenantProviderMemoryErrorwith codeTENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND
Scenario: seed with duplicate ids rejected
- WHEN
createMemoryTenantProvider({ tenants: [A, A] })is called - THEN the call throws
TenantProviderMemoryErrorwith codeTENANT_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.