/** * Definitiile de saga stau in cod, versionate, ca la notification-rules si * projection-registry. Motivul e acelasi: un proces cu mai multi pasi e logica * de business care merita review si teste, nu configuratie editabila la runtime. * * Contractul unui pas: * - run() trebuie sa fie IDEMPOTENT. Poate fi apelat de mai multe ori pentru * acelasi pas (retry dupa timeout de retea, restart de proces). Daca a scris * deja ceva, a doua rulare trebuie sa observe asta si sa nu dubleze. * - compensate() anuleaza efectul lui run(). Trebuie sa fie idempotent SI * tolerant la faptul ca run() poate sa fi esuat la jumatate -- compenseaza * ce gaseste, nu presupune ca totul a fost creat. */ export interface SagaEvent { id: string; tenantId: string; workspaceId: string | null; eventType: string; actorId: string | null; subjectId: string | null; correlationId: string | null; payload: Record; } export type SagaContext = Record; export interface SagaStepResult { /** Se uneste in contextul sagai si e vizibil pasilor urmatori. */ context?: SagaContext; output?: Record; } export interface SagaStepDefinition { name: string; run(ctx: SagaContext, event: SagaEvent): Promise; /** * Lipsa lui compensate inseamna ca pasul nu are efecte de anulat (o citire, * de exemplu). Nu inseamna "nu stim cum sa compensam". */ compensate?(ctx: SagaContext, event: SagaEvent): Promise; /** Cate incercari inainte de a declara pasul esuat. */ maxAttempts?: number; } export interface SagaDefinition { name: string; version: number; /** Ce eveniment porneste saga. */ triggerEvent: string; /** Termen absolut pentru toata saga; depasirea duce la compensare. */ timeoutMinutes: number; steps: SagaStepDefinition[]; /** * Filtru optional: chiar daca evenimentul se potriveste, saga poate decide * ca nu o priveste (ex. lipseste un camp din payload). */ shouldStart?(event: SagaEvent): boolean; } const DEFAULT_MAX_ATTEMPTS = 3; const BASE_BACKOFF_MS = 30_000; const MAX_BACKOFF_MS = 30 * 60_000; export function maxAttemptsFor(step: SagaStepDefinition): number { return step.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; } /** * Backoff exponential plafonat. Fara plafon, a 10-a incercare ar fi programata * peste zile, ceea ce in practica inseamna "niciodata". */ export function backoffDelayMs(attempt: number): number { return Math.min(BASE_BACKOFF_MS * 2 ** Math.max(0, attempt - 1), MAX_BACKOFF_MS); } const REGISTRY: SagaDefinition[] = []; export function registerSaga(definition: SagaDefinition): void { const duplicate = REGISTRY.find( (s) => s.name === definition.name && s.version === definition.version, ); if (duplicate) { throw new Error(`Saga ${definition.name}@v${definition.version} este deja inregistrata`); } REGISTRY.push(definition); } export function allSagas(): SagaDefinition[] { return [...REGISTRY]; } export function sagasForEvent(eventType: string): SagaDefinition[] { return REGISTRY.filter((s) => s.triggerEvent === eventType); } export function findSaga(name: string, version: number): SagaDefinition | undefined { return REGISTRY.find((s) => s.name === name && s.version === version); } export function triggerEventTypes(): string[] { return [...new Set(REGISTRY.map((s) => s.triggerEvent))]; } /** Doar pentru teste: goleste registrul intre cazuri. */ export function resetRegistry(): void { REGISTRY.length = 0; }