@dudousxd/nestjs-durable 0.22.0 → 0.23.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 +255 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -3
- package/dist/index.d.ts +79 -3
- package/dist/index.js +219 -66
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
- package/skills/durable-determinism/SKILL.md +144 -0
- package/skills/durable-setup/SKILL.md +189 -0
- package/skills/durable-signals-and-timers/SKILL.md +181 -0
- package/skills/durable-testing/SKILL.md +146 -0
- package/skills/durable-workflows/SKILL.md +183 -0
|
@@ -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.
|