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,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,
};
}