workflow 5.0.0-beta.2 → 5.0.0-beta.20

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 (169) hide show
  1. package/dist/api-workflow.d.ts +1 -1
  2. package/dist/api-workflow.d.ts.map +1 -1
  3. package/dist/api-workflow.js +2 -2
  4. package/dist/api.d.ts +5 -1
  5. package/dist/api.d.ts.map +1 -1
  6. package/dist/api.js +14 -2
  7. package/dist/internal/builtins.d.ts +17 -0
  8. package/dist/internal/builtins.d.ts.map +1 -1
  9. package/dist/internal/builtins.js +65 -1
  10. package/dist/observability.d.ts +1 -1
  11. package/dist/observability.js +2 -2
  12. package/dist/runtime.d.ts +1 -1
  13. package/dist/runtime.d.ts.map +1 -1
  14. package/dist/runtime.js +2 -2
  15. package/docs/ai/index.mdx +27 -23
  16. package/docs/api-reference/index.mdx +24 -0
  17. package/docs/api-reference/meta.json +8 -0
  18. package/docs/api-reference/vitest/index.mdx +28 -7
  19. package/docs/api-reference/workflow/create-hook.mdx +38 -0
  20. package/docs/api-reference/workflow/create-webhook.mdx +1 -0
  21. package/docs/api-reference/workflow/experimental-set-attributes.mdx +65 -0
  22. package/docs/api-reference/workflow/fetch.mdx +5 -0
  23. package/docs/api-reference/workflow/index.mdx +3 -0
  24. package/docs/api-reference/workflow-ai/durable-agent.mdx +7 -45
  25. package/docs/api-reference/workflow-api/get-hook-by-token.mdx +7 -0
  26. package/docs/api-reference/workflow-api/get-run.mdx +6 -0
  27. package/docs/api-reference/workflow-api/index.mdx +6 -8
  28. package/docs/api-reference/workflow-api/resume-hook.mdx +57 -0
  29. package/docs/api-reference/workflow-api/start.mdx +13 -5
  30. package/docs/api-reference/workflow-astro/index.mdx +18 -0
  31. package/docs/api-reference/workflow-astro/meta.json +4 -0
  32. package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
  33. package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
  34. package/docs/api-reference/workflow-errors/index.mdx +85 -0
  35. package/docs/api-reference/workflow-errors/meta.json +5 -0
  36. package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
  37. package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
  38. package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
  39. package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
  40. package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
  41. package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +33 -0
  42. package/docs/api-reference/workflow-nest/index.mdx +31 -0
  43. package/docs/api-reference/workflow-nest/meta.json +9 -0
  44. package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
  45. package/docs/api-reference/workflow-nest/workflow-controller.mdx +40 -0
  46. package/docs/api-reference/workflow-nest/workflow-module.mdx +74 -0
  47. package/docs/api-reference/workflow-next/with-workflow.mdx +34 -2
  48. package/docs/api-reference/workflow-nitro/index.mdx +59 -0
  49. package/docs/api-reference/workflow-nuxt/index.mdx +47 -0
  50. package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
  51. package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
  52. package/docs/api-reference/workflow-observability/index.mdx +64 -0
  53. package/docs/api-reference/workflow-observability/meta.json +11 -0
  54. package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
  55. package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
  56. package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
  57. package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
  58. package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
  59. package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
  60. package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +7 -10
  61. package/docs/api-reference/workflow-runtime/health-check.mdx +50 -0
  62. package/docs/api-reference/workflow-runtime/index.mdx +43 -0
  63. package/docs/api-reference/workflow-runtime/meta.json +12 -0
  64. package/docs/api-reference/workflow-runtime/set-world.mdx +49 -0
  65. package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +42 -0
  66. package/docs/api-reference/{workflow-api → workflow-runtime}/world/index.mdx +5 -8
  67. package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
  68. package/docs/api-reference/{workflow-api → workflow-runtime}/world/queue.mdx +2 -2
  69. package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +11 -4
  70. package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +2 -2
  71. package/docs/api-reference/workflow-serde/index.mdx +0 -1
  72. package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +1 -2
  73. package/docs/api-reference/workflow-serde/workflow-serialize.mdx +1 -2
  74. package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
  75. package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
  76. package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
  77. package/docs/api-reference/workflow-vite/index.mdx +18 -0
  78. package/docs/api-reference/workflow-vite/meta.json +4 -0
  79. package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
  80. package/docs/changelog/attributes-mvp.mdx +380 -0
  81. package/docs/changelog/eager-processing.mdx +269 -0
  82. package/docs/changelog/index.mdx +2 -1
  83. package/docs/changelog/lazy-event-creation.md +127 -0
  84. package/docs/changelog/meta.json +6 -1
  85. package/docs/changelog/resilient-start.mdx +31 -283
  86. package/docs/cookbook/advanced/child-workflows.mdx +315 -0
  87. package/docs/cookbook/advanced/meta.json +2 -3
  88. package/docs/cookbook/advanced/publishing-libraries.mdx +87 -29
  89. package/docs/cookbook/advanced/serializable-steps.mdx +17 -5
  90. package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
  91. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +156 -0
  92. package/docs/cookbook/agent-patterns/durable-agent.mdx +11 -184
  93. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +150 -173
  94. package/docs/cookbook/agent-patterns/meta.json +1 -7
  95. package/docs/cookbook/common-patterns/batching.mdx +44 -118
  96. package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
  97. package/docs/cookbook/common-patterns/meta.json +4 -4
  98. package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
  99. package/docs/cookbook/common-patterns/saga.mdx +128 -33
  100. package/docs/cookbook/common-patterns/scheduling.mdx +77 -193
  101. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
  102. package/docs/cookbook/common-patterns/timeouts.mdx +100 -0
  103. package/docs/cookbook/common-patterns/workflow-composition.mdx +117 -0
  104. package/docs/cookbook/index.mdx +14 -17
  105. package/docs/cookbook/integrations/ai-sdk.mdx +330 -142
  106. package/docs/cookbook/integrations/chat-sdk.mdx +264 -151
  107. package/docs/cookbook/integrations/sandbox.mdx +482 -81
  108. package/docs/cookbook/meta.json +1 -1
  109. package/docs/deploying/building-a-world.mdx +1 -1
  110. package/docs/deploying/world/postgres-world.mdx +5 -3
  111. package/docs/deploying/world/vercel-world.mdx +2 -0
  112. package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
  113. package/docs/errors/corrupted-event-log.mdx +5 -5
  114. package/docs/errors/hook-conflict.mdx +56 -4
  115. package/docs/errors/index.mdx +9 -0
  116. package/docs/errors/replay-divergence.mdx +27 -0
  117. package/docs/errors/runtime-decryption-failed.mdx +77 -0
  118. package/docs/errors/step-executed-multiple-times.mdx +23 -0
  119. package/docs/errors/step-not-registered.mdx +1 -1
  120. package/docs/foundations/cancellation.mdx +459 -0
  121. package/docs/foundations/errors-and-retries.mdx +7 -3
  122. package/docs/foundations/hooks.mdx +29 -0
  123. package/docs/foundations/idempotency.mdx +236 -11
  124. package/docs/foundations/index.mdx +3 -3
  125. package/docs/foundations/meta.json +3 -2
  126. package/docs/foundations/serialization.mdx +78 -42
  127. package/docs/foundations/starting-workflows.mdx +6 -2
  128. package/docs/foundations/streaming.mdx +14 -23
  129. package/docs/foundations/versioning.mdx +263 -0
  130. package/docs/getting-started/astro.mdx +6 -0
  131. package/docs/getting-started/index.mdx +6 -7
  132. package/docs/getting-started/meta.json +1 -0
  133. package/docs/getting-started/nestjs.mdx +9 -0
  134. package/docs/getting-started/next.mdx +5 -3
  135. package/docs/getting-started/nitro.mdx +22 -0
  136. package/docs/getting-started/sveltekit.mdx +6 -0
  137. package/docs/getting-started/tanstack-start.mdx +241 -0
  138. package/docs/how-it-works/cancellation.mdx +287 -0
  139. package/docs/how-it-works/code-transform.mdx +2 -2
  140. package/docs/how-it-works/encryption.mdx +2 -2
  141. package/docs/how-it-works/event-sourcing.mdx +2 -2
  142. package/docs/how-it-works/meta.json +2 -1
  143. package/docs/internal/index.mdx +21 -0
  144. package/docs/internal/meta.json +10 -0
  145. package/docs/internal/nitro-native-build.mdx +38 -0
  146. package/docs/internal/nitro-web-ui.mdx +24 -0
  147. package/docs/internal/serializable-abort-controller.mdx +148 -0
  148. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +63 -16
  149. package/docs/migration-guides/migrating-from-inngest.mdx +44 -22
  150. package/docs/migration-guides/migrating-from-temporal.mdx +43 -14
  151. package/docs/migration-guides/migrating-from-trigger-dev.mdx +59 -27
  152. package/docs/observability/attributes.mdx +87 -0
  153. package/docs/observability/index.mdx +25 -1
  154. package/docs/observability/meta.json +1 -1
  155. package/docs/observability/tracing.mdx +106 -0
  156. package/docs/testing/index.mdx +2 -2
  157. package/package.json +14 -13
  158. package/docs/api-reference/workflow-api/world/meta.json +0 -4
  159. package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
  160. package/docs/cookbook/advanced/custom-serialization.mdx +0 -168
  161. package/docs/cookbook/advanced/durable-objects.mdx +0 -148
  162. package/docs/cookbook/advanced/isomorphic-packages.mdx +0 -145
  163. package/docs/cookbook/agent-patterns/stop-workflow.mdx +0 -216
  164. package/docs/cookbook/agent-patterns/tool-orchestration.mdx +0 -255
  165. package/docs/cookbook/agent-patterns/tool-streaming.mdx +0 -181
  166. package/docs/cookbook/common-patterns/child-workflows.mdx +0 -372
  167. package/docs/cookbook/common-patterns/content-router.mdx +0 -207
  168. package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
  169. package/docs/foundations/common-patterns.mdx +0 -265
