Initial Commit
This commit is contained in:
73
openspec/archive/2026-05-25-api-sse/proposal.md
Normal file
73
openspec/archive/2026-05-25-api-sse/proposal.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# `@suaveplan/api-sse` — abstract SSE Connection + Broker + Last-Event-Id resume contract
|
||||
|
||||
**Package**: `@suaveplan/api-sse`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Stage**: 1 (Contracts) — see umbrella `openspec/changes/api-plugin-foundation/`
|
||||
|
||||
## Why
|
||||
|
||||
SSE is the simpler cousin of WebSocket (design §11.6): same subscription model, no client→server channel, no ack semantics. Useful for activity feeds, dashboards, notifications, and as a fallback on networks that block WebSocket. Plugins declare SSE endpoints in the manifest; the host runs the engine, manages connection lifecycle, fans out subscriptions, replays buffered events on `Last-Event-Id` reconnect. Concrete engines (`api-sse-bun`) plug behind this contract.
|
||||
|
||||
Per design §11.6: "Reuses the same bus + broker; just a different transport." The bus shape is shared with `api-ws`'s `SubscriptionBus` — this package re-declares the SSE-specific connection/broker surface and the `Last-Event-Id` replay contract.
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package. Contract types + the replay-buffer interface + the conformance harness.
|
||||
|
||||
- ADDED: `./connection` — abstract `SseConnection`: `id`, `principal`, `tenantId`, `lastEventId: string | null`, `state`, `send(event: SseEvent)`, `close()`.
|
||||
- ADDED: `./event` — `SseEvent`: `{ id: string, event?: string, data: string, retry?: number }` (standard SSE event field shape).
|
||||
- ADDED: `./broker` — abstract `SseBroker`: same shape as `WsBroker` (publish/subscribe/dispose) but distinct type so engines can supply different transports.
|
||||
- ADDED: `./replay-buffer` — abstract `ReplayBuffer`: per-`(tenant, topic)` ring buffer (`eventBufferSize`, `eventBufferMs`); `append(event)`, `since(lastEventId)`, `evictExpired(now)`. The host's `Last-Event-Id` reconnect contract reads from here.
|
||||
- ADDED: `./connection-registry` — abstract `SseConnectionRegistry`: same multi-index shape as `ConnectionRegistry` in `api-ws` but typed for `SseConnection`.
|
||||
- ADDED: `./capability-id` — adds `"sse"` to the `CapabilityId` union via type-level augmentation.
|
||||
- ADDED: `./error-codes` — frozen `SSE_CODES`: `HANDSHAKE_FAILED`, `PRINCIPAL_EXPIRED`, `BUFFER_OVERFLOW`, `LAST_EVENT_ID_UNKNOWN`, `CONNECTION_CAP_EXCEEDED`, `BROKER_UNAVAILABLE`.
|
||||
- ADDED: `./testing` — `runSseEngineConformance(factory)` reusable harness.
|
||||
|
||||
## CapabilityId additions
|
||||
|
||||
This package contributes `"sse"` to the `CapabilityId` union. (No "broadcast-cross-tenant" SSE variant — SSE never accepts client input, so the host owns broadcast tenant scoping uniformly.)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Concrete `api-sse-bun` engine — Stage 7.
|
||||
- WebSocket — separate contract in `api-ws`.
|
||||
- The shared `SubscriptionBus` semantics — re-used type lives in `api-ws`; this package's broker is engine-side.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
|
||||
|
||||
| Requirement | Design doc section |
|
||||
|---|---|
|
||||
| SSE simpler-cousin shape | §11.6 |
|
||||
| `Last-Event-Id` replay buffer | §11.5 (last paragraph) + §11.6 |
|
||||
| OTel surface (`sse.connections`, `sse.events_sent_total`) | §11.7 |
|
||||
| Worker-tier proxying | §11.8 |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `runSseEngineConformance` harness | Stage-5 deliverable (§13.4). |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/connection/connection.spec-conformance.test.ts` — `expectTypeOf<SseConnection>()` declares every documented field.
|
||||
- `src/event/event.test.ts` — SseEvent shape; `id`/`event`/`data`/`retry` field handling.
|
||||
- `src/broker/broker.spec-conformance.test.ts` — `SseBroker` interface declared.
|
||||
- `src/replay-buffer/replay-buffer.test.ts` — append, query `since(id)`, time-based eviction, size cap.
|
||||
- `src/replay-buffer/last-event-id.test.ts` — unknown `Last-Event-Id` → `SSE.LAST_EVENT_ID_UNKNOWN` AND fall back to live subscription (no replay).
|
||||
- `src/connection-registry/connection-registry.test.ts` — multi-index lookup; idempotent remove.
|
||||
- `src/capability-id/capability-id.spec-conformance.test.ts` — `"sse"` in `CapabilityId`.
|
||||
- `tests/dep-graph.test.ts` — deps: `@suaveplan/api-types`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/api-ws` (for shared `SubscriptionBus` type only, peer dep), `zod`.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
- (none) — [x] no action needed (reason: net-new; engines land Stage 7)
|
||||
145
openspec/archive/2026-05-25-api-sse/specs/api-sse/spec.md
Normal file
145
openspec/archive/2026-05-25-api-sse/specs/api-sse/spec.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-sse (api-sse)
|
||||
|
||||
**Package**: `@suaveplan/api-sse`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement carries a `**Verified by:**` line.
|
||||
|
||||
## ADDED 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"`
|
||||
71
openspec/archive/2026-05-25-api-sse/tasks.md
Normal file
71
openspec/archive/2026-05-25-api-sse/tasks.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Tasks — `@suaveplan/api-sse`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read design doc §11.5 (reconnect/replay), §11.6 (SSE), §11.7 (OTel), §11.8 (worker tier)
|
||||
- [x] 1.2 Read `openspec/specs/api-sse/spec.md` (does not exist yet)
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13, 14
|
||||
- [x] 1.4 Draft `proposal.md`
|
||||
- [x] 1.5 Source-mapping table
|
||||
- [x] 1.6 Draft `specs/api-sse/spec.md`
|
||||
- [x] 1.7 `**Verified by:**` line on every requirement
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/api/api-sse/` (`"version": "0.1.0"`, `"sideEffects": false`, deps: `@suaveplan/api-types`, `@suaveplan/api-ws` (peer, type-only), `@suaveplan/error`, `@suaveplan/types`, `zod`)
|
||||
- [x] 2.2 `src/connection/` — `SseConnection`
|
||||
- [x] 2.3 `src/event/` — `SseEvent` type + Zod schema (`import z from "zod"`)
|
||||
- [x] 2.4 `src/broker/` — `SseBroker`
|
||||
- [x] 2.5 `src/replay-buffer/` — ring-buffer interface + size/time eviction; `since(lastEventId)`
|
||||
- [x] 2.6 `src/connection-registry/` — multi-index registry typed for SSE
|
||||
- [x] 2.7 `src/capability-id/` — augmentation adding `"sse"`
|
||||
- [x] 2.8 `src/error-codes/` — `SSE_CODES`
|
||||
- [x] 2.9 `src/testing/` — `runSseEngineConformance(factory)`
|
||||
- [x] 2.10 `src/index.ts` — explicit named re-exports
|
||||
- [x] 2.11 `package.json` `exports` per subpath
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests per feature
|
||||
- [x] 3.2 Spec-conformance type tests
|
||||
- [x] 3.3 Replay-buffer property tests (since(id) consistent across appends)
|
||||
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts`
|
||||
- [x] 4.2 `README.md` linking every `.md`
|
||||
- [x] 4.3 No CLAUDE.md update
|
||||
|
||||
## 5. Pre-archive verification
|
||||
|
||||
- [x] 5.1 `bun run check` — 0/0/0
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-sse` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-sse` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-sse` — exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-sse` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 Generics on `ReplayBuffer<Payload>`; `expectTypeOf` on every literal claim
|
||||
- [x] 6.2 Zod wired at the `SseEvent` boundary
|
||||
- [x] 6.3 Every `SSE_CODES` entry thrown
|
||||
- [x] 6.4 Buffer overflow / underrun behaviors tested both directions
|
||||
- [x] 6.5 OTel — N/A (engines emit)
|
||||
- [x] 6.6 Lifecycle dedup — connection register / replay idempotent; tested
|
||||
- [x] 6.7 No retroactive tick
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta
|
||||
- [x] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-sse/`
|
||||
- [x] 7.3 Tick umbrella `1.6`
|
||||
- [x] 7.4 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user