first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
# @suaveplan/api-events-in-memory - In-memory EventsBus adapter for the @suaveplan/api testing kit. Wraps @suaveplan/events' EventBus with a per-topic recording layer so tests can assert that side-effect publishes happened. Exposes published(topic) and reset() helpers, options-bounded history with EVENTS_IN_MEMORY.HISTORY_OVERFLOW protection, Zod-validated options at the boundary, and a small waitFor harness for async assertions. No mocks — the real production bus delivers events to subscribers; this adapter only records publish history.
In-memory events bus adapter for the `@suaveplan/api` testing kit.
Wraps `@suaveplan/events`' production `EventBus` with a per-topic
recording layer so tests can assert that side-effect publishes
happened, without booting cross-process IPC and without mocks. The
real bus delivers events to subscribers; this adapter only adds a
queryable publish history.
<!-- begin:auto-docs -->
## Documentation tree
- [bus](./src/bus/bus.md) — `createInMemoryEventsBus` is the factory entry point of `@suaveplan/api-events-in-memory`. It returns a fresh {@link In…
- [bus](./src/bus/bus.md) — `createInMemoryEventsBus` is the factory entry point of `@suaveplan/api-events-in-memory`. It returns a fresh {@link In…
- [error-codes](./src/error-codes/error-codes.md) — The error-code registry pins the two failure modes that the in-memory events bus exposes to test code. Together they le…
- [error-codes](./src/error-codes/error-codes.md) — The error-code registry pins the two failure modes that the in-memory events bus exposes to test code. Together they le…
- [history](./src/history/history.md) — `History` is the per-topic append-only payload store that backs `bus.published(topic)`. Each `publish` invocation appen…
- [history](./src/history/history.md) — `History` is the per-topic append-only payload store that backs `bus.published(topic)`. Each `publish` invocation appen…
- [options](./src/options/options.md) — The options module is the single boundary at which user-supplied configuration enters the in-memory events bus. It owns…
- [options](./src/options/options.md) — The options module is the single boundary at which user-supplied configuration enters the in-memory events bus. It owns…
- [testing](./src/testing/testing.md) — The testing harness packages an {@link InMemoryEventsBus} with a `waitFor` helper for tests that publish asynchronously…
- [testing](./src/testing/testing.md) — The testing harness packages an {@link InMemoryEventsBus} with a `waitFor` helper for tests that publish asynchronously…
<!-- end:auto-docs -->
## Why
Per the API Plugin Foundation design (§13.2), the testing kit needs an
events bus whose publishes are observable from the test code:
```ts
import { createInMemoryEventsBus } from "@suaveplan/api-events-in-memory";
const bus = createInMemoryEventsBus();
// app code under test
bus.publish("billing.invoice.created", { invoiceId: "inv_1", amount: 100 });
// test
expect(bus.published("billing.invoice.created")).toHaveLength(1);
```
The simplest correct implementation wraps the production bus and adds
a recording layer around `publish` / `publishAsync`. Subscribers fire
as usual; ordering and FIFO invariants from the real bus are preserved.
## Install
```sh
bun add -d @suaveplan/api-events-in-memory
```
## Surface
- `./` — `createInMemoryEventsBus`, `createInMemoryEventBus` (alias),
`InMemoryEventsBus`, `History`, error codes, options.
- `./bus` (a.k.a. `./adapter`) — factory + types only.
- `./testing` — `createInMemoryEventsBusHarness` with a `waitFor`
helper for async assertions.
- `./error-codes` — `EVENTS_IN_MEMORY_CODES` + `EventsInMemoryError`.
- `./options` — Zod schema + `resolveInMemoryEventsBusOptions`.
- `./history` — standalone history ring (used by the bus internally).
## Quick reference
```ts
import { createInMemoryEventsBus } from "@suaveplan/api-events-in-memory";
import { createInMemoryEventsBusHarness } from "@suaveplan/api-events-in-memory/testing";
const bus = createInMemoryEventsBus({ maxHistoryPerTopic: 100 });
bus.on("user.signup", (payload) => doStuff(payload));
await bus.publishAsync("user.signup", { userId: "u1" });
expect(bus.published("user.signup")).toEqual([{ userId: "u1" }]);
const harness = createInMemoryEventsBusHarness();
await harness.waitFor((b) => b.published("welcome.email").length === 1);
harness.dispose();
```
## Options
| Option | Default | Notes |
| ---------------------- | ------- | ------------------------------------------------------------------ |
| `maxHistoryPerTopic` | `1000` | Strict-throw cap; `publish` raises `HISTORY_OVERFLOW` past the cap |
Invalid options raise `EVENTS_IN_MEMORY.INVALID_OPTIONS` with the
underlying `z.ZodError` on `cause`.
## Error codes
| Code | Raised when |
| ----------------------------------------- | --------------------------------------------------- |
| `EVENTS_IN_MEMORY.HISTORY_OVERFLOW` | `publish` would exceed `maxHistoryPerTopic` |
| `EVENTS_IN_MEMORY.INVALID_OPTIONS` | factory options fail Zod validation |
## Out of scope
- Persistence across process restarts — by design, in-memory.
- Multi-host fan-out — see future `api-events-redis`.
- Bounded-queue backpressure — `@suaveplan/events` already provides
this and the in-memory adapter inherits it through delegation.
## License
MIT — Suaveplan Team.

View File

@@ -0,0 +1,124 @@
# `createInMemoryEventsBus`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createInMemoryEventsBus` is the factory entry point of
`@suaveplan/api-events-in-memory`. It returns a fresh
{@link InMemoryEventsBus} whose `publish` calls are recorded in a
per-topic ring of payloads and whose subscriber delivery is delegated
to a private `@suaveplan/events` `EventBus`. Tests can both assert
"this event was published with this payload" (via `published(topic)`)
and assert "this subscriber observed the side-effect" (via the
subscriber callback) without any mocks or test doubles — the real
production bus runs underneath.
## Features
- Recording layer wraps every `publish` / `publishAsync` / `emit` call;
the payload is appended to the topic's history BEFORE dispatch so
history is still complete even if a subscriber throws.
- Strict-throw history-overflow policy via the cap-bounded
{@link History}. `published(topic)`'s "every publish since
`reset()`" guarantee survives long-running test suites.
- Subscriber surface (`on` / `once` / `off`) mirrors
`@suaveplan/events`' `EventBus`; the more powerful
`EventBus.emitAsync` semantics are exposed through `publishAsync`
(sequential await over registered listeners; same delivery order;
same error-capture model).
- `emit(topic, payload)` alias satisfies the structural
`CapabilityEventBusLike` contract from
`@suaveplan/api-types/capabilities` so the bus can be handed to API
plugins as their `events` capability without an additional adapter.
- `dispose()` clears history AND tears down every subscriber so test
isolation is straightforward — call once per test, no leaks.
- `createInMemoryEventBus` (no "s") is a stable alias matching the
prompt's `./adapter` subpath; both spellings will continue to exist.
## Usage: Basic
```ts
import { createInMemoryEventsBus } from "@suaveplan/api-events-in-memory/bus";
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.on("billing.invoice.created", (payload) => seen.push(payload));
bus.publish("billing.invoice.created", { invoiceId: "inv_1", amount: 100 });
console.log(bus.published("billing.invoice.created"));
// [{ invoiceId: "inv_1", amount: 100 }]
console.log(seen);
// [{ invoiceId: "inv_1", amount: 100 }]
bus.reset();
console.log(bus.published("billing.invoice.created")); // []
```
## Usage: Advanced
Pin the per-topic cap when the test publishes in a tight loop, then
recover by `reset()` between batches:
```ts
import { createInMemoryEventsBus } from "@suaveplan/api-events-in-memory/bus";
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "@suaveplan/api-events-in-memory/error-codes";
const bus = createInMemoryEventsBus({ maxHistoryPerTopic: 3 });
async function fanOut(count: number) {
for (let i = 0; i < count; i += 1) {
await bus.publishAsync("metrics.tick", { i });
}
}
try {
await fanOut(5);
} catch (error) {
if (
error instanceof EventsInMemoryError &&
error.code === EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW
) {
bus.reset();
await fanOut(2);
} else {
throw error;
}
}
console.log(bus.published("metrics.tick").length); // 2
bus.dispose();
```
## API Reference
- `createInMemoryEventsBus(options?: InMemoryEventsBusOptions): InMemoryEventsBus` — factory.
- `createInMemoryEventBus` — alias, same return value.
- `InMemoryEventsBus.publish(topic, payload): void` — sync publish; appends history first.
- `InMemoryEventsBus.publishAsync(topic, payload): Promise<void>` — async publish.
- `InMemoryEventsBus.emit(topic, payload): Promise<void>` — `CapabilityEventBusLike` alias.
- `InMemoryEventsBus.on(topic, listener): Unsubscribe` — register a subscriber.
- `InMemoryEventsBus.once(topic, listener): Unsubscribe` — one-shot subscriber.
- `InMemoryEventsBus.off(topic, listener): void` — remove a subscriber.
- `InMemoryEventsBus.published(topic): readonly unknown[]` — snapshot of history.
- `InMemoryEventsBus.topics(): readonly string[]` — sorted topic names.
- `InMemoryEventsBus.reset(): void` — clear history; keep subscribers.
- `InMemoryEventsBus.dispose(): void` — clear history AND subscribers.
## Implementation Notes
The underlying `EventBus` is parameterised with the open `EventMap`
shape (`Record<string, unknown>`) so the in-memory adapter can carry
any topic name without forcing the test to declare a typed map up
front. Recording happens BEFORE dispatch because the contract that
matters for tests is "this publish was attempted" — history that
drops on subscriber failure would silently hide test bugs. `emit` and
`publishAsync` share the same body; `emit` exists only so the bus
satisfies the structural `CapabilityEventBusLike` interface from
`@suaveplan/api-types`.

View File

@@ -0,0 +1,97 @@
# `EVENTS_IN_MEMORY_CODES` — Error-code registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-code registry pins the two failure modes that the in-memory
events bus exposes to test code. Together they let test authors
distinguish "you bombarded the bus past the configured ring-buffer cap"
(`HISTORY_OVERFLOW`) from "you handed the factory malformed options"
(`INVALID_OPTIONS`). Both are raised as instances of
{@link EventsInMemoryError}, which extends `SuaveplanError<EventsInMemoryErrorCode>`
so `instanceof` narrows the `code` field.
The registry is built with `defineCodes` from `@suaveplan/error` so the
codes are typed as a const-string union; mistyped branches fail
TypeScript checks and the unused-code lint gate at `bun run check`.
## Features
- Stable `EVENTS_IN_MEMORY.*` namespace — codes never change without an
OpenSpec change so downstream tests can pin them.
- `EventsInMemoryError` extends `SuaveplanError`, giving cause-chain
traversal, Zod-validated wire format, and OTel span-event adapters
for free.
- `eventsInMemoryCode` is set on the public surface as a typed alias of
the base-class `code` field; legacy code paths that read
`error.eventsInMemoryCode` directly still narrow correctly.
- Two-code surface keeps the failure-mode budget small and auditable:
one for bounded-queue overflow, one for the input-validation
boundary.
## Usage: Basic
```ts
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "@suaveplan/api-events-in-memory/error-codes";
try {
bus.publish("billing.invoice.created", { amount: 100 });
} catch (error) {
if (
error instanceof EventsInMemoryError &&
error.code === EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW
) {
console.warn("history capped — call reset() between cases");
} else {
throw error;
}
}
```
## Usage: Advanced
Both codes carry `context` for forensic logs:
```ts
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "@suaveplan/api-events-in-memory/error-codes";
try {
bus.publish("x", payload);
} catch (error) {
if (error instanceof EventsInMemoryError) {
const ctx = error.context as
| { readonly topic: string; readonly limit: number }
| undefined;
console.error("[events-in-memory]", error.code, ctx);
}
throw error;
}
```
For schema-failure paths `error.cause` carries the raw `z.ZodError`,
preserving issue-level granularity for forensic dumps without leaking
the underlying Zod runtime through the package's public surface.
## API Reference
- `EVENTS_IN_MEMORY_CODES` — frozen `{ HISTORY_OVERFLOW, INVALID_OPTIONS }` mapping.
- `EventsInMemoryErrorCode` — union of `"EVENTS_IN_MEMORY.HISTORY_OVERFLOW" | "EVENTS_IN_MEMORY.INVALID_OPTIONS"`.
- `EventsInMemoryError extends SuaveplanError<EventsInMemoryErrorCode>` — public constructor `(code, message, options?)`; exposes typed `eventsInMemoryCode` mirror of `code`.
## Implementation Notes
`defineCodes` prefixes every entry with the namespace (`EVENTS_IN_MEMORY.`)
so cross-package collisions are impossible. The class duplicates `code`
as `eventsInMemoryCode` because TypeScript's "narrowing by literal
field" works more reliably on the package-local alias than on the
generic base-class field when the same `SuaveplanError` is caught from
multiple packages in the same `try` block.

View File

@@ -0,0 +1,97 @@
# `History`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`History` is the per-topic append-only payload store that backs
`bus.published(topic)`. Each `publish` invocation appends to the
matching topic's list; `published(topic)` snapshots that list (a copy,
so external mutation can't corrupt internal state); `reset()` empties
every topic at once. The class lives in its own module because it
carries the overflow policy independently of the bus wrapper — tests
can pin overflow behaviour without booting an `EventBus`.
## Features
- Per-topic FIFO arrays — payloads are returned in publish order. No
re-ordering, no de-duplication, no transformation.
- Strict-throw overflow policy: once a topic has `maxPerTopic` entries
the next `append` raises `EVENTS_IN_MEMORY.HISTORY_OVERFLOW` with the
topic name and cap in `context`. The alternative ("drop oldest")
would silently break `published(topic)`'s contract that "every
publish since `reset()` is queryable", so the package picks the
louder failure mode.
- `read(topic)` returns `readonly unknown[]` — empty array when the
topic has never been published, never `undefined`. Callers don't
branch.
- `reset()` is `O(topics)` — fast enough to call between every test
case without measurable overhead.
- `size()` + `topics()` diagnostics ease debugging "why is this test
flaky?" sessions without leaking the internal `Map`.
## Usage: Basic
```ts
import { History } from "@suaveplan/api-events-in-memory/history";
const history = new History(100);
history.append("billing.invoice.created", { invoiceId: "inv_1" });
history.append("billing.invoice.created", { invoiceId: "inv_2" });
console.log(history.read("billing.invoice.created"));
// [{ invoiceId: "inv_1" }, { invoiceId: "inv_2" }]
```
## Usage: Advanced
Pin overflow behaviour explicitly:
```ts
import { History } from "@suaveplan/api-events-in-memory/history";
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "@suaveplan/api-events-in-memory/error-codes";
const history = new History(2);
history.append("x", 1);
history.append("x", 2);
try {
history.append("x", 3);
} catch (error) {
if (
error instanceof EventsInMemoryError &&
error.code === EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW
) {
const ctx = error.context as
| { readonly topic: string; readonly limit: number }
| undefined;
console.warn(`topic ${ctx?.topic} hit cap of ${ctx?.limit}`);
}
}
// reset() clears every topic at once
history.reset();
console.log(history.read("x")); // []
```
## API Reference
- `new History(maxPerTopic: number)` — construct an empty store.
- `append(topic, payload): void` — push payload; throws `HISTORY_OVERFLOW` at the cap.
- `read(topic): readonly unknown[]` — snapshot copy in publish order.
- `reset(): void` — drop every topic's history.
- `size(): number` — total payload count across topics (diagnostic).
- `topics(): readonly string[]` — sorted topic names (diagnostic).
## Implementation Notes
`read()` always returns a snapshot (`list.slice()`) so callers can
freeze the array, sort it, or hand it to assertion libraries without
mutating internal state. The throw-on-overflow policy is the design
doc's choice; the alternative drop-oldest could be added behind an
option in a later OpenSpec change, but doing so today would weaken the
`published(topic)` contract for every existing consumer.

View File

@@ -0,0 +1,80 @@
# `InMemoryEventsBusOptions`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The options module is the single boundary at which user-supplied
configuration enters the in-memory events bus. It owns the Zod schema,
the default for `maxHistoryPerTopic`, and the
`resolveInMemoryEventsBusOptions` helper that turns a partial caller
input into a fully-resolved internal options object. Keeping resolution
out of the factory avoids re-checking defaults on every internal call
and keeps the public Zod-validation surface in one place.
## Features
- Strict schema (`.strict()`) — typos in the options object surface as
`EVENTS_IN_MEMORY.INVALID_OPTIONS` rather than being silently
dropped. This is the difference between "the cap I set was ignored"
and "the cap key was misspelled" — the test author needs to know.
- Default `maxHistoryPerTopic = 1000` matches the design doc's
"guard against runaway tests" guidance — high enough for realistic
test suites, low enough that an accidental `while (true) publish(...)`
fails loudly.
- Public Zod-failures are repackaged as `EventsInMemoryError`. Callers
catch the package's own error type; `error.cause` still carries the
raw `z.ZodError` for forensic logs.
- `ResolvedInMemoryEventsBusOptions` exposes a concrete (non-optional)
shape downstream code can branch on without re-applying defaults.
## Usage: Basic
```ts
import { resolveInMemoryEventsBusOptions } from "@suaveplan/api-events-in-memory/options";
const resolved = resolveInMemoryEventsBusOptions(undefined);
console.log(resolved.maxHistoryPerTopic); // 1000
```
## Usage: Advanced
Combine a parsed JSON config blob with the schema directly when the
caller wants to surface validation issues at file-read time rather than
at bus-construction time:
```ts
import {
inMemoryEventsBusOptionsSchema,
resolveInMemoryEventsBusOptions,
} from "@suaveplan/api-events-in-memory/options";
const fromFile: unknown = JSON.parse(readFileSync("./bus.json", "utf8"));
const result = inMemoryEventsBusOptionsSchema.safeParse(fromFile);
if (!result.success) {
throw new Error(`bus.json failed validation: ${result.error.message}`);
}
const resolved = resolveInMemoryEventsBusOptions(result.data);
```
`maxHistoryPerTopic` must be a positive integer; `0` and negatives are
rejected so callers can't accidentally disable history entirely.
## API Reference
- `DEFAULT_MAX_HISTORY_PER_TOPIC: 1000` — applied when callers omit the field.
- `InMemoryEventsBusOptions` — interface with `maxHistoryPerTopic?: number`.
- `inMemoryEventsBusOptionsSchema` — `z.ZodObject` with `.strict()`.
- `ResolvedInMemoryEventsBusOptions` — internal resolved shape (`maxHistoryPerTopic: number`).
- `resolveInMemoryEventsBusOptions(raw?)` — parses, applies defaults, raises `EventsInMemoryError(INVALID_OPTIONS)` on failure.
## Implementation Notes
The schema rejects extra keys with `.strict()` because the alternative
("permissive, ignore unknown") empirically masks typos for months
before anyone notices. The repackaging-to-`EventsInMemoryError` step is
the boundary at which the package commits to its own error namespace —
once a Zod error has crossed `resolveInMemoryEventsBusOptions`, no
caller should ever see a raw `z.ZodError` again.

View File

@@ -0,0 +1,95 @@
# `createInMemoryEventsBusHarness`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The testing harness packages an {@link InMemoryEventsBus} with a
`waitFor` helper for tests that publish asynchronously and need to
assert on history once the publish has settled. It is the standard
shape downstream plugin tests will import to avoid hand-rolling a
poll-with-timeout loop for every assertion. Importing
`@suaveplan/api-events-in-memory/testing` returns a tightly-scoped
surface that won't leak production-only construction details into test
code.
## Features
- Fresh bus per harness — call `createInMemoryEventsBusHarness()` once
per test, `dispose()` at teardown; no shared state across cases.
- `waitFor(predicate, { timeoutMs, intervalMs })` polls until the
predicate holds or the timeout elapses. The default timeout (1 s)
and interval (5 ms) work for the overwhelming majority of unit
tests; override only when integrating with slower infra.
- Fast path: if the predicate already holds at call time the function
resolves on the next microtask without sleeping.
- Reject-on-timeout includes the requested `timeoutMs` in the message
so test failures are easy to triage.
- All options forwarded verbatim to {@link createInMemoryEventsBus}, so
the harness inherits the strict-throw history-overflow policy and
the Zod-validated options boundary.
## Usage: Basic
```ts
import { createInMemoryEventsBusHarness } from "@suaveplan/api-events-in-memory/testing";
const harness = createInMemoryEventsBusHarness();
const { bus, waitFor } = harness;
bus.on("user.signup", async () => {
await Promise.resolve();
bus.publish("welcome.email.queued", { to: "u@example.com" });
});
await bus.publishAsync("user.signup", { userId: "u1" });
await waitFor((b) => b.published("welcome.email.queued").length === 1);
console.log(bus.published("welcome.email.queued"));
harness.dispose();
```
## Usage: Advanced
Tighten the deadline for a fast unit test, or extend it for an
integration test that sits behind a real database:
```ts
import { createInMemoryEventsBusHarness } from "@suaveplan/api-events-in-memory/testing";
const harness = createInMemoryEventsBusHarness({ maxHistoryPerTopic: 50 });
try {
await harness.waitFor(
(bus) => bus.published("payment.captured").length >= 3,
{ timeoutMs: 5000, intervalMs: 25 }
);
} catch (error) {
console.error("payment.captured fan-out stalled:", error);
throw error;
} finally {
harness.dispose();
}
```
`dispose()` is idempotent — calling it twice in `afterEach` after a
mid-test failure is safe.
## API Reference
- `createInMemoryEventsBusHarness(options?): InMemoryEventsBusHarness` — factory.
- `InMemoryEventsBusHarness.bus: InMemoryEventsBus` — the wrapped bus.
- `InMemoryEventsBusHarness.waitFor(predicate, options?): Promise<void>` — poll-with-timeout.
- `InMemoryEventsBusHarness.dispose(): void` — tear down the bus.
- `WaitForPredicate = (bus: InMemoryEventsBus) => boolean`.
- `WaitForOptions = { timeoutMs?: number; intervalMs?: number }`.
## Implementation Notes
`waitFor` uses `setTimeout` rather than `setImmediate`/`queueMicrotask`
because real tests need to interleave with timers managed by the
runtime; busy-looping on microtasks starves them. The harness owns
`dispose` rather than asking the caller to call `bus.dispose()`
directly so that future versions can add additional resources
(meter providers, span recorders) without breaking the test surface.