workflow 5.0.0-beta.2 → 5.0.0-beta.4
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/docs/cookbook/{common-patterns → advanced}/child-workflows.mdx +1 -1
- package/docs/cookbook/advanced/distributed-abort-controller.mdx +318 -0
- package/docs/cookbook/advanced/meta.json +2 -3
- package/docs/cookbook/advanced/publishing-libraries.mdx +83 -26
- package/docs/cookbook/advanced/serializable-steps.mdx +15 -3
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +205 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +50 -91
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +148 -171
- package/docs/cookbook/agent-patterns/meta.json +1 -7
- package/docs/cookbook/common-patterns/batching.mdx +44 -118
- package/docs/cookbook/common-patterns/meta.json +4 -4
- package/docs/cookbook/common-patterns/saga.mdx +126 -31
- package/docs/cookbook/common-patterns/scheduling.mdx +70 -194
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +118 -0
- package/docs/cookbook/index.mdx +13 -16
- package/docs/cookbook/integrations/ai-sdk.mdx +296 -140
- package/docs/cookbook/integrations/chat-sdk.mdx +251 -151
- package/docs/cookbook/integrations/sandbox.mdx +469 -81
- package/docs/cookbook/meta.json +1 -1
- package/docs/foundations/index.mdx +0 -3
- package/docs/foundations/meta.json +0 -1
- package/docs/foundations/serialization.mdx +1 -1
- package/docs/foundations/starting-workflows.mdx +1 -1
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +60 -8
- package/docs/migration-guides/migrating-from-inngest.mdx +38 -6
- package/docs/migration-guides/migrating-from-temporal.mdx +38 -4
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +52 -11
- package/package.json +11 -11
- 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/content-router.mdx +0 -207
- package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
- package/docs/foundations/common-patterns.mdx +0 -265
|
@@ -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
244
|
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
|
|
151
245
|
- [`"use step"`](/docs/api-reference/workflow/use-step) -- 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
|
|
@@ -2,248 +2,124 @@
|
|
|
2
2
|
title: Sleep, Scheduling & Timed Workflows
|
|
3
3
|
description: Use durable sleep to schedule actions minutes, hours, days, or weeks into the future.
|
|
4
4
|
type: guide
|
|
5
|
-
summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events
|
|
5
|
+
summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events cancel the workflow early.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
Workflow's `sleep()` is durable
|
|
8
|
+
Workflow's `sleep()` is durable — it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences.
|
|
9
9
|
|
|
10
10
|
## When to use this
|
|
11
11
|
|
|
12
|
-
- Sending emails on a schedule (drip campaigns,
|
|
13
|
-
- Waiting for a deadline
|
|
14
|
-
- Any pattern where "do X, wait N hours, then do Y" needs to be reliable
|
|
12
|
+
- Sending emails on a schedule (drip campaigns, onboarding sequences, reminders)
|
|
13
|
+
- Waiting for a deadline but allowing early cancellation
|
|
14
|
+
- Any pattern where "do X, wait N hours, then do Y" needs to be both reliable and interruptible
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Drip campaign with cancellation
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
A drip campaign sends emails at intervals, sleeping between each. Each sleep races against a cancellation hook — if an external event fires the hook (e.g. user converts, unsubscribes), the campaign stops immediately.
|
|
19
19
|
|
|
20
20
|
```typescript
|
|
21
|
-
import { sleep } from "workflow";
|
|
22
|
-
|
|
23
|
-
export async function onboardingDrip(email: string) {
|
|
24
|
-
"use workflow";
|
|
25
|
-
|
|
26
|
-
await sendEmail(email, "welcome");
|
|
27
|
-
|
|
28
|
-
await sleep("1d"); // [!code highlight]
|
|
29
|
-
await sendEmail(email, "getting-started-tips");
|
|
30
|
-
|
|
31
|
-
await sleep("2d"); // [!code highlight]
|
|
32
|
-
await sendEmail(email, "feature-highlights");
|
|
21
|
+
import { defineHook, sleep } from "workflow";
|
|
33
22
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return { email, status: "completed", totalDays: 7 };
|
|
38
|
-
}
|
|
23
|
+
// Hook that any API route can fire to cancel the drip
|
|
24
|
+
export const cancelDrip = defineHook<{ reason?: string }>(); // [!code highlight]
|
|
39
25
|
|
|
40
26
|
async function sendEmail(email: string, template: string): Promise<void> {
|
|
41
27
|
"use step";
|
|
42
28
|
await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
43
29
|
method: "POST",
|
|
44
30
|
headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
|
|
45
|
-
body: JSON.stringify({
|
|
46
|
-
to: [{ email }],
|
|
47
|
-
template_id: template,
|
|
48
|
-
}),
|
|
31
|
+
body: JSON.stringify({ to: [{ email }], template_id: template }),
|
|
49
32
|
});
|
|
50
33
|
}
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
## Pattern: Interruptible reminder (sleep vs hook)
|
|
54
|
-
|
|
55
|
-
Race a `sleep()` against a `defineHook` so external events can cancel, snooze, or send early:
|
|
56
|
-
|
|
57
|
-
```typescript
|
|
58
|
-
import { defineHook, sleep } from "workflow";
|
|
59
|
-
|
|
60
|
-
type ReminderAction =
|
|
61
|
-
| { type: "cancel" }
|
|
62
|
-
| { type: "send_now" }
|
|
63
|
-
| { type: "snooze"; seconds: number };
|
|
64
|
-
|
|
65
|
-
export const reminderActionHook = defineHook<ReminderAction>();
|
|
66
34
|
|
|
67
|
-
export async function
|
|
35
|
+
export async function emailSequence(email: string) {
|
|
68
36
|
"use workflow";
|
|
69
37
|
|
|
70
|
-
|
|
71
|
-
const action = reminderActionHook.create({ token: `reminder:${userId}` });
|
|
72
|
-
|
|
73
|
-
const outcome = await Promise.race([ // [!code highlight]
|
|
74
|
-
sleep(sendAt).then(() => ({ kind: "time" as const })), // [!code highlight]
|
|
75
|
-
action.then((payload) => ({ kind: "action" as const, payload })), // [!code highlight]
|
|
76
|
-
]);
|
|
77
|
-
|
|
78
|
-
if (outcome.kind === "action") {
|
|
79
|
-
if (outcome.payload.type === "cancel") {
|
|
80
|
-
return { userId, status: "cancelled" };
|
|
81
|
-
}
|
|
82
|
-
if (outcome.payload.type === "snooze") {
|
|
83
|
-
sendAt = new Date(Date.now() + outcome.payload.seconds * 1000);
|
|
84
|
-
await sleep(sendAt);
|
|
85
|
-
}
|
|
86
|
-
// "send_now" falls through to send immediately
|
|
87
|
-
}
|
|
38
|
+
await sendEmail(email, "welcome");
|
|
88
39
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
40
|
+
// Race durable sleep against the cancellation hook
|
|
41
|
+
const hook = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
|
|
42
|
+
const cancelled = await Promise.race([ // [!code highlight]
|
|
43
|
+
sleep("2d").then(() => false), // [!code highlight]
|
|
44
|
+
hook.then(() => true), // [!code highlight]
|
|
45
|
+
]); // [!code highlight]
|
|
46
|
+
if (cancelled) return { status: "cancelled", email };
|
|
92
47
|
|
|
93
|
-
|
|
94
|
-
"use step";
|
|
95
|
-
await fetch("https://api.example.com/reminders/send", {
|
|
96
|
-
method: "POST",
|
|
97
|
-
body: JSON.stringify({ userId }),
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
```
|
|
48
|
+
await sendEmail(email, "getting-started-tips");
|
|
101
49
|
|
|
102
|
-
|
|
50
|
+
// Create a fresh hook for the next sleep window
|
|
51
|
+
const hook2 = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
|
|
52
|
+
const cancelled2 = await Promise.race([ // [!code highlight]
|
|
53
|
+
sleep("2d").then(() => false), // [!code highlight]
|
|
54
|
+
hook2.then(() => true), // [!code highlight]
|
|
55
|
+
]); // [!code highlight]
|
|
56
|
+
if (cancelled2) return { status: "cancelled", email };
|
|
103
57
|
|
|
104
|
-
|
|
105
|
-
import { resumeHook } from "workflow/api";
|
|
58
|
+
await sendEmail(email, "feature-highlights");
|
|
106
59
|
|
|
107
|
-
|
|
108
|
-
export async function POST(request: Request) {
|
|
109
|
-
const { userId } = await request.json();
|
|
110
|
-
await resumeHook(`reminder:${userId}`, { type: "cancel" }); // [!code highlight]
|
|
111
|
-
return Response.json({ ok: true });
|
|
60
|
+
return { status: "drip-complete", email };
|
|
112
61
|
}
|
|
113
62
|
```
|
|
114
63
|
|
|
115
|
-
|
|
64
|
+
### Cancelling from an API route
|
|
116
65
|
|
|
117
|
-
|
|
66
|
+
Any server-side code can fire the hook by calling `.resume()` with the same token:
|
|
118
67
|
|
|
119
68
|
```typescript
|
|
120
|
-
import {
|
|
121
|
-
|
|
122
|
-
type EventPayload = { type: string; message: string };
|
|
123
|
-
|
|
124
|
-
export const digestEvent = defineHook<EventPayload>();
|
|
125
|
-
|
|
126
|
-
export async function collectAndSendDigest(
|
|
127
|
-
digestId: string,
|
|
128
|
-
userId: string,
|
|
129
|
-
windowMs: number = 3_600_000
|
|
130
|
-
) {
|
|
131
|
-
"use workflow";
|
|
69
|
+
import { cancelDrip } from "@/workflows/email-sequence";
|
|
132
70
|
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
kind: "window_closed" as const,
|
|
136
|
-
}));
|
|
137
|
-
const events: EventPayload[] = [];
|
|
71
|
+
export async function POST(req: Request) {
|
|
72
|
+
const { email, reason } = await req.json();
|
|
138
73
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
hook.then((payload) => ({ kind: "event" as const, payload })),
|
|
142
|
-
windowClosed,
|
|
143
|
-
]);
|
|
144
|
-
|
|
145
|
-
if (outcome.kind === "window_closed") break; // [!code highlight]
|
|
146
|
-
events.push(outcome.payload);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (events.length > 0) {
|
|
150
|
-
await sendDigestEmail(userId, events);
|
|
74
|
+
if (!email) {
|
|
75
|
+
return Response.json({ error: "email is required" }, { status: 400 });
|
|
151
76
|
}
|
|
152
77
|
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
Add a timeout to any operation by racing it against `sleep()`:
|
|
168
|
-
|
|
169
|
-
```typescript
|
|
170
|
-
import { sleep, FatalError } from "workflow";
|
|
171
|
-
|
|
172
|
-
export async function processWithTimeout(jobId: string) {
|
|
173
|
-
"use workflow";
|
|
174
|
-
|
|
175
|
-
const result = await Promise.race([ // [!code highlight]
|
|
176
|
-
processData(jobId),
|
|
177
|
-
sleep("30s").then(() => "timeout" as const), // [!code highlight]
|
|
178
|
-
]);
|
|
179
|
-
|
|
180
|
-
if (result === "timeout") {
|
|
181
|
-
return { jobId, status: "timed_out" };
|
|
78
|
+
try {
|
|
79
|
+
await cancelDrip.resume(`cancel-drip:${email}`, { // [!code highlight]
|
|
80
|
+
reason: reason ?? "User completed action", // [!code highlight]
|
|
81
|
+
}); // [!code highlight]
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const msg = error instanceof Error ? error.message.toLowerCase() : "";
|
|
84
|
+
if (msg.includes("not found") || msg.includes("expired")) {
|
|
85
|
+
return Response.json({
|
|
86
|
+
success: true,
|
|
87
|
+
email,
|
|
88
|
+
note: "No active drip found (already completed or cancelled)",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
182
92
|
}
|
|
183
93
|
|
|
184
|
-
return {
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function processData(jobId: string): Promise<string> {
|
|
188
|
-
"use step";
|
|
189
|
-
// Long-running computation
|
|
190
|
-
return `result-for-${jobId}`;
|
|
94
|
+
return Response.json({ success: true, email });
|
|
191
95
|
}
|
|
192
96
|
```
|
|
193
97
|
|
|
194
|
-
##
|
|
195
|
-
|
|
196
|
-
When you need to poll an external service until a job completes, define your own `sleep` as a step function and use it in a polling loop. Each iteration becomes a separate step in the event log, making the entire loop durable.
|
|
197
|
-
|
|
198
|
-
```typescript
|
|
199
|
-
async function sleep(ms: number): Promise<void> {
|
|
200
|
-
"use step";
|
|
201
|
-
await new Promise(resolve => setTimeout(resolve, ms));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export async function waitForTranscription(jobId: string) {
|
|
205
|
-
"use workflow";
|
|
98
|
+
## How it works
|
|
206
99
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
100
|
+
1. **Durable sleep** — `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires.
|
|
101
|
+
2. **Hook creation** — `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token.
|
|
102
|
+
3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first.
|
|
103
|
+
4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token.
|
|
210
104
|
|
|
211
|
-
|
|
212
|
-
await sleep(5000); // [!code highlight]
|
|
213
|
-
attempts++;
|
|
214
|
-
const result = await checkJobStatus(jobId); // [!code highlight]
|
|
215
|
-
status = result.status;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (status !== "completed") {
|
|
219
|
-
return { jobId, status: "timed_out", attempts };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return { jobId, status: "completed", attempts };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function checkJobStatus(jobId: string): Promise<{ status: string }> {
|
|
226
|
-
"use step";
|
|
227
|
-
const res = await fetch(`https://api.example.com/jobs/${jobId}`);
|
|
228
|
-
return res.json();
|
|
229
|
-
}
|
|
230
|
-
```
|
|
105
|
+
## Adapting to your use case
|
|
231
106
|
|
|
232
|
-
**
|
|
233
|
-
-
|
|
234
|
-
-
|
|
107
|
+
- **Change durations** — replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times.
|
|
108
|
+
- **Add more steps** — the pattern scales to any number of email-then-sleep pairs.
|
|
109
|
+
- **Snooze instead of cancel** — resolve the hook with a `snooze` payload and sleep again: `sleep(new Date(Date.now() + payload.snoozeMs))`.
|
|
110
|
+
- **Timeout any operation** — the same `Promise.race(sleep, work)` pattern works for adding deadlines to slow steps.
|
|
111
|
+
- **Real providers** — swap the `sendEmail` step body for Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access.
|
|
235
112
|
|
|
236
113
|
## Tips
|
|
237
114
|
|
|
238
115
|
- **`sleep()` accepts** duration strings (`"1d"`, `"2h"`, `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time.
|
|
239
|
-
- **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping
|
|
240
|
-
- **
|
|
241
|
-
- **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use a standard `setTimeout` or return control to the workflow.
|
|
116
|
+
- **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping — no compute, no memory.
|
|
117
|
+
- **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step.
|
|
242
118
|
|
|
243
119
|
## Key APIs
|
|
244
120
|
|
|
245
|
-
- [`"use workflow"`](/docs/foundations/workflows-and-steps)
|
|
246
|
-
- [`"use step"`](/docs/foundations/workflows-and-steps)
|
|
247
|
-
- [`sleep()`](/docs/api-reference/workflow/sleep)
|
|
248
|
-
- [`defineHook`](/docs/api-reference/workflow/define-hook)
|
|
249
|
-
- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race)
|
|
121
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
|
|
122
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions that run with full Node.js access
|
|
123
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost)
|
|
124
|
+
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — creates a typed hook that external systems can fire
|
|
125
|
+
- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — races sleep against hooks for interruptible waits
|