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