63 lines
5.8 KiB
Markdown
63 lines
5.8 KiB
Markdown
# `@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.
|