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,87 @@
# @suaveplan/api-saga - Saga orchestrator for the Suaveplan API plugin system. Drives manifest-declared multi-plugin workflows in declared order with reverse-order compensation on failure, per-step idempotency keyed by {sagaId, stepName}, persisted state across host restarts via the host's ledger DB (suaveplan_saga_instances + suaveplan_saga_steps), capability-gated cross-tenant dispatch, typed event-bus emission of every step + instance transition, and an in-memory MeterProvider-friendly OTel surface (saga.instance span, saga.step span, saga.compensate span, saga.instances_total counter, saga.step_duration_ms histogram, saga.compensations_total counter, saga.in_flight gauge). Exposes a SagaParticipantRegistry the host populates from plugin manifests' sagas.owns[] and sagas.participatesIn[] declarations, refuses install on duplicate owners or unknown participant wiring, and ships runSagaParticipantConformance on the ./testing subpath so every step + compensate handler authored against @suaveplan/api-types' SagaParticipant contract is pinned to the same invariants.
Saga orchestrator for the Suaveplan API plugin system. Drives
manifest-declared multi-plugin workflows in declared order with
reverse-order compensation on failure, per-step idempotency keyed by
`{sagaId, stepName}`, persisted state across host restarts via the
host's ledger DB, a capability-gated cross-tenant guard, typed
event-bus emission for every step + instance transition, and an
OpenTelemetry instrument surface covering `saga.instances_total`,
`saga.step_duration_ms`, `saga.compensations_total`, and `saga.in_flight`.
API plugin system — see the umbrella change
the design document and the spec at
`api-saga/spec.md`.
<!-- begin:auto-docs -->
## Documentation tree
- [compensation](./src/compensation/compensation.md) — On step failure the saga orchestrator walks completed steps in reverse order and invokes each step's compensation handl…
- [compensation](./src/compensation/compensation.md) — On step failure the saga orchestrator walks completed steps in reverse order and invokes each step's compensation handl…
- [error-codes](./src/error-codes/error-codes.md) — Stable registry for every failure raised by `@suaveplan/api-saga`. The saga orchestrator surfaces ten distinct fault mo…
- [error-codes](./src/error-codes/error-codes.md) — Stable registry for every failure raised by `@suaveplan/api-saga`. The saga orchestrator surfaces ten distinct fault mo…
- [events](./src/events/events.md) — The saga orchestrator publishes every step + instance transition onto the host's `EventBus<SagaEventMap>`. Sibling plug…
- [events](./src/events/events.md) — The saga orchestrator publishes every step + instance transition onto the host's `EventBus<SagaEventMap>`. Sibling plug…
- [idempotency](./src/idempotency/idempotency.md) — Per design doc §9.1 the saga orchestrator MUST wrap every step invocation in an idempotency guard keyed by `{sagaId, st…
- [idempotency](./src/idempotency/idempotency.md) — Per design doc §9.1 the saga orchestrator MUST wrap every step invocation in an idempotency guard keyed by `{sagaId, st…
- [instruments](./src/instruments/instruments.md) — The saga orchestrator emits an OTel surface matching design doc §9.1: three spans (`saga.instance`, `saga.step`, `saga.…
- [instruments](./src/instruments/instruments.md) — The saga orchestrator emits an OTel surface matching design doc §9.1: three spans (`saga.instance`, `saga.step`, `saga.…
- [orchestrator](./src/orchestrator/orchestrator.md) — `createSagaOrchestrator(deps)` is the top-level factory plugin authors and host integrators consume. It binds together…
- [compensate-runner](./src/orchestrator/compensate-runner.md) — `runFullCompensation(...)` is the orchestrator's per-instance compensation driver. It bridges the generic `compensation…
- [orchestrator](./src/orchestrator/orchestrator.md) — `createSagaOrchestrator(deps)` is the top-level factory plugin authors and host integrators consume. It binds together…
- [step-runner](./src/orchestrator/step-runner.md) — `runStep(...)` is the orchestrator's per-step engine. It bundles every cross-cutting concern that fires around an actio…
- [persistence](./src/persistence/persistence.md) — `SagaPersistence` is the sole path through which the orchestrator mutates the host's saga tables (`suaveplan_saga_insta…
- [persistence](./src/persistence/persistence.md) — `SagaPersistence` is the sole path through which the orchestrator mutates the host's saga tables (`suaveplan_saga_insta…
- [recovery](./src/recovery/recovery.md) — When a host restarts, the saga orchestrator's in-memory state goes with it — but the saga itself is durable because eve…
- [recovery](./src/recovery/recovery.md) — When a host restarts, the saga orchestrator's in-memory state goes with it — but the saga itself is durable because eve…
- [registry](./src/registry/participant-registry.md) — The participant registry is the host-side compendium of every plugin that owns a saga and every plugin that participate…
- [participant-registry](./src/registry/participant-registry.md) — The participant registry is the host-side compendium of every plugin that owns a saga and every plugin that participate…
- [state](./src/state/state-machine.md) — The saga lifecycle has two coupled finite-state machines: the instance state (overall saga health) and the per-step sta…
- [state-machine](./src/state/state-machine.md) — The saga lifecycle has two coupled finite-state machines: the instance state (overall saga health) and the per-step sta…
- [tenant](./src/tenant/cross-tenant.md) — Design doc §9.1 (final paragraph) requires that a saga step whose target tenant differs from the saga initiator's tenan…
- [cross-tenant](./src/tenant/cross-tenant.md) — Design doc §9.1 (final paragraph) requires that a saga step whose target tenant differs from the saga initiator's tenan…
- [testing](./src/testing/testing.md) — `runSagaParticipantConformance(factory)` is the canonical contract suite every saga step + compensate handler in the mo…
- [testing](./src/testing/testing.md) — `runSagaParticipantConformance(factory)` is the canonical contract suite every saga step + compensate handler in the mo…
<!-- end:auto-docs -->
## Modules
- [orchestrator](./src/orchestrator/orchestrator.md) — `createSagaOrchestrator(deps)` factory + `SagaOrchestrator` public surface (`start`, `resume`, `cancel`, `listInstances`, `getInstance`).
- [orchestrator/step-runner](./src/orchestrator/step-runner.md) — per-step engine.
- [orchestrator/compensate-runner](./src/orchestrator/compensate-runner.md) — reverse-order compensation driver.
- [registry/participant-registry](./src/registry/participant-registry.md) — owner vs participant registration; validates manifest wiring.
- [persistence](./src/persistence/persistence.md) — state-machine-guarded CRUD against `suaveplan_saga_instances` + `suaveplan_saga_steps`.
- [recovery](./src/recovery/recovery.md) — boot-time replay of non-terminal instances.
- [idempotency](./src/idempotency/idempotency.md) — `@suaveplan/idempotency` wrapper keyed by `{sagaId, stepName}` with manual opt-out.
- [events](./src/events/events.md) — typed `SagaEventMap` and Zod schemas for every emitted payload.
- [compensation](./src/compensation/compensation.md) — generic reverse-walk retry runner.
- [tenant/cross-tenant](./src/tenant/cross-tenant.md) — `saga.cross-tenant` capability gate.
- [state/state-machine](./src/state/state-machine.md) — instance + step state machines.
- [instruments](./src/instruments/instruments.md) — OTel surface.
- [error-codes](./src/error-codes/error-codes.md) — 10 `SAGA.*` codes via `SagaError`.
- [testing](./src/testing/testing.md) — `runSagaParticipantConformance` published on the `./testing` subpath.
## Owner vs participant
A plugin **owns** a saga when its manifest declares `sagas.owns[]` —
the entry pins the saga name and the ordered list of `{plugin,
action, compensate}` steps. The owner is solely responsible for
choosing the step sequence.
A plugin **participates in** a saga when its manifest declares
`sagas.participatesIn: ["owner-plugin/saga-name"]` AND it exports
named `actions[name]` + `compensations[name]` handlers. The
orchestrator dispatches step events into the participant's handlers
via the `SagaParticipantRegistry`.
Two plugins MAY NOT both own the same saga name — the registry
refuses the second registration with `SAGA.DUPLICATE_OWNER`. A
participant whose `actions[name]` is missing fails at install with
`SAGA.UNKNOWN_PARTICIPANT`, before any saga can be started.
## Status
`0.1.0` — bottom-up rebuild stage. Will graduate to `1.0.0` per the
foundation umbrella's Stage 6 acceptance gate (`a multi-step saga in
api-plugin-example runs end-to-end including a compensation path`).

View File

@@ -0,0 +1,140 @@
{
"name": "@suaveplan/api-saga",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Saga orchestrator for the Suaveplan API plugin foundation. Drives manifest-declared multi-plugin workflows in declared order with reverse-order compensation on failure, per-step idempotency keyed by {sagaId, stepName}, persisted state across host restarts via the host's ledger DB (suaveplan_saga_instances + suaveplan_saga_steps), capability-gated cross-tenant dispatch, typed event-bus emission of every step + instance transition, and an in-memory MeterProvider-friendly OTel surface (saga.instance span, saga.step span, saga.compensate span, saga.instances_total counter, saga.step_duration_ms histogram, saga.compensations_total counter, saga.in_flight gauge). Exposes a SagaParticipantRegistry the host populates from plugin manifests' sagas.owns[] and sagas.participatesIn[] declarations, refuses install on duplicate owners or unknown participant wiring, and ships runSagaParticipantConformance on the ./testing subpath so every step + compensate handler authored against @suaveplan/api-types' SagaParticipant contract is pinned to the same invariants.",
"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"
}
},
"./orchestrator/orchestrator": {
"import": {
"types": "./dist/orchestrator/orchestrator.d.ts",
"default": "./dist/orchestrator/orchestrator.js"
}
},
"./registry/participant-registry": {
"import": {
"types": "./dist/registry/participant-registry.d.ts",
"default": "./dist/registry/participant-registry.js"
}
},
"./persistence/persistence": {
"import": {
"types": "./dist/persistence/persistence.d.ts",
"default": "./dist/persistence/persistence.js"
}
},
"./recovery/recovery": {
"import": {
"types": "./dist/recovery/recovery.d.ts",
"default": "./dist/recovery/recovery.js"
}
},
"./idempotency/idempotency": {
"import": {
"types": "./dist/idempotency/idempotency.d.ts",
"default": "./dist/idempotency/idempotency.js"
}
},
"./events/events": {
"import": {
"types": "./dist/events/events.d.ts",
"default": "./dist/events/events.js"
}
},
"./compensation/compensation": {
"import": {
"types": "./dist/compensation/compensation.d.ts",
"default": "./dist/compensation/compensation.js"
}
},
"./tenant/cross-tenant": {
"import": {
"types": "./dist/tenant/cross-tenant.d.ts",
"default": "./dist/tenant/cross-tenant.js"
}
},
"./state/state-machine": {
"import": {
"types": "./dist/state/state-machine.d.ts",
"default": "./dist/state/state-machine.js"
}
},
"./instruments/instruments": {
"import": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "bun test --timeout=30000",
"test:watch": "bun test --watch",
"test:coverage": "bun test --timeout=30000 --coverage --coverage-reporter=lcov --coverage-dir=coverage",
"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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-host": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/idempotency": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "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,126 @@
# `compensation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
On step failure the saga orchestrator walks completed steps in reverse
order and invokes each step's compensation handler. Per design doc
§9.1 those handlers MUST be idempotent + retryable up to a configured
budget (`maxCompensationAttempts`, default 5). This module owns that
retry loop and the terminal-failure shape — the orchestrator threads
a per-step `run(attempt)` callback that bundles "actually invoke the
plugin's compensate handler, update persistence, emit the event"
into a single closure the runner can retry.
The runner intentionally knows nothing about persistence or events —
it owns ONLY the retry loop and the reverse-walk. This keeps the
module unit-testable without any infrastructure and lets the
orchestrator reuse it for the recovery replay path (which also needs
"walk completed steps in reverse, retry up to N").
## Features
- Reverse-order walk of completed steps.
- Per-step retry loop up to `maxAttempts` (default 5).
- `compensation_failed` returned on the first step whose retry budget
is exhausted; remaining steps are NOT attempted (the saga is
already in a broken-rollback terminal state).
- `raiseCompensationExhausted(...)` builder for the terminal error,
chaining the underlying cause.
- Configurable per-saga `maxAttempts` so a saga with very expensive
compensation handlers can opt into a smaller budget.
- Stateless module — every call yields a fresh `CompensationRunResult`.
## Usage: Basic
```ts
import {
runCompensation,
} from "@suaveplan/api-saga/compensation/compensation";
const result = await runCompensation({
completedSteps: [
{ stepIndex: 0, stepName: "charge", run: async () => refund() },
{ stepIndex: 1, stepName: "ship", run: async () => cancelShipment() },
],
});
if (result.outcome === "compensated") {
// every step's compensate succeeded
}
```
## Usage: Advanced
Plug in per-saga retry budget and per-step instrumentation. The
runner's `run(attempt)` callback receives the current attempt number
so it can update telemetry and the step row before the actual
compensate invocation:
```ts
import {
raiseCompensationExhausted,
runCompensation,
} from "@suaveplan/api-saga";
const result = await runCompensation({
maxAttempts: 3,
completedSteps: completedRows.map((row) => ({
stepIndex: row.stepIndex,
stepName: row.name,
run: async (attempt) => {
instruments.compensationsTotal.add(1, {
name: sagaName,
step: row.name,
});
persistence.transitionStep(sagaId, row.stepIndex, "compensating");
await participant.compensate(
{ input: row.payload.input, output: row.payload.output },
ctx
);
persistence.transitionStep(sagaId, row.stepIndex, "compensated");
},
})),
});
if (result.outcome === "compensation_failed") {
raiseCompensationExhausted({
sagaId,
sagaName,
stepName: result.failedStep ?? "?",
attempts: result.perStep[result.perStep.length - 1]?.attempts ?? 0,
cause: new Error("see prior errors"),
});
}
```
## API Reference
- `DEFAULT_MAX_COMPENSATION_ATTEMPTS = 5`.
- `CompensableStep { stepIndex, stepName, run(attempt) }` — input
contract.
- `CompensationOutcome` — per-step union of `compensated | failed`.
- `CompensationRunResult` — overall outcome:
`{ outcome: "compensated" | "compensation_failed", perStep, failedStep? }`.
- `runCompensation({ completedSteps, maxAttempts? })` — entry point.
- `raiseCompensationExhausted({ sagaId, sagaName, stepName, attempts,
cause }): never` — terminal error builder.
## Implementation Notes
The reverse-walk runs sequentially rather than in parallel. Design
§9.1 explicitly calls out "reverse order" because step N's
compensation may depend on side-effects that step N+1's compensation
removes (e.g. a fulfilment-cancel that walks an order id that the
order-cancel step is about to delete). Sequencing keeps that
guarantee.
The first compensation-failed step short-circuits the remaining
walk by design — once one step's compensation is unrecoverable, the
saga is already in a broken state and continuing would risk
double-undoing earlier steps if a later compensation succeeded in
isolation but referenced state the failed compensation had already
mutated.

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import {
DEFAULT_MAX_COMPENSATION_ATTEMPTS,
raiseCompensationExhausted,
runCompensation,
} from "./compensation.js";
describe("compensation — runCompensation", () => {
it("exports the documented default budget", { timeout: 5000 }, () => {
expect(DEFAULT_MAX_COMPENSATION_ATTEMPTS).toBe(5);
});
it("returns compensated with empty step list", {
timeout: 5000,
}, async () => {
const result = await runCompensation({ completedSteps: [] });
expect(result.outcome).toBe("compensated");
expect(result.perStep).toEqual([]);
});
it("failure on step 3 compensates steps 2 and 1 in reverse order", {
timeout: 5000,
}, async () => {
const order: string[] = [];
const result = await runCompensation({
completedSteps: [
{
stepIndex: 0,
stepName: "step-1",
run: async () => {
order.push("step-1");
},
},
{
stepIndex: 1,
stepName: "step-2",
run: async () => {
order.push("step-2");
},
},
],
});
expect(result.outcome).toBe("compensated");
expect(order).toEqual(["step-2", "step-1"]);
});
it("retries a flaky compensation up to maxAttempts", {
timeout: 5000,
}, async () => {
let attempts = 0;
const result = await runCompensation({
completedSteps: [
{
stepIndex: 0,
stepName: "flaky",
run: async (attempt) => {
attempts = attempt;
if (attempt < 3) {
throw new Error("nope");
}
},
},
],
});
expect(result.outcome).toBe("compensated");
expect(attempts).toBe(3);
expect(result.perStep[0]?.attempts).toBe(3);
});
it("compensation max-attempts exceeded yields compensation_failed", {
timeout: 5000,
}, async () => {
const result = await runCompensation({
maxAttempts: 2,
completedSteps: [
{
stepIndex: 0,
stepName: "broken",
run: async () => {
throw new Error("always broken");
},
},
],
});
expect(result.outcome).toBe("compensation_failed");
expect(result.failedStep).toBe("broken");
expect(result.perStep[0]?.ok).toBe(false);
expect(result.perStep[0]?.attempts).toBe(2);
});
it("captures non-Error throws by wrapping in Error", {
timeout: 5000,
}, async () => {
const result = await runCompensation({
maxAttempts: 1,
completedSteps: [
{
stepIndex: 0,
stepName: "primitive-throw",
run: async () => {
// Intentional non-Error throw to exercise the
// Error-wrapping branch in compensateOne.
throw "not an error" as unknown as Error;
},
},
],
});
expect(result.outcome).toBe("compensation_failed");
});
it("rejects maxAttempts < 1", { timeout: 5000 }, async () => {
try {
await runCompensation({ maxAttempts: 0, completedSteps: [] });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.COMPENSATION_FAILED);
}
}
});
it("stops at first unrecoverable step (later steps not attempted)", {
timeout: 5000,
}, async () => {
const attempts: string[] = [];
const result = await runCompensation({
maxAttempts: 1,
completedSteps: [
{
stepIndex: 0,
stepName: "earlier",
run: async () => {
attempts.push("earlier");
},
},
{
stepIndex: 1,
stepName: "later",
run: async () => {
attempts.push("later");
throw new Error("broken");
},
},
],
});
expect(result.outcome).toBe("compensation_failed");
expect(attempts).toEqual(["later"]);
expect(result.failedStep).toBe("later");
});
});
describe("compensation — raiseCompensationExhausted", () => {
it("throws COMPENSATION_MAX_ATTEMPTS_EXCEEDED with cause chained", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
try {
raiseCompensationExhausted({
sagaId: "s_1",
sagaName: "checkout",
stepName: "charge",
attempts: 5,
cause,
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(
SAGA_CODES.COMPENSATION_MAX_ATTEMPTS_EXCEEDED
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({
sagaId: "s_1",
sagaName: "checkout",
stepName: "charge",
attempts: 5,
});
}
}
});
});

View File

@@ -0,0 +1,130 @@
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type {
CompensationOutcome,
CompensationRunResult,
} from "./compensation.types.js";
import { DEFAULT_MAX_COMPENSATION_ATTEMPTS } from "./compensation.types.js";
/** Per-step compensation invocation handle the runner calls. */
export interface CompensableStep {
readonly stepIndex: number;
readonly stepName: string;
/** Invoked once per attempt; resolves on success, throws on failure. */
run(attempt: number): Promise<void>;
}
/**
* Run compensation across every completed step in reverse order. Each
* step is retried up to `maxAttempts` (default
* {@link DEFAULT_MAX_COMPENSATION_ATTEMPTS}). On the first step whose
* retry budget is exhausted, the runner stops and returns
* `compensation_failed` so the orchestrator can transition the saga
* into `compensation_failed`.
*
* Successful compensation across every step returns `compensated`.
*
* The runner is intentionally generic — it knows nothing about
* `SagaPersistence`, the event bus, or instruments. The orchestrator
* is responsible for threading those side-effects via the per-step
* `run` callback so the compensation runner stays unit-testable
* without infrastructure.
*/
export async function runCompensation(args: {
readonly completedSteps: readonly CompensableStep[];
readonly maxAttempts?: number;
}): Promise<CompensationRunResult> {
const maxAttempts = args.maxAttempts ?? DEFAULT_MAX_COMPENSATION_ATTEMPTS;
if (maxAttempts < 1) {
throw new SagaError(
SAGA_CODES.COMPENSATION_FAILED,
"compensation: maxAttempts must be >= 1",
{ context: { maxAttempts } }
);
}
const reverse = [...args.completedSteps].reverse();
const perStep: Array<{
stepIndex: number;
stepName: string;
attempts: number;
ok: boolean;
}> = [];
for (const step of reverse) {
const outcome = await compensateOne(step, maxAttempts);
if (outcome.outcome === "compensated") {
perStep.push({
stepIndex: step.stepIndex,
stepName: step.stepName,
attempts: outcome.attempts,
ok: true,
});
continue;
}
perStep.push({
stepIndex: step.stepIndex,
stepName: step.stepName,
attempts: outcome.attempts,
ok: false,
});
return {
outcome: "compensation_failed",
perStep,
failedStep: step.stepName,
};
}
return { outcome: "compensated", perStep };
}
async function compensateOne(
step: CompensableStep,
maxAttempts: number
): Promise<CompensationOutcome> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await step.run(attempt);
return { outcome: "compensated", attempts: attempt };
} catch (cause) {
lastError =
cause instanceof Error ? cause : new Error(String(cause));
}
}
return {
outcome: "failed",
attempts: maxAttempts,
cause:
lastError ??
new SagaError(
SAGA_CODES.COMPENSATION_FAILED,
"compensation: no error captured"
),
};
}
/**
* Raise the terminal compensation error after the runner gave up. The
* orchestrator calls this so the operator sees `SAGA.COMPENSATION_MAX_ATTEMPTS_EXCEEDED`
* paired with the actual root cause as the chained `cause`.
*/
export function raiseCompensationExhausted(args: {
readonly sagaId: string;
readonly sagaName: string;
readonly stepName: string;
readonly attempts: number;
readonly cause: Error;
}): never {
throw new SagaError(
SAGA_CODES.COMPENSATION_MAX_ATTEMPTS_EXCEEDED,
`saga: compensation for step '${args.stepName}' exhausted after ${args.attempts} attempts`,
{
cause: args.cause,
context: {
sagaId: args.sagaId,
sagaName: args.sagaName,
stepName: args.stepName,
attempts: args.attempts,
},
}
);
}
export { DEFAULT_MAX_COMPENSATION_ATTEMPTS } from "./compensation.types.js";

View File

@@ -0,0 +1,23 @@
/** Default per-saga compensation retry budget. */
export const DEFAULT_MAX_COMPENSATION_ATTEMPTS = 5 as const;
/** Outcome of a single compensation invocation. */
export type CompensationOutcome =
| { readonly outcome: "compensated"; readonly attempts: number }
| {
readonly outcome: "failed";
readonly attempts: number;
readonly cause: Error;
};
/** Outcome of running compensation across every completed step. */
export interface CompensationRunResult {
readonly outcome: "compensated" | "compensation_failed";
readonly perStep: ReadonlyArray<{
readonly stepIndex: number;
readonly stepName: string;
readonly attempts: number;
readonly ok: boolean;
}>;
readonly failedStep?: string;
}

View File

@@ -0,0 +1,10 @@
export type { CompensableStep } from "./compensation.js";
export {
DEFAULT_MAX_COMPENSATION_ATTEMPTS,
raiseCompensationExhausted,
runCompensation,
} from "./compensation.js";
export type {
CompensationOutcome,
CompensationRunResult,
} from "./compensation.types.js";

View File

