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 fromapi-types/saga(suite definition lives in this package's./testingsubpath; abstract type contract lives inapi-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.instancespan (per saga lifetime),saga.stepspan (per step attempt),saga.compensatespan,saga.instances_totalcounter,saga.step_duration_mshistogram,saga.compensations_totalcounter,saga.in_flightgauge.
Out of scope
- The host metadata DB schema for
suaveplan_saga_instancesandsuaveplan_saga_steps— those tables ship withapi-host's migrations (umbrella §Stage 2). This package consumes them. - Cross-plugin invocation transport — step dispatch goes over the existing
@suaveplan/eventsbus +SagaParticipantRegistry, not a new RPC channel. - Manifest validation —
sagas.owns[]/sagas.participatesIn[]Zod schema lives inapi-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—runSagaParticipantConformancepinning 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-postgresto 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 realEventBus.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.