workflow 5.0.0-beta.1 → 5.0.0-beta.2
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/README.md +4 -4
- package/dist/api-workflow.js +1 -1
- package/dist/api.js +1 -1
- package/dist/astro.js +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/builtins.js +1 -1
- package/dist/internal/class-serialization.js +1 -1
- package/dist/internal/errors.js +1 -1
- package/dist/nest.js +1 -1
- package/dist/next.cjs +1 -1
- package/dist/nitro.js +1 -1
- package/dist/nuxt.js +1 -1
- package/dist/observability.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/stdlib.js +1 -1
- package/dist/sveltekit.js +1 -1
- package/dist/typescript-plugin.cjs +1 -1
- package/dist/vite.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/ai/resumable-streams.mdx +1 -1
- package/docs/api-reference/workflow/create-webhook.mdx +37 -18
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
- package/docs/api-reference/workflow-ai/index.mdx +0 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
- package/docs/cookbook/advanced/custom-serialization.mdx +168 -0
- package/docs/cookbook/advanced/durable-objects.mdx +148 -0
- package/docs/cookbook/advanced/isomorphic-packages.mdx +145 -0
- package/docs/cookbook/advanced/meta.json +10 -0
- package/docs/cookbook/advanced/publishing-libraries.mdx +279 -0
- package/docs/cookbook/advanced/serializable-steps.mdx +135 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +191 -0
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +278 -0
- package/docs/cookbook/agent-patterns/meta.json +10 -0
- package/docs/cookbook/agent-patterns/stop-workflow.mdx +216 -0
- package/docs/cookbook/agent-patterns/tool-orchestration.mdx +255 -0
- package/docs/cookbook/agent-patterns/tool-streaming.mdx +181 -0
- package/docs/cookbook/common-patterns/batching.mdx +179 -0
- package/docs/cookbook/common-patterns/child-workflows.mdx +372 -0
- package/docs/cookbook/common-patterns/content-router.mdx +207 -0
- package/docs/cookbook/common-patterns/fan-out.mdx +208 -0
- package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
- package/docs/cookbook/common-patterns/meta.json +15 -0
- package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
- package/docs/cookbook/common-patterns/saga.mdx +152 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +249 -0
- package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
- package/docs/cookbook/index.mdx +41 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +204 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +203 -0
- package/docs/cookbook/integrations/meta.json +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +128 -0
- package/docs/cookbook/meta.json +5 -0
- package/docs/deploying/world/local-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +1 -1
- package/docs/deploying/world/vercel-world.mdx +1 -1
- package/docs/errors/start-invalid-workflow-function.mdx +1 -1
- package/docs/getting-started/index.mdx +8 -1
- package/docs/getting-started/meta.json +2 -1
- package/docs/getting-started/python.mdx +165 -0
- package/docs/meta.json +1 -0
- package/docs/migration-guides/index.mdx +34 -0
- package/docs/migration-guides/meta.json +9 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +311 -0
- package/docs/migration-guides/migrating-from-inngest.mdx +282 -0
- package/docs/migration-guides/migrating-from-temporal.mdx +284 -0
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +296 -0
- package/package.json +13 -13
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Conditional Routing
|
|
3
|
+
description: Inspect a payload and route it to different step handlers based on its content.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Classify incoming messages and branch to specialized handlers using standard if/else logic in the workflow function.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use conditional routing when incoming messages need different processing paths depending on their content. A support ticket about billing goes to the billing handler; a bug report goes to engineering. The workflow inspects the payload and branches with standard JavaScript control flow.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Support ticket routing by category
|
|
13
|
+
- Order processing with different flows per product type
|
|
14
|
+
- Event handling where different event types need different logic
|
|
15
|
+
- Any message-driven system where the handler depends on the content
|
|
16
|
+
|
|
17
|
+
## Pattern: Content-based router
|
|
18
|
+
|
|
19
|
+
The workflow classifies the input, then branches with `if`/`else` to call the appropriate step:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
declare function classifyTicket(ticketId: string, subject: string): Promise<{ ticketType: string }>; // @setup
|
|
23
|
+
declare function handleBilling(ticketId: string): Promise<void>; // @setup
|
|
24
|
+
declare function handleTechnical(ticketId: string): Promise<void>; // @setup
|
|
25
|
+
declare function handleAccount(ticketId: string): Promise<void>; // @setup
|
|
26
|
+
declare function handleFeedback(ticketId: string): Promise<void>; // @setup
|
|
27
|
+
|
|
28
|
+
export async function routeTicket(ticketId: string, subject: string) {
|
|
29
|
+
"use workflow";
|
|
30
|
+
|
|
31
|
+
const { ticketType } = await classifyTicket(ticketId, subject); // [!code highlight]
|
|
32
|
+
|
|
33
|
+
if (ticketType === "billing") { // [!code highlight]
|
|
34
|
+
await handleBilling(ticketId);
|
|
35
|
+
} else if (ticketType === "technical") {
|
|
36
|
+
await handleTechnical(ticketId);
|
|
37
|
+
} else if (ticketType === "account") {
|
|
38
|
+
await handleAccount(ticketId);
|
|
39
|
+
} else {
|
|
40
|
+
await handleFeedback(ticketId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { ticketId, routedTo: ticketType };
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Step functions
|
|
48
|
+
|
|
49
|
+
Each handler is a separate `"use step"` function. The classification step can use an LLM, keyword matching, or any logic you need:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
async function classifyTicket(
|
|
53
|
+
ticketId: string,
|
|
54
|
+
subject: string
|
|
55
|
+
): Promise<{ ticketType: string }> {
|
|
56
|
+
"use step";
|
|
57
|
+
|
|
58
|
+
// Example: simple keyword classification
|
|
59
|
+
// In production, this could call an LLM or ML model
|
|
60
|
+
const lower = subject.toLowerCase();
|
|
61
|
+
if (lower.includes("invoice") || lower.includes("charge") || lower.includes("refund")) {
|
|
62
|
+
return { ticketType: "billing" };
|
|
63
|
+
}
|
|
64
|
+
if (lower.includes("error") || lower.includes("bug") || lower.includes("crash")) {
|
|
65
|
+
return { ticketType: "technical" };
|
|
66
|
+
}
|
|
67
|
+
if (lower.includes("password") || lower.includes("login") || lower.includes("access")) {
|
|
68
|
+
return { ticketType: "account" };
|
|
69
|
+
}
|
|
70
|
+
return { ticketType: "feedback" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleBilling(ticketId: string): Promise<void> {
|
|
74
|
+
"use step";
|
|
75
|
+
// Look up billing records, process refund, etc.
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handleTechnical(ticketId: string): Promise<void> {
|
|
79
|
+
"use step";
|
|
80
|
+
// Create bug report, notify engineering, etc.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handleAccount(ticketId: string): Promise<void> {
|
|
84
|
+
"use step";
|
|
85
|
+
// Reset password, update permissions, etc.
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleFeedback(ticketId: string): Promise<void> {
|
|
89
|
+
"use step";
|
|
90
|
+
// Log feedback, notify product team, etc.
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Pattern: Enrichment before routing
|
|
95
|
+
|
|
96
|
+
When downstream handlers need more context than the raw input provides, enrich the message in parallel before routing:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
export async function enrichAndRoute(email: string) {
|
|
100
|
+
"use workflow";
|
|
101
|
+
|
|
102
|
+
// Step 1: Look up base data
|
|
103
|
+
const contact = await lookupContact(email);
|
|
104
|
+
|
|
105
|
+
// Step 2: Enrich from multiple sources in parallel
|
|
106
|
+
const [crm, social] = await Promise.allSettled([ // [!code highlight]
|
|
107
|
+
fetchCrmData(contact),
|
|
108
|
+
fetchSocialData(contact),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const enriched = {
|
|
112
|
+
...contact,
|
|
113
|
+
crm: crm.status === "fulfilled" ? crm.value : null,
|
|
114
|
+
social: social.status === "fulfilled" ? social.value : null,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Step 3: Route based on enriched data
|
|
118
|
+
if (enriched.crm?.segment === "enterprise") { // [!code highlight]
|
|
119
|
+
await routeToEnterpriseSales(enriched);
|
|
120
|
+
} else {
|
|
121
|
+
await routeToSelfServe(enriched);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { email, segment: enriched.crm?.segment ?? "self-serve" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function lookupContact(email: string): Promise<{ email: string; domain: string }> {
|
|
128
|
+
"use step";
|
|
129
|
+
return { email, domain: email.split("@")[1] ?? "unknown" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchCrmData(contact: { email: string }): Promise<{ segment: string }> {
|
|
133
|
+
"use step";
|
|
134
|
+
const res = await fetch(`https://crm.example.com/lookup?email=${contact.email}`);
|
|
135
|
+
return res.json();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function fetchSocialData(contact: { email: string }): Promise<{ followers: number }> {
|
|
139
|
+
"use step";
|
|
140
|
+
const res = await fetch(`https://social.example.com/lookup?email=${contact.email}`);
|
|
141
|
+
return res.json();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function routeToEnterpriseSales(enriched: unknown): Promise<void> {
|
|
145
|
+
"use step";
|
|
146
|
+
// Assign to enterprise sales team
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function routeToSelfServe(enriched: unknown): Promise<void> {
|
|
150
|
+
"use step";
|
|
151
|
+
// Add to self-serve onboarding flow
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Pattern: Multiple event sources
|
|
156
|
+
|
|
157
|
+
When a workflow must wait for signals from different systems before proceeding, create one hook per source and use `Promise.all` with a deadline:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { defineHook, sleep } from "workflow";
|
|
161
|
+
|
|
162
|
+
export const orderSignal = defineHook<{ ok: true }>();
|
|
163
|
+
|
|
164
|
+
const SIGNALS = ["payment", "inventory", "fraud"] as const;
|
|
165
|
+
|
|
166
|
+
export async function waitForAllSignals(orderId: string) {
|
|
167
|
+
"use workflow";
|
|
168
|
+
|
|
169
|
+
const hooks = SIGNALS.map((kind) =>
|
|
170
|
+
orderSignal.create({ token: `${kind}:${orderId}` }) // [!code highlight]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const outcome = await Promise.race([ // [!code highlight]
|
|
174
|
+
Promise.all(hooks).then(() => ({ type: "ready" as const })), // [!code highlight]
|
|
175
|
+
sleep("5m").then(() => ({ type: "timeout" as const })), // [!code highlight]
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
if (outcome.type === "timeout") {
|
|
179
|
+
return { orderId, status: "timeout" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await shipOrder(orderId);
|
|
183
|
+
return { orderId, status: "shipped" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function shipOrder(orderId: string): Promise<void> {
|
|
187
|
+
"use step";
|
|
188
|
+
await fetch(`https://shipping.example.com/ship`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: JSON.stringify({ orderId }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Tips
|
|
196
|
+
|
|
197
|
+
- **Workflow functions use standard JavaScript.** `if`/`else`, `switch`, ternaries -- any branching logic works. No special routing DSL needed.
|
|
198
|
+
- **Each handler is an independent step.** This means each gets its own retries, its own error handling, and its own entry in the event log.
|
|
199
|
+
- **Combine with enrichment** when downstream handlers need data from multiple sources. Fan out enrichment with `Promise.allSettled`, then route on the merged result.
|
|
200
|
+
- **Use `defineHook` for event gateways** when the routing decision depends on external signals arriving asynchronously.
|
|
201
|
+
|
|
202
|
+
## Key APIs
|
|
203
|
+
|
|
204
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- marks the orchestrator function
|
|
205
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) -- marks each handler as a durable step
|
|
206
|
+
- [`defineHook()`](/docs/api-reference/workflow/define-hook) -- creates hooks for event gateway patterns
|
|
207
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable deadline for event gateways
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Fan-Out & Parallel Delivery
|
|
3
|
+
description: Send a message to multiple channels or recipients in parallel with independent failure handling.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Fan out an incident alert to Slack, email, SMS, and PagerDuty simultaneously using Promise.allSettled, so a failure in one channel does not block the others.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use fan-out when one event needs to trigger multiple independent actions in parallel. Each action runs as its own step, so failures are isolated -- a Slack outage doesn't prevent the email from sending.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Incident alerting across multiple channels (Slack, email, SMS, PagerDuty)
|
|
13
|
+
- Notifying a list of recipients determined at runtime
|
|
14
|
+
- Any "broadcast" where each delivery is independent
|
|
15
|
+
|
|
16
|
+
## Pattern: Static fan-out
|
|
17
|
+
|
|
18
|
+
Define one step per channel and launch them all with `Promise.allSettled()`:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
declare function sendSlackAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
22
|
+
declare function sendEmailAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
23
|
+
declare function sendSmsAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
24
|
+
declare function sendPagerDutyAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
25
|
+
|
|
26
|
+
export async function incidentFanOut(incidentId: string, message: string) {
|
|
27
|
+
"use workflow";
|
|
28
|
+
|
|
29
|
+
const settled = await Promise.allSettled([ // [!code highlight]
|
|
30
|
+
sendSlackAlert(incidentId, message),
|
|
31
|
+
sendEmailAlert(incidentId, message),
|
|
32
|
+
sendSmsAlert(incidentId, message),
|
|
33
|
+
sendPagerDutyAlert(incidentId, message),
|
|
34
|
+
]); // [!code highlight]
|
|
35
|
+
|
|
36
|
+
const ok = settled.filter((r) => r.status === "fulfilled").length;
|
|
37
|
+
return { incidentId, delivered: ok, failed: settled.length - ok };
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Step functions
|
|
42
|
+
|
|
43
|
+
Each channel is a separate `"use step"` function. Steps have full Node.js access and retry automatically on transient failures.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
async function sendSlackAlert(incidentId: string, message: string) {
|
|
47
|
+
"use step";
|
|
48
|
+
await fetch("https://hooks.slack.com/services/T.../B.../xxx", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
body: JSON.stringify({ text: `[${incidentId}] ${message}` }),
|
|
51
|
+
});
|
|
52
|
+
return { channel: "slack" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function sendEmailAlert(incidentId: string, message: string) {
|
|
56
|
+
"use step";
|
|
57
|
+
await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
to: [{ email: "oncall@example.com" }],
|
|
62
|
+
subject: `Incident ${incidentId}`,
|
|
63
|
+
content: [{ type: "text/plain", value: message }],
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
return { channel: "email" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function sendSmsAlert(incidentId: string, message: string) {
|
|
70
|
+
"use step";
|
|
71
|
+
// Call Twilio or similar SMS provider
|
|
72
|
+
return { channel: "sms" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function sendPagerDutyAlert(incidentId: string, message: string) {
|
|
76
|
+
"use step";
|
|
77
|
+
// Call PagerDuty Events API
|
|
78
|
+
return { channel: "pagerduty" };
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Pattern: Dynamic recipient list
|
|
83
|
+
|
|
84
|
+
When recipients are determined at runtime (e.g., severity-based routing), build the list dynamically:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
type Severity = "info" | "warning" | "critical";
|
|
88
|
+
|
|
89
|
+
const RULES = [
|
|
90
|
+
{ channel: "slack", match: () => true },
|
|
91
|
+
{ channel: "email", match: (s: Severity) => s === "warning" || s === "critical" },
|
|
92
|
+
{ channel: "pagerduty", match: (s: Severity) => s === "critical" },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export async function alertByRecipientList(
|
|
96
|
+
alertId: string,
|
|
97
|
+
message: string,
|
|
98
|
+
severity: Severity
|
|
99
|
+
) {
|
|
100
|
+
"use workflow";
|
|
101
|
+
|
|
102
|
+
const matched = RULES.filter((r) => r.match(severity)).map((r) => r.channel);
|
|
103
|
+
|
|
104
|
+
const settled = await Promise.allSettled( // [!code highlight]
|
|
105
|
+
matched.map((channel) => deliverToChannel(channel, alertId, message))
|
|
106
|
+
); // [!code highlight]
|
|
107
|
+
|
|
108
|
+
const delivered = settled.filter((r) => r.status === "fulfilled").length;
|
|
109
|
+
return { alertId, severity, matched, delivered, failed: matched.length - delivered };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function deliverToChannel(
|
|
113
|
+
channel: string,
|
|
114
|
+
alertId: string,
|
|
115
|
+
message: string
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
"use step";
|
|
118
|
+
// Route to the appropriate API based on channel name
|
|
119
|
+
await fetch(`https://notifications.example.com/${channel}`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify({ alertId, message }),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Pattern: Publish-subscribe
|
|
127
|
+
|
|
128
|
+
When subscribers are managed in a registry and filtered by topic:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
type Subscriber = { id: string; name: string; topics: string[] };
|
|
132
|
+
|
|
133
|
+
export async function publishEvent(topic: string, payload: string) {
|
|
134
|
+
"use workflow";
|
|
135
|
+
|
|
136
|
+
const subscribers = await loadSubscribers();
|
|
137
|
+
const matched = subscribers.filter((sub) => sub.topics.includes(topic));
|
|
138
|
+
|
|
139
|
+
await Promise.allSettled( // [!code highlight]
|
|
140
|
+
matched.map((sub) => deliverToSubscriber(sub.id, topic, payload))
|
|
141
|
+
); // [!code highlight]
|
|
142
|
+
|
|
143
|
+
return { topic, delivered: matched.length, total: subscribers.length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function loadSubscribers(): Promise<Subscriber[]> {
|
|
147
|
+
"use step";
|
|
148
|
+
// Load from database or configuration service
|
|
149
|
+
return [
|
|
150
|
+
{ id: "sub-1", name: "Order Service", topics: ["orders", "inventory"] },
|
|
151
|
+
{ id: "sub-2", name: "Email Notifier", topics: ["orders", "shipping"] },
|
|
152
|
+
{ id: "sub-3", name: "Analytics", topics: ["orders", "inventory", "shipping"] },
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function deliverToSubscriber(
|
|
157
|
+
subscriberId: string,
|
|
158
|
+
topic: string,
|
|
159
|
+
payload: string
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
"use step";
|
|
162
|
+
await fetch(`https://subscribers.example.com/${subscriberId}/deliver`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
body: JSON.stringify({ topic, payload }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Deferred await (background steps)
|
|
170
|
+
|
|
171
|
+
You don't have to await a step immediately. Start a step, do other work, and collect the result later. This is different from `Promise.all` -- you interleave sequential and parallel work instead of waiting for everything at once.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
declare function generateReport(data: Record<string, string>): Promise<any>; // @setup
|
|
175
|
+
declare function sendNotification(userId: string, message: string): Promise<void>; // @setup
|
|
176
|
+
declare function updateDashboard(userId: string): Promise<void>; // @setup
|
|
177
|
+
|
|
178
|
+
export async function onboardUser(userId: string, data: Record<string, string>) {
|
|
179
|
+
"use workflow";
|
|
180
|
+
|
|
181
|
+
// Start report generation in the background
|
|
182
|
+
const reportPromise = generateReport(data); // [!code highlight]
|
|
183
|
+
|
|
184
|
+
// Do other work while the report generates
|
|
185
|
+
await sendNotification(userId, "Processing started");
|
|
186
|
+
await updateDashboard(userId);
|
|
187
|
+
|
|
188
|
+
// Now await the report when we actually need it
|
|
189
|
+
const report = await reportPromise; // [!code highlight]
|
|
190
|
+
return { userId, report };
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The workflow runtime tracks the background step like any other. If the workflow replays, the already-completed step returns its cached result instantly.
|
|
195
|
+
|
|
196
|
+
## Tips
|
|
197
|
+
|
|
198
|
+
- **Use `Promise.allSettled` over `Promise.all`.** `allSettled` lets you know which channels failed without aborting the others.
|
|
199
|
+
- **Each delivery is an independent step.** Transient failures (e.g., Slack 503) trigger automatic retries without affecting other channels.
|
|
200
|
+
- **Use `FatalError` for permanent failures** (e.g., PagerDuty not configured) to stop retries on that channel while letting others continue.
|
|
201
|
+
- **Dynamic recipient lists** decouple routing from delivery -- adding a new channel is a configuration change, not a code change.
|
|
202
|
+
|
|
203
|
+
## Key APIs
|
|
204
|
+
|
|
205
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
|
|
206
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
|
|
207
|
+
- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) -- fans out to all targets, isolating failures
|
|
208
|
+
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- prevents automatic retry for permanent failures
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Idempotency
|
|
3
|
+
description: Ensure external side effects happen exactly once, even when steps are retried or workflows are replayed.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Use step IDs as idempotency keys for external APIs like Stripe so that retries and replays don't create duplicate charges.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Workflow steps can be retried (on failure) and replayed (on cold start). If a step calls an external API that isn't idempotent, retries could create duplicate charges, send duplicate emails, or double-process records. Use idempotency keys to make these operations safe.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Charging a payment (Stripe, PayPal)
|
|
13
|
+
- Sending transactional emails or SMS
|
|
14
|
+
- Creating records in external systems where duplicates are harmful
|
|
15
|
+
- Any step that has side effects in systems you don't control
|
|
16
|
+
|
|
17
|
+
## Pattern: Step ID as idempotency key
|
|
18
|
+
|
|
19
|
+
Every step has a unique, deterministic `stepId` available via `getStepMetadata()`. Pass this as the idempotency key to external APIs:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { getStepMetadata } from "workflow";
|
|
23
|
+
|
|
24
|
+
declare function createCharge(customerId: string, amount: number): Promise<{ id: string }>; // @setup
|
|
25
|
+
declare function sendReceipt(customerId: string, chargeId: string): Promise<void>; // @setup
|
|
26
|
+
|
|
27
|
+
export async function chargeCustomer(customerId: string, amount: number) {
|
|
28
|
+
"use workflow";
|
|
29
|
+
|
|
30
|
+
const charge = await createCharge(customerId, amount);
|
|
31
|
+
await sendReceipt(customerId, charge.id);
|
|
32
|
+
|
|
33
|
+
return { customerId, chargeId: charge.id, status: "completed" };
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Step function with idempotency key
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { getStepMetadata } from "workflow";
|
|
41
|
+
|
|
42
|
+
async function createCharge(
|
|
43
|
+
customerId: string,
|
|
44
|
+
amount: number
|
|
45
|
+
): Promise<{ id: string }> {
|
|
46
|
+
"use step";
|
|
47
|
+
|
|
48
|
+
const { stepId } = getStepMetadata(); // [!code highlight]
|
|
49
|
+
|
|
50
|
+
// Stripe uses the idempotency key to deduplicate requests.
|
|
51
|
+
// If this step is retried, Stripe returns the same charge.
|
|
52
|
+
const charge = await fetch("https://api.stripe.com/v1/charges", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
|
|
56
|
+
"Idempotency-Key": stepId, // [!code highlight]
|
|
57
|
+
},
|
|
58
|
+
body: new URLSearchParams({
|
|
59
|
+
amount: String(amount),
|
|
60
|
+
currency: "usd",
|
|
61
|
+
customer: customerId,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!charge.ok) {
|
|
66
|
+
const error = await charge.json();
|
|
67
|
+
throw new Error(`Charge failed: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return charge.json();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sendReceipt(customerId: string, chargeId: string): Promise<void> {
|
|
74
|
+
"use step";
|
|
75
|
+
|
|
76
|
+
const { stepId } = getStepMetadata();
|
|
77
|
+
|
|
78
|
+
await fetch("https://api.example.com/receipts", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Idempotency-Key": stepId },
|
|
81
|
+
body: JSON.stringify({ customerId, chargeId }),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Race condition caveats
|
|
87
|
+
|
|
88
|
+
Workflow does not currently provide distributed locking or true exactly-once delivery across concurrent runs. If two workflow runs could process the same entity concurrently:
|
|
89
|
+
|
|
90
|
+
- **Rely on the external API's idempotency** (like Stripe's `Idempotency-Key`) rather than checking a local flag.
|
|
91
|
+
- **Don't use check-then-act patterns** like "read a flag, then write if not set" -- another run could read the same flag between your read and write.
|
|
92
|
+
|
|
93
|
+
If your external API doesn't support idempotency keys natively, consider adding a deduplication layer (e.g., a database unique constraint on the operation ID).
|
|
94
|
+
|
|
95
|
+
## Tips
|
|
96
|
+
|
|
97
|
+
- **`stepId` is deterministic.** It's the same value across retries and replays of the same step, making it a reliable idempotency key.
|
|
98
|
+
- **Always provide idempotency keys for non-idempotent external calls.** Even if you think a step won't be retried, cold-start replay will re-execute it.
|
|
99
|
+
- **Handle 409/conflict as success.** If an external API returns "already processed," treat that as a successful result, not an error.
|
|
100
|
+
- **Make your own APIs idempotent** where possible. Accept an idempotency key and return the cached result on duplicate requests.
|
|
101
|
+
|
|
102
|
+
## Key APIs
|
|
103
|
+
|
|
104
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
|
|
105
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
|
|
106
|
+
- [`getStepMetadata()`](/docs/api-reference/step/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
|
|
107
|
+
- [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run
|