first commit
This commit is contained in:
105
docs/api-stack/api/api-events-in-memory/README.md
Normal file
105
docs/api-stack/api/api-events-in-memory/README.md
Normal 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.
|
||||
|
||||
124
docs/api-stack/api/api-events-in-memory/src/bus/bus.md
Normal file
124
docs/api-stack/api/api-events-in-memory/src/bus/bus.md
Normal 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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user