@@ -9,167 +9,93 @@ Use batching when you need to process a large list of items in parallel while co
9
9
 
10
10
  ## When to use this
11
11
 
12
- - Processing hundreds or thousands of items (orders, images, records)
12
+ - Bulk data imports (contacts, orders, products from a CSV)
13
+ - Processing hundreds or thousands of items against external APIs
13
14
  - Calling rate-limited APIs where you need to control concurrency
14
15
  - Any fan-out where you want failure isolation between groups
15
16
 
17
+ ## How it works
18
+
19
+ 1. Records are split into fixed-size batches.
20
+ 2. Each batch runs in parallel via `Promise.allSettled` — failures in one record don't affect others.
21
+ 3. A `sleep()` between batches paces requests to avoid overloading downstream services.
22
+ 4. After all batches, a summary is returned with succeeded/failed counts.
23
+
16
24
  ## Pattern
17
25
 
18
- The workflow splits items into chunks and processes each chunk with `Promise.allSettled()`. A `sleep()` between chunks prevents overloading downstream services.
26
+ The workflow splits records into chunks, processes each chunk concurrently, tracks results per batch, and returns a final tally.
19
27
 
20
28
  ```typescript
21
29
  import { sleep } from "workflow";
22
30
 
23
- declare function processItem(item: string): Promise<{ item: string; ok: boolean }>; // @setup
31
+ type Record = { name: string; email: string; role: string };
32
+
33
+ declare function processRecord(record: Record): Promise<string>; // @setup
24
34
 
25
- export async function processBatch(items: string[], batchSize: number = 5) {
35
+ export async function batchImport(records: Record[], batchSize: number) {
26
36
  "use workflow";
27
37
 
28
- const results = [];
38
+ let totalSucceeded = 0;
39
+ let totalFailed = 0;
29
40
 
30
- for (let i = 0; i < items.length; i += batchSize) {
31
- const batch = items.slice(i, i + batchSize);
41
+ for (let i = 0; i < records.length; i += batchSize) {
42
+ const batch = records.slice(i, i + batchSize);
32
43
 
33
- // Run batch in parallel -- failures are isolated
44
+ // Run batch in parallel failures are isolated per record
34
45
  const outcomes = await Promise.allSettled( // [!code highlight]
35
- batch.map((item) => processItem(item))
46
+ batch.map((record) => processRecord(record))
36
47
  );
37
48
 
38
49
  for (let j = 0; j < outcomes.length; j++) {
39
- const outcome = outcomes[j];
40
- results.push(
41
- outcome.status === "fulfilled"
42
- ? outcome.value
43
- : { item: batch[j], ok: false, error: String(outcome.reason) }
44
- );
50
+ if (outcomes[j].status === "fulfilled") {
51
+ totalSucceeded++;
52
+ } else {
53
+ totalFailed++;
54
+ }
45
55
  }
46
56
 
47
- // Pace between batches to avoid overload
48
- if (i + batchSize < items.length) {
57
+ // Pace between batches to avoid overloading downstream
58
+ if (i + batchSize < records.length) {
49
59
  await sleep("1s"); // [!code highlight]
50
60
  }
51
61
  }
52
62
 
53
- const succeeded = results.filter((r) => r.ok).length;
54
- return { total: results.length, succeeded, failed: results.length - succeeded };
63
+ return { total: records.length, succeeded: totalSucceeded, failed: totalFailed };
55
64
  }
56
65
  ```
