@dudousxd/nestjs-durable 0.22.0 → 0.22.1

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.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Durable workflows for NestJS — module, decorators, discovery and boot recovery",
5
5
  "license": "MIT",
6
6
  "author": "Davide Carvalho",
@@ -13,7 +13,8 @@
13
13
  "main": "./dist/index.cjs",
14
14
  "types": "./dist/index.d.ts",
15
15
  "files": [
16
- "dist"
16
+ "dist",
17
+ "skills"
17
18
  ],
18
19
  "peerDependencies": {
19
20
  "@dudousxd/durable-worker": ">=0.1.0 <1.0.0",
@@ -53,9 +54,10 @@
53
54
  "zod": "3.25.76",
54
55
  "tsup": "8.3.5",
55
56
  "@dudousxd/nestjs-diagnostics": "^0.5.0",
56
- "@dudousxd/nestjs-durable-core": "^0.37.0",
57
+ "@tanstack/intent": "^0.3.2",
57
58
  "@dudousxd/nestjs-durable-transport-event-emitter": "^0.5.0",
58
- "@dudousxd/durable-worker": "0.3.1"
59
+ "@dudousxd/durable-worker": "0.3.3",
60
+ "@dudousxd/nestjs-durable-core": "^0.38.0"
59
61
  },
60
62
  "module": "./dist/index.js",
61
63
  "exports": {
@@ -70,6 +72,9 @@
70
72
  }
71
73
  }
72
74
  },
