Aviary
Cluster

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:

topologyRequiresForbidsNotes
{ role: 'control-plane', tenant? }store and (transport or transports)partitiontenant 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 }connectionstore, namespacetenant 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)) ?? {};
  }
}
VerbPurpose
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:

  • summarizeFansOver is a hint. A gateway that ignores it returns the full detail, and the console renders every member as before.
  • getFanMembers is optional. Without it, the dashboard's GET runs/:id/fans/:group answers 404, so a summarized fan can be drawn but not expanded. Core exports summarizeTimeline, summarizeFan, pageFan and childIdsOf, so a gateway that already loads the checkpoints can implement both in a few lines, the way StoreRunGateway does.
  • getRun is optional. The ownership check uses it to read only the run row; without it, the check falls back to getRunDetail, 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

On this page