From e4af12acf1e6204993fa35daeb06aa802fe01081 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 6 Nov 2025 23:59:55 -0800 Subject: [PATCH 1/4] Add 'Pass-by-Value Semantics' section to serialization docs Signed-off-by: Nathan Rajlich --- .../docs/foundations/serialization.mdx | 100 ++++++++++++++++++ .../docs/foundations/workflows-and-steps.mdx | 4 + 2 files changed, 104 insertions(+) diff --git a/docs/content/docs/foundations/serialization.mdx b/docs/content/docs/foundations/serialization.mdx index f9423edd6..4c5ae7d41 100644 --- a/docs/content/docs/foundations/serialization.mdx +++ b/docs/content/docs/foundations/serialization.mdx @@ -70,6 +70,106 @@ For example, one step function may produce a `ReadableStream` while a different step consumes the stream. The workflow function does not interact directly with the stream but is able to pass it on to the next step: +## Pass-by-Value Semantics + +An important consequence of serialization is that **all parameters are passed by value, not by reference**. When you pass an object or array to a step function, the step receives a deserialized copy of that data. Any mutations to the arguments inside the step function will **not** be reflected in the workflow's context. + +### Common Misuse + +```typescript +// ❌ INCORRECT - mutations to arguments won't persist +export async function updateUserWorkflow(userId: string) { + "use workflow"; + + const user = { id: userId, name: "John", email: "john@example.com" }; + + // This passes a copy of the user object to the step + await updateUserStep(user); + + // The user object in the workflow is UNCHANGED - user.email is still "john@example.com" + console.log(user.email); // Still "john@example.com", not updated! +} + +async function updateUserStep(user: { id: string; name: string; email: string }) { + "use step"; + + // This modifies the LOCAL COPY, not the original in the workflow + user.email = "newemail@example.com"; // [!code highlight] +} +``` + +```typescript +// ❌ INCORRECT - pushing to an array argument won't persist +export async function collectItemsWorkflow() { + "use workflow"; + + const items: string[] = ["apple"]; + + // This passes a copy of the items array to the step + await addItemStep(items); + + // The items array in the workflow is UNCHANGED + console.log(items); // Still ["apple"], not ["apple", "banana"]! +} + +async function addItemStep(items: string[]) { + "use step"; + + // This modifies the LOCAL COPY of the array + items.push("banana"); // [!code highlight] +} +``` + +### Correct Pattern - Return Values + +To persist changes, **return the modified data from the step function** and reassign it in the workflow: + +```typescript +// ✅ CORRECT - return the modified data +export async function updateUserWorkflow(userId: string) { + "use workflow"; + + let user = { id: userId, name: "John", email: "john@example.com" }; + + // Capture the return value + user = await updateUserStep(user); // [!code highlight] + + // Now the user object reflects the changes + console.log(user.email); // "newemail@example.com" ✓ +} + +async function updateUserStep(user: { id: string; name: string; email: string }) { + "use step"; + + user.email = "newemail@example.com"; + return user; // [!code highlight] +} +``` + +```typescript +// ✅ CORRECT - return the modified array +export async function collectItemsWorkflow() { + "use workflow"; + + let items: string[] = ["apple"]; + + // Capture the return value + items = await addItemStep(items); // [!code highlight] + + // Now the items array reflects the changes + console.log(items); // ["apple", "banana"] ✓ +} + +async function addItemStep(items: string[]) { + "use step"; + + items.push("banana"); + return items; // [!code highlight] +} +``` + +Remember: **Only return values are persisted to the event log and visible in the workflow context.** Mutations to parameters are lost because each step receives a fresh deserialized copy of the data. + ```typescript lineNumbers async function generateStream() { "use step"; diff --git a/docs/content/docs/foundations/workflows-and-steps.mdx b/docs/content/docs/foundations/workflows-and-steps.mdx index f1d1fd278..c0b3352e1 100644 --- a/docs/content/docs/foundations/workflows-and-steps.mdx +++ b/docs/content/docs/foundations/workflows-and-steps.mdx @@ -79,6 +79,10 @@ async function chargePayment(order: Order) { By default, steps have a maximum of 3 retry attempts before they fail and propagate the error over to the workflow. Learn more about errors and retrying in the [Errors & Retrying](/docs/foundations/errors-and-retries) page. + +**Important:** Due to serialization, parameters are passed by **value, not by reference**. If you pass an object or array to a step and mutate it, those changes will **not** be visible in the workflow context. Always return modified data from your step functions instead. See [Pass-by-Value Semantics](/docs/foundations/serialization#pass-by-value-semantics) for details and examples. + + Step functions are primarily meant to be used inside a workflow. From 64bbbee7a84c318c87720e3eacc3d6d4e90df2d1 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 7 Nov 2025 00:01:03 -0800 Subject: [PATCH 2/4] . Signed-off-by: Nathan Rajlich --- .../docs/foundations/serialization.mdx | 166 +++++++++--------- 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/docs/content/docs/foundations/serialization.mdx b/docs/content/docs/foundations/serialization.mdx index 4c5ae7d41..a555d2aee 100644 --- a/docs/content/docs/foundations/serialization.mdx +++ b/docs/content/docs/foundations/serialization.mdx @@ -70,6 +70,89 @@ For example, one step function may produce a `ReadableStream` while a different step consumes the stream. The workflow function does not interact directly with the stream but is able to pass it on to the next step: +```typescript lineNumbers +async function generateStream() { + "use step"; + + return new ReadableStream({ + async start(controller) { + controller.enqueue(1); + controller.enqueue(2); + controller.enqueue(3); + controller.close(); + } + }); +} + +async function consumeStream(readable: ReadableStream) { + "use step"; + + const values: number[] = []; + for await (const value of readable) { + values.push(value); + } + + console.log(values); + // Logs: [1, 2, 3] +} + +export async function passingStreamWorkflow() { + "use workflow"; + + // ✅ Stream is received as a return value and passed // [!code highlight] + // into a step, but NOT directly used in the workflow // [!code highlight] + const readable = await generateStream(); // [!code highlight] + await consumeStream(readable); // [!code highlight] +} +``` + +**What NOT to do:** Do not attempt to read from or write to a stream directly within +the workflow function context, as this will not work as expected and an error will be thrown at runtime: + +```typescript +export async function incorrectStreamUsage() { + "use workflow"; + + const readable = await generateStream(); + + // ❌ This will fail - cannot read stream in workflow context // [!code highlight] + const reader = readable.getReader(); // [!code highlight] +} +``` + +Always delegate stream operations to step functions. + + +## Request & Response + +The Web API `Request` and `Response` APIs are supported by the serialization system, +and can be passed around between workflow and step functions similarly to other data types. + +As a convenience, these two APIs are treated slightly differently when used +within a workflow function: calling the `text()` / `json()` / `arrayBuffer()` instance +methods is automatically treated as a step function invocation. This allows you to consume +the body directly in the workflow context while maintaining proper serialization and caching. + +For example, consider how receiving a webhook request provides the entire `Request` +instance into the workflow context. You may consume the body of that request directly +in the workflow, which will be cached as a step result for future resumptions of the workflow: + +```typescript lineNumbers +import { createWebhook } from 'workflow'; + +export async function handleWebhookWorkflow() { + "use workflow"; + + const webhook = createWebhook(); + const request = await webhook; + + // The body of the request will only be consumed once // [!code highlight] + const body = await request.json(); // [!code highlight] + + // … +} +``` + ## Pass-by-Value Semantics An important consequence of serialization is that **all parameters are passed by value, not by reference**. When you pass an object or array to a step function, the step receives a deserialized copy of that data. Any mutations to the arguments inside the step function will **not** be reflected in the workflow's context. @@ -169,86 +252,3 @@ async function addItemStep(items: string[]) { ``` Remember: **Only return values are persisted to the event log and visible in the workflow context.** Mutations to parameters are lost because each step receives a fresh deserialized copy of the data. - -```typescript lineNumbers -async function generateStream() { - "use step"; - - return new ReadableStream({ - async start(controller) { - controller.enqueue(1); - controller.enqueue(2); - controller.enqueue(3); - controller.close(); - } - }); -} - -async function consumeStream(readable: ReadableStream) { - "use step"; - - const values: number[] = []; - for await (const value of readable) { - values.push(value); - } - - console.log(values); - // Logs: [1, 2, 3] -} - -export async function passingStreamWorkflow() { - "use workflow"; - - // ✅ Stream is received as a return value and passed // [!code highlight] - // into a step, but NOT directly used in the workflow // [!code highlight] - const readable = await generateStream(); // [!code highlight] - await consumeStream(readable); // [!code highlight] -} -``` - -**What NOT to do:** Do not attempt to read from or write to a stream directly within -the workflow function context, as this will not work as expected and an error will be thrown at runtime: - -```typescript -export async function incorrectStreamUsage() { - "use workflow"; - - const readable = await generateStream(); - - // ❌ This will fail - cannot read stream in workflow context // [!code highlight] - const reader = readable.getReader(); // [!code highlight] -} -``` - -Always delegate stream operations to step functions. - - -## Request & Response - -The Web API `Request` and `Response` APIs are supported by the serialization system, -and can be passed around between workflow and step functions similarly to other data types. - -As a convenience, these two APIs are treated slightly differently when used -within a workflow function: calling the `text()` / `json()` / `arrayBuffer()` instance -methods is automatically treated as a step function invocation. This allows you to consume -the body directly in the workflow context while maintaining proper serialization and caching. - -For example, consider how receiving a webhook request provides the entire `Request` -instance into the workflow context. You may consume the body of that request directly -in the workflow, which will be cached as a step result for future resumptions of the workflow: - -```typescript lineNumbers -import { createWebhook } from 'workflow'; - -export async function handleWebhookWorkflow() { - "use workflow"; - - const webhook = createWebhook(); - const request = await webhook; - - // The body of the request will only be consumed once // [!code highlight] - const body = await request.json(); // [!code highlight] - - // … -} -``` From 91032115272fe94652d2f58b4f000f6ac074a5c9 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 7 Nov 2025 00:18:43 -0800 Subject: [PATCH 3/4] Add changeset --- .changeset/tough-wasps-notice.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/tough-wasps-notice.md diff --git a/.changeset/tough-wasps-notice.md b/.changeset/tough-wasps-notice.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/tough-wasps-notice.md @@ -0,0 +1,2 @@ +--- +--- From 9ce8b339885659ada0008c8349462b39fac63179 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 12 Nov 2025 00:23:14 -0800 Subject: [PATCH 4/4] . --- .../docs/foundations/serialization.mdx | 82 +++---------------- 1 file changed, 12 insertions(+), 70 deletions(-) diff --git a/docs/content/docs/foundations/serialization.mdx b/docs/content/docs/foundations/serialization.mdx index a555d2aee..6a7d6f56b 100644 --- a/docs/content/docs/foundations/serialization.mdx +++ b/docs/content/docs/foundations/serialization.mdx @@ -155,100 +155,42 @@ export async function handleWebhookWorkflow() { ## Pass-by-Value Semantics -An important consequence of serialization is that **all parameters are passed by value, not by reference**. When you pass an object or array to a step function, the step receives a deserialized copy of that data. Any mutations to the arguments inside the step function will **not** be reflected in the workflow's context. +**Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow. -### Common Misuse +**Incorrect:** ```typescript -// ❌ INCORRECT - mutations to arguments won't persist export async function updateUserWorkflow(userId: string) { "use workflow"; - const user = { id: userId, name: "John", email: "john@example.com" }; - - // This passes a copy of the user object to the step + let user = { id: userId, name: "John", email: "john@example.com" }; await updateUserStep(user); - - // The user object in the workflow is UNCHANGED - user.email is still "john@example.com" - console.log(user.email); // Still "john@example.com", not updated! + + // user.email is still "john@example.com" + console.log(user.email); } async function updateUserStep(user: { id: string; name: string; email: string }) { "use step"; - - // This modifies the LOCAL COPY, not the original in the workflow - user.email = "newemail@example.com"; // [!code highlight] + user.email = "newemail@example.com"; // Changes are lost } ``` -```typescript -// ❌ INCORRECT - pushing to an array argument won't persist -export async function collectItemsWorkflow() { - "use workflow"; - - const items: string[] = ["apple"]; - - // This passes a copy of the items array to the step - await addItemStep(items); - - // The items array in the workflow is UNCHANGED - console.log(items); // Still ["apple"], not ["apple", "banana"]! -} - -async function addItemStep(items: string[]) { - "use step"; - - // This modifies the LOCAL COPY of the array - items.push("banana"); // [!code highlight] -} -``` - -### Correct Pattern - Return Values - -To persist changes, **return the modified data from the step function** and reassign it in the workflow: +**Correct - return the modified data:** ```typescript -// ✅ CORRECT - return the modified data export async function updateUserWorkflow(userId: string) { "use workflow"; let user = { id: userId, name: "John", email: "john@example.com" }; - - // Capture the return value - user = await updateUserStep(user); // [!code highlight] - - // Now the user object reflects the changes - console.log(user.email); // "newemail@example.com" ✓ + user = await updateUserStep(user); // Reassign the return value + + console.log(user.email); // "newemail@example.com" } async function updateUserStep(user: { id: string; name: string; email: string }) { "use step"; - user.email = "newemail@example.com"; - return user; // [!code highlight] -} -``` - -```typescript -// ✅ CORRECT - return the modified array -export async function collectItemsWorkflow() { - "use workflow"; - - let items: string[] = ["apple"]; - - // Capture the return value - items = await addItemStep(items); // [!code highlight] - - // Now the items array reflects the changes - console.log(items); // ["apple", "banana"] ✓ -} - -async function addItemStep(items: string[]) { - "use step"; - - items.push("banana"); - return items; // [!code highlight] + return user; } ``` - -Remember: **Only return values are persisted to the event log and visible in the workflow context.** Mutations to parameters are lost because each step receives a fresh deserialized copy of the data.