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 → ownerPluginindex; the second registration raisesSAGA.DUPLICATE_OWNER. - Cross-compensation validation — when a step declares
compensate: "x"but the participant'scompensateNameis"y", the install fails withSAGA.UNKNOWN_PARTICIPANTso 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.