Files
skills/docs/api-stack/api/api-saga/src/registry/participant-registry.md
2026-05-29 08:20:56 +01:00

4.6 KiB

registry/participant-registry

(no submodules)

Purpose

The participant registry is the host-side compendium of every plugin that owns a saga and every plugin that participates in one. The host populates it from each plugin's manifest — sagas.owns[] declares owners; sagas.participatesIn[] plus exported actions / compensations declare participants. At install time the registry validates that every owner's declared steps name a plugin + action that actually exists in the participant table, and refuses two plugins both claiming to own the same saga name.

This module is the single source of truth the orchestrator queries at start(...) and at every step dispatch — requireOwner(sagaName) yields the declared step list, and requireParticipant(plugin, action) yields the bound handler + compensate function.

Features

  • Bi-directional indexes (plugin/action → registration, ownerPlugin/sagaName → owner record, sagaName → ownerPlugin).
  • Validation on registerOwner — every declared step's wiring is checked against the registered participants up-front so a typo surfaces at install, not at the first dispatch.
  • Duplicate-owner detection via the sagaName → ownerPlugin index; the second registration raises SAGA.DUPLICATE_OWNER.
  • Cross-compensation validation — when a step declares compensate: "x" but the participant's compensateName is "y", the install fails with SAGA.UNKNOWN_PARTICIPANT so a manifest drift is caught before it can hit production.
  • defineSagaParticipant<TInput, TOutput> typed builder so plugin authors don't lose generic information at the registration site.

Usage: Basic

import {
    SagaParticipantRegistry,
    defineSagaParticipant,
} from "@suaveplan/api-saga/registry/participant-registry";

const registry = new SagaParticipantRegistry();

registry.registerParticipant(
    defineSagaParticipant({
        plugin: "billing",
        action: "charge",
        handler: async (input) => ({ chargeId: "ch_1" }),
        compensate: async ({ output }) => {
            await refund((output as { chargeId: string }).chargeId);
        },
        compensateName: "refund",
    })
);

registry.registerOwner("billing", {
    name: "checkout",
    steps: [
        { plugin: "billing", action: "charge", compensate: "refund" },
    ],
});

const owner = registry.requireOwner("checkout");

Usage: Advanced

The orchestrator dispatches into the registry to honour the saga.cross-tenant capability gate — the owner record carries an optional capabilities list, and the orchestrator runtime asks the registry "does this saga's owner declare X?" before deciding whether to refuse a cross-tenant dispatch:

import { SagaParticipantRegistry } from "@suaveplan/api-saga";

const owner = registry.requireOwner("checkout");
if (
    initiatorTenantId !== targetTenantId &&
    !(owner.saga.capabilities ?? []).includes("saga.cross-tenant")
) {
    throw new SagaError(
        SAGA_CODES.CROSS_TENANT_DENIED,
        "saga: cross-tenant dispatch without capability"
    );
}

A second registerOwner of the same name from a different plugin is refused:

try {
    registry.registerOwner("other-billing", {
        name: "checkout",
        steps: [...]
    });
} catch (err) {
    if (err instanceof SagaError && err.code === SAGA_CODES.DUPLICATE_OWNER) {
        // expected — surface to the operator
    }
}

API Reference

  • SagaParticipantRegistry:
    • registerParticipant(registration) / getParticipant(plugin, action) / requireParticipant(plugin, action).
    • registerOwner(pluginName, saga) / getOwner(sagaName) / requireOwner(sagaName).
    • listOwners() / listParticipants().
  • defineSagaParticipant<TInput, TOutput>(spec) — type-preserving builder.
  • participantKey(plugin, action), sagaKey(ownerPlugin, sagaName) — exported so callers can derive the same hash key.

Implementation Notes

Validation runs on registerOwner rather than on first dispatch because a missing-action error is much easier to debug at install time, when the operator can connect it to a manifest typo, than at 2 a.m. when the saga fires for the first time in production. The compensateName check is the same idea — it pins the wire-name contract between the manifest and the bound handler.

Two registry-internal indexes (participantKey and sagaKey) deliberately use stringified composite keys rather than nested maps so .size / .values() / iteration semantics match what the in-memory keystore expects. The ownersByName shadow index is what gives the duplicate-owner check its O(1) lookup.