57
66
 
58
67
  ### Step function
59
68
 
60
- Each item is processed in its own step, giving it full Node.js access and automatic retries.
69
+ Each record is processed in its own step with full Node.js access and automatic retries.
61
70
 
62
71
  ```typescript
63
- async function processItem(item: string): Promise<{ item: string; ok: boolean }> {
72
+ type Record = { name: string; email: string; role: string };
73
+
74
+ async function processRecord(record: Record): Promise<string> {
64
75
  "use step";
65
- const res = await fetch(`https://api.example.com/process`, {
76
+ const res = await fetch(`https://api.example.com/contacts`, {
66
77
  method: "POST",
67
- body: JSON.stringify({ item }),
78
+ body: JSON.stringify(record),
68
79
  });
69
- if (!res.ok) throw new Error(`Failed to process ${item}`);
70
- return { item, ok: true };
80
+ if (!res.ok) throw new Error(`Failed to import ${record.email}`);
81
+ const { id } = await res.json();
82
+ return id;
71
83
  }
72
84
  ```
73
85
 
74
- ## Variations
75
-
76
- ### Scatter-gather
77
-
78
- When you need results from multiple independent sources before continuing, fan out in parallel and collect all results:
79
-
80
- ```typescript
81
- export async function scatterGather(query: string) {
82
- "use workflow";
83
-
84
- const [web, database, cache] = await Promise.allSettled([ // [!code highlight]
85
- searchWeb(query),
86
- searchDatabase(query),
87
- searchCache(query),
88
- ]);
89
-
90
- return {
91
- web: web.status === "fulfilled" ? web.value : null,
92
- database: database.status === "fulfilled" ? database.value : null,
93
- cache: cache.status === "fulfilled" ? cache.value : null,
94
- };
95
- }
96
-
97
- async function searchWeb(query: string): Promise<string[]> {
98
- "use step";
99
- // Full Node.js access -- call external APIs
100
- const res = await fetch(`https://search.example.com?q=${query}`);
101
- return res.json();
102
- }
103
-
104
- async function searchDatabase(query: string): Promise<string[]> {
105
- "use step";
106
- // Query your database
107
- return [`db-result-for-${query}`];
108
- }
109
-
110
- async function searchCache(query: string): Promise<string[]> {
111
- "use step";
112
- return [`cached-result-for-${query}`];
113
- }
114
- ```
115
-
116
- ## In-step concurrency control
117
-
118
- When you need to process many items against a rate-limited API but want the entire operation to be a single atomic step, batch the work inside the step itself. This keeps the event log clean (one step instead of hundreds) while still controlling concurrency.
119
-
120
- ```typescript
121
- async function processConcurrently<T>(
122
- items: string[],
123
- processor: (item: string) => Promise<T>,
124
- maxConcurrent: number = 5,
125
- ): Promise<T[]> {
126
- "use step";
127
- const results: T[] = [];
128
-
129
- for (let i = 0; i < items.length; i += maxConcurrent) {
130
- const batch = items.slice(i, i + maxConcurrent);
131
- const batchResults = await Promise.all(batch.map(processor)); // [!code highlight]
132
- results.push(...batchResults);
133
- }
134
-
135
- return results;
136
- }
137
- ```
138
-
139
- Usage in a workflow:
140
-
141
- ```typescript
142
- declare function processConcurrently<T>(items: string[], processor: (item: string) => Promise<T>, maxConcurrent?: number): Promise<T[]>; // @setup
143
-
144
- export async function moderateImages(imageUrls: string[]) {
145
- "use workflow";
146
-
147
- const results = await processConcurrently(
148
- imageUrls,
149
- async (url) => {
150
- const res = await fetch("https://api.example.com/moderate", {
151
- method: "POST",
152
- body: JSON.stringify({ url }),
153
- });
154
- return res.json();
155
- },
156
- 3, // max 3 concurrent API calls
157
- );
158
-
159
- return { total: results.length, results };
160
- }
161
- ```
86
+ ## Adapting to your use case
162
87
 
163
- **When to use in-step batching vs workflow-level batching:**
164
- - **Workflow-level** (the pattern above): Each item is its own step with independent retries and failure isolation. Use when items are independent and individual failures should be retried.
165
- - **In-step**: All items are processed in one step. Use when the items are tightly coupled (e.g., moderating all thumbnails for a single video) or when you want to minimize step overhead for large item counts.
88
+ - Replace the `Record` type with your actual data shape (orders, images, products, etc.).
89
+ - Replace `processRecord()` with your real import logic DB upserts, API calls, file processing.
90
+ - Tune `batchSize` and the `sleep()` duration to match your downstream rate limits.
91
+ - Add or remove tracking as needed — the pattern works with any item type.
166
92
 
167
93
  ## Tips
168
94
 
169
95
  - **Use `Promise.allSettled` over `Promise.all`** when you want to continue even if some items fail. `Promise.all` rejects on the first failure; `allSettled` waits for everything and tells you what failed.
170
96
  - **Tune batch size to your downstream API limits.** If the API allows 10 concurrent requests, use `batchSize: 10`.
171
- - **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable -- it survives cold starts.
172
- - **Each `processItem` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch.
97
+ - **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable it survives cold starts.
98
+ - **Each `processRecord` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch.
173
99
 
174
100
  ## Key APIs
175
101
 
@@ -1,45 +1,25 @@
1
1
  ---
2
2
  title: Idempotency
3
- description: Ensure external side effects happen exactly once, even when steps are retried or workflows are replayed.
3
+ description: Make step retries safe and coordinate duplicate workflow starts with hook tokens.
4
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.
5
+ summary: Use step IDs for retry-safe external calls, and use deterministic hook tokens when duplicate requests must route to one active workflow.
6
6
  ---
7
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.
8
+ Use idempotency when a retry or duplicate request should not repeat the underlying work. In Workflow, there are two common patterns: use the step ID for retry-safe external calls, and use hook tokens to coordinate duplicate workflow starts.
9
9
 
10
10
  ## When to use this
11
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
12
+ - A step charges a payment, sends an email, enqueues work, or creates an external record.
13
+ - A route may receive duplicate requests that should map to one active workflow run.
16
14
 
17
- ## Pattern: Step ID as idempotency key
15
+ ## Step idempotency
18
16
 
19
17
  Every step has a unique, deterministic `stepId` available via `getStepMetadata()`. Pass this as the idempotency key to external APIs:
20
18
 
21
19
  ```typescript
