diff --git a/docs/content/docs/api-reference/workflow/create-webhook.mdx b/docs/content/docs/api-reference/workflow/create-webhook.mdx index a6d56151b4..ac63878d4d 100644 --- a/docs/content/docs/api-reference/workflow/create-webhook.mdx +++ b/docs/content/docs/api-reference/workflow/create-webhook.mdx @@ -55,9 +55,25 @@ The returned `Webhook` object has: - `url`: The HTTP endpoint URL that external systems can call - `token`: The unique token identifying this webhook -- Implements `AsyncIterable` for handling multiple requests +- Implements `AsyncIterable` for handling multiple requests, where `T` is `Request` (default) or `RequestWithResponse` (manual mode) -The `RequestWithResponse` type extends the standard `Request` interface with a `respondWith(response: Response)` method for sending custom responses back to the caller. +When using `createWebhook({ respondWith: 'manual' })`, the resolved request type is `RequestWithResponse`, which extends the standard `Request` interface with a `respondWith(response: Response): Promise` method for sending custom responses back to the caller. + + +Use the simplest option that satisfies the prompt: + +- `createWebhook()` — generated callback URL, and the default `202 Accepted` response is fine +- `createWebhook({ respondWith: 'manual' })` — generated callback URL, but you must send a custom body, status, or headers +- `createHook()` + `resumeHook()` — the app resumes from server-side code with a deterministic business token instead of a generated callback URL + + +
+Common wrong turns + +- Do not use `respondWith: 'manual'` just because the flow has a callback URL. +- Do not use `RequestWithResponse` unless you chose manual mode. +- Do not invent a custom callback route when `webhook.url` is the intended callback surface. +
## Examples @@ -84,27 +100,29 @@ export async function basicWebhookWorkflow() { } ``` -### Responding to Webhook Requests +### Responding to Webhook Requests (Manual Mode) + +Use this section only when the caller requires a non-default HTTP response. If `202 Accepted` is acceptable, use `createWebhook()` without `respondWith: "manual"`. -Use the `respondWith()` method to send custom HTTP responses. Note that `respondWith()` must be called from within a step function: +Pass `{ respondWith: "manual" }` to get a `RequestWithResponse` object with a `respondWith()` method. Note that `respondWith()` must be called from within a step function: ```typescript lineNumbers import { createWebhook, type RequestWithResponse } from "workflow" -async function sendResponse(request: RequestWithResponse) { // [!code highlight] - "use step"; // [!code highlight] - await request.respondWith( // [!code highlight] - new Response(JSON.stringify({ success: true, message: "Received!" }), { // [!code highlight] - status: 200, // [!code highlight] - headers: { "Content-Type": "application/json" } // [!code highlight] - }) // [!code highlight] - ); // [!code highlight] -} // [!code highlight] +async function sendResponse(request: RequestWithResponse): Promise { + "use step"; + await request.respondWith( + new Response(JSON.stringify({ success: true, message: "Received!" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); +} export async function respondingWebhookWorkflow() { "use workflow"; - using webhook = createWebhook(); + using webhook = createWebhook({ respondWith: "manual" }); console.log("Webhook URL:", webhook.url); const request = await webhook; @@ -117,7 +135,7 @@ export async function respondingWebhookWorkflow() { await processData(data); } -async function processData(data: any) { +async function processData(data: any): Promise { "use step"; // Process the webhook data console.log("Processing:", data); @@ -165,6 +183,7 @@ export async function eventCollectorWorkflow() { ## Related Functions -- [`createHook()`](/docs/api-reference/workflow/create-hook) - Lower-level hook primitive for arbitrary payloads -- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper -- [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) - Resume a webhook from an API route +- [`createHook()`](/docs/api-reference/workflow/create-hook) — Use when the app resumes from server-side code with a deterministic business token. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — Pairs with `createHook()` for deterministic server-side resume. +- [`defineHook()`](/docs/api-reference/workflow/define-hook) — Type-safe hook helper. +- [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) — Low-level runtime API. Most integrations should call `webhook.url` directly instead of adding a custom callback route. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index eda30fd046..62762d94fa 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -10,6 +10,7 @@ "testing", "deploying", "errors", + "migration-guides", "api-reference" ] } diff --git a/docs/content/docs/migration-guides/index.mdx b/docs/content/docs/migration-guides/index.mdx new file mode 100644 index 0000000000..bc28b14d36 --- /dev/null +++ b/docs/content/docs/migration-guides/index.mdx @@ -0,0 +1,23 @@ +--- +title: Migration Guides +description: Move your existing durable workflow system to the Workflow SDK with side-by-side code comparisons and realistic migration examples. +type: overview +summary: Migrate from Temporal, Inngest, or AWS Step Functions to the Workflow SDK. +related: + - /docs/foundations/workflows-and-steps + - /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. + + + + Replace Activities, Workers, Signals, and Child Workflows with Workflows, Steps, Hooks, and start()/getRun(). + + + Replace createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, and Hooks. + + + Replace JSON state definitions, Task/Choice/Wait/Parallel states, and .waitForTaskToken callbacks with idiomatic TypeScript. + + diff --git a/docs/content/docs/migration-guides/meta.json b/docs/content/docs/migration-guides/meta.json new file mode 100644 index 0000000000..5682025c55 --- /dev/null +++ b/docs/content/docs/migration-guides/meta.json @@ -0,0 +1,8 @@ +{ + "title": "Migration Guides", + "pages": [ + "migrating-from-temporal", + "migrating-from-inngest", + "migrating-from-aws-step-functions" + ] +} 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 new file mode 100644 index 0000000000..65420dec15 --- /dev/null +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -0,0 +1,516 @@ +--- +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. +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 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. + +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 migration path is mostly about **replacing declarative configuration with idiomatic TypeScript** and **collapsing the orchestrator/compute split**, not rewriting business logic. + +## 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 + } + } +} +``` + +Plus three separate Lambda functions: + +{/* @skip-typecheck */} +```typescript +// lambda/loadOrder.ts +export const handler = async (event: { orderId: string }) => { + const res = await fetch( + `https://example.com/api/orders/${event.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; +}; + +// 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' }; +}; +``` + +### Workflow SDK + +```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', + }); +} +``` + +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. + +## Side-by-side: waiting for external approval + +### AWS Step Functions + +```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 + } + } +} +``` + +The callback handler calls `SendTaskSuccess` with the task token: + +```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 }), + }) + ); +}; +``` + +### Workflow SDK + +```typescript +// 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`, + }); + + const { approved } = await approval; + + if (!approved) { + return { refundId, status: 'rejected' }; + } + + return { refundId, status: 'approved' }; +} +``` + +### Resuming the hook from an API route + +```typescript +// 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 body = (await request.json()) as { approved: boolean }; + + await resumeHook(`refund:${refundId}:approval`, { + approved: body.approved, + }); + + 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. + +### Child workflows: keep `start()` and `getRun()` in steps + +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 +import { getRun, start } from 'workflow/api'; + +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 }; +} + +async function spawnChild(item: string): Promise { + 'use step'; + 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 }; +} +``` + +## End-to-end migration: order processing saga + +This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. + +### 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: + +```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" + } + } +} +``` + +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. + +### Workflow SDK version + +```typescript +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'; + + 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; + } +} + +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; +} + +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', + }, + body: JSON.stringify({ orderId: order.id }), + }); + 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' } + ); +} + +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; +} + +async function refundPayment(chargeId: string): Promise { + 'use step'; + await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { + method: 'POST', + }); +} + +async function createShipment(order: Order): Promise { + '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; +} + +async function cancelShipment(shipmentId: string): Promise { + 'use step'; + await fetch(`https://example.com/api/shipments/${shipmentId}`, { + method: 'DELETE', + }); +} + +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 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. + +## Why teams usually simplify infrastructure in this move + +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. + +With the Workflow SDK: + +- **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. + +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. + +## 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. diff --git a/docs/content/docs/migration-guides/migrating-from-inngest.mdx b/docs/content/docs/migration-guides/migrating-from-inngest.mdx new file mode 100644 index 0000000000..4da3d12c51 --- /dev/null +++ b/docs/content/docs/migration-guides/migrating-from-inngest.mdx @@ -0,0 +1,543 @@ +--- +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. +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 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. + +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 migration path is mostly about **collapsing the SDK abstraction** and **writing plain async functions**, not rewriting business logic. + +## 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. | + +## Side-by-side: hello workflow + +### Inngest + +```typescript +// inngest/functions/order.ts +import { inngest } from '../client'; + +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' }; + } +); +``` + +### Workflow SDK + +```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', + }); +} +``` + +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. + +## Side-by-side: waiting for external approval + +### Inngest + +```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; + + 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' }; + } +); +``` + +### Workflow SDK + +```typescript +// workflow/workflows/refund.ts +import { createHook, sleep } from 'workflow'; + +type ApprovalResult = + | { type: 'decision'; approved: boolean } + | { type: 'timeout'; approved: false }; + +export async function refundWorkflow(refundId: string) { + 'use workflow'; + + using approval = createHook<{ approved: boolean }>({ + token: `refund:${refundId}: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' }; + } + + if (!result.approved) { + return { refundId, status: 'rejected' }; + } + + return { refundId, status: 'approved' }; +} +``` + +### Resuming the hook from an API route + +```typescript +// 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 body = (await request.json()) as { approved: boolean }; + + await resumeHook(`refund:${refundId}:approval`, { + approved: body.approved, + }); + + 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. + +### Child workflows: keep `start()` and `getRun()` in steps + +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 +import { getRun, start } from 'workflow/api'; + +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 }; +} + +async function spawnChild(item: string): Promise { + 'use step'; + 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 }; +} +``` + +## End-to-end migration: order processing saga + +This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. + +### Inngest version + +```typescript +// 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 }, + { 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" + } + } +} +``` + +**Expected output:** + +```json +{ + "orderId": "ord_123", + "reservationId": "res_456", + "chargeId": "ch_789", + "shipmentId": "shp_101", + "status": "completed" +} +``` + +### Workflow SDK version + +```typescript +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'; + + 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; + } +} + +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; +} + +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', + }, + body: JSON.stringify({ orderId: order.id }), + }); + 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' } + ); +} + +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; +} + +async function refundPayment(chargeId: string): Promise { + 'use step'; + await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { + method: 'POST', + }); +} + +async function createShipment(order: Order): Promise { + '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; +} + +async function cancelShipment(shipmentId: string): Promise { + 'use step'; + await fetch(`https://example.com/api/shipments/${shipmentId}`, { + method: 'DELETE', + }); +} + +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(); + } +} +``` + +- 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. + +## Why teams usually simplify infrastructure in this move + +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. + +With the Workflow SDK: + +- **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. + +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. + +## Quick-start checklist + +- Replace `inngest.createFunction()` with a `"use workflow"` function and start it with `start()` from your app boundary. +- 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. diff --git a/docs/content/docs/migration-guides/migrating-from-temporal.mdx b/docs/content/docs/migration-guides/migrating-from-temporal.mdx new file mode 100644 index 0000000000..608ee9b895 --- /dev/null +++ b/docs/content/docs/migration-guides/migrating-from-temporal.mdx @@ -0,0 +1,430 @@ +--- +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. +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 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. + +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 migration path is mostly about **removing infrastructure** and **collapsing indirection**, not rewriting business logic. + +## Concept mapping + +| Temporal | Workflow SDK | Migration note | +| --- | --- | --- | +| Workflow Definition / Workflow Execution | `"use workflow"` function / run started with `start()` | Keep orchestration code in the workflow function. | +| 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. | + +## Side-by-side: hello workflow + +### Temporal + +```typescript +// temporal/workflows/order.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from '../activities/order'; + +const { loadOrder, reserveInventory, 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' }; +} +``` + +### Workflow SDK + +```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', + }); +} +``` + +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. + +## Side-by-side: waiting for external approval + +### Temporal + +```typescript +// temporal/workflows/refund.ts +import * as wf from '@temporalio/workflow'; + +export const approveRefund = wf.defineSignal<[boolean]>('approveRefund'); + +export async function refundWorkflow(refundId: string) { + let approved: boolean | undefined; + + wf.setHandler(approveRefund, (value) => { + approved = value; + }); + + await wf.condition(() => approved !== undefined); + + if (!approved) { + return { refundId, status: 'rejected' }; + } + + return { refundId, status: 'approved' }; +} +``` + +### Workflow SDK + +```typescript +// 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`, + }); + + const { approved } = await approval; + + if (!approved) { + return { refundId, status: 'rejected' }; + } + + return { refundId, status: 'approved' }; +} +``` + +### Resuming the hook from an API route + +```typescript +// 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 body = (await request.json()) as { approved: boolean }; + + await resumeHook(`refund:${refundId}:approval`, { + approved: body.approved, + }); + + 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. + +### Child workflows: keep `start()` and `getRun()` in steps + +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 +import { getRun, start } from 'workflow/api'; + +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 }; +} + +async function spawnChild(item: string): Promise { + 'use step'; + 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 }; +} +``` + +## End-to-end migration: order processing saga + +This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. + +### Temporal version + +A Temporal saga requires manual compensation wiring — typically nested try/catch blocks around each Activity call, with explicit rollback Activities in each catch: + +```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 }, +}); + +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); + throw error; + } + + let shipment: { shipmentId: string }; + try { + shipment = await createShipment(order); + } catch (error) { + await refundPayment(payment.chargeId); + await releaseInventory(inventory.reservationId); + 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. + +### Workflow SDK version + +```typescript +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'; + + 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; + } +} + +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; +} + +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', + }, + body: JSON.stringify({ orderId: order.id }), + }); + 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' } + ); +} + +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; +} + +async function refundPayment(chargeId: string): Promise { + 'use step'; + await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { + method: 'POST', + }); +} + +async function createShipment(order: Order): Promise { + '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; +} + +async function cancelShipment(shipmentId: string): Promise { + 'use step'; + await fetch(`https://example.com/api/shipments/${shipmentId}`, { + method: 'DELETE', + }); +} + +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(); + } +} +``` + +- 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. + +## Why teams usually simplify infrastructure in this move + +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. + +With the Workflow SDK: + +- **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. + +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. + +## 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. diff --git a/skills/migrating-to-workflow-sdk/SKILL.md b/skills/migrating-to-workflow-sdk/SKILL.md new file mode 100644 index 0000000000..3f1f319ba2 --- /dev/null +++ b/skills/migrating-to-workflow-sdk/SKILL.md @@ -0,0 +1,174 @@ +--- +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. +metadata: + author: Vercel Inc. + version: '0.1.9' +--- + +# Migrating to the Workflow SDK + +Use this skill when converting an existing orchestration system to the Workflow SDK. + +## Intake + +1. Identify the source system: + - Temporal + - Inngest + - AWS Step Functions +2. Identify the target runtime: + - Managed hosting -> keep examples focused on `start()`, `getRun()`, hooks/webhooks, and route handlers. + - Self-hosted -> also read `references/runtime-targets.md` and explicitly say the workflow/step code can stay the same, but deployment still needs a `World` implementation and startup bootstrap. +3. Extract the source constructs: + - entrypoint + - waits / timers + - external callbacks / approvals + - retries / failure handling + - child workflows / fan-out + - progress streaming + - external side effects + +## Default migration rules + +- Put orchestration in `"use workflow"` functions. +- Put side effects, SDK calls, DB calls, HTTP calls, and stream I/O in `"use step"` functions. +- Use `sleep()` only in workflow context. +- For Signals, `step.waitForEvent()`, and `.waitForTaskToken`, choose exactly one resume surface: + - `resume/internal` -> `createHook()` + `resumeHook()` when the app resumes from server-side code with a deterministic business token. + - `resume/url/default` -> `createWebhook()` when the external system needs a generated callback URL and the default `202 Accepted` response is fine. + - `resume/url/manual` -> `createWebhook({ respondWith: 'manual' })` only when the prompt explicitly requires a custom response body, status, or headers. + - If a callback-URL prompt does not specify response semantics, default to `resume/url/default` and make the assumption explicit in `## Open Questions`. +- Never pair `createWebhook()` with `resumeHook()`, and never pass `token:` to `createWebhook()`. +- Wrap `start()` and `getRun()` inside `"use step"` functions for child runs. +- Use `getStepMetadata().stepId` as the idempotency key for external writes. +- Use `getWritable()` in workflow context to obtain the stream, but interact with it (write, close) only inside `"use step"` functions. +- Prefer rollback stacks for multi-step compensation. +- Choose app-boundary syntax in this order: + 1. If the prompt explicitly asks for framework-agnostic app-boundary code, use plain `Request` / `Response` even when a framework like Hono is named. + 2. Otherwise, if the target framework is named, shape app-boundary examples to that framework. + 3. Otherwise, keep examples framework-agnostic with `Request` / `Response`. Do not default to Next.js-only route signatures unless Next.js is explicitly named. + +> Fast memory aid: +> - Callback URL + default ack -> `createWebhook()` +> - Callback URL + custom ack -> `createWebhook({ respondWith: 'manual' })` +> - Deterministic server-side resume -> `createHook()` + `resumeHook()` + +## Fast-path router + +Load `references/resume-routing.md` when the source pauses for Signals, `step.waitForEvent()`, or `.waitForTaskToken`. + +Fast defaults: + +- callback URL only -> `resume/url/default` +- callback URL + explicit custom response -> `resume/url/manual` +- deterministic server-side resume -> `resume/internal` +- self-hosted -> add `runtime/self-hosted` +- named framework -> add `boundary/named-framework` +- explicit framework-agnostic request -> add `boundary/framework-agnostic` + +Before drafting `## Migrated Code`, write the selected route keys in `## Migration Plan`. + +## Source references + +- Temporal -> `references/temporal.md` +- Inngest -> `references/inngest.md` +- AWS Step Functions -> `references/aws-step-functions.md` + +## Shared references + +- `references/shared-patterns.md` — reusable code templates for hooks, child workflows, idempotency, streaming, and rollback. +- `references/runtime-targets.md` — Managed vs custom `World` guidance. +- `references/resume-routing.md` — route-key selection, obligations, and exact `## Migration Plan` shape. + +## Required output shape + +Return the migration in this structure: + +```md +## Migration Plan +## Source -> Target Mapping +## Migrated Code +## App Boundary / Resume Endpoints +## Verification Checklist +## Open Questions +``` + +## Verification checklist + +Fail the draft if any of these are true: + +- [ ] `## Migration Plan` omits `Route keys` +- [ ] `## Migration Plan` omits `Why these route keys` +- [ ] `## Migration Plan` lists route keys that do not match the prompt +- [ ] `## Migration Plan` lists required code obligations that do not match the selected route keys +- [ ] Source-framework primitives remain in the migrated code +- [ ] Side effects remain in workflow context +- [ ] `sleep()` appears inside a step +- [ ] Stream interaction (`getWriter()`, `write()`, `close()`) appears inside a workflow function +- [ ] Child workflows call `start()` / `getRun()` directly from workflow context +- [ ] External writes omit idempotency keys +- [ ] Hooks/webhooks are missing where the source used signals, waitForEvent, or task tokens +- [ ] A callback-URL flow uses `createHook()` + `resumeHook()` instead of `createWebhook()` +- [ ] A `resume/url/default` or `resume/url/manual` migration invents a user-authored callback route or `resumeWebhook()` wrapper when `webhook.url` should be the only resume surface +- [ ] `createWebhook()` is given a custom `token` or paired with `resumeHook()` + +Validation note: + +- Reading webhook request data in workflow context is allowed. Only `request.respondWith()` is step-only. + +Additional fail conditions: + +- `resume/internal` output omits `resumeHook()` in app-boundary code +- `resume/internal` output omits a deterministic business token +- `resume/internal` output emits `createWebhook()` or `webhook.url` +- `resume/url/default` output does not pass `webhook.url` to the external system +- `resume/url/default` output emits `resumeHook()`, `respondWith: 'manual'`, or `RequestWithResponse` without a custom-response requirement in the prompt +- `resume/url/default` output invents a user-authored callback route or `resumeWebhook()` wrapper when `webhook.url` is the intended resume surface +- `resume/url/manual` output does not pass `webhook.url` to the external system +- `resume/url/manual` output omits `RequestWithResponse` or `await request.respondWith(...)` +- `resume/url/manual` output calls `request.respondWith(...)` outside a `"use step"` function +- `resume/url/manual` output invents a user-authored callback route or `resumeWebhook()` wrapper when `webhook.url` is the intended resume surface +- `createWebhook()` is paired with `resumeHook()` +- self-hosted output omits `World extends Storage, Queue, Streamer`, `startWorkflowWorld()`, or the explicit note that the workflow and step code can stay the same while the app still needs a custom `World` +- named-framework output mixes framework syntax with plain `Request` / `Response` app-boundary code without a framework-agnostic override + +For concrete passing code, load: + +- `references/shared-patterns.md` -> `## Generated callback URL (default response)` +- `references/shared-patterns.md` -> `## Generated callback URL (manual response)` +- `references/runtime-targets.md` -> `## Self-hosted output block` +- `references/aws-step-functions.md` -> `## Combined recipe: callback URL on self-hosted Hono` + +## Sample prompt + +``` +Migrate this Inngest workflow to the Workflow SDK. +It uses step.waitForEvent() with a timeout and step.realtime.publish(). +``` + +Expected response shape: + +```md +## Migration Plan +## Source -> Target Mapping +## Migrated Code +## App Boundary / Resume Endpoints +## Verification Checklist +## Open Questions +``` + +## Example references + +Load a worked example only when the prompt needs concrete code: + +- `references/shared-patterns.md` -> `## Named-framework internal resume example (Hono)` +- `references/shared-patterns.md` -> `## Generated callback URL (default response)` +- `references/shared-patterns.md` -> `## Generated callback URL (manual response)` +- `references/runtime-targets.md` -> `## Self-hosted output block` +- `references/aws-step-functions.md` -> `## Combined recipe: callback URL on self-hosted Hono` + +Reject these counterexamples: + +- `resume/url/default` or `resume/url/manual` + user-authored callback route when `webhook.url` is the intended resume surface +- `createWebhook()` paired with `resumeHook()` +- named-framework app-boundary output mixed with plain `Request` / `Response` without a framework-agnostic override diff --git a/skills/migrating-to-workflow-sdk/evals/README.md b/skills/migrating-to-workflow-sdk/evals/README.md new file mode 100644 index 0000000000..872a03b47b --- /dev/null +++ b/skills/migrating-to-workflow-sdk/evals/README.md @@ -0,0 +1,66 @@ +# Migration skill acceptance + +A response passes only if it: + +1. removes all source-framework API symbols from final migrated code +2. uses `"use workflow"` for orchestration +3. uses `"use step"` for side effects +4. keeps `sleep()` in workflow context only +5. may call `getWritable()` in workflow or step context, but keeps all stream interaction (`getWriter()`, `write()`, `close()`) in step context only +6. uses step-wrapped `start()` / `getRun()` for child workflows +7. adds `getStepMetadata().stepId` for external idempotent writes +8. adds hooks/webhooks when the source used signals, wait-for-event, or task tokens +9. stays framework-agnostic when the target framework is unspecified +10. does not claim managed execution when the prompt says the target is self-hosted +11. chooses plain `createWebhook()` for generated callback-URL flows unless the prompt explicitly requires a custom HTTP response; when manual mode is chosen, `request.respondWith()` stays in step context + +## Sample input for manual check + +> Migrate this Inngest workflow to the Workflow SDK. It publishes progress and waits for approval with a 7d timeout. + +Expected passing excerpt: + +```ts +import { createHook, getWritable, sleep } from 'workflow'; + +export async function refundWorkflow(refundId: string) { + 'use workflow'; + + const writable = getWritable<{ stage: string }>(); + await emitStatus(writable, { stage: 'requested' }); + + using approval = createHook<{ approved: boolean }>({ + token: `refund:${refundId}:approval`, + }); + + const result = await Promise.race([ + approval.then((payload) => ({ kind: 'approval' as const, payload })), + sleep('7d').then(() => ({ kind: 'timeout' as const })), + ]); + + return result.kind === 'timeout' + ? { refundId, status: 'timed-out' as const } + : { + refundId, + status: result.payload.approved + ? ('approved' as const) + : ('rejected' as const), + }; +} + +async function emitStatus( + writable: WritableStream<{ stage: string }>, + chunk: { stage: string }, +): Promise { + 'use step'; + + const writer = writable.getWriter(); + try { + await writer.write(chunk); + } finally { + writer.releaseLock(); + } +} +``` + +**Expected outcome:** This should pass. `getWritable()` appearing in workflow context should not be treated as a failure. Only direct stream interaction in workflow context should fail. diff --git a/skills/migrating-to-workflow-sdk/evals/aws-task-token-parallel.md b/skills/migrating-to-workflow-sdk/evals/aws-task-token-parallel.md new file mode 100644 index 0000000000..4ee40a97f9 --- /dev/null +++ b/skills/migrating-to-workflow-sdk/evals/aws-task-token-parallel.md @@ -0,0 +1,72 @@ +# aws-task-token-parallel + +## Prompt + +Migrate the following Step Functions workflow to the Workflow SDK. + +```json +{ + "StartAt": "WaitForApproval", + "States": { + "WaitForApproval": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Next": "ParallelWork" + }, + "ParallelWork": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "ReserveInventory", + "States": { + "ReserveInventory": { + "Type": "Task", + "End": true + } + } + }, + { + "StartAt": "ChargePayment", + "States": { + "ChargePayment": { + "Type": "Task", + "End": true + } + } + } + ], + "Next": "WaitOneDay" + }, + "WaitOneDay": { + "Type": "Wait", + "Seconds": 86400, + "End": true + } + } +} +``` + +## Must include + +- `"use workflow"` +- `createHook()` or `createWebhook()` +- `Promise.all` +- `sleep('1d')` +- `"use step"` task functions + +## Must not include + +- ASL JSON in the final migrated code +- Lambda handler stubs +- task token plumbing +- `States.` error strings unless they are comments about the source + +## Expected excerpt + +```ts +await Promise.all([ + reserveInventory(orderId), + chargePayment(orderId), +]); +await sleep('1d'); +``` diff --git a/skills/migrating-to-workflow-sdk/evals/callback-url-default-response.md b/skills/migrating-to-workflow-sdk/evals/callback-url-default-response.md new file mode 100644 index 0000000000..b142cd260e --- /dev/null +++ b/skills/migrating-to-workflow-sdk/evals/callback-url-default-response.md @@ -0,0 +1,42 @@ +# callback-url-default-response + +## Prompt + +Migrate the following Step Functions workflow to the Workflow SDK. + +```json +{ + "StartAt": "WaitForVerification", + "States": { + "WaitForVerification": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "End": true + } + } +} +``` + +The external vendor needs a callback URL. A default HTTP 202 response is fine. Do not add a custom callback route. + +## Must include + +- `"use workflow"` +- `createWebhook()` +- `webhook.url` + +## Must not include + +- `resumeHook` +- `respondWith: 'manual'` +- `RequestWithResponse` +- invented callback route + +## Expected excerpt + +```ts +using verification = createWebhook(); +await sendVerificationRequest(documentId, verification.url); +const request = await verification; +const payload = await request.json(); +``` diff --git a/skills/migrating-to-workflow-sdk/evals/inngest-timeout-streaming.md b/skills/migrating-to-workflow-sdk/evals/inngest-timeout-streaming.md new file mode 100644 index 0000000000..116c39db36 --- /dev/null +++ b/skills/migrating-to-workflow-sdk/evals/inngest-timeout-streaming.md @@ -0,0 +1,53 @@ +# inngest-timeout-streaming + +## Prompt + +Migrate the following Inngest workflow to the Workflow SDK. + +```ts +import { inngest } from '../client'; + +export const refundWorkflow = inngest.createFunction( + { id: 'refund-workflow' }, + { event: 'refund/requested' }, + async ({ event, step }) => { + await step.realtime.publish('status', { stage: 'requested' }); + + const approval = await step.waitForEvent('wait-for-approval', { + event: 'refund/approved', + match: 'data.refundId', + timeout: '7d', + }); + + if (!approval) { + return { refundId: event.data.refundId, status: 'timed-out' as const }; + } + + return { refundId: event.data.refundId, status: 'approved' as const }; + } +); +``` + +## Must include + +- `"use workflow"` +- `Promise.race` +- `sleep('7d')` +- `createHook()` +- `getWritable()` + +## Must not include + +- `inngest.createFunction` +- `step.waitForEvent` +- `step.realtime.publish` +- `serve()` + +## Expected excerpt + +```ts +const result = await Promise.race([ + approval.then((payload) => ({ kind: 'approval' as const, payload })), + sleep('7d').then(() => ({ kind: 'timeout' as const })), +]); +``` diff --git a/skills/migrating-to-workflow-sdk/evals/non-vercel-runtime-branch.md b/skills/migrating-to-workflow-sdk/evals/non-vercel-runtime-branch.md new file mode 100644 index 0000000000..0b7c44460c --- /dev/null +++ b/skills/migrating-to-workflow-sdk/evals/non-vercel-runtime-branch.md @@ -0,0 +1,45 @@ +# non-vercel-runtime-branch + +## Prompt + +We are migrating a Temporal workflow to the Workflow SDK, but the app runs on Hono with self-hosted Postgres. Keep the migration examples framework-agnostic and do not assume managed execution. + +## Must include + +- migrated workflow / step structure +- a note that the workflow / step code can stay the same +- `World extends Storage, Queue, Streamer` +- `await getWorld().start?.()` +- a startup helper like: + +```ts +import { getWorld } from 'workflow/runtime'; + +export async function startWorkflowWorld(): Promise { + await getWorld().start?.(); +} +``` + +- app-boundary `start()` guidance without Next.js-only route syntax + +## Must not include + +- claims that no infrastructure work is needed +- Next.js-only handler signatures +- claims that managed execution is automatic in this target environment + +## Expected excerpt + +```ts +interface World extends Storage, Queue, Streamer { + start?(): Promise; +} +``` + +```ts +import { getWorld } from 'workflow/runtime'; + +export async function startWorkflowWorld(): Promise { + await getWorld().start?.(); +} +``` diff --git a/skills/migrating-to-workflow-sdk/evals/temporal-signal-child-run.md b/skills/migrating-to-workflow-sdk/evals/temporal-signal-child-run.md new file mode 100644 index 0000000000..f1c155684a --- /dev/null +++ b/skills/migrating-to-workflow-sdk/evals/temporal-signal-child-run.md @@ -0,0 +1,58 @@ +# temporal-signal-child-run + +## Prompt + +Migrate the following Temporal workflow to the Workflow SDK. Keep the business behavior the same. + +```ts +import * as wf from '@temporalio/workflow'; +import type * as activities from '../activities/order'; + +const { loadOrder, chargeCard } = wf.proxyActivities({ + startToCloseTimeout: '5 minute', +}); + +export const approveOrder = wf.defineSignal<[boolean]>('approveOrder'); + +export async function orderWorkflow(orderId: string) { + let approved: boolean | undefined; + + wf.setHandler(approveOrder, (value) => { + approved = value; + }); + + const order = await loadOrder(orderId); + + await wf.condition(() => approved !== undefined); + + if (!approved) { + return { orderId, status: 'rejected' as const }; + } + + const result = await chargeCard(order.id); + return { orderId, chargeId: result.id, status: 'completed' as const }; +} +``` + +## Must include + +- `"use workflow"` +- `"use step"` +- `createHook()` +- `resumeHook()` +- `getStepMetadata().stepId` + +## Must not include + +- `proxyActivities` +- `defineSignal` +- `wf.condition` +- Worker or Task Queue code + +## Expected excerpt + +```ts +using approval = createHook<{ approved: boolean }>({ + token: `order:${orderId}:approval`, +}); +``` diff --git a/skills/migrating-to-workflow-sdk/references/aws-step-functions.md b/skills/migrating-to-workflow-sdk/references/aws-step-functions.md new file mode 100644 index 0000000000..804fc0f16a --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/aws-step-functions.md @@ -0,0 +1,246 @@ +# AWS Step Functions -> Workflow SDK + +## Map these constructs + +| AWS Step Functions | Workflow SDK | +| --- | --- | +| ASL JSON state machine | `"use workflow"` function | +| Task / Lambda | `"use step"` | +| Choice | `if` / `else` / `switch` | +| Wait | `sleep()` | +| Parallel | `Promise.all()` | +| Map | loop or `Promise.all()` | +| Retry / Catch | step retries + `try` / `catch`, `RetryableError`, `FatalError`, `maxRetries` | +| `.waitForTaskToken` | `createHook()` or `createWebhook()` | +| `StartExecution` (child state machine) | step-wrapped `start()` / `getRun()` | + +## Remove + +- ASL JSON state machine definitions from final migrated code +- Separate Lambda function stubs that only served as Task state handlers +- Task-token plumbing (`SendTaskSuccess`, `SendTaskFailure`, SQS queue setup) after converting to hooks/webhooks +- IAM roles and CloudFormation/CDK resources for orchestrator-to-Lambda wiring +- `"Next"` / `"End"` transition logic — replaced by `await` and `return` + +## Add + +- Resume surface for `.waitForTaskToken`: + - 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)` +- Explicit `Promise.all()` for parallel work (replaces Parallel state) +- Loops or `Promise.all()` over arrays for Map state equivalents +- Rollback stack when the original graph used compensation chains (Catch → compensation states) +- Idempotency keys on external writes via `getStepMetadata().stepId` +- `getWritable()` for progress streaming (Step Functions has no built-in equivalent) +- Step-wrapped `start()` / `getRun()` for child workflows (replaces `StartExecution`) + +## `.waitForTaskToken` fast paths + +### Deterministic server-side resume + +Use this when your app receives the approval in server-side code and can reconstruct a business token. + +```ts +import { createHook } from 'workflow'; +import { resumeHook } from 'workflow/api'; + +export async function approvalWorkflow(orderId: string) { + 'use workflow'; + using approval = createHook<{ approved: boolean }>({ + token: `order:${orderId}:approval`, + }); + const { approved } = await approval; + return { + orderId, + status: approved ? ('approved' as const) : ('rejected' as const), + }; +} + +export async function POST(request: Request) { + const body = (await request.json()) as { + orderId: string; + approved: boolean; + }; + await resumeHook(`order:${body.orderId}:approval`, { + approved: body.approved, + }); + return Response.json({ ok: true }); +} +``` + +### Generated callback URL (default `202 Accepted`) + +Use this when the external system needs a callback URL and the default `202 Accepted` response is fine. + +```ts +import { createWebhook } from 'workflow'; + +type ApprovalPayload = { approved: boolean }; + +export async function approvalWorkflow(orderId: string) { + 'use workflow'; + using approval = createWebhook(); + await sendApprovalRequest(orderId, approval.url); + const request = await approval; + const body = (await request.json()) as ApprovalPayload; + return { + orderId, + status: body.approved ? ('approved' as const) : ('rejected' as const), + }; +} + +async function sendApprovalRequest( + orderId: string, + callbackUrl: string, +): Promise { + 'use step'; + await fetch(process.env.APPROVAL_API_URL!, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId, callbackUrl }), + }); +} +``` + +### Generated callback URL (manual response) + +Use this when the external system needs a callback URL and the migrated flow must send a custom HTTP response. + +```ts +import { createWebhook, type RequestWithResponse } from 'workflow'; + +type ApprovalPayload = { approved: boolean }; + +export async function approvalWorkflow(orderId: string) { + 'use workflow'; + using approval = createWebhook({ respondWith: 'manual' }); + await sendApprovalRequest(orderId, approval.url); + const request = await approval; + const body = (await request.json()) as ApprovalPayload; + await acknowledgeApproval(request, body.approved); + return { + orderId, + status: body.approved ? ('approved' as const) : ('rejected' as const), + }; +} + +async function sendApprovalRequest( + orderId: string, + callbackUrl: string, +): Promise { + 'use step'; + await fetch(process.env.APPROVAL_API_URL!, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId, callbackUrl }), + }); +} + +async function acknowledgeApproval( + request: RequestWithResponse, + approved: boolean, +): Promise { + 'use step'; + await request.respondWith( + Response.json({ ok: true, approved }), + ); +} +``` + +Choose exactly one of these paths. Do not combine them in the same migration. + +## Combined recipe: callback URL on self-hosted Hono + +Use this when all of the following are true: + +- the Step Functions source used `.waitForTaskToken` +- the external system needs a callback URL +- the target is self-hosted +- the prompt names Hono +- the prompt does not require a custom callback response + +Workflow code: + +```ts +import { createWebhook } from 'workflow'; + +type ApprovalPayload = { approved: boolean }; + +export async function refundWorkflow(refundId: string) { + 'use workflow'; + using approval = createWebhook(); + await sendApprovalRequest(refundId, approval.url); + const request = await approval; + const payload = (await request.json()) as ApprovalPayload; + return { + refundId, + status: payload.approved ? ('approved' as const) : ('rejected' as const), + }; +} + +async function sendApprovalRequest( + refundId: string, + callbackUrl: string, +): Promise { + 'use step'; + await fetch(process.env.APPROVAL_API_URL!, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refundId, callbackUrl }), + }); +} +``` + +Self-hosted runtime requirements: + +```ts +interface World extends Storage, Queue, Streamer { + start?(): Promise; +} +``` + +```ts +import { getWorld } from 'workflow/runtime'; + +export async function startWorkflowWorld(): Promise { + await getWorld().start?.(); +} +``` + +Hono app boundary: + +```ts +import { Hono } from 'hono'; +import { start } from 'workflow/api'; +import { refundWorkflow } from '../workflows/refund'; + +const app = new Hono(); + +app.post('/api/refunds/start', async (c) => { + const body = (await c.req.json()) as { refundId: string }; + const run = await start(refundWorkflow, [body.refundId]); + return c.json({ runId: run.runId }); +}); + +export default app; +``` + +Required explanation: The workflow and step code can stay the same. Because this target is self-hosted, the app still needs a `World` implementation for storage, queueing, and streaming, plus a startup path that calls `await getWorld().start?.()` when the selected world runs background workers. + +Must not appear: + +```ts +resumeHook(...) +respondWith: 'manual' +RequestWithResponse +``` + +Sample prompt and expected shape: + +- Input: `Migrate this Step Functions flow to the Workflow SDK for Hono on self-hosted Postgres. The vendor needs a callback URL. Default 202 is fine.` +- Expected route keys: `resume/url/default`, `runtime/self-hosted`, `boundary/named-framework` diff --git a/skills/migrating-to-workflow-sdk/references/inngest.md b/skills/migrating-to-workflow-sdk/references/inngest.md new file mode 100644 index 0000000000..65a969fc0a --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/inngest.md @@ -0,0 +1,39 @@ +# Inngest -> Workflow SDK + +## Map these constructs + +| Inngest | Workflow SDK | +| --- | --- | +| `inngest.createFunction()` | `"use workflow"` + `start()` | +| `step.run()` | `"use step"` | +| `step.sleep()` / `step.sleepUntil()` | `sleep()` | +| `step.waitForEvent()` | `createHook()` or `createWebhook()` | +| timeout on `step.waitForEvent()` | `Promise.race()` + `sleep()` | +| `step.invoke()` | step-wrapped `start()` / `getRun()` | +| `inngest.send()` / event triggers | app-boundary `start()` | +| `step.sendEvent()` | step-wrapped `start()` fan-out | +| `step.realtime.publish()` | `getWritable()` | + +## Remove + +- Inngest client setup (`new Inngest(...)`) +- `serve()` handler and function registration +- Event-schema dispatch layer and event type definitions used only for routing +- Inline `step.run()` closures after extracting them into named `"use step"` functions +- `step.waitForEvent()` match expressions — hook tokens replace event matching + +## Add + +- `Promise.race()` with `sleep()` when the source used timeout-based waits on `step.waitForEvent()` +- Resume surface for `step.waitForEvent()` 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 `step.realtime.publish()`) +- Idempotency keys on external writes via `getStepMetadata().stepId` +- Rollback stack for compensation-heavy flows (replaces per-step try/catch compensation) +- Step-wrapped `start()` / `getRun()` for child workflows (replaces `step.invoke()`) diff --git a/skills/migrating-to-workflow-sdk/references/resume-routing.md b/skills/migrating-to-workflow-sdk/references/resume-routing.md new file mode 100644 index 0000000000..099f0cd182 --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/resume-routing.md @@ -0,0 +1,86 @@ +# Resume routing + +Load this file when the source pauses for Signals, `step.waitForEvent()`, or `.waitForTaskToken`. + +## Quick route recipes + +| Situation | Route keys | Must emit | Must not emit | +| --- | --- | --- | --- | +| App resumes the workflow from server-side code with a deterministic business token | `resume/internal` | `createHook()`, `resumeHook()`, deterministic `token` | `createWebhook()`, `webhook.url` | +| External vendor needs a generated callback URL and the default `202 Accepted` response is fine | `resume/url/default` | `createWebhook()`, `webhook.url` | `resumeHook()`, `respondWith: 'manual'`, `RequestWithResponse`, invented callback route | +| External vendor needs a generated callback URL and the prompt requires a custom response body, status, or headers | `resume/url/manual` | `createWebhook({ respondWith: 'manual' })`, `webhook.url`, `RequestWithResponse`, step-level `request.respondWith()` | `resumeHook()`, `token:` on `createWebhook()` | +| Target is self-hosted | `runtime/self-hosted` | `World extends Storage, Queue, Streamer`, `startWorkflowWorld()` | claims of managed execution | +| Prompt explicitly names Hono, Express, Fastify, NestJS, or Next.js | `boundary/named-framework` | user-authored app-boundary code in that framework | plain `Request` / `Response` app-boundary code | +| Prompt explicitly asks for framework-agnostic output | `boundary/framework-agnostic` | plain `Request` / `Response` app-boundary code | framework-specific route syntax | + +## Selection rules + +1. If the source pauses for Signals, `step.waitForEvent()`, or `.waitForTaskToken`, pick exactly one resume key. +2. If the target is self-hosted, also pick `runtime/self-hosted`. +3. Pick exactly one boundary key when the prompt explicitly requests framework-agnostic output or names a framework. +4. If the prompt under-specifies response semantics for a callback-URL flow, default to `resume/url/default` and make the assumption explicit in `## Open Questions`. +5. Only choose `resume/url/manual` when the prompt explicitly requires a custom response body, status, headers, or manual-response handling. +6. If the prompt later states that the app resumes from server-side code with a stable business token, override any callback-URL default to `resume/internal`. + +## Route-key obligations + +### `resume/internal` + +- Workflow code must use `createHook()`. +- App boundary must call `resumeHook()`. +- Use a deterministic business token. +- Do not emit `createWebhook()` or `webhook.url`. + +### `resume/url/default` + +- Workflow code must use `createWebhook()`. +- External request setup must pass `webhook.url`. +- In `## App Boundary / Resume Endpoints`, treat the generated `webhook.url` as the resume surface. +- Do not emit `resumeHook(...)`. +- Do not pass `token:` to `createWebhook()`. +- Do not invent a user-authored callback route or `resumeWebhook()` wrapper unless the prompt explicitly asks for one. + +### `resume/url/manual` + +- Workflow code must use `createWebhook({ respondWith: 'manual' })`. +- External request setup must pass `webhook.url`. +- Use `RequestWithResponse`. +- `request.respondWith()` must stay inside a `"use step"` function. +- Do not emit `resumeHook(...)`. +- Do not pass `token:` to `createWebhook()`. +- Do not invent a user-authored callback route or `resumeWebhook()` wrapper unless the prompt explicitly asks for one. + +### `runtime/self-hosted` + +- Include `interface World extends Storage, Queue, Streamer { start?(): Promise; }`. +- Include `startWorkflowWorld(): Promise`. +- Include the explicit note that workflow/step code can stay the same while deployment still needs a custom `World`. +- Do not claim managed execution. + +### `boundary/framework-agnostic` + +- Use plain `Request` / `Response`. + +### `boundary/named-framework` + +- Use the named framework's syntax for every user-authored app-boundary snippet. +- Do not mix named-framework app-boundary code with plain `Request` / `Response` unless the prompt explicitly asks for framework-agnostic output. + +## Exact planning shape + +```md +## Migration Plan +- Source: [Temporal | Inngest | AWS Step Functions] +- Route keys: [comma-separated keys] +- Why these route keys: + - [route key]: [reason from the prompt] +- Required code obligations: + - [obligation 1] + - [obligation 2] +``` + +Sample input and expected output: + +- Input: _The vendor needs a callback URL. Default 202 is fine._ → Expected route keys: `resume/url/default` +- Input: _The vendor needs a callback URL and requires a 200 JSON acknowledgement body._ → Expected route keys: `resume/url/manual` +- Input: _Approval API resumes by orderId in Hono._ → Expected route keys: `resume/internal`, `boundary/named-framework` diff --git a/skills/migrating-to-workflow-sdk/references/runtime-targets.md b/skills/migrating-to-workflow-sdk/references/runtime-targets.md new file mode 100644 index 0000000000..5a0c8066ab --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/runtime-targets.md @@ -0,0 +1,109 @@ +# Runtime targets + +## Use managed execution when + +- The app is deploying to a managed platform. +- The user wants zero-config workflow storage, queueing, and streaming. + +Guidance: + +- Start workflows from the app boundary with `start()`. +- Retrieve runs later with `getRun()`. +- Do not invent custom `World` code unless the task requires it. + +## Use a custom World when + +- The app is self-hosted. +- The app is not deploying to a managed platform. +- The task explicitly asks for custom infrastructure. + +State this explicitly in the migration output: + +- The workflow and step code can stay the same. +- The app still needs a `World` implementation for storage, queueing, and streaming. +- The server startup path must call `await getWorld().start?.()` when the selected world requires background workers. + +Minimum interface to mention: + +```ts +interface World extends Storage, Queue, Streamer { + start?(): Promise; +} +``` + +Bootstrap example for self-hosted runtimes: + +```ts +import { getWorld } from 'workflow/runtime'; + +export async function startWorkflowWorld(): Promise { + await getWorld().start?.(); +} +``` + +## Required responsibilities + +- **Storage** for runs, steps, hooks, waits, and the event log. +- **Queueing** for workflow and step invocations. +- **Streaming** for readable/writable workflow streams. + +## Self-hosted output block + +When the target is self-hosted, include this explanation almost verbatim: + +> The workflow and step code can stay the same. Because this target is self-hosted, the app still needs a `World` implementation for storage, queueing, and streaming, plus a startup path that calls `await getWorld().start?.()` when the selected world runs background workers. + +Framework-agnostic app boundary: + +```ts +import { start } from 'workflow/api'; +import { onboardingWorkflow } from '../workflows/onboarding'; + +export async function POST(request: Request) { + const body = (await request.json()) as { userId: string }; + const run = await start(onboardingWorkflow, [body.userId]); + return Response.json({ runId: run.runId }); +} +``` + +Named-framework app boundary example (Hono): + +```ts +import { Hono } from 'hono'; +import { start } from 'workflow/api'; +import { onboardingWorkflow } from '../workflows/onboarding'; + +const app = new Hono(); + +app.post('/api/onboarding/start', async (c) => { + const body = (await c.req.json()) as { userId: string }; + const run = await start(onboardingWorkflow, [body.userId]); + return c.json({ runId: run.runId }); +}); + +export default app; +``` + +Startup bootstrap: + +```ts +import { getWorld } from 'workflow/runtime'; + +export async function startWorkflowWorld(): Promise { + await getWorld().start?.(); +} +``` + +**Sample input:** `We are migrating a Temporal workflow to the Workflow SDK, but the app runs on Hono with self-hosted Postgres. Keep the migration examples framework-agnostic and do not assume managed execution.` + +**Expected output:** The migration explicitly says the workflow/step code can stay the same, includes `World extends Storage, Queue, Streamer`, shows `startWorkflowWorld(): Promise`, and keeps the route example on plain `Request` / `Response` because the prompt explicitly asks for framework-agnostic app-boundary code. + +## Framework rule + +Apply these in order: + +1. If the prompt explicitly asks for framework-agnostic app-boundary examples, use plain `Request` / `Response` even when a framework like Hono is named. +2. Otherwise, if the target framework is named, shape every user-authored app-boundary snippet to that framework. +3. Otherwise, keep examples framework-agnostic with `Request` / `Response`. Do not default to Next.js-only route signatures unless Next.js is explicitly named. + +For `createWebhook()` migrations, the generated `webhook.url` is the callback surface. Do not invent a separate framework callback route unless the prompt explicitly asks for one. diff --git a/skills/migrating-to-workflow-sdk/references/shared-patterns.md b/skills/migrating-to-workflow-sdk/references/shared-patterns.md new file mode 100644 index 0000000000..de2a72738d --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/shared-patterns.md @@ -0,0 +1,382 @@ +# Shared migration patterns + +## Core rules + +- Orchestration -> `"use workflow"` +- Side effects, SDK access, DB access, stream I/O -> `"use step"` +- `sleep()` only in workflow context +- `getWritable()` obtainable in workflow, but interact with stream only in step context +- External side effects -> `getStepMetadata().stepId` as idempotency key +- Child workflows -> step-wrapped `start()` / `getRun()` + +## Choosing hook vs webhook + +Choose exactly one resume surface for Signals, `step.waitForEvent()`, and `.waitForTaskToken`. + +Use `createHook()` + `resumeHook()` when: +- the app resumes the workflow from server-side code +- the resume point can be addressed with a deterministic business token such as `order:${orderId}:approval` + +Use `createWebhook()` when: +- the external system needs a generated callback URL +- the migration needs raw `Request` handling inside the workflow +- the intended resume surface is the generated `webhook.url` +- the default `202 Accepted` response is fine + +Use `createWebhook({ respondWith: 'manual' })` when: +- the external system still needs a generated callback URL +- the prompt explicitly requires a custom response body, status, or headers +- the migration needs `RequestWithResponse` +- `request.respondWith()` will run inside a `"use step"` function + +Default to plain `createWebhook()` when the prompt only says "callback URL" and does not require a custom response. + +Do not: +- pair `createWebhook()` with `resumeHook()` +- pass `token:` to `createWebhook()` +- invent a custom callback route when `webhook.url` is the intended resume surface + +## Named-framework internal resume example (Hono) + +Use this when the migration selected `resume/internal` and the prompt names Hono. + +```ts +import { Hono } from 'hono'; +import { resumeHook } from 'workflow/api'; + +const app = new Hono(); + +app.post('/api/orders/:orderId/approve', async (c) => { + const orderId = c.req.param('orderId'); + const body = (await c.req.json()) as { approved: boolean }; + + await resumeHook(`order:${orderId}:approval`, { + approved: body.approved, + }); + + return c.json({ ok: true }); +}); + +export default app; +``` + +**Sample input** + +```md +Migrate an Inngest approval workflow to the Workflow SDK for Hono. +The app resumes approvals from server-side code with a deterministic token. +``` + +**Expected output** + +- Uses `createHook()` in workflow code +- Uses Hono syntax for the `resumeHook()` endpoint +- Does not use `createWebhook()` +- Does not emit a plain `Request` / `Response` approval handler + +## Deterministic server-side resume + +```ts +import { createHook } from 'workflow'; +import { resumeHook } from 'workflow/api'; + +export async function approvalWorkflow(orderId: string) { + 'use workflow'; + + using approval = createHook<{ approved: boolean }>({ + token: `order:${orderId}:approval`, + }); + + return await approval; +} + +export async function POST(request: Request) { + const body = (await request.json()) as { + orderId: string; + approved: boolean; + }; + + await resumeHook(`order:${body.orderId}:approval`, { + approved: body.approved, + }); + + return Response.json({ ok: true }); +} +``` + +## Generated callback URL (default response) + +Use this when the external system needs a callback URL and the default `202 Accepted` response is fine. + +```ts +import { createWebhook } from 'workflow'; + +type VerificationCallback = { + approved: boolean; + reviewer?: string; +}; + +export async function verificationWorkflow(documentId: string) { + 'use workflow'; + + using webhook = createWebhook(); + + await submitForVerification(documentId, webhook.url); + const request = await webhook; + const payload = (await request.json()) as VerificationCallback; + + return payload.approved + ? { status: 'verified' as const, reviewer: payload.reviewer } + : { status: 'rejected' as const, reviewer: payload.reviewer }; +} + +async function submitForVerification( + documentId: string, + callbackUrl: string, +): Promise { + 'use step'; + + await fetch(process.env.VENDOR_VERIFY_URL!, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documentId, callbackUrl }), + }); +} +``` + +## Generated callback URL (manual response) + +Use this when the external system needs a callback URL and the migration must send a custom HTTP response. + +```ts +import { createWebhook, type RequestWithResponse } from 'workflow'; + +type VerificationCallback = { + approved: boolean; + reviewer?: string; +}; + +export async function verificationWorkflow(documentId: string) { + 'use workflow'; + + using webhook = createWebhook({ respondWith: 'manual' }); + + await submitForVerification(documentId, webhook.url); + const request = await webhook; + const payload = (await request.json()) as VerificationCallback; + + await acknowledgeVerification(request, payload.approved); + + return payload.approved + ? { status: 'verified' as const, reviewer: payload.reviewer } + : { status: 'rejected' as const, reviewer: payload.reviewer }; +} + +async function submitForVerification( + documentId: string, + callbackUrl: string, +): Promise { + 'use step'; + + await fetch(process.env.VENDOR_VERIFY_URL!, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documentId, callbackUrl }), + }); +} + +async function acknowledgeVerification( + request: RequestWithResponse, + approved: boolean, +): Promise { + 'use step'; + + await request.respondWith( + Response.json({ ok: true, approved }), + ); +} +``` + +Rules: + +- Prefer the default-response version when the prompt only asks for a callback URL. +- Only use manual-response mode when the prompt requires a custom response body, status, or headers. +- Reading webhook request data may happen in workflow or step context. `request.respondWith()` is the step-only operation. + +Expected behavior: + +- Sample input: `Vendor needs a callback URL.` → Expected pattern: `createWebhook()` with no `RequestWithResponse`. +- Sample input: `Vendor needs a callback URL and a custom JSON ack body.` → Expected pattern: `createWebhook({ respondWith: 'manual' })` plus step-level `request.respondWith()`. + +## Hook with timeout + +```ts +import { createHook, sleep } from 'workflow'; + +type Approval = { approved: boolean }; + +export async function approvalWorkflow(id: string) { + 'use workflow'; + + using approval = createHook({ + token: `approval:${id}`, + }); + + const result = await Promise.race([ + approval.then((payload) => ({ kind: 'approval' as const, payload })), + sleep('7d').then(() => ({ kind: 'timeout' as const })), + ]); + + if (result.kind === 'timeout') { + return { id, status: 'timed_out' as const }; + } + + return { + id, + status: result.payload.approved ? 'approved' : 'rejected', + }; +} +``` + +Resume endpoint (framework-agnostic): + +```ts +import { resumeHook } from 'workflow/api'; + +export async function POST(request: Request) { + const body = (await request.json()) as { + id: string; + approved: boolean; + }; + + await resumeHook(`approval:${body.id}`, { + approved: body.approved, + }); + + return Response.json({ ok: true }); +} +``` + +## Child workflow via step-wrapped start/getRun + +```ts +import { getRun, start } from 'workflow/api'; + +export async function childWorkflow(input: string) { + 'use workflow'; + return await doWork(input); +} + +async function doWork(input: string) { + 'use step'; + return { input, status: 'done' as const }; +} + +async function spawnChild(input: string): Promise { + 'use step'; + const run = await start(childWorkflow, [input]); + return run.runId; +} + +async function collectChild(runId: string) { + 'use step'; + const run = getRun(runId); + return (await run.returnValue) as { input: string; status: 'done' }; +} +``` + +## Idempotent external write + +```ts +import { getStepMetadata } from 'workflow'; + +async function writeOrder(orderId: string) { + 'use step'; + + const { stepId } = getStepMetadata(); + + await fetch(process.env.ORDER_API_URL!, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': stepId, + }, + body: JSON.stringify({ orderId }), + }); +} +``` + +## Streaming boundary: obtain in workflow, interact in step + +Use: + +```ts +import { getWritable } from 'workflow'; + +export async function refundWorkflow(refundId: string) { + 'use workflow'; + + const writable = getWritable<{ stage: string }>(); + await emitStatus(writable, { stage: 'requested' }); + await emitStatus(writable, { stage: 'completed' }); + return { refundId, status: 'done' as const }; +} + +async function emitStatus( + writable: WritableStream<{ stage: string }>, + chunk: { stage: string }, +): Promise { + 'use step'; + + const writer = writable.getWriter(); + try { + await writer.write(chunk); + } finally { + writer.releaseLock(); + } +} +``` + +Avoid: + +```ts +import { getWritable } from 'workflow'; + +export async function badWorkflow() { + 'use workflow'; + + const writable = getWritable<{ stage: string }>(); + const writer = writable.getWriter(); // ❌ stream interaction in workflow context + await writer.write({ stage: 'requested' }); +} +``` + +**Sample input:** `Migrate an Inngest workflow that publishes progress and waits for approval.` + +**Expected output:** The migration may obtain `getWritable()` in workflow context, but every `getWriter()`, `write()`, and `close()` call remains inside a `"use step"` function. + +## Rollback stack + +```ts +export async function orderSaga(orderId: string) { + 'use workflow'; + + const rollbacks: Array<() => Promise> = []; + + try { + const reservation = await reserveInventory(orderId); + rollbacks.push(() => releaseInventory(reservation.id)); + + const charge = await chargePayment(orderId); + rollbacks.push(() => refundPayment(charge.id)); + + return { orderId, status: 'completed' as const }; + } catch (error) { + // Compensate in reverse order + while (rollbacks.length > 0) { + await rollbacks.pop()!(); + } + throw error; + } +} +``` diff --git a/skills/migrating-to-workflow-sdk/references/temporal.md b/skills/migrating-to-workflow-sdk/references/temporal.md new file mode 100644 index 0000000000..0eb65cfaca --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/temporal.md @@ -0,0 +1,38 @@ +# Temporal -> Workflow SDK + +## Map these constructs + +| Temporal | Workflow SDK | +| --- | --- | +| Workflow Definition / Execution | `"use workflow"` + `start()` | +| Activity | `"use step"` | +| Worker + Task Queue | remove from app code | +| Signal | `createHook()` or `createWebhook()` | +| Query / Update | `getRun()` + app API, or hook-driven mutation | +| Child Workflow | step-wrapped `start()` / `getRun()` | +| Activity retry policy (`startToCloseTimeout`, `maximumAttempts`) | `maxRetries`, `RetryableError`, `FatalError` | +| Event history | run timeline / event log | + +## Remove + +- `proxyActivities` and Activity type imports +- Worker setup and polling loop +- Task Queue configuration and plumbing +- Signal handler boilerplate (`defineSignal`, `setHandler`, `condition`) after converting to hooks +- Separate Activity modules when side effects move into colocated `"use step"` functions + +## Add + +- Resume surface when the source used Signals or approval-style pauses: + - 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)` +- `Promise.race()` with `sleep()` when the source Signal had a timeout or deadline +- Idempotency keys on external writes via `getStepMetadata().stepId` +- Rollback stack for compensation-heavy flows (replaces nested try/catch around each Activity) +- `getWritable()` for progress streaming (replaces custom progress Activities) +- Step-wrapped `start()` / `getRun()` for child workflows — return serializable `runId` values to the workflow