@dudousxd/nestjs-durable 0.26.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-durable",
3
- "version": "0.26.0",
3
+ "version": "0.28.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.5.0",
59
- "@dudousxd/nestjs-durable-core": "^0.43.0",
63
+ "@dudousxd/durable-worker": "0.7.0",
64
+ "@dudousxd/nestjs-durable-core": "^0.47.0",
65
+ "@dudousxd/nestjs-durable-dashboard": "0.27.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 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.
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 steps.
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 key
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(); // 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()
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 steps; the body is pure orchestration
43
+ ### Side effects live in a dispatched step; the body is pure orchestration
43
44
 
44
45
  ```ts
45
- // ✓ The body only calls steps and branches on their results.
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('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));
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.random / ctx.uuid for non-deterministic values
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. The `ctx` wrappers capture the value as a checkpoint on
60
- first run and replay it verbatim.
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('sample', () => sample()); // replays differently
92
- const ts = Date.now(); // changes each recovery
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) await ctx.step('sample', () => sample());
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`, `random`, `uuid`).
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('audit', () => audit(o)); // NEW step inserted before existing ones
113
- await ctx.step('charge', () => charge(o));
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 remote step physically runs exactly once
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
- @DurableStep('payments.charge-card')
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 — 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 }) };
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 the stable `stepId`.
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 / @DurableStep providers, and start runs with WorkflowService.
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, worker:false API/worker split, DurableWorkerModule thin worker,
8
- forRoot vs forRootAsync, app.enableShutdownHooks for graceful drain.
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 (remote-step contracts use it):
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 `@DurableStep` providers are plain providers — list them in `providers` (or any
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
- 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
+ `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 executes or recovers workflows
124
- DurableModule.forRoot({ store, transport, worker: false });
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
- // Worker pod (default): runs the body, recovers orphaned runs, polls timers
127
- DurableModule.forRoot({ store, transport /* worker: true is the default */ });
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** (BullMQ consumer only, no engine/store) uses `DurableWorkerModule`:
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 { DurableWorkerModule } from '@dudousxd/nestjs-durable';
137
+ import { DurableModule } from '@dudousxd/nestjs-durable';
134
138
 
135
139
  @Module({
136
- imports: [DurableWorkerModule.forRoot({ connection: 'redis://localhost:6379', groups: ['payments'] })],
137
- providers: [PaymentsWorker], // @DurableStep handlers for the 'payments' group
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('request', () => this.notify(order.id));
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('draft', () => this.draft());
112
+ await ctx.step(this.reports.draft, order.id);
97
113
  await ctx.sleep('7 days'); // durable — not setTimeout
98
- await ctx.step('send', () => this.send());
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, then `await wh.wait()` for the callback (delivered as `signal(token, body)`). Configure
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('register', () => this.provider.callMeBack(wh.url)); // url is ready now
110
- const result = await wh.wait(); // resumes on the callback
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('send', () => this.send());
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('send', () => this.send());
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. 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.
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', () => ({ reserved: true }));
40
- return ctx.step('ship', () => ({ shipped: true }));
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.engine.waitForRun(runId); // settles
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), or wire a real `@Workflow` provider via a Nest testing module when you want DI.
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', () => draft());
97
+ await ctx.step('draft', {});
63
98
  await ctx.sleep('7 days');
64
- await ctx.step('send', () => send());
99
+ await ctx.step('send', {});
65
100
  });
66
101
 
67
102
  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
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)` make a step throw before finally returning `value` — to
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.engine.register('wf', '1', async (ctx) =>
82
- ctx.step('charge', failOnce({ ok: true }), { retries: 3 }),
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.engine.waitForRun(runId);
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. Expecting `start` to run the body synchronously in a test
141
+ ### 1. Forgetting to register a transport.handle for a dispatched step
102
142
 
103
143
  ```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
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
- // ✓ Correct — wait for the run to settle (or use t.run(...) which starts + waits)
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
- `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`).
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
- ### 2. Using real time instead of tick() for a durable sleep
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.engine.waitForRun('run1'); // suspends on the sleep
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
- ### 3. Reusing the same runId across cases expecting a fresh run
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