@dudousxd/nestjs-durable 0.26.0 → 0.27.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 +988 -307
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +471 -217
- package/dist/index.d.ts +471 -217
- package/dist/index.js +951 -277
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/skills/durable-determinism/SKILL.md +45 -31
- package/skills/durable-setup/SKILL.md +23 -15
- package/skills/durable-signals-and-timers/SKILL.md +25 -9
- package/skills/durable-testing/SKILL.md +85 -28
- package/skills/durable-workflows/SKILL.md +138 -83
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-durable",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Durable workflows for NestJS — module, decorators, discovery and boot recovery",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Davide Carvalho",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"@nestjs/core": "^10.0.0 || ^11.0.0",
|
|
25
25
|
"class-transformer": "^0.5.0",
|
|
26
26
|
"class-validator": "^0.14.0",
|
|
27
|
-
"reflect-metadata": "^0.2.0"
|
|
27
|
+
"reflect-metadata": "^0.2.0",
|
|
28
|
+
"zod": "^3.0.0 || ^4.0.0"
|
|
28
29
|
},
|
|
29
30
|
"peerDependenciesMeta": {
|
|
30
31
|
"@dudousxd/durable-worker": {
|
|
@@ -38,9 +39,13 @@
|
|
|
38
39
|
},
|
|
39
40
|
"class-validator": {
|
|
40
41
|
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"zod": {
|
|
44
|
+
"optional": true
|
|
41
45
|
}
|
|
42
46
|
},
|
|
43
47
|
"devDependencies": {
|
|
48
|
+
"@testcontainers/redis": "10.18.0",
|
|
44
49
|
"@nestjs/common": "11.1.26",
|
|
45
50
|
"@nestjs/core": "11.1.26",
|
|
46
51
|
"@nestjs/event-emitter": "3.1.0",
|
|
@@ -55,8 +60,10 @@
|
|
|
55
60
|
"tsup": "8.3.5",
|
|
56
61
|
"@dudousxd/nestjs-diagnostics": "^0.5.0",
|
|
57
62
|
"@tanstack/intent": "^0.3.2",
|
|
58
|
-
"@dudousxd/durable-worker": "0.
|
|
59
|
-
"@dudousxd/nestjs-durable-core": "^0.
|
|
63
|
+
"@dudousxd/durable-worker": "0.6.0",
|
|
64
|
+
"@dudousxd/nestjs-durable-core": "^0.46.0",
|
|
65
|
+
"@dudousxd/nestjs-durable-dashboard": "0.25.0",
|
|
66
|
+
"@dudousxd/nestjs-durable-transport-bullmq": "0.12.0",
|
|
60
67
|
"@dudousxd/nestjs-durable-transport-event-emitter": "^0.5.0"
|
|
61
68
|
},
|
|
62
69
|
"module": "./dist/index.js",
|
|
@@ -3,9 +3,10 @@ name: durable-determinism
|
|
|
3
3
|
description: >
|
|
4
4
|
The one correctness rule for @dudousxd/nestjs-durable workflows — the run(ctx, input) body re-runs
|
|
5
5
|
top-to-bottom on recovery, so it must be deterministic. Keep Date.now/Math.random/IO out of the
|
|
6
|
-
body and inside
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
body and inside dispatched ctx.step calls; use ctx.now() for a checkpointed timestamp and
|
|
7
|
+
ctx.sideEffect(fn) for any other non-deterministic capture (an id, a random draw). Covers
|
|
8
|
+
positional replay, NonDeterminismError, @Workflow version pinning + side-by-side registration for
|
|
9
|
+
breaking changes, exactly-once vs physical retry idempotency, and self-healing recovery.
|
|
9
10
|
license: MIT
|
|
10
11
|
metadata:
|
|
11
12
|
type: core
|
|
@@ -19,45 +20,55 @@ metadata:
|
|
|
19
20
|
Durability comes from **checkpoint + deterministic replay**. On recovery the engine re-runs the
|
|
20
21
|
workflow body from the top; a step that already has a `completed` checkpoint returns its saved result
|
|
21
22
|
instead of executing again. For that to be safe, **the body must be deterministic** — all
|
|
22
|
-
non-determinism (clock, randomness, network, IO) lives inside
|
|
23
|
+
non-determinism (clock, randomness, network, IO) lives inside a dispatched `ctx.step`, or one of the
|
|
24
|
+
two checkpointed capture helpers.
|
|
23
25
|
|
|
24
26
|
## Setup
|
|
25
27
|
|
|
26
|
-
There is nothing to install for this rule — it governs how you write `run(ctx, input)`. The
|
|
27
|
-
helpers are checkpointed wrappers on `ctx`:
|
|
28
|
+
There is nothing to install for this rule — it governs how you write `run(ctx, input)`. The two
|
|
29
|
+
capture helpers are checkpointed wrappers on `ctx`:
|
|
28
30
|
|
|
29
31
|
```ts
|
|
30
32
|
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
|
|
31
33
|
|
|
32
34
|
async run(ctx: WorkflowCtx, input: unknown) {
|
|
33
|
-
const at = await ctx.now();
|
|
34
|
-
const
|
|
35
|
-
const id = await ctx.uuid(); // checkpointed crypto.randomUUID()
|
|
35
|
+
const at = await ctx.now(); // checkpointed clock — same value on every replay
|
|
36
|
+
const id = await ctx.sideEffect(() => crypto.randomUUID()); // checkpointed capture — author picks the generator
|
|
36
37
|
// ...use these instead of the raw globals...
|
|
37
38
|
}
|
|
38
39
|
```
|
|
39
40
|
|
|
40
41
|
## Core patterns
|
|
41
42
|
|
|
42
|
-
### Side effects live in
|
|
43
|
+
### Side effects live in a dispatched step; the body is pure orchestration
|
|
43
44
|
|
|
44
45
|
```ts
|
|
45
|
-
// ✓ The body only calls
|
|
46
|
+
// ✓ The body only calls ctx.step and branches on results — real IO is inside a @Step handler.
|
|
46
47
|
async run(ctx: WorkflowCtx, order: Order) {
|
|
47
|
-
const quote = await ctx.step(
|
|
48
|
-
if (quote.total > 1000) await ctx.step(
|
|
49
|
-
await ctx.step(
|
|
48
|
+
const quote = await ctx.step(this.pricing.fetch, order); // dispatched, checkpointed
|
|
49
|
+
if (quote.total > 1000) await ctx.step(this.review.flag, order);
|
|
50
|
+
await ctx.step(this.billing.charge, quote);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
@Injectable()
|
|
56
|
+
export class PricingService {
|
|
57
|
+
@Step()
|
|
58
|
+
async fetch(order: Order) { /* real IO here */ }
|
|
50
59
|
}
|
|
51
60
|
```
|
|
52
61
|
|
|
53
62
|
The engine guarantees each step runs **exactly once logically**, even across crashes and deploys,
|
|
54
63
|
because re-running the body short-circuits completed checkpoints.
|
|
55
64
|
|
|
56
|
-
### Use ctx.now / ctx.
|
|
65
|
+
### Use ctx.now() / ctx.sideEffect(fn) for non-deterministic values
|
|
57
66
|
|
|
58
67
|
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.
|
|
60
|
-
|
|
68
|
+
shifts later decisions, corrupting the run. `ctx.now()` captures the clock as a checkpoint on first
|
|
69
|
+
run and replays it verbatim; `ctx.sideEffect(fn)` does the same for anything else — `fn` runs ONCE
|
|
70
|
+
and its result is replayed thereafter (you pick the generator: `() => uuidv7()`, `() => ulid()`,
|
|
71
|
+
`() => Math.random()`, a config/env read).
|
|
61
72
|
|
|
62
73
|
### Version-pinned replay for breaking changes
|
|
63
74
|
|
|
@@ -88,19 +99,21 @@ renews its lease while running, so a long step is not reclaimed out from under i
|
|
|
88
99
|
```ts
|
|
89
100
|
// ✗ Wrong — different value on every replay; downstream branches diverge
|
|
90
101
|
async run(ctx, order) {
|
|
91
|
-
if (Math.random() < 0.1) await ctx.step(
|
|
92
|
-
const ts = Date.now();
|
|
102
|
+
if (Math.random() < 0.1) await ctx.step(this.sampler.sample, order); // replays differently
|
|
103
|
+
const ts = Date.now(); // changes each recovery
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
// ✓ Correct — checkpointed sources are stable across replays
|
|
96
107
|
async run(ctx, order) {
|
|
97
|
-
if ((await ctx.random()) < 0.1)
|
|
108
|
+
if ((await ctx.sideEffect(() => Math.random())) < 0.1) {
|
|
109
|
+
await ctx.step(this.sampler.sample, order);
|
|
110
|
+
}
|
|
98
111
|
const ts = await ctx.now();
|
|
99
112
|
}
|
|
100
113
|
```
|
|
101
114
|
|
|
102
115
|
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`, `
|
|
116
|
+
the replay. Source: packages/core/src/workflow-ctx.ts (`now`, `sideEffect`).
|
|
104
117
|
|
|
105
118
|
### 2. Editing a live workflow body without bumping `version`
|
|
106
119
|
|
|
@@ -109,8 +122,8 @@ the replay. Source: packages/core/src/workflow-ctx.ts (`now`, `random`, `uuid`).
|
|
|
109
122
|
@Workflow({ name: 'checkout', version: '1' })
|
|
110
123
|
class CheckoutWorkflow {
|
|
111
124
|
async run(ctx, o) {
|
|
112
|
-
await ctx.step(
|
|
113
|
-
await ctx.step(
|
|
125
|
+
await ctx.step(this.audit.log, o); // NEW step inserted before existing ones
|
|
126
|
+
await ctx.step(this.billing.charge, o);
|
|
114
127
|
}
|
|
115
128
|
}
|
|
116
129
|
|
|
@@ -123,22 +136,23 @@ Replay matches checkpoints by position; inserting/reordering steps under the sam
|
|
|
123
136
|
`NonDeterminismError` for in-flight runs. Source: website/content/docs/concepts/durability.mdx
|
|
124
137
|
("Version-pinned replay"), packages/core/src/workflow-ctx.ts (`NonDeterminismError`).
|
|
125
138
|
|
|
126
|
-
### 3. Assuming a
|
|
139
|
+
### 3. Assuming a dispatched step physically runs exactly once
|
|
127
140
|
|
|
128
141
|
```ts
|
|
129
142
|
// ✗ Wrong — handler assumes it can never be called twice for the same step
|
|
130
|
-
@
|
|
131
|
-
async charge(input) {
|
|
143
|
+
@Step('payments.charge-card')
|
|
144
|
+
async charge(input: { orderId: string; amountCents: number }) {
|
|
132
145
|
return { chargeId: await this.stripe.charge(input) }; // double-charge if checkpoint write is lost
|
|
133
146
|
}
|
|
134
147
|
|
|
135
|
-
// ✓ Correct —
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
// ✓ Correct — derive a stable idempotency key from the input itself (a @Step handler has no
|
|
149
|
+
// separate stepId parameter — it only receives `(input, log)`)
|
|
150
|
+
@Step('payments.charge-card')
|
|
151
|
+
async charge(input: { orderId: string; amountCents: number }) {
|
|
152
|
+
return { chargeId: await this.stripe.charge(input, { idempotencyKey: input.orderId }) };
|
|
139
153
|
}
|
|
140
154
|
```
|
|
141
155
|
|
|
142
156
|
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
|
|
157
|
+
checkpoint was written can physically re-run the step — dedupe on something stable in the input.
|
|
144
158
|
Source: website/content/docs/concepts/durability.mdx ("Idempotency").
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
name: durable-setup
|
|
3
3
|
description: >
|
|
4
4
|
Set up @dudousxd/nestjs-durable in a NestJS app — DurableModule.forRootAsync with a StateStore
|
|
5
|
-
+ Transport, register @Workflow / @
|
|
5
|
+
+ Transport, register @Workflow / @Step providers, and start runs with WorkflowService.
|
|
6
6
|
Covers the zero-infra EventEmitterTransport + InMemoryStateStore default, start() enqueues vs
|
|
7
|
-
waitForRun() settles, autoSchema,
|
|
8
|
-
forRoot vs forRootAsync, app.enableShutdownHooks for
|
|
7
|
+
waitForRun() settles, autoSchema, drive:false dashboard/API-only replicas, DurableModule as a
|
|
8
|
+
thin worker (connection only, no store), forRoot vs forRootAsync, app.enableShutdownHooks for
|
|
9
|
+
graceful drain.
|
|
9
10
|
license: MIT
|
|
10
11
|
metadata:
|
|
11
12
|
type: core
|
|
@@ -22,7 +23,8 @@ infrastructure (in-process transport, in-memory store) — swap the store/transp
|
|
|
22
23
|
|
|
23
24
|
## Setup
|
|
24
25
|
|
|
25
|
-
Install the module plus its core peer, a transport, and zod (
|
|
26
|
+
Install the module plus its core peer, a transport, and zod (optional `@Step({ input, output })`
|
|
27
|
+
runtime schemas use it):
|
|
26
28
|
|
|
27
29
|
```bash
|
|
28
30
|
pnpm add @dudousxd/nestjs-durable @dudousxd/nestjs-durable-core \
|
|
@@ -57,7 +59,7 @@ import { PaymentsWorker } from './payments.worker';
|
|
|
57
59
|
export class AppModule {}
|
|
58
60
|
```
|
|
59
61
|
|
|
60
|
-
`@Workflow` and `@
|
|
62
|
+
`@Workflow` and `@Step` providers are plain providers — list them in `providers` (or any
|
|
61
63
|
imported module) and `DurableModule`'s discovery registers them on the engine at boot.
|
|
62
64
|
|
|
63
65
|
Enable shutdown hooks so the engine drains in-flight runs on deploy:
|
|
@@ -116,29 +118,35 @@ DurableModule.forRootAsync({
|
|
|
116
118
|
|
|
117
119
|
### Split API pods from worker pods
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
`DurableModule.forRoot` is the ONE module; the role is inferred from which of `store`/`connection`
|
|
122
|
+
you pass — nothing else to configure. For scale, run dispatch-only pods and driving pods that share
|
|
123
|
+
only the database. An API pod's `start` only enqueues; driving pods poll and run the body.
|
|
121
124
|
|
|
122
125
|
```ts
|
|
123
|
-
// API / dashboard pod: enqueue-only, never
|
|
124
|
-
DurableModule.forRoot({ store, transport,
|
|
126
|
+
// API / dashboard pod: enqueue-only, mounts the store but never processes or recovers workflows
|
|
127
|
+
DurableModule.forRoot({ store, transport, drive: false });
|
|
125
128
|
|
|
126
|
-
//
|
|
127
|
-
DurableModule.forRoot({ store, transport /*
|
|
129
|
+
// Driving pod (default): runs the body, recovers orphaned runs, polls timers
|
|
130
|
+
DurableModule.forRoot({ store, transport /* drive: true is the default */ });
|
|
128
131
|
```
|
|
129
132
|
|
|
130
|
-
A store-less **thin worker** (
|
|
133
|
+
A store-less **thin worker** (no engine/store — just registered handlers over a connection) passes
|
|
134
|
+
`connection` with no `store`:
|
|
131
135
|
|
|
132
136
|
```ts
|
|
133
|
-
import {
|
|
137
|
+
import { DurableModule } from '@dudousxd/nestjs-durable';
|
|
134
138
|
|
|
135
139
|
@Module({
|
|
136
|
-
imports: [
|
|
137
|
-
providers: [PaymentsWorker], // @
|
|
140
|
+
imports: [DurableModule.forRoot({ connection: 'redis://localhost:6379', partition: 'payments' })],
|
|
141
|
+
providers: [PaymentsWorker], // @Step handlers this worker serves
|
|
138
142
|
})
|
|
139
143
|
export class WorkerAppModule {}
|
|
140
144
|
```
|
|
141
145
|
|
|
146
|
+
`partition` is an optional isolation suffix on this worker's queues — omit it for a single-tenant
|
|
147
|
+
deployment. An operator with no local body for a workflow dispatches to a live worker of the same
|
|
148
|
+
name automatically; there's no `groups`/`remoteByConvention` flag to opt into.
|
|
149
|
+
|
|
142
150
|
## Common mistakes
|
|
143
151
|
|
|
144
152
|
### 1. Expecting `start` to return the workflow's result
|
|
@@ -28,11 +28,14 @@ via `WorkflowService`:
|
|
|
28
28
|
```ts title="approval.workflow.ts"
|
|
29
29
|
import { Workflow } from '@dudousxd/nestjs-durable';
|
|
30
30
|
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
|
|
31
|
+
import { NotificationsService } from './notifications.service';
|
|
31
32
|
|
|
32
33
|
@Workflow({ name: 'approval', version: '1' })
|
|
33
34
|
export class ApprovalWorkflow {
|
|
35
|
+
constructor(private readonly notifications: NotificationsService) {}
|
|
36
|
+
|
|
34
37
|
async run(ctx: WorkflowCtx, order: { id: string }) {
|
|
35
|
-
await ctx.step(
|
|
38
|
+
await ctx.step(this.notifications.requestApproval, order.id);
|
|
36
39
|
// Pause until someone signals this token (or 24h passes).
|
|
37
40
|
const decision = await ctx.waitForSignal<{ approved: boolean }>(
|
|
38
41
|
`approve:${order.id}`,
|
|
@@ -43,6 +46,19 @@ export class ApprovalWorkflow {
|
|
|
43
46
|
}
|
|
44
47
|
```
|
|
45
48
|
|
|
49
|
+
```ts title="notifications.service.ts"
|
|
50
|
+
import { Step } from '@dudousxd/nestjs-durable';
|
|
51
|
+
import { Injectable } from '@nestjs/common';
|
|
52
|
+
|
|
53
|
+
@Injectable()
|
|
54
|
+
export class NotificationsService {
|
|
55
|
+
@Step()
|
|
56
|
+
async requestApproval(orderId: string) {
|
|
57
|
+
// email/Slack the approver
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
46
62
|
```ts title="approval.controller.ts"
|
|
47
63
|
import { WorkflowService } from '@dudousxd/nestjs-durable';
|
|
48
64
|
|
|
@@ -93,21 +109,21 @@ export class WelcomeWorkflow {
|
|
|
93
109
|
number of restarts; the timer poller resumes it when due. `duration` accepts `ms`-style strings.
|
|
94
110
|
|
|
95
111
|
```ts
|
|
96
|
-
await ctx.step(
|
|
112
|
+
await ctx.step(this.reports.draft, order.id);
|
|
97
113
|
await ctx.sleep('7 days'); // durable — not setTimeout
|
|
98
|
-
await ctx.step(
|
|
114
|
+
await ctx.step(this.reports.send, order.id);
|
|
99
115
|
```
|
|
100
116
|
|
|
101
117
|
### Webhooks (third-party callbacks)
|
|
102
118
|
|
|
103
119
|
`ctx.webhook()` mints a stable token + url **before** suspending, so you can hand the url to a third
|
|
104
|
-
party
|
|
105
|
-
`webhookUrl` on the module to populate `wh.url`.
|
|
120
|
+
party — inside a dispatched `ctx.step` — then `await wh.wait()` for the callback (delivered as
|
|
121
|
+
`signal(token, body)`). Configure `webhookUrl` on the module to populate `wh.url`.
|
|
106
122
|
|
|
107
123
|
```ts
|
|
108
124
|
const wh = ctx.webhook<{ status: string }>();
|
|
109
|
-
await ctx.step(
|
|
110
|
-
const result = await wh.wait();
|
|
125
|
+
await ctx.step(this.provider.register, { callbackUrl: wh.url }); // url is ready now
|
|
126
|
+
const result = await wh.wait(); // resumes on the callback
|
|
111
127
|
```
|
|
112
128
|
|
|
113
129
|
### signalWithStart — the accumulator pattern
|
|
@@ -132,13 +148,13 @@ await this.workflows.signalWithStart(
|
|
|
132
148
|
// ✗ Wrong — a real timer is lost on restart; the delay is not durable
|
|
133
149
|
async run(ctx, input) {
|
|
134
150
|
await new Promise((r) => setTimeout(r, 7 * 86_400_000)); // gone if the process restarts
|
|
135
|
-
await ctx.step(
|
|
151
|
+
await ctx.step(this.reports.send, input);
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
// ✓ Correct — a durable timer the engine persists and resumes when due
|
|
139
155
|
async run(ctx, input) {
|
|
140
156
|
await ctx.sleep('7 days');
|
|
141
|
-
await ctx.step(
|
|
157
|
+
await ctx.step(this.reports.send, input);
|
|
142
158
|
}
|
|
143
159
|
```
|
|
144
160
|
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
name: durable-testing
|
|
3
3
|
description: >
|
|
4
4
|
Unit-test @dudousxd/nestjs-durable workflows with @dudousxd/nestjs-durable-testing — createTestEngine
|
|
5
|
-
gives an in-memory engine/store/transport + a controllable clock.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
gives an in-memory engine/store/transport + a controllable clock. Register a workflow body with
|
|
6
|
+
engine.register, serve its dispatched ctx.step calls with transport.handle, start runs with
|
|
7
|
+
engine.start + waitForRun, advance durable sleeps with tick(ms), inject failures with
|
|
8
|
+
failOnce/failTimes to drive retries, and assert with assertRunStatus, assertOutput, assertStepsRan,
|
|
9
|
+
assertStepAttempts, recordedSteps. No Postgres, no Redis, no real time.
|
|
9
10
|
license: MIT
|
|
10
11
|
metadata:
|
|
11
12
|
type: core
|
|
@@ -20,6 +21,9 @@ metadata:
|
|
|
20
21
|
transport, with a clock you control and assertions that read the recorded state back. No external
|
|
21
22
|
infrastructure, no real waiting.
|
|
22
23
|
|
|
24
|
+
Every `ctx.step` is dispatched — even in a test — so a step needs a fake worker handler registered on
|
|
25
|
+
the test transport before the run reaches it.
|
|
26
|
+
|
|
23
27
|
## Setup
|
|
24
28
|
|
|
25
29
|
```bash
|
|
@@ -35,54 +39,90 @@ import {
|
|
|
35
39
|
|
|
36
40
|
const t = createTestEngine(); // { engine, store, transport, clock, tick, run }
|
|
37
41
|
|
|
42
|
+
t.transport.handle('reserve', async () => ({ reserved: true })); // fake worker for the dispatched step
|
|
43
|
+
t.transport.handle('ship', async () => ({ shipped: true }));
|
|
44
|
+
|
|
38
45
|
t.engine.register('checkout', '1', async (ctx) => {
|
|
39
|
-
await ctx.step('reserve',
|
|
40
|
-
return ctx.step('ship',
|
|
46
|
+
await ctx.step('reserve', { id: 'o1' });
|
|
47
|
+
return ctx.step('ship', { id: 'o1' });
|
|
41
48
|
});
|
|
42
49
|
|
|
43
50
|
const { runId } = await t.engine.start('checkout', { id: 'o1' }, 'run1'); // enqueues → pending
|
|
44
|
-
await t
|
|
51
|
+
const result = await settle(t, runId); // see "Settling a run" below
|
|
45
52
|
await assertRunStatus(t.store, 'run1', 'completed');
|
|
46
53
|
await assertOutput(t.store, 'run1', { shipped: true });
|
|
47
54
|
```
|
|
48
55
|
|
|
49
56
|
`createTestEngine()` returns `{ engine, store, transport, clock, tick, run }`. Use the raw
|
|
50
57
|
`engine.register(name, version, body)` to register a workflow body directly (no NestJS DI needed in a
|
|
51
|
-
unit test)
|
|
58
|
+
unit test) and `transport.handle(name, (input, log) => output)` to serve each `ctx.step` it dispatches
|
|
59
|
+
by name — or wire real `@Workflow`/`@Step` providers via a Nest testing module when you want DI.
|
|
52
60
|
|
|
53
61
|
## Core patterns
|
|
54
62
|
|
|
63
|
+
### Settling a run — a dispatched ctx.step suspends between hops
|
|
64
|
+
|
|
65
|
+
Because every step is dispatched (even against the in-memory transport), a run legitimately goes
|
|
66
|
+
`suspended` after EACH step and resumes asynchronously once its fake handler resolves — so a single
|
|
67
|
+
`waitForRun` call after `start` often returns mid-chain, not at the run's actual resting point. Poll
|
|
68
|
+
until the status stops changing:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import type { RunResult } from '@dudousxd/nestjs-durable-core';
|
|
72
|
+
import type { TestEngine } from '@dudousxd/nestjs-durable-testing';
|
|
73
|
+
|
|
74
|
+
async function settle(t: TestEngine, runId: string, max = 20): Promise<RunResult> {
|
|
75
|
+
let last: RunResult = { runId, status: 'pending' } as RunResult;
|
|
76
|
+
for (let i = 0; i < max; i += 1) {
|
|
77
|
+
await new Promise((r) => setImmediate(r)); // let a pending transport.handle result land
|
|
78
|
+
last = await t.engine.waitForRun(runId);
|
|
79
|
+
if (last.status !== 'suspended') return last;
|
|
80
|
+
}
|
|
81
|
+
return last; // still suspended after `max` hops — a real wait (sleep/signal), not more dispatch
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A workflow whose OWN resting point is `suspended` (a durable sleep, `waitForSignal`) returns that
|
|
86
|
+
status from `settle` too — assert on it directly, same as the non-dispatched case.
|
|
87
|
+
|
|
55
88
|
### Control durable time with tick(ms)
|
|
56
89
|
|
|
57
90
|
`tick(ms)` advances the clock and resumes any durable sleep now due — a 7-day sleep is tested
|
|
58
91
|
instantly.
|
|
59
92
|
|
|
60
93
|
```ts
|
|
94
|
+
t.transport.handle('draft', async () => undefined);
|
|
95
|
+
t.transport.handle('send', async () => undefined);
|
|
61
96
|
t.engine.register('digest', '1', async (ctx) => {
|
|
62
|
-
await ctx.step('draft',
|
|
97
|
+
await ctx.step('draft', {});
|
|
63
98
|
await ctx.sleep('7 days');
|
|
64
|
-
await ctx.step('send',
|
|
99
|
+
await ctx.step('send', {});
|
|
65
100
|
});
|
|
66
101
|
|
|
67
102
|
const { runId } = await t.engine.start('digest', {}, 'run1');
|
|
68
|
-
await t
|
|
69
|
-
await t.
|
|
103
|
+
await settle(t, runId); // rests on the durable sleep → suspended
|
|
104
|
+
await assertRunStatus(t.store, 'run1', 'suspended');
|
|
105
|
+
await t.tick(7 * 24 * 60 * 60 * 1000); // the sleep is now due → resumes
|
|
106
|
+
await settle(t, runId);
|
|
70
107
|
await assertRunStatus(t.store, 'run1', 'completed');
|
|
71
108
|
```
|
|
72
109
|
|
|
73
110
|
### Inject crashes and drive retries
|
|
74
111
|
|
|
75
|
-
`failOnce(value)` / `failTimes(n, value)`
|
|
76
|
-
exercise `retries` and resume.
|
|
112
|
+
`failOnce(value)` / `failTimes(n, value)` build a fake handler that throws before finally returning
|
|
113
|
+
`value` — hand it straight to `transport.handle` to exercise `retries` and resume. A zero/omitted
|
|
114
|
+
`backoffMs` still needs a timer sweep to notice the retry is due, so `tick(0)` after the failure.
|
|
77
115
|
|
|
78
116
|
```ts
|
|
79
117
|
import { failOnce, assertStepAttempts } from '@dudousxd/nestjs-durable-testing';
|
|
80
118
|
|
|
81
|
-
t.
|
|
82
|
-
|
|
83
|
-
|
|
119
|
+
t.transport.handle('charge', failOnce({ ok: true }));
|
|
120
|
+
t.engine.register('wf', '1', async (ctx) => ctx.step('charge', {}, { retries: 3 }));
|
|
121
|
+
|
|
84
122
|
const { runId } = await t.engine.start('wf', {}, 'run1');
|
|
85
|
-
await t
|
|
123
|
+
await settle(t, runId);
|
|
124
|
+
await t.tick(0); // due retries (even a zero backoff) resume on the next timer sweep
|
|
125
|
+
await settle(t, runId);
|
|
86
126
|
await assertStepAttempts(t.store, 'run1', 'charge', 2); // failed once, then succeeded
|
|
87
127
|
```
|
|
88
128
|
|
|
@@ -98,23 +138,40 @@ All assertions read the store, so they work against any run the engine produced:
|
|
|
98
138
|
|
|
99
139
|
## Common mistakes
|
|
100
140
|
|
|
101
|
-
### 1.
|
|
141
|
+
### 1. Forgetting to register a transport.handle for a dispatched step
|
|
102
142
|
|
|
103
143
|
```ts
|
|
104
|
-
// ✗ Wrong —
|
|
105
|
-
|
|
106
|
-
await
|
|
144
|
+
// ✗ Wrong — no fake worker for 'reserve'; the step fails with "no handler for reserve"
|
|
145
|
+
t.engine.register('checkout', '1', async (ctx) => ctx.step('reserve', {}));
|
|
146
|
+
await t.engine.start('checkout', {}, 'run1');
|
|
147
|
+
|
|
148
|
+
// ✓ Correct — every ctx.step name the body dispatches needs a matching transport.handle
|
|
149
|
+
t.transport.handle('reserve', async () => ({ reserved: true }));
|
|
150
|
+
t.engine.register('checkout', '1', async (ctx) => ctx.step('reserve', {}));
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`ctx.step` is always dispatched — even in a test against the in-memory transport — so there is no
|
|
154
|
+
"local" step that just runs inline. Source: packages/core/src/testing/in-memory-transport.ts (`handle`).
|
|
107
155
|
|
|
108
|
-
|
|
156
|
+
### 2. Expecting one waitForRun to reach a multi-step run's terminal state
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// ✗ Wrong — resolves on the FIRST dispatched step's pending suspend, not the run's end state
|
|
109
160
|
const { runId } = await t.engine.start('checkout', input, 'run1');
|
|
110
161
|
await t.engine.waitForRun(runId);
|
|
162
|
+
await assertRunStatus(t.store, 'run1', 'completed'); // often fails — still 'suspended' mid-chain
|
|
163
|
+
|
|
164
|
+
// ✓ Correct — poll until the status stops changing (see "Settling a run" above)
|
|
165
|
+
const { runId } = await t.engine.start('checkout', input, 'run1');
|
|
166
|
+
await settle(t, runId);
|
|
111
167
|
await assertRunStatus(t.store, 'run1', 'completed');
|
|
112
168
|
```
|
|
113
169
|
|
|
114
|
-
`
|
|
115
|
-
|
|
170
|
+
`waitForRun` resolves on the FIRST settled event, and a dispatch-pending suspend counts as settled —
|
|
171
|
+
a run with several dispatched steps in sequence settles once per step. Source:
|
|
172
|
+
packages/core/src/engine.ts (`waitForRun`).
|
|
116
173
|
|
|
117
|
-
###
|
|
174
|
+
### 3. Using real time instead of tick() for a durable sleep
|
|
118
175
|
|
|
119
176
|
```ts
|
|
120
177
|
// ✗ Wrong — the durable sleep is suspended; real time never advances the engine's clock
|
|
@@ -123,14 +180,14 @@ await new Promise((r) => setTimeout(r, 1000)); // run stays suspended forever
|
|
|
123
180
|
|
|
124
181
|
// ✓ Correct — advance the controllable clock so the sleep becomes due
|
|
125
182
|
await t.engine.start('digest', {}, 'run1');
|
|
126
|
-
await t
|
|
183
|
+
await settle(t, 'run1'); // suspends on the sleep
|
|
127
184
|
await t.tick(7 * 24 * 60 * 60 * 1000);
|
|
128
185
|
```
|
|
129
186
|
|
|
130
187
|
The test engine uses a `MutableClock`; only `tick(ms)` advances it and resumes due timers.
|
|
131
188
|
Source: packages/testing/src/harness.ts (`tick`, `MutableClock`).
|
|
132
189
|
|
|
133
|
-
###
|
|
190
|
+
### 4. Reusing the same runId across cases expecting a fresh run
|
|
134
191
|
|
|
135
192
|
```ts
|
|
136
193
|
// ✗ Wrong — start is idempotent by runId; reusing 'run1' returns the existing run, not a new one
|