workflow 5.0.0-beta.2 → 5.0.0-beta.21

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 (170) 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 +56 -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 +7 -1
  85. package/docs/changelog/resilient-start.mdx +31 -283
  86. package/docs/changelog/turbo-mode.md +87 -0
  87. package/docs/cookbook/advanced/child-workflows.mdx +315 -0
  88. package/docs/cookbook/advanced/meta.json +2 -3
  89. package/docs/cookbook/advanced/publishing-libraries.mdx +87 -29
  90. package/docs/cookbook/advanced/serializable-steps.mdx +17 -5
  91. package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
  92. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +156 -0
  93. package/docs/cookbook/agent-patterns/durable-agent.mdx +11 -184
  94. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +150 -173
  95. package/docs/cookbook/agent-patterns/meta.json +1 -7
  96. package/docs/cookbook/common-patterns/batching.mdx +44 -118
  97. package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
  98. package/docs/cookbook/common-patterns/meta.json +4 -4
  99. package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
  100. package/docs/cookbook/common-patterns/saga.mdx +128 -33
  101. package/docs/cookbook/common-patterns/scheduling.mdx +77 -193
  102. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
  103. package/docs/cookbook/common-patterns/timeouts.mdx +100 -0
  104. package/docs/cookbook/common-patterns/workflow-composition.mdx +117 -0
  105. package/docs/cookbook/index.mdx +14 -17
  106. package/docs/cookbook/integrations/ai-sdk.mdx +330 -142
  107. package/docs/cookbook/integrations/chat-sdk.mdx +264 -151
  108. package/docs/cookbook/integrations/sandbox.mdx +482 -81
  109. package/docs/cookbook/meta.json +1 -1
  110. package/docs/deploying/building-a-world.mdx +1 -1
  111. package/docs/deploying/world/postgres-world.mdx +5 -3
  112. package/docs/deploying/world/vercel-world.mdx +2 -0
  113. package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
  114. package/docs/errors/corrupted-event-log.mdx +5 -5
  115. package/docs/errors/hook-conflict.mdx +56 -4
  116. package/docs/errors/index.mdx +9 -0
  117. package/docs/errors/replay-divergence.mdx +27 -0
  118. package/docs/errors/runtime-decryption-failed.mdx +77 -0
  119. package/docs/errors/step-executed-multiple-times.mdx +23 -0
  120. package/docs/errors/step-not-registered.mdx +1 -1
  121. package/docs/foundations/cancellation.mdx +459 -0
  122. package/docs/foundations/errors-and-retries.mdx +7 -3
  123. package/docs/foundations/hooks.mdx +29 -0
  124. package/docs/foundations/idempotency.mdx +236 -11
  125. package/docs/foundations/index.mdx +3 -3
  126. package/docs/foundations/meta.json +3 -2
  127. package/docs/foundations/serialization.mdx +78 -42
  128. package/docs/foundations/starting-workflows.mdx +6 -2
  129. package/docs/foundations/streaming.mdx +14 -23
  130. package/docs/foundations/versioning.mdx +263 -0
  131. package/docs/getting-started/astro.mdx +6 -0
  132. package/docs/getting-started/index.mdx +6 -7
  133. package/docs/getting-started/meta.json +1 -0
  134. package/docs/getting-started/nestjs.mdx +9 -0
  135. package/docs/getting-started/next.mdx +5 -3
  136. package/docs/getting-started/nitro.mdx +22 -0
  137. package/docs/getting-started/sveltekit.mdx +6 -0
  138. package/docs/getting-started/tanstack-start.mdx +241 -0
  139. package/docs/how-it-works/cancellation.mdx +287 -0
  140. package/docs/how-it-works/code-transform.mdx +2 -2
  141. package/docs/how-it-works/encryption.mdx +2 -2
  142. package/docs/how-it-works/event-sourcing.mdx +2 -2
  143. package/docs/how-it-works/meta.json +2 -1
  144. package/docs/internal/index.mdx +21 -0
  145. package/docs/internal/meta.json +10 -0
  146. package/docs/internal/nitro-native-build.mdx +38 -0
  147. package/docs/internal/nitro-web-ui.mdx +24 -0
  148. package/docs/internal/serializable-abort-controller.mdx +148 -0
  149. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +63 -16
  150. package/docs/migration-guides/migrating-from-inngest.mdx +44 -22
  151. package/docs/migration-guides/migrating-from-temporal.mdx +43 -14
  152. package/docs/migration-guides/migrating-from-trigger-dev.mdx +59 -27
  153. package/docs/observability/attributes.mdx +87 -0
  154. package/docs/observability/index.mdx +25 -1
  155. package/docs/observability/meta.json +1 -1
  156. package/docs/observability/tracing.mdx +106 -0
  157. package/docs/testing/index.mdx +2 -2
  158. package/package.json +14 -13
  159. package/docs/api-reference/workflow-api/world/meta.json +0 -4
  160. package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
  161. package/docs/cookbook/advanced/custom-serialization.mdx +0 -168
  162. package/docs/cookbook/advanced/durable-objects.mdx +0 -148
  163. package/docs/cookbook/advanced/isomorphic-packages.mdx +0 -145
  164. package/docs/cookbook/agent-patterns/stop-workflow.mdx +0 -216
  165. package/docs/cookbook/agent-patterns/tool-orchestration.mdx +0 -255
  166. package/docs/cookbook/agent-patterns/tool-streaming.mdx +0 -181
  167. package/docs/cookbook/common-patterns/child-workflows.mdx +0 -372
  168. package/docs/cookbook/common-patterns/content-router.mdx +0 -207
  169. package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
  170. package/docs/foundations/common-patterns.mdx +0 -265
@@ -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