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.
- package/dist/api-workflow.d.ts +1 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -2
- package/dist/api.d.ts +5 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +14 -2
- package/dist/internal/builtins.d.ts +17 -0
- package/dist/internal/builtins.d.ts.map +1 -1
- package/dist/internal/builtins.js +65 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +2 -2
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -2
- package/docs/ai/index.mdx +27 -23
- package/docs/api-reference/index.mdx +24 -0
- package/docs/api-reference/meta.json +8 -0
- package/docs/api-reference/vitest/index.mdx +28 -7
- package/docs/api-reference/workflow/create-hook.mdx +38 -0
- package/docs/api-reference/workflow/create-webhook.mdx +1 -0
- package/docs/api-reference/workflow/experimental-set-attributes.mdx +65 -0
- package/docs/api-reference/workflow/fetch.mdx +5 -0
- package/docs/api-reference/workflow/index.mdx +3 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +7 -45
- package/docs/api-reference/workflow-api/get-hook-by-token.mdx +7 -0
- package/docs/api-reference/workflow-api/get-run.mdx +6 -0
- package/docs/api-reference/workflow-api/index.mdx +6 -8
- package/docs/api-reference/workflow-api/resume-hook.mdx +57 -0
- package/docs/api-reference/workflow-api/start.mdx +13 -5
- package/docs/api-reference/workflow-astro/index.mdx +18 -0
- package/docs/api-reference/workflow-astro/meta.json +4 -0
- package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
- package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
- package/docs/api-reference/workflow-errors/index.mdx +85 -0
- package/docs/api-reference/workflow-errors/meta.json +5 -0
- package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
- package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
- package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +33 -0
- package/docs/api-reference/workflow-nest/index.mdx +31 -0
- package/docs/api-reference/workflow-nest/meta.json +9 -0
- package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
- package/docs/api-reference/workflow-nest/workflow-controller.mdx +40 -0
- package/docs/api-reference/workflow-nest/workflow-module.mdx +74 -0
- package/docs/api-reference/workflow-next/with-workflow.mdx +56 -2
- package/docs/api-reference/workflow-nitro/index.mdx +59 -0
- package/docs/api-reference/workflow-nuxt/index.mdx +47 -0
- package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
- package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
- package/docs/api-reference/workflow-observability/index.mdx +64 -0
- package/docs/api-reference/workflow-observability/meta.json +11 -0
- package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
- package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
- package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
- package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
- package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
- package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +7 -10
- package/docs/api-reference/workflow-runtime/health-check.mdx +50 -0
- package/docs/api-reference/workflow-runtime/index.mdx +43 -0
- package/docs/api-reference/workflow-runtime/meta.json +12 -0
- package/docs/api-reference/workflow-runtime/set-world.mdx +49 -0
- package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +42 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/index.mdx +5 -8
- package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/queue.mdx +2 -2
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +11 -4
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +2 -2
- package/docs/api-reference/workflow-serde/index.mdx +0 -1
- package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +1 -2
- package/docs/api-reference/workflow-serde/workflow-serialize.mdx +1 -2
- package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
- package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
- package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
- package/docs/api-reference/workflow-vite/index.mdx +18 -0
- package/docs/api-reference/workflow-vite/meta.json +4 -0
- package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
- package/docs/changelog/attributes-mvp.mdx +380 -0
- package/docs/changelog/eager-processing.mdx +269 -0
- package/docs/changelog/index.mdx +2 -1
- package/docs/changelog/lazy-event-creation.md +127 -0
- package/docs/changelog/meta.json +7 -1
- package/docs/changelog/resilient-start.mdx +31 -283
- package/docs/changelog/turbo-mode.md +87 -0
- package/docs/cookbook/advanced/child-workflows.mdx +315 -0
- package/docs/cookbook/advanced/meta.json +2 -3
- package/docs/cookbook/advanced/publishing-libraries.mdx +87 -29
- package/docs/cookbook/advanced/serializable-steps.mdx +17 -5
- package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +156 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +11 -184
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +150 -173
- package/docs/cookbook/agent-patterns/meta.json +1 -7
- package/docs/cookbook/common-patterns/batching.mdx +44 -118
- package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
- package/docs/cookbook/common-patterns/meta.json +4 -4
- package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
- package/docs/cookbook/common-patterns/saga.mdx +128 -33
- package/docs/cookbook/common-patterns/scheduling.mdx +77 -193
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +100 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +117 -0
- package/docs/cookbook/index.mdx +14 -17
- package/docs/cookbook/integrations/ai-sdk.mdx +330 -142
- package/docs/cookbook/integrations/chat-sdk.mdx +264 -151
- package/docs/cookbook/integrations/sandbox.mdx +482 -81
- package/docs/cookbook/meta.json +1 -1
- package/docs/deploying/building-a-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +5 -3
- package/docs/deploying/world/vercel-world.mdx +2 -0
- package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
- package/docs/errors/corrupted-event-log.mdx +5 -5
- package/docs/errors/hook-conflict.mdx +56 -4
- package/docs/errors/index.mdx +9 -0
- package/docs/errors/replay-divergence.mdx +27 -0
- package/docs/errors/runtime-decryption-failed.mdx +77 -0
- package/docs/errors/step-executed-multiple-times.mdx +23 -0
- package/docs/errors/step-not-registered.mdx +1 -1
- package/docs/foundations/cancellation.mdx +459 -0
- package/docs/foundations/errors-and-retries.mdx +7 -3
- package/docs/foundations/hooks.mdx +29 -0
- package/docs/foundations/idempotency.mdx +236 -11
- package/docs/foundations/index.mdx +3 -3
- package/docs/foundations/meta.json +3 -2
- package/docs/foundations/serialization.mdx +78 -42
- package/docs/foundations/starting-workflows.mdx +6 -2
- package/docs/foundations/streaming.mdx +14 -23
- package/docs/foundations/versioning.mdx +263 -0
- package/docs/getting-started/astro.mdx +6 -0
- package/docs/getting-started/index.mdx +6 -7
- package/docs/getting-started/meta.json +1 -0
- package/docs/getting-started/nestjs.mdx +9 -0
- package/docs/getting-started/next.mdx +5 -3
- package/docs/getting-started/nitro.mdx +22 -0
- package/docs/getting-started/sveltekit.mdx +6 -0
- package/docs/getting-started/tanstack-start.mdx +241 -0
- package/docs/how-it-works/cancellation.mdx +287 -0
- package/docs/how-it-works/code-transform.mdx +2 -2
- package/docs/how-it-works/encryption.mdx +2 -2
- package/docs/how-it-works/event-sourcing.mdx +2 -2
- package/docs/how-it-works/meta.json +2 -1
- package/docs/internal/index.mdx +21 -0
- package/docs/internal/meta.json +10 -0
- package/docs/internal/nitro-native-build.mdx +38 -0
- package/docs/internal/nitro-web-ui.mdx +24 -0
- package/docs/internal/serializable-abort-controller.mdx +148 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +63 -16
- package/docs/migration-guides/migrating-from-inngest.mdx +44 -22
- package/docs/migration-guides/migrating-from-temporal.mdx +43 -14
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +59 -27
- package/docs/observability/attributes.mdx +87 -0
- package/docs/observability/index.mdx +25 -1
- package/docs/observability/meta.json +1 -1
- package/docs/observability/tracing.mdx +106 -0
- package/docs/testing/index.mdx +2 -2
- package/package.json +14 -13
- package/docs/api-reference/workflow-api/world/meta.json +0 -4
- package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
- package/docs/cookbook/advanced/custom-serialization.mdx +0 -168
- package/docs/cookbook/advanced/durable-objects.mdx +0 -148
- package/docs/cookbook/advanced/isomorphic-packages.mdx +0 -145
- package/docs/cookbook/agent-patterns/stop-workflow.mdx +0 -216
- package/docs/cookbook/agent-patterns/tool-orchestration.mdx +0 -255
- package/docs/cookbook/agent-patterns/tool-streaming.mdx +0 -181
- package/docs/cookbook/common-patterns/child-workflows.mdx +0 -372
- package/docs/cookbook/common-patterns/content-router.mdx +0 -207
- package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
- package/docs/foundations/common-patterns.mdx +0 -265
|
@@ -1,45 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Idempotency
|
|
3
|
-
description:
|
|
3
|
+
description: Make step retries safe and coordinate duplicate workflow starts with hook tokens.
|
|
4
4
|
type: guide
|
|
5
|
-
summary: Use step IDs
|
|
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
|
-
|
|
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
|
-
-
|
|
13
|
-
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
## Run idempotency
|
|
89
57
|
|
|
90
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
const run = await start(processOrder, [orderId]); // [!code highlight]
|
|
77
|
+
return Response.json({ runId: run.runId, reused: false });
|
|
78
|
+
}
|
|
79
|
+
```
|
|
96
80
|
|
|
97
|
-
-
|
|
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/
|
|
105
|
-
- [`"use step"`](/docs/
|
|
106
|
-
- [`getStepMetadata()`](/docs/api-reference/
|
|
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/
|
|
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
|
-
//
|
|
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/
|
|
151
|
-
- [`"use step"`](/docs/
|
|
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
|