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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user