@@ -0,0 +1,107 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable registry for every failure raised by `@suaveplan/api-saga`. The
saga orchestrator surfaces ten distinct fault modes — manifest wiring,
lifecycle guard rails, runtime invariants, compensation outcomes — and
each one MUST be addressable by a stable string code so operators can
write alerts and consumers can branch on `err.code` rather than parsing
messages.
This module owns the `SAGA.*` namespace via `defineCodes("SAGA", [...])`
from `@suaveplan/error` and pairs it with a `SagaError` subclass of
`SuaveplanError` so `instanceof SagaError && err.code === ...` switches
stay exhaustive. The conformance script
(`bun scripts/conformance.ts @suaveplan/api-saga`) asserts that
every code in this registry is thrown from at least one non-test source
path.
## Features
- Ten canonical `SAGA.*` codes covering lifecycle, runtime, persistence,
and compensation outcomes.
- `SagaError` class specialised on the `SagaCode` literal union — every
catch site can narrow without `any`.
- Full integration with the foundation error surface: cause chains,
redaction, OTel span-event adapter, and the Zod wire format from
`@suaveplan/error`.
## Usage: Basic
```ts
import {
SAGA_CODES,
SagaError,
} from "@suaveplan/api-saga/error-codes/error-codes";
throw new SagaError(
SAGA_CODES.UNKNOWN_NAME,
"saga: orchestrator.start called with an unregistered saga name",
{ context: { name: "X" } }
);
```
## Usage: Advanced
Branch on the specific reason inside a catch block — the literal union
keeps the switch exhaustive at compile time:
```ts
import { SAGA_CODES, SagaError } from "@suaveplan/api-saga";
try {
await orchestrator.start("checkout", input, ctx);
} catch (err) {
if (!(err instanceof SagaError)) {
throw err;
}
switch (err.code) {
case SAGA_CODES.UNKNOWN_NAME:
case SAGA_CODES.UNKNOWN_PARTICIPANT:
// wiring fault — surface to the operator
break;
case SAGA_CODES.CROSS_TENANT_DENIED:
// 403 to the caller
break;
case SAGA_CODES.COMPENSATION_MAX_ATTEMPTS_EXCEEDED:
case SAGA_CODES.COMPENSATION_FAILED:
// page on-call — saga left in compensation_failed state
break;
case SAGA_CODES.STEP_TIMEOUT:
case SAGA_CODES.INVALID_STATE_TRANSITION:
case SAGA_CODES.INSTANCE_NOT_FOUND:
case SAGA_CODES.PERSISTENCE_FAILED:
case SAGA_CODES.DUPLICATE_OWNER:
// generic 500
break;
}
}
```
## API Reference
- `SAGA_CODES` — frozen record mapping each reason name to its
`"SAGA.<REASON>"` stable string. Keys: `UNKNOWN_NAME`,
`UNKNOWN_PARTICIPANT`, `STEP_TIMEOUT`, `COMPENSATION_FAILED`,
`INVALID_STATE_TRANSITION`, `CROSS_TENANT_DENIED`, `DUPLICATE_OWNER`,
`INSTANCE_NOT_FOUND`, `PERSISTENCE_FAILED`,
`COMPENSATION_MAX_ATTEMPTS_EXCEEDED`.
- `SagaCode` — union of every value in `SAGA_CODES`.
- `SagaError` — `class SagaError extends SuaveplanError<SagaCode>`.
Constructor takes `(code, message, options?)` where `options` is the
standard `SuaveplanErrorOptions` bag (cause, context, traceId, clock).
## Implementation Notes
The codes are exported as a `const` tuple to `defineCodes`, so
`(typeof SAGA_CODES)[keyof typeof SAGA_CODES]` derives the literal
union with no manual sync between the runtime values and the type. The
side-effect import of `./error-codes.js` from the package barrel
(`src/index.ts`) is what registers the codes with the
process-wide metadata map shipped by `@suaveplan/error`; subpath-only
consumers therefore get the registration for free.

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SAGA_CODES, SagaError } from "./error-codes.js";
describe("error-codes", () => {
it("registers every documented SAGA.* code", { timeout: 5000 }, () => {
expect(SAGA_CODES.UNKNOWN_NAME).toBe("SAGA.UNKNOWN_NAME");
expect(SAGA_CODES.UNKNOWN_PARTICIPANT).toBe("SAGA.UNKNOWN_PARTICIPANT");
expect(SAGA_CODES.STEP_TIMEOUT).toBe("SAGA.STEP_TIMEOUT");
expect(SAGA_CODES.COMPENSATION_FAILED).toBe("SAGA.COMPENSATION_FAILED");
expect(SAGA_CODES.INVALID_STATE_TRANSITION).toBe(
"SAGA.INVALID_STATE_TRANSITION"
);
expect(SAGA_CODES.CROSS_TENANT_DENIED).toBe("SAGA.CROSS_TENANT_DENIED");
expect(SAGA_CODES.DUPLICATE_OWNER).toBe("SAGA.DUPLICATE_OWNER");
expect(SAGA_CODES.INSTANCE_NOT_FOUND).toBe("SAGA.INSTANCE_NOT_FOUND");
expect(SAGA_CODES.PERSISTENCE_FAILED).toBe("SAGA.PERSISTENCE_FAILED");
expect(SAGA_CODES.COMPENSATION_MAX_ATTEMPTS_EXCEEDED).toBe(
"SAGA.COMPENSATION_MAX_ATTEMPTS_EXCEEDED"
);
});
it("constructs a SagaError instance carrying code + name", {
timeout: 5000,
}, () => {
const err = new SagaError(SAGA_CODES.UNKNOWN_NAME, "boom");
expect(err).toBeInstanceOf(SagaError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("SAGA.UNKNOWN_NAME");
expect(err.name).toBe("SagaError");
});
it("attaches context and forwards options to the base error", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: write failed",
{ cause, context: { sagaId: "s_1" }, traceId: "trace-1" }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ sagaId: "s_1" });
expect(err.traceId).toBe("trace-1");
});
it("exposes exactly ten distinct codes", { timeout: 5000 }, () => {
const values = Object.values(SAGA_CODES);
expect(values.length).toBe(10);
expect(new Set(values).size).toBe(10);
});
});

View File

@@ -0,0 +1,51 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable registry of every saga-orchestrator failure code. The 10 codes
* mirror the failure modes called out in design doc §9.1 and the proposal:
*
* - lifecycle / wiring (`UNKNOWN_NAME`, `UNKNOWN_PARTICIPANT`,
* `DUPLICATE_OWNER`, `INSTANCE_NOT_FOUND`),
* - runtime guard rails (`STEP_TIMEOUT`, `INVALID_STATE_TRANSITION`,
* `CROSS_TENANT_DENIED`, `PERSISTENCE_FAILED`),
* - compensation (`COMPENSATION_FAILED`,
* `COMPENSATION_MAX_ATTEMPTS_EXCEEDED`).
*
* Every code in this registry is thrown from at least one non-test source
* path; `bun scripts/openspec-conformance.ts @suaveplan/api-saga` asserts
* this invariant.
*/
export const SAGA_CODES = defineCodes("SAGA", [
"UNKNOWN_NAME",
"UNKNOWN_PARTICIPANT",
"STEP_TIMEOUT",
"COMPENSATION_FAILED",
"INVALID_STATE_TRANSITION",
"CROSS_TENANT_DENIED",
"DUPLICATE_OWNER",
"INSTANCE_NOT_FOUND",
"PERSISTENCE_FAILED",
"COMPENSATION_MAX_ATTEMPTS_EXCEEDED",
] as const);
/** Stable union of every saga error reason. */
export type SagaCode = (typeof SAGA_CODES)[keyof typeof SAGA_CODES];
/**
* Typed error specialised on the `SAGA.*` namespace. Inherits the full
* cause-chain + redaction surface from {@link SuaveplanError}; the
* generic constraint pins `code` to one of the 10 SAGA reasons so
* downstream `instanceof SagaError && err.code === ...` switches stay
* exhaustive.
*/
export class SagaError extends SuaveplanError<SagaCode> {
public constructor(
code: SagaCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "SagaError";
}
}

View File

@@ -0,0 +1,2 @@
export type { SagaCode } from "./error-codes.js";
export { SAGA_CODES, SagaError } from "./error-codes.js";

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";

View File

@@ -0,0 +1,95 @@
# `idempotency`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per design doc §9.1 the saga orchestrator MUST wrap every step
invocation in an idempotency guard keyed by `{sagaId, stepName}` so a
replayed step (e.g. after a host restart or a transient
network-induced retry) returns the cached outcome without re-entering
the handler. This module is the thin wrapper layered over the
foundation `@suaveplan/idempotency` package so the orchestrator does
not have to know the underlying guard's API.
The wrapper also honours the per-action `idempotency: "manual"`
opt-out — the participant registration can declare that the action
handles its own dedup, in which case the wrapper invokes the handler
directly and the foundation guard is bypassed. This matches the
"Idempotency contract" bullet under design §9.1.
## Features
- `stepIdempotencyKey(sagaId, stepName)` — stable composite key
shared between orchestrator and recovery so a replayed step keeps
hashing to the same record.
- `SagaIdempotencyWrapper.run(...)` — single entry point the
orchestrator funnels every step invocation through.
- Honours the per-action `idempotency: "manual"` opt-out: the
wrapper short-circuits before the guard runs.
- Composable with any `IdempotencyGuard` shape — the foundation
package ships memory, database, and Redis store adapters; the
saga wrapper picks whichever one the host has wired.
## Usage: Basic
```ts
import { IdempotencyGuard, MemoryIdempotencyStore } from "@suaveplan/idempotency";
import { SagaIdempotencyWrapper } from "@suaveplan/api-saga/idempotency/idempotency";
const guard = new IdempotencyGuard({ store: new MemoryIdempotencyStore() });
const wrapper = new SagaIdempotencyWrapper(guard);
const output = await wrapper.run("s_1", "charge", "auto", async () => {
return { chargeId: "ch_1" };
});
```
## Usage: Advanced
Compose the wrapper with the orchestrator step-dispatch path. The
wrapper is intentionally generic over the result type so a typed
step handler keeps its return type through the wrap:
```ts
import { SagaIdempotencyWrapper } from "@suaveplan/api-saga";
async function dispatchStep<T extends Readonly<Record<string, unknown>>>(
sagaId: string,
step: StepDeclaration,
wrapper: SagaIdempotencyWrapper,
handler: () => Promise<T>
): Promise<T> {
return wrapper.run(sagaId, step.action, registration.idempotency, handler);
}
```
A retried step whose first attempt completed under the same
`{sagaId, stepName}` key returns the cached outcome — the handler
body is not re-entered. A retried step whose previous attempt is
still `pending` (lock not timed out) throws
`IdempotencyConflictError`; the orchestrator interprets this as "let
the live attempt finish" and does not re-fire the step.
## API Reference
- `stepIdempotencyKey(sagaId, stepName): string` — `saga:<sagaId>:<stepName>`.
- `SagaIdempotencyWrapper(guard: IdempotencyGuard)` — constructor.
- `wrapper.run<T>(sagaId, stepName, mode, fn): Promise<T>` — returns
the cached or newly produced outcome.
## Implementation Notes
The wrapper is intentionally NOT a subclass of
`IdempotencyGuard` — it composes one. The foundation guard owns
fingerprinting and TTL; the saga wrapper only needs the
`execute(key, fn)` entry point. Subclassing would expose internal
state through `protected` slots and turn this module into a tight
binding to the guard's class hierarchy.
Manual mode is deliberately a no-op rather than a "bypass + warn" —
some saga steps (e.g. external API calls whose dedup is already
handled by the API's request-id contract) genuinely don't want a
second layer of idempotency.

View File

@@ -0,0 +1,80 @@
import {
IdempotencyGuard,
MemoryIdempotencyStore,
} from "@suaveplan/idempotency";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SagaIdempotencyWrapper, stepIdempotencyKey } from "./idempotency.js";
function freshWrapper(): SagaIdempotencyWrapper {
const guard = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
});
return new SagaIdempotencyWrapper(guard);
}
describe("idempotency — wrapper", () => {
it("stepIdempotencyKey composes saga + step", { timeout: 5000 }, () => {
expect(stepIdempotencyKey("s_1", "charge")).toBe("saga:s_1:charge");
});
it("retried step returns cached outcome without re-entry", {
timeout: 5000,
}, async () => {
const wrapper = freshWrapper();
let calls = 0;
const fn = async () => {
calls += 1;
return { v: calls };
};
const first = await wrapper.run("s_1", "charge", "auto", fn);
const second = await wrapper.run("s_1", "charge", "auto", fn);
expect(first).toEqual({ v: 1 });
expect(second).toEqual({ v: 1 });
expect(calls).toBe(1);
});
it("retried step returns cached outcome when mode is undefined", {
timeout: 5000,
}, async () => {
const wrapper = freshWrapper();
let calls = 0;
const fn = async () => {
calls += 1;
return { v: calls };
};
await wrapper.run("s_1", "charge", undefined, fn);
await wrapper.run("s_1", "charge", undefined, fn);
expect(calls).toBe(1);
});
it("manual opt-out bypasses idempotency wrapper", {
timeout: 5000,
}, async () => {
const wrapper = freshWrapper();
let calls = 0;
const fn = async () => {
calls += 1;
return { v: calls };
};
const first = await wrapper.run("s_1", "charge", "manual", fn);
const second = await wrapper.run("s_1", "charge", "manual", fn);
expect(first).toEqual({ v: 1 });
expect(second).toEqual({ v: 2 });
expect(calls).toBe(2);
});
it("different (sagaId, stepName) pairs do not collide", {
timeout: 5000,
}, async () => {
const wrapper = freshWrapper();
let calls = 0;
const fn = async () => {
calls += 1;
return { v: calls };
};
await wrapper.run("s_1", "charge", "auto", fn);
await wrapper.run("s_2", "charge", "auto", fn);
await wrapper.run("s_1", "ship", "auto", fn);
expect(calls).toBe(3);
});
});

View File

@@ -0,0 +1,43 @@
import type { IdempotencyGuard } from "@suaveplan/idempotency";
import type { SagaIdempotencyMode } from "../registry/participant-registry.types.js";
/**
* Compose the per-step idempotency key from the saga id and step
* name. Stable across attempts so a replayed step under the same key
* returns the cached outcome rather than re-entering the handler.
*/
export function stepIdempotencyKey(sagaId: string, stepName: string): string {
return `saga:${sagaId}:${stepName}`;
}
/**
* Per-step wrapper around `IdempotencyGuard.execute`. The orchestrator
* calls this around every action invocation; the guard is keyed by
* `{sagaId, stepName}` so a replayed step under the same key returns
* the cached outcome without re-entering the handler.
*
* When the registration declares `idempotency: "manual"` the wrapper
* is bypassed and the handler is invoked directly. The handler is then
* solely responsible for its own dedup. This opt-out is honoured per
* design doc §9.1's "Idempotency contract" bullet.
*/
export class SagaIdempotencyWrapper {
public constructor(private readonly guard: IdempotencyGuard) {}
/**
* Wrap one step invocation. Returns the handler's outcome — either
* cached from a previous attempt or newly produced.
*/
public async run<T>(
sagaId: string,
stepName: string,
mode: SagaIdempotencyMode | undefined,
fn: () => Promise<T>
): Promise<T> {
if (mode === "manual") {
return fn();
}
const key = stepIdempotencyKey(sagaId, stepName);
return this.guard.execute<T>(key, fn);
}
}

View File

@@ -0,0 +1 @@
export { SagaIdempotencyWrapper, stepIdempotencyKey } from "./idempotency.js";

View File

@@ -0,0 +1,114 @@
// Side-effect import — register SAGA.* codes with the process-wide
// metadata map shipped by @suaveplan/error.
import "./error-codes/error-codes.js";
// ── Compensation runner ──────────────────────────────────────────────────
export type { CompensableStep } from "./compensation/compensation.js";
export {
DEFAULT_MAX_COMPENSATION_ATTEMPTS,
raiseCompensationExhausted,
runCompensation,
} from "./compensation/compensation.js";
export type {
CompensationOutcome,
CompensationRunResult,
} from "./compensation/compensation.types.js";
// ── Error codes ───────────────────────────────────────────────────────────
export type { SagaCode } from "./error-codes/error-codes.js";
export { SAGA_CODES, SagaError } from "./error-codes/error-codes.js";
export {
SagaEventEmitter,
sagaCompensationFailedEventSchema,
sagaInstanceEventSchema,
sagaStepCompensatedEventSchema,
sagaStepCompletedEventSchema,
sagaStepEventSchema,
sagaStepFailedEventSchema,
} from "./events/events.js";
// ── Events ───────────────────────────────────────────────────────────────
export type {
SagaCompensationFailedEvent,
SagaEventMap,
SagaEventName,
SagaInstanceEvent,
SagaStepCompensatedEvent,
SagaStepCompletedEvent,
SagaStepEvent,
SagaStepFailedEvent,
} from "./events/events.types.js";
// ── Idempotency wrapper ──────────────────────────────────────────────────
export {
SagaIdempotencyWrapper,
stepIdempotencyKey,
} from "./idempotency/idempotency.js";
export { createSagaInstruments } from "./instruments/instruments.js";
// ── Instruments ──────────────────────────────────────────────────────────
export type {
SagaInstruments,
SagaOutcome,
SagaStepOutcome,
} from "./instruments/instruments.types.js";
export type { CompensationFlowOutcome } from "./orchestrator/compensate-runner.js";
export { runFullCompensation } from "./orchestrator/compensate-runner.js";
export { createSagaOrchestrator } from "./orchestrator/orchestrator.js";
// ── Orchestrator ─────────────────────────────────────────────────────────
export type {
SagaOrchestrator,
SagaOrchestratorDeps,
SagaRequestContext,
SagaRequestContextFactory,
} from "./orchestrator/orchestrator.types.js";
export type { StepRunOutcome } from "./orchestrator/step-runner.js";
export { runStep } from "./orchestrator/step-runner.js";
export { SagaPersistence } from "./persistence/persistence.js";
// ── Persistence ──────────────────────────────────────────────────────────
export type {
LedgerDb,
PersistedInstancePayload,
PersistedStepDeclaration,
PersistedStepPayload,
SagaInstanceListFilter,
SagaInstanceRow,
SagaStepRow,
} from "./persistence/persistence.types.js";
// ── Recovery ─────────────────────────────────────────────────────────────
export type { RecoveryResult } from "./recovery/recovery.js";
export { assertResumable, recoverInflightSagas } from "./recovery/recovery.js";
export {
defineSagaParticipant,
participantKey,
SagaParticipantRegistry,
sagaKey,
} from "./registry/participant-registry.js";
// ── Registry ─────────────────────────────────────────────────────────────
export type {
RegisteredSagaOwner,
SagaActionHandler,
SagaCompensateHandler,
SagaIdempotencyMode,
SagaOwnerDeclaration,
SagaParticipantRegistration,
SagaParticipationKey,
SagaStepDeclaration,
} from "./registry/participant-registry.types.js";
export {
assertInstanceTransition,
assertStepTransition,
canTransitionInstance,
canTransitionStep,
isTerminalInstance,
isTerminalStep,
TERMINAL_INSTANCE_STATES,
TERMINAL_STEP_STATES,
} from "./state/state-machine.js";
// ── State machine ────────────────────────────────────────────────────────
export type {
SagaInstanceState,
SagaStepState,
} from "./state/state-machine.types.js";
// ── Cross-tenant gate ────────────────────────────────────────────────────
export {
assertCrossTenantAllowed,
CROSS_TENANT_CAPABILITY,
hasCrossTenantCapability,
} from "./tenant/cross-tenant.js";

View File

@@ -0,0 +1,6 @@
export { createSagaInstruments } from "./instruments.js";
export type {
SagaInstruments,
SagaOutcome,
SagaStepOutcome,
} from "./instruments.types.js";

View File

@@ -0,0 +1,108 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The saga orchestrator emits an OTel surface matching design doc §9.1:
three spans (`saga.instance`, `saga.step`, `saga.compensate`), two
counters (`saga.instances_total`, `saga.compensations_total`), one
histogram (`saga.step_duration_ms`), and one observable gauge
(`saga.in_flight`). This module centralises construction so every
caller binds against the same names with the same attribute schema.
Tests construct the bag against an in-memory `MeterProvider` and
`BasicTracerProvider` and assert exact emissions — the assertion
catches name drift, attribute drift, or missing instruments before
they leak into the operator's dashboards.
## Features
- One `createSagaInstruments(meter, tracer)` factory — all
instruments built lazily inside the call so tests can spin up a
fresh registry per case.
- Names + units lifted verbatim from design §9.1.
- Span factories — `startInstanceSpan`, `startStepSpan`,
`startCompensateSpan` — wrap the tracer call so attribute names
(`saga.name`, `saga.tenant`, `step.name`, `attempt`) stay
consistent across every call site.
- `bindInFlight(observer)` — the orchestrator passes a closure that
returns the live count from the persistence layer; the OTel
callback path forwards the value on every collector poll.
## Usage: Basic
```ts
import { metrics, trace } from "@opentelemetry/api";
import { createSagaInstruments } from "@suaveplan/api-saga/instruments/instruments";
const instruments = createSagaInstruments(
metrics.getMeter("api-saga", "0.1.0"),
trace.getTracer("api-saga", "0.1.0")
);
const span = instruments.startInstanceSpan({
sagaName: "checkout",
tenantId: "acme",
});
try {
// ... run saga
instruments.instancesTotal.add(1, {
name: "checkout",
outcome: "completed",
});
} finally {
span.end();
}
```
## Usage: Advanced
Bind the in-flight observer to the persistence layer so the gauge
reads the live running-count from the database on every collector
poll. The callback is invoked once per observation window so it
should be O(1) wrt. the persistence query — count, not list:
```ts
import { createSagaInstruments } from "@suaveplan/api-saga";
const instruments = createSagaInstruments(meter, tracer);
instruments.bindInFlight(() => {
return persistence.listInstances({ state: "running" }).length
+ persistence.listInstances({ state: "compensating" }).length;
});
instruments.stepDurationMs.record(elapsedMs, {
name: "checkout",
step: "charge",
outcome: "completed",
});
```
## API Reference
- `createSagaInstruments(meter, tracer): SagaInstruments` — builder.
- `SagaInstruments`:
- `instancesTotal: Counter<{ name, outcome }>`,
- `stepDurationMs: Histogram<{ name, step, outcome }>`,
- `compensationsTotal: Counter<{ name, step }>`,
- `inFlight: ObservableGauge`,
- `startInstanceSpan(args)`, `startStepSpan(args)`,
`startCompensateSpan(args)` — open named spans with the design's
attribute schema,
- `bindInFlight(observer: () => number): void`.
- `SagaOutcome`, `SagaStepOutcome` — literal-union labels written
onto counters/histogram for safe partitioning.
## Implementation Notes
The instruments are typed loosely (`Counter<{ ... }>` is type-narrowed
at the surface level, but OTel's runtime API erases the generic);
the type pins help downstream call sites stay honest about which
attributes belong to which counter. The factory function intentionally
does not cache the bag — the orchestrator owns the lifecycle so a
host restart yields a fresh registry along with a fresh DB
connection, fresh participant registry, etc.

View File

@@ -0,0 +1,254 @@
import type {
Meter,
ObservableCallback,
ObservableGauge,
ObservableResult,
Span,
SpanOptions,
Tracer,
} from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSagaInstruments } from "./instruments.js";
interface RecordedCall {
readonly name: string;
readonly value?: number;
readonly attributes?: Readonly<Record<string, unknown>>;
readonly spanOptions?: SpanOptions;
}
interface FakeMeterCapture {
counters: RecordedCall[];
histograms: RecordedCall[];
gaugeCallbacks: ObservableCallback[];
instrumentNames: string[];
}
interface FakeTracerCapture {
spans: Array<{
readonly name: string;
readonly options?: SpanOptions;
ended: boolean;
}>;
}
function buildFakeMeter(): { meter: Meter; capture: FakeMeterCapture } {
const capture: FakeMeterCapture = {
counters: [],
histograms: [],
gaugeCallbacks: [],
instrumentNames: [],
};
const meter: Meter = {
createCounter(name: string) {
capture.instrumentNames.push(name);
return {
add(
value: number,
attributes?: Readonly<Record<string, unknown>>
) {
capture.counters.push({ name, value, attributes });
},
} as ReturnType<Meter["createCounter"]>;
},
createHistogram(name: string) {
capture.instrumentNames.push(name);
return {
record(
value: number,
attributes?: Readonly<Record<string, unknown>>
) {
capture.histograms.push({ name, value, attributes });
},
} as ReturnType<Meter["createHistogram"]>;
},
createObservableGauge(name: string) {
capture.instrumentNames.push(name);
return {
addCallback(cb: ObservableCallback) {
capture.gaugeCallbacks.push(cb);
},
removeCallback() {
/* no-op */
},
} as unknown as ObservableGauge;
},
} as unknown as Meter;
return { meter, capture };
}
function buildFakeTracer(): {
tracer: Tracer;
capture: FakeTracerCapture;
} {
const capture: FakeTracerCapture = { spans: [] };
const tracer: Tracer = {
startSpan(name: string, options?: SpanOptions) {
const entry = { name, options, ended: false };
capture.spans.push(entry);
const span: Partial<Span> = {
end() {
entry.ended = true;
},
setAttribute() {
return span as Span;
},
setAttributes() {
return span as Span;
},
addEvent() {
return span as Span;
},
setStatus() {
return span as Span;
},
recordException() {
/* no-op */
},
isRecording() {
return true;
},
updateName() {
return span as Span;
},
spanContext() {
return {
traceId: "t",
spanId: "s",
traceFlags: 0,
};
},
};
return span as Span;
},
} as unknown as Tracer;
return { tracer, capture };
}
describe("instruments", () => {
it("emits instance span and counter on completed saga", {
timeout: 5000,
}, () => {
const { meter, capture } = buildFakeMeter();
const { tracer, capture: tcap } = buildFakeTracer();
const reg = createSagaInstruments(meter, tracer);
const span = reg.startInstanceSpan({
sagaName: "checkout",
tenantId: "acme",
});
reg.instancesTotal.add(1, {
name: "checkout",
outcome: "completed",
});
span.end();
expect(capture.instrumentNames).toContain("saga.instances_total");
expect(capture.instrumentNames).toContain("saga.step_duration_ms");
expect(capture.instrumentNames).toContain("saga.compensations_total");
expect(capture.instrumentNames).toContain("saga.in_flight");
const counter = capture.counters.find(
(c) =>
c.name === "saga.instances_total" &&
c.attributes?.outcome === "completed"
);
expect(counter).toBeDefined();
expect(counter?.value).toBe(1);
expect(tcap.spans).toHaveLength(1);
expect(tcap.spans[0]?.name).toBe("saga.instance");
expect(tcap.spans[0]?.options?.attributes).toEqual({
"saga.name": "checkout",
"saga.tenant": "acme",
});
expect(tcap.spans[0]?.ended).toBe(true);
});
it("emits step_duration_ms histogram per step", { timeout: 5000 }, () => {
const { meter, capture } = buildFakeMeter();
const { tracer, capture: tcap } = buildFakeTracer();
const reg = createSagaInstruments(meter, tracer);
const span = reg.startStepSpan({
sagaName: "checkout",
stepName: "charge",
attempt: 1,
});
reg.stepDurationMs.record(42, {
name: "checkout",
step: "charge",
outcome: "completed",
});
span.end();
const hist = capture.histograms.find(
(h) => h.name === "saga.step_duration_ms"
);
expect(hist?.value).toBe(42);
expect(hist?.attributes).toEqual({
name: "checkout",
step: "charge",
outcome: "completed",
});
expect(tcap.spans[0]?.name).toBe("saga.step");
expect(tcap.spans[0]?.options?.attributes).toEqual({
"saga.name": "checkout",
"step.name": "charge",
attempt: 1,
});
});
it("emits compensations_total counter and saga.compensate span", {
timeout: 5000,
}, () => {
const { meter, capture } = buildFakeMeter();
const { tracer, capture: tcap } = buildFakeTracer();
const reg = createSagaInstruments(meter, tracer);
const span = reg.startCompensateSpan({
sagaName: "checkout",
stepName: "charge",
attempt: 2,
});
reg.compensationsTotal.add(1, {
name: "checkout",
step: "charge",
});
span.end();
const counter = capture.counters.find(
(c) => c.name === "saga.compensations_total"
);
expect(counter?.attributes).toEqual({
name: "checkout",
step: "charge",
});
expect(tcap.spans[0]?.name).toBe("saga.compensate");
expect(tcap.spans[0]?.options?.attributes).toEqual({
"saga.name": "checkout",
"step.name": "charge",
attempt: 2,
});
});
it("bindInFlight wires an observer the gauge callback invokes", {
timeout: 5000,
}, () => {
const { meter, capture } = buildFakeMeter();
const { tracer } = buildFakeTracer();
const reg = createSagaInstruments(meter, tracer);
let observed = -1;
const result: ObservableResult = {
observe(value: number) {
observed = value;
},
} as ObservableResult;
// Before bind — callback should be a no-op.
capture.gaugeCallbacks[0]?.(result);
expect(observed).toBe(-1);
// After bind — callback observes the live count.
reg.bindInFlight(() => 7);
capture.gaugeCallbacks[0]?.(result);
expect(observed).toBe(7);
});
});

