160 lines
6.4 KiB
Markdown
160 lines
6.4 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: api-sse
|
|
|
|
**Package**: `@suaveplan/api-sse`
|
|
**Status**: SHIPPED (0.1.0)
|
|
**Archived change**: `openspec/archive/2026-05-25-api-sse/`
|
|
|
|
> Every requirement carries a `**Verified by:**` line.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: SseConnection contract
|
|
|
|
The package SHALL export `SseConnection` with: `id` (ULID), `principal: Principal`, `tenantId: TenantId`, `lastEventId: string | null`, `state: "open" | "closing" | "closed"`, methods `send(event: SseEvent)`, `close()`. SSE is uni-directional (host → client); there is no `onMessage`.
|
|
|
|
#### Scenario: every documented field present
|
|
|
|
- **WHEN** `expectTypeOf<SseConnection>()` is checked
|
|
- **THEN** every documented field is declared
|
|
|
|
**Verified by:** `src/connection/connection.spec-conformance.test.ts::"SseConnection declares every documented field"`
|
|
|
|
---
|
|
|
|
### Requirement: `import z from "zod"` everywhere
|
|
|
|
Every Zod import MUST use the default form.
|
|
|
|
#### Scenario: no named import of z
|
|
|
|
- **WHEN** the test greps `import z` from `"zod"`
|
|
- **THEN** zero matches
|
|
|
|
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
|
|
|
|
---
|
|
|
|
### Requirement: SseEvent shape
|
|
|
|
The package SHALL export `sseEventSchema: z.ZodSchema<SseEvent>` describing `{ id: string, event?: string, data: string, retry?: number }`. Unknown keys MUST be rejected.
|
|
|
|
#### Scenario: minimal event parses
|
|
|
|
- **WHEN** `{ id: "1", data: "hello" }` is parsed
|
|
- **THEN** it succeeds AND `event` is undefined (not defaulted)
|
|
|
|
#### Scenario: unknown key rejected
|
|
|
|
- **WHEN** `{ id: "1", data: "x", extra: 1 }` is parsed
|
|
- **THEN** Zod reports an issue at `extra`
|
|
|
|
**Verified by:** `src/event/event.test.ts::"minimal event parses"` AND `src/event/event.test.ts::"unknown keys rejected"`
|
|
|
|
---
|
|
|
|
### Requirement: SseBroker contract
|
|
|
|
The package SHALL export `SseBroker` with `publish(topic, envelope)`, `subscribe(topic, handler)`, `dispose()`. Same shape as `WsBroker` but a distinct type so engines can supply different transports.
|
|
|
|
#### Scenario: every method present
|
|
|
|
- **WHEN** `expectTypeOf<SseBroker>()` is checked
|
|
- **THEN** all three methods are declared
|
|
|
|
**Verified by:** `src/broker/broker.spec-conformance.test.ts::"SseBroker declares publish subscribe dispose"`
|
|
|
|
---
|
|
|
|
### Requirement: Replay buffer with Last-Event-Id resume
|
|
|
|
The package SHALL export `ReplayBuffer` with: `append(event: SseEvent)`, `since(lastEventId: string): readonly SseEvent[]`, `evictExpired(now: number)`. The buffer is per `(tenant, topic)` and bounded by `eventBufferSize` (default 256) and `eventBufferMs` (default 30000). `since` SHALL return every event whose id sorts AFTER `lastEventId` in append order. An unknown `lastEventId` (evicted or never seen) MUST surface `SSE.LAST_EVENT_ID_UNKNOWN` so the engine can fall back to live subscription.
|
|
|
|
#### Scenario: since(id) returns events after the marker
|
|
|
|
- **WHEN** events A, B, C are appended in order AND `since(B.id)` is called
|
|
- **THEN** the result is `[C]`
|
|
|
|
#### Scenario: unknown id surfaces error code
|
|
|
|
- **WHEN** `since("never-seen-id")` is called
|
|
- **THEN** the call throws `SSE.LAST_EVENT_ID_UNKNOWN`
|
|
|
|
#### Scenario: time-based eviction drops expired entries
|
|
|
|
- **WHEN** the fake clock advances past `eventBufferMs`
|
|
- **THEN** the expired entries are dropped on the next `evictExpired(now)`
|
|
|
|
**Verified by:** `src/replay-buffer/replay-buffer.test.ts::"since returns events after marker"` AND `src/replay-buffer/last-event-id.test.ts::"unknown lastEventId surfaces LAST_EVENT_ID_UNKNOWN"` AND `src/replay-buffer/replay-buffer.test.ts::"time-based eviction drops expired entries"`
|
|
|
|
---
|
|
|
|
### Requirement: Connection registry multi-index
|
|
|
|
The package SHALL export `SseConnectionRegistry` with O(1) lookup by `tenantId` and by `(topic, tenantId)`. `add`, `remove`, `lookupByTenant`, `lookupByTopic`, `countByTenant`. `remove` MUST be idempotent.
|
|
|
|
#### Scenario: lookup returns matching set
|
|
|
|
- **WHEN** 2 SSE connections subscribe to tenant T
|
|
- **THEN** `lookupByTenant("T").length === 2`
|
|
|
|
#### Scenario: remove is idempotent
|
|
|
|
- **WHEN** `remove(conn)` is called twice
|
|
- **THEN** the second call returns without throwing
|
|
|
|
**Verified by:** `src/connection-registry/connection-registry.test.ts::"lookupByTenant returns matching set"` AND `src/connection-registry/connection-registry.test.ts::"remove is idempotent"`
|
|
|
|
---
|
|
|
|
### Requirement: CapabilityId augmentation contributes "sse"
|
|
|
|
The package SHALL augment the `CapabilityId` union to include `"sse"`.
|
|
|
|
#### Scenario: sse is assignable to CapabilityId
|
|
|
|
- **WHEN** `expectTypeOf<"sse">().toMatchTypeOf<CapabilityId>()` is checked
|
|
- **THEN** the assertion passes
|
|
|
|
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"sse is a valid CapabilityId after augmentation"`
|
|
|
|
---
|
|
|
|
### Requirement: Error codes registry frozen
|
|
|
|
The package SHALL export `SSE_CODES` containing exactly: `HANDSHAKE_FAILED`, `PRINCIPAL_EXPIRED`, `BUFFER_OVERFLOW`, `LAST_EVENT_ID_UNKNOWN`, `CONNECTION_CAP_EXCEEDED`, `BROKER_UNAVAILABLE`.
|
|
|
|
#### Scenario: every code reachable
|
|
|
|
- **WHEN** the test enumerates `SSE_CODES`
|
|
- **THEN** every code has a production throw site
|
|
|
|
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every SSE_CODES entry thrown by production path"`
|
|
|
|
---
|
|
|
|
### Requirement: Conformance harness for engines
|
|
|
|
The package SHALL export `runSseEngineConformance({ factory, label })`. Every concrete SSE engine MUST pass.
|
|
|
|
#### Scenario: harness covers handshake, send, last-event-id replay, eviction
|
|
|
|
- **WHEN** the harness runs against a stub engine
|
|
- **THEN** test categories exist for each documented behavior
|
|
|
|
**Verified by:** `src/testing/conformance.test.ts::"harness covers handshake send replay eviction"`
|
|
|
|
---
|
|
|
|
### Requirement: Test timeouts use the options-object form
|
|
|
|
Every `it()` and `test()` call in this package's `*.test.ts` files SHALL pass `{ timeout: 5000 }` (or `{ timeout: 30000 }` for async-heavy tests) as the second argument, per CLAUDE.md §Quality. Tests SHALL NOT use the legacy trailing-number form (`it("...", () => {}, 5000)`) because that form is incompatible with the options-object form used by the rest of the monorepo and Vitest does not accept both simultaneously.
|
|
|
|
#### Scenario: every test declares an explicit timeout
|
|
|
|
- **WHEN** every `it()` or `test()` line in `packages/api-stack/api/api-sse/src/**/*.test.ts` is inspected
|
|
- **THEN** each one passes `{ timeout: <ms> }` as the second argument
|
|
|
|
**Verified by:** `bunx turbo lint test --filter=@suaveplan/api-sse` (the gate exercises every test under the runner)
|