75
+ "keywords": [
76
+ "tanstack-intent"
77
+ ],
73
78
  "scripts": {
74
79
  "build": "tsup",
75
80
  "typecheck": "tsc -p tsconfig.json --noEmit"
@@ -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`).
@@ -0,0 +1,183 @@
1
+ ---
2
+ name: durable-workflows
3
+ description: >
4
+ Author durable workflows with @dudousxd/nestjs-durable — @Workflow({ name, version }) classes with a
5
+ run(ctx, input) body, local ctx.step(name, fn, opts) checkpoints, typed remote steps via remoteStep
6
+ + ctx.call handled by @DurableStep('name'), step retries/backoff, FatalError to stop retrying,
7
+ Promise.all fan-out, saga compensate undo callbacks, and run tags. Covers step vs ctx.call vs
8
+ sub-process log.sub annotations and constructor dependency injection inside a workflow class.
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
+ # Authoring durable workflows
18
+
19
+ A workflow is a provider decorated with `@Workflow`; its `run(ctx, input)` method is the
20
+ deterministic body the engine executes and replays. Work happens in **steps** — local
21
+ (`ctx.step`) or remote (`ctx.call`) — each a durable checkpoint.
22
+
23
+ ## Setup
24
+
25
+ ```ts title="checkout.workflow.ts"
26
+ import { Workflow, DurableStep } from '@dudousxd/nestjs-durable';
27
+ import { remoteStep, FatalError } from '@dudousxd/nestjs-durable-core';
28
+ import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
29
+ import { Injectable } from '@nestjs/common';
30
+ import { z } from 'zod';
31
+
32
+ // 1. A typed remote step: the `name` is the contract a handler registers under.
33
+ export const chargeCard = remoteStep({
34
+ name: 'payments.charge-card',
35
+ input: z.object({ orderId: z.string(), amountCents: z.number().int() }),
36
+ output: z.object({ chargeId: z.string() }),
37
+ retries: 3,
38
+ });
39
+
40
+ // 2. The workflow — plain linear code; ctx primitives are the only durable surface.
41
+ @Workflow({ name: 'checkout', version: '1' })
42
+ export class CheckoutWorkflow {
43
+ constructor(private readonly inventory: InventoryService) {} // normal DI
44
+
45
+ async run(ctx: WorkflowCtx, order: { id: string; total: number }) {
46
+ // Local step: runs in-process, checkpointed, retried up to `retries`.
47
+ await ctx.step('reserveStock', () => this.inventory.reserve(order.id), { retries: 3 });
48
+ // Remote step: dispatched over the transport, may run in another process/language.
49
+ const charge = await ctx.call(chargeCard, { orderId: order.id, amountCents: order.total });
50
+ return { chargeId: charge.chargeId };
51
+ }
52
+ }
53
+
54
+ // 3. A provider method handles the remote step in-process (with an in-process transport).
55
+ @Injectable()
56
+ export class PaymentsWorker {
57
+ @DurableStep('payments.charge-card')
58
+ async charge(input: { orderId: string; amountCents: number }) {
59
+ return { chargeId: `ch_${input.orderId}` };
60
+ }
61
+ }
62
+ ```
63
+
64
+ Register all three as providers (see the durable-setup skill).
65
+
66
+ ## Core patterns
67
+
68
+ ### Local step vs remote step
69
+
70
+ - `ctx.step(name, fn, opts?)` runs `fn` **here** and checkpoints its return value. Use for local IO
71
+ (DB writes, calling a service the app owns).
72
+ - `ctx.call(remoteStep, input, opts?)` dispatches over the transport to a handler registered under
73
+ the same `name` (a `@DurableStep` method, another process, or a Python worker). Inputs/outputs are
74
+ validated against the `remoteStep` zod schemas at the boundary.
75
+
76
+ Both are first-class checkpoints: on crash/replay a `completed` one returns its saved result instead
77
+ of re-executing.
78
+
79
+ ### Retries, backoff, and FatalError
80
+
81
+ Per-step retry config lives in `opts`; throw `FatalError` to stop retrying a business failure a retry
82
+ can't fix.
83
+
84
+ ```ts
85
+ await ctx.step('charge', async () => {
86
+ const res = await this.stripe.charge(order);
87
+ if (res.declined) throw new FatalError('card declined', 'declined'); // fails the run now
88
+ return res;
89
+ }, { retries: 5, backoff: 'exp', backoffMs: 200, jitter: true });
90
+ ```
91
+
92
+ `StepOptions` fields: `retries`, `backoff` (`'fixed'` | `'exp'`), `backoffMs`, `backoffMaxMs`,
93
+ `jitter`, `timeoutMs` (remote liveness), `compensate` (saga).
94
+
95
+ ### Fan-out and sagas
96
+
97
+ Run steps concurrently with `Promise.all` — each step's position is taken in the synchronous prefix,
98
+ so replay stays deterministic:
99
+
100
+ ```ts
101
+ const [a, b] = await Promise.all([
102
+ ctx.step('a', () => doA()),
103
+ ctx.step('b', () => doB()),
104
+ ]);
105
+ ```
106
+
107
+ Register a saga `compensate` undo on a step; if the run later fails, compensations run in reverse:
108
+
109
+ ```ts
110
+ await ctx.step('reserve', () => this.inventory.reserve(id), {
111
+ compensate: () => this.inventory.release(id), // runs on later run failure, newest-first
112
+ });
113
+ ```
114
+
115
+ ### Steps vs sub-process events
116
+
117
+ A `ctx.step` / `ctx.call` is a durable, independently-replayed checkpoint. For pure visibility
118
+ *inside* one step, emit sub-process events on the step logger — they are annotations, not
119
+ checkpoints, and re-run if the step retries:
120
+
121
+ ```ts
122
+ await ctx.step('process-batch', async (log) => {
123
+ for (const item of batch) {
124
+ await handle(item);
125
+ log.sub(item.id, 'ok'); // shown under the step; NOT a separate checkpoint
126
+ }
127
+ });
128
+ ```
129
+
130
+ ## Common mistakes
131
+
132
+ ### 1. Doing durable work directly in the body instead of in a step
133
+
134
+ ```ts
135
+ // ✗ Wrong — the write re-runs on every replay (not checkpointed)
136
+ async run(ctx, order) {
137
+ await this.db.insert(order); // re-executed each recovery → duplicate rows
138
+ return { ok: true };
139
+ }
140
+
141
+ // ✓ Correct — wrap side effects in a step so they checkpoint and run exactly once logically
142
+ async run(ctx, order) {
143
+ await ctx.step('persist', () => this.db.insert(order));
144
+ return { ok: true };
145
+ }
146
+ ```
147
+
148
+ Only `ctx.step`/`ctx.call`/`ctx.transaction` results are checkpointed; raw work in the body re-runs
149
+ on replay. Source: website/content/docs/concepts/durability.mdx.
150
+
151
+ ### 2. Mismatching the remoteStep `name` and the @DurableStep handler
152
+
153
+ ```ts
154
+ // ✗ Wrong — handler name ≠ the remoteStep name, so ctx.call(chargeCard) finds no handler
155
+ export const chargeCard = remoteStep({ name: 'payments.charge-card', /* ... */ });
156
+ @DurableStep('payments.charge') // typo / mismatch
157
+ async charge(input) { /* ... */ }
158
+
159
+ // ✓ Correct — the string is the contract; it must match byte-for-byte
160
+ @DurableStep('payments.charge-card')
161
+ async charge(input) { /* ... */ }
162
+ ```
163
+
164
+ The `name` string is the routing contract between `remoteStep`/`ctx.call` and the handler.
165
+ Source: packages/core/src/remote-step-factory.ts, packages/nestjs/src/decorators.ts (`DurableStep`).
166
+
167
+ ### 3. Throwing a plain Error when you meant to stop retrying
168
+
169
+ ```ts
170
+ // ✗ Wrong — a plain Error is retried up to `retries`, wasting attempts on an unfixable failure
171
+ await ctx.step('charge', async () => {
172
+ if (card.declined) throw new Error('declined'); // retried 5x
173
+ }, { retries: 5 });
174
+
175
+ // ✓ Correct — FatalError fails the run immediately, no further attempts
176
+ import { FatalError } from '@dudousxd/nestjs-durable-core';
177
+ await ctx.step('charge', async () => {
178
+ if (card.declined) throw new FatalError('declined', 'card_declined');
179
+ }, { retries: 5 });
180
+ ```
181
+
182
+ Any thrown error is retried to the step's limit; only `FatalError` short-circuits retries.
183
+ Source: packages/core/src/workflow-ctx.ts (`step` retry loop), packages/core/src/errors.ts.