Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,94 @@
{
"name": "@suaveplan/api-events-in-memory",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./bus": {
"import": {
"types": "./dist/bus/bus.d.ts",
"default": "./dist/bus/bus.js"
}
},
"./adapter": {
"import": {
"types": "./dist/bus/bus.d.ts",
"default": "./dist/bus/bus.js"
}
},
"./history": {
"import": {
"types": "./dist/history/history.d.ts",
"default": "./dist/history/history.js"
}
},
"./options": {
"import": {
"types": "./dist/options/options.d.ts",
"default": "./dist/options/options.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,252 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "../error-codes/error-codes.js";
import {
createInMemoryEventBus,
createInMemoryEventsBus,
type InMemoryEventsBus,
} from "./bus.js";
describe("createInMemoryEventsBus", () => {
it("subscriber receives published payload", { timeout: 5000 }, () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.on("billing.invoice.created", (payload) => {
seen.push(payload);
});
const payload = { invoiceId: "inv_1", amount: 100 };
bus.publish("billing.invoice.created", payload);
expect(seen).toEqual([payload]);
});
it("history captures every publish in order", { timeout: 5000 }, () => {
const bus = createInMemoryEventsBus();
bus.publish("x", "A");
bus.publish("x", "B");
bus.publish("x", "C");
expect(bus.published("x")).toEqual(["A", "B", "C"]);
});
it("reset clears history without unregistering subscribers", {
timeout: 5000,
}, () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.on("x", (payload) => {
seen.push(payload);
});
bus.publish("x", 1);
bus.publish("x", 2);
expect(bus.published("x")).toEqual([1, 2]);
bus.reset();
expect(bus.published("x")).toEqual([]);
bus.publish("x", 3);
expect(bus.published("x")).toEqual([3]);
expect(seen).toEqual([1, 2, 3]);
});
it("published(topic) returns an empty array for unknown topics", {
timeout: 5000,
}, () => {
const bus = createInMemoryEventsBus();
expect(bus.published("never-touched")).toEqual([]);
});
it("topics() lists topics in sorted order", { timeout: 5000 }, () => {
const bus = createInMemoryEventsBus();
bus.publish("z", 1);
bus.publish("a", 1);
bus.publish("m", 1);
expect(bus.topics()).toEqual(["a", "m", "z"]);
});
it("publishAsync awaits async subscribers", { timeout: 5000 }, async () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.on("x", async (payload) => {
await new Promise<void>((resolve) => setTimeout(resolve, 1));
seen.push(payload);
});
await bus.publishAsync("x", "p");
expect(seen).toEqual(["p"]);
expect(bus.published("x")).toEqual(["p"]);
});
it("emit() satisfies the CapabilityEventBusLike contract", {
timeout: 5000,
}, async () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.on("cap", (payload) => {
seen.push(payload);
});
await bus.emit("cap", { ok: true });
expect(seen).toEqual([{ ok: true }]);
expect(bus.published("cap")).toEqual([{ ok: true }]);
});
it("once() fires exactly once", { timeout: 5000 }, () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.once("x", (payload) => {
seen.push(payload);
});
bus.publish("x", 1);
bus.publish("x", 2);
expect(seen).toEqual([1]);
});
it("off() removes a previously-registered subscriber", {
timeout: 5000,
}, () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
const listener = (payload: unknown): void => {
seen.push(payload);
};
bus.on("x", listener);
bus.publish("x", 1);
bus.off("x", listener);
bus.publish("x", 2);
expect(seen).toEqual([1]);
// History captured both regardless of subscriber detach.
expect(bus.published("x")).toEqual([1, 2]);
});
it("on() returns an unsubscribe that removes the listener", {
timeout: 5000,
}, () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
const dispose = bus.on("x", (payload) => {
seen.push(payload);
});
bus.publish("x", 1);
dispose();
bus.publish("x", 2);
expect(seen).toEqual([1]);
});
it("publish raises HISTORY_OVERFLOW when the cap is exceeded", {
timeout: 5000,
}, () => {
const bus = createInMemoryEventsBus({ maxHistoryPerTopic: 2 });
bus.publish("x", 1);
bus.publish("x", 2);
expect(() => bus.publish("x", 3)).toThrow(EventsInMemoryError);
try {
bus.publish("x", 3);
} catch (error) {
expect(error).toBeInstanceOf(EventsInMemoryError);
expect((error as EventsInMemoryError).code).toBe(
EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW
);
}
});
it("publishAsync raises HISTORY_OVERFLOW when the cap is exceeded", {
timeout: 5000,
}, async () => {
const bus = createInMemoryEventsBus({ maxHistoryPerTopic: 1 });
await bus.publishAsync("x", 1);
await expect(bus.publishAsync("x", 2)).rejects.toBeInstanceOf(
EventsInMemoryError
);
});
it("INVALID_OPTIONS thrown for bad options", { timeout: 5000 }, () => {
try {
createInMemoryEventsBus({
maxHistoryPerTopic: -1,
} as unknown as undefined);
throw new Error("should not reach");
} catch (error) {
expect(error).toBeInstanceOf(EventsInMemoryError);
expect((error as EventsInMemoryError).code).toBe(
EVENTS_IN_MEMORY_CODES.INVALID_OPTIONS
);
}
});
it("dispose() clears history and subscribers", { timeout: 5000 }, () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.on("x", (payload) => {
seen.push(payload);
});
bus.publish("x", 1);
bus.dispose();
bus.publish("x", 2);
expect(seen).toEqual([1]);
// History was reset by dispose, then re-populated by the post-dispose publish.
expect(bus.published("x")).toEqual([2]);
});
it("createInMemoryEventBus is an alias of createInMemoryEventsBus", {
timeout: 5000,
}, () => {
expect(createInMemoryEventBus).toBe(createInMemoryEventsBus);
});
it("published(topic) declared return type is readonly unknown[]", {
timeout: 5000,
}, () => {
const bus: InMemoryEventsBus = createInMemoryEventsBus();
expectTypeOf(bus.published("x")).toEqualTypeOf<readonly unknown[]>();
});
});
describe("createInMemoryEventsBus — production-bus conformance (self)", () => {
it("FIFO delivery across multiple listeners", { timeout: 5000 }, () => {
const bus = createInMemoryEventsBus();
const order: string[] = [];
bus.on("x", () => {
order.push("a");
});
bus.on("x", () => {
order.push("b");
});
bus.on("x", () => {
order.push("c");
});
bus.publish("x", 1);
expect(order).toEqual(["a", "b", "c"]);
});
it("subscriber throws are isolated (other subscribers still run)", {
timeout: 5000,
}, () => {
const bus = createInMemoryEventsBus();
const survived: number[] = [];
bus.on("x", () => {
throw new Error("first subscriber blew up");
});
bus.on("x", () => {
survived.push(1);
});
// EventBus.emit captures listener errors into the result.
// Our wrapper does not re-throw, so the second subscriber runs
// and history records the publish.
bus.publish("x", 1);
expect(survived).toEqual([1]);
expect(bus.published("x")).toEqual([1]);
});
it("round-trips complex payloads without copying", {
timeout: 5000,
}, () => {
const bus = createInMemoryEventsBus();
const seen: unknown[] = [];
bus.on("x", (payload) => {
seen.push(payload);
});
const payload = { nested: { a: 1, b: [1, 2, 3] } };
bus.publish("x", payload);
expect(seen[0]).toBe(payload);
expect(bus.published("x")[0]).toBe(payload);
});
});

