@dudousxd/nestjs-durable 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +255 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -3
- package/dist/index.d.ts +79 -3
- package/dist/index.js +219 -66
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
- package/skills/durable-determinism/SKILL.md +144 -0
- package/skills/durable-setup/SKILL.md +189 -0
- package/skills/durable-signals-and-timers/SKILL.md +181 -0
- package/skills/durable-testing/SKILL.md +146 -0
- package/skills/durable-workflows/SKILL.md +183 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-determinism
|
|
3
|
+
description: >
|
|
4
|
+
The one correctness rule for @dudousxd/nestjs-durable workflows — the run(ctx, input) body re-runs
|
|
5
|
+
top-to-bottom on recovery, so it must be deterministic. Keep Date.now/Math.random/IO out of the
|
|
6
|
+
body and inside steps; use ctx.now()/ctx.random()/ctx.uuid() for checkpointed non-deterministic
|
|
7
|
+
values. Covers positional replay, NonDeterminismError, @Workflow version pinning + side-by-side
|
|
8
|
+
registration for breaking changes, exactly-once vs physical retry idempotency, and self-healing recovery.
|
|
9
|
+
license: MIT
|
|
10
|
+
metadata:
|
|
11
|
+
type: core
|
|
12
|
+
library: "@dudousxd/nestjs-durable"
|
|
13
|
+
library_version: 0.22.0
|
|
14
|
+
framework: nestjs
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Determinism & replay — the one rule
|
|
18
|
+
|
|
19
|
+
Durability comes from **checkpoint + deterministic replay**. On recovery the engine re-runs the
|
|
20
|
+
workflow body from the top; a step that already has a `completed` checkpoint returns its saved result
|
|
21
|
+
instead of executing again. For that to be safe, **the body must be deterministic** — all
|
|
22
|
+
non-determinism (clock, randomness, network, IO) lives inside steps.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
There is nothing to install for this rule — it governs how you write `run(ctx, input)`. The key
|
|
27
|
+
helpers are checkpointed wrappers on `ctx`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
|
|
31
|
+
|
|
32
|
+
async run(ctx: WorkflowCtx, input: unknown) {
|
|
33
|
+
const at = await ctx.now(); // checkpointed clock — same value on every replay
|
|
34
|
+
const r = await ctx.random(); // checkpointed Math.random()
|
|
35
|
+
const id = await ctx.uuid(); // checkpointed crypto.randomUUID()
|
|
36
|
+
// ...use these instead of the raw globals...
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Core patterns
|
|
41
|
+
|
|
42
|
+
### Side effects live in steps; the body is pure orchestration
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// ✓ The body only calls steps and branches on their results.
|
|
46
|
+
async run(ctx: WorkflowCtx, order: Order) {
|
|
47
|
+
const quote = await ctx.step('quote', () => this.pricing.fetch(order)); // IO checkpointed
|
|
48
|
+
if (quote.total > 1000) await ctx.step('review', () => this.flagForReview(order));
|
|
49
|
+
await ctx.step('charge', () => this.billing.charge(quote));
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The engine guarantees each step runs **exactly once logically**, even across crashes and deploys,
|
|
54
|
+
because re-running the body short-circuits completed checkpoints.
|
|
55
|
+
|
|
56
|
+
### Use ctx.now / ctx.random / ctx.uuid for non-deterministic values
|
|
57
|
+
|
|
58
|
+
A raw `Date.now()` or `Math.random()` in the body produces a different value on each replay and
|
|
59
|
+
shifts later decisions, corrupting the run. The `ctx` wrappers capture the value as a checkpoint on
|
|
60
|
+
first run and replay it verbatim.
|
|
61
|
+
|
|
62
|
+
### Version-pinned replay for breaking changes
|
|
63
|
+
|
|
64
|
+
Replay is **positional**: reordering or inserting steps while runs are in flight would corrupt them.
|
|
65
|
+
A run resumes on the `version` it started on, so during a breaking change register both versions side
|
|
66
|
+
by side — in-flight runs drain on the old, new runs start on the new.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
@Workflow({ name: 'checkout', version: '1' })
|
|
70
|
+
export class CheckoutWorkflowV1 { /* old body, kept until in-flight runs drain */ }
|
|
71
|
+
|
|
72
|
+
@Workflow({ name: 'checkout', version: '2' })
|
|
73
|
+
export class CheckoutWorkflowV2 { /* new body */ }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A run whose version is no longer registered fails loudly rather than silently corrupting.
|
|
77
|
+
|
|
78
|
+
### Recovery is automatic and self-healing
|
|
79
|
+
|
|
80
|
+
`DurableModule` wires `engine.recoverIncomplete()` — it runs on boot **and** periodically (the
|
|
81
|
+
`TimerPoller`), so a run orphaned by a crashed worker is reclaimed within ~`leaseMs`. A live worker
|
|
82
|
+
renews its lease while running, so a long step is not reclaimed out from under it.
|
|
83
|
+
|
|
84
|
+
## Common mistakes
|
|
85
|
+
|
|
86
|
+
### 1. Reading the clock or randomness directly in the body
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// ✗ Wrong — different value on every replay; downstream branches diverge
|
|
90
|
+
async run(ctx, order) {
|
|
91
|
+
if (Math.random() < 0.1) await ctx.step('sample', () => sample()); // replays differently
|
|
92
|
+
const ts = Date.now(); // changes each recovery
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ✓ Correct — checkpointed sources are stable across replays
|
|
96
|
+
async run(ctx, order) {
|
|
97
|
+
if ((await ctx.random()) < 0.1) await ctx.step('sample', () => sample());
|
|
98
|
+
const ts = await ctx.now();
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The body re-runs on recovery; raw `Date.now()`/`Math.random()` are not checkpointed, so they shift
|
|
103
|
+
the replay. Source: packages/core/src/workflow-ctx.ts (`now`, `random`, `uuid`).
|
|
104
|
+
|
|
105
|
+
### 2. Editing a live workflow body without bumping `version`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// ✗ Wrong — insert a step into v1 while runs are in flight → positions shift, runs corrupt
|
|
109
|
+
@Workflow({ name: 'checkout', version: '1' })
|
|
110
|
+
class CheckoutWorkflow {
|
|
111
|
+
async run(ctx, o) {
|
|
112
|
+
await ctx.step('audit', () => audit(o)); // NEW step inserted before existing ones
|
|
113
|
+
await ctx.step('charge', () => charge(o));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ✓ Correct — ship the change as a new version, keep v1 registered until its runs drain
|
|
118
|
+
@Workflow({ name: 'checkout', version: '2' })
|
|
119
|
+
class CheckoutWorkflowV2 { /* new body */ }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Replay matches checkpoints by position; inserting/reordering steps under the same version throws a
|
|
123
|
+
`NonDeterminismError` for in-flight runs. Source: website/content/docs/concepts/durability.mdx
|
|
124
|
+
("Version-pinned replay"), packages/core/src/workflow-ctx.ts (`NonDeterminismError`).
|
|
125
|
+
|
|
126
|
+
### 3. Assuming a remote step physically runs exactly once
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// ✗ Wrong — handler assumes it can never be called twice for the same step
|
|
130
|
+
@DurableStep('payments.charge-card')
|
|
131
|
+
async charge(input) {
|
|
132
|
+
return { chargeId: await this.stripe.charge(input) }; // double-charge if checkpoint write is lost
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ✓ Correct — make the handler idempotent on the stable stepId
|
|
136
|
+
@DurableStep('payments.charge-card')
|
|
137
|
+
async charge(input, stepId: string) {
|
|
138
|
+
return { chargeId: await this.stripe.charge(input, { idempotencyKey: stepId }) };
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The engine guarantees *logical* exactly-once, but a crash after the worker ran and before its
|
|
143
|
+
checkpoint was written can physically re-run the step — dedupe on the stable `stepId`.
|
|
144
|
+
Source: website/content/docs/concepts/durability.mdx ("Idempotency").
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-setup
|
|
3
|
+
description: >
|
|
4
|
+
Set up @dudousxd/nestjs-durable in a NestJS app — DurableModule.forRootAsync with a StateStore
|
|
5
|
+
+ Transport, register @Workflow / @DurableStep providers, and start runs with WorkflowService.
|
|
6
|
+
Covers the zero-infra EventEmitterTransport + InMemoryStateStore default, start() enqueues vs
|
|
7
|
+
waitForRun() settles, autoSchema, worker:false API/worker split, DurableWorkerModule thin worker,
|
|
8
|
+
forRoot vs forRootAsync, app.enableShutdownHooks for graceful drain.
|
|
9
|
+
license: MIT
|
|
10
|
+
metadata:
|
|
11
|
+
type: core
|
|
12
|
+
library: "@dudousxd/nestjs-durable"
|
|
13
|
+
library_version: 0.22.0
|
|
14
|
+
framework: nestjs
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Durable setup (NestJS module wiring)
|
|
18
|
+
|
|
19
|
+
Install `@dudousxd/nestjs-durable`, register `DurableModule` with a store + transport, declare your
|
|
20
|
+
workflow/step providers, and start runs with `WorkflowService`. The defaults below use zero
|
|
21
|
+
infrastructure (in-process transport, in-memory store) — swap the store/transport for production.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
Install the module plus its core peer, a transport, and zod (remote-step contracts use it):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm add @dudousxd/nestjs-durable @dudousxd/nestjs-durable-core \
|
|
29
|
+
@dudousxd/nestjs-durable-transport-event-emitter @nestjs/event-emitter zod
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Register the module. `forRootAsync` lets the factory inject providers (here `EventEmitter2`); the
|
|
33
|
+
module is `global`, so `WorkflowService` is injectable everywhere without re-importing.
|
|
34
|
+
|
|
35
|
+
```ts title="app.module.ts"
|
|
36
|
+
import { DurableModule } from '@dudousxd/nestjs-durable';
|
|
37
|
+
import { InMemoryStateStore } from '@dudousxd/nestjs-durable-core';
|
|
38
|
+
import { EventEmitterTransport } from '@dudousxd/nestjs-durable-transport-event-emitter';
|
|
39
|
+
import { Module } from '@nestjs/common';
|
|
40
|
+
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
|
|
41
|
+
import { CheckoutWorkflow } from './checkout.workflow';
|
|
42
|
+
import { PaymentsWorker } from './payments.worker';
|
|
43
|
+
|
|
44
|
+
@Module({
|
|
45
|
+
imports: [
|
|
46
|
+
EventEmitterModule.forRoot(),
|
|
47
|
+
DurableModule.forRootAsync({
|
|
48
|
+
inject: [EventEmitter2],
|
|
49
|
+
useFactory: (emitter: EventEmitter2) => ({
|
|
50
|
+
store: new InMemoryStateStore(),
|
|
51
|
+
transport: new EventEmitterTransport(emitter),
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
],
|
|
55
|
+
providers: [CheckoutWorkflow, PaymentsWorker], // discovered by DurableModule on boot
|
|
56
|
+
})
|
|
57
|
+
export class AppModule {}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`@Workflow` and `@DurableStep` providers are plain providers — list them in `providers` (or any
|
|
61
|
+
imported module) and `DurableModule`'s discovery registers them on the engine at boot.
|
|
62
|
+
|
|
63
|
+
Enable shutdown hooks so the engine drains in-flight runs on deploy:
|
|
64
|
+
|
|
65
|
+
```ts title="main.ts"
|
|
66
|
+
const app = await NestFactory.create(AppModule);
|
|
67
|
+
app.enableShutdownHooks(); // engine releases run leases on OnApplicationShutdown
|
|
68
|
+
await app.listen(3000);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Core patterns
|
|
72
|
+
|
|
73
|
+
### Start a run, then (optionally) wait for it
|
|
74
|
+
|
|
75
|
+
`start` **enqueues** the run and returns immediately with `{ runId, status: 'pending' }` — a worker
|
|
76
|
+
runs the body, so the HTTP handler never blocks. Pass the workflow **class** for a typed input.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { WorkflowService } from '@dudousxd/nestjs-durable';
|
|
80
|
+
|
|
81
|
+
@Controller('checkout')
|
|
82
|
+
export class CheckoutController {
|
|
83
|
+
constructor(private readonly workflows: WorkflowService) {}
|
|
84
|
+
|
|
85
|
+
@Post()
|
|
86
|
+
async checkout(@Body() order: Order) {
|
|
87
|
+
const { runId } = await this.workflows.start(CheckoutWorkflow, order); // returns now
|
|
88
|
+
return { runId };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Post('sync')
|
|
92
|
+
async checkoutSync(@Body() order: Order) {
|
|
93
|
+
const { runId } = await this.workflows.start(CheckoutWorkflow, order);
|
|
94
|
+
return this.workflows.waitForRun(runId); // resolves once the run settles
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### forRoot vs forRootAsync, and the production store/transport
|
|
100
|
+
|
|
101
|
+
`forRoot(options)` takes a static options object; `forRootAsync({ useFactory, inject })` builds them
|
|
102
|
+
from injected providers (a config service, an ORM connection). For production, swap the in-memory
|
|
103
|
+
store for an ORM `StateStore` and the event-emitter transport for BullMQ:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
DurableModule.forRootAsync({
|
|
107
|
+
inject: [MikroORM],
|
|
108
|
+
useFactory: (orm: MikroORM) => ({
|
|
109
|
+
store: new MikroOrmStateStore(orm), // persists checkpoints to Postgres/MySQL
|
|
110
|
+
transport: new BullMQTransport({ connection }), // cross-process / Python steps
|
|
111
|
+
autoSchema: false, // run migrations instead in production
|
|
112
|
+
leaseMs: 30_000, // recovery lease > your longest resume step
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Split API pods from worker pods
|
|
118
|
+
|
|
119
|
+
For scale, run dispatch-only pods (`worker: false`) and worker pods (default `worker: true`) that
|
|
120
|
+
share only the database. An API pod's `start` only enqueues; worker pods poll and run the body.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// API / dashboard pod: enqueue-only, never executes or recovers workflows
|
|
124
|
+
DurableModule.forRoot({ store, transport, worker: false });
|
|
125
|
+
|
|
126
|
+
// Worker pod (default): runs the body, recovers orphaned runs, polls timers
|
|
127
|
+
DurableModule.forRoot({ store, transport /* worker: true is the default */ });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A store-less **thin worker** (BullMQ consumer only, no engine/store) uses `DurableWorkerModule`:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { DurableWorkerModule } from '@dudousxd/nestjs-durable';
|
|
134
|
+
|
|
135
|
+
@Module({
|
|
136
|
+
imports: [DurableWorkerModule.forRoot({ connection: 'redis://localhost:6379', groups: ['payments'] })],
|
|
137
|
+
providers: [PaymentsWorker], // @DurableStep handlers for the 'payments' group
|
|
138
|
+
})
|
|
139
|
+
export class WorkerAppModule {}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Common mistakes
|
|
143
|
+
|
|
144
|
+
### 1. Expecting `start` to return the workflow's result
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// ✗ Wrong — `start` enqueues; `result` is { runId, status: 'pending' }, never the run output
|
|
148
|
+
const result = await this.workflows.start(CheckoutWorkflow, order);
|
|
149
|
+
return result.output; // undefined
|
|
150
|
+
|
|
151
|
+
// ✓ Correct — await the outcome with waitForRun when you need it inline
|
|
152
|
+
const { runId } = await this.workflows.start(CheckoutWorkflow, order);
|
|
153
|
+
return this.workflows.waitForRun(runId); // resolves once the run settles
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`start` creates the run as `'pending'` and a worker runs the body asynchronously; only `waitForRun`
|
|
157
|
+
resolves with the settled outcome. Source: packages/nestjs/src/workflow.service.ts.
|
|
158
|
+
|
|
159
|
+
### 2. Leaving `autoSchema` on in production
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// ✗ Wrong — auto-creates durable tables on every boot against the prod DB
|
|
163
|
+
DurableModule.forRoot({ store, transport }); // autoSchema defaults to true
|
|
164
|
+
|
|
165
|
+
// ✓ Correct — disable it and create the schema from a migration
|
|
166
|
+
DurableModule.forRoot({ store, transport, autoSchema: false });
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`autoSchema` defaults to `true` and calls `store.ensureSchema()` on boot — fine for dev, but in
|
|
170
|
+
production you want the store adapter's `ensure*DurableSchema()` run as a migration instead.
|
|
171
|
+
Source: packages/nestjs/src/durable.module.ts (`autoSchema`).
|
|
172
|
+
|
|
173
|
+
### 3. Forgetting to register the workflow/step as a provider
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
// ✗ Wrong — the class is decorated but never provided, so discovery never finds it
|
|
177
|
+
@Module({ imports: [DurableModule.forRoot({ store, transport })] })
|
|
178
|
+
export class AppModule {} // start('checkout', ...) → unknown workflow
|
|
179
|
+
|
|
180
|
+
// ✓ Correct — list it (or import a module that provides it)
|
|
181
|
+
@Module({
|
|
182
|
+
imports: [DurableModule.forRoot({ store, transport })],
|
|
183
|
+
providers: [CheckoutWorkflow, PaymentsWorker],
|
|
184
|
+
})
|
|
185
|
+
export class AppModule {}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`DurableModule` registers workflows/steps by scanning DI providers on boot; an unprovided class is
|
|
189
|
+
invisible to the engine. Source: packages/nestjs/src/durable.module.ts (DiscoveryModule + registrars).
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-signals-and-timers
|
|
3
|
+
description: >
|
|
4
|
+
Pause and resume @dudousxd/nestjs-durable workflows — ctx.waitForSignal(token) resumed by
|
|
5
|
+
WorkflowService.signal(token, payload), ctx.waitForEvent(name, { match }) resumed by
|
|
6
|
+
publishEvent(name, payload), @OnEvent / @Workflow({ onEvent }) event-triggered starts,
|
|
7
|
+
durable ctx.sleep(duration) / ctx.sleepUntil(date), ctx.webhook() for third-party callbacks, and
|
|
8
|
+
signalWithStart for the durable-entity/accumulator pattern. Covers the timeoutMs determinism caveat.
|
|
9
|
+
license: MIT
|
|
10
|
+
metadata:
|
|
11
|
+
type: core
|
|
12
|
+
library: "@dudousxd/nestjs-durable"
|
|
13
|
+
library_version: 0.22.0
|
|
14
|
+
framework: nestjs
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Signals, events & durable timers
|
|
18
|
+
|
|
19
|
+
Workflows pause on durable primitives and resume when an external event arrives — a signal, a named
|
|
20
|
+
event, a timer, or a webhook callback. The run is `suspended` (consuming nothing) while it waits and
|
|
21
|
+
survives restarts.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
Inside a workflow body you await the wait primitive; outside (a controller, a webhook) you deliver
|
|
26
|
+
via `WorkflowService`:
|
|
27
|
+
|
|
28
|
+
```ts title="approval.workflow.ts"
|
|
29
|
+
import { Workflow } from '@dudousxd/nestjs-durable';
|
|
30
|
+
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
|
|
31
|
+
|
|
32
|
+
@Workflow({ name: 'approval', version: '1' })
|
|
33
|
+
export class ApprovalWorkflow {
|
|
34
|
+
async run(ctx: WorkflowCtx, order: { id: string }) {
|
|
35
|
+
await ctx.step('request', () => this.notify(order.id));
|
|
36
|
+
// Pause until someone signals this token (or 24h passes).
|
|
37
|
+
const decision = await ctx.waitForSignal<{ approved: boolean }>(
|
|
38
|
+
`approve:${order.id}`,
|
|
39
|
+
{ timeoutMs: 24 * 60 * 60 * 1000 },
|
|
40
|
+
);
|
|
41
|
+
return decision.approved ? 'shipped' : 'rejected';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts title="approval.controller.ts"
|
|
47
|
+
import { WorkflowService } from '@dudousxd/nestjs-durable';
|
|
48
|
+
|
|
49
|
+
@Controller('approvals')
|
|
50
|
+
export class ApprovalController {
|
|
51
|
+
constructor(private readonly workflows: WorkflowService) {}
|
|
52
|
+
|
|
53
|
+
@Post(':orderId')
|
|
54
|
+
approve(@Param('orderId') orderId: string, @Body() body: { approved: boolean }) {
|
|
55
|
+
return this.workflows.signal(`approve:${orderId}`, body); // resumes the suspended run
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Core patterns
|
|
61
|
+
|
|
62
|
+
### Signals (point-to-point) vs events (named pub/sub)
|
|
63
|
+
|
|
64
|
+
- `ctx.waitForSignal<T>(token, { timeoutMs? })` waits on an exact `token`; deliver with
|
|
65
|
+
`workflows.signal(token, payload)`. One token, one waiting run.
|
|
66
|
+
- `ctx.waitForEvent<T>(name, { match?, timeoutMs? })` waits on a named event with optional `match`
|
|
67
|
+
filtering; `workflows.publishEvent(name, payload)` fans out to every run whose `match` the payload
|
|
68
|
+
satisfies (and starts any `@OnEvent` workflows). Returns how many runs it touched.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const order = await ctx.waitForEvent<Order>('order.paid', { match: { orderId: id } });
|
|
72
|
+
// elsewhere: await workflows.publishEvent('order.paid', { orderId: id, amount });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Event-triggered workflow starts
|
|
76
|
+
|
|
77
|
+
`@Workflow({ onEvent })` or `@OnEvent(...)` start a **fresh run** whenever the event is published —
|
|
78
|
+
the payload becomes the run's input. `debounce` / `batch` coalesce bursts.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { Workflow, OnEvent } from '@dudousxd/nestjs-durable';
|
|
82
|
+
|
|
83
|
+
@OnEvent('user.registered', 'user.invited')
|
|
84
|
+
@Workflow({ name: 'welcome', version: '1', debounce: '30s' })
|
|
85
|
+
export class WelcomeWorkflow {
|
|
86
|
+
async run(ctx: WorkflowCtx, user: { id: string }) { /* ... */ }
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Durable timers
|
|
91
|
+
|
|
92
|
+
`ctx.sleep(duration)` and `ctx.sleepUntil(date)` suspend the run durably — a 7-day sleep survives any
|
|
93
|
+
number of restarts; the timer poller resumes it when due. `duration` accepts `ms`-style strings.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
await ctx.step('draft', () => this.draft());
|
|
97
|
+
await ctx.sleep('7 days'); // durable — not setTimeout
|
|
98
|
+
await ctx.step('send', () => this.send());
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Webhooks (third-party callbacks)
|
|
102
|
+
|
|
103
|
+
`ctx.webhook()` mints a stable token + url **before** suspending, so you can hand the url to a third
|
|
104
|
+
party, then `await wh.wait()` for the callback (delivered as `signal(token, body)`). Configure
|
|
105
|
+
`webhookUrl` on the module to populate `wh.url`.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const wh = ctx.webhook<{ status: string }>();
|
|
109
|
+
await ctx.step('register', () => this.provider.callMeBack(wh.url)); // url is ready now
|
|
110
|
+
const result = await wh.wait(); // resumes on the callback
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### signalWithStart — the accumulator pattern
|
|
114
|
+
|
|
115
|
+
`signalWithStart` ensures a run exists, then delivers a signal race-free — one long-lived run per key
|
|
116
|
+
fed by many calls.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
await this.workflows.signalWithStart(
|
|
120
|
+
AggregatorWorkflow,
|
|
121
|
+
{ key },
|
|
122
|
+
`aggregator:${key}`, // stable runId
|
|
123
|
+
{ token: `event:${key}`, payload: event },
|
|
124
|
+
);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Common mistakes
|
|
128
|
+
|
|
129
|
+
### 1. Using setTimeout / a cron instead of ctx.sleep
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// ✗ Wrong — a real timer is lost on restart; the delay is not durable
|
|
133
|
+
async run(ctx, input) {
|
|
134
|
+
await new Promise((r) => setTimeout(r, 7 * 86_400_000)); // gone if the process restarts
|
|
135
|
+
await ctx.step('send', () => this.send());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ✓ Correct — a durable timer the engine persists and resumes when due
|
|
139
|
+
async run(ctx, input) {
|
|
140
|
+
await ctx.sleep('7 days');
|
|
141
|
+
await ctx.step('send', () => this.send());
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`ctx.sleep`/`sleepUntil` record a durable timer checkpoint and suspend; the timer poller resumes the
|
|
146
|
+
run — a raw `setTimeout` dies with the process. Source: packages/core/src/workflow-ctx.ts
|
|
147
|
+
(`sleep`, `sleepUntil`, `suspendUntil`).
|
|
148
|
+
|
|
149
|
+
### 2. Adding `{ timeoutMs }` to a live waitForSignal
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// A run started under this (unbounded wait = 1 logical position):
|
|
153
|
+
await ctx.waitForSignal('approve');
|
|
154
|
+
|
|
155
|
+
// ✗ Wrong — adding a timeout makes it consume TWO positions, shifting every later step's seq
|
|
156
|
+
await ctx.waitForSignal('approve', { timeoutMs: 60_000 }); // in-flight runs corrupt
|
|
157
|
+
|
|
158
|
+
// ✓ Correct — treat the timeout change as a new @Workflow version
|
|
159
|
+
@Workflow({ name: 'approval', version: '2' })
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A bounded wait consumes two logical positions (deadline + wait), an unbounded one consumes one, so
|
|
163
|
+
toggling `timeoutMs` is a positional (versioned) change. Source: packages/core/src/workflow-ctx.ts
|
|
164
|
+
(determinism note above `consumeBuffered`).
|
|
165
|
+
|
|
166
|
+
### 3. Signalling a token that doesn't match the waiting run
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// Workflow waits on a per-order token:
|
|
170
|
+
await ctx.waitForSignal(`approve:${order.id}`);
|
|
171
|
+
|
|
172
|
+
// ✗ Wrong — signalling a different token leaves the run suspended forever
|
|
173
|
+
await this.workflows.signal('approve', body);
|
|
174
|
+
|
|
175
|
+
// ✓ Correct — the signal token must match the waited token exactly
|
|
176
|
+
await this.workflows.signal(`approve:${order.id}`, body);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`signal(token, payload)` resolves only the run waiting on that exact token; a mismatched token is
|
|
180
|
+
buffered and the run never wakes. Source: packages/nestjs/src/workflow.service.ts (`signal`),
|
|
181
|
+
packages/core/src/workflow-ctx.ts (`waitForSignal`).
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-testing
|
|
3
|
+
description: >
|
|
4
|
+
Unit-test @dudousxd/nestjs-durable workflows with @dudousxd/nestjs-durable-testing — createTestEngine
|
|
5
|
+
gives an in-memory engine/store/transport + a controllable clock. Start runs with engine.start +
|
|
6
|
+
waitForRun, advance durable sleeps with tick(ms), inject failures with failOnce/failTimes to drive
|
|
7
|
+
retries, and assert with assertRunStatus, assertOutput, assertStepsRan, assertStepAttempts,
|
|
8
|
+
recordedSteps. No Postgres, no Redis, no real time.
|
|
9
|
+
license: MIT
|
|
10
|
+
metadata:
|
|
11
|
+
type: core
|
|
12
|
+
library: "@dudousxd/nestjs-durable"
|
|
13
|
+
library_version: 0.22.0
|
|
14
|
+
framework: nestjs
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Testing durable workflows
|
|
18
|
+
|
|
19
|
+
`@dudousxd/nestjs-durable-testing` runs a whole workflow in a unit test against an in-memory store and
|
|
20
|
+
transport, with a clock you control and assertions that read the recorded state back. No external
|
|
21
|
+
infrastructure, no real waiting.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add -D @dudousxd/nestjs-durable-testing
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts title="checkout.spec.ts"
|
|
30
|
+
import {
|
|
31
|
+
createTestEngine,
|
|
32
|
+
assertRunStatus,
|
|
33
|
+
assertOutput,
|
|
34
|
+
} from '@dudousxd/nestjs-durable-testing';
|
|
35
|
+
|
|
36
|
+
const t = createTestEngine(); // { engine, store, transport, clock, tick, run }
|
|
37
|
+
|
|
38
|
+
t.engine.register('checkout', '1', async (ctx) => {
|
|
39
|
+
await ctx.step('reserve', () => ({ reserved: true }));
|
|
40
|
+
return ctx.step('ship', () => ({ shipped: true }));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { runId } = await t.engine.start('checkout', { id: 'o1' }, 'run1'); // enqueues → pending
|
|
44
|
+
await t.engine.waitForRun(runId); // settles
|
|
45
|
+
await assertRunStatus(t.store, 'run1', 'completed');
|
|
46
|
+
await assertOutput(t.store, 'run1', { shipped: true });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`createTestEngine()` returns `{ engine, store, transport, clock, tick, run }`. Use the raw
|
|
50
|
+
`engine.register(name, version, body)` to register a workflow body directly (no NestJS DI needed in a
|
|
51
|
+
unit test), or wire a real `@Workflow` provider via a Nest testing module when you want DI.
|
|
52
|
+
|
|
53
|
+
## Core patterns
|
|
54
|
+
|
|
55
|
+
### Control durable time with tick(ms)
|
|
56
|
+
|
|
57
|
+
`tick(ms)` advances the clock and resumes any durable sleep now due — a 7-day sleep is tested
|
|
58
|
+
instantly.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
t.engine.register('digest', '1', async (ctx) => {
|
|
62
|
+
await ctx.step('draft', () => draft());
|
|
63
|
+
await ctx.sleep('7 days');
|
|
64
|
+
await ctx.step('send', () => send());
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const { runId } = await t.engine.start('digest', {}, 'run1');
|
|
68
|
+
await t.engine.waitForRun(runId); // settles on the durable sleep → suspended
|
|
69
|
+
await t.tick(7 * 24 * 60 * 60 * 1000); // the sleep is now due → completes
|
|
70
|
+
await assertRunStatus(t.store, 'run1', 'completed');
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Inject crashes and drive retries
|
|
74
|
+
|
|
75
|
+
`failOnce(value)` / `failTimes(n, value)` make a step throw before finally returning `value` — to
|
|
76
|
+
exercise `retries` and resume.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { failOnce, assertStepAttempts } from '@dudousxd/nestjs-durable-testing';
|
|
80
|
+
|
|
81
|
+
t.engine.register('wf', '1', async (ctx) =>
|
|
82
|
+
ctx.step('charge', failOnce({ ok: true }), { retries: 3 }),
|
|
83
|
+
);
|
|
84
|
+
const { runId } = await t.engine.start('wf', {}, 'run1');
|
|
85
|
+
await t.engine.waitForRun(runId);
|
|
86
|
+
await assertStepAttempts(t.store, 'run1', 'charge', 2); // failed once, then succeeded
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Assertions
|
|
90
|
+
|
|
91
|
+
All assertions read the store, so they work against any run the engine produced:
|
|
92
|
+
|
|
93
|
+
- `assertRunStatus(store, runId, status)` — terminal/suspended status.
|
|
94
|
+
- `assertOutput(store, runId, expected)` — the run's final output.
|
|
95
|
+
- `assertStepsRan(store, runId, names)` — which steps recorded checkpoints.
|
|
96
|
+
- `assertStepAttempts(store, runId, stepName, attempts)` — attempt count for retries.
|
|
97
|
+
- `recordedSteps(store, runId)` — the list of recorded step names.
|
|
98
|
+
|
|
99
|
+
## Common mistakes
|
|
100
|
+
|
|
101
|
+
### 1. Expecting `start` to run the body synchronously in a test
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// ✗ Wrong — start only enqueues; the run is still 'pending' here
|
|
105
|
+
await t.engine.start('checkout', input, 'run1');
|
|
106
|
+
await assertRunStatus(t.store, 'run1', 'completed'); // fails — body hasn't run yet
|
|
107
|
+
|
|
108
|
+
// ✓ Correct — wait for the run to settle (or use t.run(...) which starts + waits)
|
|
109
|
+
const { runId } = await t.engine.start('checkout', input, 'run1');
|
|
110
|
+
await t.engine.waitForRun(runId);
|
|
111
|
+
await assertRunStatus(t.store, 'run1', 'completed');
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`start` enqueues and returns `{ status: 'pending' }`; the body runs asynchronously, so pair it with
|
|
115
|
+
`waitForRun` (or the harness's `run`). Source: packages/testing/src/harness.ts (`createTestEngine`).
|
|
116
|
+
|
|
117
|
+
### 2. Using real time instead of tick() for a durable sleep
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// ✗ Wrong — the durable sleep is suspended; real time never advances the engine's clock
|
|
121
|
+
await t.engine.start('digest', {}, 'run1');
|
|
122
|
+
await new Promise((r) => setTimeout(r, 1000)); // run stays suspended forever
|
|
123
|
+
|
|
124
|
+
// ✓ Correct — advance the controllable clock so the sleep becomes due
|
|
125
|
+
await t.engine.start('digest', {}, 'run1');
|
|
126
|
+
await t.engine.waitForRun('run1'); // suspends on the sleep
|
|
127
|
+
await t.tick(7 * 24 * 60 * 60 * 1000);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The test engine uses a `MutableClock`; only `tick(ms)` advances it and resumes due timers.
|
|
131
|
+
Source: packages/testing/src/harness.ts (`tick`, `MutableClock`).
|
|
132
|
+
|
|
133
|
+
### 3. Reusing the same runId across cases expecting a fresh run
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// ✗ Wrong — start is idempotent by runId; reusing 'run1' returns the existing run, not a new one
|
|
137
|
+
await t.engine.start('wf', a, 'run1');
|
|
138
|
+
await t.engine.start('wf', b, 'run1'); // same run — input b is ignored
|
|
139
|
+
|
|
140
|
+
// ✓ Correct — give each logical run a distinct id (or omit it for a random one)
|
|
141
|
+
await t.engine.start('wf', a, 'run1');
|
|
142
|
+
await t.engine.start('wf', b, 'run2');
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A `runId` makes `start` idempotent (a redelivery returns the existing run), so distinct cases need
|
|
146
|
+
distinct ids. Source: packages/nestjs/src/workflow.service.ts (`start` idempotency by `runId`).
|