Initial Commit
This commit is contained in:
132
openspec/archive/2026-05-19-add-api-plugin-event-bus/proposal.md
Normal file
132
openspec/archive/2026-05-19-add-api-plugin-event-bus/proposal.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Add @suaveplan/api-plugin-event-bus — typed event-bus plugin wrapping @suaveplan/events on the @suaveplan/api foundation
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-event-bus` (NEW, server-tier)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
- **Spec delta**: ./specs/api-plugin-event-bus/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships `@suaveplan/events` (typed event-bus core — pub/sub, async dispatch, interceptors, bounded-queue backpressure, idempotent listener wrapper) and `@suaveplan/api` (orchestrator) plus `@suaveplan/api-plugin` (`definePlugin` foundation). Multi-tenant apps composing `@suaveplan/api` repeatedly hand-roll the wiring that turns the raw `EventBus` into a per-tenant-scoped, request-aware service: construct the bus, register interceptors and the bounded queue, install middleware that scopes topics by `c.get("tenant").id`, expose a typed `eventBusService` slot for downstream plugins, and optionally publish/manage subscriptions over HTTP. Every consumer rewrites the same six steps and gets the per-tenant isolation slightly wrong each time.
|
||||
|
||||
This package wraps `@suaveplan/events` as a single `definePlugin` consumer so apps get a working, instrumented, per-tenant-scoped event bus by adding one entry to their plugins array. It is the second genesis-native plugin (after `api-plugin-auth`) that follows the canonical 3-way plug-and-play contract (Zod schemas at the boundary + OpenAPI route registration + canonical service-slot registration — no `.surql` DDL because the bus is in-memory).
|
||||
|
||||
> **Naming note.** `enterprise/api-plugin-events.md` documents a *different* package (social calendar / RSVP / meetup feed) that is deferred to a later wave. This `event-bus` plugin is intentionally singular in noun and scoped to the `@suaveplan/events` library wrapper.
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package at `0.1.0`. ADDED:
|
||||
|
||||
### Plugin factory + descriptor surface
|
||||
|
||||
- `createEventBusPlugin(options: EventBusPluginOptions): PluginDescriptor<EventBusPluginConfig>` — Zod-validated factory returning a `PluginDescriptor` from `@suaveplan/api-plugin`. The descriptor's lifecycle hooks implement the canonical 6-step onInit flow.
|
||||
- `EventBusPluginOptions` (Zod-validated via `EventBusPluginOptionsSchema`): `{ interceptors?: readonly Interceptor[]; backpressure?: BackpressureOptions; idempotency?: IdempotencyOptions; routePrefix?: string (default "/event-bus"); mountRoutes?: boolean (default true); requireAuthForRoutes?: boolean (default true); tenantIsolation?: boolean (default true); maxTopicLength?: number (default 256); logger?: LoggerLike }`.
|
||||
- `EventBusPluginConfig` — the descriptor's `configSchema`-inferred shape (resolved options).
|
||||
- `BackpressureOptions` — `{ capacity?: number (default 1024); policy?: QueuePolicy (default "drop-oldest") }`.
|
||||
- `IdempotencyOptions` — `{ enabled?: boolean (default false); cacheCapacity?: number (default 4096) }`.
|
||||
|
||||
### Topic naming + per-tenant scoping
|
||||
|
||||
- `EVENT_TOPIC_PATTERN` — `/^[a-z0-9_\-:.]+$/` regex; all `publish`/`subscribe` topics MUST match.
|
||||
- `scopeTopic(topic, tenantId?)` — returns `tenant:<id>:<topic>` when `tenantId` is provided and `tenantIsolation` is enabled, else returns `topic` unchanged. Pure function; exported so test code and downstream plugins can compute the canonical scoped form.
|
||||
- `unscopeTopic(scoped, tenantId?)` — inverse of `scopeTopic`; returns the original topic, or `null` when the scoped string does not match the tenant prefix.
|
||||
|
||||
### Service-registry slot — `eventBusService`
|
||||
|
||||
- `eventBusService` registered via `ctx.registerService("eventBusService", ...)`. Surface:
|
||||
- `publish(topic: string, payload: unknown, opts?: PublishOptions): Promise<PublishResult>`
|
||||
- `subscribe(topic: string, handler: Listener<unknown>, opts?: SubscribeOptions): SubscriptionHandle`
|
||||
- `unsubscribe(handleId: string): boolean`
|
||||
- `listSubscriptions(filter?: SubscriptionFilter): readonly SubscriptionRecord[]`
|
||||
- `getRawBus(): EventBus<EventMap>` — escape hatch for downstream consumers that need direct access (e.g. `use(interceptor)` at runtime).
|
||||
- `SubscriptionHandle` — `{ id: string; topic: string; tenantId?: string; unsubscribe(): boolean }`.
|
||||
- `PublishOptions` — `{ tenantId?: string; idempotencyKey?: string; traceId?: string; signal?: AbortSignal; timeoutMs?: number }`.
|
||||
- `SubscribeOptions` — `{ tenantId?: string; idempotencyKey?: (payload: unknown) => string; once?: boolean }`.
|
||||
- `PublishResult` — re-export of `EmitResult` from `@suaveplan/events` (`{ delivered: number; errors: readonly EventsError[] }`) plus `{ dropped: boolean }` whose value reflects whether the backpressure queue overflowed.
|
||||
- ServiceRegistry module augmentation: `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { eventBusService: EventBusService } }`.
|
||||
|
||||
### Middleware
|
||||
|
||||
- `requestBusMiddleware()` — sets `c.set("eventBus", scopedHandle)` on every request. `scopedHandle` is a thin façade over `eventBusService` that auto-fills `tenantId` from `c.get("tenant")?.id`. Hono `ContextVariableMap` augmentation: `interface ContextVariableMap { eventBus: ScopedEventBusHandle }`.
|
||||
- `ScopedEventBusHandle` — same `publish`/`subscribe`/`unsubscribe`/`listSubscriptions` surface as `EventBusService` minus the explicit `tenantId` parameter (it is bound from the request context).
|
||||
|
||||
### Optional HTTP routes
|
||||
|
||||
Mounted under `routePrefix` (default `/event-bus`). Each route is gated by `requireAuth()` from `@suaveplan/api-plugin-auth` when `requireAuthForRoutes: true` (the default); otherwise they are public. When `mountRoutes: false`, no routes are mounted.
|
||||
|
||||
- `POST /event-bus/publish` — body: `{ topic, payload, idempotencyKey?, timeoutMs? }`; response: `{ delivered, dropped, errors: ErrorReportSchema[] }`. Tenant is read from `c.get("tenant")`.
|
||||
- `GET /event-bus/subscriptions` — response: `{ subscriptions: SubscriptionRecordSchema[] }`. Tenant-scoped — only subscriptions owned by the current tenant are returned.
|
||||
- `DELETE /event-bus/subscriptions/:id` — params: `{ id }`; response: `{ unsubscribed: boolean }`. Returns 404 when the id is unknown to the current tenant (404 to avoid leaking cross-tenant ids).
|
||||
|
||||
### Backpressure + idempotency wiring
|
||||
|
||||
- `BoundedQueue` from `@suaveplan/events` wraps every `publish` so a slow listener cannot block faster ones. When the queue overflows under `drop-oldest` / `drop-newest`, the returned `PublishResult.dropped` is `true` and the `event_bus.dropped_total{policy}` counter increments by 1. Under `policy: "throw"` the publish promise rejects with `EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)`.
|
||||
- The `idempotent` wrapper from `@suaveplan/events` is composed at subscribe time when `opts.idempotencyKey` (or the plugin-level `idempotency.enabled`) is configured. The keyOf function defaults to `(envelope) => envelope.idempotencyKey ?? envelope.id`; duplicates are silently dropped and counted via `event_bus.duplicates_total`.
|
||||
|
||||
### Errors + observability
|
||||
|
||||
- `EventBusError extends SuaveplanError<EventBusErrorCode>` via `defineCodes("EVENT_BUS", [...])`. Codes:
|
||||
- `INVALID_INPUT` — Zod parse of `EventBusPluginOptions` or a route body failed.
|
||||
- `INVALID_TOPIC` — topic failed `EVENT_TOPIC_PATTERN` or exceeded `maxTopicLength`.
|
||||
- `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was absent at init.
|
||||
- `ROUTE_PREFIX_COLLISION` — `routePrefix` collides with previously registered routes.
|
||||
- `SUBSCRIPTION_NOT_FOUND` — `unsubscribe(id)` called with an unknown id.
|
||||
- `BACKPRESSURE_DROPPED` — queue overflowed under `policy: "throw"`.
|
||||
- `TENANT_MISMATCH` — handler tried to operate on another tenant's subscription id.
|
||||
- `PUBLISH_FAILED` — underlying `EventBus.emitAsync` rejected (wraps the `EventsError` as `cause`).
|
||||
- OTel: counters `event_bus.published_total{outcome,topic_root}`, `event_bus.dropped_total{policy}`, `event_bus.duplicates_total`, `event_bus.subscriptions_active`, histogram `event_bus.publish_duration_ms`.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
`.`, `./plugin/plugin`, `./middleware/request-bus`, `./middleware/context-augmentation`, `./services/event-bus-service`, `./services/registry-augmentation`, `./schemas/options`, `./schemas/routes`, `./routes/build-routes`, `./topic/topic`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Cross-process / cross-node delivery.** This plugin wraps `@suaveplan/events` which is intentionally in-process. A future `api-plugin-event-bus-redis` (or similar transport) would extend it.
|
||||
- **Persistence.** Subscriptions are in-memory and per-process — restarting the API server clears them. A persistent registry belongs in a sibling plugin.
|
||||
- **WebSocket / SSE fan-out.** Operators wanting a push surface compose `api-plugin-sse` separately and call `eventBusService.subscribe` from inside that plugin.
|
||||
- **Per-event RBAC beyond the `requireAuth` route gate.** Fine-grained authorisation (e.g. "tenant admins can publish, tenant members cannot") composes `requireRole` at the application layer.
|
||||
- **Schema validation of event payloads.** Payloads are `unknown` at the bus boundary — callers Zod-validate at the producer/consumer if they want a contract.
|
||||
- **Dead-letter queue.** Listener errors are surfaced via `PublishResult.errors` and counted; a DLQ persistence plugin would be a follow-up.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This package is **genesis-native** — there is NO upstream `enterprise/api-plugin-event-bus.md` (the unrelated `enterprise/api-plugin-events.md` documents a social-calendar package shipping in a later wave). Design intent draws from:
|
||||
|
||||
- The shipped `@suaveplan/events` library (`EventBus`, `BoundedQueue`, `idempotent`, `compose` interceptor chain).
|
||||
- The shipped `@suaveplan/api-plugin` `PluginContext` + `ServiceRegistry` shape.
|
||||
- The shipped `@suaveplan/api-openapi` `definePluginRoutes` + `RouteBuilder` pattern.
|
||||
- The shipped `@suaveplan/api-plugin-auth` reference implementation of the 6-step `onInit` lifecycle (verbatim layout for tests, dirs, and the spec-conformance file).
|
||||
- The shipped `@suaveplan/api-tenant` `c.get("tenant")` augmentation.
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| createEventBusPlugin factory | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| 6-step onInit lifecycle | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Per-tenant topic scoping | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Tenant isolation enforcement | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| eventBusService registry slot | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| ScopedEventBusHandle on c.get("eventBus") | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Topic name validation | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Bounded-queue backpressure wiring | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Idempotency wrapper composition | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Optional HTTP route surface (publish / list / delete) | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Zod boundary validation for routes + options | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| OTel instruments (5 metrics) | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| 8-code EventBusError registry | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
| Subpath exports per feature | Genesis-native event-bus integration plugin — no upstream parity; design driven by `@suaveplan/events` library contract |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/api-plugin-event-bus` to enumerate. As of authoring time the package is net-new with no dependents.
|
||||
|
||||
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/api-plugin-event-bus` returning only the package itself).
|
||||
|
||||
### Catalog additions required
|
||||
|
||||
None — every catalog key used (`catalog:server`, `catalog:observability`, `catalog:`, `catalog:forms`, `catalog:build`, `catalog:testing`) already exists in the root `package.json`.
|
||||
@@ -0,0 +1,214 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-event-bus (add-api-plugin-event-bus)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-event-bus`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: createEventBusPlugin factory validates options and returns a typed PluginDescriptor
|
||||
|
||||
The package SHALL export `createEventBusPlugin(options)` which Zod-validates `options` against `EventBusPluginOptionsSchema` and returns a `PluginDescriptor<EventBusPluginConfig>` produced by `definePlugin` from `@suaveplan/api-plugin`. Invalid input SHALL throw `EventBusError(EVENT_BUS.INVALID_INPUT)` carrying the underlying `ZodError` as `cause`.
|
||||
|
||||
#### Scenario: valid options resolve defaults and return a descriptor
|
||||
|
||||
- **WHEN** `createEventBusPlugin({})` is called with no overrides
|
||||
- **THEN** the returned descriptor has `name: "event-bus"`, `version: "0.1.0"`, `defaults.routePrefix === "/event-bus"`, `defaults.mountRoutes === true`, `defaults.requireAuthForRoutes === true`, `defaults.tenantIsolation === true`, `defaults.backpressure.policy === "drop-oldest"`, and `defaults.backpressure.capacity === 1024`
|
||||
|
||||
#### Scenario: invalid options reject at the factory boundary
|
||||
|
||||
- **WHEN** `createEventBusPlugin({ routePrefix: "no-leading-slash" })` is called
|
||||
- **THEN** the call throws `EventBusError` whose `code === "EVENT_BUS.INVALID_INPUT"` and `cause` is a `ZodError`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"createEventBusPlugin resolves defaults and returns a typed PluginDescriptor"` AND `src/plugin/plugin.test.ts::"createEventBusPlugin throws EVENT_BUS.INVALID_INPUT for invalid options"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: onInit executes the canonical 6-step lifecycle
|
||||
|
||||
The package's plugin descriptor SHALL execute, in order, an `onInit(ctx)` flow that (1) resolves `app` and (optionally) `openAPIRegistry` from the host `ServiceRegistry`, (2) constructs an `EventBus` from `@suaveplan/events` and applies configured interceptors / backpressure / idempotency, (3) registers `eventBusService` in the `ServiceRegistry`, (4) auto-prefixes topics with `tenant:<id>:` whenever publishes/subscribes happen through the scoped handle, (5) installs middleware that puts a `ScopedEventBusHandle` under `c.set("eventBus", ...)`, and (6) mounts the optional HTTP routes under `routePrefix` when `mountRoutes !== false`.
|
||||
|
||||
#### Scenario: onInit registers the service slot
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs against a context carrying the `app` slot
|
||||
- **THEN** `ctx.getService("eventBusService")` returns an `EventBusService` object exposing `publish`, `subscribe`, `unsubscribe`, `listSubscriptions`, and `getRawBus`
|
||||
|
||||
#### Scenario: onInit installs the per-request middleware
|
||||
|
||||
- **WHEN** a request hits any path on the host app after `onInit` completes
|
||||
- **THEN** the handler observes `c.get("eventBus")` typed as `ScopedEventBusHandle` with `publish`/`subscribe`/`unsubscribe`/`listSubscriptions` methods bound to the request's tenant (if any)
|
||||
|
||||
#### Scenario: onInit fails when the `app` slot is missing
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs against a context with no `app` slot in `ServiceRegistry`
|
||||
- **THEN** the call throws `EventBusError(EVENT_BUS.SERVICE_NOT_AVAILABLE)` with `context.missing` containing `"app"`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers eventBusService in the ServiceRegistry"` AND `src/plugin/plugin.test.ts::"onInit installs request-bus middleware that exposes ScopedEventBusHandle"` AND `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE when the app slot is missing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-tenant topic scoping isolates publishes and subscribes
|
||||
|
||||
When `tenantIsolation` is enabled (the default), every `publish` and `subscribe` flowing through the scoped middleware SHALL prefix the topic with `tenant:<id>:` where `<id>` comes from `c.get("tenant")?.id`. A subscriber for tenant A SHALL NOT receive payloads published by tenant B for the same logical topic, and vice versa.
|
||||
|
||||
#### Scenario: tenant A publish does not reach tenant B subscriber
|
||||
|
||||
- **WHEN** tenant A's scoped handle publishes `"order.created"` and tenant B's scoped handle subscribes to `"order.created"`
|
||||
- **THEN** tenant B's handler is invoked zero times and tenant A's handler (if subscribed) is invoked exactly once
|
||||
|
||||
#### Scenario: tenant-less request does not prefix topics
|
||||
|
||||
- **WHEN** a request without a `tenant` context publishes `"system.heartbeat"` through the scoped handle
|
||||
- **THEN** the underlying bus emits `"system.heartbeat"` verbatim (no `tenant:` prefix)
|
||||
|
||||
#### Scenario: scopeTopic / unscopeTopic round-trip
|
||||
|
||||
- **WHEN** `scopeTopic("order.created", "t1")` is computed and then passed to `unscopeTopic(scoped, "t1")`
|
||||
- **THEN** the result is `"order.created"`; calling `unscopeTopic(scoped, "t2")` returns `null`
|
||||
|
||||
**Verified by:** `src/topic/topic.test.ts::"scopeTopic prefixes with tenant id when tenantId is provided"` AND `src/topic/topic.test.ts::"unscopeTopic returns null for foreign tenant prefix"` AND `tests/integration/tenant-isolation.test.ts::"tenant A publish does not reach tenant B subscriber"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Topic names are validated against EVENT_TOPIC_PATTERN
|
||||
|
||||
The package SHALL reject any `publish`/`subscribe` topic that fails the regex `/^[a-z0-9_\-:.]+$/` or exceeds the configured `maxTopicLength` (default 256). Validation SHALL apply both at the service layer (`eventBusService.publish`/`subscribe`) and at the HTTP route layer (`POST /event-bus/publish` body schema).
|
||||
|
||||
#### Scenario: invalid topic at the service layer
|
||||
|
||||
- **WHEN** `eventBusService.publish("Order Created", {})` is called
|
||||
- **THEN** the call rejects with `EventBusError(EVENT_BUS.INVALID_TOPIC)` whose `context.topic === "Order Created"`
|
||||
|
||||
#### Scenario: topic exceeding maxTopicLength
|
||||
|
||||
- **WHEN** the plugin is configured with `maxTopicLength: 8` and `publish("a".repeat(9), {})` is called
|
||||
- **THEN** the call rejects with `EventBusError(EVENT_BUS.INVALID_TOPIC)` whose `context.reason === "exceeds-max-length"`
|
||||
|
||||
**Verified by:** `src/services/event-bus-service.test.ts::"publish rejects with INVALID_TOPIC when topic violates pattern"` AND `src/services/event-bus-service.test.ts::"publish rejects with INVALID_TOPIC when topic exceeds maxTopicLength"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Bounded-queue backpressure drops on overflow and increments the dropped counter
|
||||
|
||||
Every `publish` SHALL flow through a `BoundedQueue` from `@suaveplan/events` configured with `backpressure.capacity` and `backpressure.policy`. Under `drop-oldest` / `drop-newest`, an overflowing publish SHALL resolve with `PublishResult.dropped === true` and increment `event_bus.dropped_total{policy}` by 1. Under `policy: "throw"`, the publish SHALL reject with `EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)`.
|
||||
|
||||
#### Scenario: drop-oldest overflow surfaces dropped flag and counter
|
||||
|
||||
- **WHEN** the plugin is configured with `backpressure: { capacity: 1, policy: "drop-oldest" }` and three sequential publishes occur before any drain
|
||||
- **THEN** at least one published result carries `dropped === true` and `event_bus.dropped_total{policy="drop-oldest"}` is incremented at least once
|
||||
|
||||
#### Scenario: throw policy rejects on overflow
|
||||
|
||||
- **WHEN** the plugin is configured with `backpressure: { capacity: 1, policy: "throw" }` and a second concurrent publish overflows the queue
|
||||
- **THEN** the second `publish` promise rejects with `EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)`
|
||||
|
||||
**Verified by:** `src/services/event-bus-service.test.ts::"drop-oldest overflow returns dropped=true and increments the counter"` AND `src/services/event-bus-service.test.ts::"throw policy rejects with BACKPRESSURE_DROPPED on overflow"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Idempotent subscriptions deliver duplicates exactly once
|
||||
|
||||
When a subscription is registered with `idempotencyKey` (or the plugin-level `idempotency.enabled` flag is true), repeated publishes carrying the same key SHALL invoke the handler exactly once. The `event_bus.duplicates_total` counter SHALL increment once per duplicate that was suppressed.
|
||||
|
||||
#### Scenario: duplicate publish with same idempotency key fires handler once
|
||||
|
||||
- **WHEN** the same payload is published twice with `idempotencyKey: "k1"` to a subscriber whose `idempotencyKey: (env) => env.idempotencyKey ?? env.id` was provided
|
||||
- **THEN** the handler is invoked exactly once and `event_bus.duplicates_total` is incremented exactly once
|
||||
|
||||
**Verified by:** `src/services/event-bus-service.test.ts::"idempotent subscription fires handler once for duplicate publish"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: HTTP routes mount under routePrefix and are auth-gated by default
|
||||
|
||||
When `mountRoutes !== false`, the plugin SHALL register `POST /event-bus/publish`, `GET /event-bus/subscriptions`, and `DELETE /event-bus/subscriptions/:id` on the host Hono app and on the host's `OpenAPIRegistry` via `definePluginRoutes`. When `requireAuthForRoutes` is `true` (the default), the routes SHALL be gated by `requireAuth()` middleware imported from `@suaveplan/api-plugin-auth`. When a registered route already lives under `routePrefix`, the plugin SHALL throw `EventBusError(EVENT_BUS.ROUTE_PREFIX_COLLISION)`.
|
||||
|
||||
#### Scenario: mountRoutes false skips route registration
|
||||
|
||||
- **WHEN** `createEventBusPlugin({ mountRoutes: false })` runs through `onInit`
|
||||
- **THEN** the host's `OpenAPIRegistry` has zero routes tagged `"event-bus"`
|
||||
|
||||
#### Scenario: route prefix collision throws
|
||||
|
||||
- **WHEN** the OpenAPI registry already has a `GET /event-bus/foo` route registered and `onInit` runs with the default `routePrefix`
|
||||
- **THEN** the call throws `EventBusError(EVENT_BUS.ROUTE_PREFIX_COLLISION)` whose `context.prefix === "/event-bus"`
|
||||
|
||||
#### Scenario: publish route returns delivered + dropped on a real request
|
||||
|
||||
- **WHEN** a `POST /event-bus/publish` request arrives with body `{ topic: "order.created", payload: { id: "o1" } }` and one subscriber is registered for that topic
|
||||
- **THEN** the response body satisfies `PublishResultSchema` with `delivered === 1` and `dropped === false`
|
||||
|
||||
**Verified by:** `src/routes/build-routes.test.ts::"mountRoutes=false skips route registration"` AND `src/plugin/plugin.test.ts::"onInit throws ROUTE_PREFIX_COLLISION when routes already occupy the prefix"` AND `tests/integration/http-routes.test.ts::"POST /event-bus/publish returns delivered + dropped"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: unsubscribe + listSubscriptions enforce per-tenant ownership
|
||||
|
||||
`eventBusService.unsubscribe(id)` SHALL return `false` (and `DELETE /event-bus/subscriptions/:id` SHALL return HTTP 404) when the supplied id is not owned by the calling tenant. `listSubscriptions(filter)` SHALL filter by the calling tenant when `tenantIsolation` is enabled and a tenant scope is supplied.
|
||||
|
||||
#### Scenario: tenant B cannot unsubscribe tenant A's subscription
|
||||
|
||||
- **WHEN** tenant A subscribes, obtains handle `id1`, and tenant B's scoped handle calls `unsubscribe(id1)`
|
||||
- **THEN** the call returns `false` and tenant A's subscription remains active (a subsequent publish from tenant A still delivers)
|
||||
|
||||
#### Scenario: listSubscriptions filters to the calling tenant
|
||||
|
||||
- **WHEN** tenant A has 2 subscriptions and tenant B has 1, and tenant A's scoped handle calls `listSubscriptions()`
|
||||
- **THEN** the returned array has length 2 and every record's `tenantId === "tenant-A"`
|
||||
|
||||
**Verified by:** `tests/integration/tenant-isolation.test.ts::"tenant B cannot unsubscribe tenant A's subscription"` AND `tests/integration/tenant-isolation.test.ts::"listSubscriptions filters by calling tenant"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Every EventBusError code is reachable from production paths
|
||||
|
||||
The package SHALL declare the 8-code `EVENT_BUS_CODES` registry via `defineCodes("EVENT_BUS", [...])`. Every declared code SHALL be raised from at least one production code path and SHALL be exercised by at least one test in `src/`.
|
||||
|
||||
#### Scenario: registry exposes the expected 8 codes
|
||||
|
||||
- **WHEN** `EVENT_BUS_CODES` is enumerated
|
||||
- **THEN** the object owns keys `INVALID_INPUT`, `INVALID_TOPIC`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `SUBSCRIPTION_NOT_FOUND`, `BACKPRESSURE_DROPPED`, `TENANT_MISMATCH`, and `PUBLISH_FAILED`
|
||||
|
||||
#### Scenario: every code is raised somewhere in src/
|
||||
|
||||
- **WHEN** the spec-conformance test greps `src/` for `EVENT_BUS_CODES.<code>` references
|
||||
- **THEN** every code is referenced by at least one non-test file
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"registry exposes the 8 expected codes"` AND `src/api-plugin-event-bus.spec-conformance.test.ts::"every EVENT_BUS_CODES entry is raised from a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel instruments are emitted and assertable from production paths
|
||||
|
||||
The package SHALL emit, via `@opentelemetry/api`'s global `MeterProvider`, the counters `event_bus.published_total{outcome,topic_root}`, `event_bus.dropped_total{policy}`, `event_bus.duplicates_total`, and the up/down counter `event_bus.subscriptions_active`, plus the histogram `event_bus.publish_duration_ms`. All instruments SHALL be created lazily via a `getEventBusInstruments()` singleton that is reset between tests through `resetEventBusInstruments()`.
|
||||
|
||||
#### Scenario: published_total increments after a publish
|
||||
|
||||
- **WHEN** a publish flows through `eventBusService.publish("order.created", {})`
|
||||
- **THEN** the in-memory `MeterProvider` records `event_bus.published_total{outcome="success",topic_root="order"}` incremented by 1
|
||||
|
||||
#### Scenario: subscriptions_active reflects active subscription count
|
||||
|
||||
- **WHEN** two subscriptions are added then one is removed
|
||||
- **THEN** `event_bus.subscriptions_active` is at value 1
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"published_total increments after a publish"` AND `src/otel/instruments.test.ts::"subscriptions_active tracks the live subscription count"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports enable feature-grained tree-shaking
|
||||
|
||||
The package's `exports` map SHALL list one entry per feature module so consumers can `import { ... } from "@suaveplan/api-plugin-event-bus/<feature>/<feature>"`. The exports list SHALL match the set in the proposal's "Subpath exports" section verbatim.
|
||||
|
||||
#### Scenario: every advertised subpath resolves to a real built artefact
|
||||
|
||||
- **WHEN** the `exports` map keys are enumerated and each `default` path is checked for existence after `bunx turbo build` completes
|
||||
- **THEN** every default path resolves to a real file under `./dist/`
|
||||
|
||||
**Verified by:** `src/api-plugin-event-bus.spec-conformance.test.ts::"package.json exports list every feature subpath"`
|
||||
|
||||
---
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,75 @@
|
||||
# Tasks — Add @suaveplan/api-plugin-event-bus
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Confirm package is genesis-native (no upstream `enterprise/api-plugin-event-bus.md`); `enterprise/api-plugin-events.md` is a DIFFERENT package deferred to a later wave
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` §§13, 14, 14a
|
||||
- [x] 1.3 Read `@suaveplan/events` source (`bus`, `queue`, `idempotent`, `interceptor`)
|
||||
- [x] 1.4 Read `@suaveplan/api-plugin-auth` reference implementation
|
||||
- [x] 1.5 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.6 Fill `## Source mapping` — every requirement listed in the "no source line" justification table with the canonical genesis-native reason
|
||||
- [x] 1.7 Draft `specs/api-plugin-event-bus/spec.md` with `## ADDED Requirements` only; every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Package scaffolding
|
||||
|
||||
- [x] 2.1 Create `packages/api/api-plugin-event-bus/` with `package.json`, `vite.config.ts`, `vitest.config.ts`, `tsconfig.json`, `tsconfig.lib.json`, `README.md`
|
||||
- [x] 2.2 Per-feature `src/<feature>/{<feature>.ts, <feature>.md, <feature>.test.ts, index.ts}` layout
|
||||
- [x] 2.3 `tests/setup/polyfills.ts` installs in-memory `MeterProvider`
|
||||
- [x] 2.4 Subpath exports map matches the proposal verbatim
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [x] 3.1 `error-codes/error-codes.ts` — 8 `EVENT_BUS_*` codes + `EventBusError`
|
||||
- [x] 3.2 `topic/topic.ts` — `EVENT_TOPIC_PATTERN`, `scopeTopic`, `unscopeTopic`, `assertValidTopic`
|
||||
- [x] 3.3 `schemas/options.ts` — `EventBusPluginOptionsSchema` (Zod strict)
|
||||
- [x] 3.4 `schemas/routes.ts` — `PublishBodySchema`, `PublishResultSchema`, `SubscriptionRecordSchema`, `SubscriptionListSchema`, `UnsubscribeParamsSchema`, `UnsubscribeResultSchema`
|
||||
- [x] 3.5 `services/event-bus-service.ts` — `EventBusService`, backpressure wiring, idempotency wiring, tenant scoping
|
||||
- [x] 3.6 `services/registry-augmentation.ts` — declare `eventBusService` slot
|
||||
- [x] 3.7 `middleware/request-bus.ts` — installs `c.set("eventBus", ...)` per-request handle
|
||||
- [x] 3.8 `middleware/context-augmentation.ts` — Hono `ContextVariableMap.eventBus`
|
||||
- [x] 3.9 `routes/build-routes.ts` — 3 OpenAPI routes with `requireAuth` gate
|
||||
- [x] 3.10 `otel/instruments.ts` — 5 instruments + singleton + reset helper
|
||||
- [x] 3.11 `plugin/plugin.ts` — `createEventBusPlugin` 6-step `onInit`
|
||||
- [x] 3.12 `types/types.ts` — public option / config / handle interfaces
|
||||
- [x] 3.13 `index.ts` — explicit named re-exports
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [x] 4.1 Unit tests per feature module (real `@suaveplan/events`, no `vi.mock`)
|
||||
- [x] 4.2 `src/api-plugin-event-bus.spec-conformance.test.ts` — every error code reachable; subpath exports match
|
||||
- [x] 4.3 Integration: `tests/integration/tenant-isolation.test.ts` — A publish does not reach B subscriber; ownership enforced
|
||||
- [x] 4.4 Integration: `tests/integration/http-routes.test.ts` — publish / list / delete end-to-end with auth + tenant middleware
|
||||
- [x] 4.5 Every `it()` carries `{ timeout: 5000 }` (30000 for genuinely async)
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [x] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 5.2 README links every co-located `.md` under `## Modules`
|
||||
|
||||
## 6. Pre-archive gates
|
||||
|
||||
- [x] 6.1 `cd packages/api/api-plugin-event-bus && bun run check` — Biome clean
|
||||
- [x] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-event-bus --force` — green
|
||||
- [x] 6.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-event-bus` — 100/100/100/100
|
||||
- [x] 6.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-event-bus` — exit 0
|
||||
- [x] 6.5 `bunx turbo build --filter=@suaveplan/api-plugin-event-bus --force` — green
|
||||
- [x] 6.6 `bun scripts/lint/check-test-runner-imports.ts` — zero direct vitest/bun:test imports
|
||||
|
||||
## 7. Pre-archive 9-row checklist
|
||||
|
||||
- [x] 7.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
|
||||
- [x] 7.2 Zod schemas wired at every declared boundary
|
||||
- [x] 7.3 Every error code in `error-codes.ts` thrown by at least one production path
|
||||
- [x] 7.4 Strict-mode behaviors tested both on and off where applicable
|
||||
- [x] 7.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [x] 7.6 No `vi.mock` / no `bun:test` direct imports / no `vitest` direct imports
|
||||
- [x] 7.7 No `export *` in feature barrels
|
||||
- [x] 7.8 No `biome-ignore` in `src/`
|
||||
- [x] 7.9 No retroactive tick — every box reflects work done in this change
|
||||
|
||||
## 8. Archive
|
||||
|
||||
- [x] 8.1 Fold `specs/api-plugin-event-bus/spec.md` into `openspec/specs/api-plugin-event-bus/spec.md`
|
||||
- [x] 8.2 Move `openspec/changes/add-api-plugin-event-bus/` → `openspec/archive/2026-05-19-add-api-plugin-event-bus/`
|
||||
Reference in New Issue
Block a user