View File

@@ -0,0 +1,123 @@
import { EventBus, type EventMap } from "@suaveplan/events";
import { History } from "../history/history.js";
import {
type InMemoryEventsBusOptions,
resolveInMemoryEventsBusOptions,
} from "../options/options.js";
/**
* Listener signature exposed by the in-memory bus. Matches
* `CapabilityEventBusLike.on` from `@suaveplan/api-types/capabilities`
* so the bus can be handed to API plugins as their `events` capability
* directly.
*/
export type InMemoryListener = (payload: unknown) => void | Promise<void>;
/** Disposer returned by `on()` / `subscribe()` — invoke to detach. */
export type InMemoryUnsubscribe = () => void;
/**
* In-memory events bus. Delegates pub/sub to a fresh
* `@suaveplan/events` `EventBus` (so tests inherit the production
* delivery semantics) and adds a recording layer around `publish` /
* `publishAsync` so tests can assert that side-effect publishes
* happened.
*
* Surface intentionally mirrors the API-foundation
* `CapabilityEventBusLike` (`emit` / `on`) AND the underlying
* `@suaveplan/events` `EventBus` so callers that prefer the richer
* surface (`emitAsync` with timeouts / abort / interceptor support)
* can opt in.
*/
export interface InMemoryEventsBus {
/**
* Synchronous publish. Appends to history before dispatching to
* subscribers — history captures every publish even if a
* subscriber throws.
*/
publish(topic: string, payload: unknown): void;
/**
* Async publish. Same recording semantics as `publish`; resolves
* after every subscriber has finished (or one has thrown).
*/
publishAsync(topic: string, payload: unknown): Promise<void>;
/**
* `CapabilityEventBusLike.emit` alias — same as
* {@link publishAsync}. Lets the bus satisfy the structural
* capability contract from `@suaveplan/api-types/capabilities`.
*/
emit(topic: string, payload: unknown): Promise<void>;
/** Register a subscriber. Returns a disposer. */
on(topic: string, listener: InMemoryListener): InMemoryUnsubscribe;
/** Register a one-shot subscriber. Returns a disposer. */
once(topic: string, listener: InMemoryListener): InMemoryUnsubscribe;
/** Remove a previously-registered subscriber. */
off(topic: string, listener: InMemoryListener): void;
/** Recorded payloads for `topic` in publish order. */
published(topic: string): readonly unknown[];
/** Recorded topics with at least one publish. */
topics(): readonly string[];
/** Clear publish history. Listeners stay registered. */
reset(): void;
/** Tear down: drop history AND every registered subscriber. */
dispose(): void;
}
type RecordingMap = EventMap;
/**
* Factory entry point. Returns a fresh {@link InMemoryEventsBus} backed
* by a private `@suaveplan/events` `EventBus`. Options are validated at
* the boundary; invalid shapes raise `EVENTS_IN_MEMORY.INVALID_OPTIONS`.
*/
export function createInMemoryEventsBus(
options?: InMemoryEventsBusOptions
): InMemoryEventsBus {
const resolved = resolveInMemoryEventsBusOptions(options);
const bus = new EventBus<RecordingMap>();
const history = new History(resolved.maxHistoryPerTopic);
return {
publish(topic, payload) {
history.append(topic, payload);
bus.emit(topic, payload);
},
async publishAsync(topic, payload) {
history.append(topic, payload);
await bus.emitAsync(topic, payload);
},
async emit(topic, payload) {
history.append(topic, payload);
await bus.emitAsync(topic, payload);
},
on(topic, listener) {
return bus.on(topic, listener);
},
once(topic, listener) {
return bus.once(topic, listener);
},
off(topic, listener) {
bus.off(topic, listener);
},
published(topic) {
return history.read(topic);
},
topics() {
return history.topics();
},
reset() {
history.reset();
},
dispose() {
history.reset();
bus.clear();
},
};
}
/**
* Convenience alias matching the prompt's `./adapter` subpath surface.
* `createInMemoryEventBus` (singular "Event") returns the same value
* as {@link createInMemoryEventsBus}; both spellings are stable.
*/
export const createInMemoryEventBus = createInMemoryEventsBus;