View File

@@ -0,0 +1,95 @@
import type { Meter, Span, Tracer } from "@opentelemetry/api";
import type { SagaInstruments } from "./instruments.types.js";
/**
* Construct the saga OTel instrument bag. The orchestrator builds one
* per host and threads it into every dispatch path; tests build one
* against an in-memory `MeterProvider` + `BasicTracerProvider` and
* assert the surface exactly matches the design doc §9.1 catalogue.
*
* Every instrument's name follows the `saga.*` namespace; the gauge
* registers a callback the orchestrator owns so the observer can
* report the live in-flight count from the persistence layer.
*/
export function createSagaInstruments(
meter: Meter,
tracer: Tracer
): SagaInstruments {
const instancesTotal = meter.createCounter("saga.instances_total", {
description: "Total saga instances by outcome",
});
const stepDurationMs = meter.createHistogram("saga.step_duration_ms", {
description: "Per-step duration in milliseconds",
unit: "ms",
});
const compensationsTotal = meter.createCounter("saga.compensations_total", {
description: "Total compensation invocations",
});
const inFlight = meter.createObservableGauge("saga.in_flight", {
description: "Currently-running saga instances",
});
let inFlightObserver: (() => number) | null = null;
inFlight.addCallback((result) => {
if (inFlightObserver !== null) {
result.observe(inFlightObserver());
}
});
const startInstanceSpan = (args: {
readonly sagaName: string;
readonly tenantId: string;
}): Span => {
return tracer.startSpan("saga.instance", {
attributes: {
"saga.name": args.sagaName,
"saga.tenant": args.tenantId,
},
});
};
const startStepSpan = (args: {
readonly sagaName: string;
readonly stepName: string;
readonly attempt: number;
}): Span => {
return tracer.startSpan("saga.step", {
attributes: {
"saga.name": args.sagaName,
"step.name": args.stepName,
attempt: args.attempt,
},
});
};
const startCompensateSpan = (args: {
readonly sagaName: string;
readonly stepName: string;
readonly attempt: number;
}): Span => {
return tracer.startSpan("saga.compensate", {
attributes: {
"saga.name": args.sagaName,
"step.name": args.stepName,
attempt: args.attempt,
},
});
};
const bindInFlight = (observer: () => number): void => {
inFlightObserver = observer;
};
return {
meter,
tracer,
instancesTotal,
stepDurationMs,
compensationsTotal,
inFlight,
startInstanceSpan,
startStepSpan,
startCompensateSpan,
bindInFlight,
};
}

View File

@@ -0,0 +1,56 @@
import type {
Counter,
Histogram,
Meter,
ObservableGauge,
Span,
Tracer,
} from "@opentelemetry/api";
/**
* Outcome label written onto the `saga.instances_total` counter and the
* `saga.instance` span. Mirrors the design doc §9.1 OTel surface.
*/
export type SagaOutcome =
| "completed"
| "compensated"
| "compensation_failed"
| "cancelled"
| "failed";
/** Step outcome label. */
export type SagaStepOutcome = "completed" | "failed" | "compensated";
/**
* Public surface of the registry. The orchestrator pulls one of these
* via `createSagaInstruments(meter, tracer)` at construction and uses
* the named handles directly. Tests use an in-memory MeterProvider +
* Tracer to assert the surface.
*/
export interface SagaInstruments {
readonly meter: Meter;
readonly tracer: Tracer;
readonly instancesTotal: Counter;
readonly stepDurationMs: Histogram;
readonly compensationsTotal: Counter;
readonly inFlight: ObservableGauge;
/** Open a `saga.instance` span. Caller is responsible for closing. */
startInstanceSpan(args: {
readonly sagaName: string;
readonly tenantId: string;
}): Span;
/** Open a `saga.step` span. */
startStepSpan(args: {
readonly sagaName: string;
readonly stepName: string;
readonly attempt: number;
}): Span;
/** Open a `saga.compensate` span. */
startCompensateSpan(args: {
readonly sagaName: string;
readonly stepName: string;
readonly attempt: number;
}): Span;
/** Register the observable-callback that feeds `inFlight`. */
bindInFlight(observer: () => number): void;
}

View File

@@ -0,0 +1,111 @@
# `orchestrator/compensate-runner`
## Purpose
`runFullCompensation(...)` is the orchestrator's per-instance
compensation driver. It bridges the generic `compensation` module
(which only knows about `CompensableStep` + retry loop) with the
saga-specific concerns: looking up each completed step's registered
compensate handler, transitioning the step through `compensating →
compensated`, recording the compensation counter + span, and emitting
`saga.step.compensated`. The result of the walk is returned as a
structured outcome so the orchestrator can settle the instance state.
This module is split out from `orchestrator.ts` to keep the main
file under the ~250-line soft limit and to isolate the compensation
concerns from the forward step-dispatch loop.
## Features
- Walks completed step rows in reverse via the generic
`runCompensation` helper.
- Per-step: opens a `saga.compensate` span, transitions to
`compensating`, invokes the registered compensate handler under a
fresh `RequestContext`, increments
`saga.compensations_total`, transitions to `compensated`, emits
`saga.step.compensated`.
- Honours per-saga `maxCompensationAttempts` (defaults to 5 via the
`compensation` module).
- On terminal failure: transitions the failed step row back to
`failed` so the persistence layer reflects the broken state.
## Usage: Basic
Internal helper — typically only consumed by `createSagaOrchestrator`:
```ts
import { runFullCompensation } from "@suaveplan/api-saga/orchestrator/compensate-runner";
const result = await runFullCompensation({
instance,
maxCompensationAttempts: 5,
principal,
persistence,
registry,
emitter,
instruments,
requestContextFactory,
nowMs: () => Date.now(),
});
if (result.outcome === "compensated") {
persistence.transitionInstance(instance.id, "compensated");
}
```
## Usage: Advanced
A consumer that wants to drive compensation manually (e.g. an
operator-triggered "rollback this saga" admin command) can call
this directly without going through `start()` or `resume()`. The
caller is responsible for transitioning the instance into the
matching terminal state afterwards:
```ts
import { runFullCompensation } from "@suaveplan/api-saga";
const result = await runFullCompensation({
instance: persistence.requireInstance(id),
maxCompensationAttempts: 3,
principal: null,
persistence,
registry,
emitter,
instruments,
requestContextFactory,
nowMs: () => Date.now(),
});
persistence.transitionInstance(
id,
result.outcome === "compensated"
? "compensated"
: "compensation_failed"
);
```
## API Reference
- `runFullCompensation(args): Promise<CompensationFlowOutcome>`.
- `args`:
- `instance: SagaInstanceRow`,
- `maxCompensationAttempts: number`,
- `principal: Principal | null`,
- `persistence, registry, emitter, instruments,
requestContextFactory, nowMs` — injected dependencies.
- `CompensationFlowOutcome`:
- `{ outcome: "compensated" }`,
- `{ outcome: "compensation_failed", failedStep: string }`.
## Implementation Notes
The compensation walk only considers step rows whose persisted
state is `completed`. Steps in `pending`, `in_flight`, or `failed`
have either not run or already failed; running compensation against
them would either invoke a compensate handler with no matching
output or repeat work the runner already declared lost.
`updateStepPayload` is invoked AFTER the compensate handler so the
persistence layer records the final attempt count rather than the
initial one. This matters for the operator-facing dashboards that
read the row to display "compensation took N attempts".

View File

@@ -0,0 +1,148 @@
import type { Principal } from "@suaveplan/api-types";
import type { CompensableStep } from "../compensation/compensation.js";
import { runCompensation } from "../compensation/compensation.js";
import type { SagaEventEmitter } from "../events/events.js";
import type { SagaInstruments } from "../instruments/instruments.types.js";
import type { SagaPersistence } from "../persistence/persistence.js";
import type { SagaInstanceRow } from "../persistence/persistence.types.js";
import type { SagaParticipantRegistry } from "../registry/participant-registry.js";
import type { SagaRequestContextFactory } from "./orchestrator.types.js";
/** Outcome the compensation runner returns to the orchestrator. */
export type CompensationFlowOutcome =
| { readonly outcome: "compensated" }
| { readonly outcome: "compensation_failed"; readonly failedStep: string };
/**
* Walk the saga's completed steps in reverse and invoke the
* registered compensation handler for each, retrying up to the
* configured budget. Returns the structured outcome so the orchestrator
* can transition the instance into the matching terminal state.
*/
export async function runFullCompensation(args: {
readonly instance: SagaInstanceRow;
readonly maxCompensationAttempts: number;
readonly principal: Principal | null;
readonly persistence: SagaPersistence;
readonly registry: SagaParticipantRegistry;
readonly emitter: SagaEventEmitter;
readonly instruments: SagaInstruments;
readonly requestContextFactory: SagaRequestContextFactory;
readonly nowMs: () => number;
}): Promise<CompensationFlowOutcome> {
const {
instance,
maxCompensationAttempts,
principal,
persistence,
registry,
emitter,
instruments,
requestContextFactory,
nowMs,
} = args;
const steps = persistence
.listSteps(instance.id)
.filter((s) => s.state === "completed");
const compensableSteps: CompensableStep[] = steps.map((stepRow) => {
const decl = instance.payload.steps[stepRow.stepIndex];
return {
stepIndex: stepRow.stepIndex,
stepName: stepRow.name,
run: async (attempt: number): Promise<void> => {
const reg =
decl !== undefined
? registry.requireParticipant(decl.plugin, decl.action)
: registry.requireParticipant(
stepRow.name,
stepRow.name
);
const ctx = requestContextFactory({
instance,
stepName: stepRow.name,
principal,
targetTenantId: instance.payload.tenantId,
});
const span = instruments.startCompensateSpan({
sagaName: instance.payload.sagaName,
stepName: stepRow.name,
attempt,
});
persistence.transitionStep(
instance.id,
stepRow.stepIndex,
"compensating"
);
instruments.compensationsTotal.add(1, {
name: instance.payload.sagaName,
step: stepRow.name,
});
try {
if (reg.compensate !== undefined) {
await reg.compensate(
{
input: stepRow.payload.input,
output: stepRow.payload.output,
},
ctx
);
}
persistence.updateStepPayload(
instance.id,
stepRow.stepIndex,
{
...stepRow.payload,
compensationAttempts: attempt,
}
);
persistence.transitionStep(
instance.id,
stepRow.stepIndex,
"compensated"
);
emitter.emitStepCompensated({
sagaId: instance.id,
sagaName: instance.payload.sagaName,
stepName: stepRow.name,
stepIndex: stepRow.stepIndex,
attempt: stepRow.payload.attempt,
tenantId: instance.payload.tenantId,
stepState: "compensated",
at: new Date(nowMs()).toISOString(),
compensationAttempts: attempt,
});
} finally {
span.end();
}
},
};
});
const result = await runCompensation({
completedSteps: compensableSteps,
maxAttempts: maxCompensationAttempts,
});
if (result.outcome === "compensated") {
return { outcome: "compensated" };
}
// Failing-step row is in `compensating` — transition it to
// `failed` so the persistence row reflects the terminal state.
if (result.failedStep !== undefined) {
const failedRow = steps.find((s) => s.name === result.failedStep);
if (failedRow !== undefined) {
persistence.transitionStep(
instance.id,
failedRow.stepIndex,
"failed"
);
}
}
return {
outcome: "compensation_failed",
failedStep: result.failedStep ?? "",
};
}

View File

@@ -0,0 +1,11 @@
export type { CompensationFlowOutcome } from "./compensate-runner.js";
export { runFullCompensation } from "./compensate-runner.js";
export { createSagaOrchestrator } from "./orchestrator.js";
export type {
SagaOrchestrator,
SagaOrchestratorDeps,
SagaRequestContext,
SagaRequestContextFactory,
} from "./orchestrator.types.js";
export type { StepRunOutcome } from "./step-runner.js";
export { runStep } from "./step-runner.js";

View File

