@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.
@@ -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, 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.
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** — local
21
- (`ctx.step`) or remote (`ctx.call`) — each a durable checkpoint.
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, DurableStep } from '@dudousxd/nestjs-durable';
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 { 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
- });
33
+ import { PaymentsWorker } from './payments.worker';
39
34
 
40
- // 2. The workflow — plain linear code; ctx primitives are the only durable surface.
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 inventory: InventoryService) {} // normal DI
38
+ constructor(private readonly payments: PaymentsWorker) {} // normal DI
44
39
 
45
40
  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 });
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
- // 3. A provider method handles the remote step in-process (with an in-process transport).
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
- @DurableStep('payments.charge-card')
58
- async charge(input: { orderId: string; amountCents: number }) {
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 three as providers (see the durable-setup skill).
79
+ Register all providers (see the durable-setup skill).
65
80
 
66
81
  ## Core patterns
67
82
 
68
- ### Local step vs remote step
83
+ ### Two call forms — reference and string
69
84
 
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.
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
- Both are first-class checkpoints: on crash/replay a `completed` one returns its saved result instead
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
- Per-step retry config lives in `opts`; throw `FatalError` to stop retrying a business failure a retry
82
- can't fix.
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
- await ctx.step('charge', async () => {
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
- }, { retries: 5, backoff: 'exp', backoffMs: 200, jitter: true });
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
- `StepOptions` fields: `retries`, `backoff` (`'fixed'` | `'exp'`), `backoffMs`, `backoffMaxMs`,
93
- `jitter`, `timeoutMs` (remote liveness), `compensate` (saga).
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 and sagas
128
+ ### Fan-out
96
129
 
97
- Run steps concurrently with `Promise.all` — each step's position is taken in the synchronous prefix,
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('a', () => doA()),
103
- ctx.step('b', () => doB()),
135
+ ctx.step(this.svc.stepA, input),
136
+ ctx.step(this.svc.stepB, input),
104
137
  ]);
105
138
  ```
106
139
 
107
- Register a saga `compensate` undo on a step; if the run later fails, compensations run in reverse:
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
- await ctx.step('reserve', () => this.inventory.reserve(id), {
111
- compensate: () => this.inventory.release(id), // runs on later run failure, newest-first
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
- ### Steps vs sub-process events
158
+ ### Cross-runtime sub-workflows: ctx.child
116
159
 
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:
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
- 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
- });
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 — wrap side effects in a step so they checkpoint and run exactly once logically
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('persist', () => this.db.insert(order));
190
+ await ctx.step(this.orders.persist, order);
144
191
  return { ok: true };
145
192
  }
146
193
  ```
147
194
 
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.
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. Mismatching the remoteStep `name` and the @DurableStep handler
198
+ ### 2. A method reference with no @Step stamp
152
199
 
153
200
  ```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) { /* ... */ }
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
- 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`).
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
- await ctx.step('charge', async () => {
172
- if (card.declined) throw new Error('declined'); // retried 5x
173
- }, { retries: 5 });
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
- await ctx.step('charge', async () => {
178
- if (card.declined) throw new FatalError('declined', 'card_declined');
179
- }, { retries: 5 });
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.