View File

@@ -0,0 +1,7 @@
export {
createInMemoryEventBus,
createInMemoryEventsBus,
type InMemoryEventsBus,
type InMemoryListener,
type InMemoryUnsubscribe,
} from "./bus.js";

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,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EVENTS_IN_MEMORY_CODES, EventsInMemoryError } from "./error-codes.js";
describe("error-codes", () => {
it("exposes a stable EVENTS_IN_MEMORY namespace", { timeout: 5000 }, () => {
expect(EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW).toBe(
"EVENTS_IN_MEMORY.HISTORY_OVERFLOW"
);
expect(EVENTS_IN_MEMORY_CODES.INVALID_OPTIONS).toBe(
"EVENTS_IN_MEMORY.INVALID_OPTIONS"
);
});
it("constructs an EventsInMemoryError that mirrors code", {
timeout: 5000,
}, () => {
const error = new EventsInMemoryError(
EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW,
"boom"
);
expect(error).toBeInstanceOf(EventsInMemoryError);
expect(error.code).toBe(EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW);
expect(error.eventsInMemoryCode).toBe(
EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW
);
expect(error.message).toBe("boom");
});
it("carries SuaveplanErrorOptions through to the base class", {
timeout: 5000,
}, () => {
const cause = new Error("under");
const error = new EventsInMemoryError(
EVENTS_IN_MEMORY_CODES.INVALID_OPTIONS,
"outer",
{ cause, context: { reason: "bad-shape" } }
);
expect(error.cause).toBe(cause);
expect(error.context).toEqual({ reason: "bad-shape" });
});
});

