Roles & config
The topology preset on DurableModule.forRoot, the RunGateway surface every shape shares, and layered tenant authentication for a store-less fleet.
Which shape a process takes is inferred from the options you give it. The
topology preset on DurableModule.forRoot lets you name the role instead, and validates the
two axes that otherwise read as synonyms — namespace (the operator's poll scope) versus partition
(a worker's queue suffix). topology is entirely additive: omit it and inference behaves exactly as
before.
The configs
Omit topology and you get the default: an operator that owns the store and runs every @Workflow
on the inline fast path.
DurableModule.forRoot({
store,
transport: new EventEmitterTransport(), // or BullMQ for cross-process
})Add a connection and the same process keeps the store but moves execution onto a co-located BullMQ
consumer — every @Workflow becomes group-served instead of inline.
Any role that sets connection executes bodies through @dudousxd/durable-worker, which is an
optional peer — pnpm add @dudousxd/durable-worker bullmq ioredis alongside the module. An
operator that only owns a store never loads it and does not need it installed.
What topology validates
Each check is named after the axis it protects, and runs at forRoot/forRootAsync resolution time:
topology | Requires | Forbids | Notes |
|---|---|---|---|
{ role: 'control-plane', tenant? } | store and (transport or transports) | partition | tenant is optional and maps onto namespace. Omit it on a deployed operator (it then drives every tenant); set it to scope a self-contained local stack to its own runs while sharing a broker with a deployed cluster. Setting namespace too is allowed only when it equals tenant; a mismatch throws. |
{ role: 'tenant', tenant } | connection | store, namespace | tenant is required and maps onto partition. Setting partition too is allowed only when it equals tenant; a mismatch throws. |
The preset exists for the case plain inference can't catch: an app that sets both namespace (meant
for the operator) and partition (meant for a worker) on one instance because the two options read
as near-synonyms. topology forces you to pick one role and rejects the other role's axis outright,
with an error message that explains why. What each axis actually partitions is
Tenancy.
The shared surface: RunGateway
Every shape binds exactly one RunGateway, and the role picks the implementation. An operator with a
store reads its store directly (StoreRunGateway). A store-less pod binds a ProxyRunGateway that
round-trips every call over the transport to the control plane, which reads its store and answers.
This is what makes application code portable across shapes. Your controllers and the
dashboard call the same interface either way, so a pod moving from
single-process to store-less changes only its forRoot — no controller code, and the identical
DurableDashboardModule mounts on both.
@Controller('runs')
export class RunsController {
constructor(private readonly gateway: RunGateway) {} // inject by type — the class is its own token
@Get(':id')
async show(@Param('id') id: string) {
return (await this.gateway.getRunDetail(id)) ?? {};
}
}| Verb | Purpose |
|---|---|
topology() | this pod's role, plus its tenant when store-less. Synchronous — no round-trip. |
getRunDetail(runId, options?) | one run plus its step timeline and child ids, or null. With { summarizeFansOver: n }, every fan wider than n comes back as a FanSummary in fans instead of its members, and children is capped at 200 with childCount as the total. Unset returns the full detail. |
getFanMembers?(runId, group, query) | optional — one page { items, total } of a fan's members; query is { offset?, limit?, failedOnly? } (limit defaults to 100, at most 500) |
getRun?(runId) | optional — the run alone, without its timeline |
listRuns(query) | a filtered listing (status, workflow, tag, namespace, search attributes), each row optionally carrying what a suspended run is parked on |
runFacets(query) | whole-set counts grouped by (status, origin) — exact even when the listing is paginated |
runValueFacets(axis, query, opts?) | the distinct values of one filter axis (tag, tenant, origin, workflow, search-attribute key or value) with counts — what fills a console's pickers |
waitingFor(runIds) | bulk-resolve what each of an arbitrary set of runs is parked on |
workerHealth() | queue backlog and live worker heartbeats per group; scoped to the requester's own groups over a tenant proxy |
cancel(runId, opts?) | cancel a run, with { compensate: true } to run its saga undo first |
retry(runId) / continue(runId) | re-drive a failed run, or push a dead one one step further |
retryWithInput(runId, input) | fix-and-replay: a fresh run from corrected input, keeping the original's namespace |
redispatchPending(runId) | re-enqueue every remote step still pending — the operator escape hatch for a lost dispatch |
subscribe(runId, onEvent) | live-tail one run's lifecycle events; returns the unsubscribe |
Tune the proxy round-trip with runGatewayTimeoutMs (default 10_000). It is ignored on an
operator, which is bound to the store-backed gateway.
Wide fans over the gateway
The dashboard asks getRunDetail(id, { summarizeFansOver: 50 }) and pages a fan with
getFanMembers (see Wide fans). StoreRunGateway
implements both, plus getRun. On a store-less pod, ProxyRunGateway forwards the detail options
in its getRunDetail request and sends a getFanMembers request kind. The control plane's
responder checks that the requesting tenant owns the run before it pages the fan, as it does
before every run-scoped verb, so a tenant cannot page another tenant's fan.
If you implement RunGateway yourself:
summarizeFansOveris a hint. A gateway that ignores it returns the full detail, and the console renders every member as before.getFanMembersis optional. Without it, the dashboard'sGET runs/:id/fans/:groupanswers 404, so a summarized fan can be drawn but not expanded. Core exportssummarizeTimeline,summarizeFan,pageFanandchildIdsOf, so a gateway that already loads the checkpoints can implement both in a few lines, the wayStoreRunGatewaydoes.getRunis optional. The ownership check uses it to read only the run row; without it, the check falls back togetRunDetail, which reads every checkpoint of the run.
cancel on a store-less pod: same verb, two objects, opposite answers. Injecting
WorkflowEngine there gets you a start-only client — start publishes a start message over the
wire, but cancel, deleteRun, resume, signal, signalWithStart and publishEvent reject with
a named tenantUnsupported(...) error, because they reach for a store the pod does not have. The
RunGateway above has no such problem: its cancel is a wire request the control plane answers
against its store, so it works on every shape.
That is why the table lists cancel as a working verb. It is RunGateway.cancel that works;
WorkflowEngine.cancel is the one that rejects. Inject RunGateway in anything that has to run on
more than one shape, and you never meet the distinction.
Layered tenant authentication
The tenant on a wire request is a claim. Without authentication the
isolation boundary is meaningless — any pod could
ask for any tenant's runs. Durable stacks two layers:
Prefix / network baseline
Each tenant runs on a segmented transport prefix/namespace behind a Redis/network ACL, so a pod can only reach its own prefix. No application code.
Signed token on top
Each tenant pod carries a secret-signed token. The control plane verifies the signature and derives
the tenant from the token, ignoring any tenant in the request body — defense in depth.
The control plane is the trust boundary for these proxied requests: it forces listRuns to the
requester's own tenant (no cross-tenant enumeration), validates run.namespace === tenant on every
runId-bearing verb (anti-IDOR), and rejects unknown verbs and tampered tokens.
Next steps
- Tenancy — what
namespaceandpartitionpartition, and the rules that keep a tenant's runs on the tenant's own pool. - Handshake & capability negotiation — keep a mixed fleet version-safe.
- Cross-ecosystem interop — add Python and Adonis workers.
Topologies
Run durable in one process, split the control plane from the workers, or spread store-less thin workers per tenant — the same engine over the same wire, selected by which options a process is given.
Node worker
Author and run a store-less Node worker with @dudousxd/durable-worker — the JS counterpart to the Python client. Register @Step/@Workflow bodies on a DurableWorkerRuntime and drive them with runRedisWorker, with fixed or adaptive concurrency.