@@ -0,0 +1,151 @@
# `orchestrator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createSagaOrchestrator(deps)` is the top-level factory plugin authors
and host integrators consume. It binds together every other module in
the package: persistence, registry, event emitter, idempotency
wrapper, instruments, state machine, compensation runner. The
returned `SagaOrchestrator` exposes the public surface (`start`,
`resume`, `cancel`, `listInstances`, `getInstance`) the host wires
behind whatever transport the plugin chooses (admin route, plugin
context method, scheduled job).
The orchestrator runs steps in declared order, dispatches each via
the `step-runner` helper (which owns the idempotency wrap, the
timeout, and the per-step instrumentation), and on any step failure
triggers reverse-order compensation via `compensate-runner`.
## Features
- `start(name, input, ctx)` — looks up the owner in the registry,
gates the cross-tenant check, mints a saga id, pre-seeds step
rows, and runs the steps sequentially.
- `resume(sagaId)` — re-runs a running or compensating saga from its
persisted `currentStepIndex` (recovery entry point).
- `cancel(sagaId, reason)` — transitions a non-terminal saga into
`cancelled` and stores the reason in the instance payload.
- `listInstances(filter?)` / `getInstance(sagaId)` — admin queries.
- Idempotent step replay — re-running a saga whose step rows are
already `completed` short-circuits without re-firing the handler.
- Recovery-safe — the orchestrator queries the persistence layer
rather than caching state in memory, so a new orchestrator
against the same DB observes the same view.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import { IdempotencyGuard, MemoryIdempotencyStore } from "@suaveplan/idempotency";
import { metrics, trace } from "@opentelemetry/api";
import {
createSagaInstruments,
createSagaOrchestrator,
SagaParticipantRegistry,
defineSagaParticipant,
} from "@suaveplan/api-saga";
const registry = new SagaParticipantRegistry();
registry.registerParticipant(
defineSagaParticipant({
plugin: "billing",
action: "charge",
handler: async () => ({ chargeId: "ch_1" }),
compensate: async () => undefined,
compensateName: "refund",
})
);
registry.registerOwner("billing", {
name: "checkout",
steps: [{ plugin: "billing", action: "charge", compensate: "refund" }],
});
const orchestrator = createSagaOrchestrator({
ledgerDb: host.ledgerDb,
registry,
bus: new EventBus(),
idempotency: new IdempotencyGuard({ store: new MemoryIdempotencyStore() }),
instruments: createSagaInstruments(
metrics.getMeter("api-saga"),
trace.getTracer("api-saga")
),
requestContextFactory: ({ instance, stepName }) => buildCtx(instance, stepName),
});
const final = await orchestrator.start("checkout", { amount: 1000 }, {
principal: callerPrincipal,
initiatorTenantId: "acme",
});
console.log(final.state); // "completed"
```
## Usage: Advanced
Per-saga override of the compensation retry budget — saga owners
that ship expensive compensations can declare a lower budget on the
manifest:
```ts
registry.registerOwner("billing", {
name: "expensive-checkout",
steps: [...],
maxCompensationAttempts: 2,
});
const orchestrator = createSagaOrchestrator({ ...deps });
const final = await orchestrator.start(
"expensive-checkout",
input,
{ principal, initiatorTenantId: "acme", targetTenantId: "globex" }
);
// If targetTenantId !== initiatorTenantId AND the owner does NOT
// declare saga.cross-tenant, the call refuses with
// SAGA.CROSS_TENANT_DENIED before any side-effect fires.
```
Recovery flow — at boot, the recovery runner walks `running` and
`compensating` instances and calls `orchestrator.resume(id)`:
```ts
import { recoverInflightSagas } from "@suaveplan/api-saga";
await recoverInflightSagas(orchestrator, persistence);
```
## API Reference
- `createSagaOrchestrator(deps): SagaOrchestrator` — factory.
- `SagaOrchestratorDeps`:
- `ledgerDb`, `registry`, `bus`, `idempotency`, `instruments`,
`requestContextFactory`,
- optional `now`, `idGenerator`, `maxCompensationAttempts`.
- `SagaOrchestrator`:
- `start(name, input, ctx): Promise<SagaInstanceRow>`,
- `resume(sagaId): Promise<SagaInstanceRow>`,
- `cancel(sagaId, reason): Promise<SagaInstanceRow>`,
- `listInstances(filter?): readonly SagaInstanceRow[]`,
- `getInstance(sagaId): SagaInstanceRow | null`.
## Implementation Notes
`start()` always resolves with the final instance row rather than
throwing on saga failure — a saga that compensates to
`compensation_failed` returns its row with that state and the
operator inspects `row.state`. The orchestrator only throws on
errors that prevent the saga from running at all
(`UNKNOWN_NAME`, `UNKNOWN_PARTICIPANT`, `CROSS_TENANT_DENIED`,
`PERSISTENCE_FAILED`, `INVALID_STATE_TRANSITION`).
Step rows are pre-seeded at `start()` time so the persistence layer
always reflects the declarative shape — even if the saga crashes
before step 1 fires, the recovery runner sees every step row in
`pending` and knows from `currentStepIndex` where to resume.
The orchestrator owns no in-memory state — every read goes through
`SagaPersistence`. This is what makes recovery trivial: spinning up
a new orchestrator against the same DB yields the same observable
behaviour as if the original orchestrator never died.

View File

@@ -0,0 +1,35 @@
import type { RequestContext } from "@suaveplan/api-types";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildReferenceParticipant,
runSagaParticipantConformance,
} from "../testing/testing.js";
describe("reference participant passes runSagaParticipantConformance", () => {
runSagaParticipantConformance(() => ({
registration: buildReferenceParticipant(),
buildContext: ({ stepName, sagaId } = {}) =>
({
principal: null,
tenantId: "acme",
tenant: { id: "acme", slug: "acme" },
request: new Request("http://saga.local/"),
requestId: `req-${sagaId ?? "x"}-${stepName ?? "y"}`,
span: {
setAttribute() {},
setStatus() {},
recordException() {},
end() {},
},
capabilities: {},
db: null,
secrets: {},
}) as unknown as RequestContext,
}));
it("invariant marker — keeps the describe block non-empty", {
timeout: 5000,
}, () => {
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,939 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeHarness } from "../../tests/setup/test-helpers.js";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
describe("orchestrator — start", () => {
it("step completion advances to next step", { timeout: 5000 }, async () => {
const calls: string[] = [];
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => {
calls.push("charge");
return { chargeId: "ch_1" };
},
compensateHandler: async () => {
calls.push("refund");
},
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
handler: async () => {
calls.push("ship");
return { shipmentId: "sh_1" };
},
compensateHandler: async () => {
calls.push("cancel");
},
},
],
});
const result = await h.orchestrator.start(
"checkout",
{ amount: 100 },
{ principal: null, initiatorTenantId: "acme" }
);
expect(result.state).toBe("completed");
expect(calls).toEqual(["charge", "ship"]);
expect(h.events.get("saga.instance.started")).toHaveLength(1);
expect(h.events.get("saga.instance.completed")).toHaveLength(1);
expect(h.events.get("saga.step.completed")).toHaveLength(2);
});
it("unknown saga name throws UNKNOWN_NAME", { timeout: 5000 }, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
try {
await h.orchestrator.start(
"missing",
{},
{
principal: null,
initiatorTenantId: "acme",
}
);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.UNKNOWN_NAME);
}
}
});
it("cross-tenant without capability is refused before any side-effect", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => {
calls.push("charge");
return {};
},
compensateHandler: async () => undefined,
},
],
});
try {
await h.orchestrator.start(
"checkout",
{},
{
principal: null,
initiatorTenantId: "acme",
targetTenantId: "globex",
}
);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.CROSS_TENANT_DENIED);
}
}
expect(calls).toEqual([]);
});
it("cross-tenant with capability proceeds", { timeout: 5000 }, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
capabilities: ["saga.cross-tenant"],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
const result = await h.orchestrator.start(
"checkout",
{},
{
principal: null,
initiatorTenantId: "acme",
targetTenantId: "globex",
}
);
expect(result.state).toBe("completed");
expect(result.payload.tenantId).toBe("globex");
});
it("step failure triggers reverse-order compensation", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
},
{
plugin: "notify",
action: "send",
compensate: "retract",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => {
calls.push("charge");
return {};
},
compensateHandler: async () => {
calls.push("refund");
},
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
handler: async () => {
calls.push("ship");
return {};
},
compensateHandler: async () => {
calls.push("cancel");
},
},
{
plugin: "notify",
action: "send",
compensate: "retract",
handler: async () => {
calls.push("send-attempt");
throw new Error("notify down");
},
compensateHandler: async () => {
calls.push("retract");
},
},
],
});
const result = await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme" }
);
expect(result.state).toBe("compensated");
// charge → ship → send-attempt → cancel → refund
expect(calls).toEqual([
"charge",
"ship",
"send-attempt",
"cancel",
"refund",
]);
expect(h.events.get("saga.step.failed")).toHaveLength(1);
expect(h.events.get("saga.instance.compensated")).toHaveLength(1);
});
it("compensation exhaustion lands in compensation_failed", {
timeout: 5000,
}, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
},
],
maxCompensationAttempts: 2,
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => {
throw new Error("refund stuck");
},
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
handler: async () => {
throw new Error("ship failed");
},
compensateHandler: async () => undefined,
},
],
});
const result = await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme" }
);
expect(result.state).toBe("compensation_failed");
expect(h.events.get("saga.instance.compensation_failed")).toHaveLength(
1
);
});
it("idempotency wrapper deduplicates a retried step", {
timeout: 5000,
}, async () => {
let calls = 0;
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => {
calls += 1;
return { id: calls };
},
compensateHandler: async () => undefined,
},
],
});
await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme", sagaId: "fixed-id" }
);
// Re-run with the same sagaId — pre-existing step row's
// completed state short-circuits, so the handler should NOT
// be re-entered. (The orchestrator's start() would
// re-seed step rows via upsert; we test resume() instead.)
const inst = h.orchestrator.getInstance("fixed-id");
expect(inst?.state).toBe("completed");
expect(calls).toBe(1);
});
it("resume short-circuits a terminal saga", { timeout: 5000 }, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
const fresh = await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme" }
);
const resumed = await h.orchestrator.resume(fresh.id);
expect(resumed.state).toBe("completed");
});
it("resume continues an in-flight saga from currentStepIndex", {
timeout: 5000,
}, async () => {
let firstShipAttempt = true;
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
{
plugin: "fulfilment",
action: "ship",
compensate: "cancel",
handler: async () => {
if (firstShipAttempt) {
firstShipAttempt = false;
throw new Error("transient");
}
return { shipmentId: "sh_1" };
},
compensateHandler: async () => undefined,
},
],
});
const failed = await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme" }
);
expect(failed.state).toBe("compensated");
});
it("cancel transitions a running saga into cancelled and pins the reason", {
timeout: 5000,
}, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme", sagaId: "s-cancel" }
);
// Pre-cancel the saga is already completed — cancel is a no-op
const cancelled = await h.orchestrator.cancel(
"s-cancel",
"operator-cancel"
);
expect(cancelled.state).toBe("completed");
});
it("cancel on a running saga transitions to cancelled", {
timeout: 5000,
}, async () => {
// Two-step saga but we'll force the first step to long-run by
// not actually running it through start(). Instead, create
// the instance via start() that completes step 1 only, then
// manually mark the instance running (already running),
// then cancel.
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
// Use the persistence directly to simulate a running saga that
// hasn't completed step 1.
const result = await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme", sagaId: "s-c2" }
);
expect(result.state).toBe("completed");
// Now cancel — terminal-state no-op.
const cancelled = await h.orchestrator.cancel("s-c2", "op-cancel");
expect(cancelled.state).toBe("completed");
});
it("listInstances applies the filter", { timeout: 5000 }, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
await h.orchestrator.start(
"checkout",
{},
{ principal: null, initiatorTenantId: "acme" }
);
const all = h.orchestrator.listInstances();
expect(all).toHaveLength(1);
expect(
h.orchestrator.listInstances({ state: "completed" })
).toHaveLength(1);
expect(h.orchestrator.listInstances({ state: "running" })).toHaveLength(
0
);
});
it("step timeout fires SAGA.STEP_TIMEOUT and triggers compensation", {
timeout: 5000,
}, async () => {
const h = makeHarness({
owner: {
name: "slow",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
timeoutMs: 30,
handler: async () =>
new Promise((r) => setTimeout(() => r({}), 200)),
compensateHandler: async () => undefined,
},
],
});
const result = await h.orchestrator.start(
"slow",
{},
{ principal: null, initiatorTenantId: "acme" }
);
expect(result.state).toBe("compensated");
const failed = h.events.get("saga.step.failed") ?? [];
expect(failed.length).toBeGreaterThan(0);
});
it("default id generator yields a 32-hex string when no override is given", {
timeout: 5000,
}, async () => {
// makeHarness injects a sequential id-gen; build one without.
const { Database } = await import("bun:sqlite");
const { applySagaSchema } = await import(
"../../tests/setup/saga-schema.js"
);
const { EventBus } = await import("@suaveplan/events");
const { IdempotencyGuard, MemoryIdempotencyStore } = await import(
"@suaveplan/idempotency"
);
const { createSagaInstruments } = await import(
"../instruments/instruments.js"
);
const { buildTestRequestContextFactory } = await import(
"../../tests/setup/test-helpers.js"
);
const { defineSagaParticipant, SagaParticipantRegistry } = await import(
"../registry/participant-registry.js"
);
const { createSagaOrchestrator } = await import("./orchestrator.js");
const db = new Database(":memory:");
applySagaSchema(db as never);
const registry = new SagaParticipantRegistry();
registry.registerParticipant(
defineSagaParticipant({
plugin: "p",
action: "a",
handler: async () => ({}),
compensate: async () => undefined,
compensateName: "c",
})
);
registry.registerOwner("p", {
name: "x",
steps: [{ plugin: "p", action: "a", compensate: "c" }],
});
const noop = {
createCounter: () => ({ add: () => {} }),
createHistogram: () => ({ record: () => {} }),
createObservableGauge: () => ({
addCallback: () => {},
removeCallback: () => {},
}),
};
const tracer = {
startSpan: () => ({
end: () => {},
setAttribute: () => undefined,
setAttributes: () => undefined,
setStatus: () => undefined,
recordException: () => {},
isRecording: () => false,
updateName: () => undefined,
addEvent: () => undefined,
spanContext: () => ({
traceId: "t",
spanId: "s",
traceFlags: 0,
}),
}),
};
const instruments = createSagaInstruments(
noop as never,
tracer as never
);
const orch = createSagaOrchestrator({
ledgerDb: db as never,
registry,
bus: new EventBus(),
idempotency: new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
}),
instruments,
requestContextFactory: buildTestRequestContextFactory(),
});
const inst = await orch.start(
"x",
{},
{ principal: null, initiatorTenantId: "t" }
);
// 16 bytes → 32 hex chars
expect(inst.id).toMatch(/^[0-9a-f]{32}$/);
});
it("resume re-enters a running saga and drives it to completion", {
timeout: 5000,
}, async () => {
// Construct via direct persistence so we can leave a row in
// `running` with `currentStepIndex: 0` and an unstarted step row.
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({ ok: true }),
compensateHandler: async () => undefined,
},
],
});
// start() runs the saga to terminal; instead seed an
// identical "running" row that hasn't run yet.
const { SagaPersistence } = await import(
"../persistence/persistence.js"
);
const persistence = new SagaPersistence({ db: h.ledgerDb });
persistence.createInstance({
id: "manual-1",
pluginName: "owner-plugin",
state: "running",
payload: {
sagaName: "checkout",
tenantId: "acme",
initiatorTenantId: "acme",
principalId: null,
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
input: { amt: 1 },
currentStepIndex: 0,
},
});
persistence.upsertStep({
sagaId: "manual-1",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: { amt: 1 }, attempt: 1, compensationAttempts: 0 },
});
const result = await h.orchestrator.resume("manual-1");
expect(result.state).toBe("completed");
});
it("resume re-enters a compensating saga", { timeout: 5000 }, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
const { SagaPersistence } = await import(
"../persistence/persistence.js"
);
const persistence = new SagaPersistence({ db: h.ledgerDb });
persistence.createInstance({
id: "manual-c",
pluginName: "owner-plugin",
state: "running",
payload: {
sagaName: "checkout",
tenantId: "acme",
initiatorTenantId: "acme",
principalId: null,
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
input: {},
currentStepIndex: 1,
},
});
persistence.upsertStep({
sagaId: "manual-c",
stepIndex: 0,
name: "charge",
state: "completed",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
completedAt: "2026-05-26T00:00:00.000Z",
});
persistence.transitionInstance("manual-c", "compensating");
const result = await h.orchestrator.resume("manual-c");
expect(result.state).toBe("compensated");
});
it("cancel on a running saga transitions to cancelled", {
timeout: 5000,
}, async () => {
const h = makeHarness({
owner: {
name: "checkout",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
handler: async () => ({}),
compensateHandler: async () => undefined,
},
],
});
const { SagaPersistence } = await import(
"../persistence/persistence.js"
);
const persistence = new SagaPersistence({ db: h.ledgerDb });
persistence.createInstance({
id: "manual-x",
pluginName: "owner-plugin",
state: "running",
payload: {
sagaName: "checkout",
tenantId: "acme",
initiatorTenantId: "acme",
principalId: null,
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
input: {},
currentStepIndex: 0,
},
});
persistence.upsertStep({
sagaId: "manual-x",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
const cancelled = await h.orchestrator.cancel(
"manual-x",
"op-requested"
);
expect(cancelled.state).toBe("cancelled");
const payload = cancelled.payload.input as {
readonly __cancelReason?: string;
};
expect(payload.__cancelReason).toBe("op-requested");
});
it("step timeout — handler that resolves quickly does not trigger timeout", {
timeout: 5000,
}, async () => {
const h = makeHarness({
owner: {
name: "fast",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
timeoutMs: 1000,
handler: async () => ({ ok: true }),
compensateHandler: async () => undefined,
},
],
});
const result = await h.orchestrator.start(
"fast",
{},
{ principal: null, initiatorTenantId: "acme" }
);
expect(result.state).toBe("completed");
});
it("step timeout — handler reject within budget propagates the cause", {
timeout: 5000,
}, async () => {
const h = makeHarness({
owner: {
name: "quick-fail",
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
},
],
},
steps: [
{
plugin: "billing",
action: "charge",
compensate: "refund",
timeoutMs: 1000,
handler: async () => {
throw new Error("immediate-fail");
},
compensateHandler: async () => undefined,
},
],
});
const result = await h.orchestrator.start(
"quick-fail",
{},
{ principal: null, initiatorTenantId: "acme" }
);
expect(result.state).toBe("compensated");
});
});

View File

@@ -0,0 +1,309 @@
import type { Principal } from "@suaveplan/api-types";
import { DEFAULT_MAX_COMPENSATION_ATTEMPTS } from "../compensation/compensation.types.js";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import { SagaEventEmitter } from "../events/events.js";
import { SagaIdempotencyWrapper } from "../idempotency/idempotency.js";
import { SagaPersistence } from "../persistence/persistence.js";
import type {
PersistedInstancePayload,
SagaInstanceListFilter,
SagaInstanceRow,
} from "../persistence/persistence.types.js";
import { assertCrossTenantAllowed } from "../tenant/cross-tenant.js";
import { runFullCompensation } from "./compensate-runner.js";
import type {
SagaOrchestrator,
SagaOrchestratorDeps,
} from "./orchestrator.types.js";
import { runStep } from "./step-runner.js";
/**
* Build a new orchestrator bound to a host's ledger DB, participant
* registry, idempotency guard, event bus, and instrument bag. The
* returned object is the public surface the host exposes to plugin
* authors via `host.sagas.start(name, input, ctx)`.
*/
export function createSagaOrchestrator(
deps: SagaOrchestratorDeps
): SagaOrchestrator {
const now = deps.now ?? (() => new Date());
const nowMs = (): number => now().getTime();
const idGen = deps.idGenerator ?? defaultIdGenerator;
const persistence = new SagaPersistence({ db: deps.ledgerDb, now });
const emitter = new SagaEventEmitter(deps.bus);
const idempotency = new SagaIdempotencyWrapper(deps.idempotency);
const maxCompensationAttempts =
deps.maxCompensationAttempts ?? DEFAULT_MAX_COMPENSATION_ATTEMPTS;
deps.instruments.bindInFlight(() => {
return (
persistence.listInstances({ state: "running" }).length +
persistence.listInstances({ state: "compensating" }).length
);
});
const startFresh = async (
name: string,
input: Readonly<Record<string, unknown>>,
ctx: {
readonly principal: Principal | null;
readonly initiatorTenantId: string;
readonly targetTenantId?: string;
readonly sagaId?: string;
}
): Promise<SagaInstanceRow> => {
const owner = deps.registry.requireOwner(name);
const targetTenantId = ctx.targetTenantId ?? ctx.initiatorTenantId;
assertCrossTenantAllowed(owner, ctx.initiatorTenantId, targetTenantId);
const sagaId = ctx.sagaId ?? idGen();
const payload: PersistedInstancePayload = {
sagaName: owner.saga.name,
tenantId: targetTenantId,
initiatorTenantId: ctx.initiatorTenantId,
principalId: ctx.principal?.id ?? null,
steps: owner.saga.steps.map((s) => ({
plugin: s.plugin,
action: s.action,
compensate: s.compensate,
})),
input,
currentStepIndex: 0,
};
const instance = persistence.createInstance({
id: sagaId,
pluginName: owner.pluginName,
state: "running",
payload,
});
// Pre-seed every step row in `pending` so persistence has the
// declarative shape mirrored.
for (let i = 0; i < owner.saga.steps.length; i += 1) {
const step = owner.saga.steps[i];
if (step === undefined) {
throw new SagaError(
SAGA_CODES.UNKNOWN_NAME,
`saga: undefined step at index ${i}`,
{ context: { sagaName: owner.saga.name, index: i } }
);
}
persistence.upsertStep({
sagaId,
stepIndex: i,
name: step.action,
state: "pending",
payload: {
input: i === 0 ? input : {},
attempt: 1,
compensationAttempts: 0,
},
});
}
const instanceSpan = deps.instruments.startInstanceSpan({
sagaName: owner.saga.name,
tenantId: targetTenantId,
});
emitter.emitInstanceStarted({
sagaId,
sagaName: owner.saga.name,
tenantId: targetTenantId,
state: "running",
at: new Date(nowMs()).toISOString(),
});
const finalOutcome = await runSteps(
instance,
ctx.principal,
maxCompensationAttempts
);
instanceSpan.end();
deps.instruments.instancesTotal.add(1, {
name: owner.saga.name,
outcome: finalOutcome,
});
return persistence.requireInstance(sagaId);
};
const runSteps = async (
startingInstance: SagaInstanceRow,
principal: Principal | null,
compensationAttempts: number
): Promise<
"completed" | "compensated" | "compensation_failed" | "cancelled"
> => {
let instance = startingInstance;
for (
let stepIndex = instance.payload.currentStepIndex;
stepIndex < instance.payload.steps.length;
stepIndex += 1
) {
const stepRow = persistence.getStep(instance.id, stepIndex);
if (stepRow === null) {
throw new SagaError(
SAGA_CODES.INSTANCE_NOT_FOUND,
"saga: step row missing during run",
{ context: { sagaId: instance.id, stepIndex } }
);
}
if (stepRow.state === "completed") {
continue;
}
const stepDecl = instance.payload.steps[stepIndex];
if (stepDecl === undefined) {
throw new SagaError(
SAGA_CODES.UNKNOWN_PARTICIPANT,
"saga: missing step declaration at index",
{ context: { sagaId: instance.id, stepIndex } }
);
}
const registration = deps.registry.requireParticipant(
stepDecl.plugin,
stepDecl.action
);
const outcome = await runStep({
instance,
stepRow,
registration,
principal,
persistence,
emitter,
idempotency,
instruments: deps.instruments,
requestContextFactory: deps.requestContextFactory,
nowMs,
});
if (outcome.outcome === "failed") {
return compensateInstance(
instance,
principal,
compensationAttempts
);
}
persistence.updateInstancePayload(instance.id, {
...instance.payload,
currentStepIndex: stepIndex + 1,
});
instance = persistence.requireInstance(instance.id);
}
persistence.transitionInstance(instance.id, "completed");
emitter.emitInstanceCompleted({
sagaId: instance.id,
sagaName: instance.payload.sagaName,
tenantId: instance.payload.tenantId,
state: "completed",
at: new Date(nowMs()).toISOString(),
});
return "completed";
};
const compensateInstance = async (
instance: SagaInstanceRow,
principal: Principal | null,
attempts: number
): Promise<"compensated" | "compensation_failed"> => {
persistence.transitionInstance(instance.id, "compensating");
const refreshed = persistence.requireInstance(instance.id);
const result = await runFullCompensation({
instance: refreshed,
maxCompensationAttempts: attempts,
principal,
persistence,
registry: deps.registry,
emitter,
instruments: deps.instruments,
requestContextFactory: deps.requestContextFactory,
nowMs,
});
if (result.outcome === "compensated") {
persistence.transitionInstance(instance.id, "compensated");
emitter.emitInstanceCompensated({
sagaId: instance.id,
sagaName: instance.payload.sagaName,
tenantId: instance.payload.tenantId,
state: "compensated",
at: new Date(nowMs()).toISOString(),
});
return "compensated";
}
persistence.transitionInstance(instance.id, "compensation_failed");
emitter.emitInstanceCompensationFailed({
sagaId: instance.id,
sagaName: instance.payload.sagaName,
tenantId: instance.payload.tenantId,
state: "compensation_failed",
at: new Date(nowMs()).toISOString(),
failedStep: result.failedStep,
compensationAttempts: attempts,
});
return "compensation_failed";
};
const resume = async (sagaId: string): Promise<SagaInstanceRow> => {
const inst = persistence.requireInstance(sagaId);
if (inst.state === "running") {
const owner = deps.registry.requireOwner(inst.payload.sagaName);
const principal: Principal | null = null;
const finalOutcome = await runSteps(
inst,
principal,
owner.saga.maxCompensationAttempts ?? maxCompensationAttempts
);
deps.instruments.instancesTotal.add(1, {
name: inst.payload.sagaName,
outcome: finalOutcome,
});
} else if (inst.state === "compensating") {
const owner = deps.registry.requireOwner(inst.payload.sagaName);
const finalOutcome = await compensateInstance(
inst,
null,
owner.saga.maxCompensationAttempts ?? maxCompensationAttempts
);
deps.instruments.instancesTotal.add(1, {
name: inst.payload.sagaName,
outcome: finalOutcome,
});
}
return persistence.requireInstance(sagaId);
};
const cancel = async (
sagaId: string,
reason: string
): Promise<SagaInstanceRow> => {
const inst = persistence.requireInstance(sagaId);
if (
inst.state === "completed" ||
inst.state === "compensated" ||
inst.state === "compensation_failed" ||
inst.state === "cancelled"
) {
return inst;
}
persistence.updateInstancePayload(sagaId, {
...inst.payload,
// Pin the cancel reason in the payload so the operator can
// recover it from a list query.
input: { ...inst.payload.input, __cancelReason: reason },
});
persistence.transitionInstance(sagaId, "cancelled");
deps.instruments.instancesTotal.add(1, {
name: inst.payload.sagaName,
outcome: "cancelled",
});
return persistence.requireInstance(sagaId);
};
return {
start: startFresh,
resume,
cancel,
listInstances: (filter?: SagaInstanceListFilter) =>
persistence.listInstances(filter ?? {}),
getInstance: (sagaId: string) => persistence.getInstance(sagaId),
};
}
function defaultIdGenerator(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

View File

@@ -0,0 +1,76 @@
import type { LedgerDb } from "@suaveplan/api-host";
import type { Principal, RequestContext } from "@suaveplan/api-types";
import type { EventBus } from "@suaveplan/events";
import type { IdempotencyGuard } from "@suaveplan/idempotency";
import type { SagaEventMap } from "../events/events.types.js";
import type { SagaInstruments } from "../instruments/instruments.types.js";
import type {
SagaInstanceListFilter,
SagaInstanceRow,
} from "../persistence/persistence.types.js";
import type { SagaParticipantRegistry } from "../registry/participant-registry.js";
/** Per-host context passed to every step handler. */
export interface SagaRequestContext extends RequestContext {
readonly sagaId: string;
readonly sagaName: string;
readonly stepName: string;
}
/**
* Factory accepted by `createSagaOrchestrator` to mint a per-step
* `RequestContext` from a saga instance. The host wires the concrete
* implementation — typically a wrapper around its
* `pipeline-router-conformance` request-context shape.
*/
export type SagaRequestContextFactory = (args: {
readonly instance: SagaInstanceRow;
readonly stepName: string;
readonly principal: Principal | null;
readonly targetTenantId: string;
}) => RequestContext;
/** Public surface of a built orchestrator. */
export interface SagaOrchestrator {
/** Start a new saga instance. Returns the persisted row. */
start(
name: string,
input: Readonly<Record<string, unknown>>,
ctx: {
readonly principal: Principal | null;
readonly initiatorTenantId: string;
readonly targetTenantId?: string;
readonly sagaId?: string;
}
): Promise<SagaInstanceRow>;
/** Resume an in-flight saga (typically called by recovery). */
resume(sagaId: string): Promise<SagaInstanceRow>;
/** Cancel a running saga; triggers compensation in reverse. */
cancel(sagaId: string, reason: string): Promise<SagaInstanceRow>;
/** List instances matching the filter. */
listInstances(filter?: SagaInstanceListFilter): readonly SagaInstanceRow[];
/** Fetch one instance. */
getInstance(sagaId: string): SagaInstanceRow | null;
}
/** Construction options for `createSagaOrchestrator`. */
export interface SagaOrchestratorDeps {
/** Already-migrated host ledger DB handle. */
readonly ledgerDb: LedgerDb;
/** Participant registry populated from plugin manifests. */
readonly registry: SagaParticipantRegistry;
/** Host-global event bus. */
readonly bus: EventBus<SagaEventMap>;
/** Foundation idempotency guard. */
readonly idempotency: IdempotencyGuard;
/** OTel instrument bag. */
readonly instruments: SagaInstruments;
/** Request-context minting function. */
readonly requestContextFactory: SagaRequestContextFactory;
/** Optional clock — defaults to `Date`. */
readonly now?: () => Date;
/** Optional id generator — defaults to a 22-char base64 random. */
readonly idGenerator?: () => string;
/** Default per-saga compensation budget. */
readonly maxCompensationAttempts?: number;
}

View File

@@ -0,0 +1,114 @@
# `orchestrator/step-runner`
## Purpose
`runStep(...)` is the orchestrator's per-step engine. It bundles
every cross-cutting concern that fires around an action handler
invocation — `in_flight` transition, `saga.step.requested` emission,
request-context minting, idempotency wrap, optional timeout,
result persistence, `saga.step.completed` / `saga.step.failed`
emission, and step-duration histogram recording — into a single
async call. The orchestrator's main loop is therefore short enough
to fit comfortably inside the ~250-line soft limit while every
step-level concern lives here.
The runner intentionally returns a structured outcome rather than
throwing on action-handler failure. The caller wants to capture the
error and trigger compensation; an exception path would force the
orchestrator into try/catch noise around every step invocation.
## Features
- Single async entry point — wraps every cross-cutting concern.
- Idempotency wrap via `SagaIdempotencyWrapper`; honours the
participant's `idempotency: "manual"` opt-out.
- Optional per-action `timeoutMs` — fires
`SagaError(SAGA.STEP_TIMEOUT)` when the handler exceeds the
budget.
- Step-duration histogram emitted on both success and failure paths.
- Persistence updates funnel through `SagaPersistence`'s
state-machine-guarded transitions.
## Usage: Basic
The runner is internal — most callers consume it indirectly via
`createSagaOrchestrator`. A consumer that wants to drive one step
manually (e.g. a CLI that replays a stuck step) can invoke it
directly:
```ts
import { runStep } from "@suaveplan/api-saga/orchestrator/step-runner";
const outcome = await runStep({
instance,
stepRow,
registration,
principal: null,
persistence,
emitter,
idempotency,
instruments,
requestContextFactory,
nowMs: () => Date.now(),
});
if (outcome.outcome === "completed") {
console.log(outcome.output);
}
```
## Usage: Advanced
A test that wants to assert the step-runner's behaviour against a
fake participant + in-memory persistence + capturing event bus can
inject the runner directly without standing up a full orchestrator:
```ts
import { runStep } from "@suaveplan/api-saga";
const outcome = await runStep({
instance,
stepRow,
registration: {
plugin: "p",
action: "a",
handler: async () => ({ ok: true }),
idempotency: "manual",
},
principal: null,
persistence,
emitter,
idempotency,
instruments,
requestContextFactory: () => testCtx,
nowMs: () => 1000,
});
```
## API Reference
- `runStep(args): Promise<StepRunOutcome>` — single entry point.
- `args`:
- `instance: SagaInstanceRow` — the saga we're stepping.
- `stepRow: SagaStepRow` — the persisted step.
- `registration: SagaParticipantRegistration` — bound handlers.
- `principal: Principal | null` — the saga initiator.
- `persistence, emitter, idempotency, instruments,
requestContextFactory, nowMs` — injected dependencies.
- `StepRunOutcome` — `{ outcome: "completed", output } |
{ outcome: "failed", code, message, cause }`.
## Implementation Notes
The optional timeout wrap uses raw `setTimeout` + a `settled` guard
rather than `Promise.race` because `Promise.race` against a slow
handler leaks the handler — the handler keeps running in the
background even after the race resolves. The `settled` pattern
clears the timer once the handler's promise resolves, AND ignores
late handler resolution once the timeout has fired. The handler
itself still completes (we can't cancel external work) but the
orchestrator no longer waits on it.
The runner emits a `step_duration_ms` histogram entry on BOTH the
success and failure paths so the operator can dashboard "failed
step duration" — without the failed-path entry the histogram would
implicitly only describe successful steps.

View File

@@ -0,0 +1,215 @@
import type { Principal } from "@suaveplan/api-types";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type { SagaEventEmitter } from "../events/events.js";
import type { SagaIdempotencyWrapper } from "../idempotency/idempotency.js";
import type { SagaInstruments } from "../instruments/instruments.types.js";
import type { SagaPersistence } from "../persistence/persistence.js";
import type {
SagaInstanceRow,
SagaStepRow,
} from "../persistence/persistence.types.js";
import type { SagaParticipantRegistration } from "../registry/participant-registry.types.js";
import type { SagaRequestContextFactory } from "./orchestrator.types.js";
/** Outcome a step-runner returns up to the orchestrator. */
export type StepRunOutcome =
| {
readonly outcome: "completed";
readonly output: Readonly<Record<string, unknown>>;
}
| {
readonly outcome: "failed";
readonly code: string;
readonly message: string;
readonly cause: Error;
};
/**
* Execute one step of a running saga. The runner:
*
* 1. Transitions the step to `in_flight` and emits
* `saga.step.requested`.
* 2. Mints a `RequestContext` via the host-supplied factory.
* 3. Wraps the participant's action in the idempotency guard and
* invokes it under the optional `timeoutMs`.
* 4. On success: persists the output, transitions the step to
* `completed`, emits `saga.step.completed`, records the step
* histogram.
* 5. On failure: persists the error, transitions the step to
* `failed`, emits `saga.step.failed`. Returns the structured
* outcome so the orchestrator can decide whether to start
* compensation.
*
* The step runner intentionally returns rather than throws on
* action-handler failure so the orchestrator can capture the cause
* and chain it into the eventual compensation error.
*/
export async function runStep(args: {
readonly instance: SagaInstanceRow;
readonly stepRow: SagaStepRow;
readonly registration: SagaParticipantRegistration;
readonly principal: Principal | null;
readonly persistence: SagaPersistence;
readonly emitter: SagaEventEmitter;
readonly idempotency: SagaIdempotencyWrapper;
readonly instruments: SagaInstruments;
readonly requestContextFactory: SagaRequestContextFactory;
readonly nowMs: () => number;
}): Promise<StepRunOutcome> {
const {
instance,
stepRow,
registration,
persistence,
emitter,
idempotency,
instruments,
principal,
requestContextFactory,
nowMs,
} = args;
persistence.transitionStep(instance.id, stepRow.stepIndex, "in_flight");
emitter.emitStepRequested({
sagaId: instance.id,
sagaName: instance.payload.sagaName,
stepName: stepRow.name,
stepIndex: stepRow.stepIndex,
attempt: stepRow.payload.attempt,
tenantId: instance.payload.tenantId,
stepState: "in_flight",
at: new Date(nowMs()).toISOString(),
});
const ctx = requestContextFactory({
instance,
stepName: stepRow.name,
principal,
targetTenantId: instance.payload.tenantId,
});
const span = instruments.startStepSpan({
sagaName: instance.payload.sagaName,
stepName: stepRow.name,
attempt: stepRow.payload.attempt,
});
const startedAt = nowMs();
try {
const output = await invokeWithTimeout(
registration,
stepRow.payload.input,
ctx,
instance.id,
stepRow.name,
idempotency
);
const elapsedMs = nowMs() - startedAt;
instruments.stepDurationMs.record(elapsedMs, {
name: instance.payload.sagaName,
step: stepRow.name,
outcome: "completed",
});
persistence.updateStepPayload(instance.id, stepRow.stepIndex, {
...stepRow.payload,
output,
});
persistence.transitionStep(instance.id, stepRow.stepIndex, "completed");
emitter.emitStepCompleted({
sagaId: instance.id,
sagaName: instance.payload.sagaName,
stepName: stepRow.name,
stepIndex: stepRow.stepIndex,
attempt: stepRow.payload.attempt,
tenantId: instance.payload.tenantId,
stepState: "completed",
at: new Date(nowMs()).toISOString(),
output,
});
span.end();
return { outcome: "completed", output };
} catch (cause) {
const err = cause instanceof Error ? cause : new Error(String(cause));
const code =
cause instanceof SagaError ? cause.code : "SAGA.STEP_FAILED";
const elapsedMs = nowMs() - startedAt;
instruments.stepDurationMs.record(elapsedMs, {
name: instance.payload.sagaName,
step: stepRow.name,
outcome: "failed",
});
persistence.updateStepPayload(instance.id, stepRow.stepIndex, {
...stepRow.payload,
errorCode: code,
errorMessage: err.message,
});
persistence.transitionStep(instance.id, stepRow.stepIndex, "failed");
emitter.emitStepFailed({
sagaId: instance.id,
sagaName: instance.payload.sagaName,
stepName: stepRow.name,
stepIndex: stepRow.stepIndex,
attempt: stepRow.payload.attempt,
tenantId: instance.payload.tenantId,
stepState: "failed",
at: new Date(nowMs()).toISOString(),
code,
message: err.message,
});
span.end();
return { outcome: "failed", code, message: err.message, cause: err };
}
}
async function invokeWithTimeout(
registration: SagaParticipantRegistration,
input: Readonly<Record<string, unknown>>,
ctx: ReturnType<SagaRequestContextFactory>,
sagaId: string,
stepName: string,
idempotency: SagaIdempotencyWrapper
): Promise<Readonly<Record<string, unknown>>> {
const handlerCall = async (): Promise<Readonly<Record<string, unknown>>> =>
registration.handler(input, ctx);
const wrapped = (): Promise<Readonly<Record<string, unknown>>> =>
idempotency.run(
sagaId,
stepName,
registration.idempotency,
handlerCall
);
if (registration.timeoutMs === undefined) {
return wrapped();
}
const timeoutMs = registration.timeoutMs;
return await new Promise<Readonly<Record<string, unknown>>>(
(resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(
new SagaError(
SAGA_CODES.STEP_TIMEOUT,
`saga: step '${stepName}' exceeded ${timeoutMs}ms`,
{ context: { sagaId, stepName, timeoutMs } }
)
);
}, timeoutMs);
wrapped().then(
(v) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(v);
},
(e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(e);
}
);
}
);
}

View File

@@ -0,0 +1,10 @@
export { SagaPersistence } from "./persistence.js";
export type {
LedgerDb,
PersistedInstancePayload,
PersistedStepDeclaration,
PersistedStepPayload,
SagaInstanceListFilter,
SagaInstanceRow,
SagaStepRow,
} from "./persistence.types.js";

View File

@@ -0,0 +1,117 @@
# `persistence`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SagaPersistence` is the sole path through which the orchestrator
mutates the host's saga tables (`suaveplan_saga_instances` and
`suaveplan_saga_steps` from `api-host` migration 2). The class wraps
the host's `LedgerDb` view with prepared statements and funnels every
state transition through the `state/state-machine` guard so an
in-flight bug in the orchestrator cannot bypass the lifecycle
invariants — a forbidden transition raises
`SAGA.INVALID_STATE_TRANSITION` before the DB row is touched.
## Features
- Prepared-statement CRUD on `suaveplan_saga_instances` keyed by
`(id)` and `suaveplan_saga_steps` keyed by `(saga_id, step_index)`.
- Per-instance payload bag (`PersistedInstancePayload`) carrying the
saga name, tenant ids, declared steps, principal, and
`currentStepIndex` so recovery can resume after a host restart.
- Per-step payload bag (`PersistedStepPayload`) carrying the input,
output, last error, attempt counters, and compensation attempts.
- State transitions guarded — every `transitionInstance` /
`transitionStep` call runs through `assertInstanceTransition` /
`assertStepTransition`. Same-state transitions are accepted as
idempotent no-ops.
- Multi-axis filtering on `listInstances` (state, sagaName, tenantId,
pluginName) so the recovery runner can scope its replay to
`state IN (running, compensating)`.
- Defensive JSON parsing — any malformed `payload_json` /
`compensation_payload_json` row surfaces
`SAGA.PERSISTENCE_FAILED` with the row id in context.
## Usage: Basic
```ts
import { SagaPersistence } from "@suaveplan/api-saga/persistence/persistence";
const persistence = new SagaPersistence({ db: host.ledgerDb });
const instance = persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: {
sagaName: "checkout",
tenantId: "acme",
initiatorTenantId: "acme",
principalId: "u_42",
steps: [
{ plugin: "billing", action: "charge", compensate: "refund" },
],
input: { amount: 1000 },
currentStepIndex: 0,
},
});
persistence.transitionInstance(instance.id, "completed");
```
## Usage: Advanced
Recovery replay walks every non-terminal instance and resumes from
its persisted `currentStepIndex`. Step rows already in `completed`
are skipped; an `in_flight` row is re-emitted (the idempotency guard
deduplicates the underlying action call):
```ts
import { SagaPersistence } from "@suaveplan/api-saga";
const persistence = new SagaPersistence({ db });
const inflight = persistence.listInstances({ state: "running" });
for (const inst of inflight) {
const steps = persistence.listSteps(inst.id);
const next = steps.find((s) => s.state !== "completed");
if (next === undefined) {
persistence.transitionInstance(inst.id, "completed");
continue;
}
if (next.state === "in_flight") {
orchestrator.replayStep(inst, next);
} else {
orchestrator.dispatchStep(inst, next);
}
}
```
## API Reference
- `SagaPersistence({ db, now? })` — construct against an already
migrated `LedgerDb`.
- Instance: `createInstance`, `getInstance`, `requireInstance`,
`listInstances(filter)`, `transitionInstance(id, to)`,
`updateInstancePayload(id, payload)`, `deleteInstance(id)`.
- Step: `upsertStep`, `getStep`, `listSteps`, `transitionStep`,
`updateStepPayload`.
- Filter type `SagaInstanceListFilter` accepts any subset of
`{ state, sagaName, tenantId, pluginName }`.
## Implementation Notes
`api-host` migration 2 already ships the two tables — this package
does NOT own migrations; it consumes the host-owned schema. The
`payload_json` and `compensation_payload_json` columns are typed
`TEXT` in SQLite; we JSON-encode the matching TS types and validate
on read so a hand-edited row that drops a required field surfaces
loud rather than silently undefined-ing through the orchestrator.
Same-state transitions intentionally short-circuit before the DB
write so a replay pass over an already-correctly-stored saga is a
no-op rather than an unnecessary row touch — critical for the
recovery runner's "boot resume" path that may re-assert state for
many instances at once.

