first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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.