View File

@@ -0,0 +1,45 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-events-in-memory`.
*
* - `HISTORY_OVERFLOW` — a `publish` call would push the recorded
* payload count for a topic past the configured
* `maxHistoryPerTopic`. The strict-mode policy (this package) is to
* raise rather than drop the oldest entry: tests SHOULD see the
* overflow loudly so they can pin a higher bound or call `reset()`.
* `context.topic` carries the topic name; `context.limit` carries the
* configured cap.
* - `INVALID_OPTIONS` — the input passed to {@link createInMemoryEventsBus}
* failed Zod validation at the boundary. `cause` carries the
* underlying `z.ZodError`.
*
* Consumers branch on the string value, e.g.
* `error.code === EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW`.
*/
export const EVENTS_IN_MEMORY_CODES = defineCodes("EVENTS_IN_MEMORY", [
"HISTORY_OVERFLOW",
"INVALID_OPTIONS",
] as const);
export type EventsInMemoryErrorCode =
(typeof EVENTS_IN_MEMORY_CODES)[keyof typeof EVENTS_IN_MEMORY_CODES];
/**
* Concrete error class for the `api-events-in-memory` package. Extends
* {@link SuaveplanError} so `instanceof EventsInMemoryError` narrows
* `code` to {@link EventsInMemoryErrorCode}.
*/
export class EventsInMemoryError extends SuaveplanError<EventsInMemoryErrorCode> {
public readonly eventsInMemoryCode: EventsInMemoryErrorCode;
public constructor(
code: EventsInMemoryErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.eventsInMemoryCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
type EventsInMemoryErrorCode,
} from "./error-codes.js";

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,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "../error-codes/error-codes.js";
import { History } from "./history.js";
describe("History", () => {
it("append + read round-trips in publish order", { timeout: 5000 }, () => {
const history = new History(10);
history.append("x", "A");
history.append("x", "B");
history.append("x", "C");
expect(history.read("x")).toEqual(["A", "B", "C"]);
});
it("read() for an unknown topic returns an empty array", {
timeout: 5000,
}, () => {
const history = new History(10);
expect(history.read("never")).toEqual([]);
});
it("read() returns a snapshot copy", { timeout: 5000 }, () => {
const history = new History(10);
history.append("x", 1);
history.append("x", 2);
const first = history.read("x");
history.append("x", 3);
// First snapshot must not have observed the third append.
expect(first).toEqual([1, 2]);
expect(history.read("x")).toEqual([1, 2, 3]);
});
it("bounds history at maxHistoryPerTopic by throwing HISTORY_OVERFLOW", {
timeout: 5000,
}, () => {
const history = new History(2);
history.append("x", 1);
history.append("x", 2);
try {
history.append("x", 3);
throw new Error("should not reach");
} catch (error) {
expect(error).toBeInstanceOf(EventsInMemoryError);
const e = error as EventsInMemoryError;
expect(e.code).toBe(EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW);
expect(e.context).toEqual({ topic: "x", limit: 2 });
}
// The failed append leaves prior contents intact.
expect(history.read("x")).toEqual([1, 2]);
});
it("reset clears every topic in one call", { timeout: 5000 }, () => {
const history = new History(10);
history.append("x", 1);
history.append("y", 2);
expect(history.size()).toBe(2);
history.reset();
expect(history.size()).toBe(0);
expect(history.read("x")).toEqual([]);
expect(history.read("y")).toEqual([]);
});
it("topics() returns a sorted list of touched topics", {
timeout: 5000,
}, () => {
const history = new History(10);
history.append("z", 1);
history.append("a", 1);
history.append("m", 1);
expect(history.topics()).toEqual(["a", "m", "z"]);
});
it("size() sums payload counts across topics", { timeout: 5000 }, () => {
const history = new History(10);
history.append("x", 1);
history.append("x", 2);
history.append("y", 1);
expect(history.size()).toBe(3);
});
it("different topics are bounded independently", { timeout: 5000 }, () => {
const history = new History(1);
history.append("a", 1);
history.append("b", 1);
expect(() => history.append("a", 2)).toThrow(EventsInMemoryError);
// 'b' is still appendable up to its cap of 1.
expect(history.read("a")).toEqual([1]);
expect(history.read("b")).toEqual([1]);
});
});

View File

@@ -0,0 +1,67 @@
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "../error-codes/error-codes.js";
/**
* Per-topic publish history. Each topic owns an append-only array of
* payloads in publish order; once the array would exceed
* `maxPerTopic` the next `append` raises
* `EVENTS_IN_MEMORY.HISTORY_OVERFLOW`.
*
* The strict-throw-on-overflow policy is deliberate: test code that
* outgrows the cap SHOULD fail loudly rather than silently drop the
* oldest payload, because dropping breaks the "every publish since
* reset is queryable" guarantee that `published(topic)` returns.
*/
export class History {
private readonly entries = new Map<string, unknown[]>();
public constructor(private readonly maxPerTopic: number) {}
/** Append `payload` to the topic's history. Throws on overflow. */
public append(topic: string, payload: unknown): void {
let list = this.entries.get(topic);
if (list === undefined) {
list = [];
this.entries.set(topic, list);
}
if (list.length >= this.maxPerTopic) {
throw new EventsInMemoryError(
EVENTS_IN_MEMORY_CODES.HISTORY_OVERFLOW,
"error.events_in_memory.history_overflow",
{ context: { topic, limit: this.maxPerTopic } }
);
}
list.push(payload);
}
/**
* Snapshot of the topic's history in publish order. Returns an
* empty array (not `undefined`) when the topic has never been
* published to — callers don't have to special-case "first
* publish".
*/
public read(topic: string): readonly unknown[] {
const list = this.entries.get(topic);
if (list === undefined) return [];
return list.slice();
}
/** Clear every topic's history. Listeners stay registered. */
public reset(): void {
this.entries.clear();
}
/** Total recorded payloads across every topic. Diagnostics only. */
public size(): number {
let total = 0;
for (const list of this.entries.values()) total += list.length;
return total;
}
/** Sorted list of topics that have ever been published. */
public topics(): readonly string[] {
return Array.from(this.entries.keys()).sort();
}
}

View File

@@ -0,0 +1 @@
export { History } from "./history.js";

View File

@@ -0,0 +1,26 @@
export {
createInMemoryEventBus,
createInMemoryEventsBus,
type InMemoryEventsBus,
type InMemoryListener,
type InMemoryUnsubscribe,
} from "./bus/bus.js";
export {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
type EventsInMemoryErrorCode,
} from "./error-codes/error-codes.js";
export { History } from "./history/history.js";
export {
DEFAULT_MAX_HISTORY_PER_TOPIC,
type InMemoryEventsBusOptions,
inMemoryEventsBusOptionsSchema,
type ResolvedInMemoryEventsBusOptions,
resolveInMemoryEventsBusOptions,
} from "./options/options.js";
export {
createInMemoryEventsBusHarness,
type InMemoryEventsBusHarness,
type WaitForOptions,
type WaitForPredicate,
} from "./testing/testing.js";

View File

@@ -0,0 +1,7 @@
export {
DEFAULT_MAX_HISTORY_PER_TOPIC,
type InMemoryEventsBusOptions,
inMemoryEventsBusOptionsSchema,
type ResolvedInMemoryEventsBusOptions,
resolveInMemoryEventsBusOptions,
} from "./options.js";

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,96 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "../error-codes/error-codes.js";
import {
DEFAULT_MAX_HISTORY_PER_TOPIC,
inMemoryEventsBusOptionsSchema,
resolveInMemoryEventsBusOptions,
} from "./options.js";
describe("resolveInMemoryEventsBusOptions", () => {
it("applies the default cap when no options are passed", {
timeout: 5000,
}, () => {
const resolved = resolveInMemoryEventsBusOptions(undefined);
expect(resolved.maxHistoryPerTopic).toBe(DEFAULT_MAX_HISTORY_PER_TOPIC);
});
it("applies the default when an empty object is passed", {
timeout: 5000,
}, () => {
const resolved = resolveInMemoryEventsBusOptions({});
expect(resolved.maxHistoryPerTopic).toBe(DEFAULT_MAX_HISTORY_PER_TOPIC);
});
it("preserves explicit maxHistoryPerTopic", { timeout: 5000 }, () => {
const resolved = resolveInMemoryEventsBusOptions({
maxHistoryPerTopic: 42,
});
expect(resolved.maxHistoryPerTopic).toBe(42);
});
it("raises INVALID_OPTIONS for negative caps", { timeout: 5000 }, () => {
try {
resolveInMemoryEventsBusOptions({
maxHistoryPerTopic: -1,
});
throw new Error("should not reach");
} catch (error) {
expect(error).toBeInstanceOf(EventsInMemoryError);
expect((error as EventsInMemoryError).code).toBe(
EVENTS_IN_MEMORY_CODES.INVALID_OPTIONS
);
}
});
it("raises INVALID_OPTIONS for zero caps", { timeout: 5000 }, () => {
try {
resolveInMemoryEventsBusOptions({
maxHistoryPerTopic: 0,
});
throw new Error("should not reach");
} catch (error) {
expect(error).toBeInstanceOf(EventsInMemoryError);
}
});
it("raises INVALID_OPTIONS for non-integer caps", { timeout: 5000 }, () => {
try {
resolveInMemoryEventsBusOptions({
maxHistoryPerTopic: 1.5,
});
throw new Error("should not reach");
} catch (error) {
expect(error).toBeInstanceOf(EventsInMemoryError);
}
});
it("raises INVALID_OPTIONS for unknown keys (strict schema)", {
timeout: 5000,
}, () => {
try {
resolveInMemoryEventsBusOptions({
typoKey: 10,
} as unknown as Parameters<
typeof resolveInMemoryEventsBusOptions
>[0]);
throw new Error("should not reach");
} catch (error) {
expect(error).toBeInstanceOf(EventsInMemoryError);
expect((error as EventsInMemoryError).code).toBe(
EVENTS_IN_MEMORY_CODES.INVALID_OPTIONS
);
}
});
it("schema parses positive integer caps directly", {
timeout: 5000,
}, () => {
const parsed = inMemoryEventsBusOptionsSchema.parse({
maxHistoryPerTopic: 5,
});
expect(parsed.maxHistoryPerTopic).toBe(5);
});
});

View File

@@ -0,0 +1,66 @@
import z from "zod";
import {
EVENTS_IN_MEMORY_CODES,
EventsInMemoryError,
} from "../error-codes/error-codes.js";
/**
* Default upper bound for recorded payloads per topic. Guards against
* runaway tests that publish in a loop and never call `reset()`. 1000
* matches the design doc's "guard against runaway tests" guidance.
*/
export const DEFAULT_MAX_HISTORY_PER_TOPIC = 1000;
/**
* Options handed to {@link createInMemoryEventsBus}.
*
* `maxHistoryPerTopic` caps the recorded payload count per topic; when
* a `publish` would exceed it the bus throws
* `EVENTS_IN_MEMORY.HISTORY_OVERFLOW`. The default is high enough for
* realistic tests but low enough that pathological loops fail fast.
*/
export interface InMemoryEventsBusOptions {
readonly maxHistoryPerTopic?: number;
}
/**
* Strict-shape Zod schema for {@link InMemoryEventsBusOptions}. Extra
* keys are rejected at the boundary so typos surface as
* `EVENTS_IN_MEMORY.INVALID_OPTIONS` rather than silently being
* ignored.
*/
export const inMemoryEventsBusOptionsSchema = z
.object({
maxHistoryPerTopic: z.number().int().positive().optional(),
})
.strict();
/**
* Internal resolved shape. Every optional field has a concrete value so
* the implementation never has to re-check.
*/
export interface ResolvedInMemoryEventsBusOptions {
readonly maxHistoryPerTopic: number;
}
/**
* Parse + apply defaults. Any Zod failure is repackaged as
* `EVENTS_IN_MEMORY.INVALID_OPTIONS` so callers see the package's own
* error type rather than a raw `z.ZodError`.
*/
export function resolveInMemoryEventsBusOptions(
raw: InMemoryEventsBusOptions | undefined
): ResolvedInMemoryEventsBusOptions {
const parsed = inMemoryEventsBusOptionsSchema.safeParse(raw ?? {});
if (!parsed.success) {
throw new EventsInMemoryError(
EVENTS_IN_MEMORY_CODES.INVALID_OPTIONS,
"error.events_in_memory.invalid_options",
{ cause: parsed.error }
);
}
return {
maxHistoryPerTopic:
parsed.data.maxHistoryPerTopic ?? DEFAULT_MAX_HISTORY_PER_TOPIC,
};
}

View File

@@ -0,0 +1,6 @@
export {
createInMemoryEventsBusHarness,
type InMemoryEventsBusHarness,
type WaitForOptions,
type WaitForPredicate,
} from "./testing.js";

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.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemoryEventsBusHarness } from "./testing.js";
describe("createInMemoryEventsBusHarness", () => {
it("fast-path resolves when predicate already holds", {
timeout: 5000,
}, async () => {
const harness = createInMemoryEventsBusHarness();
harness.bus.publish("x", 1);
await harness.waitFor((bus) => bus.published("x").length === 1);
harness.dispose();
});
it("waitFor resolves after an async publish settles", {
timeout: 5000,
}, async () => {
const harness = createInMemoryEventsBusHarness();
setTimeout(() => {
harness.bus.publish("x", "later");
}, 10);
await harness.waitFor((bus) => bus.published("x").length === 1, {
intervalMs: 2,
});
expect(harness.bus.published("x")).toEqual(["later"]);
harness.dispose();
});
it("waitFor rejects on timeout", { timeout: 5000 }, async () => {
const harness = createInMemoryEventsBusHarness();
await expect(
harness.waitFor(() => false, {
timeoutMs: 20,
intervalMs: 2,
})
).rejects.toThrow(/predicate did not become true/);
harness.dispose();
});
it("harness forwards options to the bus", { timeout: 5000 }, () => {
const harness = createInMemoryEventsBusHarness({
maxHistoryPerTopic: 2,
});
harness.bus.publish("x", 1);
harness.bus.publish("x", 2);
expect(() => harness.bus.publish("x", 3)).toThrow();
harness.dispose();
});
it("dispose tears down the wrapped bus", { timeout: 5000 }, () => {
const harness = createInMemoryEventsBusHarness();
const seen: unknown[] = [];
harness.bus.on("x", (payload) => {
seen.push(payload);
});
harness.bus.publish("x", 1);
harness.dispose();
harness.bus.publish("x", 2);
expect(seen).toEqual([1]);
});
});

View File

@@ -0,0 +1,76 @@
import { createInMemoryEventsBus, type InMemoryEventsBus } from "../bus/bus.js";
import type { InMemoryEventsBusOptions } from "../options/options.js";
/**
* Predicate handed to {@link InMemoryEventsBusHarness.waitFor}. Returns
* `true` when the current bus state satisfies the caller's condition.
*/
export type WaitForPredicate = (bus: InMemoryEventsBus) => boolean;
/**
* Tunables for {@link InMemoryEventsBusHarness.waitFor}.
*/
export interface WaitForOptions {
/** Hard timeout in milliseconds. Default 1000. */
readonly timeoutMs?: number;
/** Polling interval in milliseconds. Default 5. */
readonly intervalMs?: number;
}
/**
* Test-only harness around {@link InMemoryEventsBus}. Bundles the bus
* with a `waitFor` helper that polls a caller-supplied predicate until
* it holds or a timeout elapses — the standard pattern for asserting
* an async publish has been delivered before continuing the test.
*/
export interface InMemoryEventsBusHarness {
/** The wrapped bus — same surface as {@link createInMemoryEventsBus}. */
readonly bus: InMemoryEventsBus;
/**
* Resolve when `predicate(bus)` returns `true`. Rejects with a
* descriptive `Error` if the timeout elapses first.
*/
waitFor(
predicate: WaitForPredicate,
options?: WaitForOptions
): Promise<void>;
/** Tear down the wrapped bus. Idempotent. */
dispose(): void;
}
const DEFAULT_TIMEOUT_MS = 1000;
const DEFAULT_INTERVAL_MS = 5;
/**
* Factory: build a fresh {@link InMemoryEventsBusHarness}. Forwards
* options to {@link createInMemoryEventsBus} unchanged.
*/
export function createInMemoryEventsBusHarness(
options?: InMemoryEventsBusOptions
): InMemoryEventsBusHarness {
const bus = createInMemoryEventsBus(options);
return {
bus,
async waitFor(predicate, waitOptions) {
const timeoutMs = waitOptions?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const intervalMs = waitOptions?.intervalMs ?? DEFAULT_INTERVAL_MS;
const deadline = Date.now() + timeoutMs;
// Fast path: predicate already holds.
if (predicate(bus)) return;
while (Date.now() < deadline) {
await new Promise<void>((resolve) =>
setTimeout(resolve, intervalMs)
);
if (predicate(bus)) return;
}
throw new Error(
`waitFor: predicate did not become true within ${String(
timeoutMs
)}ms`
);
},
dispose() {
bus.dispose();
},
};
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-events-in-memory",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});