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

157 lines
5.6 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: api-ws-broker-in-memory
**Package**: `@suaveplan/api-ws-broker-in-memory`
**Status**: SHIPPED 2026-05-26 (Stage 5 — Testing kit + conformance suites)
## Purpose
Single-host in-process implementation of the `@suaveplan/api-ws`
`WsBroker` contract. Maintains a per-topic `Set<handler>` registry and
fans `publish(topic, envelope)` out to every currently-subscribed
handler. Production-grade for single-host deployments AND the default
broker for `createTestHost` (Stage 5). Multi-host topologies use
`@suaveplan/api-ws-broker-redis` (Stage 7) over the same contract.
## ADDED Requirements
### Requirement: Fan-out to every subscriber on publish
The broker SHALL deliver `publish(topic, envelope)` to every handler
currently subscribed to `topic`. Delivery order across subscribers is
unspecified; per-subscriber message order is FIFO.
#### Scenario: every subscriber receives every message in publish order
- **WHEN** three handlers subscribe to `"orders"` AND two envelopes
are published in sequence
- **THEN** each handler receives both envelopes in publish order
**Verified by:** `src/broker/broker.test.ts::"fans out a single envelope to every subscriber"`
---
### Requirement: Subscription lifecycle (subscribe / unsubscribe / dispose)
The broker SHALL expose `subscribe(topic, handler)` returning a
`BrokerSubscription` whose `unsubscribe()` removes the handler. The
broker SHALL expose `dispose()` which clears every registered handler
and refuses subsequent `publish` / `subscribe` calls.
#### Scenario: unsubscribe stops delivery
- **WHEN** a handler subscribes, then unsubscribes, then a new
envelope is published
- **THEN** the handler is NOT invoked
#### Scenario: unsubscribe is idempotent
- **WHEN** `BrokerSubscription.unsubscribe()` is called twice
- **THEN** the second call is a no-op (no throw)
#### Scenario: dispose is idempotent
- **WHEN** `broker.dispose()` is called twice
- **THEN** the second call is a no-op and `isDisposed()` returns `true`
**Verified by:** `src/broker/broker.test.ts::"unsubscribe stops further delivery"`, `src/broker/broker.test.ts::"unsubscribe is idempotent"`, `src/broker/broker.test.ts::"dispose is idempotent"`
---
### Requirement: Use-after-dispose raises BROKER_DISPOSED
When `publish` or `subscribe` is invoked after `dispose()` has
resolved, the broker SHALL throw a
`WsBrokerInMemoryError` carrying the
`WS_BROKER_IN_MEMORY.BROKER_DISPOSED` code.
#### Scenario: publish after dispose throws
- **WHEN** `broker.dispose()` is called then `broker.publish(...)` is
awaited
- **THEN** the call throws `WS_BROKER_IN_MEMORY.BROKER_DISPOSED`
#### Scenario: subscribe after dispose throws
- **WHEN** `broker.dispose()` is called then `broker.subscribe(...)`
is called
- **THEN** the call throws `WS_BROKER_IN_MEMORY.BROKER_DISPOSED`
**Verified by:** `src/broker/broker.test.ts::"dispose throws BROKER_DISPOSED on subsequent publish/subscribe"`
---
### Requirement: Invalid options raise INVALID_OPTIONS
`createInMemoryWsBroker(options)` SHALL validate `options` against
`inMemoryWsBrokerOptionsSchema` (strict-mode Zod). Any failure SHALL
throw a `WsBrokerInMemoryError` carrying
`WS_BROKER_IN_MEMORY.INVALID_OPTIONS`.
#### Scenario: unknown options key throws
- **WHEN** `createInMemoryWsBroker({ unknown: true })` is called
- **THEN** the call throws `WS_BROKER_IN_MEMORY.INVALID_OPTIONS`
#### Scenario: invalid deliveryMode value throws
- **WHEN** `createInMemoryWsBroker({ deliveryMode: "blocking" })` is
called
- **THEN** the call throws `WS_BROKER_IN_MEMORY.INVALID_OPTIONS`
**Verified by:** `src/broker/broker.test.ts::"invalid options throws INVALID_OPTIONS"`, `src/broker/broker.test.ts::"invalid deliveryMode value throws INVALID_OPTIONS"`
---
### Requirement: Delivery mode semantics
`options.deliveryMode: "await-all"` SHALL await every handler before
`publish` resolves. `"fire-and-forget"` (default) SHALL dispatch
handlers without awaiting them. Per-subscriber failures in either
mode SHALL be swallowed individually and SHALL NOT reject the outer
publish nor abort sibling deliveries.
#### Scenario: await-all blocks publish until every handler settles
- **WHEN** `deliveryMode: "await-all"` AND two handlers each return a
delayed Promise
- **THEN** `publish` resolves only after both promises settle
#### Scenario: fire-and-forget isolates a throwing handler
- **WHEN** one handler throws synchronously AND another is correct
- **THEN** the correct handler still runs AND `publish` resolves
cleanly
**Verified by:** `src/broker/broker.test.ts::"isolates handler rejections in fire-and-forget mode"`, `src/broker/broker.test.ts::"isolates handler rejections in await-all mode"`
---
### Requirement: Conforms to WsBroker type contract
#### Scenario: type-shape matches WsBroker
- **WHEN** `expectTypeOf(broker)` is compared to `WsBroker<Payload>`
- **THEN** the comparison passes — every method declared on the
abstract contract exists with a compatible signature
**Verified by:** `tests/conformance/api-ws-broker.conformance.test.ts::"type-shape matches WsBroker"`
---
### Requirement: Testing harness
A `./testing` subpath SHALL export `createInMemoryBrokerHarness` that
wraps the broker with a captured-envelope observer, a `publishOne`
convenience, and an idempotent `dispose()` for use in `afterEach`.
#### Scenario: harness captures every published envelope
- **WHEN** `harness.publishOne(payload)` is called twice
- **THEN** `harness.received` contains both envelopes in publish order
**Verified by:** `src/testing/testing.test.ts::"captures every envelope published on the default topic"`
<!-- markdownlint-enable MD024 -->