Files
suaveplan/openspec/specs/api-tenant-provider-memory/spec.md
2026-05-27 23:29:53 +01:00

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