@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
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
name: durable-workflows
|
|
3
3
|
description: >
|
|
4
4
|
Author durable workflows with @dudousxd/nestjs-durable — @Workflow({ name, version }) classes with a
|
|
5
|
-
run(ctx, input) body
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
run(ctx, input) body and ONE durable step primitive: ctx.step(handlerRef | name, input, opts?),
|
|
6
|
+
always dispatched, always engine-scheduled. @Step()-decorated provider methods carry the routing
|
|
7
|
+
identity (derived Class.method name, or an explicit override); a method reference dispatches a
|
|
8
|
+
same-process/typed step, a string name dispatches a cross-runtime one (e.g. a Python worker). Covers
|
|
9
|
+
retries/backoff and FatalError, Promise.all fan-out, sub-process log.sub/subProcess annotations
|
|
10
|
+
inside a @Step handler, ctx.child for cross-runtime sub-workflows, and constructor dependency
|
|
11
|
+
injection inside a workflow class.
|
|
9
12
|
license: MIT
|
|
10
13
|
metadata:
|
|
11
14
|
type: core
|
|
@@ -17,116 +20,160 @@ metadata:
|
|
|
17
20
|
# Authoring durable workflows
|
|
18
21
|
|
|
19
22
|
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** —
|
|
21
|
-
|
|
23
|
+
deterministic body the engine executes and replays. Work happens in **steps** — always dispatched,
|
|
24
|
+
always durably checkpointed. There is no local/remote placement choice: `ctx.step` is the ONE step
|
|
25
|
+
primitive, and the worker that ends up serving it (this process, another pool, another language) is
|
|
26
|
+
an infrastructure decision, not something the workflow body encodes.
|
|
22
27
|
|
|
23
28
|
## Setup
|
|
24
29
|
|
|
25
30
|
```ts title="checkout.workflow.ts"
|
|
26
|
-
import { Workflow
|
|
27
|
-
import { remoteStep, FatalError } from '@dudousxd/nestjs-durable-core';
|
|
31
|
+
import { Workflow } from '@dudousxd/nestjs-durable';
|
|
28
32
|
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
|
|
29
|
-
import {
|
|
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
|
-
});
|
|
33
|
+
import { PaymentsWorker } from './payments.worker';
|
|
39
34
|
|
|
40
|
-
//
|
|
35
|
+
// 1. The workflow — plain linear code; ctx primitives are the only durable surface.
|
|
41
36
|
@Workflow({ name: 'checkout', version: '1' })
|
|
42
37
|
export class CheckoutWorkflow {
|
|
43
|
-
constructor(private readonly
|
|
38
|
+
constructor(private readonly payments: PaymentsWorker) {} // normal DI
|
|
44
39
|
|
|
45
40
|
async run(ctx: WorkflowCtx, order: { id: string; total: number }) {
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
// Dispatch by METHOD REFERENCE: the routing name comes off the @Step-stamped method, typed by
|
|
42
|
+
// its own signature — refactor-safe, autocompleted.
|
|
43
|
+
await ctx.step(this.payments.reserveStock, order);
|
|
44
|
+
|
|
45
|
+
const charge = await ctx.step(this.payments.charge, {
|
|
46
|
+
orderId: order.id,
|
|
47
|
+
amountCents: order.total,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
50
|
return { chargeId: charge.chargeId };
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
```
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
```ts title="payments.worker.ts"
|
|
56
|
+
import { Step } from '@dudousxd/nestjs-durable';
|
|
57
|
+
import { Injectable } from '@nestjs/common';
|
|
58
|
+
|
|
59
|
+
// 2. The step handlers this workflow dispatches to — each method is one durable checkpoint. All of
|
|
60
|
+
// them happen to run in THIS process here (via the event-emitter transport), but nothing about the
|
|
61
|
+
// workflow code above would change if `charge` moved to a separate Python/Node worker over BullMQ —
|
|
62
|
+
// ctx.step dispatches by name either way.
|
|
55
63
|
@Injectable()
|
|
56
64
|
export class PaymentsWorker {
|
|
57
|
-
@
|
|
58
|
-
async
|
|
65
|
+
@Step() // name = "PaymentsWorker.reserveStock" (derived)
|
|
66
|
+
async reserveStock(_order: { id: string; total: number }): Promise<{ reserved: boolean }> {
|
|
67
|
+
// reserve inventory for order.id
|
|
68
|
+
return { reserved: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Step('payments.charge-card') // explicit name override
|
|
72
|
+
async charge(input: { orderId: string; amountCents: number }): Promise<{ chargeId: string }> {
|
|
73
|
+
// await stripe.charge(...)
|
|
59
74
|
return { chargeId: `ch_${input.orderId}` };
|
|
60
75
|
}
|
|
61
76
|
}
|
|
62
77
|
```
|
|
63
78
|
|
|
64
|
-
Register all
|
|
79
|
+
Register all providers (see the durable-setup skill).
|
|
65
80
|
|
|
66
81
|
## Core patterns
|
|
67
82
|
|
|
68
|
-
###
|
|
83
|
+
### Two call forms — reference and string
|
|
69
84
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
```ts
|
|
86
|
+
// JS handler in this codebase → pass the method reference. Name + input/output types come from the
|
|
87
|
+
// @Step-decorated method — no separate def to declare or keep in sync.
|
|
88
|
+
const r = await ctx.step(this.extraction.runExtractionPage, { page, key });
|
|
89
|
+
|
|
90
|
+
// Cross-runtime handler (e.g. a Python @step) → there's no JS reference to import, so name it by
|
|
91
|
+
// string. Both forms dispatch identically over the wire.
|
|
92
|
+
const out = await ctx.step<ProcResult>('processing:proc', input);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`@Step()` (bare) derives the routing name from the method as `` `${ClassName}.${method}` ``;
|
|
96
|
+
`@Step('custom:name')` overrides it explicitly — needed for a cross-runtime contract where the other
|
|
97
|
+
side has no `@Step` of its own. `@Step({ name?, input?, output?, retries?, backoff?, backoffMs?,
|
|
98
|
+
backoffMaxMs?, jitter?, timeoutMs? })` additionally opts into RUNTIME zod validation (`input`
|
|
99
|
+
validates before the method runs, `output` validates its return value) and a def-level durable-retry
|
|
100
|
+
policy — a bare `@Step()` skips both and dispatches with no retry/timeout.
|
|
75
101
|
|
|
76
|
-
|
|
77
|
-
of re-executing.
|
|
102
|
+
Every dispatched step is a first-class checkpoint: on crash/replay a `completed` one returns its
|
|
103
|
+
saved result instead of re-executing.
|
|
78
104
|
|
|
79
105
|
### Retries, backoff, and FatalError
|
|
80
106
|
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
Retry policy can live on the `@Step` decorator (the default for every call to that handler) or be
|
|
108
|
+
overridden per call site via `ctx.step`'s `opts` — a call-site value wins field-by-field.
|
|
83
109
|
|
|
84
110
|
```ts
|
|
85
|
-
|
|
111
|
+
@Step({ retries: 5, backoff: 'exp', backoffMs: 200, jitter: true })
|
|
112
|
+
async charge(order: Order) {
|
|
86
113
|
const res = await this.stripe.charge(order);
|
|
87
|
-
if (res.declined) throw new FatalError('card declined', 'declined'); // fails the run now
|
|
114
|
+
if (res.declined) throw new FatalError('card declined', 'declined'); // fails the run now, no retry
|
|
88
115
|
return res;
|
|
89
|
-
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// Per-call override — wins over the @Step-declared policy for THIS dispatch only.
|
|
121
|
+
await ctx.step(this.payments.charge, order, { retries: 1 });
|
|
90
122
|
```
|
|
91
123
|
|
|
92
|
-
`
|
|
93
|
-
`
|
|
124
|
+
`StepDispatchOpts` (the `ctx.step` third argument): `queue`, `priority`, `fairnessKey`, `transport`,
|
|
125
|
+
`retries`, `backoff` (`'fixed'` | `'exp'`), `backoffMs`, `backoffMaxMs`, `jitter`, `timeoutMs` (a
|
|
126
|
+
liveness window — no result/heartbeat within it presumes the worker dead and re-dispatches).
|
|
94
127
|
|
|
95
|
-
### Fan-out
|
|
128
|
+
### Fan-out
|
|
96
129
|
|
|
97
|
-
|
|
98
|
-
so replay stays deterministic:
|
|
130
|
+
Dispatch steps concurrently with `Promise.all` — each `ctx.step` call takes its position in the
|
|
131
|
+
synchronous prefix before suspending, so replay stays deterministic:
|
|
99
132
|
|
|
100
133
|
```ts
|
|
101
134
|
const [a, b] = await Promise.all([
|
|
102
|
-
ctx.step(
|
|
103
|
-
ctx.step(
|
|
135
|
+
ctx.step(this.svc.stepA, input),
|
|
136
|
+
ctx.step(this.svc.stepB, input),
|
|
104
137
|
]);
|
|
105
138
|
```
|
|
106
139
|
|
|
107
|
-
|
|
140
|
+
### Sub-process events inside a step
|
|
141
|
+
|
|
142
|
+
For pure visibility *inside* one step's execution, the `@Step` handler's optional second argument
|
|
143
|
+
(`log: StepLogger`) records annotations — they ride back with the step's result and are NOT separate
|
|
144
|
+
checkpoints, so they re-run if the step retries:
|
|
108
145
|
|
|
109
146
|
```ts
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
import type { StepLogger } from '@dudousxd/nestjs-durable-core';
|
|
148
|
+
|
|
149
|
+
@Step()
|
|
150
|
+
async processBatch(batch: Item[], log: StepLogger) {
|
|
151
|
+
for (const item of batch) {
|
|
152
|
+
await handle(item);
|
|
153
|
+
log.sub(item.id, 'ok'); // shown under the step; not a checkpoint of its own
|
|
154
|
+
}
|
|
155
|
+
}
|
|
113
156
|
```
|
|
114
157
|
|
|
115
|
-
###
|
|
158
|
+
### Cross-runtime sub-workflows: ctx.child
|
|
116
159
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
160
|
+
`ctx.step` dispatches ONE step handler; to hand off a whole **workflow** — including to another
|
|
161
|
+
language's runtime (e.g. a Python worker) — use `ctx.child` instead. Pass the child's class for a
|
|
162
|
+
typed, same-runtime child, or a string name for a cross-runtime one:
|
|
120
163
|
|
|
121
164
|
```ts
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
});
|
|
165
|
+
// Same-runtime, typed:
|
|
166
|
+
const result = await ctx.child(ShippingWorkflow, { orderId: order.id });
|
|
167
|
+
|
|
168
|
+
// Cross-runtime (e.g. a Python-registered workflow) — no class to import, so by name:
|
|
169
|
+
const rows = await ctx.child<{ rows: number }>('processing', { baseId });
|
|
128
170
|
```
|
|
129
171
|
|
|
172
|
+
`ctx.child` starts the child once (checkpointed) and suspends — zero compute — until it reaches a
|
|
173
|
+
terminal state, then resumes with its output (or throws if the child failed). Use `ctx.all(Class,
|
|
174
|
+
inputs)` / `ctx.all(name, inputs)` to fan out N children of the same workflow and wait for all of
|
|
175
|
+
them.
|
|
176
|
+
|
|
130
177
|
## Common mistakes
|
|
131
178
|
|
|
132
179
|
### 1. Doing durable work directly in the body instead of in a step
|
|
@@ -138,45 +185,53 @@ async run(ctx, order) {
|
|
|
138
185
|
return { ok: true };
|
|
139
186
|
}
|
|
140
187
|
|
|
141
|
-
// ✓ Correct —
|
|
188
|
+
// ✓ Correct — move it into a @Step handler so it checkpoints and runs exactly once logically
|
|
142
189
|
async run(ctx, order) {
|
|
143
|
-
await ctx.step(
|
|
190
|
+
await ctx.step(this.orders.persist, order);
|
|
144
191
|
return { ok: true };
|
|
145
192
|
}
|
|
146
193
|
```
|
|
147
194
|
|
|
148
|
-
Only `ctx.step`/`ctx.
|
|
149
|
-
|
|
195
|
+
Only `ctx.step`/`ctx.transaction` results are checkpointed; raw work in the body re-runs on replay.
|
|
196
|
+
Source: website/content/docs/concepts/durability.mdx.
|
|
150
197
|
|
|
151
|
-
### 2.
|
|
198
|
+
### 2. A method reference with no @Step stamp
|
|
152
199
|
|
|
153
200
|
```ts
|
|
154
|
-
// ✗ Wrong —
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
201
|
+
// ✗ Wrong — plain method, no @Step decorator → ctx.step has no routing name to read
|
|
202
|
+
class PaymentsWorker {
|
|
203
|
+
async charge(input: ChargeInput) { /* ... */ }
|
|
204
|
+
}
|
|
205
|
+
await ctx.step(this.payments.charge, input); // throws — "handler is not a @Step reference"
|
|
206
|
+
|
|
207
|
+
// ✓ Correct — decorate the handler so its name is stamped for ctx.step to read
|
|
208
|
+
class PaymentsWorker {
|
|
209
|
+
@Step()
|
|
210
|
+
async charge(input: ChargeInput) { /* ... */ }
|
|
211
|
+
}
|
|
162
212
|
```
|
|
163
213
|
|
|
164
|
-
|
|
165
|
-
|
|
214
|
+
`ctx.step` reads the routing name off a shared symbol `@Step` stamps on the method — the reference
|
|
215
|
+
itself is never invoked directly (the serving worker re-resolves the real handler from DI), so an
|
|
216
|
+
undecorated method has nothing for `ctx.step` to route on. Source: packages/nestjs/src/decorators.ts
|
|
217
|
+
(`Step`), packages/core/src/workflow-ctx.ts (`step`).
|
|
166
218
|
|
|
167
219
|
### 3. Throwing a plain Error when you meant to stop retrying
|
|
168
220
|
|
|
169
221
|
```ts
|
|
170
222
|
// ✗ Wrong — a plain Error is retried up to `retries`, wasting attempts on an unfixable failure
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
223
|
+
@Step({ retries: 5 })
|
|
224
|
+
async charge(order: Order) {
|
|
225
|
+
if (order.card.declined) throw new Error('declined'); // retried 5x
|
|
226
|
+
}
|
|
174
227
|
|
|
175
228
|
// ✓ Correct — FatalError fails the run immediately, no further attempts
|
|
176
229
|
import { FatalError } from '@dudousxd/nestjs-durable-core';
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
230
|
+
|
|
231
|
+
@Step({ retries: 5 })
|
|
232
|
+
async charge(order: Order) {
|
|
233
|
+
if (order.card.declined) throw new FatalError('declined', 'card_declined');
|
|
234
|
+
}
|
|
180
235
|
```
|
|
181
236
|
|
|
182
237
|
Any thrown error is retried to the step's limit; only `FatalError` short-circuits retries.
|