| name | durable-signals-and-timers |
| description | Pause and resume @dudousxd/nestjs-durable workflows — ctx.waitForSignal(token) resumed by WorkflowService.signal(token, payload), ctx.waitForEvent(name, { match }) resumed by publishEvent(name, payload), @OnEvent / @Workflow({ onEvent }) event-triggered starts, durable ctx.sleep(duration) / ctx.sleepUntil(date), ctx.webhook() for third-party callbacks, and signalWithStart for the durable-entity/accumulator pattern. Covers the timeoutMs determinism caveat.
|
| license | MIT |
| metadata | {"type":"core","library":"@dudousxd/nestjs-durable","library_version":"0.22.0","framework":"nestjs"} |
Signals, events & durable timers
Workflows pause on durable primitives and resume when an external event arrives — a signal, a named
event, a timer, or a webhook callback. The run is suspended (consuming nothing) while it waits and
survives restarts.
Setup
Inside a workflow body you await the wait primitive; outside (a controller, a webhook) you deliver
via WorkflowService:
import { Workflow } from '@dudousxd/nestjs-durable';
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
import { NotificationsService } from './notifications.service';
@Workflow({ name: 'approval', version: '1' })
export class ApprovalWorkflow {
constructor(private readonly notifications: NotificationsService) {}
async run(ctx: WorkflowCtx, order: { id: string }) {
await ctx.step(this.notifications.requestApproval, order.id);
const decision = await ctx.waitForSignal<{ approved: boolean }>(
`approve:${order.id}`,
{ timeoutMs: 24 * 60 * 60 * 1000 },
);
return decision.approved ? 'shipped' : 'rejected';
}
}
import { Step } from '@dudousxd/nestjs-durable';
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotificationsService {
@Step()
async requestApproval(orderId: string) {
}
}
import { WorkflowService } from '@dudousxd/nestjs-durable';
@Controller('approvals')
export class ApprovalController {
constructor(private readonly workflows: WorkflowService) {}
@Post(':orderId')
approve(@Param('orderId') orderId: string, @Body() body: { approved: boolean }) {
return this.workflows.signal(`approve:${orderId}`, body);
}
}
Core patterns
Signals (point-to-point) vs events (named pub/sub)
ctx.waitForSignal<T>(token, { timeoutMs? }) waits on an exact token; deliver with
workflows.signal(token, payload). One token, one waiting run.
ctx.waitForEvent<T>(name, { match?, timeoutMs? }) waits on a named event with optional match
filtering; workflows.publishEvent(name, payload) fans out to every run whose match the payload
satisfies (and starts any @OnEvent workflows). Returns how many runs it touched.
const order = await ctx.waitForEvent<Order>('order.paid', { match: { orderId: id } });
Event-triggered workflow starts
@Workflow({ onEvent }) or @OnEvent(...) start a fresh run whenever the event is published —
the payload becomes the run's input. debounce / batch coalesce bursts.
import { Workflow, OnEvent } from '@dudousxd/nestjs-durable';
@OnEvent('user.registered', 'user.invited')
@Workflow({ name: 'welcome', version: '1', debounce: '30s' })
export class WelcomeWorkflow {
async run(ctx: WorkflowCtx, user: { id: string }) { }
}
Durable timers
ctx.sleep(duration) and ctx.sleepUntil(date) suspend the run durably — a 7-day sleep survives any
number of restarts; the timer poller resumes it when due. duration accepts ms-style strings.
await ctx.step(this.reports.draft, order.id);
await ctx.sleep('7 days');
await ctx.step(this.reports.send, order.id);
Webhooks (third-party callbacks)
ctx.webhook() mints a stable token + url before suspending, so you can hand the url to a third
party — inside a dispatched ctx.step — then await wh.wait() for the callback (delivered as
signal(token, body)). Configure webhookUrl on the module to populate wh.url.
const wh = ctx.webhook<{ status: string }>();
await ctx.step(this.provider.register, { callbackUrl: wh.url });
const result = await wh.wait();
signalWithStart — the accumulator pattern
signalWithStart ensures a run exists, then delivers a signal race-free — one long-lived run per key
fed by many calls.
await this.workflows.signalWithStart(
AggregatorWorkflow,
{ key },
`aggregator:${key}`,
{ token: `event:${key}`, payload: event },
);
Common mistakes
1. Using setTimeout / a cron instead of ctx.sleep
async run(ctx, input) {
await new Promise((r) => setTimeout(r, 7 * 86_400_000));
await ctx.step(this.reports.send, input);
}
async run(ctx, input) {
await ctx.sleep('7 days');
await ctx.step(this.reports.send, input);
}
ctx.sleep/sleepUntil record a durable timer checkpoint and suspend; the timer poller resumes the
run — a raw setTimeout dies with the process. Source: packages/core/src/workflow-ctx.ts
(sleep, sleepUntil, suspendUntil).
2. Adding { timeoutMs } to a live waitForSignal
await ctx.waitForSignal('approve');
await ctx.waitForSignal('approve', { timeoutMs: 60_000 });
@Workflow({ name: 'approval', version: '2' })
A bounded wait consumes two logical positions (deadline + wait), an unbounded one consumes one, so
toggling timeoutMs is a positional (versioned) change. Source: packages/core/src/workflow-ctx.ts
(determinism note above consumeBuffered).
3. Signalling a token that doesn't match the waiting run
await ctx.waitForSignal(`approve:${order.id}`);
await this.workflows.signal('approve', body);
await this.workflows.signal(`approve:${order.id}`, body);
signal(token, payload) resolves only the run waiting on that exact token; a mismatched token is
buffered and the run never wakes. Source: packages/nestjs/src/workflow.service.ts (signal),
packages/core/src/workflow-ctx.ts (waitForSignal).