first commit
This commit is contained in:
137
docs/api-stack/api/api-saga/src/registry/participant-registry.md
Normal file
137
docs/api-stack/api/api-saga/src/registry/participant-registry.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# `registry/participant-registry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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.
|
||||
Reference in New Issue
Block a user