All files / sagas saga-registry.ts

96.29% Statements 26/27
100% Branches 8/8
91.66% Functions 11/12
95.83% Lines 23/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                                                                                                                        1x 1x 1x   1x 2x             1x 5x     1x   1x 7x 3x   7x 1x   6x     1x       1x 2x     1x 3x     1x 2x       1x 9x    
/**
 * 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<string, unknown>;
}
 
export type SagaContext = Record<string, unknown>;
 
export interface SagaStepResult {
  /** Se uneste in contextul sagai si e vizibil pasilor urmatori. */
  context?: SagaContext;
  output?: Record<string, unknown>;
}
 
export interface SagaStepDefinition {
  name: string;
  run(ctx: SagaContext, event: SagaEvent): Promise<SagaStepResult | void>;
  /**
   * 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<void>;
  /** 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;
}