@dudousxd/nestjs-durable 0.21.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.
@@ -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.