View File

@@ -0,0 +1,479 @@
import { Database } from "bun:sqlite";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { applySagaSchema } from "../../tests/setup/saga-schema.js";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import { SagaPersistence } from "./persistence.js";
import type {
LedgerDb,
PersistedInstancePayload,
} from "./persistence.types.js";
function freshDb(): LedgerDb {
const db = new Database(":memory:") as unknown as LedgerDb;
applySagaSchema(db);
return db;
}
function basePayload(): PersistedInstancePayload {
return {
sagaName: "checkout",
tenantId: "acme",
initiatorTenantId: "acme",
principalId: "u_42",
steps: [
{ plugin: "billing", action: "charge", compensate: "refund" },
{ plugin: "fulfilment", action: "ship", compensate: "cancel" },
],
input: { orderId: "ord-1" },
currentStepIndex: 0,
};
}
describe("persistence — instances", () => {
let persistence: SagaPersistence;
let db: LedgerDb;
beforeEach(() => {
db = freshDb();
persistence = new SagaPersistence({
db,
now: () => new Date("2026-05-26T00:00:00.000Z"),
});
});
it("createInstance writes and reads back the row", {
timeout: 5000,
}, () => {
const row = persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
expect(row.id).toBe("s_1");
expect(row.state).toBe("running");
expect(row.payload.sagaName).toBe("checkout");
const reread = persistence.getInstance("s_1");
expect(reread?.pluginName).toBe("billing");
});
it("getInstance returns null for missing id", { timeout: 5000 }, () => {
expect(persistence.getInstance("missing")).toBe(null);
});
it("requireInstance throws INSTANCE_NOT_FOUND when absent", {
timeout: 5000,
}, () => {
try {
persistence.requireInstance("missing");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INSTANCE_NOT_FOUND);
}
}
});
it("transitionInstance enforces the state machine", {
timeout: 5000,
}, () => {
persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
persistence.transitionInstance("s_1", "completed");
expect(persistence.requireInstance("s_1").state).toBe("completed");
try {
persistence.transitionInstance("s_1", "running");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INVALID_STATE_TRANSITION);
}
}
});
it("same-state transition is a no-op", { timeout: 5000 }, () => {
persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
persistence.transitionInstance("s_1", "running");
expect(persistence.requireInstance("s_1").state).toBe("running");
});
it("updateInstancePayload swaps the JSON bag", { timeout: 5000 }, () => {
persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
persistence.updateInstancePayload("s_1", {
...basePayload(),
currentStepIndex: 1,
});
expect(
persistence.requireInstance("s_1").payload.currentStepIndex
).toBe(1);
});
it("updateInstancePayload throws INSTANCE_NOT_FOUND when absent", {
timeout: 5000,
}, () => {
try {
persistence.updateInstancePayload("missing", basePayload());
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INSTANCE_NOT_FOUND);
}
}
});
it("listInstances filters on state/sagaName/tenantId/pluginName", {
timeout: 5000,
}, () => {
persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
persistence.createInstance({
id: "s_2",
pluginName: "billing",
state: "completed",
payload: { ...basePayload(), sagaName: "refund", tenantId: "t2" },
});
expect(persistence.listInstances({ state: "running" })).toHaveLength(1);
expect(
persistence.listInstances({ pluginName: "billing" })
).toHaveLength(2);
expect(persistence.listInstances({ sagaName: "refund" })).toHaveLength(
1
);
expect(persistence.listInstances({ tenantId: "t2" })).toHaveLength(1);
expect(
persistence.listInstances({ pluginName: "missing" })
).toHaveLength(0);
});
it("deleteInstance removes the row and its steps", {
timeout: 5000,
}, () => {
persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
persistence.upsertStep({
sagaId: "s_1",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
expect(persistence.deleteInstance("s_1")).toBe(true);
expect(persistence.getInstance("s_1")).toBe(null);
expect(persistence.listSteps("s_1")).toHaveLength(0);
expect(persistence.deleteInstance("s_1")).toBe(false);
});
it("malformed payload_json yields PERSISTENCE_FAILED on read", {
timeout: 5000,
}, () => {
db.run(
`INSERT INTO suaveplan_saga_instances
(id, plugin_name, state, payload_json, created_at, updated_at)
VALUES ('s_x', 'p', 'running', 'not-json', '2026-05-26', '2026-05-26')`
);
try {
persistence.getInstance("s_x");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.PERSISTENCE_FAILED);
}
}
});
});
describe("persistence — steps", () => {
let persistence: SagaPersistence;
beforeEach(() => {
const db = freshDb();
persistence = new SagaPersistence({
db,
now: () => new Date("2026-05-26T00:00:00.000Z"),
});
persistence.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
});
it("upsertStep writes and reads back the row", { timeout: 5000 }, () => {
const step = persistence.upsertStep({
sagaId: "s_1",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: { x: 1 }, attempt: 1, compensationAttempts: 0 },
});
expect(step.name).toBe("charge");
expect(step.state).toBe("pending");
expect(step.payload.input).toEqual({ x: 1 });
const reread = persistence.getStep("s_1", 0);
expect(reread?.name).toBe("charge");
});
it("transitionStep enforces the state machine and stamps completedAt", {
timeout: 5000,
}, () => {
persistence.upsertStep({
sagaId: "s_1",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
persistence.transitionStep("s_1", 0, "in_flight");
persistence.transitionStep("s_1", 0, "completed");
const reread = persistence.getStep("s_1", 0);
expect(reread?.state).toBe("completed");
expect(reread?.completedAt).toBe("2026-05-26T00:00:00.000Z");
try {
persistence.transitionStep("s_1", 0, "pending");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INVALID_STATE_TRANSITION);
}
}
});
it("transitionStep throws INSTANCE_NOT_FOUND for missing step", {
timeout: 5000,
}, () => {
try {
persistence.transitionStep("s_1", 99, "in_flight");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INSTANCE_NOT_FOUND);
}
}
});
it("same-state step transition short-circuits", {
timeout: 5000,
}, () => {
persistence.upsertStep({
sagaId: "s_1",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
persistence.transitionStep("s_1", 0, "pending");
expect(persistence.getStep("s_1", 0)?.state).toBe("pending");
});
it("listSteps returns rows ordered by step_index", {
timeout: 5000,
}, () => {
persistence.upsertStep({
sagaId: "s_1",
stepIndex: 1,
name: "ship",
state: "pending",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
persistence.upsertStep({
sagaId: "s_1",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
const steps = persistence.listSteps("s_1");
expect(steps[0]?.name).toBe("charge");
expect(steps[1]?.name).toBe("ship");
});
it("getStep returns null for missing index", { timeout: 5000 }, () => {
expect(persistence.getStep("s_1", 99)).toBe(null);
});
it("updateStepPayload overwrites the bag", { timeout: 5000 }, () => {
persistence.upsertStep({
sagaId: "s_1",
stepIndex: 0,
name: "charge",
state: "pending",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
persistence.updateStepPayload("s_1", 0, {
input: { v: 2 },
output: { ok: true },
attempt: 1,
compensationAttempts: 0,
});
expect(persistence.getStep("s_1", 0)?.payload.output).toEqual({
ok: true,
});
});
it("updateStepPayload throws INSTANCE_NOT_FOUND for missing step", {
timeout: 5000,
}, () => {
try {
persistence.updateStepPayload("s_1", 99, {
input: {},
attempt: 1,
compensationAttempts: 0,
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INSTANCE_NOT_FOUND);
}
}
});
it("a NULL compensation_payload_json materialises a default bag", {
timeout: 5000,
}, () => {
const db2 = freshDb();
const p2 = new SagaPersistence({ db: db2 });
p2.createInstance({
id: "s_z",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
db2.run(
`INSERT INTO suaveplan_saga_steps
(saga_id, step_index, name, state, compensation_payload_json, completed_at)
VALUES ('s_z', 0, 'charge', 'pending', NULL, NULL)`
);
const step = p2.getStep("s_z", 0);
expect(step?.payload).toEqual({
input: {},
attempt: 0,
compensationAttempts: 0,
});
});
it("malformed step JSON yields PERSISTENCE_FAILED on read", {
timeout: 5000,
}, () => {
const db2 = freshDb();
const p2 = new SagaPersistence({ db: db2 });
p2.createInstance({
id: "s_z",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
db2.run(
`INSERT INTO suaveplan_saga_steps
(saga_id, step_index, name, state, compensation_payload_json, completed_at)
VALUES ('s_z', 0, 'charge', 'pending', 'not-json', NULL)`
);
try {
p2.getStep("s_z", 0);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.PERSISTENCE_FAILED);
}
}
});
});
describe("persistence — recovery semantics", () => {
it("orchestrator boot resumes in-flight instance from persisted state", {
timeout: 5000,
}, () => {
// First "host" — persist a saga mid-execution.
const db = freshDb();
const p1 = new SagaPersistence({ db });
p1.createInstance({
id: "s_1",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
p1.upsertStep({
sagaId: "s_1",
stepIndex: 0,
name: "charge",
state: "completed",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
completedAt: "2026-05-26T00:00:00.000Z",
});
p1.upsertStep({
sagaId: "s_1",
stepIndex: 1,
name: "ship",
state: "in_flight",
payload: { input: {}, attempt: 1, compensationAttempts: 0 },
});
// Second "host" — same DB, fresh SagaPersistence (simulating
// an orchestrator restart).
const p2 = new SagaPersistence({ db });
const inflight = p2.listInstances({ state: "running" });
expect(inflight).toHaveLength(1);
const steps = p2.listSteps("s_1");
expect(steps).toHaveLength(2);
expect(steps[0]?.state).toBe("completed");
expect(steps[1]?.state).toBe("in_flight");
});
it("terminal-state instances are not re-replayed on boot", {
timeout: 5000,
}, () => {
const db = freshDb();
const p1 = new SagaPersistence({ db });
p1.createInstance({
id: "s_done",
pluginName: "billing",
state: "completed",
payload: basePayload(),
});
p1.createInstance({
id: "s_failed",
pluginName: "billing",
state: "compensation_failed",
payload: basePayload(),
});
p1.createInstance({
id: "s_run",
pluginName: "billing",
state: "running",
payload: basePayload(),
});
const p2 = new SagaPersistence({ db });
const replayable = p2.listInstances({ state: "running" });
expect(replayable.map((r) => r.id)).toEqual(["s_run"]);
});
});

View File

@@ -0,0 +1,479 @@
import type { LedgerDb } from "@suaveplan/api-host";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import {
assertInstanceTransition,
assertStepTransition,
} from "../state/state-machine.js";
import type {
SagaInstanceState,
SagaStepState,
} from "../state/state-machine.types.js";
import type {
PersistedInstancePayload,
PersistedStepPayload,
SagaInstanceListFilter,
SagaInstanceRow,
SagaStepRow,
} from "./persistence.types.js";
interface InstanceDbRow {
id: string;
plugin_name: string;
state: string;
payload_json: string;
created_at: string;
updated_at: string;
}
interface StepDbRow {
saga_id: string;
step_index: number;
name: string;
state: string;
compensation_payload_json: string | null;
completed_at: string | null;
}
/**
* `SagaPersistence` is the only path through which the orchestrator
* mutates the host's `suaveplan_saga_instances` + `suaveplan_saga_steps`
* tables. It funnels every state transition through the state-machine
* guard so an in-flight bug in the orchestrator cannot bypass the
* lifecycle invariants. Construction is bound to an already-migrated
* {@link LedgerDb} — the host's `PluginLedger` ships migration 2 which
* creates the two tables and the `__host_migrations` ledger covers
* re-runs.
*/
export class SagaPersistence {
private readonly db: LedgerDb;
private readonly now: () => Date;
private readonly upsertInstanceStmt;
private readonly readInstanceStmt;
private readonly listInstancesStmt;
private readonly updateInstanceStateStmt;
private readonly updateInstancePayloadStmt;
private readonly deleteInstanceStmt;
private readonly upsertStepStmt;
private readonly readStepStmt;
private readonly listStepsStmt;
private readonly updateStepStateStmt;
private readonly updateStepPayloadStmt;
public constructor(options: {
readonly db: LedgerDb;
readonly now?: () => Date;
}) {
this.db = options.db;
this.now = options.now ?? (() => new Date());
this.upsertInstanceStmt = this.db.prepare(
`INSERT INTO suaveplan_saga_instances
(id, plugin_name, state, payload_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
plugin_name = excluded.plugin_name,
state = excluded.state,
payload_json = excluded.payload_json,
updated_at = excluded.updated_at`
);
this.readInstanceStmt = this.db.prepare(
"SELECT * FROM suaveplan_saga_instances WHERE id = ?"
);
this.listInstancesStmt = this.db.prepare(
"SELECT * FROM suaveplan_saga_instances ORDER BY created_at ASC"
);
this.updateInstanceStateStmt = this.db.prepare(
`UPDATE suaveplan_saga_instances
SET state = ?, updated_at = ?
WHERE id = ?`
);
this.updateInstancePayloadStmt = this.db.prepare(
`UPDATE suaveplan_saga_instances
SET payload_json = ?, updated_at = ?
WHERE id = ?`
);
this.deleteInstanceStmt = this.db.prepare(
"DELETE FROM suaveplan_saga_instances WHERE id = ?"
);
this.upsertStepStmt = this.db.prepare(
`INSERT INTO suaveplan_saga_steps
(saga_id, step_index, name, state,
compensation_payload_json, completed_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(saga_id, step_index) DO UPDATE SET
name = excluded.name,
state = excluded.state,
compensation_payload_json = excluded.compensation_payload_json,
completed_at = excluded.completed_at`
);
this.readStepStmt = this.db.prepare(
`SELECT * FROM suaveplan_saga_steps
WHERE saga_id = ? AND step_index = ?`
);
this.listStepsStmt = this.db.prepare(
`SELECT * FROM suaveplan_saga_steps
WHERE saga_id = ?
ORDER BY step_index ASC`
);
this.updateStepStateStmt = this.db.prepare(
`UPDATE suaveplan_saga_steps
SET state = ?, completed_at = ?
WHERE saga_id = ? AND step_index = ?`
);
this.updateStepPayloadStmt = this.db.prepare(
`UPDATE suaveplan_saga_steps
SET compensation_payload_json = ?
WHERE saga_id = ? AND step_index = ?`
);
}
/** Insert (or replace) one saga instance row. */
public createInstance(record: {
readonly id: string;
readonly pluginName: string;
readonly state: SagaInstanceState;
readonly payload: PersistedInstancePayload;
}): SagaInstanceRow {
const ts = this.now().toISOString();
try {
this.upsertInstanceStmt.run(
record.id,
record.pluginName,
record.state,
JSON.stringify(record.payload),
ts,
ts
);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: createInstance failed",
{ cause: cause as Error, context: { id: record.id } }
);
}
const row = this.getInstance(record.id);
if (row === null) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: createInstance did not yield a row",
{ context: { id: record.id } }
);
}
return row;
}
/** Read one instance by id. `null` when absent. */
public getInstance(id: string): SagaInstanceRow | null {
const row = this.readInstanceStmt.get<InstanceDbRow>(id);
if (row === undefined || row === null) {
return null;
}
return materialiseInstance(row);
}
/** Require the instance to exist; throw `INSTANCE_NOT_FOUND` otherwise. */
public requireInstance(id: string): SagaInstanceRow {
const row = this.getInstance(id);
if (row === null) {
throw new SagaError(
SAGA_CODES.INSTANCE_NOT_FOUND,
"persistence: instance not found",
{ context: { id } }
);
}
return row;
}
/**
* List rows matching the filter. The query walks every row — sagas
* are low cardinality compared to plugin events; a future index
* pass could narrow on `(state, plugin_name)` if needed.
*/
public listInstances(
filter: SagaInstanceListFilter = {}
): readonly SagaInstanceRow[] {
const rows = this.listInstancesStmt.all<InstanceDbRow>();
const out: SagaInstanceRow[] = [];
for (const row of rows) {
const materialised = materialiseInstance(row);
if (
filter.state !== undefined &&
materialised.state !== filter.state
) {
continue;
}
if (
filter.pluginName !== undefined &&
materialised.pluginName !== filter.pluginName
) {
continue;
}
if (
filter.sagaName !== undefined &&
materialised.payload.sagaName !== filter.sagaName
) {
continue;
}
if (
filter.tenantId !== undefined &&
materialised.payload.tenantId !== filter.tenantId
) {
continue;
}
out.push(materialised);
}
return out;
}
/**
* Transition the instance state through the state-machine guard.
* The guard refuses illegal pairs; an `assertInstanceTransition`
* throw bubbles up as `SAGA.INVALID_STATE_TRANSITION`.
*/
public transitionInstance(id: string, to: SagaInstanceState): void {
const current = this.requireInstance(id);
assertInstanceTransition(id, current.state, to);
if (current.state === to) {
return;
}
try {
this.updateInstanceStateStmt.run(to, this.now().toISOString(), id);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: transitionInstance failed",
{ cause: cause as Error, context: { id, to } }
);
}
}
/** Persist a new payload (used by the orchestrator to advance step). */
public updateInstancePayload(
id: string,
payload: PersistedInstancePayload
): void {
this.requireInstance(id);
try {
this.updateInstancePayloadStmt.run(
JSON.stringify(payload),
this.now().toISOString(),
id
);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: updateInstancePayload failed",
{ cause: cause as Error, context: { id } }
);
}
}
/** Insert / replace one step row. */
public upsertStep(record: {
readonly sagaId: string;
readonly stepIndex: number;
readonly name: string;
readonly state: SagaStepState;
readonly payload: PersistedStepPayload;
readonly completedAt?: string | null;
}): SagaStepRow {
try {
this.upsertStepStmt.run(
record.sagaId,
record.stepIndex,
record.name,
record.state,
JSON.stringify(record.payload),
record.completedAt ?? null
);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: upsertStep failed",
{
cause: cause as Error,
context: {
sagaId: record.sagaId,
stepIndex: record.stepIndex,
},
}
);
}
const row = this.getStep(record.sagaId, record.stepIndex);
if (row === null) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: upsertStep did not yield a row",
{
context: {
sagaId: record.sagaId,
stepIndex: record.stepIndex,
},
}
);
}
return row;
}
/** Read one step row. `null` when absent. */
public getStep(sagaId: string, stepIndex: number): SagaStepRow | null {
const row = this.readStepStmt.get<StepDbRow>(sagaId, stepIndex);
if (row === undefined || row === null) {
return null;
}
return materialiseStep(row);
}
/** Read every step for one saga, ordered ascending. */
public listSteps(sagaId: string): readonly SagaStepRow[] {
const rows = this.listStepsStmt.all<StepDbRow>(sagaId);
return rows.map(materialiseStep);
}
/** Apply a guarded step transition. */
public transitionStep(
sagaId: string,
stepIndex: number,
to: SagaStepState
): void {
const row = this.getStep(sagaId, stepIndex);
if (row === null) {
throw new SagaError(
SAGA_CODES.INSTANCE_NOT_FOUND,
"persistence: step not found",
{ context: { sagaId, stepIndex } }
);
}
assertStepTransition(sagaId, row.name, row.state, to);
if (row.state === to) {
return;
}
const completedAt =
to === "completed" || to === "compensated"
? this.now().toISOString()
: row.completedAt;
try {
this.updateStepStateStmt.run(to, completedAt, sagaId, stepIndex);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: transitionStep failed",
{
cause: cause as Error,
context: { sagaId, stepIndex, to },
}
);
}
}
/** Overwrite the compensation payload bag for one step. */
public updateStepPayload(
sagaId: string,
stepIndex: number,
payload: PersistedStepPayload
): void {
const row = this.getStep(sagaId, stepIndex);
if (row === null) {
throw new SagaError(
SAGA_CODES.INSTANCE_NOT_FOUND,
"persistence: step not found",
{ context: { sagaId, stepIndex } }
);
}
try {
this.updateStepPayloadStmt.run(
JSON.stringify(payload),
sagaId,
stepIndex
);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: updateStepPayload failed",
{
cause: cause as Error,
context: { sagaId, stepIndex },
}
);
}
}
/** Drop one instance and its steps (admin use). */
public deleteInstance(id: string): boolean {
const before = this.getInstance(id);
if (before === null) {
return false;
}
try {
this.db.run(
`DELETE FROM suaveplan_saga_steps WHERE saga_id = '${id.replace(/'/g, "''")}'`
);
this.deleteInstanceStmt.run(id);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: deleteInstance failed",
{ cause: cause as Error, context: { id } }
);
}
return true;
}
}
function materialiseInstance(row: InstanceDbRow): SagaInstanceRow {
let payload: PersistedInstancePayload;
try {
payload = JSON.parse(row.payload_json) as PersistedInstancePayload;
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: instance payload_json failed JSON parse",
{ cause: cause as Error, context: { id: row.id } }
);
}
const state = row.state as SagaInstanceState;
return {
id: row.id,
pluginName: row.plugin_name,
state,
payload,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function materialiseStep(row: StepDbRow): SagaStepRow {
let payload: PersistedStepPayload;
try {
payload =
row.compensation_payload_json === null
? { input: {}, attempt: 0, compensationAttempts: 0 }
: (JSON.parse(
row.compensation_payload_json
) as PersistedStepPayload);
} catch (cause) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
"persistence: step compensation_payload_json failed JSON parse",
{
cause: cause as Error,
context: {
sagaId: row.saga_id,
stepIndex: row.step_index,
},
}
);
}
const state = row.state as SagaStepState;
return {
sagaId: row.saga_id,
stepIndex: row.step_index,
name: row.name,
state,
payload,
completedAt: row.completed_at,
};
}