22
20
  import { getStepMetadata } from "workflow";
23
21
 
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(
22
+ export async function createCharge(
43
23
  customerId: string,
44
24
  amount: number
45
25
  ): Promise<{ id: string }> {
@@ -69,39 +49,43 @@ async function createCharge(
69
49
 
70
50
  return charge.json();
71
51
  }
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
52
  ```
85
53
 
86
- ## Race condition caveats
54
+ See [Step Idempotency](/docs/foundations/idempotency#step-idempotency) for why `stepId` is stable across retries and how to think about external API conflicts.
87
55
 
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:
56
+ ## Run idempotency
89
57
 
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.
58
+ For duplicate workflow-start requests, derive a hook token from your domain key. You can avoid obvious duplicate starts by checking whether an active hook already owns that token before calling `start()`:
92
59
 
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).
60
+ ```typescript
61
+ import { getHookByToken, start } from "workflow/api";
62
+ import { HookNotFoundError } from "workflow/errors";
63
+ import { processOrder } from "./workflows/process-order";
64
+
65
+ export async function POST(request: Request) {
66
+ const { orderId } = await request.json();
67
+ const token = `order:${orderId}`;
68
+
69
+ try {
70
+ const hook = await getHookByToken(token); // [!code highlight]
71
+ return Response.json({ runId: hook.runId, reused: true });
72
+ } catch (error) {
73
+ if (!HookNotFoundError.is(error)) throw error;
74
+ }
94
75
 
95
- ## Tips
76
+ const run = await start(processOrder, [orderId]); // [!code highlight]
77
+ return Response.json({ runId: run.runId, reused: false });
78
+ }
79
+ ```
96
80
 
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.
81
+ The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work — awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration.
101
82
 
102
83
  ## Key APIs
103
84
 
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
85
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
86
+ - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
87
+ - [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
88
+ - [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token
89
+ - [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the active hook for a token
90
+ - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data
107
91
  - [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run
@@ -2,14 +2,14 @@
2
2
  "title": "Common Patterns",
3
3
  "defaultOpen": true,
4
4
  "pages": [
5
+ "sequential-and-parallel",
6
+ "workflow-composition",
5
7
  "saga",
6
8
  "batching",
7
9
  "rate-limiting",
8
- "fan-out",
9
10
  "scheduling",
11
+ "timeouts",
10
12
  "idempotency",
11
- "webhooks",
12
- "content-router",
13
- "child-workflows"
13
+ "webhooks"
14
14
  ]
15
15
  }
@@ -224,5 +224,5 @@ export async function downloadWithRetry(url: string) {
224
224
  - [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
225
225
  - [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
226
226
  - [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
227
- - [`getStepMetadata()`](/docs/api-reference/step/get-step-metadata) -- provides the current attempt number and step ID
227
+ - [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the current attempt number and step ID
228
228
  - [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns
@@ -13,6 +13,12 @@ Use the saga pattern when a business transaction spans multiple services and you
13
13
  - Any sequence where partial completion leaves the system in an inconsistent state
14
14
  - Operations that need "all or nothing" semantics across external APIs
15
15
 
16
+ ## How it works
17
+
18
+ 1. Each forward step does work and registers a compensation function.
19
+ 2. If any step throws `FatalError`, the catch block runs compensations in reverse (LIFO) order to restore consistency.
20
+ 3. Regular errors are retried automatically (up to 3x by default). Use `FatalError` only for permanent failures where retrying won't help.
21
+
16
22
  ## Pattern
17
23
 
18
24
  Each step returns a result and pushes a compensation handler onto a stack. If a later step throws a `FatalError`, the workflow catches it and executes compensations in LIFO order.
@@ -34,23 +40,21 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
34
40
  const compensations: Array<() => Promise<void>> = [];
35
41
 
36
42
  try {
37
- // Step 1: Reserve seats
38
43
  const reservationId = await reserveSeats(accountId, seats);
39
44
  compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight]
40
45
 
41
- // Step 2: Capture payment
42
46
  const invoiceId = await captureInvoice(accountId, seats);
43
47
  compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight]
44
48
 
45
- // Step 3: Provision access
46
49
  const entitlementId = await provisionSeats(accountId, seats);
47
50
  compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight]
48
51
 
49
- // Step 4: Notify
52
+ // No compensation — notifications are fire-and-forget
50
53
  await sendConfirmation(accountId, invoiceId, entitlementId);
54
+
51
55
  return { status: "completed" };
52
56
  } catch (error) {
53
- // Unwind compensations in reverse order
57
+ // Unwind compensations in reverse (LIFO) order
54
58
  for (const compensate of compensations.reverse()) { // [!code highlight]
55
59
  await compensate(); // [!code highlight]
56
60
  }
@@ -62,11 +66,13 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
62
66
 
63
67
  ### Step functions
64
68
 
65
- Each step is a `"use step"` function with full Node.js access. Forward steps do the work; compensation steps undo it.
69
+ Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent — safe to call multiple times if the workflow restarts mid-rollback.
66
70
 
67
71
  ```typescript
68
72
  import { FatalError } from "workflow";
69
73
 
74
+ // Forward steps
75
+
70
76
  async function reserveSeats(accountId: string, seats: number): Promise<string> {
71
77
  "use step";
72
78
  const res = await fetch(`https://api.example.com/seats/reserve`, {
@@ -78,15 +84,6 @@ async function reserveSeats(accountId: string, seats: number): Promise<string> {
78
84
  return reservationId;
79
85
  }
80
86
 
81
- async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
82
- "use step";
83
- // Compensations should be idempotent — safe to call twice
84
- await fetch(`https://api.example.com/seats/release`, {
85
- method: "POST",
86
- body: JSON.stringify({ accountId, reservationId }),
87
- });
88
- }
89
-
90
87
  async function captureInvoice(accountId: string, seats: number): Promise<string> {
91
88
  "use step";
92
89
  const res = await fetch(`https://api.example.com/invoices`, {
@@ -98,14 +95,6 @@ async function captureInvoice(accountId: string, seats: number): Promise<string>
98
95
  return invoiceId;
99
96
  }
100
97
 
101
- async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
102
- "use step";
103
- await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
104
- method: "POST",
105
- body: JSON.stringify({ accountId }),
106
- });
107
- }
108
-
109
98
  async function provisionSeats(accountId: string, seats: number): Promise<string> {
110
99
  "use step";
111
100
  const res = await fetch(`https://api.example.com/entitlements`, {
@@ -117,14 +106,6 @@ async function provisionSeats(accountId: string, seats: number): Promise<string>
117
106
  return entitlementId;
118
107
  }
119
108
 
120
- async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
121
- "use step";
122
- await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
123
- method: "DELETE",
124
- body: JSON.stringify({ accountId }),
125
- });
126
- }
127
-
128
109
  async function sendConfirmation(
129
110
  accountId: string,
130
111
  invoiceId: string,
@@ -136,17 +117,131 @@ async function sendConfirmation(
136
117
  body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }),
137
118
  });
