Files
skills/docs/api-stack/api/api-saga/src/state/state-machine.md
2026-05-29 08:20:56 +01:00

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} 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

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.