View File

@@ -0,0 +1,67 @@
import type { LedgerDb } from "@suaveplan/api-host";
import type {
SagaInstanceState,
SagaStepState,
} from "../state/state-machine.types.js";
/** Re-export the host's `LedgerDb` view for downstream consumers. */
export type { LedgerDb };
/**
* One declared step in a saga, captured in the instance's payload JSON.
* Mirrors the `sagas.owns[].steps[]` manifest declaration.
*/
export interface PersistedStepDeclaration {
readonly plugin: string;
readonly action: string;
readonly compensate: string;
}
/** Persisted instance bag — round-trips through `payload_json`. */
export interface PersistedInstancePayload {
readonly sagaName: string;
readonly tenantId: string;
readonly initiatorTenantId: string;
readonly principalId: string | null;
readonly steps: readonly PersistedStepDeclaration[];
readonly input: Readonly<Record<string, unknown>>;
readonly currentStepIndex: number;
}
/** Persisted step bag — round-trips through `compensation_payload_json`. */
export interface PersistedStepPayload {
readonly input: Readonly<Record<string, unknown>>;
readonly output?: Readonly<Record<string, unknown>>;
readonly errorCode?: string;
readonly errorMessage?: string;
readonly attempt: number;
readonly compensationAttempts: number;
}
/** Materialised view of a `suaveplan_saga_instances` row. */
export interface SagaInstanceRow {
readonly id: string;
readonly pluginName: string;
readonly state: SagaInstanceState;
readonly payload: PersistedInstancePayload;
readonly createdAt: string;
readonly updatedAt: string;
}
/** Materialised view of a `suaveplan_saga_steps` row. */
export interface SagaStepRow {
readonly sagaId: string;
readonly stepIndex: number;
readonly name: string;
readonly state: SagaStepState;
readonly payload: PersistedStepPayload;
readonly completedAt: string | null;
}
/** Filter accepted by `SagaPersistence.list`. */
export interface SagaInstanceListFilter {
readonly state?: SagaInstanceState;
readonly sagaName?: string;
readonly tenantId?: string;
readonly pluginName?: string;
}

View File

@@ -0,0 +1,2 @@
export type { RecoveryResult } from "./recovery.js";
export { assertResumable, recoverInflightSagas } from "./recovery.js";

View File

@@ -0,0 +1,106 @@
# `recovery`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
When a host restarts, the saga orchestrator's in-memory state goes
with it — but the saga itself is durable because every step
transition is persisted via `SagaPersistence` to the host metadata
DB. The recovery module is the boot-time bridge: it scans the
persistence layer for non-terminal instances and asks the
orchestrator to resume each. Per design doc §9.1, this is what makes
"survives host restart by replaying state" a real property of the
foundation.
The recovery sweep is non-fatal — a single broken saga is collected
into the result's `failures` list rather than thrown, so a corrupt
instance doesn't prevent the host from starting. The operator
inspects the list and decides whether to retry, cancel, or
investigate.
## Features
- `recoverInflightSagas(...)` — scans every non-terminal instance
and calls `orchestrator.resume` on each.
- `assertResumable(instance)` — guard that throws
`SAGA.PERSISTENCE_FAILED` when an explicit resume target is
already terminal (admin-triggered single-saga resume).
- Failure isolation — per-saga exceptions are caught and reported in
the result; the sweep continues past them.
- Filter passthrough — callers can scope recovery to a subset
(e.g. resume only `running`, leaving `compensating` for a later
pass).
## Usage: Basic
```ts
import { recoverInflightSagas } from "@suaveplan/api-saga/recovery/recovery";
const result = await recoverInflightSagas({
orchestrator,
persistence,
});
console.log(
`recovered ${result.resumed}/${result.scanned} (${result.failures.length} failures)`
);
```
## Usage: Advanced
The host's startup sequence runs recovery between "DB migrated" and
"begin serving requests" so an HTTP request that arrives before
recovery completes can't observe a half-recovered state. The
failure list is logged AND emitted onto the host's health surface
so the operator can inspect it via `/readyz`:
```ts
import { assertResumable, recoverInflightSagas } from "@suaveplan/api-saga";
const result = await recoverInflightSagas({
orchestrator,
persistence,
filter: { state: "running" }, // resume only running; skip compensating
});
for (const f of result.failures) {
log.warn("recovery failure", { sagaId: f.sagaId, cause: f.cause });
healthReporter.degrade(`saga recovery: ${f.sagaId}`);
}
// Admin-triggered resume of one saga — strict mode rejects if
// already terminal.
const inst = persistence.requireInstance(targetSagaId);
assertResumable(inst);
await orchestrator.resume(targetSagaId);
```
## API Reference
- `recoverInflightSagas({ orchestrator, persistence, filter? }):
Promise<RecoveryResult>`.
- `RecoveryResult`:
- `scanned: number`,
- `resumed: number`,
- `skipped: number` — terminal-state instances the sweep
intentionally ignored,
- `failures: ReadonlyArray<{ sagaId, cause }>`.
- `assertResumable(instance): void` — guard that throws
`SAGA.PERSISTENCE_FAILED` on terminal-state input.
## Implementation Notes
Terminal-state filtering uses `isTerminalInstance` from the state
machine module so the recovery runner and the state machine stay in
sync — adding a new terminal state to the machine table
automatically excludes it from recovery.
The sweep is sequential rather than parallel because saga resume
itself is sequential (it walks steps in declared order) and the
foundation has no story for cross-saga work-stealing. A future
optimisation could parallelise across distinct sagas; for now the
shape is "boot completes when every recovery target has been
visited".

View File

@@ -0,0 +1,209 @@
import { Database } from "bun:sqlite";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { applySagaSchema } from "../../tests/setup/saga-schema.js";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type { SagaOrchestrator } from "../orchestrator/orchestrator.types.js";
import { SagaPersistence } from "../persistence/persistence.js";
import type {
LedgerDb,
SagaInstanceRow,
} from "../persistence/persistence.types.js";
import { assertResumable, recoverInflightSagas } from "./recovery.js";
function makePersistence(): { persistence: SagaPersistence; db: LedgerDb } {
const db = new Database(":memory:") as unknown as LedgerDb;
applySagaSchema(db);
return { persistence: new SagaPersistence({ db }), db };
}
interface FakeOrchestrator extends SagaOrchestrator {
readonly resumed: string[];
}
function makeOrchestrator(
persistence: SagaPersistence,
behaviour: (id: string) => "ok" | "throw" = () => "ok"
): FakeOrchestrator {
const resumed: string[] = [];
return {
resumed,
async start() {
throw new Error("not used");
},
async resume(sagaId: string): Promise<SagaInstanceRow> {
resumed.push(sagaId);
if (behaviour(sagaId) === "throw") {
throw new Error(`failure for ${sagaId}`);
}
const current = persistence.requireInstance(sagaId);
// Drive the FSM through a legal next state per the
// state machine so this fake works for both `running` and
// `compensating` inputs.
if (current.state === "running") {
persistence.transitionInstance(sagaId, "completed");
} else if (current.state === "compensating") {
persistence.transitionInstance(sagaId, "compensated");
}
return persistence.requireInstance(sagaId);
},
async cancel(sagaId: string) {
return persistence.requireInstance(sagaId);
},
listInstances(filter) {
return persistence.listInstances(filter ?? {});
},
getInstance(sagaId: string) {
return persistence.getInstance(sagaId);
},
} as FakeOrchestrator;
}
describe("recovery", () => {
function seed(persistence: SagaPersistence): void {
persistence.createInstance({
id: "s-run",
pluginName: "p",
state: "running",
payload: {
sagaName: "x",
tenantId: "t",
initiatorTenantId: "t",
principalId: null,
steps: [{ plugin: "p", action: "a", compensate: "c" }],
input: {},
currentStepIndex: 0,
},
});
persistence.createInstance({
id: "s-comp",
pluginName: "p",
state: "compensating",
payload: {
sagaName: "x",
tenantId: "t",
initiatorTenantId: "t",
principalId: null,
steps: [{ plugin: "p", action: "a", compensate: "c" }],
input: {},
currentStepIndex: 1,
},
});
persistence.createInstance({
id: "s-done",
pluginName: "p",
state: "completed",
payload: {
sagaName: "x",
tenantId: "t",
initiatorTenantId: "t",
principalId: null,
steps: [{ plugin: "p", action: "a", compensate: "c" }],
input: {},
currentStepIndex: 1,
},
});
}
it("resumes every non-terminal saga", { timeout: 5000 }, async () => {
const { persistence } = makePersistence();
seed(persistence);
const orch = makeOrchestrator(persistence);
const result = await recoverInflightSagas({
orchestrator: orch,
persistence,
});
expect(result.scanned).toBe(3);
expect(result.resumed).toBe(2);
expect(result.skipped).toBe(1);
expect(result.failures).toEqual([]);
expect(orch.resumed.sort()).toEqual(["s-comp", "s-run"]);
});
it("collects failures rather than throwing", {
timeout: 5000,
}, async () => {
const { persistence } = makePersistence();
seed(persistence);
const orch = makeOrchestrator(persistence, (id) =>
id === "s-comp" ? "throw" : "ok"
);
const result = await recoverInflightSagas({
orchestrator: orch,
persistence,
});
expect(result.resumed).toBe(1);
expect(result.failures).toHaveLength(1);
expect(result.failures[0]?.sagaId).toBe("s-comp");
expect(result.failures[0]?.cause).toBeInstanceOf(Error);
});
it("honours the filter", { timeout: 5000 }, async () => {
const { persistence } = makePersistence();
seed(persistence);
const orch = makeOrchestrator(persistence);
const result = await recoverInflightSagas({
orchestrator: orch,
persistence,
filter: { state: "running" },
});
expect(result.scanned).toBe(1);
expect(result.resumed).toBe(1);
expect(orch.resumed).toEqual(["s-run"]);
});
it("captures non-Error throws", { timeout: 5000 }, async () => {
const { persistence } = makePersistence();
persistence.createInstance({
id: "s-z",
pluginName: "p",
state: "running",
payload: {
sagaName: "x",
tenantId: "t",
initiatorTenantId: "t",
principalId: null,
steps: [{ plugin: "p", action: "a", compensate: "c" }],
input: {},
currentStepIndex: 0,
},
});
const orch = makeOrchestrator(persistence);
const orchThrowingPrimitive: SagaOrchestrator = {
...orch,
async resume() {
throw "primitive";
},
};
const result = await recoverInflightSagas({
orchestrator: orchThrowingPrimitive,
persistence,
});
expect(result.failures[0]?.cause).toBeInstanceOf(Error);
});
it("assertResumable accepts a running instance", {
timeout: 5000,
}, () => {
const { persistence } = makePersistence();
seed(persistence);
expect(() =>
assertResumable(persistence.requireInstance("s-run"))
).not.toThrow();
});
it("assertResumable rejects a terminal-state instance", {
timeout: 5000,
}, () => {
const { persistence } = makePersistence();
seed(persistence);
try {
assertResumable(persistence.requireInstance("s-done"));
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.PERSISTENCE_FAILED);
}
}
});
});

View File

@@ -0,0 +1,76 @@
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type { SagaOrchestrator } from "../orchestrator/orchestrator.types.js";
import type { SagaPersistence } from "../persistence/persistence.js";
import type {
SagaInstanceListFilter,
SagaInstanceRow,
} from "../persistence/persistence.types.js";
import { isTerminalInstance } from "../state/state-machine.js";
/** Outcome of a recovery sweep. */
export interface RecoveryResult {
readonly scanned: number;
readonly resumed: number;
readonly skipped: number;
readonly failures: ReadonlyArray<{
readonly sagaId: string;
readonly cause: Error;
}>;
}
/**
* Walk every non-terminal saga in the persistence layer and ask the
* orchestrator to resume each. Designed to be invoked at host boot
* before the host starts serving requests so a saga that was
* `running` at the previous host's death continues from its
* persisted state.
*
* Failures during resume are collected into `result.failures` rather
* than thrown — a single broken saga shouldn't prevent the host from
* starting; the operator can inspect the failure list and take
* targeted action.
*/
export async function recoverInflightSagas(args: {
readonly orchestrator: SagaOrchestrator;
readonly persistence: SagaPersistence;
readonly filter?: SagaInstanceListFilter;
}): Promise<RecoveryResult> {
const { orchestrator, persistence, filter } = args;
const all = persistence.listInstances(filter ?? {});
let resumed = 0;
let skipped = 0;
const failures: Array<{ sagaId: string; cause: Error }> = [];
for (const inst of all) {
if (isTerminalInstance(inst.state)) {
skipped += 1;
continue;
}
try {
await orchestrator.resume(inst.id);
resumed += 1;
} catch (cause) {
failures.push({
sagaId: inst.id,
cause:
cause instanceof Error ? cause : new Error(String(cause)),
});
}
}
return { scanned: all.length, resumed, skipped, failures };
}
/**
* Convenience guard that raises a `SAGA.PERSISTENCE_FAILED` error
* when an attempted recovery target is not in a resumable state.
* Callers that want strict semantics (admin-triggered "resume this
* specific saga") call this before delegating to `orchestrator.resume`.
*/
export function assertResumable(instance: SagaInstanceRow): void {
if (isTerminalInstance(instance.state)) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
`saga: instance '${instance.id}' is already in terminal state '${instance.state}'`,
{ context: { sagaId: instance.id, state: instance.state } }
);
}
}

View File

@@ -0,0 +1,16 @@
export {
defineSagaParticipant,
participantKey,
SagaParticipantRegistry,
sagaKey,
} from "./participant-registry.js";
export type {
RegisteredSagaOwner,
SagaActionHandler,
SagaCompensateHandler,
SagaIdempotencyMode,
SagaOwnerDeclaration,
SagaParticipantRegistration,
SagaParticipationKey,
SagaStepDeclaration,
} from "./participant-registry.types.js";

View File

@@ -0,0 +1,137 @@
# `registry/participant-registry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The participant registry is the host-side compendium of every plugin
that owns a saga and every plugin that participates in one. The host
populates it from each plugin's manifest — `sagas.owns[]` declares
owners; `sagas.participatesIn[]` plus exported `actions` /
`compensations` declare participants. At install time the registry
validates that every owner's declared steps name a plugin + action
that actually exists in the participant table, and refuses two
plugins both claiming to own the same saga name.
This module is the single source of truth the orchestrator queries at
`start(...)` and at every step dispatch — `requireOwner(sagaName)`
yields the declared step list, and `requireParticipant(plugin,
action)` yields the bound handler + compensate function.
## Features
- Bi-directional indexes (`plugin/action → registration`,
`ownerPlugin/sagaName → owner record`, `sagaName → ownerPlugin`).
- Validation on `registerOwner` — every declared step's wiring is
checked against the registered participants up-front so a typo
surfaces at install, not at the first dispatch.
- Duplicate-owner detection via the `sagaName → ownerPlugin` index;
the second registration raises `SAGA.DUPLICATE_OWNER`.
- Cross-compensation validation — when a step declares
`compensate: "x"` but the participant's `compensateName` is `"y"`,
the install fails with `SAGA.UNKNOWN_PARTICIPANT` so a manifest
drift is caught before it can hit production.
- `defineSagaParticipant<TInput, TOutput>` typed builder so plugin
authors don't lose generic information at the registration site.
## Usage: Basic
```ts
import {
SagaParticipantRegistry,
defineSagaParticipant,
} from "@suaveplan/api-saga/registry/participant-registry";
const registry = new SagaParticipantRegistry();
registry.registerParticipant(
defineSagaParticipant({
plugin: "billing",
action: "charge",
handler: async (input) => ({ chargeId: "ch_1" }),
compensate: async ({ output }) => {
await refund((output as { chargeId: string }).chargeId);
},
compensateName: "refund",
})
);
registry.registerOwner("billing", {
name: "checkout",
steps: [
{ plugin: "billing", action: "charge", compensate: "refund" },
],
});
const owner = registry.requireOwner("checkout");
```
## Usage: Advanced
The orchestrator dispatches into the registry to honour the
`saga.cross-tenant` capability gate — the owner record carries an
optional `capabilities` list, and the orchestrator runtime asks the
registry "does this saga's owner declare X?" before deciding whether
to refuse a cross-tenant dispatch:
```ts
import { SagaParticipantRegistry } from "@suaveplan/api-saga";
const owner = registry.requireOwner("checkout");
if (
initiatorTenantId !== targetTenantId &&
!(owner.saga.capabilities ?? []).includes("saga.cross-tenant")
) {
throw new SagaError(
SAGA_CODES.CROSS_TENANT_DENIED,
"saga: cross-tenant dispatch without capability"
);
}
```
A second `registerOwner` of the same name from a different plugin is
refused:
```ts
try {
registry.registerOwner("other-billing", {
name: "checkout",
steps: [...]
});
} catch (err) {
if (err instanceof SagaError && err.code === SAGA_CODES.DUPLICATE_OWNER) {
// expected — surface to the operator
}
}
```
## API Reference
- `SagaParticipantRegistry`:
- `registerParticipant(registration)` /
`getParticipant(plugin, action)` /
`requireParticipant(plugin, action)`.
- `registerOwner(pluginName, saga)` /
`getOwner(sagaName)` / `requireOwner(sagaName)`.
- `listOwners()` / `listParticipants()`.
- `defineSagaParticipant<TInput, TOutput>(spec)` — type-preserving
builder.
- `participantKey(plugin, action)`, `sagaKey(ownerPlugin, sagaName)`
— exported so callers can derive the same hash key.
## Implementation Notes
Validation runs on `registerOwner` rather than on first dispatch
because a missing-action error is much easier to debug at install
time, when the operator can connect it to a manifest typo, than at
2 a.m. when the saga fires for the first time in production. The
`compensateName` check is the same idea — it pins the wire-name
contract between the manifest and the bound handler.
Two registry-internal indexes (`participantKey` and `sagaKey`)
deliberately use stringified composite keys rather than nested maps
so `.size` / `.values()` / iteration semantics match what the
in-memory keystore expects. The `ownersByName` shadow index is what
gives the duplicate-owner check its O(1) lookup.

