517 lines
16 KiB
Markdown
517 lines
16 KiB
Markdown
# Creating Workflows
|
|
|
|
Workflows are the standard way to perform mutations (create, update, delete) in modules in Medusa. If you have built a custom module and need to perform mutations on models in the module, you should create a workflow.
|
|
|
|
## Creating Workflows - Implementation Checklist
|
|
|
|
**IMPORTANT FOR CLAUDE CODE**: When implementing workflows, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps and provides visibility to the user.
|
|
|
|
Create these tasks in your todo list:
|
|
|
|
- Define the input type for your workflow
|
|
- Create step function (one mutation per step)
|
|
- Add compensation function to steps for rollback
|
|
- Create workflow composition function
|
|
- Follow workflow composition rules (no async, no arrow functions, etc.)
|
|
- Return WorkflowResponse with results
|
|
- Test idempotency (workflow can be retried safely)
|
|
- **CRITICAL: Run build to validate implementation** (catches type errors and issues)
|
|
|
|
## Basic Workflow Structure
|
|
|
|
**File Organization:**
|
|
- **Recommended**: Create workflow steps in `src/workflows/steps/[step-name].ts`
|
|
- Workflow composition functions go in `src/workflows/[workflow-name].ts`
|
|
- This keeps steps reusable and organized
|
|
|
|
```typescript
|
|
// src/workflows/steps/create-my-model.ts
|
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
|
|
|
type Input = {
|
|
my_key: string
|
|
}
|
|
|
|
// Note: a step should only do one mutation this ensures rollback mechanisms work
|
|
// For workflows that retry build your steps to be idempotent
|
|
export const createMyModelStep = createStep(
|
|
"create-my-model",
|
|
async (input: Input, { container }) => {
|
|
const myModule = container.resolve("my")
|
|
|
|
const [newMy] = await myModule.createMyModels({
|
|
...input,
|
|
})
|
|
|
|
return new StepResponse(
|
|
newMy,
|
|
newMy.id // explicit compensation input - otherwise defaults to step's output
|
|
)
|
|
},
|
|
// Optional compensation function
|
|
async (id, { container }) => {
|
|
const myModule = container.resolve("my")
|
|
await myModule.deleteMyModels(id)
|
|
}
|
|
)
|
|
|
|
// src/workflows/create-my-model.ts
|
|
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
|
import { createMyModelStep } from "./steps/create-my-model"
|
|
|
|
type Input = {
|
|
my_key: string
|
|
}
|
|
|
|
const createMyModel = createWorkflow(
|
|
"create-my-model",
|
|
// Note: See "Workflow Composition Rules" section below for important constraints
|
|
// The workflow function must be a regular synchronous function (not async/arrow)
|
|
// No direct variable manipulation, conditionals, or date creation - use transform/when instead
|
|
function (input: Input) {
|
|
const newMy = createMyModelStep(input)
|
|
|
|
return new WorkflowResponse({
|
|
newMy,
|
|
})
|
|
}
|
|
)
|
|
|
|
export default createMyModel
|
|
```
|
|
|
|
## Workflow Composition Rules
|
|
|
|
The workflow composition function runs at application load time and has important limitations:
|
|
|
|
### Function Declaration
|
|
- ✅ Use regular synchronous functions
|
|
- ❌ No `async` functions
|
|
- ❌ No arrow functions (use `function` keyword)
|
|
|
|
### Using Steps Multiple Times
|
|
|
|
**⚠️ CRITICAL**: When using the same step multiple times in a workflow, you MUST rename each invocation AFTER the first invocation using `.config()` to avoid conflicts.
|
|
|
|
```typescript
|
|
// ✅ CORRECT - Rename each step invocation with .config()
|
|
export const processCustomersWorkflow = createWorkflow(
|
|
"process-customers",
|
|
function (input) {
|
|
const customers = transform({ ids: input.customer_ids }, (input) => input.ids)
|
|
|
|
// First invocation - no need to rename
|
|
const customer1 = fetchCustomerStep(customers[0])
|
|
|
|
// Second invocation - different name
|
|
const customer2 = fetchCustomerStep(customers[1]).config({
|
|
name: "fetch-customer-2"
|
|
})
|
|
|
|
const result = transform({ customer1, customer2 }, (data) => ({
|
|
customers: [data.customer1, data.customer2]
|
|
}))
|
|
|
|
return new WorkflowResponse(result)
|
|
}
|
|
)
|
|
|
|
// ❌ WRONG - Calling the same step multiple times without renaming
|
|
export const processCustomersWorkflow = createWorkflow(
|
|
"process-customers",
|
|
function (input) {
|
|
const customers = transform({ ids: input.customer_ids }, (input) => input.ids)
|
|
|
|
// This will cause runtime errors - duplicate step names
|
|
const customer1 = fetchCustomerStep(customers[0])
|
|
const customer2 = fetchCustomerStep(customers[1]) // ❌ Conflict!
|
|
|
|
return new WorkflowResponse({ customers: [customer1, customer2] })
|
|
}
|
|
)
|
|
```
|
|
|
|
**Why this matters:**
|
|
- Medusa uses step names to track execution state
|
|
- Duplicate names cause conflicts in the workflow execution engine
|
|
- Each step invocation needs a unique identifier
|
|
- The workflow will fail at runtime if steps aren't renamed
|
|
|
|
### Variable Operations
|
|
- ❌ No direct variable manipulation or concatenation → Use `transform({ in }, ({ in }) => \`Transformed: ${in}\`)` instead
|
|
- Variables lack values until execution time - all operations must use `transform()`
|
|
|
|
### Date/Time Operations
|
|
- ❌ No `new Date()` (will be fixed to load time) → Wrap in `transform()` for execution-time evaluation
|
|
|
|
### Conditional Logic
|
|
- ❌ No `if`/`else` statements → Use `when(input, (input) => input.is_active).then(() => { /* steps */ })` instead
|
|
- ❌ No ternary operators (`? :`) → Use `transform()` instead
|
|
- ❌ No nullish coalescing (`??`) → Use `transform()` instead
|
|
- ❌ No logical OR (`||`) → Use `transform()` instead
|
|
- ❌ No optional chaining (`?.`) → Use `transform()` instead
|
|
- ❌ No double negation (`!!`) → Use `transform()` instead
|
|
|
|
### Object Operations
|
|
- ❌ No object spreading (`...`) for destructuring or spreading properties → Use `transform()` to create new objects with desired properties
|
|
|
|
```typescript
|
|
// ❌ WRONG - Object spreading in workflow
|
|
const myWorkflow = createWorkflow(
|
|
"process-data",
|
|
function (input: WorkflowInput) {
|
|
const updatedData = {
|
|
...input.data,
|
|
newField: "value"
|
|
} // Won't work - spread operator not allowed
|
|
|
|
step1(updatedData)
|
|
})
|
|
|
|
// ✅ CORRECT - Use transform to create new objects
|
|
import { transform } from "@medusajs/framework/workflows-sdk"
|
|
|
|
const myWorkflow = createWorkflow(
|
|
"process-data",
|
|
function (input: WorkflowInput) {
|
|
const updatedData = transform(
|
|
{ input },
|
|
(data) => ({
|
|
...data.input.data,
|
|
newField: "value"
|
|
})
|
|
)
|
|
|
|
step1(updatedData)
|
|
})
|
|
```
|
|
|
|
### Loops
|
|
- ❌ No `for`/`while` loops → Use alternatives below based on your use case
|
|
|
|
Workflow composition functions run at application load time to define the workflow structure, not to execute logic. Loops cannot be used directly in the composition function. Instead, use these patterns:
|
|
|
|
**Alternative 1: Loop in Calling Code (Repeat entire workflow)**
|
|
|
|
When you need to execute a workflow multiple times (e.g., once per item in an array), wrap the workflow execution in a loop in the code that calls the workflow:
|
|
|
|
```typescript
|
|
// ❌ WRONG - Loop inside workflow composition
|
|
const myWorkflow = createWorkflow(
|
|
"hello-world",
|
|
function (input: WorkflowInput) {
|
|
for (const item of input.items) {
|
|
step1(item) // Won't work - loop runs at load time, not execution time
|
|
}
|
|
})
|
|
|
|
// ✅ CORRECT - Loop in calling code
|
|
// API route that calls the workflow
|
|
import {
|
|
MedusaRequest,
|
|
MedusaResponse,
|
|
} from "@medusajs/framework/http"
|
|
import myWorkflow from "../../workflows/my-workflow"
|
|
|
|
export async function POST(
|
|
req: MedusaRequest,
|
|
res: MedusaResponse
|
|
) {
|
|
const { items } = req.body
|
|
|
|
// Execute the workflow once for each item
|
|
for (const item of items) {
|
|
await myWorkflow(req.scope)
|
|
.run({ item })
|
|
}
|
|
|
|
res.status(200).send({ success: true })
|
|
}
|
|
|
|
// Workflow definition - processes a single item
|
|
const myWorkflow = createWorkflow(
|
|
"hello-world",
|
|
function (input: WorkflowInput) {
|
|
step1(input.item)
|
|
})
|
|
```
|
|
|
|
**Alternative 2: Use `transform` for Array Operations (Prepare step inputs)**
|
|
|
|
When you need to iterate over an array to prepare inputs for a step, use `transform()` to map over the array:
|
|
|
|
```typescript
|
|
// ❌ WRONG - Loop to build array
|
|
const myWorkflow = createWorkflow(
|
|
"hello-world",
|
|
function (input: WorkflowInput) {
|
|
const stepInputs = []
|
|
for (const item of input.items) {
|
|
stepInputs.push({ id: item.id }) // Won't work - loop runs at load time
|
|
}
|
|
step1(stepInputs)
|
|
})
|
|
|
|
// ✅ CORRECT - Use transform to map array
|
|
import { transform } from "@medusajs/framework/workflows-sdk"
|
|
|
|
const myWorkflow = createWorkflow(
|
|
"hello-world",
|
|
function (input: WorkflowInput) {
|
|
const stepInputs = transform(
|
|
{
|
|
input,
|
|
},
|
|
(data) => {
|
|
// This function runs at execution time
|
|
return data.input.items.map((item) => ({ id: item.id }))
|
|
}
|
|
)
|
|
|
|
step1(stepInputs)
|
|
})
|
|
```
|
|
|
|
**Why this matters:**
|
|
- The workflow composition function runs once at application load time to define the structure
|
|
- Loops would execute at load time with no data, not at execution time with actual input
|
|
- Alternative 1 repeats the entire workflow (including rollback capability) for each item
|
|
- Alternative 2 processes arrays within a single workflow execution using `transform()`
|
|
|
|
### Error Handling
|
|
- ❌ No `try-catch` blocks → See error handling patterns in Medusa documentation
|
|
|
|
### Return Values
|
|
- ✅ Only return serializable values (primitives, plain objects)
|
|
- ❌ No non-serializable types (Maps, Sets, etc.)
|
|
- For buffers: Return as object property, then recreate with `Buffer.from()` when processing results
|
|
|
|
## Step Best Practices
|
|
|
|
1. **One mutation per step**: Ensures rollback mechanisms work correctly
|
|
2. **Idempotency**: Design steps to be safely retryable
|
|
3. **Explicit compensation input**: Specify what data the compensation function needs if different from step output
|
|
4. **Return StepResponse**: Always wrap your return value in `StepResponse`
|
|
|
|
## Reusing Built-in Medusa Steps
|
|
|
|
**⚠️ IMPORTANT**: Before creating custom steps, check if Medusa provides a built-in step for your use case. Reusing built-in steps is preferred over creating custom ones.
|
|
|
|
### Common Built-in Steps to Reuse
|
|
|
|
**Creating Links Between Modules:**
|
|
|
|
**⚠️ CRITICAL - Link Order (Direction):** When creating links, the order of modules in `createRemoteLinkStep` MUST match the order in `defineLink()`. Mismatched order causes runtime errors.
|
|
|
|
```typescript
|
|
// Link definition in src/links/review-product.ts
|
|
import { defineLink } from "@medusajs/framework/utils"
|
|
import ReviewModule from "../modules/review"
|
|
import ProductModule from "@medusajs/medusa/product"
|
|
|
|
// Order: review FIRST, then product
|
|
export default defineLink(
|
|
{
|
|
linkable: ReviewModule.linkable.review,
|
|
isList: true,
|
|
},
|
|
ProductModule.linkable.product
|
|
)
|
|
```
|
|
|
|
```typescript
|
|
// ✅ CORRECT - Order matches defineLink (review first, then product)
|
|
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
|
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
|
import { Modules } from "@medusajs/framework/utils"
|
|
import { REVIEW_MODULE } from "../modules/review"
|
|
|
|
export const createReviewWorkflow = createWorkflow(
|
|
"create-review",
|
|
function (input) {
|
|
const review = createReviewStep(input)
|
|
|
|
// Order MUST match defineLink: review first, then product
|
|
const linkData = transform({ review, input }, ({ review, input }) => [{
|
|
[REVIEW_MODULE]: {
|
|
review_id: review.id,
|
|
},
|
|
[Modules.PRODUCT]: {
|
|
product_id: input.product_id,
|
|
},
|
|
}])
|
|
|
|
createRemoteLinkStep(linkData)
|
|
|
|
return new WorkflowResponse({ review })
|
|
}
|
|
)
|
|
|
|
// ❌ WRONG - Order doesn't match defineLink (product first, then review)
|
|
const linkData = transform({ review, input }, ({ review, input }) => [{
|
|
[Modules.PRODUCT]: {
|
|
product_id: input.product_id,
|
|
},
|
|
[REVIEW_MODULE]: {
|
|
review_id: review.id,
|
|
},
|
|
}]) // Runtime error: link direction mismatch!
|
|
```
|
|
|
|
```typescript
|
|
// ❌ WRONG - Don't create custom link steps
|
|
const createReviewLinkStep = createStep(
|
|
"create-review-link",
|
|
async ({ reviewId, productId }, { container }) => {
|
|
const link = container.resolve("link")
|
|
await link.create({
|
|
product: { product_id: productId },
|
|
review: { review_id: reviewId },
|
|
})
|
|
// This duplicates functionality that createRemoteLinkStep provides
|
|
}
|
|
)
|
|
```
|
|
|
|
**Removing Links:**
|
|
|
|
```typescript
|
|
// ✅ CORRECT - Use Medusa's built-in dismissRemoteLinkStep
|
|
import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
|
|
|
export const deleteReviewWorkflow = createWorkflow(
|
|
"delete-review",
|
|
function (input) {
|
|
const linkData = transform({ input }, ({ input }) => [{
|
|
[Modules.PRODUCT]: { product_id: input.product_id },
|
|
review: { review_id: input.review_id },
|
|
}])
|
|
|
|
dismissRemoteLinkStep(linkData)
|
|
deleteReviewStep(input)
|
|
|
|
return new WorkflowResponse({ success: true })
|
|
}
|
|
)
|
|
```
|
|
|
|
**Querying Data in Workflows:**
|
|
|
|
```typescript
|
|
// ✅ CORRECT - Use Medusa's built-in useQueryGraphStep
|
|
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
|
|
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
|
|
|
export const getProductReviewsWorkflow = createWorkflow(
|
|
"get-product-reviews",
|
|
function (input) {
|
|
// Query product with reviews using built-in step
|
|
const { data: products } = useQueryGraphStep({
|
|
entity: "product",
|
|
fields: ["id", "title", "reviews.*"],
|
|
filters: {
|
|
id: input.product_id,
|
|
},
|
|
})
|
|
|
|
return new WorkflowResponse({ product: products[0] })
|
|
}
|
|
)
|
|
|
|
// ❌ WRONG - Don't create custom query steps
|
|
const queryProductStep = createStep(
|
|
"query-product",
|
|
async ({ productId }, { container }) => {
|
|
const query = container.resolve("query")
|
|
const { data } = await query.graph({
|
|
entity: "product",
|
|
fields: ["id", "title", "reviews.*"],
|
|
filters: { id: productId },
|
|
})
|
|
return new StepResponse(data[0])
|
|
}
|
|
)
|
|
// This duplicates functionality that useQueryGraphStep provides
|
|
```
|
|
|
|
**Why reuse built-in steps:**
|
|
- Already tested and optimized by Medusa
|
|
- Handles edge cases and error scenarios
|
|
- Maintains consistency with Medusa's internal workflows
|
|
- Includes proper compensation/rollback logic
|
|
- Less code to maintain
|
|
|
|
**Other common built-in steps to look for:**
|
|
- Event emission steps
|
|
- Notification steps
|
|
- Inventory management steps
|
|
- Payment processing steps
|
|
|
|
Check Medusa documentation or `@medusajs/medusa/core-flows` for available built-in steps before creating custom ones.
|
|
|
|
## Business Logic and Validation Placement
|
|
|
|
**CRITICAL**: All business logic and validation must be performed inside workflow steps, NOT in API routes.
|
|
|
|
### ✅ CORRECT - Validation in Workflow Step
|
|
|
|
```typescript
|
|
// src/workflows/steps/delete-review.ts
|
|
export const deleteReviewStep = createStep(
|
|
"delete-review",
|
|
async ({ reviewId, customerId }: Input, { container }) => {
|
|
const reviewModule = container.resolve("review")
|
|
|
|
// Validation happens inside the step
|
|
const review = await reviewModule.retrieveReview(reviewId)
|
|
|
|
if (review.customer_id !== customerId) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_ALLOWED,
|
|
"You can only delete your own reviews"
|
|
)
|
|
}
|
|
|
|
await reviewModule.deleteReviews(reviewId)
|
|
|
|
return new StepResponse({ id: reviewId }, reviewId)
|
|
},
|
|
async (reviewId, { container }) => {
|
|
// Compensation: restore the review if needed
|
|
}
|
|
)
|
|
```
|
|
|
|
### ❌ WRONG - Validation in API Route
|
|
|
|
```typescript
|
|
// src/api/store/reviews/[id]/route.ts
|
|
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
|
|
const { id } = req.params
|
|
const customerId = req.auth_context.actor_id
|
|
|
|
// ❌ WRONG: Don't validate business rules in the route
|
|
const reviewModule = req.scope.resolve("review")
|
|
const review = await reviewModule.retrieveReview(id)
|
|
|
|
if (review.customer_id !== customerId) {
|
|
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not your review")
|
|
}
|
|
|
|
// ❌ WRONG: Don't call workflows after manual validation
|
|
const { result } = await deleteReviewWorkflow(req.scope).run({
|
|
input: { reviewId: id }
|
|
})
|
|
}
|
|
```
|
|
|
|
**Why this matters:**
|
|
- Workflows are the single source of truth for business logic
|
|
- Validation in routes bypasses workflow rollback mechanisms
|
|
- Makes testing harder and logic harder to reuse
|
|
- Breaks the Module → Workflow → API Route architecture
|
|
|
|
## Advanced Features
|
|
|
|
Workflows have advanced options to define retries, async behavior, pausing for human confirmation, and much more. Ask MedusaDocs for more details if these are relevant to your use case.
|