Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-ws-broker-redis
**Package**: `@suaveplan/api-ws-broker-redis`
**Status**: SHIPPED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: Implements the WsBroker contract
The package SHALL export `createRedisWsBroker(opts)` producing a broker value implementing the `WsBroker` contract from `@suaveplan/api-ws`. The conformance suite from `@suaveplan/api-ws/testing` MUST pass against this broker.
#### Scenario: conformance suite passes
- **WHEN** the conformance suite runs against `createRedisWsBroker(opts)` backed by a real Redis container
- **THEN** every invariant passes
**Verified by:** `src/broker/redis-broker.spec-conformance.test.ts::"createRedisWsBroker passes WsBroker conformance against real Redis"`
---
### Requirement: Cross-host broadcast via Redis pub/sub
`broker.broadcast(topic, { tenant, payload })` SHALL publish to `<channelPrefix><topic>` on Redis. Every host subscribed to the channel pattern MUST receive the message and deliver it to its locally-connected subscribers via the local `ConnectionRegistry`. Connections are NOT migrated across hosts — the broker only makes broadcasts visible everywhere, and each host independently delivers to its own subscribers.
#### Scenario: broadcast from host A is delivered to subscribers on host B
- **WHEN** two `api-ws-bun` engines run on different processes pointed at the same Redis AND a client C subscribed on host B AND host A issues `broker.broadcast(topic, { tenant: T, payload: ... })` with C's tenant
- **THEN** C receives the payload
#### Scenario: sticky sessions are NOT required
- **WHEN** a client connects to host A AND later reconnects and lands on host B
- **THEN** subscriptions resume on host B from the event buffer with no requirement on routing back to host A
**Verified by:** `tests/integration/two-host-broadcast.test.ts::"broadcast from host A delivered to subscriber on host B"` AND `tests/integration/two-host-broadcast.test.ts::"reconnect lands on different host and resumes from event buffer"`
---
### Requirement: Reconnect event-buffer storage in Redis sorted-sets
The broker SHALL maintain a per-`(tenant, topic)` event buffer in Redis sorted-sets: `<keyPrefix>buffer:<tenant>:<topic>`, scored by `lastEventId`. Each entry SHALL be capped at `eventBufferMaxEvents` (default 256, oldest evicted on overflow) AND TTL'd at `eventBufferTtlSec` (default 300s). On reconnect, `api-ws-bun` calls `broker.readBuffer(tenant, topic, sinceEventId)` to replay events; SSE calls the same surface for `Last-Event-Id` resume.
#### Scenario: buffer cap evicts oldest entries on overflow
- **WHEN** `eventBufferMaxEvents: 4` AND 5 events are written for the same `(tenant, topic)`
- **THEN** the buffer holds the 4 most recent events AND the oldest was evicted
#### Scenario: TTL expires the buffer
- **WHEN** no writes for `eventBufferTtlSec + 1`
- **THEN** the buffer key has been removed from Redis
#### Scenario: replay returns events with id > sinceEventId
- **WHEN** the buffer holds events 1..10 AND `readBuffer(tenant, topic, 7)` is called
- **THEN** events 8, 9, 10 are returned in that order
**Verified by:** `src/event-buffer/event-buffer.test.ts::"buffer cap evicts oldest on overflow"` AND `src/event-buffer/event-buffer.test.ts::"buffer TTL expires keys"` AND `src/event-buffer/event-buffer.test.ts::"readBuffer returns events with id greater than sinceEventId"`
---
### Requirement: Payload compression above a configurable threshold
When a payload exceeds `compressionThresholdBytes` (default 1024), the broker SHALL gzip the payload before publishing. Subscribers SHALL gunzip on receive transparently. A payload that exceeds `4_194_304` bytes (4 MiB) hard cap MUST be refused with `WS_BROKER_REDIS_PAYLOAD_TOO_LARGE` — operators see the misuse instead of silently flooding Redis.
#### Scenario: payload above threshold is compressed
- **WHEN** a 2 KiB payload is broadcast
- **THEN** the bytes published to Redis are gzip-prefixed AND the subscriber receives the decompressed payload identical to input
#### Scenario: payload above 4 MiB hard cap is refused
- **WHEN** a 5 MiB payload is broadcast
- **THEN** the call fails with `WS_BROKER_REDIS_PAYLOAD_TOO_LARGE` AND nothing is published
**Verified by:** `src/publish/publish.test.ts::"payload above threshold is gzip-compressed end-to-end"` AND `src/publish/publish.test.ts::"payload above hard cap refused with WS_BROKER_REDIS_PAYLOAD_TOO_LARGE"`
---
### Requirement: Reconnect after Redis bounce
If the underlying Redis connection drops, the broker SHALL attempt auto-reconnect per `reconnectStrategy` (exponential backoff default). During the outage, `healthz()` returns `degraded`; in-flight publishes either queue (for short outages, configurable) or fail with `WS_BROKER_REDIS_CONNECTION_LOST`. On reconnect, pattern subscriptions MUST be re-established AND `ws.broker.connection_lost_total` reflects the outage count.
#### Scenario: short Redis bounce recovers transparently
- **WHEN** Redis is restarted (under 5 seconds) AND publishes continue
- **THEN** publishes resume after reconnect AND `ws.broker.connection_lost_total` increments by 1
#### Scenario: healthz reflects connection state
- **WHEN** Redis is unreachable
- **THEN** `broker.healthz()` returns `{ status: "degraded" | "down" }`
**Verified by:** `src/reconnect/reconnect.test.ts::"short Redis bounce recovers transparently and counter increments"` AND `src/health/health.test.ts::"healthz reflects Redis connection state"`
---
### Requirement: Same broker serves SSE and WebSocket
The broker SHALL be consumable by both `api-ws-bun` (WebSocket) and `api-sse-bun` (SSE). Both engines use `broker.broadcast` for fan-out AND `broker.readBuffer` for reconnect resume. The broker MUST NOT assume one transport over the other.
#### Scenario: SSE engine and WS engine share the same broker
- **WHEN** an integration test boots `api-sse-bun` and `api-ws-bun` against the same broker AND a broadcast fires
- **THEN** both engines deliver to their respective subscribers
**Verified by:** `tests/integration/two-host-broadcast.test.ts::"single broker serves both WS and SSE engines"`
---
### Requirement: OTel surface
The broker SHALL emit:
- `ws.broker.publishes_total` counter `{ topic, outcome }`.
- `ws.broker.publish_duration_ms` histogram.
- `ws.broker.receives_total` counter `{ topic }`.
- `ws.broker.buffer_writes_total` counter.
- `ws.broker.buffer_reads_total` counter `{ hit }` (`"hit"` or `"miss"` based on whether events were returned).
- `ws.broker.connection_lost_total` counter.
#### Scenario: successful publish emits counter + histogram
- **WHEN** a publish completes successfully
- **THEN** `ws.broker.publishes_total{outcome="ok"}` increments AND one observation lands in `ws.broker.publish_duration_ms`
**Verified by:** `src/instruments/instruments.test.ts::"successful publish emits publishes_total + publish_duration_ms"`