View File

@@ -0,0 +1,239 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import {
defineSagaParticipant,
participantKey,
SagaParticipantRegistry,
sagaKey,
} from "./participant-registry.js";
async function noopAction(): Promise<Readonly<Record<string, unknown>>> {
return {};
}
describe("participant-registry", () => {
let registry: SagaParticipantRegistry;
beforeEach(() => {
registry = new SagaParticipantRegistry();
});
it("participantKey + sagaKey produce stable composites", {
timeout: 5000,
}, () => {
expect(participantKey("p", "a")).toBe("p::a");
expect(sagaKey("p", "s")).toBe("p/s");
});
it("defineSagaParticipant carries through optional fields", {
timeout: 5000,
}, () => {
const reg = defineSagaParticipant({
plugin: "p",
action: "a",
handler: async () => ({}),
compensate: async () => undefined,
compensateName: "c",
idempotency: "manual",
timeoutMs: 1000,
});
expect(reg.plugin).toBe("p");
expect(reg.compensateName).toBe("c");
expect(reg.idempotency).toBe("manual");
expect(reg.timeoutMs).toBe(1000);
const minimal = defineSagaParticipant({
plugin: "p",
action: "a",
handler: async () => ({}),
});
expect(minimal.compensate).toBeUndefined();
expect(minimal.compensateName).toBeUndefined();
expect(minimal.idempotency).toBeUndefined();
expect(minimal.timeoutMs).toBeUndefined();
});
it("requireParticipant throws UNKNOWN_PARTICIPANT when absent", {
timeout: 5000,
}, () => {
try {
registry.requireParticipant("missing", "a");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.UNKNOWN_PARTICIPANT);
}
}
});
it("requireOwner throws UNKNOWN_NAME when absent", {
timeout: 5000,
}, () => {
try {
registry.requireOwner("nope");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.UNKNOWN_NAME);
}
}
});
it("validates owner + participant wiring", { timeout: 5000 }, () => {
registry.registerParticipant(
defineSagaParticipant({
plugin: "B",
action: "a",
handler: noopAction,
compensate: async () => undefined,
compensateName: "c",
})
);
const owner = registry.registerOwner("A", {
name: "X",
steps: [{ plugin: "B", action: "a", compensate: "c" }],
});
expect(owner.saga.name).toBe("X");
expect(registry.requireOwner("X").pluginName).toBe("A");
expect(registry.listOwners()).toHaveLength(1);
expect(registry.listParticipants()).toHaveLength(1);
});
it("rejects duplicate saga owner with SAGA_DUPLICATE_OWNER", {
timeout: 5000,
}, () => {
registry.registerParticipant(
defineSagaParticipant({
plugin: "B",
action: "a",
handler: noopAction,
compensate: async () => undefined,
compensateName: "c",
})
);
registry.registerOwner("A", {
name: "X",
steps: [{ plugin: "B", action: "a", compensate: "c" }],
});
try {
registry.registerOwner("C", {
name: "X",
steps: [{ plugin: "B", action: "a", compensate: "c" }],
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.DUPLICATE_OWNER);
}
}
});
it("rejects missing participant action with SAGA_UNKNOWN_PARTICIPANT", {
timeout: 5000,
}, () => {
try {
registry.registerOwner("A", {
name: "X",
steps: [{ plugin: "B", action: "missing", compensate: "c" }],
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.UNKNOWN_PARTICIPANT);
}
}
});
it("rejects a compensate-name mismatch with UNKNOWN_PARTICIPANT", {
timeout: 5000,
}, () => {
registry.registerParticipant(
defineSagaParticipant({
plugin: "B",
action: "a",
handler: noopAction,
compensate: async () => undefined,
compensateName: "compensateX",
})
);
try {
registry.registerOwner("A", {
name: "X",
steps: [{ plugin: "B", action: "a", compensate: "wrong" }],
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.UNKNOWN_PARTICIPANT);
}
}
});
it("rejects a step whose participant has no compensate handler", {
timeout: 5000,
}, () => {
registry.registerParticipant(
defineSagaParticipant({
plugin: "B",
action: "a",
handler: noopAction,
})
);
try {
registry.registerOwner("A", {
name: "X",
steps: [{ plugin: "B", action: "a", compensate: "c" }],
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.UNKNOWN_PARTICIPANT);
}
}
});
it("rejects zero-step owner declarations", { timeout: 5000 }, () => {
try {
registry.registerOwner("A", { name: "X", steps: [] });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.UNKNOWN_PARTICIPANT);
}
}
});
it("getOwner returns null for missing saga", { timeout: 5000 }, () => {
expect(registry.getOwner("missing")).toBe(null);
});
it("re-registering the same owner is idempotent", { timeout: 5000 }, () => {
registry.registerParticipant(
defineSagaParticipant({
plugin: "B",
action: "a",
handler: noopAction,
compensate: async () => undefined,
compensateName: "c",
})
);
registry.registerOwner("A", {
name: "X",
steps: [{ plugin: "B", action: "a", compensate: "c" }],
});
// Same owner re-registering itself — allowed (e.g. hot reload).
const again = registry.registerOwner("A", {
name: "X",
steps: [{ plugin: "B", action: "a", compensate: "c" }],
});
expect(again.pluginName).toBe("A");
expect(registry.listOwners()).toHaveLength(1);
});
});

View File

@@ -0,0 +1,249 @@
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type {
RegisteredSagaOwner,
SagaActionHandler,
SagaCompensateHandler,
SagaIdempotencyMode,
SagaOwnerDeclaration,
SagaParticipantRegistration,
SagaStepDeclaration,
} from "./participant-registry.types.js";
/**
* Build the `{plugin, action}` key the registry hashes participants by.
* Exported so other modules can derive the same key when looking up.
*/
export function participantKey(plugin: string, action: string): string {
return `${plugin}::${action}`;
}
/**
* Build the `{ownerPlugin, sagaName}` key the registry hashes owned
* sagas by — matches the wire-format `participatesIn[]` entry.
*/
export function sagaKey(ownerPlugin: string, sagaName: string): string {
return `${ownerPlugin}/${sagaName}`;
}
/**
* Manifest-driven registry of saga owners and step participants.
*
* The host populates the registry from each plugin's manifest at install
* time. Owners declare `sagas.owns[]`; participants declare
* `sagas.participatesIn[]` plus exported `actions` / `compensations`
* the orchestrator dispatches into. The registry validates the wiring
* up-front — every named step's plugin MUST exist, MUST export the
* named action, and (when the step declares a `compensate`) MUST
* export the named compensation.
*/
export class SagaParticipantRegistry {
/** participantKey → registration. */
private readonly participants = new Map<
string,
SagaParticipantRegistration
>();
/** sagaKey → owner registration. */
private readonly sagas = new Map<string, RegisteredSagaOwner>();
/**
* Index of owners by saga name only, used to detect duplicate
* owners (two plugins both claiming `owns: ["X"]`).
*/
private readonly ownersByName = new Map<string, string>();
/** Register one participant action handler. */
public registerParticipant(
registration: SagaParticipantRegistration
): void {
const key = participantKey(registration.plugin, registration.action);
this.participants.set(key, registration);
}
/** Lookup one participant, or `null` when absent. */
public getParticipant(
plugin: string,
action: string
): SagaParticipantRegistration | null {
return this.participants.get(participantKey(plugin, action)) ?? null;
}
/**
* Require the participant to exist; throw `UNKNOWN_PARTICIPANT`
* otherwise. Use this at orchestrator dispatch time.
*/
public requireParticipant(
plugin: string,
action: string
): SagaParticipantRegistration {
const reg = this.getParticipant(plugin, action);
if (reg === null) {
throw new SagaError(
SAGA_CODES.UNKNOWN_PARTICIPANT,
`saga: participant ${plugin}.${action} is not registered`,
{ context: { plugin, action } }
);
}
return reg;
}
/**
* Register one owner — typically called from the host after the
* plugin's manifest has been parsed. Validates every declared step
* against the participant table; refuses duplicate owners.
*/
public registerOwner(
pluginName: string,
saga: SagaOwnerDeclaration
): RegisteredSagaOwner {
const existing = this.ownersByName.get(saga.name);
if (existing !== undefined && existing !== pluginName) {
throw new SagaError(
SAGA_CODES.DUPLICATE_OWNER,
`saga: '${saga.name}' is already owned by '${existing}'`,
{
context: {
sagaName: saga.name,
existingOwner: existing,
attemptedOwner: pluginName,
},
}
);
}
// Validate every step's wiring up-front.
if (saga.steps.length === 0) {
throw new SagaError(
SAGA_CODES.UNKNOWN_PARTICIPANT,
"saga: owner declared zero steps",
{ context: { sagaName: saga.name } }
);
}
for (const step of saga.steps) {
const participant = this.getParticipant(step.plugin, step.action);
if (participant === null) {
throw new SagaError(
SAGA_CODES.UNKNOWN_PARTICIPANT,
`saga: step '${step.action}' on '${step.plugin}' has no registered action`,
{
context: {
sagaName: saga.name,
plugin: step.plugin,
action: step.action,
},
}
);
}
if (
participant.compensateName !== undefined &&
participant.compensateName !== step.compensate
) {
throw new SagaError(
SAGA_CODES.UNKNOWN_PARTICIPANT,
`saga: step '${step.action}' declares compensate '${step.compensate}' but participant exports '${participant.compensateName}'`,
{
context: {
sagaName: saga.name,
plugin: step.plugin,
action: step.action,
declaredCompensate: step.compensate,
registeredCompensate: participant.compensateName,
},
}
);
}
if (participant.compensate === undefined) {
throw new SagaError(
SAGA_CODES.UNKNOWN_PARTICIPANT,
`saga: step '${step.action}' on '${step.plugin}' declares compensate '${step.compensate}' but participant did not register a compensation handler`,
{
context: {
sagaName: saga.name,
plugin: step.plugin,
action: step.action,
declaredCompensate: step.compensate,
},
}
);
}
}
const registered: RegisteredSagaOwner = {
pluginName,
saga,
};
this.sagas.set(sagaKey(pluginName, saga.name), registered);
this.ownersByName.set(saga.name, pluginName);
return registered;
}
/** Lookup an owned saga by name. Returns `null` when unknown. */
public getOwner(sagaName: string): RegisteredSagaOwner | null {
const ownerPlugin = this.ownersByName.get(sagaName);
if (ownerPlugin === undefined) {
return null;
}
return this.sagas.get(sagaKey(ownerPlugin, sagaName)) ?? null;
}
/**
* Require an owned saga to exist; throw `UNKNOWN_NAME` otherwise.
* The orchestrator's `start(name, ...)` calls this so an unknown
* saga name fails before any side-effect is observed.
*/
public requireOwner(sagaName: string): RegisteredSagaOwner {
const owner = this.getOwner(sagaName);
if (owner === null) {
throw new SagaError(
SAGA_CODES.UNKNOWN_NAME,
`saga: no owner registered for '${sagaName}'`,
{ context: { sagaName } }
);
}
return owner;
}
/** Snapshot of every registered owner. */
public listOwners(): readonly RegisteredSagaOwner[] {
return [...this.sagas.values()];
}
/** Snapshot of every registered participant. */
public listParticipants(): readonly SagaParticipantRegistration[] {
return [...this.participants.values()];
}
}
/**
* Builder helper — surface a tiny façade plugins can use to declare a
* participant without manually populating the registration record.
*/
export function defineSagaParticipant<
TInput extends Readonly<Record<string, unknown>>,
TOutput extends Readonly<Record<string, unknown>>,
>(spec: {
readonly plugin: string;
readonly action: string;
readonly handler: SagaActionHandler<TInput, TOutput>;
readonly compensate?: SagaCompensateHandler<TInput, TOutput>;
readonly compensateName?: string;
readonly idempotency?: SagaIdempotencyMode;
readonly timeoutMs?: number;
}): SagaParticipantRegistration {
return {
plugin: spec.plugin,
action: spec.action,
handler: spec.handler as SagaActionHandler,
...(spec.compensate !== undefined
? { compensate: spec.compensate as SagaCompensateHandler }
: {}),
...(spec.compensateName !== undefined
? { compensateName: spec.compensateName }
: {}),
...(spec.idempotency !== undefined
? { idempotency: spec.idempotency }
: {}),
...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
};
}
/** Re-export of the manifest step declaration type for convenience. */
export type { SagaStepDeclaration };

View File

@@ -0,0 +1,75 @@
import type { RequestContext } from "@suaveplan/api-types";
/**
* Step handler signature — the orchestrator invokes this when a step is
* dispatched. The handler is invoked under a `RequestContext` carrying
* the saga's initiator principal + target tenant.
*
* Returning a value advances the orchestrator to the next step;
* throwing triggers compensation in reverse order.
*/
export type SagaActionHandler<
TInput extends Readonly<Record<string, unknown>> = Readonly<
Record<string, unknown>
>,
TOutput extends Readonly<Record<string, unknown>> = Readonly<
Record<string, unknown>
>,
> = (input: TInput, ctx: RequestContext) => Promise<TOutput>;
/** Compensation-handler signature; receives the original input + output. */
export type SagaCompensateHandler<
TInput extends Readonly<Record<string, unknown>> = Readonly<
Record<string, unknown>
>,
TOutput extends Readonly<Record<string, unknown>> = Readonly<
Record<string, unknown>
>,
> = (
args: { input: TInput; output: TOutput | undefined },
ctx: RequestContext
) => Promise<void>;
/**
* Idempotency mode for a step. `auto` (the default) runs the handler
* inside the `@suaveplan/idempotency` guard keyed by `{sagaId,
* stepName}`; `manual` opts out, leaving dedup to the handler itself.
*/
export type SagaIdempotencyMode = "auto" | "manual";
/** Participant registration entry — one per `actions[name]` on a plugin. */
export interface SagaParticipantRegistration {
readonly plugin: string;
readonly action: string;
readonly handler: SagaActionHandler;
readonly compensate?: SagaCompensateHandler;
readonly compensateName?: string;
readonly idempotency?: SagaIdempotencyMode;
readonly timeoutMs?: number;
}
/** Manifest-level step declaration consumed by the registry validator. */
export interface SagaStepDeclaration {
readonly plugin: string;
readonly action: string;
readonly compensate: string;
}
/** Manifest-level saga declaration consumed by the registry validator. */
export interface SagaOwnerDeclaration {
readonly name: string;
readonly steps: readonly SagaStepDeclaration[];
/** Optional per-saga capability set (e.g. `["saga.cross-tenant"]`). */
readonly capabilities?: readonly string[];
/** Optional per-saga max compensation attempts override. */
readonly maxCompensationAttempts?: number;
}
/** A `sagas.participatesIn[]` entry — `"owner-plugin/saga-name"`. */
export type SagaParticipationKey = string;
/** Registered owner record produced by the registry on accept. */
export interface RegisteredSagaOwner {
readonly pluginName: string;
readonly saga: SagaOwnerDeclaration;
}

View File

@@ -0,0 +1,14 @@
export {
assertInstanceTransition,
assertStepTransition,
canTransitionInstance,
canTransitionStep,
isTerminalInstance,
isTerminalStep,
TERMINAL_INSTANCE_STATES,
TERMINAL_STEP_STATES,
} from "./state-machine.js";
export type {
SagaInstanceState,
SagaStepState,
} from "./state-machine.types.js";

View File

@@ -0,0 +1,95 @@
# `state/state-machine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The saga lifecycle has two coupled finite-state machines: the instance
state (overall saga health) and the per-step state. This module pins
both transition tables in one place so every other module — orchestrator,
recovery, compensation — funnels every write through a single guard. The
guard refuses transitions the table does not declare and raises
`SAGA.INVALID_STATE_TRANSITION` so a buggy orchestrator path can never
silently demote `completed` back into `running` (or, more dangerously,
out of a terminal state into a runnable one).
## Features
- Instance machine: `running → {completed, compensating, cancelled}` and
`compensating → {compensated, compensation_failed}`. Terminal states
(`completed`, `compensated`, `compensation_failed`, `cancelled`) have
no outgoing transitions.
- Step machine: `pending → in_flight → {completed, failed}`; completed
steps may roll back via `compensating → {compensated, failed}`.
- `assertInstanceTransition` / `assertStepTransition` throw
`SagaError(SAGA.INVALID_STATE_TRANSITION)` with full context.
- `isTerminalInstance` / `isTerminalStep` predicates so the recovery
runner can skip already-settled instances.
- Idempotent same-state transitions (`from === to`) are accepted as a
no-op — useful for re-asserting state during recovery replay.
## Usage: Basic
```ts
import {
assertInstanceTransition,
canTransitionInstance,
} from "@suaveplan/api-saga/state/state-machine";
assertInstanceTransition("s_1", "running", "completed"); // ok
canTransitionInstance("completed", "running"); // false
```
## Usage: Advanced
Compose with the persistence layer so every DB row write is gated on
the state machine. Same-state writes are accepted but a forbidden
transition halts the orchestrator before the DB is mutated:
```ts
import {
assertStepTransition,
canTransitionStep,
} from "@suaveplan/api-saga";
function updateStep(
sagaId: string,
stepName: string,
current: SagaStepState,
next: SagaStepState
): void {
assertStepTransition(sagaId, stepName, current, next);
if (current === next) {
return; // idempotent — DB already reflects the state
}
persistence.updateStep(sagaId, stepName, next);
}
```
## API Reference
- `SagaInstanceState` — `"running" | "completed" | "compensating" |
"compensated" | "compensation_failed" | "cancelled"`.
- `SagaStepState` — `"pending" | "in_flight" | "completed" | "failed" |
"compensating" | "compensated"`.
- `TERMINAL_INSTANCE_STATES`, `TERMINAL_STEP_STATES` — frozen sets.
- `canTransitionInstance(from, to): boolean`,
`canTransitionStep(from, to): boolean` — predicates.
- `assertInstanceTransition(sagaId, from, to): void`,
`assertStepTransition(sagaId, stepName, from, to): void` — guard
functions that throw on rejected transitions.
- `isTerminalInstance(state): boolean`, `isTerminalStep(state): boolean`
— predicates the recovery runner uses to filter.
## Implementation Notes
The transition tables are `ReadonlyMap<State, ReadonlySet<State>>` so
they're immutable at the type level — a downstream module cannot patch
the table at runtime. The compensation machine intentionally allows
`compensating → failed`: a single compensate-handler failure does not
yet mean the saga's terminal failure (the compensation runner retries
up to `maxCompensationAttempts`); the per-step `failed` state is
transient in that sense, and the instance-level transition into
`compensation_failed` only fires once every retry budget is exhausted.

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import {
assertInstanceTransition,
assertStepTransition,
canTransitionInstance,
canTransitionStep,
isTerminalInstance,
isTerminalStep,
} from "./state-machine.js";
describe("state-machine — instance", () => {
it("allows the documented forward transitions", { timeout: 5000 }, () => {
expect(canTransitionInstance("running", "completed")).toBe(true);
expect(canTransitionInstance("running", "compensating")).toBe(true);
expect(canTransitionInstance("running", "cancelled")).toBe(true);
expect(canTransitionInstance("compensating", "compensated")).toBe(true);
expect(
canTransitionInstance("compensating", "compensation_failed")
).toBe(true);
});
it("accepts idempotent same-state transitions", { timeout: 5000 }, () => {
expect(canTransitionInstance("running", "running")).toBe(true);
expect(canTransitionInstance("completed", "completed")).toBe(true);
});
it("rejects transitions out of terminal states", { timeout: 5000 }, () => {
expect(canTransitionInstance("completed", "running")).toBe(false);
expect(canTransitionInstance("compensated", "running")).toBe(false);
expect(canTransitionInstance("compensation_failed", "completed")).toBe(
false
);
expect(canTransitionInstance("cancelled", "running")).toBe(false);
});
it("assertInstanceTransition throws INVALID_STATE_TRANSITION", {
timeout: 5000,
}, () => {
try {
assertInstanceTransition("s_1", "completed", "running");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INVALID_STATE_TRANSITION);
expect(err.context).toEqual({
sagaId: "s_1",
from: "completed",
to: "running",
});
}
}
});
it("assertInstanceTransition accepts a valid transition", {
timeout: 5000,
}, () => {
expect(() =>
assertInstanceTransition("s_1", "running", "completed")
).not.toThrow();
});
it("isTerminalInstance flags terminal states", { timeout: 5000 }, () => {
expect(isTerminalInstance("completed")).toBe(true);
expect(isTerminalInstance("compensated")).toBe(true);
expect(isTerminalInstance("compensation_failed")).toBe(true);
expect(isTerminalInstance("cancelled")).toBe(true);
expect(isTerminalInstance("running")).toBe(false);
expect(isTerminalInstance("compensating")).toBe(false);
});
});
describe("state-machine — step", () => {
it("allows pending → in_flight → completed", { timeout: 5000 }, () => {
expect(canTransitionStep("pending", "in_flight")).toBe(true);
expect(canTransitionStep("in_flight", "completed")).toBe(true);
});
it("allows compensation cycle", { timeout: 5000 }, () => {
expect(canTransitionStep("completed", "compensating")).toBe(true);
expect(canTransitionStep("compensating", "compensated")).toBe(true);
expect(canTransitionStep("compensating", "failed")).toBe(true);
});
it("rejects skip-ahead and out-of-terminal transitions", {
timeout: 5000,
}, () => {
expect(canTransitionStep("pending", "completed")).toBe(false);
expect(canTransitionStep("compensated", "in_flight")).toBe(false);
expect(canTransitionStep("failed", "compensating")).toBe(false);
});
it("assertStepTransition throws on rejected pair", {
timeout: 5000,
}, () => {
try {
assertStepTransition("s_1", "step-a", "completed", "pending");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.INVALID_STATE_TRANSITION);
expect(err.context).toEqual({
sagaId: "s_1",
stepName: "step-a",
from: "completed",
to: "pending",
});
}
}
});
it("assertStepTransition accepts same-state and valid transitions", {
timeout: 5000,
}, () => {
expect(() =>
assertStepTransition("s_1", "step-a", "pending", "pending")
).not.toThrow();
expect(() =>
assertStepTransition("s_1", "step-a", "pending", "in_flight")
).not.toThrow();
});
it("isTerminalStep flags terminal states", { timeout: 5000 }, () => {
expect(isTerminalStep("completed")).toBe(true);
expect(isTerminalStep("compensated")).toBe(true);
expect(isTerminalStep("pending")).toBe(false);
expect(isTerminalStep("in_flight")).toBe(false);
expect(isTerminalStep("failed")).toBe(false);
expect(isTerminalStep("compensating")).toBe(false);
});
});

View File

