Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

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,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,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,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,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,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,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,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,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,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,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,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,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 @@
# `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.