3.6 KiB
state/state-machine
(no submodules)
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}andcompensating → {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 viacompensating → {compensated, failed}. assertInstanceTransition/assertStepTransitionthrowSagaError(SAGA.INVALID_STATE_TRANSITION)with full context.isTerminalInstance/isTerminalSteppredicates 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
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:
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.