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.
Files changed (68) hide show
  1. package/README.md +4 -4
  2. package/dist/api-workflow.js +1 -1
  3. package/dist/api.js +1 -1
  4. package/dist/astro.js +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/internal/builtins.js +1 -1
  7. package/dist/internal/class-serialization.js +1 -1
  8. package/dist/internal/errors.js +1 -1
  9. package/dist/nest.js +1 -1
  10. package/dist/next.cjs +1 -1
  11. package/dist/nitro.js +1 -1
  12. package/dist/nuxt.js +1 -1
  13. package/dist/observability.js +1 -1
  14. package/dist/runtime.js +1 -1
  15. package/dist/stdlib.js +1 -1
  16. package/dist/sveltekit.js +1 -1
  17. package/dist/typescript-plugin.cjs +1 -1
  18. package/dist/vite.js +1 -1
  19. package/dist/workflow.js +1 -1
  20. package/docs/ai/resumable-streams.mdx +1 -1
  21. package/docs/api-reference/workflow/create-webhook.mdx +37 -18
  22. package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
  23. package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
  24. package/docs/api-reference/workflow-ai/index.mdx +0 -5
  25. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
  26. package/docs/cookbook/advanced/custom-serialization.mdx +168 -0
  27. package/docs/cookbook/advanced/durable-objects.mdx +148 -0
  28. package/docs/cookbook/advanced/isomorphic-packages.mdx +145 -0
  29. package/docs/cookbook/advanced/meta.json +10 -0
  30. package/docs/cookbook/advanced/publishing-libraries.mdx +279 -0
  31. package/docs/cookbook/advanced/serializable-steps.mdx +135 -0
  32. package/docs/cookbook/agent-patterns/durable-agent.mdx +191 -0
  33. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +278 -0
  34. package/docs/cookbook/agent-patterns/meta.json +10 -0
  35. package/docs/cookbook/agent-patterns/stop-workflow.mdx +216 -0
  36. package/docs/cookbook/agent-patterns/tool-orchestration.mdx +255 -0
  37. package/docs/cookbook/agent-patterns/tool-streaming.mdx +181 -0
  38. package/docs/cookbook/common-patterns/batching.mdx +179 -0
  39. package/docs/cookbook/common-patterns/child-workflows.mdx +372 -0
  40. package/docs/cookbook/common-patterns/content-router.mdx +207 -0
  41. package/docs/cookbook/common-patterns/fan-out.mdx +208 -0
  42. package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
  43. package/docs/cookbook/common-patterns/meta.json +15 -0
  44. package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
  45. package/docs/cookbook/common-patterns/saga.mdx +152 -0
  46. package/docs/cookbook/common-patterns/scheduling.mdx +249 -0
  47. package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
  48. package/docs/cookbook/index.mdx +41 -0
  49. package/docs/cookbook/integrations/ai-sdk.mdx +204 -0
  50. package/docs/cookbook/integrations/chat-sdk.mdx +203 -0
  51. package/docs/cookbook/integrations/meta.json +4 -0
  52. package/docs/cookbook/integrations/sandbox.mdx +128 -0
  53. package/docs/cookbook/meta.json +5 -0
  54. package/docs/deploying/world/local-world.mdx +1 -1
  55. package/docs/deploying/world/postgres-world.mdx +1 -1
  56. package/docs/deploying/world/vercel-world.mdx +1 -1
  57. package/docs/errors/start-invalid-workflow-function.mdx +1 -1
  58. package/docs/getting-started/index.mdx +8 -1
  59. package/docs/getting-started/meta.json +2 -1
  60. package/docs/getting-started/python.mdx +165 -0
  61. package/docs/meta.json +1 -0
  62. package/docs/migration-guides/index.mdx +34 -0
  63. package/docs/migration-guides/meta.json +9 -0
  64. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +311 -0
  65. package/docs/migration-guides/migrating-from-inngest.mdx +282 -0
  66. package/docs/migration-guides/migrating-from-temporal.mdx +284 -0
  67. package/docs/migration-guides/migrating-from-trigger-dev.mdx +296 -0
  68. 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
@@ -0,0 +1,15 @@
1
+ {
2
+ "title": "Common Patterns",
3
+ "defaultOpen": true,
4
+ "pages": [
5
+ "saga",
6
+ "batching",
7
+ "rate-limiting",
8
+ "fan-out",
9
+ "scheduling",
10
+ "idempotency",
11
+ "webhooks",
12
+ "content-router",
13
+ "child-workflows"
14
+ ]
15
+ }