@dudousxd/nestjs-durable
Version:
Durable workflows for NestJS — module, decorators, discovery and boot recovery
1,134 lines • 65.7 kB
TypeScript
import { WorkflowCtx, SearchAttributes, WorkflowClass, StepError, WorkflowRef, SingletonConfig, SearchAttributesSchema, StateStore, Transport, NamedTransport, ControlPlane, ScheduledWorkflow, RetentionPolicy, QueueConfig, AdmissionBackend, WorkflowInputOf, StartOptions, RunResult, RunGateway, WorkflowEngine, EntityHandler, DurableTopology, RunDetail, RunQuery, RunListItem, GroupHealth, RunWaiting, EngineEvent, RunRequest, RunReply, StepInvocation, TenantEvent } from '@dudousxd/nestjs-durable-core';
export { AttributeFilter, EngineEvent, InferSearchAttributes, RunDetail, RunGateway, RunListItem, RunQuery, RunStatus, RunWaiting, SearchAttributes, SearchAttributesSchema, StepCheckpoint, StepEvent, StepLogger, WorkflowCtx, WorkflowEngine, WorkflowHandler, WorkflowRun, readSearchAttributes } from '@dudousxd/nestjs-durable-core';
import { z } from 'zod';
import { ConcurrencyOption, StartRunDeps, RunRedisWorkerOptions, RunningWorker, DurableWorkerRuntime } from '@dudousxd/durable-worker';
import { DynamicModule, InjectionToken, OnModuleInit, OnApplicationBootstrap, OnApplicationShutdown, Provider } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
/**
* The search-attributes shape a `@Workflow` class declares — extracted from its `run` method's
* `ctx: WorkflowCtx<A>` annotation, mirroring the `WorkflowInputOf`/`WorkflowOutputOf` structural-
* typing idiom in core's `workflow-ref.ts` (which extract `run`'s `input`/return type the same way).
* A class whose `run` leaves `ctx` untyped (or types it as the bare `WorkflowCtx`) resolves to the
* untyped `SearchAttributes` default — matching `WorkflowCtx` itself.
*/
type WorkflowAttributesOf<C> = C extends abstract new (...args: never[]) => {
run(ctx: WorkflowCtx<infer A>, input: never): unknown;
} ? A : SearchAttributes;
/**
* Read a run's search attributes **by workflow class**, with the schema resolved from that class's
* `@Workflow({ searchAttributes })` decorator metadata — the same single-source-of-truth idiom as
* triggering a workflow by class (`ctx.child(ShippingWorkflow, input)`, `engine.start(CheckoutWorkflow,
* input)`): the decorator is the one place the schema lives, and every reader references the
* WORKFLOW, never re-imports or re-declares the schema itself. The return type is inferred
* structurally from the class's `run(ctx: WorkflowCtx<A>, …)` annotation (see
* {@link WorkflowAttributesOf}), so a valid read is typed to `A` with no explicit type argument.
*
* Delegates to core's `readSearchAttributes(schema, run)` for the actual read, so the same lenient
* safe-parse semantics apply: a run whose stored `searchAttributes` predate the schema, or fail it,
* reads back as `{}` rather than throwing (see `readSearchAttributes`'s doc comment).
*
* @throws if `workflow` isn't a `@Workflow`-decorated class (no metadata at all — nothing to resolve
* the schema from).
* @throws if `workflow` is a `@Workflow` class that never declared a `searchAttributes` schema —
* reading attributes by class needs one to resolve against.
*
* @example
* ```ts
* import { z } from 'zod';
* import { Injectable } from '@nestjs/common';
* import {
* attributesOf,
* InferSearchAttributes,
* Workflow,
* WorkflowCtx,
* WorkflowHandler,
* } from '@dudousxd/nestjs-durable';
*
* const orderAttrs = z.object({ tier: z.enum(['free', 'pro']), amount: z.number() });
* type OrderAttrs = InferSearchAttributes<typeof orderAttrs>;
*
* @Workflow({ name: 'checkout', searchAttributes: orderAttrs })
* class CheckoutWorkflow implements WorkflowHandler<{ orderId: string }, void, OrderAttrs> {
* async run(ctx: WorkflowCtx<OrderAttrs>, input: { orderId: string }): Promise<void> {
* await ctx.upsertSearchAttributes({ tier: 'pro', amount: 100 });
* }
* }
*
* @Injectable()
* class CheckoutDashboardService {
* constructor(private readonly store: StateStore) {}
*
* async tierOf(runId: string) {
* const run = await this.store.getRun(runId);
* const attrs = attributesOf(CheckoutWorkflow, run ?? {}); // OrderAttrs
* return attrs.tier;
* }
* }
* ```
*/
declare function attributesOf<C extends WorkflowClass>(workflow: C, run: {
searchAttributes?: SearchAttributes | null | undefined;
}): WorkflowAttributesOf<C>;
/**
* Local, structural mirror of `@dudousxd/nestjs-context`'s public accessor
* (`packages/core/src/accessor.ts`).
*
* We deliberately do NOT import nestjs-context (it is an OPTIONAL peer). Instead
* we declare the same shape here and inject it via the shared
* {@link CONTEXT_ACCESSOR} token with `@Optional()`. Any object that structurally
* satisfies this interface — including nestjs-context's real accessor — works.
*
* Kept byte-aligned with nestjs-context's `ContextAccessor` (and the identical
* mirror in `@dudousxd/nestjs-authz`): `traceId()` / `tenantId()` are REQUIRED
* (the real accessor always provides them) and `get()` is included, so the
* structural match stays exact when DurableModule auto-feeds the carrier.
*/
interface UserRef {
type: string;
id: string | number;
}
/** Opaque shape of the context store. durable never reads it; mirrors the upstream surface. */
type ContextStore = Record<string, unknown>;
interface ContextAccessor {
/** Trace id for the current request, or `undefined` when unavailable. */
traceId(): string | undefined;
/** Current tenant id, or `undefined` when no multi-tenant context is populated. */
tenantId(): string | undefined;
/** Reference to the current user, or `undefined` when unauthenticated. */
userRef(): UserRef | undefined;
/** The raw context store for the current request, or `undefined`. */
get(): ContextStore | undefined;
}
declare const WORKFLOW_METADATA: unique symbol;
interface WorkflowMeta {
name: string;
version: string;
/** The workflow this workflow's dead runs route to (a name or a class). See `WorkflowOptions`. */
deadLetterWorkflow?: WorkflowRef | undefined;
/** Static searchable labels stamped on every run of this workflow. See `WorkflowOptions`. */
tags?: string[] | undefined;
/** Per-key serialization (a durable mutex). See `WorkflowOptions`. */
singleton?: SingletonConfig | undefined;
/** Max wall-clock lifetime before a run is cancelled (e.g. `'2h'`). See `WorkflowOptions`. */
executionTimeout?: string | number | undefined;
/** class-validator DTO validated at start. See `WorkflowOptions`. */
inputSchema?: (new (...args: any[]) => object) | undefined;
/** Custom input validator (throws on invalid). See `WorkflowOptions`. */
validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
/** Typed/validated `searchAttributes` shape. See `WorkflowOptions`. */
searchAttributes?: SearchAttributesSchema | undefined;
/** Event names that start a fresh run of this workflow. See `WorkflowOptions`. */
onEvent?: string[] | undefined;
/** Debounce `onEvent` triggers — fire once it's quiet for this long. See `WorkflowOptions`. */
debounce?: string | number | undefined;
/** Batch `onEvent` triggers — fire on size or window. See `WorkflowOptions`. */
batch?: {
maxSize: number;
within: string | number;
} | undefined;
/** Capabilities a live worker must advertise to run this workflow's turns (handshake §7.5). See
* `WorkflowOptions`. */
requires?: string[] | undefined;
}
interface WorkflowOptions {
name: string;
version?: string;
/**
* Route this workflow's dead-lettered runs to another **registered** workflow — by class
* (`deadLetterWorkflow: CheckoutDlqWorkflow`, refactor-safe) or by name for a cross-runtime
* handler. For a handler co-located on the same class, prefer an inline `@DeadLetter()` method
* instead — it takes precedence, and declaring both is a boot-time error. The handler receives a
* {@link DeadLetter} payload, idempotent by a `dlq:<runId>` id.
*/
deadLetterWorkflow?: WorkflowRef;
/**
* Static labels stamped on **every run** of this workflow (e.g. `tags: ['etl', 'critical']`) and
* merged with any per-run tags passed to `start`. Searchable/filterable in the dashboard.
*/
tags?: string[];
/**
* Serialize runs of this workflow that share a key — a durable FIFO mutex. e.g.
* `singleton: { key: (input) => `base:${input.baseId}` }` runs at most one pipeline per base at a
* time; same-key runs queue (suspended) and admit in creation order as slots free. `limit` (default
* 1) raises the concurrency.
*/
singleton?: SingletonConfig;
/**
* Max wall-clock lifetime for a run of this workflow (e.g. `'2h'`, `'7 days'`, or ms). A run that
* outlives it is moved to `cancelled` (`execution_timeout`) by the timer poller — a backstop for
* runs that get stuck or loop forever. Omit for no limit.
*/
executionTimeout?: string | number;
/**
* Capabilities a live worker MUST advertise to run this workflow's turns (handshake design §7.5).
* Only meaningful for a group-served / remote workflow (an in-app worker or a cross-runtime body):
* the control-plane dispatches a turn only to a capability-capable + protocol-compatible worker, and
* if descriptors are published on its group but none qualifies the run parks `blocked` until the
* recovery poll finds one. Absent/empty = "runs anywhere" (a legacy fleet skips the guard, §7.7).
*/
requires?: string[];
/**
* Validate the workflow input at `start` against a **class-validator DTO** (the same
* `plainToInstance` + `validate` NestJS runs in controllers) — invalid input is rejected before any
* run is created. Needs the optional peers `class-validator` + `class-transformer`.
* `@Workflow({ inputSchema: CheckoutInput })`.
*/
inputSchema?: new (...args: any[]) => object;
/**
* Custom input validator (throws on invalid) — an escape hatch for zod/yup/etc. instead of
* `inputSchema`. Takes precedence over `inputSchema` if both are set.
*/
validateInput?: (input: unknown) => void | Promise<void>;
/**
* Validate this workflow's {@link WorkflowCtx.upsertSearchAttributes} writes against a **Standard
* Schema** (https://standardschema.dev — zod 3.24+, valibot, arktype, …). `ctx.upsertSearchAttributes`
* validates the MERGED result (existing attributes shallow-merged with the patch) on every call —
* an invalid merge throws, naming this workflow, the offending key(s), and the schema's issues. The
* schema's inferred output must be search-attribute-shaped (flat `string`/`number`/`boolean` values
* only) — a schema whose output has a nested object/array is a compile-time error here. Omit for
* the prior unvalidated behavior.
*
* ```ts
* import { z } from 'zod';
*
* const orderAttrs = z.object({
* tier: z.enum(['free', 'pro']),
* amount: z.number(),
* });
*
* @Workflow({ name: 'checkout', searchAttributes: orderAttrs })
* class CheckoutWorkflow {
* async run(ctx: WorkflowCtx<InferSearchAttributes<typeof orderAttrs>>, input: CheckoutInput) {
* await ctx.upsertSearchAttributes({ tier: input.tier, amount: input.total });
* }
* }
* ```
*/
searchAttributes?: SearchAttributesSchema;
/**
* Start a fresh run of this workflow whenever any of these events is published via
* `publishEvent(name, payload)` — the payload becomes the run's input. e.g.
* `onEvent: ['user.registered', 'user.invited']`. The same subscription can also be declared with
* the `@OnDurableEvent(...)` class decorator; the two are merged.
*/
onEvent?: string[];
/**
* Coalesce `onEvent` triggers by **debouncing** — start one run only once events have been quiet
* for this long (resets on each event), with the LAST payload. e.g. `debounce: '30s'`.
*/
debounce?: string | number;
/**
* Coalesce `onEvent` triggers by **batching** — start one run with all payloads (`{ events: [...] }`)
* once `maxSize` is reached or `within` elapses from the first event. e.g.
* `batch: { maxSize: 100, within: '10s' }`.
*/
batch?: {
maxSize: number;
within: string | number;
};
}
/**
* Marks a provider class as a durable workflow. Its `run(ctx, input)` method becomes the
* workflow function the engine executes and replays.
*/
declare function Workflow(options: WorkflowOptions): ClassDecorator;
declare function getWorkflowMeta(target: Function): WorkflowMeta | undefined;
declare const DURABLE_STEP_METADATA: unique symbol;
interface DurableStepMeta {
/** The resolved routing name — derived (`Class.method`) or explicit, always present. */
name: string;
/** Opt-in runtime input schema, validated when the handler is served (see `scanSteps`). Absent on
* a bare `@Step()` — compile-time types from the method signature only, no runtime check. */
input?: z.ZodType | undefined;
/** Opt-in runtime output schema, validated before the handler's result is handed back. */
output?: z.ZodType | undefined;
}
/**
* `@Step({ name?, input?, output?, retries?, backoff?, backoffMs?, backoffMaxMs?, jitter?,
* timeoutMs? })` — the object call form. See {@link Step}.
*/
interface StepOptions {
/** Explicit routing name, overriding the derived `Class.method`. */
name?: string;
/** Runtime input schema, validated at the serve boundary (opt-in — a bare `@Step()` skips it). */
input?: z.ZodType;
/** Runtime output schema, validated before the result is returned (opt-in). */
output?: z.ZodType;
/**
* Def-level durable-dispatch policy stamped under `DURABLE_STEP_CONFIG` and read by `ctx.step` at
* the dispatch boundary (see `stepConfigOf`) — a per-call `ctx.step(ref, input, opts)` overrides
* these field-by-field. Max attempts before the step (and run) fails.
*/
retries?: number;
/** How the delay between retries grows: `fixed` (constant) or `exp` (doubles each attempt). */
backoff?: 'fixed' | 'exp';
/** Base delay in ms between retries. Omit (or 0) to retry with no delay. */
backoffMs?: number;
/** Upper bound on the (exponential) backoff delay. */
backoffMaxMs?: number;
/** Add random jitter (50–100% of the computed delay) to avoid thundering-herd retries. */
jitter?: boolean;
/**
* Liveness window for a dispatched step: no result/heartbeat within this many ms presumes the
* worker dead and fails the dispatch with a `RemoteStepTimeout` (retryable — re-dispatches per
* `retries`). Omit to wait indefinitely.
*/
timeoutMs?: number;
/**
* Capabilities a live worker MUST advertise to run this step (handshake design §7.5). The
* control-plane routes the step only to workers whose descriptor advertises every name here; if
* descriptors are published on the step's group but none is capability-capable + protocol-compatible,
* the run parks `blocked` (never a silent hang) until the recovery poll finds one. Absent/empty =
* "runs anywhere" (the default — a legacy fleet publishing no descriptors skips the guard, §7.7). A
* per-call `ctx.step(ref, input, { requires })` overrides this.
*/
requires?: string[];
}
/**
* Marks a provider method as a durable step handler. An in-process transport (e.g. the
* event-emitter transport) or a co-located/thin worker runtime routes a dispatched task to it BY
* NAME — `ctx.step(this.svc.method, input)` reads that same name off the method reference (stamped
* via the shared, cross-package `DURABLE_STEP_NAME` symbol from `@dudousxd/nestjs-durable-core`), so
* there's no separately-declared def linking a call site to this handler. The method's single
* argument is the step input (plus an optional `StepLogger` second arg); its return value is the
* step output.
*
* Three call forms:
* - `@Step()` — bare: the routing name is DERIVED from the method as `` `${ClassName}.${method}` ``
* (e.g. `ExtractionService.runExtractionPage`) — refactor-safe, no magic string.
* - `@Step('custom:name')` — explicit name override (stable across refactors, or a cross-runtime
* contract with a non-JS worker that has no `@Step` of its own).
* - `@Step({ name?, input?, output?, retries?, backoff?, backoffMs?, backoffMaxMs?, jitter?,
* timeoutMs? })` — optional name override, opt-in RUNTIME zod schemas, and a def-level
* durable-retry/liveness-timeout policy. `input` validates before the method runs; `output`
* validates its return value before it's handed back (see `scanSteps`). `retries`/`backoff`/
* `backoffMs`/`backoffMaxMs`/`jitter`/`timeoutMs` are the policy `ctx.step` reads off this method
* reference (via `stepConfigOf`) to build the dispatched `StepDef`'s durable retry/backoff and
* remote-liveness timeout — a per-call `ctx.step(ref, input, opts)` overrides them field-by-field.
* A bare `@Step()` carries none of this and skips validation/retry/timeout — compile-time types
* from the method signature are the only check, and the step dispatches with no retry/timeout.
*/
declare function Step(nameOrOptions?: string | StepOptions): MethodDecorator;
/**
* @deprecated Use `@Step` instead. `@DurableStep` is a back-compat alias of {@link Step} and
* writes the same `DURABLE_STEP_METADATA`, so discovery/registrars treat them identically.
*/
declare const DurableStep: typeof Step;
declare function getDurableStepMeta(method: Function): DurableStepMeta | undefined;
declare const DEAD_LETTER_METADATA: unique symbol;
/**
* The payload a dead-letter handler receives: the dead run's id, its workflow, the input it was
* started with (typed via `TInput`), and the failure that killed it.
*/
interface DeadLetter<TInput = unknown> {
/** Id of the run that was dead-lettered (inspectable + retriable in the dashboard). */
deadRunId: string;
/** Name of the workflow whose run died. */
workflow: string;
/** The original input the dead run was started with. */
input: TInput;
/** The structured error that moved the run to `dead`, when known. */
error?: StepError;
}
/**
* Marks a method on a `@Workflow` class as that workflow's **inline dead-letter handler**. When a
* run of the workflow is moved to `dead` (exceeded `maxRecoveryAttempts`), this method runs — as a
* durable workflow itself, auto-registered as `<workflow>.dlq` with a `dlq:<runId>` id — receiving a
* {@link DeadLetter} payload. It shares the class's injected dependencies, so the handler lives in
* the same file as the workflow it protects.
*
* Takes precedence over `@Workflow({ deadLetterWorkflow })` and the module-level `deadLetterWorkflow`
* default. The method signature is `(ctx, dead)` — the same `ctx` a workflow `run` gets.
*/
declare function DeadLetter(): MethodDecorator;
declare function isDeadLetterHandler(method: Function): boolean;
declare const ON_EVENT_METADATA: unique symbol;
/**
* Subscribe a `@Workflow` class to one or more events: when any of them is published via
* `publishEvent(name, payload)`, a fresh run of the workflow starts with the payload as input.
* Equivalent to `@Workflow({ onEvent })` — use whichever reads better; multiple
* `@OnDurableEvent(...)` decorators and the option are all merged.
*
* Named "durable" on purpose: `@nestjs/event-emitter` exports an `@OnEvent` decorator, and in an
* app using both libs an auto-import picking the wrong one fails silently in either direction.
*/
declare function OnDurableEvent(...events: string[]): ClassDecorator;
/** @deprecated Renamed to `OnDurableEvent` — this alias clashes with `@nestjs/event-emitter`'s `@OnEvent` and will be removed in the next minor. */
declare const OnEvent: typeof OnDurableEvent;
/** All events a workflow class subscribes to — the union of `@Workflow({ onEvent })` and `@OnDurableEvent`. */
declare function getOnEvents(meta: WorkflowMeta, target: object): string[];
/**
* Retention config for the {@link RetentionPoller}. One or more {@link RetentionPolicy policies}
* (status sets must be disjoint — validated at boot), swept together on a shared interval.
*
* ```ts
* retention: {
* sweepInterval: '1m',
* batchSize: 1_000,
* policies: [
* { statuses: ['completed', 'cancelled'], maxAge: '14d', maxCount: 200 },
* { statuses: ['failed'], maxAge: '90d' }, // keep failures longer for debugging
* ],
* }
* ```
*/
interface DurableRetentionOptions {
/** The retention rules, one per (disjoint) status group. */
policies: RetentionPolicy[];
/**
* How often to run the prune sweep. A number is milliseconds; a string is an `ms`-style duration
* (`'1m'`, `'5m'` — `'m'` is minutes). `0` runs it once on boot only. Defaults to 60000 (1 minute).
*/
sweepInterval?: number | string;
/** Max runs hard-deleted per batch (per policy, looped until drained). Defaults to 1000. */
batchSize?: number;
}
/**
* Options for `DurableModule.forRoot`/`forRootAsync`. The **role is inferred** from which of `store`/
* `connection` are set:
*
* - `{ store, transport }` — **operator**: a real `WorkflowEngine` + `StoreRunGateway` + drivers/
* timer/retention/registrars, executing registered bodies INLINE. Driven by {@link drive} (default
* `true`).
* - `{ connection }` (no `store`) — **thin worker**: `WorkflowEngine` resolves to a store-less
* `DurableStartClient`; `RUN_GATEWAY` is a `ProxyRunGateway` when `transport` is also given, else
* every method rejects with a clear error. No store/timer/retention/entity — just discovered
* `@Workflow`/`@Step` handlers served over ONE `runRedisWorker` consumer.
* - `{ store, transport, connection }` — an **operator that also runs a co-located worker**: every
* `@Workflow` is registered GROUP-SERVED (dispatched over the transport, PER WORKFLOW NAME, instead
* of inline) and a co-located consumer (one `runRedisWorker` call) replays the same bodies.
*
* `forRoot` throws when neither `store` nor `connection` is set, and when `store` is set without a
* `transport`.
*
* Set {@link DurableModuleOptions.topology} to name the role explicitly instead of relying on this
* inference — it additionally VALIDATES the axes above (`namespace` vs `partition`) that this prose
* is otherwise the only source of truth for. Omitting it changes nothing.
*/
interface DurableModuleOptions {
/** State store — set this to play the **operator** role (see the interface doc for the full role
* matrix). Omit for a store-less **thin worker** (`connection` only). */
store?: StateStore;
transport?: Transport;
/**
* An ordered pool of named transports for failover / multi-broker setups. The engine dispatches on
* the first and fails over to the next; a step pins one via `ctx.step(handler, input, { transport })`.
* Use instead of `transport`.
*/
transports?: NamedTransport[];
/**
* Cross-instance broadcast pub/sub (lifecycle events + cancellation). Defaults to the (first)
* transport when it can broadcast (event-emitter, BullMQ); set explicitly to use a dedicated one.
*/
controlPlane?: ControlPlane;
/** Interval (ms) for the durable-timer poller. `0` disables it. Defaults to 1000. Operator only. */
timerPollMs?: number;
/**
* Auto-create the durable tables on boot via `store.ensureSchema()`. Defaults to true. Turn
* off in production and call the store adapter's `ensure*DurableSchema()` from a migration.
* Operator only.
*/
autoSchema?: boolean;
/**
* Worker-pool namespace for this instance (forwarded to the engine). The poll paths
* (`runPending`/`recoverIncomplete`/`resumeDueTimers`/`sweepTimeouts`) only act on runs in this
* namespace. Set distinct values to safely share ONE state store across non-interchangeable
* pools — e.g. a developer's local instance vs the deployed cluster. **Omit it to make this
* instance an OPERATOR** — an unset namespace drives/recovers/resumes runs of EVERY namespace and
* leaves the transport on its bare prefix. See `WorkflowEngineDeps.namespace`. Not to be confused
* with {@link partition} (the co-located/thin-worker QUEUE routing suffix).
*/
namespace?: string;
/**
* Multi-instance recovery lease, in ms — how long an instance owns a run it picked up before
* another may take over. Defaults to 30000. Set above your longest synchronous run. Operator only.
*/
leaseMs?: number;
/** Unique id for this instance (for leases, and the co-located/thin-worker consumer's heartbeats). */
instanceId?: string;
/**
* Cap recovery attempts before a still-`running` run is moved to the `dead` dead-letter state
* (a poison pill that crashes the process every boot). Omit for unlimited. Operator only.
*/
maxRecoveryAttempts?: number;
/**
* Opt-in liveness deadline (ms) for a remote (polyglot) workflow `advance`. If the worker neither
* returns a decision nor sends a run-scoped heartbeat within this window, the engine presumes it dead
* and lets recovery re-drive; each heartbeat rearms the window so a slow-but-alive worker is never
* re-driven. Pair with a worker SDK that emits run-scoped heartbeats (`@dudousxd/durable-worker` ≥ the
* release that ships them, and the Python `durable-worker`). Omit for the prior unbounded await.
* Operator only.
*/
remoteAdvanceSilenceMs?: number;
/**
* The **default** workflow to route dead-lettered runs to, for workflows that don't declare their
* own. When a run is moved to `dead` (exceeded `maxRecoveryAttempts`), the started handler gets a
* `DeadLetter` payload `{ deadRunId, workflow, input, error }` (idempotent by a `dlq:<runId>` id) —
* it can alert, compensate, or queue for review. Resolution per dead run: the workflow's inline
* `@DeadLetter()` method → its `@Workflow({ deadLetterWorkflow })` reference → this default. Omit
* everything to just leave dead runs parked (inspectable + retriable from the dashboard). Accepts a
* workflow class (refactor-safe) or a name (cross-runtime). Operator only.
*/
deadLetterWorkflow?: WorkflowRef;
/**
* Whether an **operator** instance actively DRIVES runs — polls pending, recovers crashed
* (`recoverIncomplete`), resumes due timers, sweeps timeouts, prunes retention, and consumes local
* steps — as opposed to a read-only/dashboard replica. Defaults to `true`. Set `false` for a
* **dashboard/dispatch-only** instance (e.g. an API pod) that mounts the store/dashboard but must
* not process or recover workflows — leave that to another driving instance. `drive: false` also
* installs the engine's no-op run dispatcher, so a freshly `start()`ed run stays enqueue-only until
* a driving instance's poll picks it up. Ignored (irrelevant) for a thin worker (no `store`).
*/
drive?: boolean;
/** Max ms to wait for in-flight runs on shutdown before exiting. Defaults to 10000. Operator only. */
shutdownTimeoutMs?: number;
/**
* Recurring workflows to start on a schedule (fixed interval or cron). The timer poller fires
* them each tick on **driving** instances only; `engine.start` is idempotent by the schedule's
* time-bucket run id, so racing instances start each window exactly once. Cron schedules need the
* optional `cron-parser` peer dependency. Operator only.
*/
schedules?: ScheduledWorkflow[];
/**
* Hard-prune terminal run history on an interval so `durable_workflow_runs` (and its child tables)
* stays bounded — without it, completed/failed/cancelled runs accumulate forever and the timer
* poller's per-tick status scans get linearly slower. Driving instances only. Omit to keep all
* history (the default). Requires a store adapter that implements `pruneTerminalRuns` (the
* MikroORM adapter does); other adapters no-op with a warning. See {@link DurableRetentionOptions}.
* Operator only.
*/
retention?: DurableRetentionOptions;
/**
* Build the public callback URL for a `ctx.webhook()` token, e.g.
* ``(t) => `https://api.example.com/durable/api/webhooks/${t}` ``. Populates `DurableWebhook.url`
* so a step can hand the URL to a third party. The dashboard's `POST webhooks/:token` receives the
* callback. Omit to build URLs yourself from the token. Operator only.
*/
webhookUrl?: (token: string) => string;
/**
* Flow-control queues for remote steps, registered on the engine at startup. Reference one from a
* workflow with `ctx.step(handler, input, { queue: name })` to cap its concurrency / admission rate.
* Operator only.
*/
queues?: QueueConfig[];
/**
* Admission backend for the flow-control `queues`. Defaults to in-process (per-instance) caps. Pass
* a `RedisAdmissionBackend` (from `@dudousxd/nestjs-durable-admission-redis`) to make concurrency /
* rate-limit / priority ordering GLOBAL across every engine replica. Operator only.
*/
admission?: AdmissionBackend;
/**
* Provide the current W3C `traceparent` to stamp on dispatched remote tasks, so workers continue
* the distributed trace. Pass `otelTraceparent` from `@dudousxd/nestjs-durable-otel`. Operator only.
*/
traceparent?: () => string | undefined;
/**
* Provide an opaque context carrier (tenant / user / correlation ids) to stamp on dispatched remote
* tasks, so workers re-expose it to the step handler alongside the `traceparent`. The engine never
* inspects its shape.
*
* **Auto-feed**: if you omit this AND `@dudousxd/nestjs-context` is installed (its accessor is bound
* to the shared `CONTEXT_ACCESSOR` token), DurableModule defaults this to a reader that builds
* `{ traceId, tenantId, userRef }` from the accessor — so a workflow dispatched within a request
* automatically carries the originating context across process boundaries. Pass your own reader to
* override the auto-feed; with neither, the carrier is omitted (unchanged behavior).
*
* Re-evaluated at each (re)dispatch — including a retry or a crash/scale-down resume that the engine
* drives OUTSIDE the originating request scope, where this reader may return empty or stale values.
* Treat the carrier as best-effort correlation/propagation metadata only — do NOT treat it as an
* authorization boundary. Operator only.
*/
context?: () => Record<string, unknown> | undefined;
/** Attempts for each saga compensation when a run fails. Default 1 (no retry). Idempotent undos.
* Operator only. */
compensationRetries?: number;
/**
* Opt into tenant read scoping: when `true` AND {@link namespace} is set, the module confines the
* store's reads to that namespace (a tenant-boundary view) instead of the operator view that sees
* all namespaces. Default `false` — the control plane (e.g. flip's `/ctrl` operator screens) stays
* unscoped. Requires a store that exposes the `withScope` capability (the MikroORM adapter does);
* a pre-built store without it is used as-is (construct it already-scoped instead). Operator only.
*/
scopeReads?: boolean;
/**
* ioredis connection (string or options) for a **thin worker** (`connection` only) or the
* **co-located worker** consumer (`store` + `connection`). Set this to play a worker role (see the
* interface doc for the full role matrix). Omit to keep every `@Workflow` on the operator's inline
* fast path with zero dispatch round-trips.
*/
connection?: string | Record<string, unknown>;
/**
* The isolation partition a worker role serves — a thin worker's or co-located worker's queue
* subscription, AND (for a co-located worker) the suffix each `@Workflow`'s dispatch token carries.
* Each handler's queue token is `tenantGroup(sanitizeQueueToken(name), partition)`
* (`@dudousxd/nestjs-durable-core`), so `undefined`, `''`, or `'default'` stays byte-identical to
* the bare (sanitized) name (single-tenant deployment unchanged), and any other partition serves
* `<name>@<partition>` — matching the queue name an operator's convention dispatch routes that
* tenant's runs to (`tenantGroup(run.workflow, run.namespace)` on the engine side). Not to be
* confused with {@link namespace} (the operator's own poll-scoping axis). Ignored for a plain
* operator (no `connection`).
*/
partition?: string;
/** Key prefix namespacing the durable queues for a worker role's consumer. Defaults to `durable`
* (matches the transport). Ignored for a plain operator (no `connection`). */
prefix?: string;
/**
* How many tasks a worker role's co-located/thin consumer runs concurrently PER SUBSCRIBED QUEUE
* (BullMQ Worker concurrency — the same limit is applied to every per-name queue the single
* `runRedisWorker` call starts). Defaults to 1. Raise it so a fanned-out batch (e.g. the N remote
* steps of a `gather`) runs in parallel instead of serially.
*
* Pass `'adaptive'` (or `{ mode:'adaptive', ... }`) to let the consumer self-tune its concurrency
* (latency gradient + RAM brake + backpressure) and publish a live status on its heartbeat. Ignored
* for a plain operator (no `connection`).
*/
concurrency?: ConcurrencyOption;
/**
* Per-handler concurrency override, keyed by workflow/step NAME. Falls back to {@link concurrency}
* (then 1). NOT YET WIRED THROUGH: `runRedisWorker` (`@dudousxd/durable-worker`) currently applies
* one {@link concurrency} limit uniformly across every per-name queue it subscribes to from a
* single call — reserved here for when per-name concurrency lands there.
*/
concurrencyByHandler?: Record<string, ConcurrencyOption>;
/** Timeout for a thin worker's `RunGateway` round-trip over `transport` before it rejects. Defaults
* to 10_000ms inside `ProxyRunGateway`. Ignored for an operator (bound to `StoreRunGateway`). */
runGatewayTimeoutMs?: number;
/**
* An explicit preset that NAMES the deployment role instead of leaving it to `store`/`connection`
* inference, and VALIDATES the axes that are otherwise only prose (see the interface doc's role
* matrix, and {@link namespace} vs {@link partition}). Omit to keep the existing inference —
* `topology` is entirely additive and changes nothing when unset.
*
* **`namespace` vs `partition` — the two axes this preset locks down:**
* - `namespace` is the OPERATOR's poll-scoping axis: which runs a control-plane instance
* drives/recovers/resumes (`runPending`/`recoverIncomplete`/`resumeDueTimers`/`sweepTimeouts`).
* Unset makes the instance see (drive) EVERY namespace.
* `{ role: 'control-plane' }` is the only preset that allows it.
* - `partition` is the WORKER's queue-routing suffix: which queue a thin/co-located worker's
* consumer subscribes to (`<name>@<partition>`), matching the queue an operator's convention
* dispatch routes a run's `namespace` to. `{ role: 'tenant' }` sets this FOR you from `tenant` —
* you never set `partition` directly under `topology`.
*
* **One `tenant` word, two axes — the preset maps it to the right one per role:** on a
* `control-plane` an optional `tenant` scopes the OPERATOR to its own runs (maps to `namespace` —
* a self-contained local stack sharing a broker with a deployed cluster names itself so neither
* drives the other's runs; leave it unset on a deployed operator to drive every tenant). On a
* `tenant` role it is required and maps to `partition` (the worker's queue suffix).
*
* ```ts
* // Control plane: owns the store, dispatches over the transport, prunes old runs. The optional
* // `tenant` (e.g. from an env var) scopes this operator to its own runs — undefined on a
* // deployed operator (drives everything), set on a local stack sharing the broker.
* DurableModule.forRoot({
* topology: { role: 'control-plane', tenant: process.env.DURABLE_TENANT },
* store,
* transport,
* retention: { policies: [{ statuses: ['completed', 'cancelled'], maxAge: '14d' }] },
* });
*
* // Tenant: store-less worker scoped to its own partition — `tenant` maps to `partition` for you.
* DurableModule.forRoot({
* topology: { role: 'tenant', tenant: 'acme-corp' },
* connection: process.env.REDIS_URL,
* });
* ```
*
* Validated at `forRoot`/`forRootAsync` resolution time — see {@link DurableModule} for the exact
* error messages, which double as the axis primer above.
*/
topology?: {
role: 'control-plane';
tenant?: string;
} | {
role: 'tenant';
tenant: string;
};
}
interface DurableModuleAsyncOptions {
useFactory: (...args: never[]) => DurableModuleOptions | Promise<DurableModuleOptions>;
inject?: InjectionToken[];
}
declare class DurableModule {
static forRoot(options: DurableModuleOptions): DynamicModule;
static forRootAsync(options: DurableModuleAsyncOptions): DynamicModule;
/**
* Validates role/axis constraints, in resolution order. When {@link DurableModuleOptions.topology}
* is set, it OWNS validation — {@link assertValidTopology}'s own store/transport/connection checks
* are strictly stronger than {@link assertValidRole}'s, so the topology-specific, axis-teaching
* message is what a `topology`-opted-in consumer sees (not the older generic one). Falls back to
* {@link assertValidRole} unchanged when `topology` is absent — zero behavior change.
*/
private static assertValid;
private static build;
}
/**
* The **store-less `engine.start` facade** for a tenant worker. Provided under the `WorkflowEngine`
* DI token by {@link import('./durable.module').DurableModule} (`forRoot({ connection })`, no
* `store`), so tenant code calls `engine.start(...)` UNCHANGED — it has no idea it is a tenant.
* Instead of touching a DB, `start` publishes a `StartRunMessage` on the SHARED `durable-start-run`
* queue (Option B: tenant rides as message DATA, never as wire segmentation). The operator (control
* plane, `namespace: undefined`) consumes it, stamps the run's namespace from `tenant`, and routes
* the run's task to `<workflow>@<tenant>` — the partition THIS tenant's worker serves.
*
* `cancel`/`deleteRun` need the store/driver a tenant does not have; they throw. No wire message
* exists for them (the operator owns cancellation/retention).
*/
declare class DurableStartClient {
private readonly options;
private readonly deps?;
private readonly tenant;
constructor(options: DurableModuleOptions, deps?: StartRunDeps | undefined);
start<C extends WorkflowClass>(workflow: C, input: WorkflowInputOf<C>, runId?: string, opts?: StartOptions): Promise<RunResult>;
start(workflow: string, input: unknown, runId?: string, opts?: StartOptions): Promise<RunResult>;
cancel(_runId: string): Promise<void>;
deleteRun(_runId: string): Promise<void>;
resume(_runId: string): Promise<void>;
waitForRun(_runId: string, _opts?: {
timeoutMs?: number;
}): Promise<void>;
signal(_token: string, _payload: unknown): Promise<void>;
signalWithStart(_workflow: string, _input: unknown, _runId: string, _signal: {
token: string;
payload?: unknown;
}, _opts?: StartOptions): Promise<void>;
publishEvent(_name: string, _payload: unknown, _opts?: {
id?: string;
buffer?: boolean;
}): Promise<void>;
}
/**
* The `runRedisWorker` function the module uses to start each partition's BullMQ consumer.
* Defaults to the real one from `@dudousxd/durable-worker`; tests `overrideProvider` it with a
* fake so no real Redis is needed.
*/
declare const RUN_REDIS_WORKER: unique symbol;
/** The list of started {@link RunningWorker} handles (the single handle {@link ThinWorkerBootstrap}
* starts), closed on shutdown. */
declare const DURABLE_WORKER_RUNNERS: unique symbol;
/** The signature of `runRedisWorker` — injected behind {@link RUN_REDIS_WORKER}. */
type RunRedisWorkerFn = (opts: RunRedisWorkerOptions) => Promise<RunningWorker>;
/**
* Discovers every provider carrying `@Workflow` metadata and registers its `run(ctx, input)` on the
* thin {@link DurableWorkerRuntime}. Mirrors the engine-side `WorkflowRegistrar`, but registers on
* the runner-core runtime instead of a `WorkflowEngine` — the runtime drives `run` with the thin
* `WorkflowContext` (which `implements WorkflowCtx`), so the body runs unchanged. NO store/engine.
* Inert (skips registration) outside the pure thin-worker role — see {@link isPureThinWorker}.
*/
declare class ThinWorkflowRegistrar implements OnModuleInit {
private readonly discovery;
private readonly runtime;
private readonly options;
constructor(discovery: DiscoveryService, runtime: DurableWorkerRuntime, options: DurableModuleOptions);
onModuleInit(): void;
}
/**
* Discovers every `@Step` method and registers it as a step handler on the thin
* {@link DurableWorkerRuntime}. Mirrors the engine-side `DurableStepRegistrar`, but always registers
* on the runtime (a thin worker IS the consumer — there is no in-process-vs-queue branch). Inert
* outside the pure thin-worker role — see {@link isPureThinWorker}.
*/
declare class ThinStepRegistrar implements OnModuleInit {
private readonly discovery;
private readonly metadataScanner;
private readonly runtime;
private readonly options;
constructor(discovery: DiscoveryService, metadataScanner: MetadataScanner, runtime: DurableWorkerRuntime, options: DurableModuleOptions);
onModuleInit(): void;
}
/**
* Starts ONE `runRedisWorker` call on bootstrap — after both registrars have run, so every handler
* is registered before any task is consumed — and closes it on shutdown. The runner (`@dudousxd/
* durable-worker`) derives its subscription from `runtime.registeredNames()` (one queue per
* registered `@Workflow`/`@Step` name), not a hand-declared group list. Inert outside the pure
* thin-worker role — see {@link isPureThinWorker}: an operator (`store` set) never starts this
* consumer, and `store + connection` starts its OWN co-located consumer instead (`in-app-worker.ts`).
*
* For the shutdown close to fire, enable Nest's shutdown hooks: `app.enableShutdownHooks()`.
*/
declare class ThinWorkerBootstrap implements OnApplicationBootstrap, OnApplicationShutdown {
private readonly runtime;
private readonly options;
private readonly runRedisWorker;
private readonly runnersSink;
private readonly runners;
constructor(runtime: DurableWorkerRuntime, options: DurableModuleOptions, runRedisWorker: RunRedisWorkerFn, runnersSink: RunningWorker[]);
onApplicationBootstrap(): Promise<void>;
onApplicationShutdown(): Promise<void>;
}
/**
* The thin-worker slice of {@link import('./durable.module').DurableModule}'s unified provider set —
* active for the pure thin-worker role (`connection` set, `store` unset): registers discovered
* `@Workflow`/`@Step` on a store-less {@link DurableWorkerRuntime} and starts ONE `runRedisWorker`
* consumer, one queue PER REGISTERED NAME. Always present in the provider list (so a single
* `DurableModule.forRootAsync` can serve any role once its options resolve); every class/factory here
* is inert (see {@link isPureThinWorker}) outside that role.
*/
declare function thinWorkerProviders(): Provider[];
/**
* The no-transport fallback bound to `RUN_GATEWAY` for a thin worker (`connection` set, `store`
* unset) that didn't also pass a `transport` — mirrors `DurableStartClient`'s tenant-error idiom
* (`tenantUnsupported`): every method rejects with a clear, named error instead of a cryptic
* `this.gateway.X is not a function`. `subscribe` is synchronous on the `RunGateway` port, so it
* throws synchronously (same message) rather than returning a rejected promise.
*/
declare function unavailableRunGateway(): RunGateway;
declare const ENTITY_METADATA: unique symbol;
declare const ENTITY_ON_METADATA: unique symbol;
/**
* Marks an `@Injectable()` class as a **durable entity** (a virtual object): its `@On(op)` methods run
* serialized per key over the instance's fields as durable state. e.g.
*
* ```ts
* @Entity({ name: 'cart' }) @Injectable()
* class Cart { items: Item[] = []; @On('add') add(i: Item) { this.items.push(i); } @On('list') list() { return this.items; } }
* ```
*
* The class must be **constructible with no arguments** (a fresh instance is the initial state per key)
* — keep entities pure state, no DI. Drive them with `EntityService` or `ctx.signalEntity`/`callEntity`.
*/
declare function Entity(options: {
name: string;
}): ClassDecorator;
/** Marks an entity method as the handler for operation `op`. */
declare function On(op: string): MethodDecorator;
declare function getEntityMeta(target: Function): {
name: string;
} | undefined;
/**
* Build the engine `EntityConfig` for a discovered `@Entity` class: a fresh instance per key, and
* handlers that rehydrate the class prototype onto the (serialized) state before dispatching the op,
* so methods work after replay.
*/
declare function entityConfigFor(ctor: Function): {
initialState: () => object;
handlers: Record<string, EntityHandler>;
};
/** Inject this to drive durable entities from outside a workflow. */
declare class EntityService {
private readonly engine;
constructor(engine: WorkflowEngine);
/** Send an operation to an entity (fire-and-forget; ordered + exactly-once per key). */
signal(name: string, key: string, op: string, arg?: unknown): Promise<void>;
/** Read an entity's current durable state (or undefined if it has none yet). */
getState<S = unknown>(name: string, key: string): Promise<S | undefined>;
}
/**
* The **co-located in-app worker** (uniform dispatch): active whenever an app supplies BOTH `store`
* AND `connection` (`DurableModule.forRoot({ store, transport, connection, partition? })`) — the same
* process runs the engine AND serves its own discovered `@Workflow`/`@Step`. The engine registers each
* `@Workflow` GROUP-SERVED — its turns are dispatched, PER WORKFLOW NAME, to
* `tenantGroup(sanitizeQueueToken(name), partition)` over the transport via a per-workflow
* `RemoteWorkflowExecutor` instead of run inline — and a co-located {@link DurableWorkerRuntime}
* subscribes one queue per discovered name (via `runRedisWorker`) and replays the very same TS bodies.
* This is the uniform-dispatch "one app, both roles" shape — every turn pays a transport round-trip
* even though the worker is the same process. Requires a transport that carries workflow tasks
* (BullMQ); an in-process-only transport cannot dispatch a `WorkflowExecutor`.
*
* Distinct from the PURE thin-worker role (`connection` set, `store` unset — see
* `durable-worker.module.ts`'s `ThinWorkflowRegistrar`/`ThinStepRegistrar`/`ThinWorkerBootstrap`),
* which has no engine/store of its own at all.
*/
declare const IN_APP_WORKER_BINDING: unique symbol;
/** The co-located worker's {@link DurableWorkerRuntime} (the consumer half). */
declare const IN_APP_WORKER_RUNTIME: unique symbol;
/** `runRedisWorker`, injected so tests can substitute a fake (no real Redis). Defaults to the real one. */
declare const IN_APP_RUN_REDIS_WORKER: unique symbol;
/** Started {@link RunningWorker} handles for the in-app worker, closed on shutdown. */
declare const IN_APP_WORKER_RUNNERS: unique symbol;
/** The group-served binding shape resolved behind {@link IN_APP_WORKER_BINDING}. */
interface InAppWorkerBinding {
transport: Transport;
partition?: string;
}
/**
* The consumer half of the in-app worker: on init it registers every discovered `@Workflow`/`@Step`
* on a {@link DurableWorkerRuntime} (the SAME bodies the engine registered group-served), and on
* bootstrap it starts one `runRedisWorker` call that subscribes one queue per discovered name,
* suffixed by the configured partition, closing it on shutdown. A no-op outside the co-located role
* (see {@link isCoLocatedWorker}). Mirrors the thin {@link
* import('./durable-worker.module').ThinWorkerBootstrap}, but co-located with a full engine.
*/
declare class InAppWorkerBootstrap implements OnModuleInit, OnApplicationBootstrap, OnApplicationShutdown {
private readonly discovery;
private readonly metadataScanner;
private readonly options;
private readonly runtime;
private readonly runRedisWorker;
private readonly runnersSink;
private readonly runners;
constructor(discovery: DiscoveryService, metadataScanner: MetadataScanner, options: DurableModuleOptions, runtime: DurableWorkerRuntime, runRedisWorker: RunRedisWorkerFn, runnersSink: RunningWorker[]);
onModuleInit(): void;
onApplicationBootstrap(): Promise<void>;
onApplicationShutdown(): Promise<void>;
}
/**
* The providers that stand up the co-located in-app worker, added to {@link
* import('./durable.module').DurableModule}'s unified provider set. All are inert outside the
* co-located role (`store` + `connection` both set — see {@link isCoLocatedWorker}), so a plain
* operator (`store` only) or pure thin worker (`connection` only) is unaffected.
*/
declare function inAppWorkerProviders(): Provider[];
/**
* Tenant-side `RunGateway` — round-trips every verb as a `RunRequest`/`RunReply` pair over the
* transport, correlated by a minted `requestId`, and bridges `subscribe` onto the transport's
* per-tenant event stream. Bound under `RUN_GATEWAY` by `DurableModule`'s thin-worker role
* (`connection` set, no `store`) when the app supplies a `transport` (see `unavailableRunGateway`
* for the no-transport fallback). The counterpart to the operator-side `StoreRunGateway`: a tenant
* worker never touches a store/driver directly, only this proxy.
*/
declare class ProxyRunGateway implements RunGateway {
private readonly transport;
private readonly tenant;
private readonly timeoutMs;
private readonly pending;
constructor(transport: Transport, tenant: string, timeoutMs?: number);
private handleReply;
private request;
topology(): DurableTopology;
getRunDetail(runId: string): Promise<RunDetail | null>;
/** The control plane's `StoreRunGateway` resolves each run's `waiting` descriptor; the reply carries
* it through as plain JSON, so a tenant's list rows name the wait too. */
listRuns(query: RunQuery): Promise<RunListItem[]>;
/** Round-trips to the operator, which scopes the result to this tenant's own `@<tenant>` groups. */
workerHealth(): Promise<GroupHealth[]>;
/** Bulk, one request for the whole id list (like `listRuns`, not one request per id). The operator
* filters the reply to runs this tenant actually owns (see `RunRequestResponder`). */
waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
cancel(runId: string, opts?: {
compensate?: boolean;
}): Promise<RunResult | null>;
retry(runId: string): Promise<RunResult | null>;
continue(runId: string): Promise<RunResult | null>;
retryWithInput(runId: string, input: unknown): Promise<{
runId: string;
} | null>;
redispatchPending(runId: string): Promise<(RunResult & {
redispatched: number;
}) | null>;
subscribe(runId: string, onEvent: (event: EngineEvent) => void): () => void;
}
/**
* True for the **operator** role — `store` is set (with or without a co-located `connection`). See
* {@link DurableModuleOptions} for the full role matrix. Kept in its own module (rather than
* `durable.module.ts`) so the poller/registrar classes can import it as a plain VALUE without a
* circular value dependency on the module that also imports them as providers.
*/
declare function isOperatorRole(options: DurableModuleOptions): boolean;
/**
* True when an operator instance actively DRIVES runs — polls pending, recovers crashed, resumes due
* timers, sweeps timeouts, prunes retention, consumes local steps — as opposed to a read-only/
* dashboard replica (`drive: false`) that mounts the store/dashboard but leaves driving to another
* instance. Meaningless (`false`) outside the operator role. Defaults to `true` for an operator.
*/
declare function isDrivingOperator(options: DurableModuleOptions): boolean;
/** The narrow slice of `Transport` the responder needs — a tenant's read/control request in, a
* correlated reply out. Both are OPTIONAL on the full `Transport` interface (only broker
* transports carry the run-request/reply protocol); the caller capability-checks before wiring
* this up (see `durable.module.ts`). */
interface RunRequestTransport {
onRunRequest(handler: (msg: RunRequest) => Promise<void>): void;
publishRunReply(reply: RunReply): Promise<void>;
}
/**
* Operator-side consumer of a tenant's {@link RunRequest}s: answers each one against a
* `RunGateway`, enforcing the tenant boundary before touching the run. For every runId-bearing
* verb it loads the run via `getRunDetail` FIRST and compares `run.namespace` to the requesting
* `msg.tenant` — a mismatch short-circuits into a `cross-tenant` error reply WITHOUT calling the
* verb, so a tenant can never read or act on another tenant's run. `listRuns` is scoped by
* overwriting the query's `namespace` with the requester's tenant, ignoring whatever the client
* sent. This is the security boundary of the tenant run gateway — do not weaken it.
*/
declare class RunRequestResponder {
private readonly transport;
private readonly gateway;
constructor(transport: RunRequestTransport, gateway: RunGateway);
/** Register the consumer on the transport. Each request is answered independently; a handler
* failure never throws back into the transport (errors are captured into an error reply). */
start(): void;
private handle;
private callVerb;
}
declare const STEP_INTERCEPTOR_METADATA: unique symbol;
/**
* The shape a `@StepInterceptor()` provider must implement: `intercept(invocation, next)` wraps the
* real execution of every local `ctx.step` (call `next()` to run the step body / next interceptor,
* and return — or transform — its result). The engine-level {@link StepInterceptor} primitive, with
* NestJS dependency injection.
*/
interface DurableStepInterceptor {
intercept(invocation: StepInvocation, next: () => Promise<unknown>): Promise<unknown>;
}
/**
* Marks an `@Injectable()` class as a durable step interceptor. The module discovers it on boot and
* registers its `intercept` method with the engine (so it can inject loggers/tracers/etc.). First
* declared is outermost. Interceptors fire only when a step actually executes, never on replay.
*/
declare function StepInterceptor(): ClassDecorator;
declare function isStepInterceptor(target: Function): boolean;
/**
* Store-backed `RunGateway` — the operator-side implementation, bound to {@link RUN_GATEWAY} on
* worker/drive instances. Reuses `DashboardService`'s six read/control method bodies verbatim
* (`dashboard.service.ts:76-172`), so a consumer that only needs the bounded `RunGateway` surface
* (the `RunRequestResponder`, a thin controller) doesn't have to depend on the dashboard package.
*/
declare class StoreRunGateway implements RunGateway {
private readonly store;
private readonly engine;
constructor(store: StateStore, engine: WorkflowEngine);
topology(): DurableTopology;
getRunDetail(runId: string): Promise<RunDetail | null>;
listRuns(query: RunQuery): Promise<RunListItem[]>;
/**
* Bulk-resolve what each of `runIds` is currently parked on — for a consumer with its own filtered/
* paginated run listing (e.g. "which of MY suspended runs are stuck at a breakpoint") without
* re-deriving `listRuns`' waiter scan or querying `durable_step_checkpoints` directly. Mirrors
* `listRuns`' waiting computation (the SAME bulk signal-waiter scan + `resolveRunWaiting`), but ALSO
* bulk-fetches the currently-suspended runs to check real status: `engine.cancel` (the non-compensate
* path) doesn't clear a run's signal waiter row, so a cancelled run can leave an ORPHANED waiter
* behind — trusting waiter presence alone would wrongly report a terminal run as still waiting.
* Two bulk scans total (never one query per requested id), same as `listRuns`.
*/
waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
/** Every group the engine knows about — unscoped. A tenant proxy's request is scoped by the
* `RunRequestResponder` (to the requester's `@<tenant>` groups); the operator's own UI sees all. */
workerHealth(): Promise<GroupHealth[]>;
cancel(runId: string, opts?: {
compensate?: boolean;
}): Promise<RunResult | null>;
/** Re-enqueue (dispatch model) instead of resuming inline — a worker picks the run up and replays it. */
retry(runId: string): Promise<RunResult | null>;
continue(runId: string): Promise<RunResult | null>;
retryWithInput(runId: string, input: unknown): Promise<{
runId: string;
} | null>;
redispatchPending(runId: string): Promise<(RunResult & {
redispatched: number;
}) | null>;
subscribe(runId: string, onEvent: (event: EngineEvent) => void): () => void;
}
/** The narrow slice of `StateStore` the republisher needs — the `step.*`-event namespace fallback
* (see {@link TenantEventRepublisher.namespaceFor}). Mirrors the `RunRequestTransport` narrowing
* convention (`run-request-responder.ts`): depend on the smallest surface, not the whole store. */
type RunLookupStore = Pick<StateStore, 'getRun'>;
/**
* Re-publishes engine lifecycle events onto a run's per-tenant channel via `publish` (bound from
* `Transport.publishTenantEvent`), so a store-less tenant worker can live-tail its OWN runs.
*
* The run's namespace is read straight off `event.namespace` (stamped by `engine.ts`'s `emit()` on
* every `run.*` lifecycle event, where the run is already in hand); a `step.*` event doesn't carry
* it, so those fall back to `store.getRun`. EVERY run's resolved namespace is memoized in
* `runNamespaces` — including a bare/`default` run — so the store is read at most ONCE per run id
* regardless of how many `step.*` events that run emits; a `null` cache entry means "resolved, not
* a tenant" (distinct from "not yet resolved", i.e. absent from the map). A run's entry is deleted
* the moment its terminal event is handled, so the cache stays bounded to in-flight runs (tenant and
* default alike) — the same order of magnitude it was already paying for tenant runs alone, in
* exchange for turning a per-event store read into a per-run one.
*/
declare class TenantEventRepublisher {
private readonly store;
private readonly publish;
private readonly runNamespaces;
constructor(store: RunLookupStore, publish: (event: TenantEvent) => Promise<void>);
handle(event: EngineEvent): Promise<void>;
private namespaceFor;
}
/**
* Cross-lib injection token for the current-request context accessor, owned by
* `@dudousxd/nestjs-context`. We do NOT import nestjs-context (it is an OPTIONAL
* peer dependency) — instead we share its well-known token by value so DI
* resolves the same provider when nestjs-context is installed and present.
*
* `Symbol.for(key)` uses the global symbol registry, so this resolves to the
* SAME symbol instance as nestjs-context's `tokens.ts` (and the identical token
* declared by `@dudousxd/nestjs-authz`) without any import. The key MUST stay
* byte-identical with nestjs-context's export.
*/
declare const CONTEXT_ACCESSOR: unique symbol;
/**
* @deprecated Inject the `RunGateway` abstract class directly (it is its own DI token now):
* `constructor(private readonly gateway: RunGateway)`, provider `{ provide: RunGateway, useClass }`.
* This symbol is kept as a back-compat alias — it points at the `RunGateway` class, so existing
* `@Inject(RUN_GATEWAY)` sites resolve the very same token — and will be removed in a future major.
*
* `RunGateway` is owned by `@dudousxd/nestjs-durable-core` (a required peer dep of both this package
* and the dashboard), so the abstract class is a single shared token across packages without the
* previous `Symbol.for` value-sharing hack.
*/
declare const RUN_GATEWAY: typeof RunGateway;
/** Public entry point for starting and resuming workflow runs. */
declare class WorkflowService {
private readonly engine;
constructor(engine: WorkflowEngine);
/**
* Enqueue a workflow run — it creates the run (`pending`) and returns `{ runId, status: 'pending' }`
* immediately; a worker executes the body (so the caller never blocks on workflow logic). Use
* {@link waitForRun} when you need the outcome. Pass the workflow's **class**
* (`start(CheckoutWorkflow, input)`) for a typed input + refactor-safety, or a **name** string for a
* cross-runtime workflow. `runId` defaults to a random id; pass your own to make the start idempotent
* (a redelivery returns the existing run). `opts.tags` are merged with the workflow's static
* `@Workflow({ tags })`; `opts.searchAttributes` stamp typed, queryable run data.
*/
start<C extends WorkflowClass>(workflow: C, input: WorkflowInputOf<C>, runId?: string, opts?: StartOptions): Promise<RunResult>;
start(workflow: string, input: unknown, runId?: string, opts?: StartOptions): Promise<RunResult>;
resume(runId: string): Promise<RunResult>;
/**
* Resolve once a run settles — terminal (completed/failed/cancelled/dead) or suspended. `start`
* only enqueues (a worker runs the body), so pair them when a request needs the outcome:
* `const { runId } = await svc.start(...); const result = await svc.waitForRun(runId)`.
*/
waitForRun(runId: string, opts?: {
timeoutMs?: number;
}): Promise<RunResult>;
/** Deliver an external signal (e.g. from a webhook) to the run waiting on `token`. */
signal(token: string, payload: unknown): Promise<RunResult | null>;
/**
* Ensure a run exists for `runId`, then deliver a signal to it — race-free (the signal is buffered
* until the run reaches its `waitForSignal`). The durable-entity / accumulator pattern: one
* long-lived run per key fed events by many calls. See {@link WorkflowEngine.signalWithStart}.
*/
signalWithStart<C extends WorkflowClass>(workflow: C, input: WorkflowInputOf<C>, runId: string, signal: {
token: string;
payload?: unknown;
}, opts?: StartOptions): Promise<{
runId: string;
}>;
signalWithStart(workflow: string, input: unknown, runId: string, signal: {
token: string;
payload?: unknown;
}, opts?: StartOptions): Promise<{
runId: string;
}>;
/**
* Publish a named event. Resumes runs waiting on it via `ctx.waitForEvent(name, { match })` and
* starts a fresh run of every workflow subscribed via `@Workflow({ onEvent })` / `@OnDurableEvent` (the
* payload becomes its input). Pass `opts.id` to dedupe redeliveries. Returns how many runs it
* touched (resumed + started).
*
* Reliable by default: a publish that touches NOBODY (no live waiter, no subscriber) buffers ONE
* copy so a LATER `waitForEvent(name, { match })` still consumes it instead of it being dropped —
* see {@link WorkflowEngine.publishEvent}'s full semantics doc. Pass `opts.buffer: false` to opt out.
*/
publishEvent(name: string, payload: unknown, opts?: {
id?: string;
buffer?: boolean;
}): Promise<number>;
}
export { CONTEXT_ACCESSOR, type ContextAccessor, type ContextStore, DEAD_LETTER_METADATA, DURABLE_STEP_METADATA, DURABLE_WORKER_RUNNERS, DeadLetter, DurableModule, type DurableModuleAsyncOptions, type DurableModuleOptions, type DurableRetentionOptions, DurableStartClient, DurableStep, type DurableStepInterceptor, type DurableStepMeta, ENTITY_METADATA, ENTITY_ON_METADATA, Entity, EntityService, IN_APP_RUN_REDIS_WORKER, IN_APP_WORKER_BINDING, IN_APP_WORKER_RUNNERS, IN_APP_WORKER_RUNTIME, type InAppWorkerBinding, InAppWorkerBootstrap, ON_EVENT_METADATA, On, OnDurableEvent, OnEvent, ProxyRunGateway, RUN_GATEWAY, RUN_REDIS_WORKER, type RunLookupStore, type RunRedisWorkerFn, RunRequestResponder, type RunRequestTransport, STEP_INTERCEPTOR_METADATA, Step, StepInterceptor, type StepOptions, StoreRunGateway, TenantEventRepublisher, ThinStepRegistrar, ThinWorkerBootstrap, ThinWorkflowRegistrar, type UserRef, WORKFLOW_METADATA, Workflow, type WorkflowAttributesOf, type WorkflowMeta, type WorkflowOptions, WorkflowService, attributesOf, entityConfigFor, getDurableStepMeta, getEntityMeta, getOnEvents, getWorkflowMeta, inAppWorkerProviders, isDeadLetterHandler, isDrivingOperator, isOperatorRole, isStepInterceptor, thinWorkerProviders, unavailableRunGateway };