workflow 5.0.0-beta.4 → 5.0.0-beta.6
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.d.ts +5 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +14 -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 +6 -5
- package/docs/api-reference/vitest/index.mdx +28 -1
- package/docs/api-reference/workflow-api/start.mdx +5 -4
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
- package/docs/api-reference/workflow-next/with-workflow.mdx +32 -0
- package/docs/changelog/eager-processing.mdx +595 -0
- package/docs/changelog/index.mdx +2 -1
- package/docs/cookbook/advanced/child-workflows.mdx +13 -24
- package/docs/cookbook/advanced/meta.json +1 -6
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +29 -78
- package/docs/cookbook/agent-patterns/durable-agent.mdx +4 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +4 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +1 -1
- package/docs/cookbook/common-patterns/workflow-composition.mdx +7 -14
- package/docs/cookbook/index.mdx +0 -1
- package/docs/cookbook/integrations/ai-sdk.mdx +4 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +4 -0
- 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/hook-conflict.mdx +56 -4
- package/docs/foundations/cancellation.mdx +460 -0
- package/docs/foundations/errors-and-retries.mdx +7 -3
- package/docs/foundations/index.mdx +3 -0
- package/docs/foundations/meta.json +3 -1
- package/docs/foundations/serialization.mdx +77 -41
- package/docs/foundations/starting-workflows.mdx +5 -1
- 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 +8 -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/event-sourcing.mdx +2 -2
- package/docs/how-it-works/meta.json +2 -1
- package/docs/internal/index.mdx +19 -0
- package/docs/internal/meta.json +5 -0
- package/docs/internal/serializable-abort-controller.mdx +148 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +7 -12
- package/docs/migration-guides/migrating-from-inngest.mdx +7 -17
- package/docs/migration-guides/migrating-from-temporal.mdx +5 -10
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +8 -17
- package/package.json +13 -12
- package/docs/cookbook/advanced/distributed-abort-controller.mdx +0 -318
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: How Cancellation Works
|
|
3
|
+
description: Learn how AbortController is made durable using hooks and streams under the hood.
|
|
4
|
+
type: conceptual
|
|
5
|
+
summary: Understand the hook and stream backing that makes AbortSignal work across workflow boundaries.
|
|
6
|
+
prerequisites:
|
|
7
|
+
- /docs/foundations/cancellation
|
|
8
|
+
- /docs/how-it-works/event-sourcing
|
|
9
|
+
related:
|
|
10
|
+
- /docs/foundations/hooks
|
|
11
|
+
- /docs/foundations/streaming
|
|
12
|
+
- /docs/foundations/serialization
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<Callout>
|
|
16
|
+
This guide explains how cancellation works internally. Understanding these details is helpful for debugging and advanced use cases, but is not required to use `AbortController` in workflows. For usage patterns, see the [Cancellation](/docs/foundations/cancellation) guide.
|
|
17
|
+
</Callout>
|
|
18
|
+
|
|
19
|
+
When you write `new AbortController()` in a workflow function, Workflow DevKit creates a durable controller backed by two existing primitives: a [hook](/docs/foundations/hooks) and a [stream](/docs/foundations/streaming). This page explains why both are needed and how they work together.
|
|
20
|
+
|
|
21
|
+
## The Problem
|
|
22
|
+
|
|
23
|
+
`AbortController` and `AbortSignal` are inherently stateful — an abort happens once and is permanent. In a durable workflow, this state must:
|
|
24
|
+
|
|
25
|
+
1. **Survive replay** — If `abort()` was called, `signal.aborted` must return `true` on every subsequent replay of the workflow.
|
|
26
|
+
2. **Propagate in real-time** — A running step on a different compute instance must receive the abort immediately, not on the next replay.
|
|
27
|
+
|
|
28
|
+
No single primitive solves both. Hooks provide durable event log state but can't reach into a running step. Streams provide real-time cross-process communication but aren't part of the event log. The solution is to use both.
|
|
29
|
+
|
|
30
|
+
## Dual Backing: Hook + Stream
|
|
31
|
+
|
|
32
|
+
Every `AbortController` in the workflow context is backed by:
|
|
33
|
+
|
|
34
|
+
### Hook (Durable State)
|
|
35
|
+
|
|
36
|
+
When `new AbortController()` is called in a workflow, an internal hook is created — similar to calling `createHook()`. This hook is registered in the workflow's invocations queue and produces events in the [event log](/docs/how-it-works/event-sourcing):
|
|
37
|
+
|
|
38
|
+
- **On creation**: A `hook_created` event records that the controller exists
|
|
39
|
+
- **On abort**: The hook is resumed (producing a `hook_received` event), recording the abort permanently
|
|
40
|
+
- **On replay**: The event consumer processes the `hook_received` event and updates `signal.aborted` to `true` at the same point in the replay as the original abort
|
|
41
|
+
|
|
42
|
+
This gives the workflow deterministic access to the abort state — `controller.signal.aborted` always returns the correct value, even after cold starts.
|
|
43
|
+
|
|
44
|
+
### Stream (Real-Time Propagation)
|
|
45
|
+
|
|
46
|
+
When `controller.signal` is serialized as a step argument, a stream name is included in the serialized form. Inside the step, the deserialized `AbortSignal` listens on this stream:
|
|
47
|
+
|
|
48
|
+
- **On abort**: A cancellation packet is written to the stream
|
|
49
|
+
- **In the step**: A background reader receives the packet and calls `abort()` on the local `AbortController`, firing the signal immediately
|
|
50
|
+
|
|
51
|
+
This gives steps real-time cancellation without waiting for the workflow to replay.
|
|
52
|
+
|
|
53
|
+
### Why Both?
|
|
54
|
+
|
|
55
|
+
| Mechanism | Solves | Doesn't Solve |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| Hook only | Deterministic replay, event log consistency | Can't reach into a running step on another instance |
|
|
58
|
+
| Stream only | Real-time propagation to running steps | Not part of the event log, lost on replay |
|
|
59
|
+
| Hook + Stream | Both | — |
|
|
60
|
+
|
|
61
|
+
## Lifecycle
|
|
62
|
+
|
|
63
|
+
### 1. Controller Created in Workflow
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
new AbortController()
|
|
67
|
+
│
|
|
68
|
+
├─→ Internal hook created (registered in invocations queue)
|
|
69
|
+
└─→ Stream name generated (deterministic ULID)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 2. Signal Passed to Step
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
stepFunction(controller.signal)
|
|
76
|
+
│
|
|
77
|
+
├─→ Signal serialized as { streamName, hookToken, aborted }
|
|
78
|
+
└─→ In the step: deserialized as real AbortSignal
|
|
79
|
+
│
|
|
80
|
+
└─→ Background reader listens on stream for abort packet
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 3. abort() Called in Workflow
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
controller.abort()
|
|
87
|
+
│
|
|
88
|
+
├─→ signal.aborted set to true (synchronous, local state)
|
|
89
|
+
├─→ Hook marked for resumption in invocations queue
|
|
90
|
+
└─→ Workflow suspends (reaches next step/sleep/hook await)
|
|
91
|
+
│
|
|
92
|
+
├─→ Suspension handler creates hook_received event
|
|
93
|
+
├─→ Suspension handler writes cancellation packet to stream
|
|
94
|
+
│ │
|
|
95
|
+
│ └─→ Step receives packet → local signal fires → fetch cancelled
|
|
96
|
+
└─→ Workflow re-enqueued for replay
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 4. Workflow Replays After Abort
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Replay starts → events loaded
|
|
103
|
+
│
|
|
104
|
+
├─→ new AbortController() → hook created → event consumer subscribes
|
|
105
|
+
├─→ hook_created event consumed
|
|
106
|
+
├─→ hook_received event consumed → signal.aborted re-asserted as true
|
|
107
|
+
└─→ Workflow code sees signal.aborted === true at the correct point in replay
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
On replay, the events consumer re-applies the abort by calling `_setAborted` when it encounters the `hook_received` event in the log — at the same point in execution where the original `abort()` happened. This is what makes the abort deterministic across replays.
|
|
111
|
+
|
|
112
|
+
## Where the Hook Is Created
|
|
113
|
+
|
|
114
|
+
The backing hook is set up whenever an `AbortController` or `AbortSignal` enters the workflow context:
|
|
115
|
+
|
|
116
|
+
**`new AbortController()` in a workflow function** — The workflow VM provides a durable `AbortController` implementation (similar to how it provides deterministic `Date` and serializable `Request`/`Response`). The hook is created in the constructor using the orchestrator context injected via VM globals.
|
|
117
|
+
|
|
118
|
+
**Returned from a step** — A step can create a plain `new AbortController()` and return it. The step-side serializer generates a stream name and hook token (using a random ULID) and includes them in the serialized payload. When the return value is deserialized into the workflow via `hydrateStepReturnValue`, the workflow reviver reads the token from the payload and sets up the hook with that token. Since the serialized payload is stored in the event log (as part of the `step_completed` event), the same token is used on every replay — no deterministic generation needed in the workflow.
|
|
119
|
+
|
|
120
|
+
**Passed as workflow input** — Conceptually the same as "returned from a step". The **external reducer** handles it at serialization time:
|
|
121
|
+
|
|
122
|
+
1. Generates a stream name and hook token (random ULID)
|
|
123
|
+
2. Attaches an `abort` event listener on the source signal: when the external code calls `controller.abort()`, the listener writes the cancellation packet to the stream
|
|
124
|
+
3. Pushes the listener's async work into `ops` (awaited via `waitUntil`)
|
|
125
|
+
4. Serializes the reference as `{ streamName, hookToken, aborted }`
|
|
126
|
+
|
|
127
|
+
The serialized payload (including the generated token) is stored in the event log as part of the workflow's input. When the workflow deserializes the input, the reviver reads the token from the payload and creates the hook — identical to the "returned from a step" case. On replay, the same token is read from the event log, so the hook matches the same events.
|
|
128
|
+
|
|
129
|
+
If the external code calls `abort()` while the process is still alive (within the `waitUntil` window), the stream packet arrives in the workflow, and the workflow can resume the hook to record it in the event log.
|
|
130
|
+
|
|
131
|
+
<Callout type="info">
|
|
132
|
+
Since the external `AbortController` is a plain JavaScript object (not the workflow VM's durable version), the stream write depends on the originating process still being alive. This is the same constraint that applies to passing a `ReadableStream` as a workflow argument — the stream pipe runs via `waitUntil` and requires the process to remain active until the data is written.
|
|
133
|
+
</Callout>
|
|
134
|
+
|
|
135
|
+
## Serialization & Deserialization
|
|
136
|
+
|
|
137
|
+
### Serialized Form
|
|
138
|
+
|
|
139
|
+
An `AbortController` or `AbortSignal` is serialized as:
|
|
140
|
+
|
|
141
|
+
{/* @skip-typecheck: type definition, not runnable code */}
|
|
142
|
+
```typescript
|
|
143
|
+
{
|
|
144
|
+
streamName: string; // e.g., "abrt_01HWKZ..."
|
|
145
|
+
hookToken: string; // Generated at serialization time, used by workflow reviver to create the hook
|
|
146
|
+
aborted: boolean; // Current state at serialization time
|
|
147
|
+
reason?: unknown; // The abort reason, if any
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The `streamName` and `hookToken` are generated once at serialization time (in the step or external context) and stored in the event log as part of the serialized payload. On replay, the workflow reviver reads them from the payload — it never generates them itself. This is the same pattern used by `ReadableStream` and `WritableStream` serialization.
|
|
152
|
+
|
|
153
|
+
### Reducers (Serialization)
|
|
154
|
+
|
|
155
|
+
**In step context** (`getStepReducers`): When a step returns an `AbortController`, the reducer captures the stream name. If `abort()` was called in the step, `aborted: true` is recorded.
|
|
156
|
+
|
|
157
|
+
**In workflow context** (`getWorkflowReducers`): The reducer captures the stream name and hook token. These are handles — no I/O happens during serialization in the workflow.
|
|
158
|
+
|
|
159
|
+
**In external context** (`getExternalReducers`): When an `AbortController` is passed as a workflow argument from outside, the reducer creates the backing stream and serializes the reference.
|
|
160
|
+
|
|
161
|
+
### Revivers (Deserialization)
|
|
162
|
+
|
|
163
|
+
**Into step context** (`getStepRevivers`): Creates a real `AbortController`. If `aborted: true`, calls `abort()` immediately. Otherwise, pushes a stream reader into the step's `ops` array that listens for the cancellation packet and calls `abort()` when received.
|
|
164
|
+
|
|
165
|
+
**Into workflow context** (`getWorkflowRevivers`): Creates the durable AbortController with hook backing. Subscribes to the events consumer for the hook's correlation ID. If the event log contains a `hook_received` event, `signal.aborted` is `true`.
|
|
166
|
+
|
|
167
|
+
### abort() in a Step
|
|
168
|
+
|
|
169
|
+
When `abort()` is called on a deserialized `AbortController` inside a step:
|
|
170
|
+
|
|
171
|
+
1. The local signal is aborted synchronously (standard behavior)
|
|
172
|
+
2. The stream write (cancellation packet) is pushed into `ctx.ops`
|
|
173
|
+
3. The hook resume (`resumeHook`) is pushed into `ctx.ops`
|
|
174
|
+
|
|
175
|
+
The step's `ops` array is awaited via `waitUntil(Promise.all(ops))` after the step function returns — the same mechanism used by [`getWritable()`](/docs/api-reference/workflow/get-writable). This keeps `abort()` synchronous from the caller's perspective while ensuring the async work completes.
|
|
176
|
+
|
|
177
|
+
### Abort Errors Are Wrapped in FatalError
|
|
178
|
+
|
|
179
|
+
When a step throws due to an abort — whether from `fetch` throwing `AbortError`, `signal.throwIfAborted()`, or any other abort-induced error — the step handler wraps the error in `FatalError` before recording it in the event log. This ensures:
|
|
180
|
+
|
|
181
|
+
- **No retries**: An abort is intentional cancellation, not a transient failure. Retrying would just abort again.
|
|
182
|
+
- **Immediate propagation**: The error bubbles up to the workflow as a `FatalError`, which the workflow can catch with `FatalError.is(err)`.
|
|
183
|
+
|
|
184
|
+
The wrapping happens at the step handler level (`runtime/step-handler.ts`), during error hydration. When the step's thrown error is an `AbortError` (checked via `err.name === 'AbortError'`), it is treated as fatal regardless of the step's `maxRetries` configuration.
|
|
185
|
+
|
|
186
|
+
### abort() in the Workflow
|
|
187
|
+
|
|
188
|
+
When `abort()` is called in the workflow context:
|
|
189
|
+
|
|
190
|
+
1. `signal.aborted` is updated to `true` immediately (so subsequent reads and serialization capture the correct state)
|
|
191
|
+
2. The internal hook is marked for resumption in the invocations queue (same pattern as `hook.dispose()`)
|
|
192
|
+
3. The workflow continues until it reaches the next suspension point (step call, hook await, or sleep) or completes
|
|
193
|
+
4. The pending queue items are processed:
|
|
194
|
+
- Creates a `hook_received` event in the event log
|
|
195
|
+
- Writes the cancellation packet to the stream (for real-time step propagation)
|
|
196
|
+
- Re-enqueues the workflow for replay
|
|
197
|
+
4. On replay, the event consumer processes the `hook_received` event, updating `signal.aborted` to `true` at the deterministically correct point
|
|
198
|
+
|
|
199
|
+
`signal.aborted` is updated synchronously so that the workflow can immediately check the state and serialization captures `aborted: true` when passing the signal to steps. On replay, the event consumer also processes the `hook_received` event, ensuring the state is consistent.
|
|
200
|
+
|
|
201
|
+
For abort specifically, this ensures that:
|
|
202
|
+
|
|
203
|
+
- The abort's `hook_received` event is created in the event log
|
|
204
|
+
- The cancellation stream packet is written to propagate to running steps
|
|
205
|
+
|
|
206
|
+
## Race Conditions
|
|
207
|
+
|
|
208
|
+
### Abort Before Hook Exists
|
|
209
|
+
|
|
210
|
+
When an `AbortSignal` is passed as a workflow argument via `start()`, the external reducer attaches a listener at serialization time. If the external code calls `abort()` before the workflow has started and created the internal hook, the stream packet is written but the hook doesn't exist yet.
|
|
211
|
+
|
|
212
|
+
This is resolved through eventual consistency:
|
|
213
|
+
|
|
214
|
+
1. The stream packet is durable — it persists in storage
|
|
215
|
+
2. When the workflow runs and passes the signal to a step, the step's reviver reads from the stream starting at index 0
|
|
216
|
+
3. The step sees the existing packet, aborts locally, and resumes the hook (via `ops`)
|
|
217
|
+
4. On the next workflow replay, the hook event is in the log and `signal.aborted` is `true`
|
|
218
|
+
|
|
219
|
+
**Important:** There is a window where the workflow's `signal.aborted` returns `false` even though the external code has already called `abort()`. This lasts until a step processes the stream packet and resumes the hook. This is analogous to hooks — `resumeHook()` doesn't take effect until the workflow replays.
|
|
220
|
+
|
|
221
|
+
### Abort at Serialization Time
|
|
222
|
+
|
|
223
|
+
To prevent a micro-window where `abort()` is called between checking `signal.aborted` and attaching the listener, the external reducer uses this order:
|
|
224
|
+
|
|
225
|
+
1. Attach the `abort` event listener first
|
|
226
|
+
2. Then check `signal.aborted` — if already `true`, the listener won't fire, so handle immediately
|
|
227
|
+
|
|
228
|
+
This ensures no abort events are missed regardless of timing.
|
|
229
|
+
|
|
230
|
+
## Stream/Hook Consistency
|
|
231
|
+
|
|
232
|
+
Since abort involves two operations (stream write + hook resume), partial failure is possible:
|
|
233
|
+
|
|
234
|
+
### Stream Succeeds, Hook Fails
|
|
235
|
+
|
|
236
|
+
- Steps see the abort and throw `AbortError` (stream worked)
|
|
237
|
+
- Workflow doesn't see `signal.aborted === true` on the next replay (hook not resumed)
|
|
238
|
+
- The workflow sees the step failure as an error, which it can handle with try/catch
|
|
239
|
+
- **Recovery:** The step-side `resumeHook` call is best-effort — if it throws, the failure is swallowed. Convergence comes from the next replay: when the step's reviver re-reads the stream, it sees the abort packet and calls `resumeHook` again. There's no in-process retry loop; the dual-mechanism design relies on either the stream or the hook eventually landing.
|
|
240
|
+
|
|
241
|
+
### Hook Succeeds, Stream Fails
|
|
242
|
+
|
|
243
|
+
- Workflow sees `signal.aborted === true` on replay (hook worked)
|
|
244
|
+
- Steps don't receive real-time cancellation (stream failed) — they run to completion
|
|
245
|
+
- On the next suspension, the workflow knows the abort happened and can stop calling more steps
|
|
246
|
+
- **Recovery:** Natural convergence — no active harm, just missed real-time cancellation for in-flight steps.
|
|
247
|
+
|
|
248
|
+
### Both Fail
|
|
249
|
+
|
|
250
|
+
- Abort is lost — no propagation
|
|
251
|
+
- No crash or corruption — the system continues as if abort was never called
|
|
252
|
+
- **Recovery:** The caller can retry the abort. If using a hook for external cancellation, the hook's retry semantics apply.
|
|
253
|
+
|
|
254
|
+
The dual mechanism provides natural resilience — if either one succeeds, the system converges on the correct state.
|
|
255
|
+
|
|
256
|
+
## `AbortSignal.timeout()` in Workflow VM
|
|
257
|
+
|
|
258
|
+
`AbortSignal.timeout()` is blocked in the workflow VM because it depends on real-time timers, which break deterministic replay. Calling it throws an error with a suggestion to use `sleep()` + `AbortController` instead. See [AbortSignal.timeout() in Workflow](/docs/errors/abort-signal-timeout-in-workflow) for details.
|
|
259
|
+
|
|
260
|
+
`AbortSignal.timeout()` works normally in step functions, which have full Node.js runtime access.
|
|
261
|
+
|
|
262
|
+
## Request.signal
|
|
263
|
+
|
|
264
|
+
A `Request`'s `.signal` is forwarded by the `Request` reducer in two cases:
|
|
265
|
+
|
|
266
|
+
1. **The signal is already aborted.** The serialized payload preserves `aborted: true` and the abort `reason`, so the deserialized step sees the cancellation that happened before the boundary.
|
|
267
|
+
2. **The signal is workflow-managed** (i.e., it has the `ABORT_STREAM_NAME` symbol — produced by a workflow-context `AbortController`). Its hook + stream backing carries through, and the deserialized step listens on the stream as usual.
|
|
268
|
+
|
|
269
|
+
Plain non-aborted native signals are intentionally dropped, including the auto-generated signal that `new Request(url)` synthesizes when no `signal` is passed. Forwarding every Request signal would mint stream infrastructure for the throwaway auto-signals on every Request, even ones the caller never intended to use for cancellation.
|
|
270
|
+
|
|
271
|
+
If you want cross-boundary cancellation through a `Request`, build it with a signal from a workflow-context `AbortController`:
|
|
272
|
+
|
|
273
|
+
{/* @skip-typecheck: conceptual snippet */}
|
|
274
|
+
```typescript
|
|
275
|
+
const controller = new AbortController(); // in workflow function
|
|
276
|
+
const req = new Request(url, { signal: controller.signal });
|
|
277
|
+
await fetchStep(req); // signal carries through
|
|
278
|
+
controller.abort(); // step-side fetch sees the abort
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Related Documentation
|
|
282
|
+
|
|
283
|
+
- [Cancellation](/docs/foundations/cancellation) — Usage patterns and API
|
|
284
|
+
- [Event Sourcing](/docs/how-it-works/event-sourcing) — How the event log works
|
|
285
|
+
- [Hooks](/docs/foundations/hooks) — The hook primitive
|
|
286
|
+
- [Streaming](/docs/foundations/streaming) — The stream primitive
|
|
287
|
+
- [Serialization](/docs/foundations/serialization) — Serializable types
|
|
@@ -193,7 +193,7 @@ handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; /
|
|
|
193
193
|
- The `workflowId` property is added (same as workflow mode)
|
|
194
194
|
- Step functions are not transformed in client mode
|
|
195
195
|
|
|
196
|
-
**Why this transformation?** Workflow functions cannot be called directly—they must be started using [`start()`](/docs/api-reference/workflow-api/start). The error prevents accidental direct execution while the `workflowId` property allows the `start()` function to identify which workflow to launch.
|
|
196
|
+
**Why this transformation?** Workflow functions cannot be called directly from application code—they must be started using [`start()`](/docs/api-reference/workflow-api/start). The error prevents accidental direct execution while the `workflowId` property allows the `start()` function to identify which workflow to launch.
|
|
197
197
|
|
|
198
198
|
The IDs are generated exactly like in workflow mode to ensure they can be directly referenced at runtime.
|
|
199
199
|
|
|
@@ -321,7 +321,7 @@ The compiler generates stable IDs for workflows and steps based on file paths an
|
|
|
321
321
|
- **Portable**: Works across different runtimes and deployments
|
|
322
322
|
|
|
323
323
|
<Callout type="info">
|
|
324
|
-
Although IDs can change when files are moved or functions are renamed, Workflow SDK
|
|
324
|
+
Although IDs can change when files are moved or functions are renamed, Workflow SDK functions assume [atomic versioning](/docs/foundations/versioning) in the World. This means changing IDs won't break old workflows from running, but will prevent runs from being upgraded and will cause your workflow/step names to change in observability across deployments.
|
|
325
325
|
</Callout>
|
|
326
326
|
|
|
327
327
|
## Framework Integration
|
|
@@ -127,7 +127,7 @@ flowchart TD
|
|
|
127
127
|
|
|
128
128
|
Unlike other entities, hooks don't have a `status` field—the states above are conceptual. An "active" hook is one that exists in storage, while "disposed" means the hook has been deleted. When a `hook_disposed` event is created, the hook record is removed rather than updated.
|
|
129
129
|
|
|
130
|
-
While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token that is already in use by another active hook, a `hook_conflict` event is recorded instead of `hook_created`. This causes the hook's promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details.
|
|
130
|
+
While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token that is already in use by another active hook, a `hook_conflict` event is recorded instead of `hook_created`. Current worlds include the token and the run ID that currently owns it, though older persisted events or world implementations may only include the token. This causes the hook's promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details.
|
|
131
131
|
|
|
132
132
|
When a hook is disposed (either explicitly or when its workflow completes), the token is released and can be claimed by future workflows. Hooks are automatically disposed when a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`). The `hook_disposed` event is only needed for explicit disposal before workflow completion.
|
|
133
133
|
|
|
@@ -188,7 +188,7 @@ Events are categorized by the entity type they affect. Each event contains metad
|
|
|
188
188
|
| Event | Description |
|
|
189
189
|
|-------|-------------|
|
|
190
190
|
| `hook_created` | Creates a new hook in `active` state. Contains the hook token and optional metadata. |
|
|
191
|
-
| `hook_conflict` | Records that hook creation failed because the token is already in use by another active hook. The hook is not created, and awaiting the hook will reject with a `HookConflictError`. |
|
|
191
|
+
| `hook_conflict` | Records that hook creation failed because the token is already in use by another active hook. Contains the token and, for current worlds, the active hook owner's run ID. The hook is not created, and awaiting the hook will reject with a `HookConflictError`. |
|
|
192
192
|
| `hook_received` | Records that a payload was delivered to the hook. The hook remains `active` and can receive more payloads. |
|
|
193
193
|
| `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows. |
|
|
194
194
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Internal
|
|
3
|
+
description: Preview-only page for internal tools, draft changelogs, and testing utilities.
|
|
4
|
+
type: overview
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<Callout type="warn">
|
|
8
|
+
This page is only visible on preview deployments and local development. It does not appear in production.
|
|
9
|
+
</Callout>
|
|
10
|
+
|
|
11
|
+
## Preview Package
|
|
12
|
+
|
|
13
|
+
<PreviewInstall />
|
|
14
|
+
|
|
15
|
+
## Draft Changelogs
|
|
16
|
+
|
|
17
|
+
Changelog entries staged here for review before publishing to the Vercel website.
|
|
18
|
+
|
|
19
|
+
- [Serializable AbortController and AbortSignal](/docs/internal/serializable-abort-controller) — March 12, 2026
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Serializable AbortController and AbortSignal
|
|
3
|
+
description: AbortController and AbortSignal now work across workflow and step boundaries using the standard Web API.
|
|
4
|
+
type: overview
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Serializable AbortController and AbortSignal
|
|
8
|
+
|
|
9
|
+
<span className="text-sm text-fd-muted-foreground">March 12, 2026</span>
|
|
10
|
+
|
|
11
|
+
`AbortController` and `AbortSignal` now work natively in workflow functions. Create a controller, pass its signal to steps, and call `abort()` — no special imports or wrapper functions needed.
|
|
12
|
+
|
|
13
|
+
## What's new
|
|
14
|
+
|
|
15
|
+
- **Standard API, zero boilerplate.** `new AbortController()` works inside `"use workflow"` functions. The controller and its signal are automatically serialized across workflow and step boundaries.
|
|
16
|
+
- **Dual hook + stream backing for durability.** Under the hood, each controller is backed by a durable [hook](/docs/foundations/hooks) (for replay correctness) and a [stream](/docs/foundations/streaming) (for real-time propagation to running steps). This means aborts survive cold starts, replays, and scale events.
|
|
17
|
+
- **Cooperative cancellation.** Steps receive the abort in real time and can respond by checking `signal.aborted`, calling `signal.throwIfAborted()`, or passing the signal to APIs like `fetch`.
|
|
18
|
+
- **Abort errors skip retries.** When a step throws due to an abort (e.g., `fetch` throws `AbortError`), the error is automatically wrapped in `FatalError` so it skips retries and bubbles up immediately.
|
|
19
|
+
- **`AbortSignal.timeout()` blocked in workflow VM.** Because it relies on real-time timers that break deterministic replay, `AbortSignal.timeout()` throws a helpful error pointing to the `sleep()` + `AbortController` pattern instead.
|
|
20
|
+
- **`Request.signal` preserved when it carries abort state.** A `Request`'s `.signal` is serialized when it's already aborted (so the cancellation that happened pre-serialization is preserved) or when it's a workflow-managed signal (so its hook + stream backing carries through). Plain non-aborted native signals — including the auto-generated signal on `new Request(url)` — are dropped to avoid minting stream infrastructure for every `Request`. To get cross-boundary cancellation through a `Request`, build it with the signal from a workflow-context `AbortController`.
|
|
21
|
+
- **Pending queue items drain on completion.** If you call `abort()` (or `dispose` a hook, or kick off a `void sleep('1d')`, or fire a `void someStep()`) without a suspension point between that call and the workflow's return, the runtime now treats end-of-run as a final suspension and commits all pending operations before the run is marked terminal. This matches normal JS semantics — `setTimeout` etc. continue running after the surrounding function returns. The most important case: `controller.abort()` called as the last statement of a workflow now actually propagates to in-flight steps on other compute instances.
|
|
22
|
+
|
|
23
|
+
## Timeout with cancellation
|
|
24
|
+
|
|
25
|
+
Race a step against a durable `sleep()`, and cancel the step if the timeout wins:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { sleep } from "workflow";
|
|
29
|
+
|
|
30
|
+
export async function fetchWithTimeout(url: string) {
|
|
31
|
+
"use workflow";
|
|
32
|
+
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
|
|
35
|
+
const result = await Promise.race([
|
|
36
|
+
fetchUrl(url, controller.signal),
|
|
37
|
+
sleep("10s").then(() => null),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
if (result === null) {
|
|
41
|
+
controller.abort();
|
|
42
|
+
throw new Error(`Request to ${url} timed out after 10s`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchUrl(url: string, signal: AbortSignal) {
|
|
49
|
+
"use step";
|
|
50
|
+
const response = await fetch(url, { signal });
|
|
51
|
+
return response.json();
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Cancelling parallel work
|
|
56
|
+
|
|
57
|
+
When racing multiple steps, cancel the losers:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
declare function fetchUrl(url: string, signal: AbortSignal): Promise<{ url: string; data: unknown }>; // @setup
|
|
61
|
+
|
|
62
|
+
export async function firstResponder(urls: string[]) {
|
|
63
|
+
"use workflow";
|
|
64
|
+
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
|
|
67
|
+
const result = await Promise.race(
|
|
68
|
+
urls.map((url) => fetchUrl(url, controller.signal))
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
controller.abort(); // Cancel remaining fetches
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## User-triggered cancellation with hooks
|
|
78
|
+
|
|
79
|
+
Combine hooks with abort controllers to let users cancel work from an external API:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
declare function doExpensiveWork(signal: AbortSignal): Promise<unknown>; // @setup
|
|
83
|
+
import { createHook } from "workflow";
|
|
84
|
+
|
|
85
|
+
export async function userCancellableWorkflow(jobId: string) {
|
|
86
|
+
"use workflow";
|
|
87
|
+
|
|
88
|
+
using cancelHook = createHook<{ reason: string }>({
|
|
89
|
+
token: `cancel:${jobId}`,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const workPromise = doExpensiveWork(controller.signal);
|
|
94
|
+
|
|
95
|
+
const result = await Promise.race([
|
|
96
|
+
workPromise.then((data) => ({ status: "completed", data })),
|
|
97
|
+
cancelHook.then((payload) => {
|
|
98
|
+
controller.abort();
|
|
99
|
+
return { status: "cancelled", reason: payload.reason };
|
|
100
|
+
}),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Step-initiated abort
|
|
108
|
+
|
|
109
|
+
A step can receive the full `AbortController` and call `abort()` to cancel parallel work — useful for watchdog patterns like quota monitoring:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
declare function processData(url: string, signal: AbortSignal): Promise<{ processed: boolean }>; // @setup
|
|
113
|
+
|
|
114
|
+
export async function processWithQuotaCheck(userId: string, dataUrl: string) {
|
|
115
|
+
"use workflow";
|
|
116
|
+
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
|
|
119
|
+
const [result] = await Promise.all([
|
|
120
|
+
processData(dataUrl, controller.signal),
|
|
121
|
+
monitorQuota(userId, controller),
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function monitorQuota(userId: string, controller: AbortController) {
|
|
128
|
+
"use step";
|
|
129
|
+
|
|
130
|
+
while (!controller.signal.aborted) {
|
|
131
|
+
const quota = await fetch(`https://api.example.com/quota/${userId}`);
|
|
132
|
+
const { exceeded } = await quota.json();
|
|
133
|
+
|
|
134
|
+
if (exceeded) {
|
|
135
|
+
controller.abort("Quota exceeded"); // Cancels processData
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Learn more
|
|
145
|
+
|
|
146
|
+
- [Cancellation](/docs/foundations/cancellation) — Full guide with all usage patterns
|
|
147
|
+
- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream internals
|
|
148
|
+
- [AbortSignal.timeout() in Workflow](/docs/errors/abort-signal-timeout-in-workflow) — Why `AbortSignal.timeout()` is blocked and what to use instead
|
|
@@ -54,11 +54,11 @@ The migration replaces declarative configuration with idiomatic TypeScript and c
|
|
|
54
54
|
| Choice state | `if` / `else` / `switch` | Native TypeScript control flow. |
|
|
55
55
|
| Wait state | `sleep()` | Import `sleep` from `workflow`. |
|
|
56
56
|
| Parallel state | `Promise.all()` | Standard concurrency primitives. |
|
|
57
|
-
| Map state | Inline sequential → `for` loop; bounded parallel (`MaxConcurrency: N`) → batched `Promise.all` or a concurrency limiter like `p-limit`; Distributed Map / large fan-out →
|
|
57
|
+
| Map state | Inline sequential → `for` loop; bounded parallel (`MaxConcurrency: N`) → batched `Promise.all` or a concurrency limiter like `p-limit`; Distributed Map / large fan-out → `start()` per item, then step-wrapped `getRun()` to collect. | Match the concurrency mode of the original Map. |
|
|
58
58
|
| Retry / Catch | Step retries, `RetryableError`, `FatalError` | Retry logic moves to step boundaries. |
|
|
59
59
|
| `Catch` to a compensation state | `try`/`catch` in the workflow function, calling compensation steps in reverse order (push/pop a rollback stack) | See [`/docs/foundations/errors-and-retries`](/docs/foundations/errors-and-retries) for the SAGA pattern. |
|
|
60
60
|
| `.waitForTaskToken` | `createHook()` or `createWebhook()` | Hooks for typed signals; webhooks for HTTP. |
|
|
61
|
-
| Child state machine (`StartExecution`) | `"use step"` around `
|
|
61
|
+
| Child state machine (`StartExecution`) | `start()` plus a `"use step"` wrapper around `getRun()` | Return the `Run` object, await its result from another step. |
|
|
62
62
|
| Execution event history | Workflow event log | Same durable replay model. |
|
|
63
63
|
| Progress via DynamoDB / SNS for client polling | `getWritable()` + named streams | Stream durable updates; clients read from the stream. |
|
|
64
64
|
|
|
@@ -213,21 +213,16 @@ return { refundId, status: 'rejected' };
|
|
|
213
213
|
|
|
214
214
|
## Spawn a child workflow
|
|
215
215
|
|
|
216
|
-
In ASL, a parent machine calls `StartExecution` (usually via `.sync` or `.waitForTaskToken`) to launch a child. In
|
|
216
|
+
In ASL, a parent machine calls `StartExecution` (usually via `.sync` or `.waitForTaskToken`) to launch a child. In v5, call `start()` directly from the workflow to launch a child. Wrap `getRun()` and `returnValue` access in a `"use step"` function when you need to await the child result.
|
|
217
217
|
|
|
218
218
|
### Parent starts a child
|
|
219
219
|
|
|
220
220
|
```typescript title="workflow/workflows/parent.ts"
|
|
221
221
|
import { start } from 'workflow/api';
|
|
222
222
|
|
|
223
|
-
async function spawnChild(item: string) {
|
|
224
|
-
'use step'; // [!code highlight]
|
|
225
|
-
return await start(childWorkflow, [item]);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
223
|
export async function parentWorkflow(item: string) {
|
|
229
224
|
'use workflow';
|
|
230
|
-
const run = await
|
|
225
|
+
const run = await start(childWorkflow, [item]); // [!code highlight]
|
|
231
226
|
return { childRunId: run.runId };
|
|
232
227
|
}
|
|
233
228
|
```
|
|
@@ -338,7 +333,7 @@ Delete the ASL JSON, per-task Lambda deployments, IAM roles, and callback queues
|
|
|
338
333
|
## Features without a 1:1 equivalent
|
|
339
334
|
|
|
340
335
|
- **Express workflows.** At-least-once semantics and 5-minute duration make them a poor fit for the SDK's durable replay model. Consider keeping them on Step Functions or migrating to a queue consumer.
|
|
341
|
-
- **Distributed Map state.** Up to 10,000 concurrent child executions with S3 item sources has no 1:1 analog; fan out with
|
|
336
|
+
- **Distributed Map state.** Up to 10,000 concurrent child executions with S3 item sources has no 1:1 analog; fan out with `start()` per item, then `Promise.all` with `p-limit` to bound concurrency.
|
|
342
337
|
- **Optimized AWS service integrations (`arn:aws:states:::dynamodb:*`, `eventbridge:*`, `bedrock:*`, `ecs:runTask.sync`, etc.).** These become regular SDK calls inside `'use step'` functions — credentials, retries, and polling move into the step.
|
|
343
338
|
- **Per-state IAM roles.** ASL lets each state run under its own IAM role. In the SDK, all steps share the deployment's credentials; scope secrets and roles at deployment time.
|
|
344
339
|
- **CloudWatch alarms / X-Ray cross-service traces / CloudWatch Logs retention.** The SDK event log + observability UI replaces orchestrator state transitions, not AWS-wide observability. Keep alarms and traces for other resources.
|
|
@@ -351,8 +346,8 @@ Delete the ASL JSON, per-task Lambda deployments, IAM roles, and callback queues
|
|
|
351
346
|
- Replace Choice states with `if`/`else`/`switch`.
|
|
352
347
|
- Replace Wait states with `sleep()` from `workflow`.
|
|
353
348
|
- Replace Parallel states with `Promise.all()`.
|
|
354
|
-
- Replace Map states based on their concurrency mode: inline sequential → `for` loop; bounded parallel (`MaxConcurrency: N`) → batched `Promise.all` or a concurrency limiter like `p-limit`; Distributed Map / large fan-out →
|
|
355
|
-
- Replace `StartExecution` child machines with `"use step"`
|
|
349
|
+
- Replace Map states based on their concurrency mode: inline sequential → `for` loop; bounded parallel (`MaxConcurrency: N`) → batched `Promise.all` or a concurrency limiter like `p-limit`; Distributed Map / large fan-out → `start()` per item, then step-wrapped `getRun()` to collect.
|
|
350
|
+
- Replace `StartExecution` child machines with direct `start()` calls and a `"use step"` wrapper around `getRun()` when collecting results.
|
|
356
351
|
- Replace `.waitForTaskToken` with `createHook()` (internal callers) or `createWebhook()` (HTTP callers).
|
|
357
352
|
- Move Retry/Catch to step boundaries using `maxRetries`, `RetryableError`, and `FatalError`.
|
|
358
353
|
- Use `getStepMetadata().stepId` as the idempotency key for external side effects.
|
|
@@ -51,10 +51,10 @@ Inngest's event-bus model is loosely coupled — publishers don't know consumers
|
|
|
51
51
|
| `step.run()` | `"use step"` function | Standalone async function with Node.js access. |
|
|
52
52
|
| `step.sleep()` / `step.sleepUntil()` | `sleep()` | `sleep('5m')` for a duration; `sleep(date)` for sleep-until. |
|
|
53
53
|
| `step.waitForEvent()` | `createHook()` or `createWebhook()` | Hooks for typed signals, webhooks for HTTP. |
|
|
54
|
-
| `step.invoke()` | `"use step"`
|
|
54
|
+
| `step.invoke()` | `start()` plus a `"use step"` wrapper around `getRun()` | Spawn a child run, pass `runId` forward, and collect from a step when needed. |
|
|
55
55
|
| `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly. |
|
|
56
56
|
| Retry configuration (`retries`) | `RetryableError`, `FatalError`, `maxRetries` | Retry logic lives at the step level. |
|
|
57
|
-
| `step.sendEvent()` | `
|
|
57
|
+
| `step.sendEvent()` | `start()` from the workflow or app boundary | Fan out explicitly, not through an event bus. |
|
|
58
58
|
| Realtime / `step.realtime.publish()` | `getWritable()` / `getWritable({ namespace })` | Named streams are the canonical way for clients to read workflow status. No database or `getRun()` polling required. |
|
|
59
59
|
|
|
60
60
|
## Translate your first workflow
|
|
@@ -169,20 +169,10 @@ Event matching disappears. A hook's token encodes the routing (for example, `ref
|
|
|
169
169
|
|
|
170
170
|
## Spawn a child workflow
|
|
171
171
|
|
|
172
|
-
`step.invoke()` splits into
|
|
172
|
+
`step.invoke()` splits into spawn and collect. In v5, call `start()` directly from the workflow to spawn the child. Wrap `getRun()` and `returnValue` access in a `"use step"` function when you need to collect the result. Returning the `Run` object from `start()` lets observability deep-link into the child run.
|
|
173
173
|
|
|
174
174
|
You can return either the full `Run` object (enables deep-linking) or just `run.runId` (simpler).
|
|
175
175
|
|
|
176
|
-
{/* @skip-typecheck: snippet without imports */}
|
|
177
|
-
```typescript title="workflow/workflows/parent.ts"
|
|
178
|
-
async function spawnChild(item: string) {
|
|
179
|
-
'use step';
|
|
180
|
-
return start(childWorkflow, [item]); // [!code highlight]
|
|
181
|
-
}
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
Await the result in a second step, then orchestrate both from the parent:
|
|
185
|
-
|
|
186
176
|
{/* @skip-typecheck: snippet without imports */}
|
|
187
177
|
```typescript title="workflow/workflows/parent.ts"
|
|
188
178
|
async function collectResult(runId: string) {
|
|
@@ -193,7 +183,7 @@ async function collectResult(runId: string) {
|
|
|
193
183
|
|
|
194
184
|
export async function parentWorkflow(item: string) {
|
|
195
185
|
'use workflow';
|
|
196
|
-
const child = await
|
|
186
|
+
const child = await start(childWorkflow, [item]); // [!code highlight]
|
|
197
187
|
return await collectResult(child.runId);
|
|
198
188
|
}
|
|
199
189
|
```
|
|
@@ -266,7 +256,7 @@ See [Errors and retries](/docs/foundations/errors-and-retries) for full retry do
|
|
|
266
256
|
|
|
267
257
|
- `step.waitForEvent(...)` → `createHook({ token })` + `await hook`. Resume it from an API route with `resumeHook(token, payload)`.
|
|
268
258
|
- `step.sleep(...)` → `sleep("5m")` from `workflow`.
|
|
269
|
-
- `step.invoke(child, { data })` →
|
|
259
|
+
- `step.invoke(child, { data })` → call `start(child, [data])` from the workflow, and optionally read its return value from a step with `getRun(run.runId).returnValue`.
|
|
270
260
|
|
|
271
261
|
### Step 5: Start runs from the app
|
|
272
262
|
|
|
@@ -301,8 +291,8 @@ Remove the `inngest` client, the `serve()` route, event schemas, and the Inngest
|
|
|
301
291
|
- Swap `step.sleep()` / `step.sleepUntil()` for `sleep()` from `workflow`.
|
|
302
292
|
- Swap `step.waitForEvent()` for `createHook()` (internal) or `createWebhook()` (HTTP).
|
|
303
293
|
- Model `waitForEvent` timeouts as `Promise.race()` between the hook and `sleep()`.
|
|
304
|
-
- Replace `step.invoke()` with `"use step"`
|
|
305
|
-
- Replace `step.sendEvent()` fan-out with `start()`
|
|
294
|
+
- Replace `step.invoke()` with direct `start()` calls and a `"use step"` wrapper around `getRun()` when collecting results.
|
|
295
|
+
- Replace `step.sendEvent()` fan-out with explicit `start()` calls.
|
|
306
296
|
- Remove the Inngest client, `serve()` handler, and event definitions.
|
|
307
297
|
- Push retry configuration down to step boundaries via `maxRetries`, `RetryableError`, and `FatalError`.
|
|
308
298
|
- Use `getStepMetadata().stepId` as the idempotency key for external side effects.
|