Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/legal-parts-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/swc-plugin": patch
---

Add support for "use step" functions in class instance methods
56 changes: 56 additions & 0 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,62 @@ describe('e2e', () => {
}
);

test(
'instanceMethodStepWorkflow - instance methods with "use step" directive',
{ timeout: 60_000 },
async () => {
// This workflow tests instance methods marked with "use step".
// The Counter class has custom serialization so the `this` context
// (the Counter instance) can be serialized across the workflow/step boundary.
//
// instanceMethodStepWorkflow(5) should:
// 1. Create Counter(5)
// 2. counter.add(10) -> 5 + 10 = 15
// 3. counter.multiply(3) -> 5 * 3 = 15
// 4. counter.describe('test counter') -> { label: 'test counter', value: 5 }
// 5. Create Counter(100), call counter2.add(50) -> 100 + 50 = 150
const run = await triggerWorkflow('instanceMethodStepWorkflow', [5]);
const returnValue = await getWorkflowReturnValue(run.runId);

expect(returnValue).toEqual({
initialValue: 5,
added: 15, // 5 + 10
multiplied: 15, // 5 * 3
description: { label: 'test counter', value: 5 },
added2: 150, // 100 + 50
});

// Verify the run completed successfully
const { json: runData } = await cliInspectJson(
`runs ${run.runId} --withData`
);
expect(runData.status).toBe('completed');
expect(runData.output).toEqual({
initialValue: 5,
added: 15,
multiplied: 15,
description: { label: 'test counter', value: 5 },
added2: 150,
});

// Verify the steps were executed (should have 4 steps: add, multiply, describe, add)
const { json: steps } = await cliInspectJson(
`steps --runId ${run.runId}`
);
// Filter to only Counter instance method steps
const counterSteps = steps.filter(
(s: any) =>
s.stepName.includes('Counter#add') ||
s.stepName.includes('Counter#multiply') ||
s.stepName.includes('Counter#describe')
);
expect(counterSteps.length).toBe(4); // add, multiply, describe, add (from counter2)
expect(counterSteps.every((s: any) => s.status === 'completed')).toBe(
true
);
}
);

test(
'crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context',
{ timeout: 60_000 },
Expand Down
55 changes: 54 additions & 1 deletion packages/swc-plugin-workflow/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Examples:
- `step//src/jobs/order.ts//fetchData`
- `step//src/jobs/order.ts//processOrder/innerStep` (nested step)
- `step//src/jobs/order.ts//MyClass.staticMethod` (static method)
- `step//src/jobs/order.ts//MyClass#instanceMethod` (instance method)
- `class//src/models/Point.ts//Point` (serialization class)

---
Expand Down Expand Up @@ -171,6 +172,57 @@ function wrapper(multiplier) {
registerStepFunction("step//input.js//wrapper/_anonymousStep0", wrapper$_anonymousStep0);
```

### Instance Method Step

Instance methods can use `"use step"` if the class provides custom serialization methods. The `this` context is serialized when calling the step and deserialized before execution.

Input:
```javascript
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@vercel/workflow';

export class Counter {
static [WORKFLOW_SERIALIZE](instance) {
return { value: instance.value };
}
static [WORKFLOW_DESERIALIZE](data) {
return new Counter(data.value);
}
constructor(value) {
this.value = value;
}
async add(amount) {
'use step';
return this.value + amount;
}
}
```

Output:
```javascript
import { registerStepFunction } from "workflow/internal/private";
import { registerSerializationClass } from "workflow/internal/class-serialization";
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@vercel/workflow';
/**__internal_workflows{"steps":{"input.js":{"Counter#add":{"stepId":"step//input.js//Counter#add"}}},"classes":{"input.js":{"Counter":{"classId":"class//input.js//Counter"}}}}*/;
export class Counter {
static [WORKFLOW_SERIALIZE](instance) {
return { value: instance.value };
}
static [WORKFLOW_DESERIALIZE](data) {
return new Counter(data.value);
}
constructor(value) {
this.value = value;
}
async add(amount) {
return this.value + amount;
}
}
registerStepFunction("step//input.js//Counter#add", Counter.prototype["add"]);
registerSerializationClass("class//input.js//Counter", Counter);
```

Note: Instance methods use `#` in the step ID (e.g., `Counter#add`) and are registered via `ClassName.prototype["methodName"]`.

### Module-Level Directive

Input:
Expand Down Expand Up @@ -591,7 +643,7 @@ The plugin emits errors for invalid usage:
| Error | Description |
|-------|-------------|
| Non-async function | Functions with `"use step"` or `"use workflow"` must be async |
| Instance methods | Only static methods can have directives (not instance methods) |
| Instance methods with `"use workflow"` | Only static methods can have `"use workflow"` (not instance methods) |
| Misplaced directive | Directive must be at top of file or start of function body |
| Conflicting directives | Cannot have both `"use step"` and `"use workflow"` at module level |
| Invalid exports | Module-level directive files can only export async functions |
Expand All @@ -610,6 +662,7 @@ The plugin supports various function declaration styles:
- `const name = async function() { "use step"; }` - Function expression
- `{ async method() { "use step"; } }` - Object method
- `static async method() { "use step"; }` - Static class method
- `async method() { "use step"; }` - Instance class method (requires custom serialization)

---

Expand Down
Loading
Loading