138
119
  }
120
+
121
+ // Compensation steps — must be idempotent
122
+
123
+ async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
124
+ "use step";
125
+ await fetch(`https://api.example.com/seats/release`, {
126
+ method: "POST",
127
+ body: JSON.stringify({ accountId, reservationId }),
128
+ });
129
+ }
130
+
131
+ async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
132
+ "use step";
133
+ await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
134
+ method: "POST",
135
+ body: JSON.stringify({ accountId }),
136
+ });
137
+ }
138
+
139
+ async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
140
+ "use step";
141
+ await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
142
+ method: "DELETE",
143
+ body: JSON.stringify({ accountId }),
144
+ });
145
+ }
146
+ ```
147
+
148
+ ### Streaming step progress (optional)
149
+
150
+ Use `getWritable()` to stream progress events to a UI so users can see each step execute in real time.
151
+
152
+ ```typescript
153
+ import { FatalError } from "workflow";
154
+ import { getWritable } from "workflow";
155
+
156
+ type SagaEvent =
157
+ | { type: "step_start"; step: string }
158
+ | { type: "step_done"; step: string; detail: string }
159
+ | { type: "step_failed"; step: string; error: string }
160
+ | { type: "compensating"; step: string }
161
+ | { type: "compensated"; step: string }
162
+ | { type: "result"; status: "completed" | "rolled_back" };
163
+
164
+ async function emit(event: SagaEvent) {
165
+ "use step";
166
+ const writer = getWritable<SagaEvent>().getWriter();
167
+ try {
168
+ await writer.write(event);
169
+ } finally {
170
+ writer.releaseLock();
171
+ }
172
+ }
173
+
174
+ declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup
175
+ declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup
176
+ declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup
177
+ declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup
178
+ declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup
179
+ declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup
180
+ declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup
181
+
182
+ export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
183
+ "use workflow";
184
+
185
+ const compensations: Array<{ name: string; execute: () => Promise<void> }> = [];
186
+
187
+ try {
188
+ await emit({ type: "step_start", step: "Reserve Seats" });
189
+ const reservationId = await reserveSeats(accountId, seats);
190
+ compensations.push({ name: "Release Seats", execute: () => releaseSeats(accountId, reservationId) });
191
+ await emit({ type: "step_done", step: "Reserve Seats", detail: reservationId });
192
+
193
+ await emit({ type: "step_start", step: "Capture Invoice" });
194
+ const invoiceId = await captureInvoice(accountId, seats);
195
+ compensations.push({ name: "Refund Invoice", execute: () => refundInvoice(accountId, invoiceId) });
196
+ await emit({ type: "step_done", step: "Capture Invoice", detail: invoiceId });
197
+
198
+ await emit({ type: "step_start", step: "Provision Seats" });
199
+ const entitlementId = await provisionSeats(accountId, seats);
200
+ compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) });
201
+ await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId });
202
+
203
+ // No compensation — notifications are fire-and-forget
204
+ await emit({ type: "step_start", step: "Send Confirmation" });
205
+ await sendConfirmation(accountId, invoiceId, entitlementId);
206
+ await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" });
207
+
208
+ await emit({ type: "result", status: "completed" });
209
+ return { status: "completed" };
210
+ } catch (error) {
211
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
212
+ await emit({ type: "step_failed", step: "failed", error: errorMessage });
213
+
214
+ // Unwind compensations in reverse (LIFO) order
215
+ for (const comp of compensations.reverse()) {
216
+ await emit({ type: "compensating", step: comp.name });
217
+ await comp.execute();
218
+ await emit({ type: "compensated", step: comp.name });
219
+ }
220
+
221
+ await emit({ type: "result", status: "rolled_back" });
222
+ return { status: "rolled_back" };
223
+ }
224
+ }
139
225
  ```
140
226
 
227
+ ## Adapting to your use case
228
+
229
+ - Replace the step functions with real API calls. Each `"use step"` function has full Node.js access.
230
+ - Add or remove steps as needed — the pattern scales to any number of steps.
231
+ - Make compensations idempotent — they may be retried if the workflow restarts mid-rollback.
232
+ - The `emit()` calls and `SagaEvent` type are optional — remove them if you don't need real-time UI progress.
233
+
141
234
  ## Tips
142
235
 
143
236
  - **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input).
144
237
  - **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again.
145
238
  - **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off.
146
239
  - **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state.
240
+ - **Notifications don't need compensations.** Fire-and-forget steps like sending emails or Slack messages typically don't register a compensation.
147
241
 
148
242
  ## Key APIs
149
243
 
150
- - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
151
- - [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
244
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
245
+ - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
152
246
  - [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
247
+ - [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates