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,113 @@
# `events`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The saga orchestrator publishes every step + instance transition onto
the host's `EventBus<SagaEventMap>`. Sibling plugins use those events
to drive UI updates, audit, and observability. This module pins both
the type-level `SagaEventMap` shape (so a misspelt event name is a
compile error at every subscribe site) and the Zod schemas every emit
goes through (so an internal bug in the orchestrator cannot produce a
wire-corrupt payload).
The `SagaEventEmitter` bundles those validations into eight typed
methods — one per event — that the orchestrator calls instead of
hand-writing `bus.emit(...)` calls scattered across modules.
## Features
- Typed `SagaEventMap` covering every step lifecycle event
(`saga.step.requested`, `.completed`, `.failed`, `.compensated`) and
every instance lifecycle event (`.started`, `.completed`,
`.compensated`, `.compensation_failed`).
- Strict Zod schemas (`z.object(...).strict()`) for every payload —
extra fields are rejected, missing fields are rejected.
- `SagaEventEmitter` with one method per event so callers can't mix
the wrong payload with the wrong event name.
- Schemas exported so external consumers (audit sinks, web hooks,
log shippers) can re-validate inbound payloads.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import {
SagaEventEmitter,
type SagaEventMap,
} from "@suaveplan/api-saga/events/events";
const bus = new EventBus<SagaEventMap>();
const emitter = new SagaEventEmitter(bus);
bus.on("saga.step.completed", async (payload) => {
// payload.output is typed as Readonly<Record<string, unknown>>
console.log(`step ${payload.stepName} produced`, payload.output);
});
emitter.emitStepCompleted({
sagaId: "s_1",
sagaName: "checkout",
stepName: "charge-card",
stepIndex: 0,
attempt: 1,
tenantId: "acme",
stepState: "completed",
at: "2026-05-26T00:00:00.000Z",
output: { chargeId: "ch_1" },
});
```
## Usage: Advanced
The schemas are publicly exported so a downstream component (e.g. a
webhook plugin that forwards saga events outside the host) can apply
the same validation to inbound payloads it receives from network:
```ts
import {
sagaStepFailedEventSchema,
sagaCompensationFailedEventSchema,
} from "@suaveplan/api-saga/events/events";
router.post("/webhook/saga", async (req) => {
const json = await req.json();
if ("failedStep" in json) {
const parsed = sagaCompensationFailedEventSchema.parse(json);
alertOnCall(parsed.sagaId, parsed.failedStep);
} else {
const parsed = sagaStepFailedEventSchema.parse(json);
log.warn("step failed", parsed);
}
});
```
## API Reference
- `SagaEventMap` — `Record<saga.event.name, payload>` type pinned to
the eight documented event names.
- `SagaEventEmitter` — wrapper around `EventBus<SagaEventMap>`:
- `emitStepRequested(payload)`,
- `emitStepCompleted(payload)`,
- `emitStepFailed(payload)`,
- `emitStepCompensated(payload)`,
- `emitInstanceStarted(payload)`,
- `emitInstanceCompleted(payload)`,
- `emitInstanceCompensated(payload)`,
- `emitInstanceCompensationFailed(payload)`.
- Zod schemas: `sagaStepEventSchema`, `sagaStepCompletedEventSchema`,
`sagaStepFailedEventSchema`, `sagaStepCompensatedEventSchema`,
`sagaInstanceEventSchema`, `sagaCompensationFailedEventSchema`.
## Implementation Notes
The emitter validates *outbound* payloads before pushing them onto the
bus. This is deliberate — `bus.emit` is fire-and-forget; if the
emitter trusted the caller and pushed a bad payload, every listener
would see corrupt data with no callback to recover. By failing at the
emit site the bug surfaces inside the orchestrator path that produced
the payload, not in a downstream consumer that has no visibility into
which path produced what.

View File

@@ -0,0 +1,248 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SagaEventEmitter,
sagaCompensationFailedEventSchema,
sagaInstanceEventSchema,
sagaStepCompensatedEventSchema,
sagaStepCompletedEventSchema,
sagaStepEventSchema,
sagaStepFailedEventSchema,
} from "./events.js";
import type {
SagaCompensationFailedEvent,
SagaEventMap,
SagaInstanceEvent,
SagaStepCompensatedEvent,
SagaStepCompletedEvent,
SagaStepEvent,
SagaStepFailedEvent,
} from "./events.types.js";
function baseStep(): SagaStepEvent {
return {
sagaId: "s_1",
sagaName: "checkout",
stepName: "charge",
stepIndex: 0,
attempt: 1,
tenantId: "acme",
stepState: "in_flight",
at: "2026-05-26T00:00:00.000Z",
};
}
function baseInstance(): SagaInstanceEvent {
return {
sagaId: "s_1",
sagaName: "checkout",
tenantId: "acme",
state: "running",
at: "2026-05-26T00:00:00.000Z",
};
}
describe("events — schemas", () => {
it("step event schema accepts a valid payload", { timeout: 5000 }, () => {
const parsed = sagaStepEventSchema.parse(baseStep());
expect(parsed.stepName).toBe("charge");
});
it("step event schema rejects extra fields (strict)", {
timeout: 5000,
}, () => {
const result = sagaStepEventSchema.safeParse({
...baseStep(),
extra: 1,
});
expect(result.success).toBe(false);
});
it("step-completed schema requires output", { timeout: 5000 }, () => {
const payload: SagaStepCompletedEvent = {
...baseStep(),
stepState: "completed",
output: { id: "x" },
};
expect(sagaStepCompletedEventSchema.parse(payload).output).toEqual({
id: "x",
});
const bad = sagaStepCompletedEventSchema.safeParse(baseStep());
expect(bad.success).toBe(false);
});
it("step-failed schema requires code + message", { timeout: 5000 }, () => {
const payload: SagaStepFailedEvent = {
...baseStep(),
stepState: "failed",
code: "SAGA.STEP_TIMEOUT",
message: "boom",
};
const parsed = sagaStepFailedEventSchema.parse(payload);
expect(parsed.code).toBe("SAGA.STEP_TIMEOUT");
});
it("step-compensated schema requires compensationAttempts ≥ 1", {
timeout: 5000,
}, () => {
const payload: SagaStepCompensatedEvent = {
...baseStep(),
stepState: "compensated",
compensationAttempts: 1,
};
expect(
sagaStepCompensatedEventSchema.parse(payload).compensationAttempts
).toBe(1);
expect(
sagaStepCompensatedEventSchema.safeParse({
...payload,
compensationAttempts: 0,
}).success
).toBe(false);
});
it("instance schema accepts a valid payload", { timeout: 5000 }, () => {
const parsed = sagaInstanceEventSchema.parse(baseInstance());
expect(parsed.state).toBe("running");
});
it("compensation-failed schema requires failedStep + attempts", {
timeout: 5000,
}, () => {
const payload: SagaCompensationFailedEvent = {
...baseInstance(),
state: "compensation_failed",
failedStep: "release",
compensationAttempts: 5,
};
expect(
sagaCompensationFailedEventSchema.parse(payload).failedStep
).toBe("release");
expect(
sagaCompensationFailedEventSchema.safeParse(baseInstance()).success
).toBe(false);
});
});
describe("events — emitter", () => {
function setup(): {
bus: EventBus<SagaEventMap>;
emitter: SagaEventEmitter;
} {
const bus = new EventBus<SagaEventMap>();
return { bus, emitter: new SagaEventEmitter(bus) };
}
it("emitStepRequested pushes onto the bus", { timeout: 5000 }, () => {
const { bus, emitter } = setup();
const seen: SagaStepEvent[] = [];
bus.on("saga.step.requested", (p) => {
seen.push(p);
});
emitter.emitStepRequested(baseStep());
expect(seen).toHaveLength(1);
expect(seen[0]?.stepName).toBe("charge");
});
it("emitStepCompleted requires output", { timeout: 5000 }, () => {
const { bus, emitter } = setup();
const seen: SagaStepCompletedEvent[] = [];
bus.on("saga.step.completed", (p) => {
seen.push(p);
});
emitter.emitStepCompleted({
...baseStep(),
stepState: "completed",
output: { ok: true },
});
expect(seen[0]?.output).toEqual({ ok: true });
});
it("emitStepFailed validates code + message", { timeout: 5000 }, () => {
const { bus, emitter } = setup();
const seen: SagaStepFailedEvent[] = [];
bus.on("saga.step.failed", (p) => {
seen.push(p);
});
emitter.emitStepFailed({
...baseStep(),
stepState: "failed",
code: "SAGA.STEP_TIMEOUT",
message: "x",
});
expect(seen[0]?.code).toBe("SAGA.STEP_TIMEOUT");
});
it("emitStepCompensated pushes onto the bus", { timeout: 5000 }, () => {
const { bus, emitter } = setup();
const seen: SagaStepCompensatedEvent[] = [];
bus.on("saga.step.compensated", (p) => {
seen.push(p);
});
emitter.emitStepCompensated({
...baseStep(),
stepState: "compensated",
compensationAttempts: 2,
});
expect(seen[0]?.compensationAttempts).toBe(2);
});
it("emitInstanceStarted/Completed/Compensated emit the right channel", {
timeout: 5000,
}, () => {
const { bus, emitter } = setup();
const started: SagaInstanceEvent[] = [];
const completed: SagaInstanceEvent[] = [];
const compensated: SagaInstanceEvent[] = [];
bus.on("saga.instance.started", (p) => {
started.push(p);
});
bus.on("saga.instance.completed", (p) => {
completed.push(p);
});
bus.on("saga.instance.compensated", (p) => {
compensated.push(p);
});
emitter.emitInstanceStarted(baseInstance());
emitter.emitInstanceCompleted({
...baseInstance(),
state: "completed",
});
emitter.emitInstanceCompensated({
...baseInstance(),
state: "compensated",
});
expect(started).toHaveLength(1);
expect(completed).toHaveLength(1);
expect(compensated).toHaveLength(1);
});
it("emitInstanceCompensationFailed pushes onto the bus", {
timeout: 5000,
}, () => {
const { bus, emitter } = setup();
const seen: SagaCompensationFailedEvent[] = [];
bus.on("saga.instance.compensation_failed", (p) => {
seen.push(p);
});
emitter.emitInstanceCompensationFailed({
...baseInstance(),
state: "compensation_failed",
failedStep: "release",
compensationAttempts: 5,
});
expect(seen[0]?.failedStep).toBe("release");
});
it("emit rejects a malformed payload before pushing", {
timeout: 5000,
}, () => {
const { emitter } = setup();
expect(() =>
emitter.emitStepRequested({
...baseStep(),
stepIndex: -1,
})
).toThrow();
});
});

View File

@@ -0,0 +1,149 @@
import type { EventBus } from "@suaveplan/events";
import z from "zod";
import type {
SagaCompensationFailedEvent,
SagaEventMap,
SagaInstanceEvent,
SagaStepCompensatedEvent,
SagaStepCompletedEvent,
SagaStepEvent,
SagaStepFailedEvent,
} from "./events.types.js";
const stepStateSchema = z.enum([
"pending",
"in_flight",
"completed",
"failed",
"compensating",
"compensated",
]);
const instanceStateSchema = z.enum([
"running",
"completed",
"compensating",
"compensated",
"compensation_failed",
"cancelled",
]);
const baseStepShape = {
sagaId: z.string().min(1),
sagaName: z.string().min(1),
stepName: z.string().min(1),
stepIndex: z.number().int().min(0),
attempt: z.number().int().min(1),
tenantId: z.string().min(1),
stepState: stepStateSchema,
at: z.string().min(1),
};
/** Zod schema for `saga.step.requested` / lifecycle step payload. */
export const sagaStepEventSchema = z.object(baseStepShape).strict();
/** Zod schema for `saga.step.completed`. */
export const sagaStepCompletedEventSchema = z
.object({
...baseStepShape,
output: z.record(z.string(), z.unknown()),
})
.strict();
/** Zod schema for `saga.step.failed`. */
export const sagaStepFailedEventSchema = z
.object({
...baseStepShape,
code: z.string().min(1),
message: z.string().min(1),
})
.strict();
/** Zod schema for `saga.step.compensated`. */
export const sagaStepCompensatedEventSchema = z
.object({
...baseStepShape,
compensationAttempts: z.number().int().min(1),
})
.strict();
const baseInstanceShape = {
sagaId: z.string().min(1),
sagaName: z.string().min(1),
tenantId: z.string().min(1),
state: instanceStateSchema,
at: z.string().min(1),
};
/** Zod schema for `saga.instance.*` non-failure events. */
export const sagaInstanceEventSchema = z.object(baseInstanceShape).strict();
/** Zod schema for `saga.instance.compensation_failed`. */
export const sagaCompensationFailedEventSchema = z
.object({
...baseInstanceShape,
failedStep: z.string().min(1),
compensationAttempts: z.number().int().min(1),
})
.strict();
/**
* Bound emitter helpers. The orchestrator constructs one instance per
* host and uses these typed methods so a misspelt event name is a
* compile error and a misshapen payload is rejected at the boundary.
*
* Every emit validates the payload via the matching Zod schema before
* pushing it onto the bus so a bug in one orchestrator path cannot
* silently produce wire-corrupt events.
*/
export class SagaEventEmitter {
public constructor(private readonly bus: EventBus<SagaEventMap>) {}
public emitStepRequested(payload: SagaStepEvent): void {
const parsed = sagaStepEventSchema.parse(payload);
this.bus.emit("saga.step.requested", parsed as SagaStepEvent);
}
public emitStepCompleted(payload: SagaStepCompletedEvent): void {
const parsed = sagaStepCompletedEventSchema.parse(payload);
this.bus.emit("saga.step.completed", parsed as SagaStepCompletedEvent);
}
public emitStepFailed(payload: SagaStepFailedEvent): void {
const parsed = sagaStepFailedEventSchema.parse(payload);
this.bus.emit("saga.step.failed", parsed as SagaStepFailedEvent);
}
public emitStepCompensated(payload: SagaStepCompensatedEvent): void {
const parsed = sagaStepCompensatedEventSchema.parse(payload);
this.bus.emit(
"saga.step.compensated",
parsed as SagaStepCompensatedEvent
);
}
public emitInstanceStarted(payload: SagaInstanceEvent): void {
const parsed = sagaInstanceEventSchema.parse(payload);
this.bus.emit("saga.instance.started", parsed as SagaInstanceEvent);
}
public emitInstanceCompleted(payload: SagaInstanceEvent): void {
const parsed = sagaInstanceEventSchema.parse(payload);
this.bus.emit("saga.instance.completed", parsed as SagaInstanceEvent);
}
public emitInstanceCompensated(payload: SagaInstanceEvent): void {
const parsed = sagaInstanceEventSchema.parse(payload);
this.bus.emit("saga.instance.compensated", parsed as SagaInstanceEvent);
}
public emitInstanceCompensationFailed(
payload: SagaCompensationFailedEvent
): void {
const parsed = sagaCompensationFailedEventSchema.parse(payload);
this.bus.emit(
"saga.instance.compensation_failed",
parsed as SagaCompensationFailedEvent
);
}
}

View File

@@ -0,0 +1,65 @@
import type {
SagaInstanceState,
SagaStepState,
} from "../state/state-machine.types.js";
/** Shape of a step-level event payload. */
export interface SagaStepEvent {
readonly sagaId: string;
readonly sagaName: string;
readonly stepName: string;
readonly stepIndex: number;
readonly attempt: number;
readonly tenantId: string;
readonly stepState: SagaStepState;
readonly at: string;
}
/** Step-success specialisation carrying the produced output. */
export interface SagaStepCompletedEvent extends SagaStepEvent {
readonly output: Readonly<Record<string, unknown>>;
}
/** Step-failure specialisation carrying the cause + code. */
export interface SagaStepFailedEvent extends SagaStepEvent {
readonly code: string;
readonly message: string;
}
/** Compensation outcome for a previously-completed step. */
export interface SagaStepCompensatedEvent extends SagaStepEvent {
readonly compensationAttempts: number;
}
/** Instance-lifetime payload. */
export interface SagaInstanceEvent {
readonly sagaId: string;
readonly sagaName: string;
readonly tenantId: string;
readonly state: SagaInstanceState;
readonly at: string;
}
/** Compensation-failed terminal payload. */
export interface SagaCompensationFailedEvent extends SagaInstanceEvent {
readonly failedStep: string;
readonly compensationAttempts: number;
}
/**
* Typed event map the orchestrator emits onto the host's
* `EventBus<SagaEventMap>`. The host wires its global event bus so saga
* payloads land on `bus.on("saga.step.requested", ...)`.
*/
export type SagaEventMap = {
"saga.step.requested": SagaStepEvent;
"saga.step.completed": SagaStepCompletedEvent;
"saga.step.failed": SagaStepFailedEvent;
"saga.step.compensated": SagaStepCompensatedEvent;
"saga.instance.started": SagaInstanceEvent;
"saga.instance.completed": SagaInstanceEvent;
"saga.instance.compensated": SagaInstanceEvent;
"saga.instance.compensation_failed": SagaCompensationFailedEvent;
} & Record<string, unknown>;
export type SagaEventName = keyof SagaEventMap;

View File

@@ -0,0 +1,19 @@
export {
SagaEventEmitter,
sagaCompensationFailedEventSchema,
sagaInstanceEventSchema,
sagaStepCompensatedEventSchema,
sagaStepCompletedEventSchema,
sagaStepEventSchema,
sagaStepFailedEventSchema,
} from "./events.js";
export type {
SagaCompensationFailedEvent,
SagaEventMap,
SagaEventName,
SagaInstanceEvent,
SagaStepCompensatedEvent,
SagaStepCompletedEvent,
SagaStepEvent,
SagaStepFailedEvent,
} from "./events.types.js";