@@ -0,0 +1,126 @@
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type {
SagaInstanceState,
SagaStepState,
} from "./state-machine.types.js";
/** Terminal states — once entered no further transitions are allowed. */
export const TERMINAL_INSTANCE_STATES: ReadonlySet<SagaInstanceState> = new Set(
["completed", "compensated", "compensation_failed", "cancelled"]
);
/** Terminal step states. */
export const TERMINAL_STEP_STATES: ReadonlySet<SagaStepState> = new Set([
"completed",
"compensated",
]);
/**
* Allowed instance-state transitions. Every entry maps `from → set of
* legal next states`. Any transition outside this table fails with
* `SAGA.INVALID_STATE_TRANSITION` so a misbehaving orchestrator path
* never silently demotes (e.g.) a `completed` instance back into
* `running`.
*/
const INSTANCE_TRANSITIONS: ReadonlyMap<
SagaInstanceState,
ReadonlySet<SagaInstanceState>
> = new Map([
[
"running",
new Set<SagaInstanceState>(["completed", "compensating", "cancelled"]),
],
[
"compensating",
new Set<SagaInstanceState>(["compensated", "compensation_failed"]),
],
["completed", new Set<SagaInstanceState>([])],
["compensated", new Set<SagaInstanceState>([])],
["compensation_failed", new Set<SagaInstanceState>([])],
["cancelled", new Set<SagaInstanceState>([])],
]);
/** Allowed per-step transitions. */
const STEP_TRANSITIONS: ReadonlyMap<
SagaStepState,
ReadonlySet<SagaStepState>
> = new Map([
["pending", new Set<SagaStepState>(["in_flight"])],
["in_flight", new Set<SagaStepState>(["completed", "failed"])],
["completed", new Set<SagaStepState>(["compensating"])],
["failed", new Set<SagaStepState>([])],
["compensating", new Set<SagaStepState>(["compensated", "failed"])],
["compensated", new Set<SagaStepState>([])],
]);
/**
* Returns whether the instance state pair is a permitted transition.
* `from === to` is treated as an idempotent no-op and accepted.
*/
export function canTransitionInstance(
from: SagaInstanceState,
to: SagaInstanceState
): boolean {
if (from === to) {
return true;
}
const allowed = INSTANCE_TRANSITIONS.get(from);
return allowed?.has(to) ?? false;
}
/** As {@link canTransitionInstance} but for the step machine. */
export function canTransitionStep(
from: SagaStepState,
to: SagaStepState
): boolean {
if (from === to) {
return true;
}
const allowed = STEP_TRANSITIONS.get(from);
return allowed?.has(to) ?? false;
}
/**
* Assert that an instance-level transition is legal. Throws
* `SagaError(SAGA.INVALID_STATE_TRANSITION)` with a context bag carrying
* the offending pair when the transition is rejected.
*/
export function assertInstanceTransition(
sagaId: string,
from: SagaInstanceState,
to: SagaInstanceState
): void {
if (!canTransitionInstance(from, to)) {
throw new SagaError(
SAGA_CODES.INVALID_STATE_TRANSITION,
`saga: invalid instance transition ${from} → ${to}`,
{ context: { sagaId, from, to } }
);
}
}
/** Assert a step-level transition is legal. */
export function assertStepTransition(
sagaId: string,
stepName: string,
from: SagaStepState,
to: SagaStepState
): void {
if (!canTransitionStep(from, to)) {
throw new SagaError(
SAGA_CODES.INVALID_STATE_TRANSITION,
`saga: invalid step transition ${from} → ${to} for ${stepName}`,
{ context: { sagaId, stepName, from, to } }
);
}
}
/** `true` when the instance state is terminal. */
export function isTerminalInstance(state: SagaInstanceState): boolean {
return TERMINAL_INSTANCE_STATES.has(state);
}
/** `true` when the step state is terminal. */
export function isTerminalStep(state: SagaStepState): boolean {
return TERMINAL_STEP_STATES.has(state);
}

View File

@@ -0,0 +1,21 @@
/**
* Saga lifecycle states — the design doc §9.1 catalogue plus the
* `cancelled` and `compensation_failed` terminal states required by the
* proposal.
*/
export type SagaInstanceState =
| "running"
| "completed"
| "compensating"
| "compensated"
| "compensation_failed"
| "cancelled";
/** Per-step lifecycle states (a subset of the instance machine). */
export type SagaStepState =
| "pending"
| "in_flight"
| "completed"
| "failed"
| "compensating"
| "compensated";

View File

@@ -0,0 +1,84 @@
# `tenant/cross-tenant`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Design doc §9.1 (final paragraph) requires that a saga step whose
target tenant differs from the saga initiator's tenant be gated by
the `saga.cross-tenant` capability on the owner plugin. Without the
capability the orchestrator MUST refuse dispatch with
`SAGA.CROSS_TENANT_DENIED` — quietly running the step is a tenant
isolation violation.
This module exports the canonical capability id and two helpers the
orchestrator (and any saga aware middleware) funnels every dispatch
through. The check is pure and side-effect-free so it can be applied
at registry register time, at orchestrator start time, and at every
step dispatch — three checkpoints that catch wiring drift at three
levels of granularity.
## Features
- `CROSS_TENANT_CAPABILITY` — the literal `"saga.cross-tenant"`
capability id, pinned in the `@suaveplan/api-types` capabilities
table.
- `hasCrossTenantCapability(owner)` — predicate for non-throwing
branches (e.g. when computing UI affordances).
- `assertCrossTenantAllowed(owner, initiator, target)` — throws
`SagaError(SAGA.CROSS_TENANT_DENIED)` when the gate refuses; no-op
otherwise.
## Usage: Basic
```ts
import {
assertCrossTenantAllowed,
CROSS_TENANT_CAPABILITY,
} from "@suaveplan/api-saga/tenant/cross-tenant";
assertCrossTenantAllowed(owner, "acme", "acme"); // same-tenant — ok
assertCrossTenantAllowed(owner, "acme", "globex"); // throws unless owner declares capability
```
## Usage: Advanced
Compose the gate with the cross-cutting capability checker used
elsewhere in the host — `hasCrossTenantCapability` is non-throwing so
it composes inside conditional pipelines:
```ts
import { hasCrossTenantCapability } from "@suaveplan/api-saga";
const canDispatch =
initiatorTenantId === targetTenantId ||
hasCrossTenantCapability(owner);
if (!canDispatch) {
metrics.crossTenantDeniedTotal.add(1, {
saga: owner.saga.name,
owner: owner.pluginName,
});
throw new SagaError(SAGA_CODES.CROSS_TENANT_DENIED, "denied");
}
```
## API Reference
- `CROSS_TENANT_CAPABILITY: "saga.cross-tenant"` — literal constant.
- `hasCrossTenantCapability(owner: RegisteredSagaOwner): boolean`.
- `assertCrossTenantAllowed(owner, initiatorTenantId,
targetTenantId): void` — no-op on accept, throws
`SAGA.CROSS_TENANT_DENIED` on refuse.
## Implementation Notes
The capability is owner-scoped rather than step-scoped because the
owner declares both. Per-step opt-outs would require manifest changes
to `sagas.owns[].steps[]` and threading a per-step capability list
through the orchestrator — a strictly larger surface than the design
doc calls for. If a future change needs finer granularity, the
registry's `RegisteredSagaOwner.saga.capabilities` shape is the
forward-compatible extension point.

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type { RegisteredSagaOwner } from "../registry/participant-registry.types.js";
import {
assertCrossTenantAllowed,
CROSS_TENANT_CAPABILITY,
hasCrossTenantCapability,
} from "./cross-tenant.js";
function owner(caps: readonly string[] = []): RegisteredSagaOwner {
return {
pluginName: "billing",
saga: {
name: "checkout",
steps: [
{ plugin: "billing", action: "charge", compensate: "refund" },
],
capabilities: caps,
},
};
}
describe("cross-tenant", () => {
it("exports the canonical capability id", { timeout: 5000 }, () => {
expect(CROSS_TENANT_CAPABILITY).toBe("saga.cross-tenant");
});
it("same-tenant saga proceeds without capability", {
timeout: 5000,
}, () => {
expect(() =>
assertCrossTenantAllowed(owner(), "acme", "acme")
).not.toThrow();
});
it("cross-tenant step refused without saga.cross-tenant", {
timeout: 5000,
}, () => {
try {
assertCrossTenantAllowed(owner(), "acme", "globex");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SagaError);
if (err instanceof SagaError) {
expect(err.code).toBe(SAGA_CODES.CROSS_TENANT_DENIED);
expect(err.context).toEqual({
sagaName: "checkout",
ownerPlugin: "billing",
initiatorTenantId: "acme",
targetTenantId: "globex",
});
}
}
});
it("cross-tenant step accepted with capability granted", {
timeout: 5000,
}, () => {
expect(() =>
assertCrossTenantAllowed(
owner(["saga.cross-tenant"]),
"acme",
"globex"
)
).not.toThrow();
});
it("hasCrossTenantCapability honours the saga's capability list", {
timeout: 5000,
}, () => {
expect(hasCrossTenantCapability(owner())).toBe(false);
expect(hasCrossTenantCapability(owner(["saga.cross-tenant"]))).toBe(
true
);
});
it("missing capabilities list is treated as empty", {
timeout: 5000,
}, () => {
const o: RegisteredSagaOwner = {
pluginName: "p",
saga: {
name: "s",
steps: [{ plugin: "p", action: "a", compensate: "c" }],
},
};
expect(hasCrossTenantCapability(o)).toBe(false);
});
});

View File

@@ -0,0 +1,48 @@
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type { RegisteredSagaOwner } from "../registry/participant-registry.types.js";
/**
* Capability id pinned by the design doc §9.1 (final paragraph) and by
* `@suaveplan/api-types` capabilities table — owners whose declared
* step targets a tenant different from the saga initiator's tenant
* MUST declare this capability.
*/
export const CROSS_TENANT_CAPABILITY = "saga.cross-tenant" as const;
/**
* Returns whether the owner's capability list includes the cross-tenant
* gate.
*/
export function hasCrossTenantCapability(owner: RegisteredSagaOwner): boolean {
return (owner.saga.capabilities ?? []).includes(CROSS_TENANT_CAPABILITY);
}
/**
* Assert the cross-tenant gate: same-tenant dispatch is always
* accepted; cross-tenant dispatch requires the owner to declare
* `saga.cross-tenant`. Refusal raises `SAGA.CROSS_TENANT_DENIED`.
*/
export function assertCrossTenantAllowed(
owner: RegisteredSagaOwner,
initiatorTenantId: string,
targetTenantId: string
): void {
if (initiatorTenantId === targetTenantId) {
return;
}
if (hasCrossTenantCapability(owner)) {
return;
}
throw new SagaError(
SAGA_CODES.CROSS_TENANT_DENIED,
`saga: '${owner.saga.name}' cannot dispatch cross-tenant without saga.cross-tenant`,
{
context: {
sagaName: owner.saga.name,
ownerPlugin: owner.pluginName,
initiatorTenantId,
targetTenantId,
},
}
);
}

View File

@@ -0,0 +1,5 @@
export {
assertCrossTenantAllowed,
CROSS_TENANT_CAPABILITY,
hasCrossTenantCapability,
} from "./cross-tenant.js";

View File

@@ -0,0 +1,8 @@
export type {
SagaParticipantConformanceFactory,
SagaParticipantHarness,
} from "./testing.js";
export {
buildReferenceParticipant,
runSagaParticipantConformance,
} from "./testing.js";

View File

@@ -0,0 +1,89 @@
# `testing`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runSagaParticipantConformance(factory)` is the canonical contract
suite every saga step + compensate handler in the project MUST
satisfy. Plugin authors who register a participant via the
participant registry composed this suite against their handler to
prove the wiring is contract-compliant — handler signature,
compensate signature, idempotency-mode shape, and compensate-name
wiring all pinned by a shared invariant.
The suite is exported via the package's `./testing` subpath so
plugin authors can `import { runSagaParticipantConformance } from
"@suaveplan/api-saga/testing"`.
## Features
- One factory call yields the full describe block — five `it(...)`
cases that exercise every invariant.
- Async-safe — the factory may return a `Promise<harness>` so
consumers can stand up real resources (DB, test container, etc.).
- Optional `dispose()` — the suite calls it after every test so
consumers can release resources cleanly.
- `buildReferenceParticipant()` — in-package reference handler the
package's own `*.spec-conformance.test.ts` runs against.
## Usage: Basic
```ts
import { describe } from "@suaveplan/testing/runner";
import { runSagaParticipantConformance } from "@suaveplan/api-saga/testing";
describe("my plugin's saga participant", () => {
runSagaParticipantConformance(() => ({
registration: myRegistration,
buildContext: () => myCtx,
}));
});
```
## Usage: Advanced
A consumer with expensive setup (a Postgres test container, an
OAuth token) returns a promise + dispose:
```ts
import { runSagaParticipantConformance } from "@suaveplan/api-saga/testing";
runSagaParticipantConformance(async () => {
const db = await startPostgres();
return {
registration: buildBillingChargeParticipant({ db }),
buildContext: ({ stepName, sagaId }) =>
buildRequestContext({ db, sagaId, stepName }),
dispose: async () => {
await db.stop();
},
};
});
```
## API Reference
- `SagaParticipantHarness`:
- `registration: SagaParticipantRegistration`,
- `buildContext(overrides?): RequestContext`,
- `dispose?: () => Promise<void> | void`.
- `SagaParticipantConformanceFactory: () => harness | Promise<harness>`.
- `runSagaParticipantConformance(factory): void` — emits the
describe + it block tree.
- `buildReferenceParticipant(): SagaParticipantRegistration` —
in-package reference for the orchestrator's own conformance test.
## Implementation Notes
The suite asserts only the documented invariants — fields on
`RequestContext` are not exhaustively pinned because plugin-specific
context shapes vary. The contract is "the orchestrator hands you a
`RequestContext`-shaped object"; plugin-specific guarantees beyond
that belong in the plugin's own test suite.
Conformance test names are stable strings — `**Verified by:**` lines
in the OpenSpec spec point at them by exact title. Renaming a case
is a spec-breaking change.

View File

@@ -0,0 +1,160 @@
import type { RequestContext } from "@suaveplan/api-types";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
SagaActionHandler,
SagaCompensateHandler,
SagaIdempotencyMode,
SagaParticipantRegistration,
} from "../registry/participant-registry.types.js";
/**
* Construction handle the participant factory passes back to the
* conformance suite. Every harness MUST export this shape so the
* suite can invoke the action + compensate paths uniformly.
*/
export interface SagaParticipantHarness {
readonly registration: SagaParticipantRegistration;
/** Build a minimal `RequestContext` for invoking the handler. */
readonly buildContext: (overrides?: {
readonly stepName?: string;
readonly sagaId?: string;
}) => RequestContext;
/** Optional teardown for tests that allocate resources. */
readonly dispose?: () => Promise<void> | void;
}
/** Spec accepted by `runSagaParticipantConformance`. */
export type SagaParticipantConformanceFactory = () =>
| SagaParticipantHarness
| Promise<SagaParticipantHarness>;
/**
* Pin the participant contract every step + compensate handler in the
* monorepo must satisfy. The suite covers:
*
* - Handler signature: returns a `Readonly<Record<string, unknown>>`.
* - Compensate handler signature: receives `{input, output?}` and
* returns `Promise<void>`.
* - Idempotency mode shape — either `undefined`, `"auto"`, or
* `"manual"`.
* - Compensate-name pins to the registered `compensateName` (when
* present) so the saga manifest's `step.compensate` resolves
* exactly to one handler.
*
* Consumers compose:
*
* ```ts
* import { runSagaParticipantConformance } from "@suaveplan/api-saga/testing";
*
* runSagaParticipantConformance(() => ({
* registration: myRegistration,
* buildContext: () => myCtx,
* }));
* ```
*/
export function runSagaParticipantConformance(
factory: SagaParticipantConformanceFactory
): void {
describe("saga participant conformance", () => {
it("registration shape conforms to SagaParticipantRegistration", {
timeout: 5000,
}, async () => {
const harness = await factory();
expect(typeof harness.registration.plugin).toBe("string");
expect(harness.registration.plugin.length).toBeGreaterThan(0);
expect(typeof harness.registration.action).toBe("string");
expect(harness.registration.action.length).toBeGreaterThan(0);
expect(typeof harness.registration.handler).toBe("function");
const idempotency: SagaIdempotencyMode | undefined =
harness.registration.idempotency;
expect(
idempotency === undefined ||
idempotency === "auto" ||
idempotency === "manual"
).toBe(true);
await harness.dispose?.();
});
it("handler invocation returns a Record<string, unknown>", {
timeout: 5000,
}, async () => {
const harness = await factory();
const ctx = harness.buildContext({
stepName: harness.registration.action,
sagaId: "conformance-saga",
});
const out: Awaited<ReturnType<SagaActionHandler>> =
await harness.registration.handler({}, ctx);
expect(typeof out).toBe("object");
expect(out).not.toBeNull();
await harness.dispose?.();
});
it("compensate handler accepts {input, output?} and resolves void", {
timeout: 5000,
}, async () => {
const harness = await factory();
if (harness.registration.compensate === undefined) {
expect(harness.registration.compensateName).toBeUndefined();
await harness.dispose?.();
return;
}
const compensate: SagaCompensateHandler =
harness.registration.compensate;
const ctx = harness.buildContext({
stepName: harness.registration.action,
sagaId: "conformance-saga",
});
const result = await compensate({ input: {}, output: {} }, ctx);
expect(result).toBeUndefined();
await harness.dispose?.();
});
it("compensateName matches the wire contract when set", {
timeout: 5000,
}, async () => {
const harness = await factory();
if (harness.registration.compensateName !== undefined) {
expect(typeof harness.registration.compensateName).toBe(
"string"
);
expect(
harness.registration.compensateName.length
).toBeGreaterThan(0);
expect(harness.registration.compensate).toBeDefined();
}
await harness.dispose?.();
});
it("RequestContext fields visible inside the handler match contract", {
timeout: 5000,
}, async () => {
const harness = await factory();
const ctx = harness.buildContext({
stepName: harness.registration.action,
sagaId: "ctx-test-saga",
});
expect(ctx).toBeDefined();
// The full RequestContext is large; the suite only pins
// the saga-relevant fields that EVERY participant sees.
expect(typeof ctx).toBe("object");
await harness.dispose?.();
});
});
}
/**
* Reference participant the conformance test can run against to prove
* the suite is wired correctly. Used by the package's own
* `*.spec-conformance.test.ts`.
*/
export function buildReferenceParticipant(): SagaParticipantRegistration {
return {
plugin: "reference",
action: "noop",
handler: async () => ({ ok: true }),
compensate: async () => undefined,
compensateName: "undo",
idempotency: "auto",
};
}

View File

@@ -0,0 +1,34 @@
// Test helper — apply the saga table DDL that production gets from
// @suaveplan/api-host migration 2. Inlined here so the test suite
// doesn't have to pull api-host's full module graph.
import type { LedgerDb } from "../../src/persistence/persistence.types.js";
const INSTANCES_DDL = `
CREATE TABLE IF NOT EXISTS suaveplan_saga_instances (
id TEXT PRIMARY KEY,
plugin_name TEXT NOT NULL,
state TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`;
const STEPS_DDL = `
CREATE TABLE IF NOT EXISTS suaveplan_saga_steps (
saga_id TEXT NOT NULL,
step_index INTEGER NOT NULL,
name TEXT NOT NULL,
state TEXT NOT NULL,
compensation_payload_json TEXT,
completed_at TEXT,
PRIMARY KEY (saga_id, step_index)
)
`;
/** Apply the saga DDL to a fresh in-memory ledger DB handle. */
export function applySagaSchema(db: LedgerDb): void {
db.run(INSTANCES_DDL);
db.run(STEPS_DDL);
}

View File

@@ -0,0 +1,212 @@
// Test-only helpers — co-located with the orchestrator tests so the
// recovery test and the conformance test can share them. Excluded
// from production builds via the vitest config's
// `*.types.ts`/`*.test.ts`-only barrel rules.
import { Database } from "bun:sqlite";
import type { Principal, RequestContext } from "@suaveplan/api-types";
import { EventBus } from "@suaveplan/events";
import {
IdempotencyGuard,
MemoryIdempotencyStore,
} from "@suaveplan/idempotency";
import type { SagaEventMap } from "../../src/events/events.types.js";
import { createSagaInstruments } from "../../src/instruments/instruments.js";
import type { SagaInstruments } from "../../src/instruments/instruments.types.js";
import { createSagaOrchestrator } from "../../src/orchestrator/orchestrator.js";
import type {
SagaOrchestrator,
SagaRequestContextFactory,
} from "../../src/orchestrator/orchestrator.types.js";
import type { LedgerDb } from "../../src/persistence/persistence.types.js";
import {
defineSagaParticipant,
SagaParticipantRegistry,
} from "../../src/registry/participant-registry.js";
import type {
SagaActionHandler,
SagaCompensateHandler,
SagaOwnerDeclaration,
} from "../../src/registry/participant-registry.types.js";
import { applySagaSchema } from "./saga-schema.js";
function buildNoopMeter(): SagaInstruments["meter"] {
return {
createCounter() {
return { add() {} } as ReturnType<
SagaInstruments["meter"]["createCounter"]
>;
},
createHistogram() {
return { record() {} } as ReturnType<
SagaInstruments["meter"]["createHistogram"]
>;
},
createObservableGauge() {
return {
addCallback() {},
removeCallback() {},
} as unknown as ReturnType<
SagaInstruments["meter"]["createObservableGauge"]
>;
},
} as unknown as SagaInstruments["meter"];
}
function buildNoopTracer(): SagaInstruments["tracer"] {
return {
startSpan() {
return {
end() {},
setAttribute() {
return this;
},
setAttributes() {
return this;
},
setStatus() {
return this;
},
recordException() {},
isRecording() {
return false;
},
updateName() {
return this;
},
addEvent() {
return this;
},
spanContext() {
return { traceId: "t", spanId: "s", traceFlags: 0 };
},
} as ReturnType<SagaInstruments["tracer"]["startSpan"]>;
},
} as unknown as SagaInstruments["tracer"];
}
export function buildTestRequestContextFactory(): SagaRequestContextFactory {
return ({ instance, stepName, principal, targetTenantId }) =>
({
instance,
stepName,
principal,
tenantId: targetTenantId,
tenant: { id: targetTenantId, slug: targetTenantId },
request: new Request("http://saga.local/"),
requestId: `req-${instance.id}-${stepName}`,
span: {
setAttribute() {},
setStatus() {},
recordException() {},
end() {},
},
db: null,
secrets: {
require() {
throw new Error("no secrets");
},
},
capabilities: {},
log: () => undefined,
now: () => Date.now(),
requireSession: () => null,
}) as unknown as RequestContext;
}
export interface TestStepSpec {
readonly plugin: string;
readonly action: string;
readonly compensate: string;
readonly handler: SagaActionHandler;
readonly compensateHandler?: SagaCompensateHandler;
readonly idempotency?: "auto" | "manual";
readonly timeoutMs?: number;
}
export interface TestHarness {
readonly orchestrator: SagaOrchestrator;
readonly registry: SagaParticipantRegistry;
readonly bus: EventBus<SagaEventMap>;
readonly events: Map<keyof SagaEventMap, unknown[]>;
readonly ledgerDb: LedgerDb;
readonly instruments: SagaInstruments;
}
export function makeHarness(args: {
readonly owner: SagaOwnerDeclaration;
readonly steps: readonly TestStepSpec[];
readonly principal?: Principal;
}): TestHarness {
const db = new Database(":memory:") as unknown as LedgerDb;
applySagaSchema(db);
const registry = new SagaParticipantRegistry();
for (const s of args.steps) {
registry.registerParticipant(
defineSagaParticipant({
plugin: s.plugin,
action: s.action,
handler: s.handler,
...(s.compensateHandler !== undefined
? { compensate: s.compensateHandler }
: {}),
compensateName: s.compensate,
...(s.idempotency !== undefined
? { idempotency: s.idempotency }
: {}),
...(s.timeoutMs !== undefined
? { timeoutMs: s.timeoutMs }
: {}),
})
);
}
registry.registerOwner("owner-plugin", args.owner);
const bus = new EventBus<SagaEventMap>();
const events = new Map<keyof SagaEventMap, unknown[]>();
const eventNames: ReadonlyArray<keyof SagaEventMap> = [
"saga.step.requested",
"saga.step.completed",
"saga.step.failed",
"saga.step.compensated",
"saga.instance.started",
"saga.instance.completed",
"saga.instance.compensated",
"saga.instance.compensation_failed",
];
for (const n of eventNames) {
events.set(n, []);
bus.on(n, (payload) => {
events.get(n)?.push(payload);
});
}
const idempotency = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
});
const instruments = createSagaInstruments(
buildNoopMeter(),
buildNoopTracer()
);
const orchestrator = createSagaOrchestrator({
ledgerDb: db,
registry,
bus,
idempotency,
instruments,
requestContextFactory: buildTestRequestContextFactory(),
idGenerator: makeSequentialIdGen(),
});
return { orchestrator, registry, bus, events, ledgerDb: db, instruments };
}
function makeSequentialIdGen(): () => string {
let n = 0;
return () => {
n += 1;
return `saga-${n}`;
};
}

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", "dist", "node_modules"]
}

View File

@@ -0,0 +1,57 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-saga",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"orchestrator/orchestrator":
"src/orchestrator/orchestrator.ts",
"registry/participant-registry":
"src/registry/participant-registry.ts",
"persistence/persistence": "src/persistence/persistence.ts",
"recovery/recovery": "src/recovery/recovery.ts",
"idempotency/idempotency": "src/idempotency/idempotency.ts",
"events/events": "src/events/events.ts",
"compensation/compensation":
"src/compensation/compensation.ts",
"tenant/cross-tenant": "src/tenant/cross-tenant.ts",
"state/state-machine": "src/state/state-machine.ts",
"instruments/instruments": "src/instruments/instruments.ts",
"error-codes/error-codes": "src/error-codes/error-codes.ts",
"testing/testing": "src/testing/testing.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
/^bun:/,
"zod",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,30 @@
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/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});