Files
2026-05-27 23:29:53 +01:00

5.8 KiB

@suaveplan/api-saga — saga orchestrator for compensable cross-plugin workflows

Package: @suaveplan/api-saga Status: PROPOSED Type: new-package Stage: 6 (Distributed transactions + Better-Auth) — see umbrella openspec/changes/api-plugin-foundation/

Why

Cross-plugin workflows ("create customer → open subscription → send welcome email") need rollback semantics that no single plugin can give. Per design doc §9.1 the saga engine is the recommended default for these flows — eventual consistency with compensable undo. Sagas execute in declared order; on any step failure the orchestrator runs compensate handlers in reverse over completed steps. Without this package, the foundation has no first-class story for multi-plugin eventual consistency, and every plugin author would re-invent state machines + idempotency wrappers ad hoc.

The saga engine is also a Stage-6 prerequisite for the reference plugin's integration test (umbrella §15 Stage 6 acceptance: "a multi-step saga in api-plugin-example runs end-to-end including a compensation path").

What changes

Net-new package depending on @suaveplan/api-types, @suaveplan/api-host (consumes the metadata DB), @suaveplan/api-manifest (reads sagas.owns[] / sagas.participatesIn[]), @suaveplan/idempotency (per-step idempotency wrapper), @suaveplan/events (saga step event bus), @suaveplan/error, @suaveplan/logger, @suaveplan/opentelemetry, and zod.

  • ADDED: createSagaOrchestrator(deps) — factory binding the orchestrator to a host's metadata DB, event bus, idempotency store, capability registry, and OTel meter.
  • ADDED: SagaOrchestrator — public surface: start(name, input, ctx), resume(sagaId), cancel(sagaId, reason), listInstances(filter), getInstance(sagaId).
  • ADDED: SagaInstanceState — "running" | "completed" | "compensating" | "compensated" | "compensation_failed" | "cancelled".
  • ADDED: SagaStepState — "pending" | "in_flight" | "completed" | "failed" | "compensating" | "compensated".
  • ADDED: SagaParticipantRegistry — host-side registry where plugins register their action and compensate handlers at start; orchestrator dispatches step events into them.
  • ADDED: Event payloads on @suaveplan/events: saga.step.requested, saga.step.completed, saga.step.failed, saga.step.compensated, saga.instance.started, saga.instance.completed, saga.instance.compensated, saga.instance.compensation_failed.
  • ADDED: runSagaParticipantConformance(participantFactory) — re-export of the conformance suite from api-types/saga (suite definition lives in this package's ./testing subpath; abstract type contract lives in api-types).
  • ADDED: SAGA_* error codes: SAGA_UNKNOWN_NAME, SAGA_UNKNOWN_PARTICIPANT, SAGA_STEP_TIMEOUT, SAGA_COMPENSATION_FAILED, SAGA_INVALID_STATE_TRANSITION, SAGA_CROSS_TENANT_DENIED, SAGA_DUPLICATE_OWNER, SAGA_INSTANCE_NOT_FOUND, SAGA_PERSISTENCE_FAILED, SAGA_COMPENSATION_MAX_ATTEMPTS_EXCEEDED.
  • ADDED: OTel instruments: saga.instance span (per saga lifetime), saga.step span (per step attempt), saga.compensate span, saga.instances_total counter, saga.step_duration_ms histogram, saga.compensations_total counter, saga.in_flight gauge.

Out of scope

  • The host metadata DB schema for suaveplan_saga_instances and suaveplan_saga_steps — those tables ship with api-host's migrations (umbrella §Stage 2). This package consumes them.
  • Cross-plugin invocation transport — step dispatch goes over the existing @suaveplan/events bus + SagaParticipantRegistry, not a new RPC channel.
  • Manifest validation — sagas.owns[] / sagas.participatesIn[] Zod schema lives in api-manifest. This package consumes the validated manifest tree.
  • 2PC integration — distinct engine; see api-2pc.

Source mapping

Genesis-original. Source of design intent: docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md.

Requirement Design doc section
Saga orchestrator drives steps in declared order §9.1
Compensation in reverse on failure §9.1
Persisted state in suaveplan_saga_instances / _steps §9.1 (paragraph 2)
Survives host restart by replaying state §9.1 (paragraph 2)
Idempotency keyed by {sagaId, stepName} §9.1 (bullet "Idempotency contract")
RequestContext carries initiator principal + target tenant §9.1 (final paragraph)
saga.cross-tenant capability gate §9.1 (final paragraph)
OTel instruments listed above §9.1 (OTel surface block)
Owner vs participant distinction §9.1 (sagas.owns vs sagas.participatesIn)

Verification approach

  • src/orchestrator/orchestrator.test.ts — step-ordering, success-path, single-step compensation, multi-step compensation, idempotency-on-retry, cross-tenant denial.
  • src/orchestrator/orchestrator.spec-conformance.test.ts — runSagaParticipantConformance pinning the contract every step handler must satisfy.
  • src/registry/participant-registry.test.ts — owner vs participant registration, duplicate-owner refusal, validation that every step's named plugin exists.
  • src/persistence/persistence.test.ts (integration, 30000ms) — uses @suaveplan/testing-integration-postgres to assert state survives a simulated host restart: start saga, kill orchestrator mid-flight, instantiate new orchestrator against same DB, assert it resumes correctly.
  • src/events/events.test.ts — every documented event payload shape pinned against its Zod schema; bus emission asserted with a real EventBus.
  • tests/integration/cross-plugin-saga.test.ts (60000ms) — full two-plugin integration: plugin A owns the saga, plugin B participates, force a step-2 failure, assert step-1 compensate fires.

Migration impact

None — additive only. The umbrella's Stage 6 acceptance criterion depends on this package being green.