From b0dc1f9b49b6a18bb8c150011298204d81ffaa10 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Mon, 13 Apr 2026 20:47:03 -0600 Subject: [PATCH 1/7] [docs] Polish migration guides for scanability Tighten prose, break up code walls, and rework each migration guide (Temporal, Inngest, AWS Step Functions) to the house style. Imperative section headers, minimum-viable code samples that layer up, and a step-by-step first-migration walkthrough before the checklist. --- docs/content/docs/migration-guides/index.mdx | 10 +- .../migrating-from-aws-step-functions.mdx | 632 +++++++----------- .../migrating-from-inngest.mdx | 493 ++++++-------- .../migrating-from-temporal.mdx | 497 +++++++------- 4 files changed, 700 insertions(+), 932 deletions(-) diff --git a/docs/content/docs/migration-guides/index.mdx b/docs/content/docs/migration-guides/index.mdx index bc28b14d36..3ff673835b 100644 --- a/docs/content/docs/migration-guides/index.mdx +++ b/docs/content/docs/migration-guides/index.mdx @@ -1,6 +1,6 @@ --- title: Migration Guides -description: Move your existing durable workflow system to the Workflow SDK with side-by-side code comparisons and realistic migration examples. +description: Move your existing durable workflow system to the Workflow SDK with side-by-side code comparisons and a realistic migration example. type: overview summary: Migrate from Temporal, Inngest, or AWS Step Functions to the Workflow SDK. related: @@ -8,16 +8,16 @@ related: - /docs/getting-started --- -Migrate your existing orchestration system to the Workflow SDK. Each guide includes concept mappings, side-by-side code comparisons, and a full end-to-end migration example. +Move an existing orchestration system to the Workflow SDK. Each guide pairs a concept-mapping table with side-by-side code and a full order-processing saga, so you can translate one piece of your codebase at a time. - Replace Activities, Workers, Signals, and Child Workflows with Workflows, Steps, Hooks, and start()/getRun(). + Map Activities, Workers, Signals, and Child Workflows onto workflows, steps, hooks, and `start()` / `getRun()`. - Replace createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, and Hooks. + Map `createFunction`, `step.run`, `step.sleep`, `step.waitForEvent`, and `step.invoke` onto workflows, steps, and hooks. - Replace JSON state definitions, Task/Choice/Wait/Parallel states, and .waitForTaskToken callbacks with idiomatic TypeScript. + Replace ASL JSON states, Task / Choice / Wait / Parallel states, and `.waitForTaskToken` callbacks with TypeScript. diff --git a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx index 65420dec15..f473cd9cc6 100644 --- a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -14,503 +14,363 @@ related: - /docs/deploying/world/vercel-world --- -## What changes when you leave Step Functions? +## What changes when you leave Step Functions -With AWS Step Functions, you define workflows as JSON state machines using Amazon States Language (ASL). Each state — Task, Choice, Wait, Parallel, Map — is a node in a declarative graph. You wire Lambda functions as task handlers, configure Retry/Catch blocks per state, and manage callback patterns through `.waitForTaskToken`. The execution engine is powerful, but the authoring experience is configuration-heavy and detached from your application code. +AWS Step Functions defines workflows as JSON state machines using Amazon States Language (ASL). Each state — Task, Choice, Wait, Parallel, Map — is a node in a declarative graph. Lambda functions handle tasks, Retry/Catch blocks configure per-state error handling, and `.waitForTaskToken` manages callbacks. -With the Workflow SDK, you write `"use workflow"` functions that orchestrate `"use step"` functions — all in the same file, all plain TypeScript. Branching is `if`/`else`, waiting is `sleep()`, parallelism is `Promise.all()`, and retries are step-level configuration. There is no state-machine JSON to maintain, no Lambda function wiring, and no IAM roles to configure between orchestrator and compute. +The Workflow SDK replaces that JSON DSL with TypeScript. `"use workflow"` functions orchestrate `"use step"` functions in the same file. Branching is `if`/`else`. Waiting is `sleep()`. Parallelism is `Promise.all()`. Retries move down to the step level. -The migration path is mostly about **replacing declarative configuration with idiomatic TypeScript** and **collapsing the orchestrator/compute split**, not rewriting business logic. +The migration replaces declarative configuration with idiomatic TypeScript and collapses the orchestrator/compute split. Business logic stays the same. ## Concept mapping | AWS Step Functions | Workflow SDK | Migration note | | --- | --- | --- | -| State machine (ASL JSON) | `"use workflow"` function | The workflow function _is_ the state machine — expressed as async TypeScript. | -| Task state / Lambda | `"use step"` function | Put side effects and Node.js access in steps. No separate Lambda deployment. | -| Choice state | `if` / `else` / `switch` | Use native TypeScript control flow instead of JSON condition rules. | -| Wait state | `sleep()` | Import `sleep` from `workflow` and call it in your workflow function. | -| Parallel state | `Promise.all()` | Run steps concurrently with standard JavaScript concurrency primitives. | -| Map state | Loop + `Promise.all()` or batched child workflows | Iterate over items with `for`/`map` and parallelize as needed. | -| Retry / Catch | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retry logic moves down to the step level with `try`/`catch` for error handling. | -| `.waitForTaskToken` | `createHook()` or `createWebhook()` | Use hooks for typed resume signals; webhooks for HTTP callbacks. | -| Child state machine (`StartExecution`) | `"use step"` wrappers around `start()` / `getRun()` | Start the child from a step, return its `runId`, then poll or await the child result from another step. | -| Execution event history | Workflow event log / run timeline | Same durable replay idea, fewer surfaces to manage directly. | - -## Side-by-side: hello workflow - -### AWS Step Functions - -State machine definition (ASL): - -```json -{ - "StartAt": "LoadOrder", - "States": { - "LoadOrder": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:loadOrder", - "Next": "ReserveInventory" - }, - "ReserveInventory": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:reserveInventory", - "Next": "ChargePayment" - }, - "ChargePayment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:chargePayment", - "End": true - } - } -} +| State machine (ASL JSON) | `"use workflow"` function | The workflow function is the state machine. | +| Task state / Lambda | `"use step"` function | Side effects go in steps. No separate Lambda. | +| Choice state | `if` / `else` / `switch` | Native TypeScript control flow. | +| Wait state | `sleep()` | Import `sleep` from `workflow`. | +| Parallel state | `Promise.all()` | Standard concurrency primitives. | +| Map state | Loop + `Promise.all()` or child workflows | Iterate with `for`/`map`. | +| Retry / Catch | Step retries, `RetryableError`, `FatalError` | Retry logic moves to step boundaries. | +| `.waitForTaskToken` | `createHook()` or `createWebhook()` | Hooks for typed signals; webhooks for HTTP. | +| Child state machine (`StartExecution`) | `"use step"` around `start()` / `getRun()` | Return `runId`, await result from another step. | +| Execution event history | Workflow event log | Same durable replay model. | + + +`.waitForTaskToken` becomes `createHook()` or `createWebhook()`. Choice states become `if`/`else`. Map states become `Promise.all()`. Retry policies move from per-state configuration to step-level defaults. + + +## Translate your first workflow + +Start with a single Task state. In ASL, even "call one Lambda" requires a state machine shell: + +```json title="stateMachine.asl.json" +// Before (ASL) +"LoadOrder": { "Type": "Task", "Resource": "arn:...:function:loadOrder", "End": true } ``` -Plus three separate Lambda functions: +```typescript title="workflow/workflows/order.ts" +// After (TypeScript) +export async function processOrder(orderId: string) { + 'use workflow'; // [!code highlight] + return await loadOrder(orderId); +} -{/* @skip-typecheck */} -```typescript -// lambda/loadOrder.ts -export const handler = async (event: { orderId: string }) => { - const res = await fetch( - `https://example.com/api/orders/${event.orderId}` - ); +async function loadOrder(orderId: string) { + 'use step'; // [!code highlight] + const res = await fetch(`https://example.com/api/orders/${orderId}`); return res.json() as Promise<{ id: string }>; -}; +} +``` -// lambda/reserveInventory.ts -export const handler = async (event: { id: string }) => { - await fetch(`https://example.com/api/orders/${event.id}/reserve`, { - method: 'POST', - }); - return event; -}; +What changed: the ASL state machine and its Lambda collapse into two directive-tagged functions in one file. -// lambda/chargePayment.ts -export const handler = async (event: { id: string }) => { - await fetch(`https://example.com/api/orders/${event.id}/charge`, { - method: 'POST', - }); - return { orderId: event.id, status: 'completed' }; -}; -``` +### Adding a second step -### Workflow SDK +In ASL, a second Task means a new state and a `"Next"` transition. In the Workflow SDK, it's another `await`: ```typescript -// workflow/workflows/order.ts export async function processOrder(orderId: string) { 'use workflow'; - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; -} - -async function loadOrder(orderId: string) { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - return res.json() as Promise<{ id: string }>; -} - -async function reserveInventory(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/reserve`, { - method: 'POST', - }); -} - -async function chargePayment(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/charge`, { - method: 'POST', - }); + await reserveInventory(order.id); // [!code highlight] + return { orderId: order.id, status: 'reserved' }; } ``` -The JSON state machine, three Lambda deployments, and IAM wiring collapse into a single TypeScript file. The orchestration reads as the async control flow it always was — `await` replaces `"Next"` transitions, and each step is a function instead of a separately deployed Lambda. +`await` replaces `"Next"`. Each new step is a new function with `"use step"`; no additional deployment. -## Side-by-side: waiting for external approval +### Starting from an API route -### AWS Step Functions +Step Functions starts a run via `StartExecution` (AWS SDK or API Gateway integration). The Workflow SDK starts a run with `start()` from a route handler: -```json -{ - "StartAt": "WaitForApproval", - "States": { - "WaitForApproval": { - "Type": "Task", - "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", - "Parameters": { - "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123456789/approvals", - "MessageBody": { - "refundId.$": "$.refundId", - "taskToken.$": "$$.Task.Token" - } - }, - "Next": "CheckApproval" - }, - "CheckApproval": { - "Type": "Choice", - "Choices": [ - { - "Variable": "$.approved", - "BooleanEquals": true, - "Next": "Approved" - } - ], - "Default": "Rejected" - }, - "Approved": { - "Type": "Pass", - "Result": { "status": "approved" }, - "End": true - }, - "Rejected": { - "Type": "Pass", - "Result": { "status": "rejected" }, - "End": true - } - } +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflows/order'; + +export async function POST(request: Request) { + const { orderId } = (await request.json()) as { orderId: string }; + const run = await start(processOrder, [orderId]); // [!code highlight] + return Response.json({ runId: run.runId }); } ``` -The callback handler calls `SendTaskSuccess` with the task token: +## Wait for an external signal -```typescript -// lambda/approveRefund.ts -import { SFNClient, SendTaskSuccessCommand } from '@aws-sdk/client-sfn'; - -const sfn = new SFNClient({}); - -export const handler = async (event: { - taskToken: string; - approved: boolean; -}) => { - await sfn.send( - new SendTaskSuccessCommand({ - taskToken: event.taskToken, - output: JSON.stringify({ approved: event.approved }), - }) - ); -}; -``` +The minimal ASL for a callback is a Task with `.waitForTaskToken`: -### Workflow SDK +```json title="approval.asl.json" +// Before (ASL) +"WaitForApproval": { "Type": "Task", "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", "End": true } +``` -```typescript -// workflow/workflows/refund.ts +```typescript title="workflow/workflows/refund.ts" +// After (TypeScript) import { createHook } from 'workflow'; export async function refundWorkflow(refundId: string) { 'use workflow'; - - using approval = createHook<{ approved: boolean }>({ + using approval = createHook<{ approved: boolean }>({ // [!code highlight] token: `refund:${refundId}:approval`, }); - - const { approved } = await approval; - - if (!approved) { - return { refundId, status: 'rejected' }; - } - - return { refundId, status: 'approved' }; + return await approval; } ``` -### Resuming the hook from an API route +What changed: no SQS queue, no task token, no callback Lambda — the hook suspends the workflow durably until it is resumed. -```typescript -// app/api/refunds/[refundId]/approve/route.ts -import { resumeHook } from 'workflow/api'; +### Resuming the hook -export async function POST( - request: Request, - { params }: { params: Promise<{ refundId: string }> } -) { - const { refundId } = await params; - const body = (await request.json()) as { approved: boolean }; +Step Functions resumes by calling `SendTaskSuccess` with the task token. The Workflow SDK resumes by calling `resumeHook` with the hook's token: - await resumeHook(`refund:${refundId}:approval`, { - approved: body.approved, - }); +```typescript title="app/api/refunds/[refundId]/approve/route.ts" +import { resumeHook } from 'workflow/api'; +export async function POST(req: Request, { params }: { params: Promise<{ refundId: string }> }) { + const { refundId } = await params; + const { approved } = (await req.json()) as { approved: boolean }; + await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight] return Response.json({ ok: true }); } ``` -Step Functions' `.waitForTaskToken` pattern requires SQS (or another service) to deliver the task token, a separate Lambda to call `SendTaskSuccess`/`SendTaskFailure`, and a Choice state to branch on the result. With Workflow, `createHook()` suspends durably until resumed — no queue, no task token plumbing, no separate callback handler. +### Branching on the result -### Child workflows: keep `start()` and `getRun()` in steps +In ASL, branching after the wait requires a Choice state. In TypeScript, it's just `if`/`else`: -When you need an independent child run, the important migration detail is the **step boundary**. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass serializable `runId` values through the workflow: +```json +// Before (ASL) +"CheckApproval": { "Type": "Choice", "Choices": [{ "Variable": "$.approved", "BooleanEquals": true, "Next": "Approved" }], "Default": "Rejected" } +``` ```typescript -import { getRun, start } from 'workflow/api'; +// After (TypeScript) +const { approved } = await approval; +if (approved) return { refundId, status: 'approved' }; // [!code highlight] +return { refundId, status: 'rejected' }; +``` -async function processItem(item: string): Promise { - 'use step'; - return `processed-${item}`; -} +## Spawn a child workflow -export async function childWorkflow(item: string) { - 'use workflow'; - const result = await processItem(item); - return { item, result }; -} +In ASL, a parent machine calls `StartExecution` (usually via `.sync` or `.waitForTaskToken`) to launch a child. In the Workflow SDK, `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass the serializable `runId` through the workflow. + +### Parent starts a child + +```typescript title="workflow/workflows/parent.ts" +import { start } from 'workflow/api'; async function spawnChild(item: string): Promise { - 'use step'; + 'use step'; // [!code highlight] const run = await start(childWorkflow, [item]); return run.runId; } -async function collectResult( - runId: string -): Promise<{ item: string; result: string }> { - 'use step'; - const run = getRun(runId); - const value = await run.returnValue; - return value as { item: string; result: string }; -} - export async function parentWorkflow(item: string) { 'use workflow'; const runId = await spawnChild(item); - const result = await collectResult(runId); - return { childRunId: runId, result }; + return { childRunId: runId }; } ``` -## End-to-end migration: order processing saga +### Awaiting the child's result -This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. +Add a second step that wraps `getRun()` and awaits `returnValue`: -### AWS Step Functions version - -A Step Functions saga requires explicit Catch blocks on each state to trigger compensation states, resulting in a large ASL graph: +```typescript +import { getRun } from 'workflow/api'; -```json -{ - "StartAt": "LoadOrder", - "States": { - "LoadOrder": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:loadOrder", - "Next": "ReserveInventory", - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "FailOrder" }] - }, - "ReserveInventory": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:reserveInventory", - "Next": "ChargePayment", - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "ReleaseInventory" }] - }, - "ChargePayment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:chargePayment", - "Next": "CreateShipment", - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "RefundPayment" }] - }, - "CreateShipment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:createShipment", - "End": true, - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "CancelShipmentCompensation" }] - }, - "CancelShipmentCompensation": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:cancelShipment", - "Next": "RefundPayment" - }, - "RefundPayment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:refundPayment", - "Next": "ReleaseInventory" - }, - "ReleaseInventory": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:releaseInventory", - "Next": "FailOrder" - }, - "FailOrder": { - "Type": "Fail", - "Error": "OrderProcessingFailed" - } - } +async function collectResult(runId: string) { + 'use step'; // [!code highlight] + const run = getRun(runId); + return (await run.returnValue) as { item: string; result: string }; } ``` -Each Task state maps to a separate Lambda function, and the compensation chain (CancelShipment → RefundPayment → ReleaseInventory → Fail) must be wired explicitly in the state machine. +Then in the workflow: `const result = await collectResult(runId);`. The child workflow itself (`childWorkflow`) is defined elsewhere with `"use workflow"`. -### Workflow SDK version +## Migrate a full order-processing saga -```typescript -import { FatalError, getStepMetadata, getWritable } from 'workflow'; +This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming. + +### Step Functions version + +A Step Functions saga wires Retry and a Catch-to-compensation path on every forward state. Two representative states: + +```json title="saga.asl.json" +"ReserveInventory": { + "Type": "Task", + "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], + "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "ReleaseInventory" }], + "Next": "ChargePayment" +}, +"ReleaseInventory": { "Type": "Task", "Next": "FailOrder" } +``` + +Other forward states (`ChargePayment`, `CreateShipment`) repeat the same Retry/Catch shape, and the compensation chain is wired explicitly as separate states. + +### Workflow SDK version -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; +The forward path lives in the workflow function. A rollback stack replaces the Catch-to-compensation wiring: +```typescript title="workflow/workflows/order-saga.ts" export async function processOrderSaga(orderId: string) { 'use workflow'; + const rollbacks: Array<() => Promise> = []; // [!code highlight] - const rollbacks: Array<() => Promise> = []; - - try { - const order = await loadOrder(orderId); - await emitProgress({ stage: 'loaded', orderId: order.id }); - - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - await emitProgress({ stage: 'inventory_reserved', orderId: order.id }); - - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - await emitProgress({ stage: 'payment_captured', orderId: order.id }); - - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); - await emitProgress({ stage: 'shipment_created', orderId: order.id }); - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; - } catch (error) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); - } - throw error; - } + const order = await loadOrder(orderId); + const inventory = await reserveInventory(order); + rollbacks.push(() => releaseInventory(inventory.reservationId)); + const payment = await chargePayment(order); + rollbacks.push(() => refundPayment(payment.chargeId)); + + return { orderId: order.id, status: 'completed' }; } +``` -async function loadOrder(orderId: string): Promise { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - if (!res.ok) throw new FatalError('Order not found'); - return res.json() as Promise; +Progress emission and the `createShipment` step are elided for clarity; they follow the same push-a-rollback pattern. + +#### Rollback on failure + +Wrap the forward path in `try/catch` and drain the stack in reverse: + +```typescript +try { + // forward path as above +} catch (error) { + while (rollbacks.length > 0) { // [!code highlight] + await rollbacks.pop()!(); + } + throw error; } +``` + +#### Forward steps: retry + idempotency + +Retry moves from per-state ASL config to step defaults. `FatalError` opts out of retry; `getStepMetadata().stepId` gives a stable idempotency key: + +```typescript title="workflow/workflows/order-saga.ts" +import { FatalError, getStepMetadata } from 'workflow'; async function reserveInventory(order: Order): Promise { 'use step'; const { stepId } = getStepMetadata(); const res = await fetch('https://example.com/api/inventory/reservations', { method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, + headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, // [!code highlight] body: JSON.stringify({ orderId: order.id }), }); + if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] if (!res.ok) throw new Error('Inventory reservation failed'); return res.json() as Promise; } +``` -async function releaseInventory(reservationId: string): Promise { - 'use step'; - await fetch( - `https://example.com/api/inventory/reservations/${reservationId}`, - { method: 'DELETE' } - ); -} +`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. Compensation steps (`releaseInventory`, `refundPayment`, `cancelShipment`) are `"use step"` functions that issue a DELETE or refund call — each runs durably even if the workflow restarts mid-rollback. -async function chargePayment(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/payments/charges', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - }); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; -} +#### Streaming progress -async function refundPayment(chargeId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { - method: 'POST', - }); -} +`getWritable()` replaces DynamoDB or SNS for client-visible updates: + +```typescript +import { getWritable } from 'workflow'; -async function createShipment(order: Order): Promise { +async function emitProgress(update: { stage: string; orderId: string }) { 'use step'; - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; + const writer = getWritable().getWriter(); + await writer.write(update); // [!code highlight] + writer.releaseLock(); } +``` -async function cancelShipment(shipmentId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/shipments/${shipmentId}`, { - method: 'DELETE', - }); +Three things collapse in this migration: + +- A rollback stack replaces the explicit Catch-to-compensation wiring on every state. +- `getStepMetadata().stepId` supplies the idempotency key — no manual token management. +- `getWritable()` streams progress durably. In Step Functions, progress typically goes to DynamoDB or SNS for client polling. + +## What you stop operating + +Moving off Step Functions removes these surfaces from the application: + +- ASL state machine JSON and its reference syntax. +- Per-task Lambda functions, their IAM roles, and CloudFormation/CDK wiring. +- Task-token delivery infrastructure (SQS queues, callback Lambdas). +- Separate progress channels (DynamoDB, SNS) for client-visible updates. +- CloudWatch and X-Ray configuration for orchestrator observability. + +Workflow and step functions live in the same deployment as the application. State transitions are `await` calls. Progress streaming, retries, and observability are built in. + +## Step-by-step first migration + +Pick one state machine and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path. + +### Step 1: Install the Workflow SDK + +Add the runtime and the framework integration that matches the app. + +```bash +pnpm add workflow @workflow/next +``` + +### Step 2: Rewrite the state machine as a `"use workflow"` function + +Transitions become `await` calls. Control flow (`Choice`, `Wait`, `Parallel`, `Map`) becomes `if`/`switch`, `sleep`, `Promise.all`, and loops. + +```ts title="workflows/order.ts" +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + const order = await loadOrder(orderId); + if (order.total > 1000) await reviewManually(order); + await chargePayment(order); } +``` -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writable = getWritable<{ stage: string; orderId: string }>(); - const writer = writable.getWriter(); - try { - await writer.write(update); - } finally { - writer.releaseLock(); - } +### Step 3: Move each Lambda into a step function + +Inline the Lambda body into a function with `"use step"` on the first line. Step functions keep full Node.js access, so existing SDK calls work unchanged. + +```ts +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); } ``` -- Step Functions compensation requires explicit Catch-to-compensation-state wiring for every state in the graph. With Workflow, a rollback stack in the workflow function handles compensation for any number of steps without duplicating graph nodes. -- Use `getStepMetadata().stepId` as the idempotency key for payment and inventory APIs — no manual token management required. -- Stream user-visible progress from steps with `getWritable()` instead of adding a separate progress transport. Step Functions has no built-in equivalent; you would typically write to DynamoDB or SNS and poll from the client. +### Step 4: Replace `.waitForTaskToken` with a hook -## Why teams usually simplify infrastructure in this move +Swap the task-token callback Lambda for `createHook()`. Callers `resumeHook(token, payload)` instead of `SendTaskSuccess`. -Step Functions requires you to define state machines in JSON (Amazon States Language), deploy each task as a separate Lambda function, manage IAM roles between the orchestrator and every Lambda, and configure CloudWatch, X-Ray, or third-party tooling for observability. These are powerful primitives when you need visual workflow editing or cross-service AWS orchestration — but for TypeScript-first teams, they are overhead without a corresponding benefit. +### Step 5: Start runs from the app -With the Workflow SDK: +Delete the `StartExecution` call and IAM wiring. Launch runs directly from an API route: + +```ts title="app/api/orders/route.ts" +import { start } from "workflow/api"; +import { processOrder } from "@/workflows/order"; + +export async function POST(req: Request) { + const { orderId } = await req.json(); + const run = await start(processOrder, [orderId]); + return Response.json({ runId: run.runId }); +} +``` -- **No state machine JSON.** The workflow function _is_ the state machine. Branching, looping, and error handling are TypeScript — not a JSON DSL with its own type system and reference syntax. -- **No Lambda function wiring.** Steps run in the same deployment as your app. There are no separate functions to deploy, version, or connect with IAM policies. -- **No infrastructure to provision.** There is no CloudFormation/CDK stack for the orchestrator, no Lambda concurrency limits to tune, and no pricing tiers to navigate. -- **TypeScript all the way down.** Workflow and step functions are regular TypeScript with directive annotations. State transitions are `await` calls, not `"Next"` pointers. -- **Durable streaming built in.** `getWritable()` lets you push progress updates from steps without adding DynamoDB, SNS, or a WebSocket API Gateway. -- **Efficient resource usage.** When a workflow is suspended on `sleep()` or a hook, it pauses cleanly instead of keeping a worker process alive. +### Step 6: Retire the Step Functions infrastructure -This is not about replacing every Step Functions feature. It is about recognizing that most TypeScript teams use a fraction of Step Functions' state-language surface and pay for the rest in configuration complexity and AWS service sprawl. +Delete the ASL JSON, per-task Lambda deployments, IAM roles, and callback queues. Remove CloudWatch and X-Ray wiring used for orchestrator observability. Verify the run in `npx workflow web` before shipping. ## Quick-start checklist -- Replace your ASL state machine JSON with a single `"use workflow"` function. State transitions become `await` calls. -- Convert each Task state / Lambda function into a `"use step"` function in the same file. -- Replace Choice states with `if`/`else`/`switch` in your workflow function. -- Replace Wait states with `sleep()` imported from `workflow`. -- Replace Parallel states with `Promise.all()` over concurrent step calls. -- Replace Map states with loops or `Promise.all()` over arrays, using `"use step"` wrappers around `start()` for large fan-outs. -- Replace child state machines (`StartExecution`) with `"use step"` wrappers around `start()` and `getRun()` when you need independent child runs. -- Replace `.waitForTaskToken` callback patterns with `createHook()` or `createWebhook()` depending on whether the caller is internal or HTTP-based. -- Move Retry/Catch configuration down to step boundaries using default retries, `maxRetries`, `RetryableError`, and `FatalError`, with standard `try`/`catch` for error handling. -- Add idempotency keys to external side effects using `getStepMetadata().stepId`. -- Stream user-visible progress from steps with `getWritable()` when you previously polled DynamoDB or used SNS notifications. -- Deploy your app and verify workflows run end-to-end with built-in observability. +- Replace the ASL state machine with a single `"use workflow"` function. Transitions become `await` calls. +- Convert each Task / Lambda into a `"use step"` function in the same file. +- Replace Choice states with `if`/`else`/`switch`. +- Replace Wait states with `sleep()` from `workflow`. +- Replace Parallel states with `Promise.all()`. +- Replace Map states with loops or `Promise.all()`. For large fan-outs, wrap `start()` in a step. +- Replace `StartExecution` child machines with `"use step"` wrappers around `start()` and `getRun()`. +- Replace `.waitForTaskToken` with `createHook()` (internal callers) or `createWebhook()` (HTTP callers). +- Move Retry/Catch to step boundaries using `maxRetries`, `RetryableError`, and `FatalError`. +- Use `getStepMetadata().stepId` as the idempotency key for external side effects. +- Stream progress from steps with `getWritable()` instead of polling DynamoDB or SNS. +- Deploy and verify runs end-to-end with built-in observability. diff --git a/docs/content/docs/migration-guides/migrating-from-inngest.mdx b/docs/content/docs/migration-guides/migrating-from-inngest.mdx index 4da3d12c51..752041e5b6 100644 --- a/docs/content/docs/migration-guides/migrating-from-inngest.mdx +++ b/docs/content/docs/migration-guides/migrating-from-inngest.mdx @@ -14,245 +14,167 @@ related: - /docs/deploying/world/vercel-world --- -## What changes when you leave Inngest? +## What changes when you leave Inngest -With Inngest, you define functions using `inngest.createFunction()`, register them through a `serve()` handler, and break work into steps with `step.run()`, `step.sleep()`, and `step.waitForEvent()`. The Inngest platform manages event routing, step execution, and retries on its infrastructure. +Inngest defines functions with `inngest.createFunction()`, registers them through a `serve()` handler, and breaks work into steps with `step.run()`, `step.sleep()`, and `step.waitForEvent()`. The platform routes events, schedules steps, and applies retries. -With the Workflow SDK, you write `"use workflow"` functions that orchestrate `"use step"` functions — all in the same file, all plain TypeScript. There is no separate function registry, no event-driven dispatch layer, and no SDK client to configure. Durable replay, automatic retries, and step-level persistence still exist — they are built into the runtime. +The Workflow SDK replaces that with `"use workflow"` functions that orchestrate `"use step"` functions in plain TypeScript. There is no function registry, event dispatch layer, or SDK client. Durable replay, automatic retries, and step-level persistence are built into the runtime. -The migration path is mostly about **collapsing the SDK abstraction** and **writing plain async functions**, not rewriting business logic. +Migration collapses the SDK abstraction into plain async functions. Business logic stays the same. ## Concept mapping | Inngest | Workflow SDK | Migration note | | --- | --- | --- | -| `inngest.createFunction()` | `"use workflow"` function started with `start()` | The workflow function itself is the entry point — no wrapper needed. | -| `step.run()` | `"use step"` function | Each step is a standalone async function with full Node.js access. | -| `step.sleep()` / `step.sleepUntil()` | `sleep()` | Import `sleep` from `workflow` and call it in your workflow function. | -| `step.waitForEvent()` | `createHook()` or `createWebhook()` | Use hooks for typed resume signals; webhooks for HTTP callbacks. | -| `step.invoke()` | `"use step"` wrappers around `start()` / `getRun()` | Start another workflow from a step, return its `runId`, then await the child result from another step. | -| `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly instead of routing through an event bus. | -| Retry configuration (`retries`) | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retry logic moves down to the step level. | -| `step.sendEvent()` | `"use step"` wrapper around `start()` | Fan out to other workflows from a step instead of emitting onto an event bus. | -| Realtime / `step.realtime.publish()` | `getWritable()` | Stream progress directly from steps with built-in durable streaming. | +| `inngest.createFunction()` | `"use workflow"` function started with `start()` | No wrapper needed. | +| `step.run()` | `"use step"` function | Standalone async function with Node.js access. | +| `step.sleep()` / `step.sleepUntil()` | `sleep()` | Import from `workflow`. | +| `step.waitForEvent()` | `createHook()` or `createWebhook()` | Hooks for typed signals, webhooks for HTTP. | +| `step.invoke()` | `"use step"` wrappers around `start()` / `getRun()` | Spawn a child run, pass `runId` forward. | +| `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly. | +| Retry configuration (`retries`) | `RetryableError`, `FatalError`, `maxRetries` | Retry logic lives at the step level. | +| `step.sendEvent()` | `"use step"` wrapper around `start()` | Fan out via `start()`, not an event bus. | +| Realtime / `step.realtime.publish()` | `getWritable()` | Durable streaming from steps. | -## Side-by-side: hello workflow +## Translate your first workflow -### Inngest - -```typescript -// inngest/functions/order.ts -import { inngest } from '../client'; +Start with the shell of a function. Inngest wraps it in `createFunction`; Workflow SDK marks it with a directive. +```typescript title="inngest/functions/order.ts" export const processOrder = inngest.createFunction( { id: 'process-order' }, { event: 'order/created' }, async ({ event, step }) => { - const order = await step.run('load-order', async () => { - const res = await fetch( - `https://example.com/api/orders/${event.data.orderId}` - ); - return res.json() as Promise<{ id: string }>; - }); - - await step.run('reserve-inventory', async () => { - await fetch(`https://example.com/api/orders/${order.id}/reserve`, { - method: 'POST', - }); - }); - - await step.run('charge-payment', async () => { - await fetch(`https://example.com/api/orders/${order.id}/charge`, { - method: 'POST', - }); - }); - - return { orderId: order.id, status: 'completed' }; + return { orderId: event.data.orderId, status: 'completed' }; } ); ``` -### Workflow SDK - -```typescript -// workflow/workflows/order.ts +```typescript title="workflow/workflows/order.ts" export async function processOrder(orderId: string) { - 'use workflow'; - - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; + 'use workflow'; // [!code highlight] + return { orderId, status: 'completed' }; } +``` +**What changed:** the factory + event binding collapses into a plain exported function with a `"use workflow"` directive. + +### Add a step + +`step.run()` closures become named `"use step"` functions. + +```typescript title="workflow/workflows/order.ts" async function loadOrder(orderId: string) { - 'use step'; + 'use step'; // [!code highlight] const res = await fetch(`https://example.com/api/orders/${orderId}`); return res.json() as Promise<{ id: string }>; } - -async function reserveInventory(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/reserve`, { - method: 'POST', - }); -} - -async function chargePayment(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/charge`, { - method: 'POST', - }); -} ``` -The biggest change is replacing `step.run()` closures with named `"use step"` functions. Each step becomes a regular async function instead of an inline callback — easier to test, easier to reuse, and the orchestration reads as plain TypeScript. +Call it from the workflow like any async function: `const order = await loadOrder(orderId)`. Additional side effects (`reserveInventory`, `chargePayment`) follow the same shape. -## Side-by-side: waiting for external approval +### Start the run -### Inngest +Inngest dispatches via `inngest.send({ name: 'order/created', data: { orderId } })`. Workflow SDK launches directly: -```typescript -// inngest/functions/refund.ts -import { inngest } from '../client'; - -export const refundWorkflow = inngest.createFunction( - { id: 'refund-workflow' }, - { event: 'refund/requested' }, - async ({ event, step }) => { - const refundId = event.data.refundId; +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflow/workflows/order'; - const approval = await step.waitForEvent('wait-for-approval', { - event: 'refund/approved', - match: 'data.refundId', - timeout: '7d', - }); - - if (!approval) { - return { refundId, status: 'timed-out' }; - } - - if (!approval.data.approved) { - return { refundId, status: 'rejected' }; - } - - return { refundId, status: 'approved' }; - } -); +const run = await start(processOrder, [orderId]); // [!code highlight] ``` -### Workflow SDK +No event bus, no registry — `start()` returns a handle immediately. -```typescript -// workflow/workflows/refund.ts -import { createHook, sleep } from 'workflow'; +## Wait for an external signal -type ApprovalResult = - | { type: 'decision'; approved: boolean } - | { type: 'timeout'; approved: false }; +`step.waitForEvent()` becomes `createHook()` plus `await`. -export async function refundWorkflow(refundId: string) { - 'use workflow'; +```typescript title="workflow/workflows/refund.ts" +// Before (Inngest) +const approval = await step.waitForEvent('wait-for-approval', { + event: 'refund/approved', + match: 'data.refundId', +}); - using approval = createHook<{ approved: boolean }>({ - token: `refund:${refundId}:approval`, - }); +// After (Workflow SDK) +using approval = createHook<{ approved: boolean }>({ // [!code highlight] + token: `refund:${refundId}:approval`, +}); +const payload = await approval; +``` - const result: ApprovalResult = await Promise.race([ - approval.then((payload) => ({ - type: 'decision' as const, - approved: payload.approved, - })), - sleep('7d').then(() => ({ - type: 'timeout' as const, - approved: false as const, - })), - ]); - - if (result.type === 'timeout') { - return { refundId, status: 'timed-out' }; - } +**What changed:** event name + match expression collapse into a single `token` string. The caller supplies that token directly — no event schema. - if (!result.approved) { - return { refundId, status: 'rejected' }; - } +### Resume from an API route - return { refundId, status: 'approved' }; -} -``` +Inngest resumes with `inngest.send({ name: 'refund/approved', data: { refundId, approved } })`. The SDK equivalent is `resumeHook`: -### Resuming the hook from an API route - -```typescript -// app/api/refunds/[refundId]/approve/route.ts +```typescript title="app/api/refunds/[refundId]/approve/route.ts" import { resumeHook } from 'workflow/api'; -export async function POST( - request: Request, - { params }: { params: Promise<{ refundId: string }> } -) { +export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) { const { refundId } = await params; - const body = (await request.json()) as { approved: boolean }; - - await resumeHook(`refund:${refundId}:approval`, { - approved: body.approved, - }); - + const { approved } = (await request.json()) as { approved: boolean }; + await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight] return Response.json({ ok: true }); } ``` -Inngest's `step.waitForEvent()` with event matching maps to `createHook()`, and the `timeout: '7d'` behavior maps to `sleep('7d')` combined with `Promise.race()`. The workflow still suspends durably in both branches — there is no event bus or matching expression, but the timeout is modeled explicitly. +### Add a timeout and branch on the payload -### Child workflows: keep `start()` and `getRun()` in steps +Inngest's `timeout: '7d'` option maps to a `Promise.race()` with `sleep()`: -When you need an independent child run, the important migration detail is the **step boundary**. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass serializable `runId` values through the workflow: +```typescript title="workflow/workflows/refund.ts" +const result = await Promise.race([ + approval.then((p) => ({ type: 'decision' as const, approved: p.approved })), + sleep('7d').then(() => ({ type: 'timeout' as const })), // [!code highlight] +]); -```typescript -import { getRun, start } from 'workflow/api'; +if (result.type === 'timeout') return { refundId, status: 'timed-out' }; +if (!result.approved) return { refundId, status: 'rejected' }; +return { refundId, status: 'approved' }; +``` -async function processItem(item: string): Promise { - 'use step'; - return `processed-${item}`; -} + +Event matching disappears. A hook's token encodes the routing (for example, `refund:${refundId}:approval`), and the caller supplies that token to `resumeHook()`. + -export async function childWorkflow(item: string) { - 'use workflow'; - const result = await processItem(item); - return { item, result }; -} +## Spawn a child workflow +`step.invoke()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass the `runId` through the workflow. + +```typescript title="workflow/workflows/parent.ts" async function spawnChild(item: string): Promise { 'use step'; - const run = await start(childWorkflow, [item]); + const run = await start(childWorkflow, [item]); // [!code highlight] return run.runId; } +``` -async function collectResult( - runId: string -): Promise<{ item: string; result: string }> { +Await the result in a second step, then orchestrate both from the parent: + +```typescript title="workflow/workflows/parent.ts" +async function collectResult(runId: string) { 'use step'; const run = getRun(runId); - const value = await run.returnValue; - return value as { item: string; result: string }; + return await run.returnValue; // [!code highlight] } export async function parentWorkflow(item: string) { 'use workflow'; const runId = await spawnChild(item); - const result = await collectResult(runId); - return { childRunId: runId, result }; + return await collectResult(runId); } ``` -## End-to-end migration: order processing saga +## Migrate a full order-processing saga -This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. +This example covers compensation, idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. ### Inngest version -```typescript -// inngest/functions/order-saga.ts +```typescript title="inngest/functions/order-saga.ts" import { inngest } from '../client'; type Order = { id: string; customerId: string; total: number }; @@ -379,165 +301,196 @@ export const processOrderSaga = inngest.createFunction( ### Workflow SDK version -```typescript +The saga splits into four sections: the orchestrator, the forward steps, the compensation steps, and a progress emitter. Each block shares the same imports and types: + +```typescript title="workflow/workflows/order-saga.ts" import { FatalError, getStepMetadata, getWritable } from 'workflow'; type Order = { id: string; customerId: string; total: number }; type Reservation = { reservationId: string }; type Charge = { chargeId: string }; type Shipment = { shipmentId: string }; +``` -export async function processOrderSaga(orderId: string) { - 'use workflow'; +#### Orchestrator: forward path - const rollbacks: Array<() => Promise> = []; +The workflow calls each step in order and pushes a compensation onto a stack after every side effect. +```typescript +export async function processOrderSaga(orderId: string) { + 'use workflow'; + const rollbacks: Array<() => Promise> = []; // [!code highlight] try { const order = await loadOrder(orderId); - await emitProgress({ stage: 'loaded', orderId: order.id }); - const inventory = await reserveInventory(order); rollbacks.push(() => releaseInventory(inventory.reservationId)); - await emitProgress({ stage: 'inventory_reserved', orderId: order.id }); - const payment = await chargePayment(order); rollbacks.push(() => refundPayment(payment.chargeId)); - await emitProgress({ stage: 'payment_captured', orderId: order.id }); - const shipment = await createShipment(order); rollbacks.push(() => cancelShipment(shipment.shipmentId)); - await emitProgress({ stage: 'shipment_created', orderId: order.id }); - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; + return { orderId: order.id, shipmentId: shipment.shipmentId, status: 'completed' }; } catch (error) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); - } + await unwind(rollbacks); throw error; } } +``` -async function loadOrder(orderId: string): Promise { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - if (!res.ok) throw new FatalError('Order not found'); - return res.json() as Promise; +Sprinkle `await emitProgress(...)` between steps to stream stage updates. + +#### Compensation loop + +If any forward step throws, unwind in LIFO order: + +```typescript +async function unwind(rollbacks: Array<() => Promise>) { + while (rollbacks.length > 0) { + await rollbacks.pop()!(); // [!code highlight] + } } +``` + +#### Forward step with retry semantics + +`getStepMetadata().stepId` is stable across replays — pass it as `Idempotency-Key`. Use `FatalError` to skip retries on permanent failures. +```typescript async function reserveInventory(order: Order): Promise { 'use step'; - const { stepId } = getStepMetadata(); + const { stepId } = getStepMetadata(); // [!code highlight] const res = await fetch('https://example.com/api/inventory/reservations', { method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, + headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId: order.id }), }); + if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] if (!res.ok) throw new Error('Inventory reservation failed'); return res.json() as Promise; } +``` -async function releaseInventory(reservationId: string): Promise { - 'use step'; - await fetch( - `https://example.com/api/inventory/reservations/${reservationId}`, - { method: 'DELETE' } - ); -} +`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. -async function chargePayment(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/payments/charges', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - }); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; -} +#### Compensation steps + +Compensations are `"use step"` functions too, so a restart mid-rollback resumes where it left off. Keep them idempotent. -async function refundPayment(chargeId: string): Promise { +```typescript +async function releaseInventory(reservationId: string) { 'use step'; - await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { - method: 'POST', + await fetch(`https://example.com/api/inventory/reservations/${reservationId}`, { + method: 'DELETE', }); } +``` -async function createShipment(order: Order): Promise { +`refundPayment` and `cancelShipment` follow the same shape. + +#### Durable progress streaming + +`getWritable()` replaces Inngest Realtime. + +```typescript +async function emitProgress(update: { stage: string; orderId: string }) { 'use step'; - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; + const writer = getWritable().getWriter(); + try { await writer.write(update); } // [!code highlight] + finally { writer.releaseLock(); } } +``` -async function cancelShipment(shipmentId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/shipments/${shipmentId}`, { - method: 'DELETE', - }); + +Inngest's per-step try/catch compensation maps to a single rollback stack. The pattern scales to any number of steps without nested error handlers. + + +## What you stop operating + +Dropping the Inngest SDK removes several moving parts: + +- **No SDK client or serve handler.** Workflow files carry directive annotations. No registry, no serve endpoint. +- **No event bus.** `start()` launches workflows directly from API routes, server actions, or other entry points. No event schemas or dispatch layer. +- **No inline step closures.** Steps are named async functions. They type-check and test like any other TypeScript function. +- **No separate streaming transport.** `getWritable()` delivers progress to clients without WebSockets or SSE glue. +- **No idle workers.** Workflows suspended on `sleep()` or a hook consume no compute until resumed. + +## Step-by-step first migration + +Pick one Inngest function and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path. + +### Step 1: Install the Workflow SDK + +Add the runtime and the framework integration that matches the app. + +```bash +pnpm add workflow @workflow/next +``` + +### Step 2: Convert `createFunction` to a `"use workflow"` export + +Replace the factory call with a plain async export. Move the handler body up — the event-binding argument goes away. + +```ts title="workflows/order.ts" +// Before (Inngest) +// export const processOrder = inngest.createFunction( +// { id: "process-order" }, +// { event: "order.created" }, +// async ({ event, step }) => { ... } +// ); + +// After (Workflow SDK) +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + // ... } +``` -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writable = getWritable<{ stage: string; orderId: string }>(); - const writer = writable.getWriter(); - try { - await writer.write(update); - } finally { - writer.releaseLock(); - } +### Step 3: Convert `step.run` callbacks into named step functions + +Each inline callback becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`. + +```ts +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); } ``` -- Inngest's per-step compensation (`step.run` for rollback after a failed step) maps cleanly to a rollback stack in the workflow function. The rollback pattern scales to any number of steps without nested try/catch blocks. -- Use `getStepMetadata().stepId` as the idempotency key for payment and inventory APIs — no manual step naming required. -- Stream user-visible progress from steps with `getWritable()` instead of Inngest Realtime's `step.realtime.publish()`. The stream is durable and built into the workflow runtime. +### Step 4: Replace `waitForEvent`, `sleep`, and `invoke` -## Why teams usually simplify infrastructure in this move +- `step.waitForEvent(...)` → `createHook({ token })` + `await hook`. Resume it from an API route with `resumeHook(token, payload)`. +- `step.sleep(...)` → `sleep("5m")` from `workflow`. +- `step.invoke(child, { data })` → wrap `start(child, [data])` in a `"use step"` function and optionally read its return value with `getRun(runId).returnValue`. -Inngest adds an event-driven orchestration layer between your app and your durable logic. You configure an Inngest client, register functions through a `serve()` handler, route work through events, and manage step execution through the Inngest platform. This is a clean model when you want event-driven fan-out across many loosely coupled functions — but for TypeScript teams, the indirection often outweighs the benefit. +### Step 5: Start runs from the app -With the Workflow SDK: +Delete the `serve()` handler and event dispatch. Launch runs directly from an API route: + +```ts title="app/api/orders/route.ts" +import { start } from "workflow/api"; +import { processOrder } from "@/workflows/order"; + +export async function POST(req: Request) { + const { orderId } = await req.json(); + const run = await start(processOrder, [orderId]); + return Response.json({ runId: run.runId }); +} +``` -- **No SDK client or serve handler.** Workflow functions are regular TypeScript files with directive annotations. There is no client to configure, no function registry to maintain, and no serve endpoint to wire up. -- **No event bus.** Start workflows directly with `start()` from your API routes, server actions, or app boundary. You do not need to define event schemas or route through a dispatch layer. -- **TypeScript all the way down.** Steps are named async functions, not inline closures passed to `step.run()`. They are easier to test, type, and reuse. -- **Durable streaming built in.** `getWritable()` lets you push progress updates from steps without adding Inngest Realtime or a separate WebSocket/SSE transport. -- **Efficient resource usage.** When a workflow is suspended on `sleep()` or a hook, it pauses cleanly instead of keeping a worker process alive. +### Step 6: Retire the Inngest infrastructure -This is not about replacing every Inngest feature. It is about recognizing that most TypeScript teams use a fraction of Inngest's event-routing surface and pay for the rest in SDK complexity and platform coupling. +Remove the `inngest` client, the `serve()` route, event schemas, and the Inngest Dev Server from the app. Verify the run in `npx workflow web` before shipping. ## Quick-start checklist -- Replace `inngest.createFunction()` with a `"use workflow"` function and start it with `start()` from your app boundary. +- Replace `inngest.createFunction()` with a `"use workflow"` function; launch it with `start()`. - Convert each `step.run()` callback into a named `"use step"` function. -- Replace `step.sleep()` / `step.sleepUntil()` with `sleep()` imported from `workflow`. -- Replace `step.waitForEvent()` with `createHook()` or `createWebhook()` depending on whether the caller is internal or HTTP-based. -- When `step.waitForEvent()` includes a timeout, map it to `createHook()` or `createWebhook()` plus `sleep()` and `Promise.race()`. -- Replace `step.invoke()` with `"use step"` wrappers around `start()` and `getRun()` when you need a child workflow with an independent run. -- Replace `step.sendEvent()` fan-out with `start()` called from a `"use step"` function when the fan-out originates inside a workflow. -- Remove the Inngest client, `serve()` handler, and event definitions from your app. -- Move retry configuration down to step boundaries using default retries, `maxRetries`, `RetryableError`, and `FatalError`. -- Add idempotency keys to external side effects using `getStepMetadata().stepId`. -- Replace `step.realtime.publish()` with `getWritable()` for streaming progress to clients. -- Deploy your app and verify workflows run end-to-end with built-in observability. +- Swap `step.sleep()` / `step.sleepUntil()` for `sleep()` from `workflow`. +- Swap `step.waitForEvent()` for `createHook()` (internal) or `createWebhook()` (HTTP). +- Model `waitForEvent` timeouts as `Promise.race()` between the hook and `sleep()`. +- Replace `step.invoke()` with `"use step"` wrappers around `start()` and `getRun()`. +- Replace `step.sendEvent()` fan-out with `start()` called from a `"use step"` function. +- Remove the Inngest client, `serve()` handler, and event definitions. +- Push retry configuration down to step boundaries via `maxRetries`, `RetryableError`, and `FatalError`. +- Use `getStepMetadata().stepId` as the idempotency key for external side effects. +- Replace `step.realtime.publish()` with `getWritable()`. +- Deploy and verify end-to-end with the built-in observability UI. diff --git a/docs/content/docs/migration-guides/migrating-from-temporal.mdx b/docs/content/docs/migration-guides/migrating-from-temporal.mdx index 608ee9b895..7eb4b16e1e 100644 --- a/docs/content/docs/migration-guides/migrating-from-temporal.mdx +++ b/docs/content/docs/migration-guides/migrating-from-temporal.mdx @@ -14,13 +14,13 @@ related: - /docs/deploying/world/vercel-world --- -## What changes when you leave Temporal? +## What changes when you leave Temporal -With Temporal, you operate a control plane (Temporal Server or Cloud), deploy and maintain a Worker fleet, define Activities in a separate module, wire them through `proxyActivities`, and manage Task Queues. Your workflow code is durable, but the infrastructure around it is not trivial. +Temporal requires operating a control plane (Temporal Server or Cloud), a Worker fleet, Activity modules wired through `proxyActivities`, and Task Queues. The workflow code is durable; the surrounding infrastructure is substantial. -With the Workflow SDK, the runtime is managed for you. You write `"use workflow"` functions that orchestrate `"use step"` functions — all in the same file, all plain TypeScript. There are no Workers to poll, no Task Queues to configure, and no separate Activity modules to maintain. Durable replay, automatic retries, and event history still exist — they just happen behind the scenes. +The Workflow SDK runs on managed infrastructure. Write `"use workflow"` functions that orchestrate `"use step"` functions in the same file, in plain TypeScript. There are no Workers, Task Queues, or separate Activity modules. Durable replay, automatic retries, and event history are handled by the runtime. -The migration path is mostly about **removing infrastructure** and **collapsing indirection**, not rewriting business logic. +Migration removes infrastructure and collapses indirection. Business logic stays as regular async TypeScript. ## Concept mapping @@ -30,401 +30,356 @@ The migration path is mostly about **removing infrastructure** and **collapsing | Activity | `"use step"` function | Put side effects and Node.js access in steps. | | Worker + Task Queue | Managed execution | No worker fleet or polling loop to operate. | | Signal | `createHook()` or `createWebhook()` | Use hooks for typed resume signals; webhooks for HTTP callbacks. | -| Query / Update | `getRun()` + app API, or hook-driven mutation + returned state | Usually model reads through your app and writes through hooks/webhooks. | -| Child Workflow | `"use step"` wrappers around `start()` / `getRun()` | Spawn children from a step, return serializable `runId` values, then poll or await child results from another step. | -| Activity retry policy | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retry logic moves down to the step level. | -| Event History | Workflow event log / run timeline | Same durable replay idea, fewer surfaces to manage directly. | +| Query / Update | `getRun()` + app API, or hook-driven mutation | Reads go through the app; writes go through hooks. | +| Child Workflow | `"use step"` wrappers around `start()` / `getRun()` | Spawn from a step, pass `runId` through the workflow. | +| Activity retry policy | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retries live at the step boundary. | +| Event History | Workflow event log / run timeline | Same durable replay, fewer surfaces to manage. | -## Side-by-side: hello workflow +## Translate your first workflow -### Temporal +### Minimal translation -```typescript -// temporal/workflows/order.ts -import * as wf from '@temporalio/workflow'; -import type * as activities from '../activities/order'; +Start with the directive change. The Temporal definition proxies activities through a module; the Workflow SDK version puts the directive inline. -const { loadOrder, reserveInventory, chargePayment } = - wf.proxyActivities({ - startToCloseTimeout: '5 minute', - }); +```typescript title="workflows/order.ts" +// Before (Temporal) +const { chargePayment } = wf.proxyActivities({ + startToCloseTimeout: '5 minute', +}); export async function processOrder(orderId: string) { - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; + await chargePayment(orderId); + return { orderId, status: 'completed' }; } -``` - -### Workflow SDK -```typescript -// workflow/workflows/order.ts +// After (Workflow SDK) export async function processOrder(orderId: string) { - 'use workflow'; - - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; + 'use workflow'; // [!code highlight] + await chargePayment(orderId); + return { orderId, status: 'completed' }; } +``` -async function loadOrder(orderId: string) { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - return res.json() as Promise<{ id: string }>; -} +**What changed:** `proxyActivities` and the activity module disappear. The orchestrator is plain async TypeScript marked with `"use workflow"`. -async function reserveInventory(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/reserve`, { - method: 'POST', - }); -} +### Adding a step +Side effects move into a colocated `"use step"` function: + +```typescript title="workflow/workflows/order.ts" async function chargePayment(orderId: string) { - 'use step'; + 'use step'; // [!code highlight] await fetch(`https://example.com/api/orders/${orderId}/charge`, { method: 'POST', }); } ``` -The biggest code change is not "rewrite the workflow logic." It is "move side effects into `"use step"` functions and stop thinking about workers as application code." The orchestration stays as regular async TypeScript. +Additional steps (`loadOrder`, `reserveInventory`) follow the same shape and can be called from the workflow in sequence. -## Side-by-side: waiting for external approval +### Starting the run -### Temporal +Replace Worker + Task Queue wiring with a single `start()` call from an API route: -```typescript -// temporal/workflows/refund.ts -import * as wf from '@temporalio/workflow'; +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflows/order'; -export const approveRefund = wf.defineSignal<[boolean]>('approveRefund'); +export async function POST(request: Request) { + const { orderId } = (await request.json()) as { orderId: string }; + const run = await start(processOrder, [orderId]); // [!code highlight] + return Response.json({ runId: run.runId }); +} +``` -export async function refundWorkflow(refundId: string) { - let approved: boolean | undefined; +## Wait for an external signal - wf.setHandler(approveRefund, (value) => { - approved = value; - }); +### Minimal translation - await wf.condition(() => approved !== undefined); +Temporal needs a signal definition, a handler, and a `condition()` guard. The Workflow SDK collapses all three into a single `createHook()` + `await`. - if (!approved) { - return { refundId, status: 'rejected' }; - } +```typescript title="temporal/workflows/refund.ts" +export const approveRefund = wf.defineSignal<[boolean]>('approveRefund'); - return { refundId, status: 'approved' }; +export async function refundWorkflow(refundId: string) { + let approved: boolean | undefined; + wf.setHandler(approveRefund, (v) => { approved = v; }); + await wf.condition(() => approved !== undefined); + return { refundId, approved }; } ``` -### Workflow SDK - -```typescript -// workflow/workflows/refund.ts +```typescript title="workflow/workflows/refund.ts" import { createHook } from 'workflow'; export async function refundWorkflow(refundId: string) { 'use workflow'; - using approval = createHook<{ approved: boolean }>({ - token: `refund:${refundId}:approval`, + token: `refund:${refundId}:approval`, // [!code highlight] }); - - const { approved } = await approval; - - if (!approved) { - return { refundId, status: 'rejected' }; - } - - return { refundId, status: 'approved' }; + const { approved } = await approval; // [!code highlight] + return { refundId, approved }; } ``` -### Resuming the hook from an API route +The workflow suspends durably at `await approval` until resumed — no polling, no handler registration. -```typescript -// app/api/refunds/[refundId]/approve/route.ts +### Resuming from an API route + +Any HTTP caller can resume by token: + +```typescript title="app/api/refunds/[refundId]/approve/route.ts" import { resumeHook } from 'workflow/api'; -export async function POST( - request: Request, - { params }: { params: Promise<{ refundId: string }> } -) { +export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) { const { refundId } = await params; const body = (await request.json()) as { approved: boolean }; - - await resumeHook(`refund:${refundId}:approval`, { - approved: body.approved, - }); - + await resumeHook(`refund:${refundId}:approval`, body); // [!code highlight] return Response.json({ ok: true }); } ``` -Temporal's Signal + `condition()` pattern becomes a single `createHook()` call. The workflow suspends durably until the hook is resumed — no polling, no separate signal handler registration. + +Temporal Queries expose in-memory workflow state on demand. Hooks are a write channel — they resume a paused workflow with new data. To expose state, read it from the app using `getRun()` or persist it to a database from a step. + -### Child workflows: keep `start()` and `getRun()` in steps +## Spawn a child workflow -When you need an independent child run, the important migration detail is the **step boundary**. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass serializable `runId` values through the workflow: +### Minimal translation -```typescript -import { getRun, start } from 'workflow/api'; +`start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. The parent spawns a child by calling a step that returns the serializable `runId`. -async function processItem(item: string): Promise { - 'use step'; - return `processed-${item}`; -} - -export async function childWorkflow(item: string) { - 'use workflow'; - const result = await processItem(item); - return { item, result }; -} +```typescript title="workflow/workflows/parent.ts" +import { start } from 'workflow/api'; async function spawnChild(item: string): Promise { - 'use step'; + 'use step'; // [!code highlight] const run = await start(childWorkflow, [item]); return run.runId; } -async function collectResult( - runId: string -): Promise<{ item: string; result: string }> { - 'use step'; - const run = getRun(runId); - const value = await run.returnValue; - return value as { item: string; result: string }; -} - export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); - const result = await collectResult(runId); - return { childRunId: runId, result }; + const runId = await spawnChild(item); // [!code highlight] + return { childRunId: runId }; } ``` -## End-to-end migration: order processing saga +### Awaiting the child's return value -This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. +A second step fetches the run and awaits `returnValue`: -### Temporal version +```typescript title="workflow/workflows/parent.ts" +import { getRun } from 'workflow/api'; -A Temporal saga requires manual compensation wiring — typically nested try/catch blocks around each Activity call, with explicit rollback Activities in each catch: +async function collectResult(runId: string) { + 'use step'; + const run = getRun(runId); + return (await run.returnValue) as { item: string; result: string }; // [!code highlight] +} +``` -```typescript -// temporal/workflows/order-saga.ts -import * as wf from '@temporalio/workflow'; -import type * as activities from '../activities/order-saga'; - -const { - loadOrder, - reserveInventory, - releaseInventory, - chargePayment, - refundPayment, - createShipment, - cancelShipment, -} = wf.proxyActivities({ - startToCloseTimeout: '5 minute', - retry: { maximumAttempts: 3 }, -}); +Call both steps from the parent in sequence: `const result = await collectResult(runId)`. To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. + + +Activity retry policy moves to the step boundary. Use `maxRetries`, `RetryableError`, and `FatalError` on each step instead of a single workflow-wide retry block. + + +## Migrate a full order-processing saga +This example covers compensation, idempotency keys, retry semantics, and progress streaming. + +### Before: Temporal + +A Temporal saga wires compensations manually with nested try/catch around each Activity. The nesting deepens with each additional step: + +```typescript title="temporal/workflows/order-saga.ts" export async function processOrderSaga(orderId: string) { const order = await loadOrder(orderId); - const inventory = await reserveInventory(order); let payment: { chargeId: string }; try { payment = await chargePayment(order); } catch (error) { - await releaseInventory(inventory.reservationId); + await releaseInventory(inventory.reservationId); // [!code highlight] throw error; } - let shipment: { shipmentId: string }; try { - shipment = await createShipment(order); + const shipment = await createShipment(order); + return { orderId: order.id, shipmentId: shipment.shipmentId }; } catch (error) { - await refundPayment(payment.chargeId); - await releaseInventory(inventory.reservationId); + await refundPayment(payment.chargeId); // [!code highlight] + await releaseInventory(inventory.reservationId); // [!code highlight] throw error; } - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; } ``` -Each compensation step must be wired explicitly — and as the saga grows, the nested try/catch blocks multiply. There is no built-in streaming primitive; progress reporting requires a separate Activity that writes to an external store. +Activity imports and `proxyActivities` wiring are omitted — the point is the nested compensation structure, which multiplies as the saga grows. Progress reporting needs a separate Activity and external store. -### Workflow SDK version +### After: Workflow SDK -```typescript -import { FatalError, getStepMetadata, getWritable } from 'workflow'; +The workflow uses a rollback stack. Each forward step pushes its compensation, and a single catch unwinds them in reverse order. -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; +#### Orchestration — forward path +Start with the happy path. Each forward call pushes its compensation onto a stack. + +```typescript export async function processOrderSaga(orderId: string) { 'use workflow'; - const rollbacks: Array<() => Promise> = []; - try { - const order = await loadOrder(orderId); - await emitProgress({ stage: 'loaded', orderId: order.id }); + const order = await loadOrder(orderId); + const inventory = await reserveInventory(order); + rollbacks.push(() => releaseInventory(inventory.reservationId)); // [!code highlight] + const payment = await chargePayment(order); + rollbacks.push(() => refundPayment(payment.chargeId)); // [!code highlight] + const shipment = await createShipment(order); + rollbacks.push(() => cancelShipment(shipment.shipmentId)); // [!code highlight] - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - await emitProgress({ stage: 'inventory_reserved', orderId: order.id }); + return { orderId: order.id, shipmentId: shipment.shipmentId, status: 'completed' }; +} +``` - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - await emitProgress({ stage: 'payment_captured', orderId: order.id }); +#### Compensation — rollback loop - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); - await emitProgress({ stage: 'shipment_created', orderId: order.id }); - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; - } catch (error) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); - } - throw error; +Wrap the forward path in a try/catch. On failure, unwind the stack in reverse: + +```typescript +try { + // ...forward path above +} catch (error) { + while (rollbacks.length > 0) { + await rollbacks.pop()!(); // [!code highlight] } + throw error; } +``` -async function loadOrder(orderId: string): Promise { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - if (!res.ok) throw new FatalError('Order not found'); - return res.json() as Promise; -} +#### Steps — one forward step + +Forward steps use `FatalError` to skip retries on unrecoverable responses and `getStepMetadata().stepId` as an idempotency key: -async function reserveInventory(order: Order): Promise { +```typescript +import { FatalError, getStepMetadata } from 'workflow'; + +async function reserveInventory(order: Order) { 'use step'; const { stepId } = getStepMetadata(); const res = await fetch('https://example.com/api/inventory/reservations', { method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, + headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, // [!code highlight] body: JSON.stringify({ orderId: order.id }), }); + if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; + return res.json() as Promise<{ reservationId: string }>; } +``` -async function releaseInventory(reservationId: string): Promise { - 'use step'; - await fetch( - `https://example.com/api/inventory/reservations/${reservationId}`, - { method: 'DELETE' } - ); -} +The other forward steps (`loadOrder`, `chargePayment`, `createShipment`) follow the same shape. Compensation steps (`releaseInventory`, `refundPayment`, `cancelShipment`) are plain `DELETE`/refund calls marked `"use step"`. -async function chargePayment(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/payments/charges', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - }); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; -} +#### Streaming progress -async function refundPayment(chargeId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { - method: 'POST', - }); -} +`getWritable()` replaces the external progress transport. Writes flow back to the caller over the run's durable stream: + +```typescript +import { getWritable } from 'workflow'; -async function createShipment(order: Order): Promise { +async function emitProgress(update: { stage: string; orderId: string }) { 'use step'; - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; + const writer = getWritable().getWriter(); // [!code highlight] + try { await writer.write(update); } finally { writer.releaseLock(); } } +``` -async function cancelShipment(shipmentId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/shipments/${shipmentId}`, { - method: 'DELETE', - }); +Call `await emitProgress({ stage: 'inventory_reserved', orderId: order.id })` between forward steps. + +**What changed:** + +- Compensation wiring collapses into a rollback stack with a single catch block. +- `getStepMetadata().stepId` provides a stable idempotency key for external APIs. +- `getWritable()` streams progress from steps directly — no separate progress store. + +## What you stop operating + +- **Temporal Server or Cloud.** Durable state lives in the managed event log. +- **Worker fleet.** The runtime schedules execution; workflows run where the app runs. +- **Task Queues.** No queue routing to configure or monitor. +- **Activity modules and `proxyActivities`.** Steps live next to the workflow that calls them. +- **Custom progress transport.** `getWritable()` streams updates from steps. + +Suspended workflows (on `sleep()` or a hook) consume no compute until resumed. + +## Step-by-step first migration + +Pick one Temporal workflow and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path. + +### Step 1: Install the Workflow SDK + +Add the runtime and the Next.js integration (or the framework integration that matches the app). + +```bash +pnpm add workflow @workflow/next +``` + +### Step 2: Collapse Activities into step functions + +Delete the `activities/` module and the `proxyActivities` call. Each former Activity becomes a plain async function with `"use step"` on the first line, living next to the orchestrator. + +```ts title="workflows/order.ts" +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); } +``` -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writable = getWritable<{ stage: string; orderId: string }>(); - const writer = writable.getWriter(); - try { - await writer.write(update); - } finally { - writer.releaseLock(); - } +### Step 3: Mark the orchestrator with `"use workflow"` + +Keep the existing control flow — `await`, `try/catch`, `Promise.all`. The directive turns the function into a durable replay target. + +```ts +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + const order = await loadOrder(orderId); + // ... } ``` -- Temporal compensation logic maps cleanly to a rollback stack in the workflow function. -- Use `getStepMetadata().stepId` as the idempotency key for payment and inventory APIs. -- Stream user-visible progress from steps with `getWritable()` instead of adding a separate progress transport. +### Step 4: Replace Signals with hooks -## Why teams usually simplify infrastructure in this move +Swap `defineSignal` + `setHandler` for `createHook()`. Callers `resumeHook(token, payload)` instead of `client.workflow.signal(...)`. -Temporal requires you to operate (or pay for) a Temporal Server, deploy and scale a Worker fleet, and manage Task Queue routing. These are powerful abstractions when you need cross-language orchestration or custom search attributes — but for TypeScript-first teams, they are overhead without a corresponding benefit. +### Step 5: Start runs from the app -With the Workflow SDK: +Delete the Worker bootstrap. Launch runs from an API route or server action with `start()`: + +```ts title="app/api/orders/route.ts" +import { start } from "workflow/api"; +import { processOrder } from "@/workflows/order"; + +export async function POST(req: Request) { + const { orderId } = await req.json(); + const run = await start(processOrder, [orderId]); + return Response.json({ runId: run.runId }); +} +``` -- **No workers to operate.** The runtime manages execution scheduling. You deploy your app; workflows run where your app runs. -- **No separate server.** Durable state lives in the managed event log. There is no cluster to provision, monitor, or upgrade. -- **TypeScript all the way down.** Workflow and step functions are regular TypeScript with directive annotations. No code generation, no separate SDK for activities, no `proxyActivities` indirection. -- **Durable streaming built in.** `getWritable()` lets you push progress updates from steps without bolting on a separate WebSocket or SSE transport. -- **Efficient resource usage.** When a workflow is suspended on `sleep()` or a hook, it pauses cleanly instead of keeping a worker process alive. +### Step 6: Retire the Temporal infrastructure -This is not about replacing every Temporal feature. It is about recognizing that most TypeScript teams use a fraction of Temporal's surface and pay for the rest in operational complexity. +Remove the Worker process, `@temporalio/*` dependencies, and the Temporal Server or Cloud connection. Verify the run in the built-in observability UI (`npx workflow web`) before shipping. ## Quick-start checklist -- Move orchestration into a single `"use workflow"` function. -- Convert each Temporal Activity into a `"use step"` function. -- Remove Worker and task-queue application code; start workflows from your app boundary with `start()`. -- Replace Signals with `createHook()` or `createWebhook()` depending on whether the caller is internal or HTTP-based. -- Replace Child Workflows with `"use step"` wrappers around `start()` and `getRun()` when you need independent runs. Return serializable `runId` values to the workflow and collect child results from a step. -- Move retry policy down to step boundaries using default retries, `maxRetries`, `RetryableError`, and `FatalError`. -- Add idempotency keys to external side effects using `getStepMetadata().stepId`. -- Stream user-visible progress from steps with `getWritable()` when you previously used custom progress plumbing. -- Deploy your app and verify workflows run end-to-end with built-in observability. +- Move orchestration into a `"use workflow"` function. +- Convert each Activity into a `"use step"` function. +- Remove Worker and Task Queue code. Start workflows from the app with `start()`. +- Replace Signals with `createHook()` or `createWebhook()` for HTTP callers. +- Wrap `start()` and `getRun()` in `"use step"` functions for child workflows. Pass `runId` through the workflow. +- Set retry policy per step with `maxRetries`, `RetryableError`, and `FatalError`. +- Use `getStepMetadata().stepId` as the idempotency key for external side effects. +- Stream progress from steps with `getWritable()`. +- Deploy the app and verify runs end-to-end in the built-in observability UI. From a1f2dcb999299659c83bba44996001558a4021d1 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Mon, 13 Apr 2026 20:51:11 -0600 Subject: [PATCH 2/7] [docs] Fix factual inaccuracies in migration guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification pass across the three guides: - Temporal: correct `startToCloseTimeout` duration string ('5 minute' → '5 minutes'). - Inngest: update `inngest.createFunction` to the current 2-arg form with `triggers` inside the config, and add the required `timeout` option to the `step.waitForEvent` Before example. - AWS Step Functions: add missing `Resource: "arn:aws:states:::lambda:invoke"` + `Parameters` to the saga Task states so the ASL snippet is valid. Workflow SDK snippets checked against node_modules/workflow/docs and packages/core source; no changes needed. --- .../migrating-from-aws-step-functions.mdx | 9 ++++++++- .../migration-guides/migrating-from-inngest.mdx | 17 +++++++++++------ .../migrating-from-temporal.mdx | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx index f473cd9cc6..08e5cde09d 100644 --- a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -200,11 +200,18 @@ A Step Functions saga wires Retry and a Catch-to-compensation path on every forw ```json title="saga.asl.json" "ReserveInventory": { "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { "FunctionName": "reserveInventory", "Payload.$": "$" }, "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "ReleaseInventory" }], "Next": "ChargePayment" }, -"ReleaseInventory": { "Type": "Task", "Next": "FailOrder" } +"ReleaseInventory": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { "FunctionName": "releaseInventory", "Payload.$": "$" }, + "Next": "FailOrder" +} ``` Other forward states (`ChargePayment`, `CreateShipment`) repeat the same Retry/Catch shape, and the compensation chain is wired explicitly as separate states. diff --git a/docs/content/docs/migration-guides/migrating-from-inngest.mdx b/docs/content/docs/migration-guides/migrating-from-inngest.mdx index 752041e5b6..519b0256af 100644 --- a/docs/content/docs/migration-guides/migrating-from-inngest.mdx +++ b/docs/content/docs/migration-guides/migrating-from-inngest.mdx @@ -42,8 +42,10 @@ Start with the shell of a function. Inngest wraps it in `createFunction`; Workfl ```typescript title="inngest/functions/order.ts" export const processOrder = inngest.createFunction( - { id: 'process-order' }, - { event: 'order/created' }, + { + id: 'process-order', + triggers: [{ event: 'order/created' }], + }, async ({ event, step }) => { return { orderId: event.data.orderId, status: 'completed' }; } @@ -95,6 +97,7 @@ No event bus, no registry — `start()` returns a handle immediately. const approval = await step.waitForEvent('wait-for-approval', { event: 'refund/approved', match: 'data.refundId', + timeout: '7d', }); // After (Workflow SDK) @@ -183,8 +186,11 @@ type Charge = { chargeId: string }; type Shipment = { shipmentId: string }; export const processOrderSaga = inngest.createFunction( - { id: 'process-order-saga', retries: 3 }, - { event: 'order/process' }, + { + id: 'process-order-saga', + retries: 3, + triggers: [{ event: 'order/process' }], + }, async ({ event, step }) => { const orderId = event.data.orderId; @@ -432,8 +438,7 @@ Replace the factory call with a plain async export. Move the handler body up — ```ts title="workflows/order.ts" // Before (Inngest) // export const processOrder = inngest.createFunction( -// { id: "process-order" }, -// { event: "order.created" }, +// { id: "process-order", triggers: [{ event: "order.created" }] }, // async ({ event, step }) => { ... } // ); diff --git a/docs/content/docs/migration-guides/migrating-from-temporal.mdx b/docs/content/docs/migration-guides/migrating-from-temporal.mdx index 7eb4b16e1e..8fd738c46e 100644 --- a/docs/content/docs/migration-guides/migrating-from-temporal.mdx +++ b/docs/content/docs/migration-guides/migrating-from-temporal.mdx @@ -44,7 +44,7 @@ Start with the directive change. The Temporal definition proxies activities thro ```typescript title="workflows/order.ts" // Before (Temporal) const { chargePayment } = wf.proxyActivities({ - startToCloseTimeout: '5 minute', + startToCloseTimeout: '5 minutes', }); export async function processOrder(orderId: string) { From d75ea289a514b8afcffb1f6755405d25111fb2c6 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Mon, 13 Apr 2026 21:07:35 -0600 Subject: [PATCH 3/7] [docs] Bring migration-guide source sides to Grade A Second verification pass against each system's current docs: - Temporal: audited wf.proxyActivities / defineSignal / setHandler / condition usage; no further fixes needed. - Inngest: audited createFunction / step.run / step.waitForEvent / step.invoke / realtime patterns; no further fixes needed. - AWS Step Functions: LoadOrder Task now uses the optimized arn:aws:states:::lambda:invoke integration with Parameters; WaitForApproval SQS task has QueueUrl + MessageBody with TaskToken.$; CheckApproval Choice state formatted multi-line with sibling-state comment. Workflow SDK snippets re-audited against node_modules/workflow/docs and packages/core source. Both sides of each guide now match current canonical APIs. --- .../migrating-from-aws-step-functions.mdx | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx index 08e5cde09d..20243e54d8 100644 --- a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -46,8 +46,13 @@ The migration replaces declarative configuration with idiomatic TypeScript and c Start with a single Task state. In ASL, even "call one Lambda" requires a state machine shell: ```json title="stateMachine.asl.json" -// Before (ASL) -"LoadOrder": { "Type": "Task", "Resource": "arn:...:function:loadOrder", "End": true } +// Before (ASL) — excerpt from a state machine's "States" block +"LoadOrder": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { "FunctionName": "loadOrder", "Payload.$": "$" }, + "End": true +} ``` ```typescript title="workflow/workflows/order.ts" @@ -101,8 +106,19 @@ export async function POST(request: Request) { The minimal ASL for a callback is a Task with `.waitForTaskToken`: ```json title="approval.asl.json" -// Before (ASL) -"WaitForApproval": { "Type": "Task", "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", "End": true } +// Before (ASL) — excerpt; paired with a consumer that calls SendTaskSuccess +"WaitForApproval": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123456789012/approvals", + "MessageBody": { + "refundId.$": "$.refundId", + "TaskToken.$": "$$.Task.Token" + } + }, + "End": true +} ``` ```typescript title="workflow/workflows/refund.ts" @@ -140,8 +156,14 @@ export async function POST(req: Request, { params }: { params: Promise<{ refundI In ASL, branching after the wait requires a Choice state. In TypeScript, it's just `if`/`else`: ```json -// Before (ASL) -"CheckApproval": { "Type": "Choice", "Choices": [{ "Variable": "$.approved", "BooleanEquals": true, "Next": "Approved" }], "Default": "Rejected" } +// Before (ASL) — excerpt; "Approved" and "Rejected" are sibling states in "States" +"CheckApproval": { + "Type": "Choice", + "Choices": [ + { "Variable": "$.approved", "BooleanEquals": true, "Next": "Approved" } + ], + "Default": "Rejected" +} ``` ```typescript From 0c5337fc9a7e7880f8cc6899a77e804f4856ae26 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Mon, 13 Apr 2026 21:11:02 -0600 Subject: [PATCH 4/7] [docs] Add trigger.dev migration guide and skill reference Stakeholder-requested addition. Trigger.dev is a closer competitor than AWS Step Functions for the Workflow SDK audience. New files: - docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx Follows the same structure as the other three guides: concept mapping table, translate-first-workflow with Before/After, hook signal, child workflows, full order-processing saga with phased breakdown, step-by-step walkthrough, and checklist. - skills/migrating-to-workflow-sdk/references/trigger-dev.md Agent-facing reference with map / remove / add sections. Updates: - migration-guides index.mdx: fourth card for trigger.dev. - migration-guides meta.json: added to pages array. - migrating-to-workflow-sdk/SKILL.md: trigger.dev in intake sources list; version bumped 0.1.9 -> 0.1.10. --- docs/content/docs/migration-guides/index.mdx | 5 +- docs/content/docs/migration-guides/meta.json | 3 +- .../migrating-from-trigger-dev.mdx | 420 ++++++++++++++++++ skills/migrating-to-workflow-sdk/SKILL.md | 6 +- .../references/trigger-dev.md | 53 +++ 5 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx create mode 100644 skills/migrating-to-workflow-sdk/references/trigger-dev.md diff --git a/docs/content/docs/migration-guides/index.mdx b/docs/content/docs/migration-guides/index.mdx index 3ff673835b..8131493d61 100644 --- a/docs/content/docs/migration-guides/index.mdx +++ b/docs/content/docs/migration-guides/index.mdx @@ -2,7 +2,7 @@ title: Migration Guides description: Move your existing durable workflow system to the Workflow SDK with side-by-side code comparisons and a realistic migration example. type: overview -summary: Migrate from Temporal, Inngest, or AWS Step Functions to the Workflow SDK. +summary: Migrate from Temporal, Inngest, AWS Step Functions, or trigger.dev to the Workflow SDK. related: - /docs/foundations/workflows-and-steps - /docs/getting-started @@ -20,4 +20,7 @@ Move an existing orchestration system to the Workflow SDK. Each guide pairs a co Replace ASL JSON states, Task / Choice / Wait / Parallel states, and `.waitForTaskToken` callbacks with TypeScript. + + Map `task()`, `schemaTask()`, `wait.for` / `wait.forToken`, `triggerAndWait`, and `metadata.stream` onto workflows, steps, hooks, and `start()` / `getRun()`. + diff --git a/docs/content/docs/migration-guides/meta.json b/docs/content/docs/migration-guides/meta.json index 5682025c55..546403fbd0 100644 --- a/docs/content/docs/migration-guides/meta.json +++ b/docs/content/docs/migration-guides/meta.json @@ -3,6 +3,7 @@ "pages": [ "migrating-from-temporal", "migrating-from-inngest", - "migrating-from-aws-step-functions" + "migrating-from-aws-step-functions", + "migrating-from-trigger-dev" ] } diff --git a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx new file mode 100644 index 0000000000..14c5534155 --- /dev/null +++ b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx @@ -0,0 +1,420 @@ +--- +title: Migrating from trigger.dev +description: Move a trigger.dev v3 TypeScript app to the Workflow SDK by replacing task(), schemaTask(), wait.for / wait.forToken, triggerAndWait, and metadata streams with Workflows, Steps, Hooks, and start() / getRun(). +type: guide +summary: Translate a trigger.dev v3 app into the Workflow SDK with side-by-side code and a realistic order-processing saga. +prerequisites: + - /docs/getting-started/next + - /docs/foundations/workflows-and-steps +related: + - /docs/foundations/starting-workflows + - /docs/foundations/errors-and-retries + - /docs/foundations/hooks + - /docs/foundations/streaming + - /docs/deploying/world/vercel-world +--- + +## What changes when you leave trigger.dev? + +trigger.dev v3 defines durable work with `task()` or `schemaTask()` from `@trigger.dev/sdk/v3`, deploys tasks to the trigger.dev cloud or a self-hosted instance, and triggers runs via `tasks.trigger()`. A separate worker fleet picks up runs, applies retry policies, and routes `wait.for`, `wait.forToken`, and `metadata.stream` calls through the platform. + +The Workflow SDK replaces that with `"use workflow"` functions that orchestrate `"use step"` functions in plain TypeScript. There is no task registry, separate deploy target, or SDK client. Durable replay, retries, and event history ship with the runtime. + +Migration collapses the task abstraction into plain async functions. Business logic stays the same. + +## Concept mapping + +| trigger.dev | Workflow SDK | Migration note | +| --- | --- | --- | +| `task({ id, run })` | `"use workflow"` function started with `start()` | No factory or id registry. | +| `schemaTask({ schema, run })` | Typed function + `"use workflow"` | Validate inputs at the call site. | +| Inline `run` body | `"use step"` function | Side effects move into named steps. | +| `logger` / `metadata.set` | `console` + step-local state | Logs flow through the run timeline. | +| `wait.for({ seconds })` / `wait.until({ date })` | `sleep()` | Import from `workflow`. | +| `wait.forToken({ timeout })` | `createHook()` + `Promise.race` with `sleep()` | Hooks carry a typed token. | +| `tasks.trigger()` / `triggerAndWait()` | `start()` and `getRun(runId).returnValue` | Wrap both in `"use step"` functions. | +| `batch.triggerAndWait()` | `Promise.all(runIds.map(collectResult))` | Fan out via standard concurrency. | +| `AbortTaskRunError` | `FatalError` | Stops retries immediately. | +| `retry.onThrow` / `retry.fetch` | `RetryableError`, `FatalError`, `maxRetries` | Retry lives on the step. | +| `metadata.stream()` / Realtime | `getWritable()` | Durable stream per run. | +| Self-hosted worker + dashboard | Managed execution + built-in UI | No worker fleet to operate. | + +## Translate your first workflow + +Start with the shell. trigger.dev wraps the handler in `task()`; the Workflow SDK marks the function with a directive. + +```typescript title="trigger/order.ts" +// Before (trigger.dev) +import { task } from '@trigger.dev/sdk/v3'; + +export const processOrder = task({ + id: 'process-order', + run: async (payload: { orderId: string }) => { + return { orderId: payload.orderId, status: 'completed' }; + }, +}); + +// After (Workflow SDK) +export async function processOrder(orderId: string) { + 'use workflow'; // [!code highlight] + return { orderId, status: 'completed' }; +} +``` + +**What changed:** the `task()` factory and its `id` field disappear. The function is a plain export tagged with `"use workflow"`. + +### Add a step + +The body of `run` becomes one or more `"use step"` functions. + +```typescript title="workflow/workflows/order.ts" +async function loadOrder(orderId: string) { + 'use step'; // [!code highlight] + const res = await fetch(`https://example.com/api/orders/${orderId}`); + return res.json() as Promise<{ id: string }>; +} +``` + +Call it from the workflow with a plain `await`. Each additional side effect (`reserveInventory`, `chargePayment`) follows the same shape. + +### Start the run + +trigger.dev dispatches with `tasks.trigger('process-order', { orderId })`. The Workflow SDK calls `start()` directly: + +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflow/workflows/order'; + +export async function POST(request: Request) { + const { orderId } = (await request.json()) as { orderId: string }; + const run = await start(processOrder, [orderId]); // [!code highlight] + return Response.json({ runId: run.runId }); +} +``` + +No id lookup, no API key, no separate worker. `start()` returns a handle immediately. + +## Wait for an external signal + +`wait.forToken()` becomes `createHook()` plus `await`. + +```typescript title="workflow/workflows/refund.ts" +// Before (trigger.dev) +const token = await wait.createToken({ timeout: '7d' }); +const approval = await wait.forToken<{ approved: boolean }>(token); + +// After (Workflow SDK) +using approval = createHook<{ approved: boolean }>({ // [!code highlight] + token: `refund:${refundId}:approval`, +}); +const payload = await approval; +``` + +**What changed:** the platform-issued opaque token becomes an app-owned string. The caller that resumes the run supplies that same string — no token lookup. + +### Resume from an API route + +trigger.dev completes a token with `wait.completeToken(tokenId, { approved })`. The SDK equivalent is `resumeHook`: + +```typescript title="app/api/refunds/[refundId]/approve/route.ts" +import { resumeHook } from 'workflow/api'; + +export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) { + const { refundId } = await params; + const { approved } = (await request.json()) as { approved: boolean }; + await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight] + return Response.json({ ok: true }); +} +``` + +### Add a timeout and branch on the payload + +trigger.dev's `timeout: '7d'` option maps to a `Promise.race()` with `sleep()`: + +```typescript title="workflow/workflows/refund.ts" +const result = await Promise.race([ + approval.then((p) => ({ type: 'decision' as const, approved: p.approved })), + sleep('7d').then(() => ({ type: 'timeout' as const })), // [!code highlight] +]); + +if (result.type === 'timeout') return { refundId, status: 'timed-out' }; +if (!result.approved) return { refundId, status: 'rejected' }; +return { refundId, status: 'approved' }; +``` + + +A hook is a write channel. The caller that knows the token resumes the run with a typed payload. To expose in-flight state to a dashboard, read it from the app with `getRun()` or persist it from a step. + + +## Spawn a child workflow + +`triggerAndWait()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass the `runId` through the workflow. + +```typescript title="workflow/workflows/parent.ts" +import { start } from 'workflow/api'; + +async function spawnChild(item: string): Promise { + 'use step'; + const run = await start(childWorkflow, [item]); // [!code highlight] + return run.runId; +} +``` + +Await the result in a second step, then orchestrate both from the parent: + +```typescript title="workflow/workflows/parent.ts" +import { getRun } from 'workflow/api'; + +async function collectResult(runId: string) { + 'use step'; + const run = getRun(runId); + return (await run.returnValue) as { item: string; result: string }; // [!code highlight] +} + +export async function parentWorkflow(item: string) { + 'use workflow'; + const runId = await spawnChild(item); + return await collectResult(runId); +} +``` + +To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls — that replaces `batch.triggerAndWait()`. + +## Migrate a full order-processing saga + +This example covers compensation, idempotency keys, retry semantics, and progress streaming. + +### trigger.dev version + +A trigger.dev saga nests try/catch around each `tasks.triggerAndWait` call. Retry config sits on the task; compensation is wired by hand: + +```typescript title="trigger/order-saga.ts" +import { task, AbortTaskRunError } from '@trigger.dev/sdk/v3'; +import { reserveInventory, chargePayment, createShipment } from './steps'; +import { releaseInventory, refundPayment } from './compensations'; + +export const processOrderSaga = task({ + id: 'process-order-saga', + retry: { maxAttempts: 3 }, + run: async ({ orderId }: { orderId: string }) => { + const order = await loadOrder.triggerAndWait({ orderId }).unwrap(); + const inventory = await reserveInventory.triggerAndWait({ order }).unwrap(); + let payment; + try { + payment = await chargePayment.triggerAndWait({ order }).unwrap(); + } catch (error) { + await releaseInventory.triggerAndWait({ id: inventory.reservationId }); + throw error; + } + try { + const shipment = await createShipment.triggerAndWait({ order }).unwrap(); + return { orderId: order.id, shipmentId: shipment.shipmentId }; + } catch (error) { + await refundPayment.triggerAndWait({ id: payment.chargeId }); + await releaseInventory.triggerAndWait({ id: inventory.reservationId }); + throw error; + } + }, +}); +``` + +Each referenced task is defined separately with `task({ id, run })`. Progress streaming uses `metadata.stream(...)` and a Realtime subscription on the client. + +### Workflow SDK version + +The saga splits into four parts: the orchestrator, the forward steps, the compensation steps, and a progress emitter. Each block shares the same imports and types: + +```typescript title="workflow/workflows/order-saga.ts" +import { FatalError, getStepMetadata, getWritable } from 'workflow'; + +type Order = { id: string; customerId: string; total: number }; +type Reservation = { reservationId: string }; +type Charge = { chargeId: string }; +type Shipment = { shipmentId: string }; +``` + +#### Orchestrator: forward path + +The workflow calls each step in order and pushes a compensation onto a stack after every side effect. + +```typescript +export async function processOrderSaga(orderId: string) { + 'use workflow'; + const rollbacks: Array<() => Promise> = []; // [!code highlight] + try { + const order = await loadOrder(orderId); + const inventory = await reserveInventory(order); + rollbacks.push(() => releaseInventory(inventory.reservationId)); + const payment = await chargePayment(order); + rollbacks.push(() => refundPayment(payment.chargeId)); + const shipment = await createShipment(order); + rollbacks.push(() => cancelShipment(shipment.shipmentId)); + return { orderId: order.id, shipmentId: shipment.shipmentId, status: 'completed' }; + } catch (error) { + await unwind(rollbacks); + throw error; + } +} +``` + +Call `await emitProgress(...)` between steps to stream stage updates. + +#### Compensation loop + +If any forward step throws, unwind in LIFO order: + +```typescript +async function unwind(rollbacks: Array<() => Promise>) { + while (rollbacks.length > 0) { + await rollbacks.pop()!(); // [!code highlight] + } +} +``` + +#### Forward step with retry semantics + +`getStepMetadata().stepId` stays stable across replays — pass it as `Idempotency-Key`. `FatalError` replaces `AbortTaskRunError` for permanent failures. + +```typescript +async function reserveInventory(order: Order): Promise { + 'use step'; + const { stepId } = getStepMetadata(); // [!code highlight] + const res = await fetch('https://example.com/api/inventory/reservations', { + method: 'POST', + headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId: order.id }), + }); + if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] + if (!res.ok) throw new Error('Inventory reservation failed'); + return res.json() as Promise; +} +``` + +`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. + +#### Compensation steps + +Compensations are `"use step"` functions too, so a restart mid-rollback resumes where it left off. Keep them idempotent. + +```typescript +async function releaseInventory(reservationId: string) { + 'use step'; + await fetch(`https://example.com/api/inventory/reservations/${reservationId}`, { + method: 'DELETE', + }); +} +``` + +`refundPayment` and `cancelShipment` follow the same shape. + +#### Durable progress streaming + +`getWritable()` replaces `metadata.stream()` and Realtime. + +```typescript +async function emitProgress(update: { stage: string; orderId: string }) { + 'use step'; + const writer = getWritable().getWriter(); + try { await writer.write(update); } // [!code highlight] + finally { writer.releaseLock(); } +} +``` + + +Per-task retry config (`retry: { maxAttempts: 3 }`) collapses into step-level defaults. Throw `RetryableError` to retry with a delay, or `FatalError` to stop immediately — no central task policy. + + +## What you stop operating + +Dropping the trigger.dev SDK removes several moving parts: + +- **No task registry or `id` strings.** Workflow files carry directive annotations and export plain functions. +- **No `@trigger.dev/sdk/v3` client or API key.** `start()` launches runs directly from API routes or server actions. +- **No worker fleet or self-hosted instance.** The runtime schedules execution inside the app's deploy target. +- **No separate Realtime channel.** `getWritable()` streams updates from steps over the run's durable stream. +- **No dashboard account.** The built-in observability UI (`npx workflow web`) reads the same event log the runtime writes. + +Workflows suspended on `sleep()` or a hook consume no compute until resumed. + +## Step-by-step first migration + +Pick one trigger.dev task and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path. + +### Step 1: Install the Workflow SDK + +Add the runtime and the framework integration that matches the app. + +```bash +pnpm add workflow @workflow/next +``` + +### Step 2: Convert `task()` to a `"use workflow"` export + +Drop the factory call and the `id`. Move the handler body up and replace the payload object with typed function arguments. + +```ts title="workflows/order.ts" +// Before (trigger.dev) +// export const processOrder = task({ +// id: "process-order", +// run: async ({ orderId }: { orderId: string }) => { ... }, +// }); + +// After (Workflow SDK) +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + // ... +} +``` + +### Step 3: Convert the task body into `"use step"` named functions + +Each side effect becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`. `schemaTask()` validation moves to the call site — validate with zod before calling `start()`. + +```ts +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); +} +``` + +### Step 4: Replace `wait.*` with hooks and `sleep` + +- `wait.for({ seconds })` / `wait.until({ date })` → `sleep('5m')` or `sleep(date)` from `workflow`. +- `wait.forToken(token)` → `createHook({ token })` + `await`. Complete it with `resumeHook(token, payload)` from an API route. +- `wait.forToken({ timeout })` → `Promise.race([hook, sleep(timeout)])`. +- `triggerAndWait(payload)` → wrap `start(child, [payload])` in a `"use step"` function, then read the result with a second step that calls `getRun(runId).returnValue`. + +### Step 5: Start runs from the app + +Delete the trigger.dev client setup. Launch runs directly from an API route or server action: + +```ts title="app/api/orders/route.ts" +import { start } from "workflow/api"; +import { processOrder } from "@/workflows/order"; + +export async function POST(req: Request) { + const { orderId } = await req.json(); + const run = await start(processOrder, [orderId]); + return Response.json({ runId: run.runId }); +} +``` + +### Step 6: Retire trigger.dev infrastructure + +Remove the `@trigger.dev/sdk` dependency, the `trigger.config.ts` file, the `trigger/` directory, and any self-hosted worker deployment. Delete dashboard API keys from the environment. Verify the run in `npx workflow web` before shipping. + +## Quick-start checklist + +- Replace `task({ id, run })` with a `"use workflow"` function; launch it with `start()`. +- Convert each task body into named `"use step"` functions. +- Swap `wait.for` / `wait.until` for `sleep()` from `workflow`. +- Swap `wait.forToken` for `createHook()` (internal) or `createWebhook()` (HTTP). +- Model `wait.forToken` timeouts as `Promise.race()` between the hook and `sleep()`. +- Replace `triggerAndWait()` with `"use step"` wrappers around `start()` and `getRun()`. +- Replace `batch.triggerAndWait()` with `Promise.all` over collected `runId`s. +- Move `schemaTask` validation to the call site; pass typed arguments into the workflow. +- Replace `AbortTaskRunError` with `FatalError`; model retries per step with `RetryableError` and `maxRetries`. +- Use `getStepMetadata().stepId` as the idempotency key for external side effects. +- Replace `metadata.stream()` and Realtime with `getWritable()`. +- Remove the `@trigger.dev/sdk` dependency, `trigger.config.ts`, and any self-hosted worker. +- Deploy and verify runs end-to-end with the built-in observability UI. diff --git a/skills/migrating-to-workflow-sdk/SKILL.md b/skills/migrating-to-workflow-sdk/SKILL.md index 3f1f319ba2..bd220b5694 100644 --- a/skills/migrating-to-workflow-sdk/SKILL.md +++ b/skills/migrating-to-workflow-sdk/SKILL.md @@ -1,9 +1,9 @@ --- name: migrating-to-workflow-sdk -description: Migrates Temporal, Inngest, and AWS Step Functions workflows to the Workflow SDK. Use when porting Activities, Workers, Signals, step.run(), step.waitForEvent(), ASL JSON state machines, Task/Choice/Wait/Parallel states, task tokens, or child workflows. +description: Migrates Temporal, Inngest, Trigger.dev, and AWS Step Functions workflows to the Workflow SDK. Use when porting Activities, Workers, Signals, step.run(), step.waitForEvent(), Trigger.dev tasks / wait.forToken / triggerAndWait, ASL JSON state machines, Task/Choice/Wait/Parallel states, task tokens, or child workflows. metadata: author: Vercel Inc. - version: '0.1.9' + version: '0.1.10' --- # Migrating to the Workflow SDK @@ -15,6 +15,7 @@ Use this skill when converting an existing orchestration system to the Workflow 1. Identify the source system: - Temporal - Inngest + - Trigger.dev - AWS Step Functions 2. Identify the target runtime: - Managed hosting -> keep examples focused on `start()`, `getRun()`, hooks/webhooks, and route handlers. @@ -72,6 +73,7 @@ Before drafting `## Migrated Code`, write the selected route keys in `## Migrati - Temporal -> `references/temporal.md` - Inngest -> `references/inngest.md` +- Trigger.dev -> `references/trigger-dev.md` - AWS Step Functions -> `references/aws-step-functions.md` ## Shared references diff --git a/skills/migrating-to-workflow-sdk/references/trigger-dev.md b/skills/migrating-to-workflow-sdk/references/trigger-dev.md new file mode 100644 index 0000000000..c8087670b4 --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/trigger-dev.md @@ -0,0 +1,53 @@ +# Trigger.dev -> Workflow SDK + +## Map these constructs + +| Trigger.dev | Workflow SDK | +| --- | --- | +| `task({ id, run })` | `"use workflow"` or `"use step"` | +| `schemaTask({ schema, run })` | `"use workflow"` with plain typed args | +| `wait.for({ seconds })` | `sleep()` | +| `wait.until({ date })` | `sleep()` until target date | +| `wait.forToken({ timeout })` | `createHook()` or `createWebhook()` | +| `task.triggerAndWait()` | step-wrapped `start()` + `getRun().returnValue` | +| `task.trigger()` | `start()` from `workflow/api` | +| `batch.triggerAndWait()` | parallel `start()` + `Promise.all(runIds.map(id => getRun(id).returnValue))` | +| `tasks.trigger()` from API route / server action | `start()` from `workflow/api` | +| `AbortTaskRunError` | `FatalError` | +| `retry.onThrow` / `retry.fetch` / `retry.exponentialBackoff` | step `RetryableError` + `maxRetries` | +| `retry` options on `task()` | `maxRetries` on the step | +| `queue` / `machine` config on `task()` | remove from app code | +| `logger.info` / `logger.warn` | standard logging | +| `metadata.set()` | `executionContext` or step return values | +| `metadata.stream()` | `getWritable()` | + +## Remove + +- `@trigger.dev/sdk` task registration and `client.defineJob` / `task()` wiring +- `trigger.config.ts` project config, queue config, and machine config +- `schemaTask()` zod wrapper layer — move validation to the app boundary +- `tasks.trigger()` / `runs.retrieve()` imports inside task bodies in favor of `start()` / `getRun()` +- `wait.forToken()` token-issuance plumbing after converting to hooks/webhooks +- `AbortTaskRunError` imports after converting to `FatalError` +- `retry.onThrow` / `retry.fetch` wrappers after converting to step-level `RetryableError` + `maxRetries` +- `metadata.*` and `logger.*` runtime helpers after converting to `getWritable()` or standard logging + +## Add + +- `Promise.race()` with `sleep()` when the source used `wait.forToken({ timeout })` or `wait.for()` as a deadline +- Resume surface for `wait.forToken()` migrations: + - Use `createHook()` + `resumeHook()` when the app resumes the workflow from server-side code with a deterministic business token. + - Use `createWebhook()` when the external system needs a generated callback URL or the migrated flow should receive a raw `Request`, and the default `202 Accepted` response is fine. + - Use `createWebhook({ respondWith: 'manual' })` only when the prompt explicitly requires a custom response body, status, or headers. + - Choose exactly one surface. Do not pair `createWebhook()` with `resumeHook()`. + - See `references/shared-patterns.md` -> `## Deterministic server-side resume` + - See `references/shared-patterns.md` -> `## Generated callback URL (default response)` + - See `references/shared-patterns.md` -> `## Generated callback URL (manual response)` +- Durable progress writes with `getWritable()` (replaces `metadata.stream()`) +- Idempotency keys on external writes via `getStepMetadata().stepId` +- Step-level `RetryableError` + `maxRetries` (replaces `retry.onThrow`, `retry.fetch`, `retry.exponentialBackoff`) +- `FatalError` at step boundaries (replaces `AbortTaskRunError`) +- Step-wrapped `start()` / `getRun()` for child runs (replaces `task.triggerAndWait()` and `batch.triggerAndWait()`) +- Parallel fan-out via `Promise.all()` over step-wrapped `start()` calls (replaces `batch.triggerAndWait()`) +- App-boundary `start()` from `workflow/api` in API routes / server actions (replaces `tasks.trigger()`) +- Rollback stack for compensation-heavy flows (replaces per-task `onFailure` hooks) From d9342b86d5b97cf59dee3a9abcbc60686624943f Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Mon, 13 Apr 2026 21:17:06 -0600 Subject: [PATCH 5/7] [docs] Split inline Before/After into separate code fences Across all four migration guides, any code block that paired a source- system example with a Workflow SDK example via inline // Before / // After comments is now two adjacent fenced blocks, each with a descriptive title= attribute naming the source. Also applies trigger.dev verification fixes: remove the non-existent retry.exponentialBackoff helper from the guide's concept table and the skill reference, and tighten the refund Before snippet with token.id, .unwrap(), and a wait.completeToken example. --- .../migrating-from-aws-step-functions.mdx | 18 ++++++------------ .../migrating-from-inngest.mdx | 6 +++--- .../migrating-from-temporal.mdx | 6 +++--- .../migrating-from-trigger-dev.mdx | 18 ++++++++++-------- .../references/trigger-dev.md | 4 ++-- 5 files changed, 24 insertions(+), 28 deletions(-) diff --git a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx index 20243e54d8..41647f4360 100644 --- a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -45,8 +45,7 @@ The migration replaces declarative configuration with idiomatic TypeScript and c Start with a single Task state. In ASL, even "call one Lambda" requires a state machine shell: -```json title="stateMachine.asl.json" -// Before (ASL) — excerpt from a state machine's "States" block +```json title="stateMachine.asl.json (Step Functions)" "LoadOrder": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", @@ -55,8 +54,7 @@ Start with a single Task state. In ASL, even "call one Lambda" requires a state } ``` -```typescript title="workflow/workflows/order.ts" -// After (TypeScript) +```typescript title="workflow/workflows/order.ts (Workflow SDK)" export async function processOrder(orderId: string) { 'use workflow'; // [!code highlight] return await loadOrder(orderId); @@ -105,8 +103,7 @@ export async function POST(request: Request) { The minimal ASL for a callback is a Task with `.waitForTaskToken`: -```json title="approval.asl.json" -// Before (ASL) — excerpt; paired with a consumer that calls SendTaskSuccess +```json title="approval.asl.json (Step Functions)" "WaitForApproval": { "Type": "Task", "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", @@ -121,8 +118,7 @@ The minimal ASL for a callback is a Task with `.waitForTaskToken`: } ``` -```typescript title="workflow/workflows/refund.ts" -// After (TypeScript) +```typescript title="workflow/workflows/refund.ts (Workflow SDK)" import { createHook } from 'workflow'; export async function refundWorkflow(refundId: string) { @@ -155,8 +151,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ refundI In ASL, branching after the wait requires a Choice state. In TypeScript, it's just `if`/`else`: -```json -// Before (ASL) — excerpt; "Approved" and "Rejected" are sibling states in "States" +```json title="approval.asl.json (Step Functions)" "CheckApproval": { "Type": "Choice", "Choices": [ @@ -166,8 +161,7 @@ In ASL, branching after the wait requires a Choice state. In TypeScript, it's ju } ``` -```typescript -// After (TypeScript) +```typescript title="workflow/workflows/refund.ts (Workflow SDK)" const { approved } = await approval; if (approved) return { refundId, status: 'approved' }; // [!code highlight] return { refundId, status: 'rejected' }; diff --git a/docs/content/docs/migration-guides/migrating-from-inngest.mdx b/docs/content/docs/migration-guides/migrating-from-inngest.mdx index 519b0256af..7eafe1fdf9 100644 --- a/docs/content/docs/migration-guides/migrating-from-inngest.mdx +++ b/docs/content/docs/migration-guides/migrating-from-inngest.mdx @@ -92,15 +92,15 @@ No event bus, no registry — `start()` returns a handle immediately. `step.waitForEvent()` becomes `createHook()` plus `await`. -```typescript title="workflow/workflows/refund.ts" -// Before (Inngest) +```typescript title="workflow/workflows/refund.ts (Inngest)" const approval = await step.waitForEvent('wait-for-approval', { event: 'refund/approved', match: 'data.refundId', timeout: '7d', }); +``` -// After (Workflow SDK) +```typescript title="workflow/workflows/refund.ts (Workflow SDK)" using approval = createHook<{ approved: boolean }>({ // [!code highlight] token: `refund:${refundId}:approval`, }); diff --git a/docs/content/docs/migration-guides/migrating-from-temporal.mdx b/docs/content/docs/migration-guides/migrating-from-temporal.mdx index 8fd738c46e..b5c415473e 100644 --- a/docs/content/docs/migration-guides/migrating-from-temporal.mdx +++ b/docs/content/docs/migration-guides/migrating-from-temporal.mdx @@ -41,8 +41,7 @@ Migration removes infrastructure and collapses indirection. Business logic stays Start with the directive change. The Temporal definition proxies activities through a module; the Workflow SDK version puts the directive inline. -```typescript title="workflows/order.ts" -// Before (Temporal) +```typescript title="workflows/order.ts (Temporal)" const { chargePayment } = wf.proxyActivities({ startToCloseTimeout: '5 minutes', }); @@ -51,8 +50,9 @@ export async function processOrder(orderId: string) { await chargePayment(orderId); return { orderId, status: 'completed' }; } +``` -// After (Workflow SDK) +```typescript title="workflows/order.ts (Workflow SDK)" export async function processOrder(orderId: string) { 'use workflow'; // [!code highlight] await chargePayment(orderId); diff --git a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx index 14c5534155..58ba179883 100644 --- a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx +++ b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx @@ -35,7 +35,7 @@ Migration collapses the task abstraction into plain async functions. Business lo | `tasks.trigger()` / `triggerAndWait()` | `start()` and `getRun(runId).returnValue` | Wrap both in `"use step"` functions. | | `batch.triggerAndWait()` | `Promise.all(runIds.map(collectResult))` | Fan out via standard concurrency. | | `AbortTaskRunError` | `FatalError` | Stops retries immediately. | -| `retry.onThrow` / `retry.fetch` | `RetryableError`, `FatalError`, `maxRetries` | Retry lives on the step. | +| `retry.onThrow` / `retry.fetch` | `RetryableError`, `FatalError`, `maxRetries` | Retry lives on the step. Exponential backoff is a step `maxRetries` config, not a helper. | | `metadata.stream()` / Realtime | `getWritable()` | Durable stream per run. | | Self-hosted worker + dashboard | Managed execution + built-in UI | No worker fleet to operate. | @@ -43,8 +43,7 @@ Migration collapses the task abstraction into plain async functions. Business lo Start with the shell. trigger.dev wraps the handler in `task()`; the Workflow SDK marks the function with a directive. -```typescript title="trigger/order.ts" -// Before (trigger.dev) +```typescript title="trigger/order.ts (trigger.dev)" import { task } from '@trigger.dev/sdk/v3'; export const processOrder = task({ @@ -53,8 +52,9 @@ export const processOrder = task({ return { orderId: payload.orderId, status: 'completed' }; }, }); +``` -// After (Workflow SDK) +```typescript title="trigger/order.ts (Workflow SDK)" export async function processOrder(orderId: string) { 'use workflow'; // [!code highlight] return { orderId, status: 'completed' }; @@ -98,12 +98,14 @@ No id lookup, no API key, no separate worker. `start()` returns a handle immedia `wait.forToken()` becomes `createHook()` plus `await`. -```typescript title="workflow/workflows/refund.ts" -// Before (trigger.dev) +```typescript title="workflow/workflows/refund.ts (trigger.dev, abbreviated)" +// import { wait } from '@trigger.dev/sdk/v3'; const token = await wait.createToken({ timeout: '7d' }); -const approval = await wait.forToken<{ approved: boolean }>(token); +const approval = await wait.forToken<{ approved: boolean }>(token.id).unwrap(); +// External system resumes with: await wait.completeToken(token.id, { approved: true }); +``` -// After (Workflow SDK) +```typescript title="workflow/workflows/refund.ts (Workflow SDK)" using approval = createHook<{ approved: boolean }>({ // [!code highlight] token: `refund:${refundId}:approval`, }); diff --git a/skills/migrating-to-workflow-sdk/references/trigger-dev.md b/skills/migrating-to-workflow-sdk/references/trigger-dev.md index c8087670b4..dba46f1701 100644 --- a/skills/migrating-to-workflow-sdk/references/trigger-dev.md +++ b/skills/migrating-to-workflow-sdk/references/trigger-dev.md @@ -14,7 +14,7 @@ | `batch.triggerAndWait()` | parallel `start()` + `Promise.all(runIds.map(id => getRun(id).returnValue))` | | `tasks.trigger()` from API route / server action | `start()` from `workflow/api` | | `AbortTaskRunError` | `FatalError` | -| `retry.onThrow` / `retry.fetch` / `retry.exponentialBackoff` | step `RetryableError` + `maxRetries` | +| `retry.onThrow` / `retry.fetch` | step `RetryableError` + `maxRetries` | | `retry` options on `task()` | `maxRetries` on the step | | `queue` / `machine` config on `task()` | remove from app code | | `logger.info` / `logger.warn` | standard logging | @@ -45,7 +45,7 @@ - See `references/shared-patterns.md` -> `## Generated callback URL (manual response)` - Durable progress writes with `getWritable()` (replaces `metadata.stream()`) - Idempotency keys on external writes via `getStepMetadata().stepId` -- Step-level `RetryableError` + `maxRetries` (replaces `retry.onThrow`, `retry.fetch`, `retry.exponentialBackoff`) +- Step-level `RetryableError` + `maxRetries` (replaces `retry.onThrow` and `retry.fetch`; exponential backoff moves to `maxRetries` config) - `FatalError` at step boundaries (replaces `AbortTaskRunError`) - Step-wrapped `start()` / `getRun()` for child runs (replaces `task.triggerAndWait()` and `batch.triggerAndWait()`) - Parallel fan-out via `Promise.all()` over step-wrapped `start()` calls (replaces `batch.triggerAndWait()`) From 44b764b0cb5ac59844301b721c6689cb4b2bef05 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Mon, 13 Apr 2026 22:13:03 -0600 Subject: [PATCH 6/7] [docs] Address review feedback on migration guides From Pranay's Slack review of PR #1721. Applied across all four guides (Temporal, Inngest, AWS Step Functions, trigger.dev): - Remove emdashes from intros and prose (AI-generated tell). - Fix install command: drop non-existent @workflow/next package; workflow/next is a subpath of the `workflow` package. - Rewrite saga sections. The rollback stack is a generic TypeScript compensation pattern, not an SDK feature. Dropped comparative framing that misrepresented how Temporal / Inngest / Step Functions / trigger.dev implement SAGA. - Recommend named streams (getWritable({ namespace: 'status' })) for read paths instead of database polling, getRun() polling, or app-layer caches. Clients read from the end of the stream for current status. - Return Run objects (not runId strings) from child-workflow step wrappers so workflow observability can deep-link into child runs. API-route HTTP handlers still return { runId } to external clients. - Add a "Why migrate to the Workflow SDK" section per guide, tailored to each source system's architecture. - Scrub vague "app API" / "app layer" references. --- .../migrating-from-aws-step-functions.mdx | 129 +++++-------- .../migrating-from-inngest.mdx | 174 +++--------------- .../migrating-from-temporal.mdx | 92 ++++----- .../migrating-from-trigger-dev.mdx | 93 ++++------ 4 files changed, 140 insertions(+), 348 deletions(-) diff --git a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx index 41647f4360..94514f73ed 100644 --- a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -14,13 +14,23 @@ related: - /docs/deploying/world/vercel-world --- -## What changes when you leave Step Functions +Move an AWS Step Functions state machine to the Workflow SDK by replacing JSON state definitions with TypeScript functions. This guide shows the direct mapping between ASL states and Workflow SDK primitives. -AWS Step Functions defines workflows as JSON state machines using Amazon States Language (ASL). Each state — Task, Choice, Wait, Parallel, Map — is a node in a declarative graph. Lambda functions handle tasks, Retry/Catch blocks configure per-state error handling, and `.waitForTaskToken` manages callbacks. +## Why migrate to the Workflow SDK + +- Orchestration code is TypeScript, not JSON ASL. Transitions are `await`, branches are `if`/`switch`, and parallelism is `Promise.all`. +- Streaming is built in. Write durable progress from steps with `getWritable()` and named streams. No DynamoDB or SNS glue to surface status to clients. +- Infrastructure lives in one deployment. No separate state machine, per-task Lambda, IAM role wiring, or callback SQS queues. +- Error handling is TypeScript-native: step-level retries, `RetryableError`, and `FatalError` replace per-state Retry/Catch blocks. +- Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` integration, and the Claude skill are available out of the box. + +## What changes when you leave Step Functions? + +AWS Step Functions defines workflows as JSON state machines using Amazon States Language (ASL). Each state (Task, Choice, Wait, Parallel, Map) is a node in a declarative graph. Lambda functions handle tasks, Retry/Catch blocks configure per-state error handling, and `.waitForTaskToken` manages callbacks. The Workflow SDK replaces that JSON DSL with TypeScript. `"use workflow"` functions orchestrate `"use step"` functions in the same file. Branching is `if`/`else`. Waiting is `sleep()`. Parallelism is `Promise.all()`. Retries move down to the step level. -The migration replaces declarative configuration with idiomatic TypeScript and collapses the orchestrator/compute split. Business logic stays the same. +The migration replaces declarative configuration with idiomatic TypeScript and collapses the orchestrator and compute split. Business logic stays the same. ## Concept mapping @@ -34,8 +44,9 @@ The migration replaces declarative configuration with idiomatic TypeScript and c | Map state | Loop + `Promise.all()` or child workflows | Iterate with `for`/`map`. | | Retry / Catch | Step retries, `RetryableError`, `FatalError` | Retry logic moves to step boundaries. | | `.waitForTaskToken` | `createHook()` or `createWebhook()` | Hooks for typed signals; webhooks for HTTP. | -| Child state machine (`StartExecution`) | `"use step"` around `start()` / `getRun()` | Return `runId`, await result from another step. | +| Child state machine (`StartExecution`) | `"use step"` around `start()` / `getRun()` | Return the `Run` object, await its result from another step. | | Execution event history | Workflow event log | Same durable replay model. | +| Progress via DynamoDB / SNS for client polling | `getWritable()` + named streams | Stream durable updates; clients read from the stream. | `.waitForTaskToken` becomes `createHook()` or `createWebhook()`. Choice states become `if`/`else`. Map states become `Promise.all()`. Retry policies move from per-state configuration to step-level defaults. @@ -130,7 +141,7 @@ export async function refundWorkflow(refundId: string) { } ``` -What changed: no SQS queue, no task token, no callback Lambda — the hook suspends the workflow durably until it is resumed. +What changed: no SQS queue, no task token, no callback Lambda. The hook suspends the workflow durably until it is resumed. ### Resuming the hook @@ -169,23 +180,22 @@ return { refundId, status: 'rejected' }; ## Spawn a child workflow -In ASL, a parent machine calls `StartExecution` (usually via `.sync` or `.waitForTaskToken`) to launch a child. In the Workflow SDK, `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass the serializable `runId` through the workflow. +In ASL, a parent machine calls `StartExecution` (usually via `.sync` or `.waitForTaskToken`) to launch a child. In the Workflow SDK, `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Returning the `Run` object from the spawn step lets workflow observability deep-link to the child run. ### Parent starts a child ```typescript title="workflow/workflows/parent.ts" import { start } from 'workflow/api'; -async function spawnChild(item: string): Promise { +async function spawnChild(item: string) { 'use step'; // [!code highlight] - const run = await start(childWorkflow, [item]); - return run.runId; + return start(childWorkflow, [item]); } export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); - return { childRunId: runId }; + const run = await spawnChild(item); + return { childRunId: run.runId }; } ``` @@ -203,74 +213,41 @@ async function collectResult(runId: string) { } ``` -Then in the workflow: `const result = await collectResult(runId);`. The child workflow itself (`childWorkflow`) is defined elsewhere with `"use workflow"`. - -## Migrate a full order-processing saga - -This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming. - -### Step Functions version - -A Step Functions saga wires Retry and a Catch-to-compensation path on every forward state. Two representative states: - -```json title="saga.asl.json" -"ReserveInventory": { - "Type": "Task", - "Resource": "arn:aws:states:::lambda:invoke", - "Parameters": { "FunctionName": "reserveInventory", "Payload.$": "$" }, - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "ReleaseInventory" }], - "Next": "ChargePayment" -}, -"ReleaseInventory": { - "Type": "Task", - "Resource": "arn:aws:states:::lambda:invoke", - "Parameters": { "FunctionName": "releaseInventory", "Payload.$": "$" }, - "Next": "FailOrder" -} -``` +Then in the workflow: `const result = await collectResult(run.runId);`. The child workflow itself (`childWorkflow`) is defined elsewhere with `"use workflow"`. -Other forward states (`ChargePayment`, `CreateShipment`) repeat the same Retry/Catch shape, and the compensation chain is wired explicitly as separate states. +## Writing a saga with the Workflow SDK -### Workflow SDK version +Step Functions expresses the saga pattern with `Catch` transitions to explicit compensation states. The Workflow SDK expresses the same pattern with plain TypeScript: collect compensating actions in an array as forward steps succeed, then drain it in reverse inside a `catch` block. This is a standard compensation pattern, not a built-in SDK feature. -The forward path lives in the workflow function. A rollback stack replaces the Catch-to-compensation wiring: +### Forward path with a compensation array ```typescript title="workflow/workflows/order-saga.ts" export async function processOrderSaga(orderId: string) { 'use workflow'; const rollbacks: Array<() => Promise> = []; // [!code highlight] - const order = await loadOrder(orderId); - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - - return { orderId: order.id, status: 'completed' }; -} -``` - -Progress emission and the `createShipment` step are elided for clarity; they follow the same push-a-rollback pattern. - -#### Rollback on failure - -Wrap the forward path in `try/catch` and drain the stack in reverse: - -```typescript -try { - // forward path as above -} catch (error) { - while (rollbacks.length > 0) { // [!code highlight] - await rollbacks.pop()!(); + try { + const order = await loadOrder(orderId); + const inventory = await reserveInventory(order); + rollbacks.push(() => releaseInventory(inventory.reservationId)); + const payment = await chargePayment(order); + rollbacks.push(() => refundPayment(payment.chargeId)); + + return { orderId: order.id, status: 'completed' }; + } catch (error) { + while (rollbacks.length > 0) { // [!code highlight] + await rollbacks.pop()!(); + } + throw error; } - throw error; } ``` -#### Forward steps: retry + idempotency +The `rollbacks` array is a plain TypeScript variable. Each forward step pushes a compensating action; the `catch` block drains the stack in reverse. Because each compensating call is a `"use step"` function, every rollback runs durably even if the workflow restarts mid-compensation. + +### Forward steps: retry and idempotency -Retry moves from per-state ASL config to step defaults. `FatalError` opts out of retry; `getStepMetadata().stepId` gives a stable idempotency key: +Retry moves from per-state ASL config to step defaults. `FatalError` opts out of retry, and `getStepMetadata().stepId` gives a stable idempotency key: ```typescript title="workflow/workflows/order-saga.ts" import { FatalError, getStepMetadata } from 'workflow'; @@ -289,28 +266,24 @@ async function reserveInventory(order: Order): Promise { } ``` -`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. Compensation steps (`releaseInventory`, `refundPayment`, `cancelShipment`) are `"use step"` functions that issue a DELETE or refund call — each runs durably even if the workflow restarts mid-rollback. +`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. Compensation steps (`releaseInventory`, `refundPayment`, `cancelShipment`) are `"use step"` functions that issue a DELETE or refund call. -#### Streaming progress +### Streaming progress with named streams -`getWritable()` replaces DynamoDB or SNS for client-visible updates: +Write durable progress updates out with `getWritable()`. Clients read them back by reading from the end of the stream via `getRun()`. No DynamoDB or SNS glue required. ```typescript import { getWritable } from 'workflow'; async function emitProgress(update: { stage: string; orderId: string }) { 'use step'; - const writer = getWritable().getWriter(); + const writer = getWritable({ namespace: 'status' }).getWriter(); await writer.write(update); // [!code highlight] writer.releaseLock(); } ``` -Three things collapse in this migration: - -- A rollback stack replaces the explicit Catch-to-compensation wiring on every state. -- `getStepMetadata().stepId` supplies the idempotency key — no manual token management. -- `getWritable()` streams progress durably. In Step Functions, progress typically goes to DynamoDB or SNS for client polling. +The `namespace` option distinguishes multiple streams on the same run (for example, `'status'` for UI updates versus the default stream for logs). Clients get current progress by reading from the stream on the returned `Run`. ## What you stop operating @@ -330,10 +303,10 @@ Pick one state machine and migrate it end-to-end before touching the rest. The s ### Step 1: Install the Workflow SDK -Add the runtime and the framework integration that matches the app. +Add the `workflow` runtime package. ```bash -pnpm add workflow @workflow/next +pnpm add workflow ``` ### Step 2: Rewrite the state machine as a `"use workflow"` function @@ -364,9 +337,9 @@ async function loadOrder(id: string) { Swap the task-token callback Lambda for `createHook()`. Callers `resumeHook(token, payload)` instead of `SendTaskSuccess`. -### Step 5: Start runs from the app +### Step 5: Start runs from an API route -Delete the `StartExecution` call and IAM wiring. Launch runs directly from an API route: +Delete the `StartExecution` call and IAM wiring. Launch runs directly from a route handler: ```ts title="app/api/orders/route.ts" import { start } from "workflow/api"; diff --git a/docs/content/docs/migration-guides/migrating-from-inngest.mdx b/docs/content/docs/migration-guides/migrating-from-inngest.mdx index 7eafe1fdf9..7337f12634 100644 --- a/docs/content/docs/migration-guides/migrating-from-inngest.mdx +++ b/docs/content/docs/migration-guides/migrating-from-inngest.mdx @@ -14,6 +14,13 @@ related: - /docs/deploying/world/vercel-world --- +## Why migrate to the Workflow SDK + +- Streaming is built in. Durable progress writes go to named streams via `getWritable()`. There is no separate realtime publish channel or WebSocket layer to operate. +- Infrastructure and orchestration live in a single deployment. Workflows run where the app runs. There is no separate Inngest Dev Server or event bus to operate. +- TypeScript-first DX. Steps are named async functions marked with `"use step"`. No inline closures tied to a framework-specific lifecycle. +- Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` integration for durable AI agents, and a Claude skill for generating workflows. + ## What changes when you leave Inngest Inngest defines functions with `inngest.createFunction()`, registers them through a `serve()` handler, and breaks work into steps with `step.run()`, `step.sleep()`, and `step.waitForEvent()`. The platform routes events, schedules steps, and applies retries. @@ -34,7 +41,7 @@ Migration collapses the SDK abstraction into plain async functions. Business log | `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly. | | Retry configuration (`retries`) | `RetryableError`, `FatalError`, `maxRetries` | Retry logic lives at the step level. | | `step.sendEvent()` | `"use step"` wrapper around `start()` | Fan out via `start()`, not an event bus. | -| Realtime / `step.realtime.publish()` | `getWritable()` | Durable streaming from steps. | +| 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. | ## Translate your first workflow @@ -86,7 +93,7 @@ import { processOrder } from '@/workflow/workflows/order'; const run = await start(processOrder, [orderId]); // [!code highlight] ``` -No event bus, no registry — `start()` returns a handle immediately. +No event bus, no registry. `start()` returns a handle immediately. ## Wait for an external signal @@ -107,7 +114,7 @@ using approval = createHook<{ approved: boolean }>({ // [!code highlight] const payload = await approval; ``` -**What changed:** event name + match expression collapse into a single `token` string. The caller supplies that token directly — no event schema. +**What changed:** event name + match expression collapse into a single `token` string. The caller supplies that token directly, with no event schema. ### Resume from an API route @@ -145,13 +152,12 @@ Event matching disappears. A hook's token encodes the routing (for example, `ref ## Spawn a child workflow -`step.invoke()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass the `runId` through the workflow. +`step.invoke()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the `Run` object from the spawn step so observability can deep-link into the child run. ```typescript title="workflow/workflows/parent.ts" -async function spawnChild(item: string): Promise { +async function spawnChild(item: string) { 'use step'; - const run = await start(childWorkflow, [item]); // [!code highlight] - return run.runId; + return start(childWorkflow, [item]); // [!code highlight] } ``` @@ -166,146 +172,14 @@ async function collectResult(runId: string) { export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); - return await collectResult(runId); -} -``` - -## Migrate a full order-processing saga - -This example covers compensation, idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. - -### Inngest version - -```typescript title="inngest/functions/order-saga.ts" -import { inngest } from '../client'; - -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; - -export const processOrderSaga = inngest.createFunction( - { - id: 'process-order-saga', - retries: 3, - triggers: [{ event: 'order/process' }], - }, - async ({ event, step }) => { - const orderId = event.data.orderId; - - const order = await step.run('load-order', async () => { - const res = await fetch( - `https://example.com/api/orders/${orderId}` - ); - if (!res.ok) throw new Error('Order not found'); - return res.json() as Promise; - }); - - const inventory = await step.run('reserve-inventory', async () => { - const res = await fetch( - 'https://example.com/api/inventory/reservations', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - } - ); - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; - }); - - let payment: Charge; - try { - payment = await step.run('charge-payment', async () => { - const res = await fetch( - 'https://example.com/api/payments/charges', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - } - ); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; - }); - } catch (error) { - await step.run('release-inventory', async () => { - await fetch( - `https://example.com/api/inventory/reservations/${inventory.reservationId}`, - { method: 'DELETE' } - ); - }); - throw error; - } - - let shipment: Shipment; - try { - shipment = await step.run('create-shipment', async () => { - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; - }); - } catch (error) { - await step.run('refund-payment', async () => { - await fetch( - `https://example.com/api/payments/charges/${payment.chargeId}/refund`, - { method: 'POST' } - ); - }); - await step.run('release-inventory-after-refund', async () => { - await fetch( - `https://example.com/api/inventory/reservations/${inventory.reservationId}`, - { method: 'DELETE' } - ); - }); - throw error; - } - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; - } -); -``` - -**Sample input:** - -```json -{ - "event": { - "data": { - "orderId": "ord_123" - } - } + const child = await spawnChild(item); + return await collectResult(child.runId); } ``` -**Expected output:** - -```json -{ - "orderId": "ord_123", - "reservationId": "res_456", - "chargeId": "ch_789", - "shipmentId": "shp_101", - "status": "completed" -} -``` +## Writing a saga with the Workflow SDK -### Workflow SDK version +A saga coordinates multiple side effects so that a failure in a later step unwinds the earlier ones. Both Inngest and the Workflow SDK support this pattern through ordinary TypeScript control flow. The example below shows one way to implement compensation in plain TypeScript: a rollback array that the orchestrator builds up as it goes and unwinds in a `catch` block. It is a convention, not a built-in SDK feature. The saga splits into four sections: the orchestrator, the forward steps, the compensation steps, and a progress emitter. Each block shares the same imports and types: @@ -358,7 +232,7 @@ async function unwind(rollbacks: Array<() => Promise>) { #### Forward step with retry semantics -`getStepMetadata().stepId` is stable across replays — pass it as `Idempotency-Key`. Use `FatalError` to skip retries on permanent failures. +`getStepMetadata().stepId` is stable across replays, so pass it as `Idempotency-Key`. Use `FatalError` to skip retries on permanent failures. ```typescript async function reserveInventory(order: Order): Promise { @@ -394,19 +268,19 @@ async function releaseInventory(reservationId: string) { #### Durable progress streaming -`getWritable()` replaces Inngest Realtime. +Workflows push data out through named streams. Clients read from the end of the stream to get the latest value. No separate realtime publish channel, database write, or `getRun()` poll is involved. ```typescript async function emitProgress(update: { stage: string; orderId: string }) { 'use step'; - const writer = getWritable().getWriter(); + const writer = getWritable({ namespace: 'status' }).getWriter(); try { await writer.write(update); } // [!code highlight] finally { writer.releaseLock(); } } ``` -Inngest's per-step try/catch compensation maps to a single rollback stack. The pattern scales to any number of steps without nested error handlers. +A single rollback array scales to any number of steps without nested `try`/`catch` blocks. Any other compensation strategy that plain TypeScript supports works too. ## What you stop operating @@ -428,12 +302,12 @@ Pick one Inngest function and migrate it end-to-end before touching the rest. Th Add the runtime and the framework integration that matches the app. ```bash -pnpm add workflow @workflow/next +pnpm add workflow ``` ### Step 2: Convert `createFunction` to a `"use workflow"` export -Replace the factory call with a plain async export. Move the handler body up — the event-binding argument goes away. +Replace the factory call with a plain async export. Move the handler body up. The event-binding argument goes away. ```ts title="workflows/order.ts" // Before (Inngest) @@ -464,7 +338,7 @@ async function loadOrder(id: string) { - `step.waitForEvent(...)` → `createHook({ token })` + `await hook`. Resume it from an API route with `resumeHook(token, payload)`. - `step.sleep(...)` → `sleep("5m")` from `workflow`. -- `step.invoke(child, { data })` → wrap `start(child, [data])` in a `"use step"` function and optionally read its return value with `getRun(runId).returnValue`. +- `step.invoke(child, { data })` → wrap `start(child, [data])` in a `"use step"` function that returns the `Run`, and optionally read its return value with `getRun(run.runId).returnValue`. ### Step 5: Start runs from the app diff --git a/docs/content/docs/migration-guides/migrating-from-temporal.mdx b/docs/content/docs/migration-guides/migrating-from-temporal.mdx index b5c415473e..b5eef1ef03 100644 --- a/docs/content/docs/migration-guides/migrating-from-temporal.mdx +++ b/docs/content/docs/migration-guides/migrating-from-temporal.mdx @@ -14,6 +14,14 @@ related: - /docs/deploying/world/vercel-world --- +## Why migrate to the Workflow SDK + +- Streaming is built in. Durable progress writes go to named streams via `getWritable({ namespace })`, and clients read them directly. No separate WebSocket, SSE, or progress-polling layer to operate. +- Infrastructure and orchestration live in a single deployment. There is no separate Worker fleet or Temporal Server to run. The runtime, step logic, and orchestration share the app's observability and log aggregation. +- TypeScript-first developer experience. Workflows and steps live in the same file with plain `await` control flow, `try/catch`, and `Promise.all`. +- Agent-first tooling. A first-class CLI (`npx workflow`), `@workflow/ai` integration for durable AI agents, and a bundled Claude skill for AI-assisted authoring. +- Per-step retry controls. `RetryableError`, `FatalError`, and `maxRetries` live at the step boundary instead of an Activity-level retry policy configured elsewhere. + ## What changes when you leave Temporal Temporal requires operating a control plane (Temporal Server or Cloud), a Worker fleet, Activity modules wired through `proxyActivities`, and Task Queues. The workflow code is durable; the surrounding infrastructure is substantial. @@ -30,8 +38,9 @@ Migration removes infrastructure and collapses indirection. Business logic stays | Activity | `"use step"` function | Put side effects and Node.js access in steps. | | Worker + Task Queue | Managed execution | No worker fleet or polling loop to operate. | | Signal | `createHook()` or `createWebhook()` | Use hooks for typed resume signals; webhooks for HTTP callbacks. | -| Query / Update | `getRun()` + app API, or hook-driven mutation | Reads go through the app; writes go through hooks. | -| Child Workflow | `"use step"` wrappers around `start()` / `getRun()` | Spawn from a step, pass `runId` through the workflow. | +| Query | `getWritable({ namespace: 'status' })` stream | Durably stream status updates from the workflow. Clients read from the stream instead of polling a database. | +| Update | `createHook()` + `resumeHook()` | Writes go through hooks. | +| Child Workflow | `"use step"` wrappers around `start()` / `getRun()` | Spawn from a step and return the `Run` object so observability can deep-link into child runs. | | Activity retry policy | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retries live at the step boundary. | | Event History | Workflow event log / run timeline | Same durable replay, fewer surfaces to manage. | @@ -122,7 +131,7 @@ export async function refundWorkflow(refundId: string) { } ``` -The workflow suspends durably at `await approval` until resumed — no polling, no handler registration. +The workflow suspends durably at `await approval` until resumed. No polling, no handler registration. ### Resuming from an API route @@ -140,28 +149,27 @@ export async function POST(request: Request, { params }: { params: Promise<{ ref ``` -Temporal Queries expose in-memory workflow state on demand. Hooks are a write channel — they resume a paused workflow with new data. To expose state, read it from the app using `getRun()` or persist it to a database from a step. +Temporal Queries expose in-memory workflow state on demand. In the Workflow SDK, the equivalent is a durable stream: call `getWritable({ namespace: 'status' })` from inside the workflow to write status updates, and have clients read from the end of that stream to get the current state. Hooks are the write channel for resuming a paused workflow with new data. ## Spawn a child workflow ### Minimal translation -`start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. The parent spawns a child by calling a step that returns the serializable `runId`. +`start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the `Run` object (not a plain `runId` string) so workflow observability can deep-link into child runs. ```typescript title="workflow/workflows/parent.ts" import { start } from 'workflow/api'; -async function spawnChild(item: string): Promise { +async function spawnChild(item: string) { 'use step'; // [!code highlight] - const run = await start(childWorkflow, [item]); - return run.runId; + return start(childWorkflow, [item]); // [!code highlight] } export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); // [!code highlight] - return { childRunId: runId }; + const child = await spawnChild(item); // [!code highlight] + return { childRunId: child.runId }; } ``` @@ -179,51 +187,19 @@ async function collectResult(runId: string) { } ``` -Call both steps from the parent in sequence: `const result = await collectResult(runId)`. To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. +Call both steps from the parent in sequence: `const result = await collectResult(child.runId)`. To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. Activity retry policy moves to the step boundary. Use `maxRetries`, `RetryableError`, and `FatalError` on each step instead of a single workflow-wide retry block. -## Migrate a full order-processing saga - -This example covers compensation, idempotency keys, retry semantics, and progress streaming. - -### Before: Temporal - -A Temporal saga wires compensations manually with nested try/catch around each Activity. The nesting deepens with each additional step: - -```typescript title="temporal/workflows/order-saga.ts" -export async function processOrderSaga(orderId: string) { - const order = await loadOrder(orderId); - const inventory = await reserveInventory(order); - - let payment: { chargeId: string }; - try { - payment = await chargePayment(order); - } catch (error) { - await releaseInventory(inventory.reservationId); // [!code highlight] - throw error; - } - - try { - const shipment = await createShipment(order); - return { orderId: order.id, shipmentId: shipment.shipmentId }; - } catch (error) { - await refundPayment(payment.chargeId); // [!code highlight] - await releaseInventory(inventory.reservationId); // [!code highlight] - throw error; - } -} -``` - -Activity imports and `proxyActivities` wiring are omitted — the point is the nested compensation structure, which multiplies as the saga grows. Progress reporting needs a separate Activity and external store. +## Writing a saga -### After: Workflow SDK +Sagas are not a feature of any particular SDK. They are a general pattern: run forward steps, and on failure run their compensations in reverse. Temporal workflows and Workflow SDK workflows can both implement this pattern the same way, typically with a compensation stack (see, for example, [Temporal's saga pattern guide](https://temporal.io/blog/saga-pattern-made-easy)). -The workflow uses a rollback stack. Each forward step pushes its compensation, and a single catch unwinds them in reverse order. +Below is one way to write the SAGA pattern with the Workflow SDK, using a rollback stack, per-step idempotency keys, and a durable progress stream. -#### Orchestration — forward path +#### Orchestration: forward path Start with the happy path. Each forward call pushes its compensation onto a stack. @@ -244,7 +220,7 @@ export async function processOrderSaga(orderId: string) { } ``` -#### Compensation — rollback loop +#### Compensation: rollback loop Wrap the forward path in a try/catch. On failure, unwind the stack in reverse: @@ -259,7 +235,7 @@ try { } ``` -#### Steps — one forward step +#### Steps: one forward step Forward steps use `FatalError` to skip retries on unrecoverable responses and `getStepMetadata().stepId` as an idempotency key: @@ -284,14 +260,14 @@ The other forward steps (`loadOrder`, `chargePayment`, `createShipment`) follow #### Streaming progress -`getWritable()` replaces the external progress transport. Writes flow back to the caller over the run's durable stream: +`getWritable({ namespace: 'status' })` replaces any external progress transport. Writes flow back to callers over a durable stream, and clients read from the end of the stream to get the latest status: ```typescript import { getWritable } from 'workflow'; async function emitProgress(update: { stage: string; orderId: string }) { 'use step'; - const writer = getWritable().getWriter(); // [!code highlight] + const writer = getWritable({ namespace: 'status' }).getWriter(); // [!code highlight] try { await writer.write(update); } finally { writer.releaseLock(); } } ``` @@ -302,7 +278,7 @@ Call `await emitProgress({ stage: 'inventory_reserved', orderId: order.id })` be - Compensation wiring collapses into a rollback stack with a single catch block. - `getStepMetadata().stepId` provides a stable idempotency key for external APIs. -- `getWritable()` streams progress from steps directly — no separate progress store. +- `getWritable()` streams progress from steps directly, without a separate progress store. ## What you stop operating @@ -320,10 +296,10 @@ Pick one Temporal workflow and migrate it end-to-end before touching the rest. T ### Step 1: Install the Workflow SDK -Add the runtime and the Next.js integration (or the framework integration that matches the app). +Add the runtime. The Next.js integration ships as a subpath (`workflow/next`) of the same package. ```bash -pnpm add workflow @workflow/next +pnpm add workflow ``` ### Step 2: Collapse Activities into step functions @@ -339,7 +315,7 @@ async function loadOrder(id: string) { ### Step 3: Mark the orchestrator with `"use workflow"` -Keep the existing control flow — `await`, `try/catch`, `Promise.all`. The directive turns the function into a durable replay target. +Keep the existing control flow: `await`, `try/catch`, `Promise.all`. The directive turns the function into a durable replay target. ```ts export async function processOrder(orderId: string) { @@ -353,7 +329,7 @@ export async function processOrder(orderId: string) { Swap `defineSignal` + `setHandler` for `createHook()`. Callers `resumeHook(token, payload)` instead of `client.workflow.signal(...)`. -### Step 5: Start runs from the app +### Step 5: Start runs from an API route or server action Delete the Worker bootstrap. Launch runs from an API route or server action with `start()`: @@ -378,8 +354,8 @@ Remove the Worker process, `@temporalio/*` dependencies, and the Temporal Server - Convert each Activity into a `"use step"` function. - Remove Worker and Task Queue code. Start workflows from the app with `start()`. - Replace Signals with `createHook()` or `createWebhook()` for HTTP callers. -- Wrap `start()` and `getRun()` in `"use step"` functions for child workflows. Pass `runId` through the workflow. +- Wrap `start()` and `getRun()` in `"use step"` functions for child workflows. Return the `Run` object from `start()` so observability can deep-link into child runs. - Set retry policy per step with `maxRetries`, `RetryableError`, and `FatalError`. - Use `getStepMetadata().stepId` as the idempotency key for external side effects. -- Stream progress from steps with `getWritable()`. +- Stream status and progress from steps with `getWritable({ namespace: 'status' })`, and have clients read from the stream instead of polling. - Deploy the app and verify runs end-to-end in the built-in observability UI. diff --git a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx index 58ba179883..786cf6f606 100644 --- a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx +++ b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx @@ -14,6 +14,14 @@ related: - /docs/deploying/world/vercel-world --- +## Why migrate to the Workflow SDK? + +- Streaming is built in via named streams (`getWritable()` and `getWritable({ namespace })`). Durable status writes replace `metadata.stream()` and `metadata.set()`, and clients read from the end of the stream for current status. +- Orchestration and infrastructure live in a single deployment. There is no separate trigger.dev cloud or self-hosted worker fleet to operate. +- TypeScript-first DX: plain async/await control flow, no `task()` factory, no `schemaTask()` wrapper for payloads you already type with TypeScript. +- Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` for durable AI agents, and a Claude skill for generating workflows. +- Retry policy is per step. Throw `RetryableError` to retry with a delay, or `FatalError` to stop. There is no central task-level retry config. + ## What changes when you leave trigger.dev? trigger.dev v3 defines durable work with `task()` or `schemaTask()` from `@trigger.dev/sdk/v3`, deploys tasks to the trigger.dev cloud or a self-hosted instance, and triggers runs via `tasks.trigger()`. A separate worker fleet picks up runs, applies retry policies, and routes `wait.for`, `wait.forToken`, and `metadata.stream` calls through the platform. @@ -29,14 +37,14 @@ Migration collapses the task abstraction into plain async functions. Business lo | `task({ id, run })` | `"use workflow"` function started with `start()` | No factory or id registry. | | `schemaTask({ schema, run })` | Typed function + `"use workflow"` | Validate inputs at the call site. | | Inline `run` body | `"use step"` function | Side effects move into named steps. | -| `logger` / `metadata.set` | `console` + step-local state | Logs flow through the run timeline. | +| `logger` / `metadata.set` | `console` + `getWritable({ namespace: 'status' })` | Logs flow through the run timeline. Status writes go on a named stream. | | `wait.for({ seconds })` / `wait.until({ date })` | `sleep()` | Import from `workflow`. | | `wait.forToken({ timeout })` | `createHook()` + `Promise.race` with `sleep()` | Hooks carry a typed token. | | `tasks.trigger()` / `triggerAndWait()` | `start()` and `getRun(runId).returnValue` | Wrap both in `"use step"` functions. | | `batch.triggerAndWait()` | `Promise.all(runIds.map(collectResult))` | Fan out via standard concurrency. | | `AbortTaskRunError` | `FatalError` | Stops retries immediately. | | `retry.onThrow` / `retry.fetch` | `RetryableError`, `FatalError`, `maxRetries` | Retry lives on the step. Exponential backoff is a step `maxRetries` config, not a helper. | -| `metadata.stream()` / Realtime | `getWritable()` | Durable stream per run. | +| `metadata.stream()` / Realtime | `getWritable()` / `getWritable({ namespace })` | Named streams are the canonical read channel. Clients read from the end of the stream for current status. Do not poll `getRun()` or persist status to a database for client reads. | | Self-hosted worker + dashboard | Managed execution + built-in UI | No worker fleet to operate. | ## Translate your first workflow @@ -112,7 +120,7 @@ using approval = createHook<{ approved: boolean }>({ // [!code highlight] const payload = await approval; ``` -**What changed:** the platform-issued opaque token becomes an app-owned string. The caller that resumes the run supplies that same string — no token lookup. +**What changed:** the platform-issued opaque token becomes an app-owned string. The caller that resumes the run supplies that same string, so there is no token lookup. ### Resume from an API route @@ -145,20 +153,19 @@ return { refundId, status: 'approved' }; ``` -A hook is a write channel. The caller that knows the token resumes the run with a typed payload. To expose in-flight state to a dashboard, read it from the app with `getRun()` or persist it from a step. +A hook is an inbound write channel. The caller that knows the token resumes the run with a typed payload. To expose in-flight state to a dashboard, write updates from a step with `getWritable()` (or `getWritable({ namespace: 'status' })`), and have the client read from the end of that named stream. ## Spawn a child workflow -`triggerAndWait()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass the `runId` through the workflow. +`triggerAndWait()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the full `Run` object from `spawnChild` so observability tooling can deep-link to the child run. ```typescript title="workflow/workflows/parent.ts" import { start } from 'workflow/api'; -async function spawnChild(item: string): Promise { +async function spawnChild(item: string) { 'use step'; - const run = await start(childWorkflow, [item]); // [!code highlight] - return run.runId; + return start(childWorkflow, [item]); // [!code highlight] } ``` @@ -175,56 +182,18 @@ async function collectResult(runId: string) { export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); - return await collectResult(runId); + const child = await spawnChild(item); + return await collectResult(child.runId); } ``` -To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls — that replaces `batch.triggerAndWait()`. - -## Migrate a full order-processing saga - -This example covers compensation, idempotency keys, retry semantics, and progress streaming. - -### trigger.dev version - -A trigger.dev saga nests try/catch around each `tasks.triggerAndWait` call. Retry config sits on the task; compensation is wired by hand: - -```typescript title="trigger/order-saga.ts" -import { task, AbortTaskRunError } from '@trigger.dev/sdk/v3'; -import { reserveInventory, chargePayment, createShipment } from './steps'; -import { releaseInventory, refundPayment } from './compensations'; - -export const processOrderSaga = task({ - id: 'process-order-saga', - retry: { maxAttempts: 3 }, - run: async ({ orderId }: { orderId: string }) => { - const order = await loadOrder.triggerAndWait({ orderId }).unwrap(); - const inventory = await reserveInventory.triggerAndWait({ order }).unwrap(); - let payment; - try { - payment = await chargePayment.triggerAndWait({ order }).unwrap(); - } catch (error) { - await releaseInventory.triggerAndWait({ id: inventory.reservationId }); - throw error; - } - try { - const shipment = await createShipment.triggerAndWait({ order }).unwrap(); - return { orderId: order.id, shipmentId: shipment.shipmentId }; - } catch (error) { - await refundPayment.triggerAndWait({ id: payment.chargeId }); - await releaseInventory.triggerAndWait({ id: inventory.reservationId }); - throw error; - } - }, -}); -``` +To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. That replaces `batch.triggerAndWait()`. -Each referenced task is defined separately with `task({ id, run })`. Progress streaming uses `metadata.stream(...)` and a Realtime subscription on the client. +## Writing a saga with the Workflow SDK -### Workflow SDK version +A saga coordinates forward work with compensation on failure. The Workflow SDK does not ship a dedicated saga primitive. The pattern below is plain TypeScript: push a compensating callback into an array after each successful side effect, and unwind the array in LIFO order if anything throws. Trigger.dev users write the same pattern by hand today. The difference is that the Workflow SDK runs every step and every compensation durably, so a crash mid-rollback resumes where it left off. -The saga splits into four parts: the orchestrator, the forward steps, the compensation steps, and a progress emitter. Each block shares the same imports and types: +This example covers compensation, idempotency keys, retry semantics, and progress streaming. The saga splits into four parts: the orchestrator, the forward steps, the compensation steps, and a progress emitter. Each block shares the same imports and types: ```typescript title="workflow/workflows/order-saga.ts" import { FatalError, getStepMetadata, getWritable } from 'workflow'; @@ -237,7 +206,7 @@ type Shipment = { shipmentId: string }; #### Orchestrator: forward path -The workflow calls each step in order and pushes a compensation onto a stack after every side effect. +The workflow calls each step in order. After every successful side effect, it pushes a compensation callback onto a plain array. This is a standard TypeScript compensation pattern, not a Workflow SDK primitive. ```typescript export async function processOrderSaga(orderId: string) { @@ -275,7 +244,7 @@ async function unwind(rollbacks: Array<() => Promise>) { #### Forward step with retry semantics -`getStepMetadata().stepId` stays stable across replays — pass it as `Idempotency-Key`. `FatalError` replaces `AbortTaskRunError` for permanent failures. +`getStepMetadata().stepId` stays stable across replays, so pass it as `Idempotency-Key`. `FatalError` replaces `AbortTaskRunError` for permanent failures. ```typescript async function reserveInventory(order: Order): Promise { @@ -311,19 +280,19 @@ async function releaseInventory(reservationId: string) { #### Durable progress streaming -`getWritable()` replaces `metadata.stream()` and Realtime. +`getWritable()` replaces `metadata.stream()` and Realtime. Use a namespace to isolate status updates from other streams on the same run. Clients read from the end of the named stream for current status. There is no polling of `getRun()` and no separate database write. ```typescript async function emitProgress(update: { stage: string; orderId: string }) { 'use step'; - const writer = getWritable().getWriter(); + const writer = getWritable({ namespace: 'status' }).getWriter(); try { await writer.write(update); } // [!code highlight] finally { writer.releaseLock(); } } ``` -Per-task retry config (`retry: { maxAttempts: 3 }`) collapses into step-level defaults. Throw `RetryableError` to retry with a delay, or `FatalError` to stop immediately — no central task policy. +Per-task retry config (`retry: { maxAttempts: 3 }`) collapses into step-level defaults. Throw `RetryableError` to retry with a delay, or `FatalError` to stop immediately. There is no central task policy. ## What you stop operating @@ -344,10 +313,10 @@ Pick one trigger.dev task and migrate it end-to-end before touching the rest. Th ### Step 1: Install the Workflow SDK -Add the runtime and the framework integration that matches the app. +Add the runtime. The Next.js integration ships as the `workflow/next` subpath of the same package. ```bash -pnpm add workflow @workflow/next +pnpm add workflow ``` ### Step 2: Convert `task()` to a `"use workflow"` export @@ -370,7 +339,7 @@ export async function processOrder(orderId: string) { ### Step 3: Convert the task body into `"use step"` named functions -Each side effect becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`. `schemaTask()` validation moves to the call site — validate with zod before calling `start()`. +Each side effect becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`. `schemaTask()` validation moves to the call site: validate with zod before calling `start()`. ```ts async function loadOrder(id: string) { @@ -384,7 +353,7 @@ async function loadOrder(id: string) { - `wait.for({ seconds })` / `wait.until({ date })` → `sleep('5m')` or `sleep(date)` from `workflow`. - `wait.forToken(token)` → `createHook({ token })` + `await`. Complete it with `resumeHook(token, payload)` from an API route. - `wait.forToken({ timeout })` → `Promise.race([hook, sleep(timeout)])`. -- `triggerAndWait(payload)` → wrap `start(child, [payload])` in a `"use step"` function, then read the result with a second step that calls `getRun(runId).returnValue`. +- `triggerAndWait(payload)` → wrap `start(child, [payload])` in a `"use step"` function and return the `Run` object, then read the result with a second step that calls `getRun(runId).returnValue`. ### Step 5: Start runs from the app @@ -413,7 +382,7 @@ Remove the `@trigger.dev/sdk` dependency, the `trigger.config.ts` file, the `tri - Swap `wait.forToken` for `createHook()` (internal) or `createWebhook()` (HTTP). - Model `wait.forToken` timeouts as `Promise.race()` between the hook and `sleep()`. - Replace `triggerAndWait()` with `"use step"` wrappers around `start()` and `getRun()`. -- Replace `batch.triggerAndWait()` with `Promise.all` over collected `runId`s. +- Replace `batch.triggerAndWait()` with `Promise.all` over the collected child `Run` handles. - Move `schemaTask` validation to the call site; pass typed arguments into the workflow. - Replace `AbortTaskRunError` with `FatalError`; model retries per step with `RetryableError` and `maxRetries`. - Use `getStepMetadata().stepId` as the idempotency key for external side effects. From 53e22144563817d9f15a5314d7298fb5c07ab1bd Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Tue, 14 Apr 2026 12:36:21 -0600 Subject: [PATCH 7/7] [docs] Drop saga sections and add migration skill callout Remove the "Writing a saga" section from each migration guide (it described a generic TypeScript compensation pattern rather than an SDK feature) and surface the migration skill install command in a callout at the top of the index and each guide. --- docs/content/docs/migration-guides/index.mdx | 10 +- .../migrating-from-aws-step-functions.mdx | 80 ++---------- .../migrating-from-inngest.mdx | 116 ++---------------- .../migrating-from-temporal.mdx | 97 ++------------- .../migrating-from-trigger-dev.mdx | 116 ++---------------- 5 files changed, 45 insertions(+), 374 deletions(-) diff --git a/docs/content/docs/migration-guides/index.mdx b/docs/content/docs/migration-guides/index.mdx index 8131493d61..7f1cd91836 100644 --- a/docs/content/docs/migration-guides/index.mdx +++ b/docs/content/docs/migration-guides/index.mdx @@ -8,7 +8,15 @@ related: - /docs/getting-started --- -Move an existing orchestration system to the Workflow SDK. Each guide pairs a concept-mapping table with side-by-side code and a full order-processing saga, so you can translate one piece of your codebase at a time. + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + +Move an existing orchestration system to the Workflow SDK. Each guide pairs a concept-mapping table with side-by-side code, so you can translate one piece of your codebase at a time. diff --git a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx index 94514f73ed..2470a60927 100644 --- a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -2,7 +2,7 @@ title: Migrating from AWS Step Functions description: Move an AWS Step Functions state machine to the Workflow SDK by replacing JSON state definitions, Task states, Choice/Wait/Parallel states, Retry/Catch blocks, and .waitForTaskToken callbacks with Workflows, Steps, Hooks, and idiomatic TypeScript control flow. type: guide -summary: Translate an AWS Step Functions state machine into the Workflow SDK with side-by-side code and a realistic order-processing saga. +summary: Translate an AWS Step Functions state machine into the Workflow SDK with side-by-side code examples. prerequisites: - /docs/getting-started/next - /docs/foundations/workflows-and-steps @@ -16,6 +16,14 @@ related: Move an AWS Step Functions state machine to the Workflow SDK by replacing JSON state definitions with TypeScript functions. This guide shows the direct mapping between ASL states and Workflow SDK primitives. + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + ## Why migrate to the Workflow SDK - Orchestration code is TypeScript, not JSON ASL. Transitions are `await`, branches are `if`/`switch`, and parallelism is `Promise.all`. @@ -215,76 +223,6 @@ async function collectResult(runId: string) { Then in the workflow: `const result = await collectResult(run.runId);`. The child workflow itself (`childWorkflow`) is defined elsewhere with `"use workflow"`. -## Writing a saga with the Workflow SDK - -Step Functions expresses the saga pattern with `Catch` transitions to explicit compensation states. The Workflow SDK expresses the same pattern with plain TypeScript: collect compensating actions in an array as forward steps succeed, then drain it in reverse inside a `catch` block. This is a standard compensation pattern, not a built-in SDK feature. - -### Forward path with a compensation array - -```typescript title="workflow/workflows/order-saga.ts" -export async function processOrderSaga(orderId: string) { - 'use workflow'; - const rollbacks: Array<() => Promise> = []; // [!code highlight] - - try { - const order = await loadOrder(orderId); - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - - return { orderId: order.id, status: 'completed' }; - } catch (error) { - while (rollbacks.length > 0) { // [!code highlight] - await rollbacks.pop()!(); - } - throw error; - } -} -``` - -The `rollbacks` array is a plain TypeScript variable. Each forward step pushes a compensating action; the `catch` block drains the stack in reverse. Because each compensating call is a `"use step"` function, every rollback runs durably even if the workflow restarts mid-compensation. - -### Forward steps: retry and idempotency - -Retry moves from per-state ASL config to step defaults. `FatalError` opts out of retry, and `getStepMetadata().stepId` gives a stable idempotency key: - -```typescript title="workflow/workflows/order-saga.ts" -import { FatalError, getStepMetadata } from 'workflow'; - -async function reserveInventory(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/inventory/reservations', { - method: 'POST', - headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, // [!code highlight] - body: JSON.stringify({ orderId: order.id }), - }); - if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; -} -``` - -`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. Compensation steps (`releaseInventory`, `refundPayment`, `cancelShipment`) are `"use step"` functions that issue a DELETE or refund call. - -### Streaming progress with named streams - -Write durable progress updates out with `getWritable()`. Clients read them back by reading from the end of the stream via `getRun()`. No DynamoDB or SNS glue required. - -```typescript -import { getWritable } from 'workflow'; - -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writer = getWritable({ namespace: 'status' }).getWriter(); - await writer.write(update); // [!code highlight] - writer.releaseLock(); -} -``` - -The `namespace` option distinguishes multiple streams on the same run (for example, `'status'` for UI updates versus the default stream for logs). Clients get current progress by reading from the stream on the returned `Run`. - ## What you stop operating Moving off Step Functions removes these surfaces from the application: diff --git a/docs/content/docs/migration-guides/migrating-from-inngest.mdx b/docs/content/docs/migration-guides/migrating-from-inngest.mdx index 7337f12634..300c978cd9 100644 --- a/docs/content/docs/migration-guides/migrating-from-inngest.mdx +++ b/docs/content/docs/migration-guides/migrating-from-inngest.mdx @@ -2,7 +2,7 @@ title: Migrating from Inngest description: Move an Inngest TypeScript app to the Workflow SDK by replacing createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, Hooks, and start()/getRun(). type: guide -summary: Translate an Inngest app into the Workflow SDK with side-by-side code and a realistic order-processing saga. +summary: Translate an Inngest app into the Workflow SDK with side-by-side code examples. prerequisites: - /docs/getting-started/next - /docs/foundations/workflows-and-steps @@ -14,6 +14,14 @@ related: - /docs/deploying/world/vercel-world --- + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + ## Why migrate to the Workflow SDK - Streaming is built in. Durable progress writes go to named streams via `getWritable()`. There is no separate realtime publish channel or WebSocket layer to operate. @@ -177,112 +185,6 @@ export async function parentWorkflow(item: string) { } ``` -## Writing a saga with the Workflow SDK - -A saga coordinates multiple side effects so that a failure in a later step unwinds the earlier ones. Both Inngest and the Workflow SDK support this pattern through ordinary TypeScript control flow. The example below shows one way to implement compensation in plain TypeScript: a rollback array that the orchestrator builds up as it goes and unwinds in a `catch` block. It is a convention, not a built-in SDK feature. - -The saga splits into four sections: the orchestrator, the forward steps, the compensation steps, and a progress emitter. Each block shares the same imports and types: - -```typescript title="workflow/workflows/order-saga.ts" -import { FatalError, getStepMetadata, getWritable } from 'workflow'; - -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; -``` - -#### Orchestrator: forward path - -The workflow calls each step in order and pushes a compensation onto a stack after every side effect. - -```typescript -export async function processOrderSaga(orderId: string) { - 'use workflow'; - const rollbacks: Array<() => Promise> = []; // [!code highlight] - try { - const order = await loadOrder(orderId); - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); - return { orderId: order.id, shipmentId: shipment.shipmentId, status: 'completed' }; - } catch (error) { - await unwind(rollbacks); - throw error; - } -} -``` - -Sprinkle `await emitProgress(...)` between steps to stream stage updates. - -#### Compensation loop - -If any forward step throws, unwind in LIFO order: - -```typescript -async function unwind(rollbacks: Array<() => Promise>) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); // [!code highlight] - } -} -``` - -#### Forward step with retry semantics - -`getStepMetadata().stepId` is stable across replays, so pass it as `Idempotency-Key`. Use `FatalError` to skip retries on permanent failures. - -```typescript -async function reserveInventory(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); // [!code highlight] - const res = await fetch('https://example.com/api/inventory/reservations', { - method: 'POST', - headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; -} -``` - -`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. - -#### Compensation steps - -Compensations are `"use step"` functions too, so a restart mid-rollback resumes where it left off. Keep them idempotent. - -```typescript -async function releaseInventory(reservationId: string) { - 'use step'; - await fetch(`https://example.com/api/inventory/reservations/${reservationId}`, { - method: 'DELETE', - }); -} -``` - -`refundPayment` and `cancelShipment` follow the same shape. - -#### Durable progress streaming - -Workflows push data out through named streams. Clients read from the end of the stream to get the latest value. No separate realtime publish channel, database write, or `getRun()` poll is involved. - -```typescript -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writer = getWritable({ namespace: 'status' }).getWriter(); - try { await writer.write(update); } // [!code highlight] - finally { writer.releaseLock(); } -} -``` - - -A single rollback array scales to any number of steps without nested `try`/`catch` blocks. Any other compensation strategy that plain TypeScript supports works too. - - ## What you stop operating Dropping the Inngest SDK removes several moving parts: diff --git a/docs/content/docs/migration-guides/migrating-from-temporal.mdx b/docs/content/docs/migration-guides/migrating-from-temporal.mdx index b5eef1ef03..1a875d0f10 100644 --- a/docs/content/docs/migration-guides/migrating-from-temporal.mdx +++ b/docs/content/docs/migration-guides/migrating-from-temporal.mdx @@ -2,7 +2,7 @@ title: Migrating from Temporal description: Move a Temporal TypeScript workflow to the Workflow SDK by replacing Activities, Workers, Signals, and Child Workflows with Workflows, Steps, Hooks, and start()/getRun(). type: guide -summary: Translate a Temporal app into the Workflow SDK with side-by-side code and a realistic order-processing saga. +summary: Translate a Temporal app into the Workflow SDK with side-by-side code examples. prerequisites: - /docs/getting-started/next - /docs/foundations/workflows-and-steps @@ -14,6 +14,14 @@ related: - /docs/deploying/world/vercel-world --- + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + ## Why migrate to the Workflow SDK - Streaming is built in. Durable progress writes go to named streams via `getWritable({ namespace })`, and clients read them directly. No separate WebSocket, SSE, or progress-polling layer to operate. @@ -193,93 +201,6 @@ Call both steps from the parent in sequence: `const result = await collectResult Activity retry policy moves to the step boundary. Use `maxRetries`, `RetryableError`, and `FatalError` on each step instead of a single workflow-wide retry block. -## Writing a saga - -Sagas are not a feature of any particular SDK. They are a general pattern: run forward steps, and on failure run their compensations in reverse. Temporal workflows and Workflow SDK workflows can both implement this pattern the same way, typically with a compensation stack (see, for example, [Temporal's saga pattern guide](https://temporal.io/blog/saga-pattern-made-easy)). - -Below is one way to write the SAGA pattern with the Workflow SDK, using a rollback stack, per-step idempotency keys, and a durable progress stream. - -#### Orchestration: forward path - -Start with the happy path. Each forward call pushes its compensation onto a stack. - -```typescript -export async function processOrderSaga(orderId: string) { - 'use workflow'; - const rollbacks: Array<() => Promise> = []; - - const order = await loadOrder(orderId); - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); // [!code highlight] - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); // [!code highlight] - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); // [!code highlight] - - return { orderId: order.id, shipmentId: shipment.shipmentId, status: 'completed' }; -} -``` - -#### Compensation: rollback loop - -Wrap the forward path in a try/catch. On failure, unwind the stack in reverse: - -```typescript -try { - // ...forward path above -} catch (error) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); // [!code highlight] - } - throw error; -} -``` - -#### Steps: one forward step - -Forward steps use `FatalError` to skip retries on unrecoverable responses and `getStepMetadata().stepId` as an idempotency key: - -```typescript -import { FatalError, getStepMetadata } from 'workflow'; - -async function reserveInventory(order: Order) { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/inventory/reservations', { - method: 'POST', - headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, // [!code highlight] - body: JSON.stringify({ orderId: order.id }), - }); - if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise<{ reservationId: string }>; -} -``` - -The other forward steps (`loadOrder`, `chargePayment`, `createShipment`) follow the same shape. Compensation steps (`releaseInventory`, `refundPayment`, `cancelShipment`) are plain `DELETE`/refund calls marked `"use step"`. - -#### Streaming progress - -`getWritable({ namespace: 'status' })` replaces any external progress transport. Writes flow back to callers over a durable stream, and clients read from the end of the stream to get the latest status: - -```typescript -import { getWritable } from 'workflow'; - -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writer = getWritable({ namespace: 'status' }).getWriter(); // [!code highlight] - try { await writer.write(update); } finally { writer.releaseLock(); } -} -``` - -Call `await emitProgress({ stage: 'inventory_reserved', orderId: order.id })` between forward steps. - -**What changed:** - -- Compensation wiring collapses into a rollback stack with a single catch block. -- `getStepMetadata().stepId` provides a stable idempotency key for external APIs. -- `getWritable()` streams progress from steps directly, without a separate progress store. - ## What you stop operating - **Temporal Server or Cloud.** Durable state lives in the managed event log. diff --git a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx index 786cf6f606..a2a7b145b2 100644 --- a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx +++ b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx @@ -2,7 +2,7 @@ title: Migrating from trigger.dev description: Move a trigger.dev v3 TypeScript app to the Workflow SDK by replacing task(), schemaTask(), wait.for / wait.forToken, triggerAndWait, and metadata streams with Workflows, Steps, Hooks, and start() / getRun(). type: guide -summary: Translate a trigger.dev v3 app into the Workflow SDK with side-by-side code and a realistic order-processing saga. +summary: Translate a trigger.dev v3 app into the Workflow SDK with side-by-side code examples. prerequisites: - /docs/getting-started/next - /docs/foundations/workflows-and-steps @@ -14,6 +14,14 @@ related: - /docs/deploying/world/vercel-world --- + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + ## Why migrate to the Workflow SDK? - Streaming is built in via named streams (`getWritable()` and `getWritable({ namespace })`). Durable status writes replace `metadata.stream()` and `metadata.set()`, and clients read from the end of the stream for current status. @@ -189,112 +197,6 @@ export async function parentWorkflow(item: string) { To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. That replaces `batch.triggerAndWait()`. -## Writing a saga with the Workflow SDK - -A saga coordinates forward work with compensation on failure. The Workflow SDK does not ship a dedicated saga primitive. The pattern below is plain TypeScript: push a compensating callback into an array after each successful side effect, and unwind the array in LIFO order if anything throws. Trigger.dev users write the same pattern by hand today. The difference is that the Workflow SDK runs every step and every compensation durably, so a crash mid-rollback resumes where it left off. - -This example covers compensation, idempotency keys, retry semantics, and progress streaming. The saga splits into four parts: the orchestrator, the forward steps, the compensation steps, and a progress emitter. Each block shares the same imports and types: - -```typescript title="workflow/workflows/order-saga.ts" -import { FatalError, getStepMetadata, getWritable } from 'workflow'; - -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; -``` - -#### Orchestrator: forward path - -The workflow calls each step in order. After every successful side effect, it pushes a compensation callback onto a plain array. This is a standard TypeScript compensation pattern, not a Workflow SDK primitive. - -```typescript -export async function processOrderSaga(orderId: string) { - 'use workflow'; - const rollbacks: Array<() => Promise> = []; // [!code highlight] - try { - const order = await loadOrder(orderId); - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); - return { orderId: order.id, shipmentId: shipment.shipmentId, status: 'completed' }; - } catch (error) { - await unwind(rollbacks); - throw error; - } -} -``` - -Call `await emitProgress(...)` between steps to stream stage updates. - -#### Compensation loop - -If any forward step throws, unwind in LIFO order: - -```typescript -async function unwind(rollbacks: Array<() => Promise>) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); // [!code highlight] - } -} -``` - -#### Forward step with retry semantics - -`getStepMetadata().stepId` stays stable across replays, so pass it as `Idempotency-Key`. `FatalError` replaces `AbortTaskRunError` for permanent failures. - -```typescript -async function reserveInventory(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); // [!code highlight] - const res = await fetch('https://example.com/api/inventory/reservations', { - method: 'POST', - headers: { 'Idempotency-Key': stepId, 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (res.status === 404) throw new FatalError('Order not found'); // [!code highlight] - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; -} -``` - -`loadOrder`, `chargePayment`, and `createShipment` follow the same shape. - -#### Compensation steps - -Compensations are `"use step"` functions too, so a restart mid-rollback resumes where it left off. Keep them idempotent. - -```typescript -async function releaseInventory(reservationId: string) { - 'use step'; - await fetch(`https://example.com/api/inventory/reservations/${reservationId}`, { - method: 'DELETE', - }); -} -``` - -`refundPayment` and `cancelShipment` follow the same shape. - -#### Durable progress streaming - -`getWritable()` replaces `metadata.stream()` and Realtime. Use a namespace to isolate status updates from other streams on the same run. Clients read from the end of the named stream for current status. There is no polling of `getRun()` and no separate database write. - -```typescript -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writer = getWritable({ namespace: 'status' }).getWriter(); - try { await writer.write(update); } // [!code highlight] - finally { writer.releaseLock(); } -} -``` - - -Per-task retry config (`retry: { maxAttempts: 3 }`) collapses into step-level defaults. Throw `RetryableError` to retry with a delay, or `FatalError` to stop immediately. There is no central task policy. - - ## What you stop operating Dropping the trigger.dev SDK removes several moving parts: