From 2087c8d15549ebbceb3536c0f6210a8809cd8070 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Wed, 3 Dec 2025 15:47:41 +0200 Subject: [PATCH] docs: add best practices doc for third-party syncing (#14203) * docs: add best practices doc for third-party syncing * small fix --- .../best-practices/third-party-sync/page.mdx | 924 ++++++++ .../emit-event/page.mdx | 2 +- .../fundamentals/workflows/locks/page.mdx | 4 +- www/apps/book/generated/edit-dates.mjs | 3 +- www/apps/book/generated/sidebar.mjs | 96 +- www/apps/book/public/llms-full.txt | 2034 ++++++++++++++--- www/apps/book/sidebar.mjs | 16 + 7 files changed, 2772 insertions(+), 307 deletions(-) create mode 100644 www/apps/book/app/learn/best-practices/third-party-sync/page.mdx diff --git a/www/apps/book/app/learn/best-practices/third-party-sync/page.mdx b/www/apps/book/app/learn/best-practices/third-party-sync/page.mdx new file mode 100644 index 0000000000..64bc56ca76 --- /dev/null +++ b/www/apps/book/app/learn/best-practices/third-party-sync/page.mdx @@ -0,0 +1,924 @@ +export const metadata = { + title: `${pageNumber} Best Practices for Third-Party Syncing`, +} + +# {metadata.title} + +In this chapter, you'll learn about best practices for syncing data between Medusa and third-party systems. + +## Common Issues with Third-Party Syncing Implementation + +Syncing data between Medusa and external systems is a common use case for commerce applications. For example, if your commerce ecosystem includes an external CMS or inventory management system, you may need to sync product data between Medusa and these systems. + +However, how you implement third-party syncing can significantly impact performance and memory usage. Syncing large amounts of data without proper handling can lead to issues like: + +- Out-of-memory (OOM) errors. +- Slow syncs that block the event loop and degrade application performance. +- Application crashes in production. + +This chapter covers best practices to avoid these issues when syncing data between Medusa and third-party systems. These best practices are general programming patterns that aren't Medusa-specific, but they're essential for building robust third-party syncs. + +--- + +## How to Sync Data Between Systems + +Before diving into best practices, it's important to understand the general approach for syncing data between Medusa and third-party systems. Third-party syncing typically involves two main steps: + +1. Define the syncing logic in a [workflow](../../fundamentals/workflows/page.mdx). +2. Execute the workflow from either a [scheduled job](../../fundamentals/scheduled-jobs/page.mdx) or a [subscriber](../../fundamentals/events-and-subscribers/page.mdx). + +### Define Syncing Logic in a Workflow + +[Workflows](../../fundamentals/workflows/page.mdx) are special functions designed for long-running, asynchronous tasks. They provide features like [compensation](../../fundamentals/workflows/compensation-function/page.mdx), [retries](../../fundamentals/workflows/retry-failed-steps/page.mdx), and [async execution](../../fundamentals/workflows/long-running-workflow/page.mdx) that are essential for reliable data syncing. + +When defining your syncing logic, such as pushing product data to a third-party service or pulling inventory data into Medusa, you should define a workflow that encapsulates this logic. + +Medusa also exposes [built-in workflows](!resources!/medusa-workflows-reference) for common commerce operations, like creating or updating products, that you can leverage in your syncing logic. + +For example, you can use Medusa's built-in [batchProductsWorkflow](!resources!/references/medusa-workflows/batchProductsWorkflow) to create or update products in batches: + +```ts title="src/jobs/sync-products.ts" +import { MedusaContainer } from "@medusajs/framework/types" +import { batchProductsWorkflow } from "@medusajs/medusa/core-flows" + +export default async function syncProductsJob(container: MedusaContainer) { + // ... + await batchProductsWorkflow(container).run({ + input: { + create: productsToCreate, + update: productsToUpdate, + }, + }) +} +``` + +### Execute Workflows from Scheduled Jobs or Subscribers + +After defining your syncing logic in a workflow, or choosing a Medusa workflow to use, you need to execute it based on your syncing requirements: + +- [Scheduled Jobs](../../fundamentals/scheduled-jobs/page.mdx): Use scheduled jobs for periodic syncs, such as syncing products daily or inventory hourly. Scheduled jobs run at specified intervals and can trigger your workflow to perform the sync. +- [Subscribers](../../fundamentals/events-and-subscribers/page.mdx): Use subscribers for event-driven syncs, such as syncing data when a product is updated in Medusa or when an order is placed. Subscribers listen for specific events and can trigger your workflow in response. + +If you've set up [server and worker instances](../../production/worker-mode/page.mdx), the worker will handle the execution. So, the syncing execution won't block the main server process. + + + +[Cloud](!cloud!) creates server and worker instances for your project automatically, so you don't need to set this up manually. + + + +In the scheduled job or subscriber, you retrieve the data to be synced from the third-party service or from Medusa itself. Then, you execute the workflow, passing it the data to be synced. + +For example, the following scheduled job fetches products from a third-party service and syncs them to Medusa using a workflow: + +```ts title="src/jobs/sync-products.ts" +import { MedusaContainer } from "@medusajs/framework/types" +import { batchProductsWorkflow } from "@medusajs/medusa/core-flows" + +export default async function syncProductsJob(container: MedusaContainer) { + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, 50) + + for await (const batch of batchedProducts) { + const { + productsToCreate, + productsToUpdate, + } = await prepareProducts(batch) + + await batchProductsWorkflow(container).run({ + input: { + create: productsToCreate, + update: productsToUpdate, + }, + }) + } +} +``` + +You'll learn about best practices for implementing the data fetching, batching, and preparation logic in the next sections. + +--- + +## Syncing Best Practices + +The following sections cover best practices for implementing third-party syncing in a way that minimizes memory usage, maximizes performance, and ensures reliability. + +
+ +The following sections take snippets from this complete example of a high-performance, memory-efficient product synchronization job that incorporates all the best practices discussed: + +```ts title="src/jobs/sync-products.ts" +import { MedusaContainer } from "@medusajs/framework/types" +import { ContainerRegistrationKeys, MedusaError, Modules } from "@medusajs/framework/utils" +import { batchProductsWorkflow } from "@medusajs/medusa/core-flows" +import { Readable } from "stream" +import { parser } from "stream-json" +import { pick } from "stream-json/filters/Pick" +import { streamArray } from "stream-json/streamers/StreamArray" +import { chain } from "stream-chain" + +const API_FETCH_SIZE = 200 +const PROCESS_BATCH_SIZE = 50 +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 1000 +const FETCH_TIMEOUT_MS = 30000 + +async function fetchWithRetry( + url: string, + retries = MAX_RETRIES +): Promise { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + const response = await fetch(url, { + signal: controller.signal, + // Keep connection alive for efficiency with multiple requests + headers: { + "Connection": "keep-alive", + }, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } catch (error: any) { + lastError = error + const isRetryable = + error.code === "UND_ERR_SOCKET" || + error.code === "ECONNREFUSED" || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT" || + error.name === "AbortError" + + if (isRetryable && attempt < retries) { + // Exponential backoff: 1s → 2s → 4s + const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delay)) + } else { + break + } + } + } + + throw lastError +} + +/** + * Streams products from the external API using incremental JSON parsing + * Products are yielded one by one as they're parsed from the response stream + */ +async function* streamProductsFromApi() { + let offset = 0 + let hasMore = true + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + + const response = await fetchWithRetry(url) + + // Convert web ReadableStream to Node.js Readable + const nodeStream = Readable.fromWeb(response.body as any) + + // Create a streaming JSON parser pipeline that: + // 1. Parses JSON incrementally + // 2. Picks only the "products" array + // 3. Streams each array item individually + const pipeline = chain([ + nodeStream, + parser(), + pick({ filter: "products" }), + streamArray(), + ]) + + let productCount = 0 + + // Yield each product as it's parsed - memory stays constant + try { + for await (const { value } of pipeline) { + yield value + productCount++ + + // Yield to event loop periodically to prevent blocking + if (productCount % 100 === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + } + } catch (streamError: any) { + // Handle stream errors (socket closed mid-stream) + if (streamError.code === "UND_ERR_SOCKET" || streamError.code === "ECONNRESET") { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Stream interrupted after ${productCount} products: ${streamError.message}` + ) + } + throw streamError + } + + // If the products are less than expected, there are no more products + if (productCount < API_FETCH_SIZE) { + hasMore = false + } else { + offset += productCount + } + } +} + +/** + * Collects products into batches of the specified size + */ +async function* batchProducts( + products: AsyncGenerator, + batchSize: number +): AsyncGenerator { + let batch: any[] = [] + + for await (const product of products) { + batch.push(product) + + if (batch.length >= batchSize) { + yield batch + // Release reference for GC + batch = [] + } + } + + // Yield remaining products + if (batch.length > 0) { + yield batch + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + const workflowEngine = container.resolve(Modules.WORKFLOW_ENGINE) + + let totalCreated = 0 + let totalUpdated = 0 + let batchNumber = 0 + + // Stream products from API and process in batches + // Memory stays constant - we only hold PROCESS_BATCH_SIZE products at a time + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + batchNumber++ + + // Extract external IDs from this batch to look up in Medusa + const externalIds = batch.map((p) => p.id) + + // Query Medusa for products matching these external IDs + const { data: existingProducts } = await query.graph( + { + entity: "product", + fields: ["id", "updated_at", "external_id"], + filters: { + external_id: externalIds, + }, + } + ) + + // Build a map for quick lookup + const existingByExternalId = new Map( + existingProducts.map((p) => [p.external_id, { + id: p.id, + updatedAt: p.updated_at, + }]) + ) + + const productsToCreate: any[] = [] + const productsToUpdate: any[] = [] + + for (const externalProduct of batch) { + const existing = existingByExternalId.get(externalProduct.id) + + if (existing) { + // Product exists - prepare update + productsToUpdate.push({ + id: existing.id, + title: externalProduct.title, + description: externalProduct.description ?? undefined, + metadata: { + external_id: externalProduct.id, + last_synced: new Date().toISOString(), + }, + }) + } else { + // New product - prepare create + productsToCreate.push({ + title: externalProduct.title, + description: externalProduct.description ?? undefined, + handle: externalProduct.handle, + status: "draft", + metadata: { + external_id: externalProduct.id, + last_synced: new Date().toISOString(), + }, + options: [ + { + title: "Default", + values: ["Default"], + }, + ], + variants: externalProduct.variants.map((v) => ({ + title: v.title ?? "Default Variant", + sku: v.sku ?? undefined, + options: { + Default: "Default", + }, + prices: [ + { + amount: v.price, + currency_code: "usd", + }, + ], + })), + }) + } + } + + // Execute batch workflow for this batch + if (productsToCreate.length > 0 || productsToUpdate.length > 0) { + await batchProductsWorkflow(container).run({ + input: { + create: productsToCreate, + update: productsToUpdate, + }, + }) + + totalCreated += productsToCreate.length + totalUpdated += productsToUpdate.length + } + // Yield to event loop between batches + await new Promise((resolve) => setImmediate(resolve)) + } +} + +export const config = { + name: "sync-products", + schedule: "0 0 * * *", // Run at midnight every day +} +``` + +
+ +### Stream Data from External APIs + +When retrieving data from external APIs using `fetch`, the common approach is to fetch the data and load it entirely into memory using `response.json()`. For example: + +```ts +const response = await fetch("https://third-party-api.com/products") +// Load entire response into memory +const data = await response.json() +``` + +However, this can lead to high memory usage and performance issues, especially with large datasets. + +Instead, process data in streams or batches. This approach allows you to handle large datasets without loading everything into memory at once. You can use libraries like [stream-json](https://www.npmjs.com/package/stream-json) to parse JSON data incrementally as it's received. + +![Diagram showcasing object in memory when loading entire JSON response vs streaming JSON parsing](https://res.cloudinary.com/dza7lstvk/image/upload/v1764761087/Medusa%20Book/stream-vs-full_iz2fyv.jpg) + +First, install the `stream-json` library in your Medusa project: + +```bash npm2yarn +npm install stream-json @types/stream-json +``` + +Then, use it in your scheduled job or subscriber to stream and parse JSON data from the third-party service: + +export const streamDataHighlights = [ + ["19", "nodeStream", "Create a Node.js Readable stream from the response body"], + ["25", "pipeline", "Set up a streaming JSON parser pipeline"], + ["36", "for await", "Yield each product one at a time as it's parsed"], +] + +```ts title="src/jobs/sync-products.ts" highlights={streamDataHighlights} +import { Readable } from "stream" +import { parser } from "stream-json" +import { pick } from "stream-json/filters/Pick" +import { streamArray } from "stream-json/streamers/StreamArray" +import { chain } from "stream-chain" + +const API_FETCH_SIZE = 200 + +async function* streamProductsFromApi() { + let offset = 0 + let hasMore = true + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + // TODO: Add retry with exponential backoff + const response = await fetch(url) + + // Convert web ReadableStream to Node.js Readable + const nodeStream = Readable.fromWeb(response.body as any) + + // Create a streaming JSON parser pipeline that: + // 1. Parses JSON incrementally + // 2. Picks only the "products" array + // 3. Streams each array item individually + const pipeline = chain([ + nodeStream, + parser(), + pick({ filter: "products" }), + streamArray(), + ]) + + let productCount = 0 + + try { + // Yield each product one at a time + for await (const { value } of pipeline) { + yield value + productCount++ + + // TODO: Yield to event loop periodically to prevent blocking + } + } catch (streamError: any) { + // TODO: Handle stream errors + } + + // If the products are less than expected, there are no more products + if (productCount < API_FETCH_SIZE) { + hasMore = false + } else { + offset += productCount + } + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + const productStream = streamProductsFromApi() + // ... +} +``` + +In the above snippet, you set up a streaming JSON parser that processes the API response incrementally. Instead of loading the entire response into memory, the parser yields each product one at a time. + +This approach significantly reduces memory usage, as you only hold JSON tokens and the current product being parsed, rather than the entire dataset. + +#### Handle Stream Errors + +When working with streams, it's important to handle potential errors that may occur during streaming, such as network interruptions. Catch these errors and implement retry logic or error reporting as needed. + +For example: + +```ts title="src/jobs/sync-products.ts" +async function* streamProductsFromApi() { + // Initial setup... + while (hasMore) { + // Setup pipeline... + try { + for await (const { value } of pipeline) { + yield value + // ... + } + } catch (streamError: any) { + // Handle stream errors (socket closed mid-stream) + if ( + streamError.code === "UND_ERR_SOCKET" || + streamError.code === "ECONNRESET" + ) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Stream interrupted after ${productCount} products: ${streamError.message}` + ) + } + throw streamError + } + // Update pagination... + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + // sync products... +} +``` + +In the above snippet, you catch stream errors and check for specific error codes that indicate transient network issues. You can then decide how to handle these errors, such as retrying the fetch or logging the error. + +### Retrieve Only Necessary Fields + +A common performance pitfall when syncing data is retrieving more fields than necessary from third-party services or Medusa's [Query](../../fundamentals/module-links/query/page.mdx). This leads to increased data size, slower performance, and higher memory usage. + +When retrieving data from third-party services or with Medusa's Query, only request the necessary fields. Then, to efficiently group existing data for updates, use a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) for quick lookups. + +For example, don't retrieve all product fields like this: + +```ts +// DON'T +const { data: existingProducts } = await query.graph( + { + entity: "product", + fields: ["*"], + filters: { + external_id: externalIds, + }, + } +) +``` + +Instead, only request the fields you need: + +export const fieldsHighlights = [ + ["19", "fields", "Request only necessary fields from Medusa"], + ["27", "Map", "Build a map for quick lookups by external ID"], +] + +```ts title="src/jobs/sync-products.ts" highlights={fieldsHighlights} +export default async function syncProductsJob(container: MedusaContainer) { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + + // Initial setup... + + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + // Increment data... + + // Extract external IDs from this batch to look up in Medusa + const externalIds = batch.map((p) => p.id) + + // Query Medusa for products matching these external IDs + const { data: existingProducts } = await query.graph( + { + entity: "product", + fields: ["id", "updated_at", "external_id"], + filters: { + external_id: externalIds, + }, + } + ) + + // Build a map for quick lookup + const existingByExternalId = new Map( + existingProducts.map((p) => [p.external_id, { + id: p.id, + updatedAt: p.updated_at, + }]) + ) + + // Process batch and sync to Medusa... + } +} +``` + +In the above snippet, after retrieving a batch of products from the external API, you query Medusa's products to find existing products that match the external IDs. You only request the necessary fields for this operation. + +Then, you build a map `existingByExternalId` that enables efficient lookups when determining whether to create or update products. + +This approach minimizes the amount of data transferred and processed, leading to better performance and lower memory usage. + +### Use Async Generators + +[Async generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator) are a powerful feature in JavaScript that allow you to define asynchronous iterators. They're particularly useful for processing large datasets incrementally, as they enable you to yield data items one at a time without loading everything into memory. + +When retrieving large datasets from third-party services, use async generators to yield data items one at a time. This allows you to process data incrementally without loading everything into memory. + +![Diagram showcasing how batches are loaded into memories one at a time with async generators](https://res.cloudinary.com/dza7lstvk/image/upload/v1764761706/Medusa%20Book/async-gen-batches_ne09bs.jpg) + +For example: + +export const asyncGeneratorHighlights = [ + ["1", "streamProductsFromApi", "Async generator to stream products from API"], + ["9", "yield", "Yield one product at a time from the API"], + ["21", "batchProducts", "Async generator to batch products"], + ["31", "yield batch", "Yield batches of products for processing"], +] + +```ts title="src/jobs/sync-products.ts" highlights={asyncGeneratorHighlights} +async function* streamProductsFromApi() { + // Initial setup... + + while (hasMore) { + // Setup pipeline... + + try { + for await (const { value } of pipeline) { + yield value // Yield one product at a time + + // TODO: Yield to event loop periodically to prevent blocking... + } + } catch (streamError: any) { + // Handle stream errors... + } + + // Update pagination... + } +} + +async function* batchProducts( + products: AsyncGenerator, + batchSize: number +): AsyncGenerator { + let batch: any[] = [] + + for await (const product of products) { + batch.push(product) + + if (batch.length >= batchSize) { + yield batch + // Release reference for GC + batch = [] + } + } + + // Yield remaining products + if (batch.length > 0) { + yield batch + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + // Process batch and sync to Medusa... + } +} +``` + +In the above snippet, you define two async generators: + +1. `streamProductsFromApi`: Yields individual products from the third-party service one at a time. +2. `batchProducts`: Takes an async generator of products and yields them in batches of a specified size. + +Then, in your scheduled job, you consume these generators using [for await...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of) loops to process product batches incrementally. + +This approach keeps memory usage low, as you only hold the current product or batch in memory at any given time. + +#### Release References for Garbage Collection + +When using async generators, it's important to release references to processed data to allow for [garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Memory_management#garbage_collection). This keeps memory usage low, especially when processing large datasets. + +For example, the `batchProducts` generator demonstrates this: + +```ts title="src/jobs/sync-products.ts" highlights={[["13"]]} +async function* batchProducts( + products: AsyncGenerator, + batchSize: number +): AsyncGenerator { + let batch: any[] = [] + + for await (const product of products) { + batch.push(product) + + if (batch.length >= batchSize) { + yield batch + // Release reference for GC + batch = [] + } + } + + // Yield remaining products + if (batch.length > 0) { + yield batch + } +} +``` + +### Handle Backpressure + +Backpressure is a mechanism that manages the flow of data between producers (API fetches) and consumers (data processing workflows). Without proper backpressure handling, fast API fetches can overwhelm the processing workflow, leading to high memory usage and potential crashes. + +Handle backpressure by controlling the pace at which data is processed. One effective way to do this in JavaScript is using `for await...of` loops, which naturally provide backpressure by waiting for each iteration to complete before fetching the next item. + +![Diagram showcasing timeline from start to end of scheduled job execution where batches are fetched and synced one at a time](https://res.cloudinary.com/dza7lstvk/image/upload/v1764762095/Medusa%20Book/timeline-backpressure_rtwwzt.jpg) + +For example, you can implement backpressure handling in your scheduled job: + +```ts title="src/jobs/sync-products.ts" highlights={[["7"]]} +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + // Process batch and sync to Medusa... + // The next batch won't be fetched until processing of the current batch is complete + } +} +``` + +In the above snippet, the `for await...of` loop processes each batch of products. This ensures that the next batch isn't fetched until the current batch has been fully processed, effectively implementing backpressure. + +This approach keeps memory usage controlled and prevents the system from being overwhelmed by incoming data, ensuring stability during large data syncs. + +### Retry Errors with Exponential Backoff + +Errors can occur during data syncing due to transient network issues, rate limiting, or temporary unavailability of third-party services. To improve reliability, implement retry logic with exponential backoff for transient errors. + +For example, implement a custom function that fetches data with retry logic, then use it to fetch data from the third-party service: + +export const retryHighlights = [ + ["1", "MAX_RETRIES", "Maximum number of retry attempts"], + ["2", "RETRY_DELAY_MS", "Base delay for retries"], + ["4", "fetchWithRetry", "Function to fetch with retry logic"], + ["13", "fetch", "Fetch data from API"], + ["22", "isRetryable", "Check if error is retryable"], + ["31", "delay", "Calculate exponential backoff delay"], +] + +```ts title="src/jobs/sync-products.ts" highlights={retryHighlights} +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 1000 + +async function fetchWithRetry( + url: string, + retries = MAX_RETRIES +): Promise { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + // TODO: Add request timeout... + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } catch (error: any) { + lastError = error + const isRetryable = + error.code === "UND_ERR_SOCKET" || + error.code === "ECONNREFUSED" || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT" || + error.name === "AbortError" + + if (isRetryable && attempt < retries) { + // Exponential backoff: 1s → 2s → 4s + const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delay)) + } else { + break + } + } + } + + throw lastError +} + +async function* streamProductsFromApi() { + // Initial setup... + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + + const response = await fetchWithRetry(url) + // TODO Setup pipeline... + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + // Process and sync products... +} +``` + +In the above snippet, the `fetchWithRetry` function attempts to fetch a URL multiple times if a retryable error occurs. It uses exponential backoff to increase the delay between retries, reducing the load on the third-party service. + +This approach improves the reliability of your data syncing process by handling transient errors gracefully. + +### Set Request Timeouts + +When making API calls to third-party services, always set request timeouts. This prevents the event loop from being blocked indefinitely if the third-party service is unresponsive. + +For example, set a request timeout on a `fetch` call using the `AbortController`: + +export const timeoutHighlights = [ + ["1", "FETCH_TIMEOUT_MS", "Timeout duration for fetch requests"], + ["11", "AbortController", "Create an `AbortController` for request timeout"], + ["12", "setTimeout", "Set a timeout to abort the request"], + ["22", "clearTimeout", "Clear the timeout on successful response"], +] + +```ts title="src/jobs/sync-products.ts" highlights={timeoutHighlights} +const FETCH_TIMEOUT_MS = 30000 + +async function fetchWithRetry( + url: string, + retries = MAX_RETRIES +): Promise { + const lastError: Error | null = null + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + const response = await fetch(url, { + signal: controller.signal, + // Keep connection alive for efficiency with multiple requests + headers: { + "Connection": "keep-alive", + }, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } catch (error: any) { + // Retry logic with exponential backoff... + } + } + + throw lastError +} + +async function* streamProductsFromApi() { + // initial setup... + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + + const response = await fetchWithRetry(url) + // Setup streaming pipeline... + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + // Process and sync products... +} +``` + +In the above snippet, an `AbortController` sets a timeout for the `fetch` request. If the request takes longer than the specified timeout, it's aborted, preventing indefinite blocking of the event loop. + +This approach ensures that your application remains responsive even when third-party services are slow or unresponsive. + +### Yield to the Event Loop + +When processing large amounts of data in loops, periodically yield control back to the event loop. This prevents blocking the event loop for extended periods, which can lead to unresponsiveness in your application. For example, it may prevent other scheduled jobs or subscribers from executing. + +Yield to the event loop using [setImmediate](https://nodejs.org/api/timers.html#timers_setimmediate_callback_args). For example: + +export const yieldHighlights = [ + ["11", "productCount", "Count of processed products"], + ["15", "setImmediate", "Yield to event loop every 100 products"], +] + +```ts title="src/jobs/sync-products.ts" highlights={yieldHighlights} +async function* streamProductsFromApi() { + // Initial setup... + + while (hasMore) { + // Setup pipeline... + + try { + // Yield each product one at a time + for await (const { value } of pipeline) { + yield value + productCount++ + + // Yield to event loop periodically to prevent blocking + if (productCount % 100 === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + } + } catch (streamError: any) { + // Handle stream errors... + } + + // If the products are less than expected, there are no more products + if (productCount < API_FETCH_SIZE) { + hasMore = false + } else { + offset += productCount + } + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + const productStream = streamProductsFromApi() + // Process and sync products... +} +``` + +In the above snippet, the code yields to the event loop every 100 products processed. This allows other tasks in the event loop to execute, improving overall responsiveness. \ No newline at end of file diff --git a/www/apps/book/app/learn/fundamentals/events-and-subscribers/emit-event/page.mdx b/www/apps/book/app/learn/fundamentals/events-and-subscribers/emit-event/page.mdx index ca2f983bd0..1e24dadee7 100644 --- a/www/apps/book/app/learn/fundamentals/events-and-subscribers/emit-event/page.mdx +++ b/www/apps/book/app/learn/fundamentals/events-and-subscribers/emit-event/page.mdx @@ -110,7 +110,7 @@ const helloWorldWorkflow = createWorkflow( { id: "123" }, { id: "456" }, { id: "789" }, - ] + ], }) } ) diff --git a/www/apps/book/app/learn/fundamentals/workflows/locks/page.mdx b/www/apps/book/app/learn/fundamentals/workflows/locks/page.mdx index df9b36f239..84d407332c 100644 --- a/www/apps/book/app/learn/fundamentals/workflows/locks/page.mdx +++ b/www/apps/book/app/learn/fundamentals/workflows/locks/page.mdx @@ -151,7 +151,7 @@ import { createWorkflow } from "@medusajs/framework/workflows-sdk" import { acquireLockStep, releaseLockStep, - completeCartWorkflow + completeCartWorkflow, } from "@medusajs/medusa/core-flows" type WorkflowInput = { @@ -172,7 +172,7 @@ export const customCompleteCartWorkflow = createWorkflow( completeCartWorkflow.runAsStep({ input: { id: input.cart_id, - } + }, }) // release the lock after the nested workflow is complete diff --git a/www/apps/book/generated/edit-dates.mjs b/www/apps/book/generated/edit-dates.mjs index 2db9fffefb..284f0e2d57 100644 --- a/www/apps/book/generated/edit-dates.mjs +++ b/www/apps/book/generated/edit-dates.mjs @@ -136,5 +136,6 @@ export const generatedEditDates = { "app/learn/codemods/page.mdx": "2025-09-29T15:40:03.620Z", "app/learn/codemods/replace-imports/page.mdx": "2025-10-09T11:37:44.754Z", "app/learn/fundamentals/admin/translations/page.mdx": "2025-10-30T11:55:32.221Z", - "app/learn/configurations/medusa-config/asymmetric-encryption/page.mdx": "2025-10-31T09:53:38.607Z" + "app/learn/configurations/medusa-config/asymmetric-encryption/page.mdx": "2025-10-31T09:53:38.607Z", + "app/learn/best-practices/third-party-sync/page.mdx": "2025-12-03T11:48:58.209Z" } \ No newline at end of file diff --git a/www/apps/book/generated/sidebar.mjs b/www/apps/book/generated/sidebar.mjs index 990c39f3df..c4753c1efe 100644 --- a/www/apps/book/generated/sidebar.mjs +++ b/www/apps/book/generated/sidebar.mjs @@ -1294,7 +1294,37 @@ export const generatedSidebars = [ "loaded": true, "isPathHref": true, "type": "category", - "title": "8. Production", + "title": "8. Best Practices", + "children": [ + { + "loaded": true, + "isPathHref": true, + "type": "link", + "path": "/learn/best-practices/third-party-sync", + "title": "Third-Party Syncing", + "children": [], + "chapterTitle": "8.1. Third-Party Syncing", + "number": "8.1." + }, + { + "loaded": true, + "isPathHref": true, + "type": "ref", + "path": "/learn/fundamentals/scheduled-jobs/interval", + "title": "Scheduled Job Intervals", + "children": [], + "chapterTitle": "8.2. Scheduled Job Intervals", + "number": "8.2." + } + ], + "chapterTitle": "8. Best Practices", + "number": "8." + }, + { + "loaded": true, + "isPathHref": true, + "type": "category", + "title": "9. Production", "children": [ { "loaded": true, @@ -1303,8 +1333,8 @@ export const generatedSidebars = [ "path": "/learn/build", "title": "Build", "children": [], - "chapterTitle": "8.1. Build", - "number": "8.1." + "chapterTitle": "9.1. Build", + "number": "9.1." }, { "loaded": true, @@ -1313,8 +1343,8 @@ export const generatedSidebars = [ "path": "/learn/production/worker-mode", "title": "Worker Modes", "children": [], - "chapterTitle": "8.2. Worker Modes", - "number": "8.2." + "chapterTitle": "9.2. Worker Modes", + "number": "9.2." }, { "loaded": true, @@ -1330,22 +1360,22 @@ export const generatedSidebars = [ "path": "/learn/deployment/general", "title": "General Deployment", "children": [], - "chapterTitle": "8.3.1. General Deployment", - "number": "8.3.1." + "chapterTitle": "9.3.1. General Deployment", + "number": "9.3.1." } ], - "chapterTitle": "8.3. Deployment Overview", - "number": "8.3." + "chapterTitle": "9.3. Deployment Overview", + "number": "9.3." } ], - "chapterTitle": "8. Production", - "number": "8." + "chapterTitle": "9. Production", + "number": "9." }, { "loaded": true, "isPathHref": true, "type": "category", - "title": "9. Upgrade", + "title": "10. Upgrade", "children": [ { "loaded": true, @@ -1354,8 +1384,8 @@ export const generatedSidebars = [ "path": "/learn/update", "title": "Update Medusa", "children": [], - "chapterTitle": "9.1. Update Medusa", - "number": "9.1." + "chapterTitle": "10.1. Update Medusa", + "number": "10.1." }, { "loaded": true, @@ -1364,8 +1394,8 @@ export const generatedSidebars = [ "path": "https://github.com/medusajs/medusa/releases", "title": "Release Notes", "children": [], - "chapterTitle": "9.2. Release Notes", - "number": "9.2." + "chapterTitle": "10.2. Release Notes", + "number": "10.2." }, { "loaded": true, @@ -1381,22 +1411,22 @@ export const generatedSidebars = [ "title": "Replace Imports (v2.11.0+)", "path": "/learn/codemods/replace-imports", "children": [], - "chapterTitle": "9.3.1. Replace Imports (v2.11.0+)", - "number": "9.3.1." + "chapterTitle": "10.3.1. Replace Imports (v2.11.0+)", + "number": "10.3.1." } ], - "chapterTitle": "9.3. Codemods", - "number": "9.3." + "chapterTitle": "10.3. Codemods", + "number": "10.3." } ], - "chapterTitle": "9. Upgrade", - "number": "9." + "chapterTitle": "10. Upgrade", + "number": "10." }, { "loaded": true, "isPathHref": true, "type": "category", - "title": "10. Resources", + "title": "11. Resources", "children": [ { "loaded": true, @@ -1411,8 +1441,8 @@ export const generatedSidebars = [ "path": "/learn/resources/contribution-guidelines/docs", "title": "Docs", "children": [], - "chapterTitle": "10.1.1. Docs", - "number": "10.1.1." + "chapterTitle": "11.1.1. Docs", + "number": "11.1.1." }, { "loaded": true, @@ -1421,12 +1451,12 @@ export const generatedSidebars = [ "path": "/learn/resources/contribution-guidelines/admin-translations", "title": "Admin Translations", "children": [], - "chapterTitle": "10.1.2. Admin Translations", - "number": "10.1.2." + "chapterTitle": "11.1.2. Admin Translations", + "number": "11.1.2." } ], - "chapterTitle": "10.1. Contribution Guidelines", - "number": "10.1." + "chapterTitle": "11.1. Contribution Guidelines", + "number": "11.1." }, { "loaded": true, @@ -1435,12 +1465,12 @@ export const generatedSidebars = [ "path": "/learn/resources/usage", "title": "Usage", "children": [], - "chapterTitle": "10.2. Usage", - "number": "10.2." + "chapterTitle": "11.2. Usage", + "number": "11.2." } ], - "chapterTitle": "10. Resources", - "number": "10." + "chapterTitle": "11. Resources", + "number": "11." } ] } diff --git a/www/apps/book/public/llms-full.txt b/www/apps/book/public/llms-full.txt index 5cac32fca5..6059b105fa 100644 --- a/www/apps/book/public/llms-full.txt +++ b/www/apps/book/public/llms-full.txt @@ -1,5 +1,882 @@ +# Best Practices for Third-Party Syncing + +In this chapter, you'll learn about best practices for syncing data between Medusa and third-party systems. + +## Common Issues with Third-Party Syncing Implementation + +Syncing data between Medusa and external systems is a common use case for commerce applications. For example, if your commerce ecosystem includes an external CMS or inventory management system, you may need to sync product data between Medusa and these systems. + +However, how you implement third-party syncing can significantly impact performance and memory usage. Syncing large amounts of data without proper handling can lead to issues like: + +- Out-of-memory (OOM) errors. +- Slow syncs that block the event loop and degrade application performance. +- Application crashes in production. + +This chapter covers best practices to avoid these issues when syncing data between Medusa and third-party systems. These best practices are general programming patterns that aren't Medusa-specific, but they're essential for building robust third-party syncs. + +*** + +## How to Sync Data Between Systems + +Before diving into best practices, it's important to understand the general approach for syncing data between Medusa and third-party systems. Third-party syncing typically involves two main steps: + +1. Define the syncing logic in a [workflow](https://docs.medusajs.com/learn/fundamentals/workflows/index.html.md). +2. Execute the workflow from either a [scheduled job](https://docs.medusajs.com/learn/fundamentals/scheduled-jobs/index.html.md) or a [subscriber](https://docs.medusajs.com/learn/fundamentals/events-and-subscribers/index.html.md). + +### Define Syncing Logic in a Workflow + +[Workflows](https://docs.medusajs.com/learn/fundamentals/workflows/index.html.md) are special functions designed for long-running, asynchronous tasks. They provide features like [compensation](https://docs.medusajs.com/learn/fundamentals/workflows/compensation-function/index.html.md), [retries](https://docs.medusajs.com/learn/fundamentals/workflows/retry-failed-steps/index.html.md), and [async execution](https://docs.medusajs.com/learn/fundamentals/workflows/long-running-workflow/index.html.md) that are essential for reliable data syncing. + +When defining your syncing logic, such as pushing product data to a third-party service or pulling inventory data into Medusa, you should define a workflow that encapsulates this logic. + +Medusa also exposes [built-in workflows](https://docs.medusajs.com/resources/medusa-workflows-reference/index.html.md) for common commerce operations, like creating or updating products, that you can leverage in your syncing logic. + +For example, you can use Medusa's built-in [batchProductsWorkflow](https://docs.medusajs.com/resources/references/medusa-workflows/batchProductsWorkflow/index.html.md) to create or update products in batches: + +```ts title="src/jobs/sync-products.ts" +import { MedusaContainer } from "@medusajs/framework/types" +import { batchProductsWorkflow } from "@medusajs/medusa/core-flows" + +export default async function syncProductsJob(container: MedusaContainer) { + // ... + await batchProductsWorkflow(container).run({ + input: { + create: productsToCreate, + update: productsToUpdate, + }, + }) +} +``` + +### Execute Workflows from Scheduled Jobs or Subscribers + +After defining your syncing logic in a workflow, or choosing a Medusa workflow to use, you need to execute it based on your syncing requirements: + +- [Scheduled Jobs](https://docs.medusajs.com/learn/fundamentals/scheduled-jobs/index.html.md): Use scheduled jobs for periodic syncs, such as syncing products daily or inventory hourly. Scheduled jobs run at specified intervals and can trigger your workflow to perform the sync. +- [Subscribers](https://docs.medusajs.com/learn/fundamentals/events-and-subscribers/index.html.md): Use subscribers for event-driven syncs, such as syncing data when a product is updated in Medusa or when an order is placed. Subscribers listen for specific events and can trigger your workflow in response. + +If you've set up [server and worker instances](https://docs.medusajs.com/learn/production/worker-mode/index.html.md), the worker will handle the execution. So, the syncing execution won't block the main server process. + +[Cloud](https://docs.medusajs.com/cloud/index.html.md) creates server and worker instances for your project automatically, so you don't need to set this up manually. + +In the scheduled job or subscriber, you retrieve the data to be synced from the third-party service or from Medusa itself. Then, you execute the workflow, passing it the data to be synced. + +For example, the following scheduled job fetches products from a third-party service and syncs them to Medusa using a workflow: + +```ts title="src/jobs/sync-products.ts" +import { MedusaContainer } from "@medusajs/framework/types" +import { batchProductsWorkflow } from "@medusajs/medusa/core-flows" + +export default async function syncProductsJob(container: MedusaContainer) { + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, 50) + + for await (const batch of batchedProducts) { + const { + productsToCreate, + productsToUpdate, + } = await prepareProducts(batch) + + await batchProductsWorkflow(container).run({ + input: { + create: productsToCreate, + update: productsToUpdate, + }, + }) + } +} +``` + +You'll learn about best practices for implementing the data fetching, batching, and preparation logic in the next sections. + +*** + +## Syncing Best Practices + +The following sections cover best practices for implementing third-party syncing in a way that minimizes memory usage, maximizes performance, and ensures reliability. + +### Full Scheduled Job Code + +The following sections take snippets from this complete example of a high-performance, memory-efficient product synchronization job that incorporates all the best practices discussed: + +```ts title="src/jobs/sync-products.ts" +import { MedusaContainer } from "@medusajs/framework/types" +import { ContainerRegistrationKeys, MedusaError, Modules } from "@medusajs/framework/utils" +import { batchProductsWorkflow } from "@medusajs/medusa/core-flows" +import { Readable } from "stream" +import { parser } from "stream-json" +import { pick } from "stream-json/filters/Pick" +import { streamArray } from "stream-json/streamers/StreamArray" +import { chain } from "stream-chain" + +const API_FETCH_SIZE = 200 +const PROCESS_BATCH_SIZE = 50 +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 1000 +const FETCH_TIMEOUT_MS = 30000 + +async function fetchWithRetry( + url: string, + retries = MAX_RETRIES +): Promise { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + const response = await fetch(url, { + signal: controller.signal, + // Keep connection alive for efficiency with multiple requests + headers: { + "Connection": "keep-alive", + }, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } catch (error: any) { + lastError = error + const isRetryable = + error.code === "UND_ERR_SOCKET" || + error.code === "ECONNREFUSED" || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT" || + error.name === "AbortError" + + if (isRetryable && attempt < retries) { + // Exponential backoff: 1s → 2s → 4s + const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delay)) + } else { + break + } + } + } + + throw lastError +} + +/** + - Streams products from the external API using incremental JSON parsing + - Products are yielded one by one as they're parsed from the response stream + */ +async function* streamProductsFromApi() { + let offset = 0 + let hasMore = true + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + + const response = await fetchWithRetry(url) + + // Convert web ReadableStream to Node.js Readable + const nodeStream = Readable.fromWeb(response.body as any) + + // Create a streaming JSON parser pipeline that: + // 1. Parses JSON incrementally + // 2. Picks only the "products" array + // 3. Streams each array item individually + const pipeline = chain([ + nodeStream, + parser(), + pick({ filter: "products" }), + streamArray(), + ]) + + let productCount = 0 + + // Yield each product as it's parsed - memory stays constant + try { + for await (const { value } of pipeline) { + yield value + productCount++ + + // Yield to event loop periodically to prevent blocking + if (productCount % 100 === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + } + } catch (streamError: any) { + // Handle stream errors (socket closed mid-stream) + if (streamError.code === "UND_ERR_SOCKET" || streamError.code === "ECONNRESET") { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Stream interrupted after ${productCount} products: ${streamError.message}` + ) + } + throw streamError + } + + // If the products are less than expected, there are no more products + if (productCount < API_FETCH_SIZE) { + hasMore = false + } else { + offset += productCount + } + } +} + +/** + - Collects products into batches of the specified size + */ +async function* batchProducts( + products: AsyncGenerator, + batchSize: number +): AsyncGenerator { + let batch: any[] = [] + + for await (const product of products) { + batch.push(product) + + if (batch.length >= batchSize) { + yield batch + // Release reference for GC + batch = [] + } + } + + // Yield remaining products + if (batch.length > 0) { + yield batch + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + const workflowEngine = container.resolve(Modules.WORKFLOW_ENGINE) + + let totalCreated = 0 + let totalUpdated = 0 + let batchNumber = 0 + + // Stream products from API and process in batches + // Memory stays constant - we only hold PROCESS_BATCH_SIZE products at a time + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + batchNumber++ + + // Extract external IDs from this batch to look up in Medusa + const externalIds = batch.map((p) => p.id) + + // Query Medusa for products matching these external IDs + const { data: existingProducts } = await query.graph( + { + entity: "product", + fields: ["id", "updated_at", "external_id"], + filters: { + external_id: externalIds, + }, + } + ) + + // Build a map for quick lookup + const existingByExternalId = new Map( + existingProducts.map((p) => [p.external_id, { + id: p.id, + updatedAt: p.updated_at, + }]) + ) + + const productsToCreate: any[] = [] + const productsToUpdate: any[] = [] + + for (const externalProduct of batch) { + const existing = existingByExternalId.get(externalProduct.id) + + if (existing) { + // Product exists - prepare update + productsToUpdate.push({ + id: existing.id, + title: externalProduct.title, + description: externalProduct.description ?? undefined, + metadata: { + external_id: externalProduct.id, + last_synced: new Date().toISOString(), + }, + }) + } else { + // New product - prepare create + productsToCreate.push({ + title: externalProduct.title, + description: externalProduct.description ?? undefined, + handle: externalProduct.handle, + status: "draft", + metadata: { + external_id: externalProduct.id, + last_synced: new Date().toISOString(), + }, + options: [ + { + title: "Default", + values: ["Default"], + }, + ], + variants: externalProduct.variants.map((v) => ({ + title: v.title ?? "Default Variant", + sku: v.sku ?? undefined, + options: { + Default: "Default", + }, + prices: [ + { + amount: v.price, + currency_code: "usd", + }, + ], + })), + }) + } + } + + // Execute batch workflow for this batch + if (productsToCreate.length > 0 || productsToUpdate.length > 0) { + await batchProductsWorkflow(container).run({ + input: { + create: productsToCreate, + update: productsToUpdate, + }, + }) + + totalCreated += productsToCreate.length + totalUpdated += productsToUpdate.length + } + // Yield to event loop between batches + await new Promise((resolve) => setImmediate(resolve)) + } +} + +export const config = { + name: "sync-products", + schedule: "0 0 * * *", // Run at midnight every day +} +``` + +### Stream Data from External APIs + +When retrieving data from external APIs using `fetch`, the common approach is to fetch the data and load it entirely into memory using `response.json()`. For example: + +```ts +const response = await fetch("https://third-party-api.com/products") +// Load entire response into memory +const data = await response.json() +``` + +However, this can lead to high memory usage and performance issues, especially with large datasets. + +Instead, process data in streams or batches. This approach allows you to handle large datasets without loading everything into memory at once. You can use libraries like [stream-json](https://www.npmjs.com/package/stream-json) to parse JSON data incrementally as it's received. + +![Diagram showcasing object in memory when loading entire JSON response vs streaming JSON parsing](https://res.cloudinary.com/dza7lstvk/image/upload/v1764761087/Medusa%20Book/stream-vs-full_iz2fyv.jpg) + +First, install the `stream-json` library in your Medusa project: + +```bash npm2yarn +npm install stream-json @types/stream-json +``` + +Then, use it in your scheduled job or subscriber to stream and parse JSON data from the third-party service: + +```ts title="src/jobs/sync-products.ts" highlights={streamDataHighlights} +import { Readable } from "stream" +import { parser } from "stream-json" +import { pick } from "stream-json/filters/Pick" +import { streamArray } from "stream-json/streamers/StreamArray" +import { chain } from "stream-chain" + +const API_FETCH_SIZE = 200 + +async function* streamProductsFromApi() { + let offset = 0 + let hasMore = true + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + // TODO: Add retry with exponential backoff + const response = await fetch(url) + + // Convert web ReadableStream to Node.js Readable + const nodeStream = Readable.fromWeb(response.body as any) + + // Create a streaming JSON parser pipeline that: + // 1. Parses JSON incrementally + // 2. Picks only the "products" array + // 3. Streams each array item individually + const pipeline = chain([ + nodeStream, + parser(), + pick({ filter: "products" }), + streamArray(), + ]) + + let productCount = 0 + + try { + // Yield each product one at a time + for await (const { value } of pipeline) { + yield value + productCount++ + + // TODO: Yield to event loop periodically to prevent blocking + } + } catch (streamError: any) { + // TODO: Handle stream errors + } + + // If the products are less than expected, there are no more products + if (productCount < API_FETCH_SIZE) { + hasMore = false + } else { + offset += productCount + } + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + const productStream = streamProductsFromApi() + // ... +} +``` + +In the above snippet, you set up a streaming JSON parser that processes the API response incrementally. Instead of loading the entire response into memory, the parser yields each product one at a time. + +This approach significantly reduces memory usage, as you only hold JSON tokens and the current product being parsed, rather than the entire dataset. + +#### Handle Stream Errors + +When working with streams, it's important to handle potential errors that may occur during streaming, such as network interruptions. Catch these errors and implement retry logic or error reporting as needed. + +For example: + +```ts title="src/jobs/sync-products.ts" +async function* streamProductsFromApi() { + // Initial setup... + while (hasMore) { + // Setup pipeline... + try { + for await (const { value } of pipeline) { + yield value + // ... + } + } catch (streamError: any) { + // Handle stream errors (socket closed mid-stream) + if ( + streamError.code === "UND_ERR_SOCKET" || + streamError.code === "ECONNRESET" + ) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Stream interrupted after ${productCount} products: ${streamError.message}` + ) + } + throw streamError + } + // Update pagination... + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + // sync products... +} +``` + +In the above snippet, you catch stream errors and check for specific error codes that indicate transient network issues. You can then decide how to handle these errors, such as retrying the fetch or logging the error. + +### Retrieve Only Necessary Fields + +A common performance pitfall when syncing data is retrieving more fields than necessary from third-party services or Medusa's [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query/index.html.md). This leads to increased data size, slower performance, and higher memory usage. + +When retrieving data from third-party services or with Medusa's Query, only request the necessary fields. Then, to efficiently group existing data for updates, use a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) for quick lookups. + +For example, don't retrieve all product fields like this: + +```ts +// DON'T +const { data: existingProducts } = await query.graph( + { + entity: "product", + fields: ["*"], + filters: { + external_id: externalIds, + }, + } +) +``` + +Instead, only request the fields you need: + +```ts title="src/jobs/sync-products.ts" highlights={fieldsHighlights} +export default async function syncProductsJob(container: MedusaContainer) { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + + // Initial setup... + + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + // Increment data... + + // Extract external IDs from this batch to look up in Medusa + const externalIds = batch.map((p) => p.id) + + // Query Medusa for products matching these external IDs + const { data: existingProducts } = await query.graph( + { + entity: "product", + fields: ["id", "updated_at", "external_id"], + filters: { + external_id: externalIds, + }, + } + ) + + // Build a map for quick lookup + const existingByExternalId = new Map( + existingProducts.map((p) => [p.external_id, { + id: p.id, + updatedAt: p.updated_at, + }]) + ) + + // Process batch and sync to Medusa... + } +} +``` + +In the above snippet, after retrieving a batch of products from the external API, you query Medusa's products to find existing products that match the external IDs. You only request the necessary fields for this operation. + +Then, you build a map `existingByExternalId` that enables efficient lookups when determining whether to create or update products. + +This approach minimizes the amount of data transferred and processed, leading to better performance and lower memory usage. + +### Use Async Generators + +[Async generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator) are a powerful feature in JavaScript that allow you to define asynchronous iterators. They're particularly useful for processing large datasets incrementally, as they enable you to yield data items one at a time without loading everything into memory. + +When retrieving large datasets from third-party services, use async generators to yield data items one at a time. This allows you to process data incrementally without loading everything into memory. + +![Diagram showcasing how batches are loaded into memories one at a time with async generators](https://res.cloudinary.com/dza7lstvk/image/upload/v1764761706/Medusa%20Book/async-gen-batches_ne09bs.jpg) + +For example: + +```ts title="src/jobs/sync-products.ts" highlights={asyncGeneratorHighlights} +async function* streamProductsFromApi() { + // Initial setup... + + while (hasMore) { + // Setup pipeline... + + try { + for await (const { value } of pipeline) { + yield value // Yield one product at a time + + // TODO: Yield to event loop periodically to prevent blocking... + } + } catch (streamError: any) { + // Handle stream errors... + } + + // Update pagination... + } +} + +async function* batchProducts( + products: AsyncGenerator, + batchSize: number +): AsyncGenerator { + let batch: any[] = [] + + for await (const product of products) { + batch.push(product) + + if (batch.length >= batchSize) { + yield batch + // Release reference for GC + batch = [] + } + } + + // Yield remaining products + if (batch.length > 0) { + yield batch + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + // Process batch and sync to Medusa... + } +} +``` + +In the above snippet, you define two async generators: + +1. `streamProductsFromApi`: Yields individual products from the third-party service one at a time. +2. `batchProducts`: Takes an async generator of products and yields them in batches of a specified size. + +Then, in your scheduled job, you consume these generators using [for await...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of) loops to process product batches incrementally. + +This approach keeps memory usage low, as you only hold the current product or batch in memory at any given time. + +#### Release References for Garbage Collection + +When using async generators, it's important to release references to processed data to allow for [garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Memory_management#garbage_collection). This keeps memory usage low, especially when processing large datasets. + +For example, the `batchProducts` generator demonstrates this: + +```ts title="src/jobs/sync-products.ts" highlights={[["13"]]} +async function* batchProducts( + products: AsyncGenerator, + batchSize: number +): AsyncGenerator { + let batch: any[] = [] + + for await (const product of products) { + batch.push(product) + + if (batch.length >= batchSize) { + yield batch + // Release reference for GC + batch = [] + } + } + + // Yield remaining products + if (batch.length > 0) { + yield batch + } +} +``` + +### Handle Backpressure + +Backpressure is a mechanism that manages the flow of data between producers (API fetches) and consumers (data processing workflows). Without proper backpressure handling, fast API fetches can overwhelm the processing workflow, leading to high memory usage and potential crashes. + +Handle backpressure by controlling the pace at which data is processed. One effective way to do this in JavaScript is using `for await...of` loops, which naturally provide backpressure by waiting for each iteration to complete before fetching the next item. + +![Diagram showcasing timeline from start to end of scheduled job execution where batches are fetched and synced one at a time](https://res.cloudinary.com/dza7lstvk/image/upload/v1764762095/Medusa%20Book/timeline-backpressure_rtwwzt.jpg) + +For example, you can implement backpressure handling in your scheduled job: + +```ts title="src/jobs/sync-products.ts" highlights={[["7"]]} +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + const batchedProducts = batchProducts(productStream, PROCESS_BATCH_SIZE) + + for await (const batch of batchedProducts) { + // Process batch and sync to Medusa... + // The next batch won't be fetched until processing of the current batch is complete + } +} +``` + +In the above snippet, the `for await...of` loop processes each batch of products. This ensures that the next batch isn't fetched until the current batch has been fully processed, effectively implementing backpressure. + +This approach keeps memory usage controlled and prevents the system from being overwhelmed by incoming data, ensuring stability during large data syncs. + +### Retry Errors with Exponential Backoff + +Errors can occur during data syncing due to transient network issues, rate limiting, or temporary unavailability of third-party services. To improve reliability, implement retry logic with exponential backoff for transient errors. + +For example, implement a custom function that fetches data with retry logic, then use it to fetch data from the third-party service: + +```ts title="src/jobs/sync-products.ts" highlights={retryHighlights} +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 1000 + +async function fetchWithRetry( + url: string, + retries = MAX_RETRIES +): Promise { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + // TODO: Add request timeout... + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } catch (error: any) { + lastError = error + const isRetryable = + error.code === "UND_ERR_SOCKET" || + error.code === "ECONNREFUSED" || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT" || + error.name === "AbortError" + + if (isRetryable && attempt < retries) { + // Exponential backoff: 1s → 2s → 4s + const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delay)) + } else { + break + } + } + } + + throw lastError +} + +async function* streamProductsFromApi() { + // Initial setup... + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + + const response = await fetchWithRetry(url) + // TODO Setup pipeline... + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + // Process and sync products... +} +``` + +In the above snippet, the `fetchWithRetry` function attempts to fetch a URL multiple times if a retryable error occurs. It uses exponential backoff to increase the delay between retries, reducing the load on the third-party service. + +This approach improves the reliability of your data syncing process by handling transient errors gracefully. + +### Set Request Timeouts + +When making API calls to third-party services, always set request timeouts. This prevents the event loop from being blocked indefinitely if the third-party service is unresponsive. + +For example, set a request timeout on a `fetch` call using the `AbortController`: + +```ts title="src/jobs/sync-products.ts" highlights={timeoutHighlights} +const FETCH_TIMEOUT_MS = 30000 + +async function fetchWithRetry( + url: string, + retries = MAX_RETRIES +): Promise { + const lastError: Error | null = null + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + const response = await fetch(url, { + signal: controller.signal, + // Keep connection alive for efficiency with multiple requests + headers: { + "Connection": "keep-alive", + }, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } catch (error: any) { + // Retry logic with exponential backoff... + } + } + + throw lastError +} + +async function* streamProductsFromApi() { + // initial setup... + + while (hasMore) { + const url = `https://third-party-api.com/products?limit=${API_FETCH_SIZE}&offset=${offset}` + + const response = await fetchWithRetry(url) + // Setup streaming pipeline... + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + // Initial setup... + + const productStream = streamProductsFromApi() + // Process and sync products... +} +``` + +In the above snippet, an `AbortController` sets a timeout for the `fetch` request. If the request takes longer than the specified timeout, it's aborted, preventing indefinite blocking of the event loop. + +This approach ensures that your application remains responsive even when third-party services are slow or unresponsive. + +### Yield to the Event Loop + +When processing large amounts of data in loops, periodically yield control back to the event loop. This prevents blocking the event loop for extended periods, which can lead to unresponsiveness in your application. For example, it may prevent other scheduled jobs or subscribers from executing. + +Yield to the event loop using [setImmediate](https://nodejs.org/api/timers.html#timers_setimmediate_callback_args). For example: + +```ts title="src/jobs/sync-products.ts" highlights={yieldHighlights} +async function* streamProductsFromApi() { + // Initial setup... + + while (hasMore) { + // Setup pipeline... + + try { + // Yield each product one at a time + for await (const { value } of pipeline) { + yield value + productCount++ + + // Yield to event loop periodically to prevent blocking + if (productCount % 100 === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + } + } catch (streamError: any) { + // Handle stream errors... + } + + // If the products are less than expected, there are no more products + if (productCount < API_FETCH_SIZE) { + hasMore = false + } else { + offset += productCount + } + } +} + +export default async function syncProductsJob(container: MedusaContainer) { + const productStream = streamProductsFromApi() + // Process and sync products... +} +``` + +In the above snippet, the code yields to the event loop every 100 products processed. This allows other tasks in the event loop to execute, improving overall responsiveness. + + # Build Medusa Application In this chapter, you'll learn how to create a production build of your Medusa application for deployment to a hosting provider. @@ -13225,7 +14102,7 @@ const helloWorldWorkflow = createWorkflow( { id: "123" }, { id: "456" }, { id: "789" }, - ] + ], }) } ) @@ -22456,7 +23333,7 @@ import { createWorkflow } from "@medusajs/framework/workflows-sdk" import { acquireLockStep, releaseLockStep, - completeCartWorkflow + completeCartWorkflow, } from "@medusajs/medusa/core-flows" type WorkflowInput = { @@ -22477,7 +23354,7 @@ export const customCompleteCartWorkflow = createWorkflow( completeCartWorkflow.runAsStep({ input: { id: input.cart_id, - } + }, }) // release the lock after the nested workflow is complete @@ -32769,6 +33646,101 @@ An order can have multiple transactions. The sum of these transactions must be e Refer to the [Transactions](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/order/transactions/index.html.md) guide to learn more. +# Custom Order Display ID + +In this guide, you'll learn how to customize the display ID of orders in Medusa. + +This feature is available since [Medusa v2.12.0](https://github.com/medusajs/medusa/releases/tag/v2.12.0). + +## Default Display ID + +By default, Medusa stores the display ID of orders in the `display_id` property of the [Order data model](https://docs.medusajs.com/references/order/models/Order/index.html.md). The display ID is a serial integer that starts at 1 and increments with each new order. + +For example: + +```json +{ + "id": "order_123", + "display_id": 1, + // other properties... +} +``` + +*** + +## Custom Display ID + +In some cases, you might want to use a custom display ID for orders. This is useful for integrating with external systems or providing a more user-friendly order identifier. + +The `Order` data model has a `custom_display_id` property that stores a custom display ID you generate. + +You can define the logic for generating this ID in the `generateCustomDisplayId` module option set in `medusa-config.ts`. + +For example: + +```ts title="medusa-config.ts" +// other imports... +import { Modules } from "@medusajs/framework/utils" +import { OrderTypes, Context } from "@medusajs/framework/types" + +module.exports = defineConfig({ + modules: [ + { + key: Modules.ORDER, + options: { + generateCustomDisplayId: async function ( + order: OrderTypes.CreateOrderDTO, + sharedContext: Context + ): Promise { + // Return your custom display ID + return `${order.email}-${Date.now()}` + }, + }, + }, + // other modules... + ], + // other configurations... +}) +``` + +In the example above, the `generateCustomDisplayId` function generates a custom display ID by combining the order's email with the current timestamp. + +You can implement any logic to generate a unique and meaningful display ID for your orders. + +*** + +## View Custom Display ID in Medusa Admin + +By default, Medusa Admin displays the `display_id` in the table on the Orders page. To view the custom display ID in the table, you can enable the `view_configurations` experimental feature. + +To enable this feature, add the following to `medusa-config.ts`: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // other configurations... + featureFlags: { + view_configurations: true, + }, +}) +``` + +This enables the feature's flag. + +Next, run the necessary migrations: + +```bash +npx medusa db:migrate +``` + +Then, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Finally, customize the Order view in Medusa Admin to display the `custom_display_id` property. + + # Draft Orders Plugin In this guide, you'll learn about the Draft Orders Plugin and its features. @@ -38818,6 +39790,24 @@ The Medusa Admin UI may not provide a way to create each of these promotion exam *** +## Promotion Limits + +The `limit` property is available since [Medusa v2.12.0](https://github.com/medusajs/medusa/releases/tag/v2.12.0). + +A promotion can have usage limits to restrict how many times it can be used. + +There are three ways to limit a promotion's usage: + +1. By setting its `limit` property: This limits the total number of times the promotion can be used across all orders. +2. By setting the [global budget on the promotion's campaign](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/promotion/campaign#global-budgets/index.html.md): This limits the total spend or usage across all promotions in the campaign. +3. By setting the [attribute-based budget on the promotion's campaign](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/promotion/campaign#attribute-based-budgets/index.html.md): This limits the total spend or usage for specific attributes across all promotions in the campaign. For example, limiting the budget for a specific customer group. + +All budgets are applied on the promotion. Once a budget is exhausted, the promotion can no longer be applied. + +For example, if a promotion has a `limit` of `10` and its campaign has a global budget of `$1000`, the promotion can only be used `10` times or until the total discount given reaches `$1000`, whichever comes first. + +*** + ## Promotion Rules A promotion can be restricted by a set of rules, each rule is represented by the [PromotionRule data model](https://docs.medusajs.com/references/promotion/models/PromotionRule/index.html.md). @@ -46859,6 +47849,7 @@ Connection to Redis in module 'workflow-engine-redis' established - [addDraftOrderShippingMethodsWorkflow](https://docs.medusajs.com/references/medusa-workflows/addDraftOrderShippingMethodsWorkflow/index.html.md) - [beginDraftOrderEditWorkflow](https://docs.medusajs.com/references/medusa-workflows/beginDraftOrderEditWorkflow/index.html.md) - [cancelDraftOrderEditWorkflow](https://docs.medusajs.com/references/medusa-workflows/cancelDraftOrderEditWorkflow/index.html.md) +- [computeDraftOrderAdjustmentsWorkflow](https://docs.medusajs.com/references/medusa-workflows/computeDraftOrderAdjustmentsWorkflow/index.html.md) - [confirmDraftOrderEditWorkflow](https://docs.medusajs.com/references/medusa-workflows/confirmDraftOrderEditWorkflow/index.html.md) - [convertDraftOrderStep](https://docs.medusajs.com/references/medusa-workflows/convertDraftOrderStep/index.html.md) - [convertDraftOrderWorkflow](https://docs.medusajs.com/references/medusa-workflows/convertDraftOrderWorkflow/index.html.md) @@ -46946,6 +47937,7 @@ Connection to Redis in module 'workflow-engine-redis' established - [cancelTransferOrderRequestValidationStep](https://docs.medusajs.com/references/medusa-workflows/cancelTransferOrderRequestValidationStep/index.html.md) - [cancelValidateOrder](https://docs.medusajs.com/references/medusa-workflows/cancelValidateOrder/index.html.md) - [completeOrderWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeOrderWorkflow/index.html.md) +- [computeAdjustmentsForPreviewWorkflow](https://docs.medusajs.com/references/medusa-workflows/computeAdjustmentsForPreviewWorkflow/index.html.md) - [confirmClaimRequestValidationStep](https://docs.medusajs.com/references/medusa-workflows/confirmClaimRequestValidationStep/index.html.md) - [confirmClaimRequestWorkflow](https://docs.medusajs.com/references/medusa-workflows/confirmClaimRequestWorkflow/index.html.md) - [confirmExchangeRequestValidationStep](https://docs.medusajs.com/references/medusa-workflows/confirmExchangeRequestValidationStep/index.html.md) @@ -46994,6 +47986,7 @@ Connection to Redis in module 'workflow-engine-redis' established - [markOrderFulfillmentAsDeliveredWorkflow](https://docs.medusajs.com/references/medusa-workflows/markOrderFulfillmentAsDeliveredWorkflow/index.html.md) - [markPaymentCollectionAsPaid](https://docs.medusajs.com/references/medusa-workflows/markPaymentCollectionAsPaid/index.html.md) - [maybeRefreshShippingMethodsWorkflow](https://docs.medusajs.com/references/medusa-workflows/maybeRefreshShippingMethodsWorkflow/index.html.md) +- [onCarryPromotionsFlagSet](https://docs.medusajs.com/references/medusa-workflows/onCarryPromotionsFlagSet/index.html.md) - [orderClaimAddNewItemValidationStep](https://docs.medusajs.com/references/medusa-workflows/orderClaimAddNewItemValidationStep/index.html.md) - [orderClaimAddNewItemWorkflow](https://docs.medusajs.com/references/medusa-workflows/orderClaimAddNewItemWorkflow/index.html.md) - [orderClaimItemValidationStep](https://docs.medusajs.com/references/medusa-workflows/orderClaimItemValidationStep/index.html.md) @@ -47050,6 +48043,7 @@ Connection to Redis in module 'workflow-engine-redis' established - [updateExchangeShippingMethodValidationStep](https://docs.medusajs.com/references/medusa-workflows/updateExchangeShippingMethodValidationStep/index.html.md) - [updateExchangeShippingMethodWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateExchangeShippingMethodWorkflow/index.html.md) - [updateOrderChangeActionsWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateOrderChangeActionsWorkflow/index.html.md) +- [updateOrderChangeWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateOrderChangeWorkflow/index.html.md) - [updateOrderChangesWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateOrderChangesWorkflow/index.html.md) - [updateOrderEditAddItemValidationStep](https://docs.medusajs.com/references/medusa-workflows/updateOrderEditAddItemValidationStep/index.html.md) - [updateOrderEditAddItemWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateOrderEditAddItemWorkflow/index.html.md) @@ -47068,6 +48062,7 @@ Connection to Redis in module 'workflow-engine-redis' established - [updateReturnShippingMethodWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateReturnShippingMethodWorkflow/index.html.md) - [updateReturnValidationStep](https://docs.medusajs.com/references/medusa-workflows/updateReturnValidationStep/index.html.md) - [updateReturnWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateReturnWorkflow/index.html.md) +- [validateCarryPromotionsFlagStep](https://docs.medusajs.com/references/medusa-workflows/validateCarryPromotionsFlagStep/index.html.md) - [validateOrderCreditLinesStep](https://docs.medusajs.com/references/medusa-workflows/validateOrderCreditLinesStep/index.html.md) - [capturePaymentWorkflow](https://docs.medusajs.com/references/medusa-workflows/capturePaymentWorkflow/index.html.md) - [processPaymentWorkflow](https://docs.medusajs.com/references/medusa-workflows/processPaymentWorkflow/index.html.md) @@ -47323,6 +48318,7 @@ Connection to Redis in module 'workflow-engine-redis' established - [deleteOrderLineItems](https://docs.medusajs.com/references/medusa-workflows/steps/deleteOrderLineItems/index.html.md) - [deleteOrderShippingMethods](https://docs.medusajs.com/references/medusa-workflows/steps/deleteOrderShippingMethods/index.html.md) - [deleteReturnsStep](https://docs.medusajs.com/references/medusa-workflows/steps/deleteReturnsStep/index.html.md) +- [listOrderChangeActionsByTypeStep](https://docs.medusajs.com/references/medusa-workflows/steps/listOrderChangeActionsByTypeStep/index.html.md) - [previewOrderChangeStep](https://docs.medusajs.com/references/medusa-workflows/steps/previewOrderChangeStep/index.html.md) - [registerOrderChangesStep](https://docs.medusajs.com/references/medusa-workflows/steps/registerOrderChangesStep/index.html.md) - [registerOrderDeliveryStep](https://docs.medusajs.com/references/medusa-workflows/steps/registerOrderDeliveryStep/index.html.md) @@ -50873,10 +51869,12 @@ The workflow you'll implement in this section has the following steps: - [useQueryGraphStep (Retrieve Cart)](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's ID and currency using Query. - [useQueryGraphStep (Retrieve Variant)](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the variant's details using Query - [getVariantMetalPricesStep](#getvariantmetalpricesstep): Retrieve the variant's price using the third-party service. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [addToCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/addToCartWorkflow/index.html.md): Add the item with the custom price to the cart. - [useQueryGraphStep (Retrieve Cart)](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the updated cart's details using Query. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. -`useQueryGraphStep` and `addToCartWorkflow` are available through Medusa's core workflows package. You'll only implement the `getVariantMetalPricesStep`. +You'll only implement the `getVariantMetalPricesStep`. Medusa provides the other steps out-of-the-box. ### getVariantMetalPricesStep @@ -51010,6 +52008,9 @@ export const addCustomToCartWorkflow = createWorkflow( entity: "cart", filters: { id: cart_id }, fields: ["id", "currency_code"], + options: { + throwIfKeyNotFound: true, + }, }) const { data: variants } = useQueryGraphStep({ @@ -51074,7 +52075,10 @@ Next, you'll add the item with the custom price to the cart. First, add the foll ```ts title="src/workflows/add-custom-to-cart.ts" import { transform } from "@medusajs/framework/workflows-sdk" -import { addToCartWorkflow } from "@medusajs/medusa/core-flows" +import { + acquireLockStep, + addToCartWorkflow, +} from "@medusajs/medusa/core-flows" ``` Then, replace the `TODO` in the workflow with the following: @@ -51090,6 +52094,12 @@ const itemToAdd = transform({ }] }) +acquireLockStep({ + key: cart_id, + timeout: 2, + ttl: 10, +}) + addToCartWorkflow.runAsStep({ input: { items: itemToAdd, @@ -51100,7 +52110,9 @@ addToCartWorkflow.runAsStep({ // TODO retrieve and return cart ``` -You prepare the item to add to the cart using `transform` from the Workflows SDK. It allows you to manipulate and create variables in a workflow. After that, you use Medusa's `addToCartWorkflow` to add the item with the custom price to the cart. +You prepare the item to add to the cart using `transform` from the Workflows SDK. It allows you to manipulate and create variables in a workflow. + +After that, you use Medusa's `acquireLockStep` to acquire a lock on the cart, and `addToCartWorkflow` to add the item with the custom price to the cart. A workflow's constructor function has some constraints in implementation, which is why you need to use `transform` for variable manipulation. Learn more about these constraints in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/constructor-constraints/index.html.md). @@ -51108,6 +52120,9 @@ Lastly, you'll retrieve the cart's details again and return them. Add the follow ```ts title="src/workflows/add-custom-to-cart.ts" import { WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { + releaseLockStep, +} from "@medusajs/medusa/core-flows" ``` And replace the last `TODO` in the workflow with the following: @@ -51119,12 +52134,18 @@ const { data: updatedCarts } = useQueryGraphStep({ fields: ["id", "items.*"], }).config({ name: "refetch-cart" }) +releaseLockStep({ + key: cart_id, +}) + return new WorkflowResponse({ cart: updatedCarts[0], }) ``` -In the code above, you retrieve the updated cart's details using the `useQueryGraphStep` helper step. To return data from the workflow, you create and return a `WorkflowResponse` instance. It accepts as a parameter the data to return, which is the updated cart. +In the code above, you retrieve the updated cart's details using the `useQueryGraphStep` helper step. Then, you release the lock on the cart with the `releaseLockStep`. + +To return data from the workflow, you create and return a `WorkflowResponse` instance. It accepts as a parameter the data to return, which is the updated cart. In the next step, you'll use the workflow in a custom route to add an item with a custom price to the cart. @@ -59312,7 +60333,7 @@ The widget should display a button that authenticates or redirects the user to t For example, the widget may look like this: -```tsx +```tsx title="src/admin/widgets/custom-login.tsx" import { defineWidgetConfig } from "@medusajs/admin-sdk" import { Button, toast } from "@medusajs/ui" import { decodeToken } from "react-jwt" @@ -61123,7 +62144,7 @@ These steps and workflows are available in Medusa out-of-the-box. So, you can im Create the file `src/workflows/create-checkout-session.ts` with the following content: -```ts title="src/workflows/create-checkout-session.ts" collapsibleLines="1-18" expandButtonLabel="Show Imports" +```ts title="src/workflows/create-checkout-session.ts" collapsibleLines="1-20" expandButtonLabel="Show Imports" import { createWorkflow, transform, @@ -61131,11 +62152,13 @@ import { WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { + acquireLockStep, addShippingMethodToCartWorkflow, createCartWorkflow, CreateCartWorkflowInput, createCustomersWorkflow, listShippingOptionsForCartWithPricingWorkflow, + releaseLockStep, useQueryGraphStep, } from "@medusajs/medusa/core-flows" import { @@ -61376,9 +62399,17 @@ when(input, (input) => !!input.fulfillment_address) }], } }) + acquireLockStep({ + key: createdCart.id, + timeout: 2, + ttl: 10, + }) addShippingMethodToCartWorkflow.runAsStep({ input: shippingMethodData, }) + releaseLockStep({ + key: createdCart.id, + }) }) // TODO prepare checkout session response @@ -61388,7 +62419,9 @@ You use the `when` function to check if a fulfillment address is provided in the - Retrieve the shipping options using the [listShippingOptionsForCartWithPricingWorkflow](https://docs.medusajs.com/references/medusa-workflows/listShippingOptionsForCartWithPricingWorkflow/index.html.md). - Create a variable with the cheapest shipping option using the `transform` function. +- Acquire a lock on the cart to avoid race conditions. - Add the cheapest shipping option to the cart using the [addShippingMethodToCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/addShippingMethodToCartWorkflow/index.html.md). +- Release the lock on the cart. #### Prepare Checkout Session Response @@ -61756,14 +62789,16 @@ The workflow has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart details - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve customer if it exists. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to avoid concurrent modifications. - [updateCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateCartWorkflow/index.html.md): Update the cart with the new data - [prepareCheckoutSessionDataWorkflow](#prepareCheckoutSessionDataWorkflow): Prepare the checkout session response +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release lock on the cart These steps and workflows are available in Medusa out-of-the-box. So, you can implement the workflow without creating custom steps. Create the file `src/workflows/update-checkout-session.ts` with the following content: -```ts title="src/workflows/update-checkout-session.ts" collapsibleLines="1-16" expandButtonLabel="Show Imports" +```ts title="src/workflows/update-checkout-session.ts" collapsibleLines="1-18" expandButtonLabel="Show Imports" import { createWorkflow, transform, @@ -61771,8 +62806,10 @@ import { WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { + acquireLockStep, addShippingMethodToCartWorkflow, createCustomersWorkflow, + releaseLockStep, updateCartWorkflow, useQueryGraphStep, } from "@medusajs/medusa/core-flows" @@ -61904,6 +62941,11 @@ You use the `when` function to check if items are provided in the input. If so, Next, you'll update the cart based on the input. Replace the `TODO` in the workflow with the following: ```ts title="src/workflows/update-checkout-session.ts" +acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, +}) // Prepare update data const updateData = transform({ input, @@ -61939,9 +62981,11 @@ updateCartWorkflow.runAsStep({ // TODO add shipping method if fulfillment option ID is provided ``` -You use the `transform` function to prepare the input for the `updateCartWorkflow` workflow. You map the input properties to the cart properties. +First, you acquire a lock on the cart to avoid concurrent modifications. -Then, you update the cart using the `updateCartWorkflow`. This workflow will also clear the cart's payment sessions. +Then, you use the `transform` function to prepare the input for the `updateCartWorkflow` workflow. You map the input properties to the cart properties. + +After that, you update the cart using the `updateCartWorkflow`. This workflow will also clear the cart's payment sessions. #### Add Shipping Method if Fulfillment Option ID is Provided @@ -61969,12 +63013,18 @@ const responseData = prepareCheckoutSessionDataWorkflow.runAsStep({ }, }) +releaseLockStep({ + key: input.cart_id, +}) + return new WorkflowResponse(responseData) ``` You use the `when` function to check if a fulfillment option ID is provided in the input. If it is, you add it to the cart using the `addShippingMethodToCartWorkflow` workflow. -Then, you prepare the checkout session response using the `prepareCheckoutSessionDataWorkflow` workflow you created earlier. You return it as the workflow's response. +Then, you prepare the checkout session response using the `prepareCheckoutSessionDataWorkflow` workflow you created earlier. + +Finally, you release the lock on the cart and return the prepared response as the workflow's response. ### b. Update Checkout Session API Route @@ -62248,12 +63298,14 @@ You'll also set up the [Stripe Payment Module Provider](https://docs.medusajs.co The workflow that completes a checkout session has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart details +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart These steps and workflows are available in Medusa out-of-the-box. So, you can implement the workflow without creating custom steps. To create the workflow, create the file `src/workflows/complete-checkout-session.ts` with the following content: -```ts title="src/workflows/complete-checkout-session.ts" collapsibleLines="1-19" expandButtonLabel="Show Imports" +```ts title="src/workflows/complete-checkout-session.ts" collapsibleLines="1-21" expandButtonLabel="Show Imports" import { createWorkflow, transform, @@ -62261,10 +63313,12 @@ import { WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { + acquireLockStep, completeCartWorkflow, createPaymentCollectionForCartWorkflow, createPaymentSessionsWorkflow, refreshPaymentCollectionForCartWorkflow, + releaseLockStep, updateCartWorkflow, useQueryGraphStep, } from "@medusajs/medusa/core-flows" @@ -62315,6 +63369,11 @@ export const completeCheckoutSessionWorkflow = createWorkflow( throwIfKeyNotFound: true, }, }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) // TODO update cart with billing address if provided } @@ -62323,7 +63382,7 @@ export const completeCheckoutSessionWorkflow = createWorkflow( The `completeCheckoutSessionWorkflow` accepts an input with the properties received from the AI agent to complete the checkout session. -So far, you retrieve the cart using the `useQueryGraphStep` step. +So far, you retrieve the cart using the `useQueryGraphStep`, and you acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications. #### Update Cart with Billing Address @@ -62503,10 +63562,16 @@ const responseData = transform({ return data.completeCartResponse || data.invalidPaymentResponse }) +releaseLockStep({ + key: input.cart_id, +}) + return new WorkflowResponse(responseData) ``` -You use `transform` to pick either the response from completing the cart or the error response for an invalid payment provider. Then, you return the response. +You use `transform` to pick either the response from completing the cart or the error response for an invalid payment provider. + +Then, you release the lock on the cart using the `releaseLockStep` step, and return the response as the workflow's response. ### b. Complete Checkout Session API Route @@ -62757,8 +63822,10 @@ The workflow that cancels a checkout session has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart details - [validateCartCancelationStep](#validateCartCancelationStep): Validate if the cart can be canceled +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications - [updateCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateCartWorkflow/index.html.md): Update the cart status to canceled - [prepareCheckoutSessionDataWorkflow](#prepareCheckoutSessionDataWorkflow): Prepare the checkout session response +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart You only need to implement the `validateCartCancelationStep` and `cancelPaymentSessionsStep` steps. The other steps and workflows are available in Medusa out-of-the-box. @@ -62890,7 +63957,7 @@ Create the file `src/workflows/cancel-checkout-session.ts` with the following co ```ts title="src/workflows/cancel-checkout-session.ts" collapsibleLines="1-6" expandButtonLabel="Show Imports" import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" import { validateCartCancelationStep, ValidateCartCancelationStepInput } from "./steps/validate-cart-cancelation" -import { updateCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { acquireLockStep, releaseLockStep, updateCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" import { cancelPaymentSessionsStep } from "./steps/cancel-payment-sessions" import { prepareCheckoutSessionDataWorkflow } from "./prepare-checkout-session-data" @@ -62921,6 +63988,12 @@ export const cancelCheckoutSessionWorkflow = createWorkflow( cart: carts[0], } as unknown as ValidateCartCancelationStepInput) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + // TODO cancel payment sessions if any } ) @@ -62928,7 +64001,11 @@ export const cancelCheckoutSessionWorkflow = createWorkflow( The `cancelCheckoutSessionWorkflow` accepts an input with the cart ID of the checkout session to cancel. -So far, you retrieve the cart using the `useQueryGraphStep` step and validate that the cart can be canceled using the `validateCartCancelationStep`. +So far, you: + +1. Retrieve the cart using the `useQueryGraphStep` step. +2. Validate that the cart can be canceled using the `validateCartCancelationStep`. +3. Acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications. Next, you'll cancel the payment sessions if there are any. Replace the `TODO` in the workflow with the following: @@ -62963,7 +64040,7 @@ You use the `when` function to check if the cart has any payment sessions. If so You also update the cart using the `updateCartWorkflow` workflow to add a `checkout_session_canceled` metadata field to the cart. This is useful to detect canceled checkout sessions in the future. -Finally, you'll prepare and return the checkout session response. Replace the `TODO` in the workflow with the following: +Finally, you'll prepare the checkout session response, release the lock, and return the response. Replace the `TODO` in the workflow with the following: ```ts title="src/workflows/cancel-checkout-session.ts" const responseData = prepareCheckoutSessionDataWorkflow.runAsStep({ @@ -62972,10 +64049,14 @@ const responseData = prepareCheckoutSessionDataWorkflow.runAsStep({ }, }) +releaseLockStep({ + key: input.cart_id, +}) + return new WorkflowResponse(responseData) ``` -You prepare the checkout session response using the `prepareCheckoutSessionDataWorkflow` workflow and return it as the workflow's response. +You prepare the checkout session response using the `prepareCheckoutSessionDataWorkflow` workflow, then you release the lock on the cart using the `releaseLockStep`. Finally, you return the response. ### b. Cancel Checkout Session API Route @@ -68942,6 +70023,8 @@ To build this feature, you need a workflow that applies the tier promotion to a The workflow to add a customer's tier promotion to a cart has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of the cart with its customer and tier. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. You only need to create the `validateTierPromotionStep`. Medusa provides the other steps and workflows out-of-the-box. @@ -68998,14 +70081,19 @@ You can now create the workflow that adds a customer's tier promotion to a cart. To create the workflow, create the file `src/workflows/add-tier-promotion-to-cart.ts` with the following content: -```ts title="src/workflows/add-tier-promotion-to-cart.ts" collapsibleLines="1-10" expandButtonLabel="Show Imports" +```ts title="src/workflows/add-tier-promotion-to-cart.ts" collapsibleLines="1-15" expandButtonLabel="Show Imports" import { createWorkflow, WorkflowResponse, transform, when, } from "@medusajs/framework/workflows-sdk" -import { updateCartPromotionsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { + acquireLockStep, + releaseLockStep, + updateCartPromotionsWorkflow, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" import { PromotionActions } from "@medusajs/framework/utils" import { validateTierPromotionStep } from "./steps/validate-tier-promotion" @@ -69038,6 +70126,12 @@ export const addTierPromotionToCartWorkflow = createWorkflow( }, }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + // Check if customer exists and has tier const validationResult = when({ carts }, (data) => !!data.carts[0].customer).then(() => { @@ -69081,6 +70175,10 @@ export const addTierPromotionToCartWorkflow = createWorkflow( }) }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse(void 0) } ) @@ -69091,8 +70189,10 @@ The workflow receives the cart's ID as input. In the workflow, you: - Retrieve the cart details using `useQueryGraphStep`. +- Acquire a lock on the cart using `acquireLockStep`. - Validate that the customer exists and has a tier promotion using `validateTierPromotionStep`. - Update the cart's promotions if the customer has a tier promotion that hasn't been applied yet, using `updateCartPromotionsWorkflow`. +- Release the lock on the cart using `releaseLockStep`. ### b. Cart Updated Subscriber @@ -74573,11 +75673,13 @@ The workflow will have the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's details. - [validateCustomerExistsStep](#validateCustomerExistsStep): Validate that the customer is registered. - [getCartLoyaltyPromoStep](#getCartLoyaltyPromoStep): Retrieve the cart's loyalty promotion. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [getCartLoyaltyPromoAmountStep](#getCartLoyaltyPromoAmountStep): Get the amount to be discounted based on the loyalty points. - [createPromotionsStep](https://docs.medusajs.com/references/medusa-workflows/steps/createPromotionsStep/index.html.md): Create a new loyalty promotion for the cart. - [updateCartPromotionsWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateCartPromotionsWorkflow/index.html.md): Update the cart's promotions with the new loyalty promotion. - [updateCartsStep](https://docs.medusajs.com/references/medusa-workflows/steps/updateCartsStep/index.html.md): Update the cart to store the ID of the loyalty promotion in the metadata. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's details again. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. Most of the workflow's steps are either provided by Medusa in the `@medusajs/medusa/core-flows` package or steps you've already implemented. You only need to implement the `getCartLoyaltyPromoAmountStep` step. @@ -74646,14 +75748,16 @@ You can now create the workflow that applies a loyalty promotion to the cart. To create the workflow, create the file `src/workflows/apply-loyalty-on-cart.ts` with the following content: -```ts title="src/workflows/apply-loyalty-on-cart.ts" highlights={applyLoyaltyOnCartWorkflowHighlights} collapsibleLines="1-24" expandButtonLabel="Show Imports" +```ts title="src/workflows/apply-loyalty-on-cart.ts" highlights={applyLoyaltyOnCartWorkflowHighlights} collapsibleLines="1-26" expandButtonLabel="Show Imports" import { createWorkflow, transform, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { + acquireLockStep, createPromotionsStep, + releaseLockStep, updateCartPromotionsWorkflow, updateCartsStep, useQueryGraphStep, @@ -74710,6 +75814,12 @@ export const applyLoyaltyOnCartWorkflow = createWorkflow( throwErrorOn: "found", }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + const amount = getCartLoyaltyPromoAmountStep({ cart: carts[0], } as unknown as GetCartLoyaltyPromoAmountStepInput) @@ -74726,6 +75836,7 @@ So far, you: - Use `useQueryGraphStep` to retrieve the cart's details. You pass the cart's ID as a filter to retrieve the cart. - Validate that the customer is registered using the `validateCustomerExistsStep`. - Check whether the cart has a loyalty promotion using the `getCartLoyaltyPromoStep`. You pass the `throwErrorOn` parameter with the value `found` to throw an error if a loyalty promotion is found in the cart. +- Acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications. - Retrieve the amount to be discounted based on the loyalty points using the `getCartLoyaltyPromoAmountStep`. Next, you need to create a new loyalty promotion for the cart. First, you'll prepare the data of the promotion to be created. @@ -74832,6 +75943,10 @@ const { data: updatedCarts } = useQueryGraphStep({ filters: { id: input.cart_id }, }).config({ name: "retrieve-cart" }) +releaseLockStep({ + key: input.cart_id, +}) + return new WorkflowResponse(updatedCarts[0]) ``` @@ -74842,6 +75957,7 @@ In the rest of the workflow, you: - Update the cart's promotions with the new loyalty promotion using the `updateCartPromotionsWorkflow` workflow. - Update the cart's metadata with the loyalty promotion ID using the `updateCartsStep`. - Retrieve the cart's details again using `useQueryGraphStep` to get the updated cart with the new loyalty promotion. +- Release the lock on the cart using the `releaseLockStep`. To return data from the workflow, you must return an instance of `WorkflowResponse`. You pass it the data to be returned, which is in this case the cart's details. @@ -74970,10 +76086,12 @@ The workflow will have the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's details. - [getCartLoyaltyPromoStep](#getCartLoyaltyPromoStep): Retrieve the cart's loyalty promotion. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [updateCartPromotionsWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateCartPromotionsWorkflow/index.html.md): Update the cart's promotions to remove the loyalty promotion. - [updateCartsStep](https://docs.medusajs.com/references/medusa-workflows/steps/updateCartsStep/index.html.md): Update the cart to remove the loyalty promotion ID from the metadata. - [updatePromotionsStep](https://docs.medusajs.com/references/medusa-workflows/steps/updatePromotionsStep/index.html.md): Deactivate the loyalty promotion. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's details again. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. Since you already have all the steps, you can create the workflow. @@ -74986,6 +76104,8 @@ import { WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { + acquireLockStep, + releaseLockStep, useQueryGraphStep, updateCartPromotionsWorkflow, updateCartsStep, @@ -75020,6 +76140,9 @@ export const removeLoyaltyFromCartWorkflow = createWorkflow( filters: { id: input.cart_id, }, + options: { + throwIfKeyNotFound: true, + }, }) const loyaltyPromo = getCartLoyaltyPromoStep({ @@ -75027,6 +76150,12 @@ export const removeLoyaltyFromCartWorkflow = createWorkflow( throwErrorOn: "not-found", }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + updateCartPromotionsWorkflow.runAsStep({ input: { cart_id: input.cart_id, @@ -75067,6 +76196,10 @@ export const removeLoyaltyFromCartWorkflow = createWorkflow( filters: { id: input.cart_id }, }).config({ name: "retrieve-cart" }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse(updatedCarts[0]) } ) @@ -75078,11 +76211,13 @@ In the workflow, you: - Use `useQueryGraphStep` to retrieve the cart's details. You pass the cart's ID as a filter to retrieve the cart. - Check whether the cart has a loyalty promotion using the `getCartLoyaltyPromoStep`. You pass the `throwErrorOn` parameter with the value `not-found` to throw an error if a loyalty promotion isn't found in the cart. +- Acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications. - Update the cart's promotions using the `updateCartPromotionsWorkflow`, removing the loyalty promotion. - Use the `transform` function to prepare the new metadata of the cart. You remove the `loyalty_promo_id` from the metadata. - Update the cart's metadata with the new metadata using the `updateCartsStep`. - Deactivate the loyalty promotion using the `updatePromotionsStep`. - Retrieve the cart's details again using `useQueryGraphStep` to get the updated cart with the new loyalty promotion. +- Release the lock on the cart using the `releaseLockStep`. - Return the cart's details in a `WorkflowResponse` instance. ### Create the API Route @@ -78625,10 +79760,13 @@ In this step, you'll wrap custom logic around Medusa's cart completion logic in The workflow that completes a cart with pre-order items has the following steps: +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md): Complete the cart with pre-order items. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve existing preorders of the order for idempotency. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve all line items in the cart. - [retrievePreorderItemIdsStep](#retrievePreorderItemIdsStep): Retrieve the IDs of pre-order variants in the cart. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the created order. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. You only need to implement the `retrievePreorderItemIdsStep` and `createPreordersStep` steps. @@ -78726,10 +79864,22 @@ You can now create the workflow that completes a cart with pre-order items. Create the file `src/workflows/complete-cart-preorder.ts` with the following content: -```ts title="src/workflows/complete-cart-preorder.ts" highlights={completeCartPreorderWorkflowHighlights} -import { createWorkflow, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" -import { completeCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" -import { retrievePreorderItemIdsStep, RetrievePreorderItemIdsStepInput } from "./steps/retrieve-preorder-items" +```ts title="src/workflows/complete-cart-preorder.ts" highlights={completeCartPreorderWorkflowHighlights} collapsibleLines="1-17" expandButtonLabel="Show Imports" +import { + createWorkflow, + when, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + acquireLockStep, + completeCartWorkflow, + useQueryGraphStep, + releaseLockStep, +} from "@medusajs/medusa/core-flows" +import { + retrievePreorderItemIdsStep, + RetrievePreorderItemIdsStepInput, +} from "./steps/retrieve-preorder-items" import { createPreordersStep } from "./steps/create-preorders" type WorkflowInput = { @@ -78739,12 +79889,27 @@ type WorkflowInput = { export const completeCartPreorderWorkflow = createWorkflow( "complete-cart-preorder", (input: WorkflowInput) => { + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) const { id } = completeCartWorkflow.runAsStep({ input: { id: input.cart_id, }, }) + const { data: preorders } = useQueryGraphStep({ + entity: "preorder", + fields: [ + "id", + ], + filters: { + order_id: id, + }, + }) + const { data: line_items } = useQueryGraphStep({ entity: "line_item", fields: [ @@ -78754,15 +79919,16 @@ export const completeCartPreorderWorkflow = createWorkflow( filters: { cart_id: input.cart_id, }, - }) + }).config({ name: "retrieve-line-items" }) const preorderItemIds = retrievePreorderItemIdsStep({ line_items, } as unknown as RetrievePreorderItemIdsStepInput) when({ + preorders, preorderItemIds, - }, (data) => data.preorderItemIds.length > 0) + }, (data) => data.preorders.length === 0 && data.preorderItemIds.length > 0) .then(() => { createPreordersStep({ preorder_variant_ids: preorderItemIds, @@ -78787,6 +79953,10 @@ export const completeCartPreorderWorkflow = createWorkflow( }, }).config({ name: "retrieve-order" }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse({ order: orders[0], @@ -78799,13 +79969,17 @@ The workflow receives the cart ID as input. In the workflow, you: -1. Complete the cart using the [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md) as a step. This is Medusa's cart completion logic. -2. Retrieve all line items in the cart using the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md). -3. Retrieve the IDs of the pre-order variants in the cart using the `retrievePreorderItemIdsStep`. -4. Use [when-then](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) to check if there are pre-order items in the cart. - - If so, you create `Preorder` records for the pre-order items using the `createPreordersStep`. -5. Retrieve the created order using the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md). -6. Return the created Medusa order. +1. Acquire a lock on the cart using the [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md) to prevent concurrent modifications. +2. Complete the cart using the [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md) as a step. This is Medusa's cart completion logic. +3. Retrieve existing pre-orders of the created order using the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md). + - This is essential for idempotency, ensuring that pre-orders are not created multiple times if the workflow is retried. +4. Retrieve all line items in the cart using the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md). +5. Retrieve the IDs of the pre-order variants in the cart using the `retrievePreorderItemIdsStep`. +6. Use [when-then](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) to check if there are no existing pre-orders and if there are pre-order items in the cart. + - If the condition is met, you create `Preorder` records for the pre-order items using the `createPreordersStep`. +7. Retrieve the created order using the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md). +8. Release the lock on the cart using the [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md). +9. Return the created Medusa order. ### b. Create Complete Pre-order Cart API Route @@ -84402,9 +85576,11 @@ The workflow will validate the builder configurations, add the main product vari The workflow will have the following steps: - [validateProductBuilderConfigurationStep](#validateProductBuilderConfigurationStep): Validates the product builder configuration +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquires a lock on the cart to prevent concurrent modifications. - [addToCartWorkflow](#addToCartWorkflow): Adds the product to the cart. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get cart with items details. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get updated cart details. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Releases the lock on the cart. You only need to implement the `validateProductBuilderConfigurationStep`, as Medusa provides the rest. @@ -84553,15 +85729,23 @@ You can now implement the workflow that adds products with builder configuration Create the file `src/workflows/add-product-builder-to-cart.ts` with the following content: -```ts title="src/workflows/add-product-builder-to-cart.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" highlights={addToCartWorkflowHighlights} +```ts title="src/workflows/add-product-builder-to-cart.ts" collapsibleLines="1-17" expandButtonLabel="Show Imports" highlights={addToCartWorkflowHighlights} import { createWorkflow, WorkflowResponse, transform, when, } from "@medusajs/framework/workflows-sdk" -import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" -import { validateProductBuilderConfigurationStep } from "./steps/validate-product-builder-configuration" +import { + addToCartWorkflow, + updateLineItemInCartWorkflow, + useQueryGraphStep, + acquireLockStep, + releaseLockStep, +} from "@medusajs/medusa/core-flows" +import { + validateProductBuilderConfigurationStep, +} from "./steps/validate-product-builder-configuration" type AddProductBuilderToCartInput = { cart_id: string @@ -84584,6 +85768,12 @@ export const addProductBuilderToCartWorkflow = createWorkflow( addon_variants: input.addon_variants, }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + // TODO add main, complementary, and addon product variants to the cart } ) @@ -84591,7 +85781,7 @@ export const addProductBuilderToCartWorkflow = createWorkflow( The workflow accepts the cart, product, variant, and builder configuration information as input. -So far, you only validate the product builder configuration using the step you created earlier. If the validation fails, the workflow will stop executing. +So far, you validate the product builder configuration using the step you created earlier. If the validation fails, the workflow will stop executing. You also acquire a lock on the cart to prevent concurrent modifications. Next, you need to add the main product variant to the cart. Replace the `TODO` with the following: @@ -84749,12 +85939,16 @@ const { data: updatedCart } = useQueryGraphStep({ }, }).config({ name: "get-final-cart" }) +releaseLockStep({ + key: input.cart_id, +}) + return new WorkflowResponse({ cart: updatedCart[0], }) ``` -You retrieve the final cart details after all items have been added, and you return the updated cart. +You retrieve the final cart details after all items have been added. Then, you release the lock on the cart and return the updated cart in the workflow response. ### b. Create API Route @@ -85669,20 +86863,27 @@ You'll create a workflow, use that workflow in an API route, then customize the The workflow to remove a product with builder configurations from the cart has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve cart details. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [deleteLineItemsWorkflow](https://docs.medusajs.com/references/medusa-workflows/deleteLineItemsWorkflow/index.html.md): Delete line items from the cart. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the updated cart details. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. Medusa provides all of these steps, so you can create the workflow without needing to implement any custom steps. Create the file `src/workflows/remove-product-builder-from-cart.ts` with the following content: -```ts title="src/workflows/remove-product-builder-from-cart.ts" highlights={removeProductBuilderFromCartWorkflowHighlights} +```ts title="src/workflows/remove-product-builder-from-cart.ts" highlights={removeProductBuilderFromCartWorkflowHighlights} collapsibleLines="1-12" expandButtonLabel="Show Imports" import { createWorkflow, WorkflowResponse, transform, } from "@medusajs/framework/workflows-sdk" -import { deleteLineItemsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { + deleteLineItemsWorkflow, + useQueryGraphStep, + acquireLockStep, + releaseLockStep, +} from "@medusajs/medusa/core-flows" type RemoveProductBuilderFromCartInput = { cart_id: string @@ -85695,7 +86896,7 @@ export const removeProductBuilderFromCartWorkflow = createWorkflow( // Step 1: Get current cart with all items const { data: carts } = useQueryGraphStep({ entity: "cart", - fields: ["*", "items.*", "items.metadata"], + fields: ["*", "items.*"], filters: { id: input.cart_id, }, @@ -85704,18 +86905,21 @@ export const removeProductBuilderFromCartWorkflow = createWorkflow( }, }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + // Step 2: Remove line item and its addons const itemsToRemove = transform({ input, - carts, + currentCart: carts, }, (data) => { - const cart = data.carts[0] - const targetLineItem = cart.items.find( - (item: any) => item.id === data.input.line_item_id - ) + const cart = data.currentCart[0] + const targetLineItem = cart.items.find((item: any) => item.id === data.input.line_item_id) const lineItemIdsToRemove = [data.input.line_item_id] - const isBuilderItem = - targetLineItem?.metadata?.is_builder_main_product === true + const isBuilderItem = targetLineItem?.metadata?.is_builder_main_product === true if (targetLineItem && isBuilderItem) { // Find all related addon items @@ -85752,6 +86956,10 @@ export const removeProductBuilderFromCartWorkflow = createWorkflow( }, }).config({ name: "get-updated-cart" }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse({ cart: updatedCart[0], }) @@ -85763,10 +86971,12 @@ This workflow receives the IDs of the cart and the line item to remove. In the workflow, you: +- Acquire a lock on the cart to prevent concurrent modifications. - Retrieve the cart details with its items. - Prepare the line items to remove by identifying the main product and its related addons. - Remove the line items from the cart. - Retrieve the updated cart details. +- Release the lock on the cart. You return the cart details in the response. @@ -88944,8 +90154,10 @@ The workflow will have the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve cart details. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve details of the variant to add to the cart. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [addToCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/addToCartWorkflow/index.html.md): Add the product to the cart. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve updated cart details. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. Medusa provides all the steps and workflows out-of-the-box, except for the `validateRentalCartItemStep` step, which you'll implement. @@ -89159,16 +90371,24 @@ You can now create the `addToCartWithRentalWorkflow` that uses the `validateRent Create the file `src/workflows/add-to-cart-with-rental.ts` with the following content: -```ts title="src/workflows/add-to-cart-with-rental.ts" badgeLabel="Medusa Application" badgeColor="green" collapsibleLines="1-10" expandButtonLabel="Show Imports" +```ts title="src/workflows/add-to-cart-with-rental.ts" badgeLabel="Medusa Application" badgeColor="green" collapsibleLines="1-18" expandButtonLabel="Show Imports" import { createWorkflow, WorkflowResponse, transform, when, } from "@medusajs/framework/workflows-sdk" -import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { + acquireLockStep, + addToCartWorkflow, + releaseLockStep, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" import { QueryContext } from "@medusajs/framework/utils" -import { ValidateRentalCartItemInput, validateRentalCartItemStep } from "./steps/validate-rental-cart-item" +import { + ValidateRentalCartItemInput, + validateRentalCartItemStep, +} from "./steps/validate-rental-cart-item" type AddToCartWorkflowInput = { cart_id: string @@ -89187,7 +90407,7 @@ export const addToCartWithRentalWorkflow = createWorkflow( options: { throwIfKeyNotFound: true, }, - }).config({ name: "retrieve-cart" }) + }) const { data: variants } = useQueryGraphStep({ entity: "product_variant", @@ -89223,6 +90443,12 @@ export const addToCartWithRentalWorkflow = createWorkflow( } as unknown as ValidateRentalCartItemInput) }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + const itemToAdd = transform({ input, rentalData, @@ -89261,6 +90487,10 @@ export const addToCartWithRentalWorkflow = createWorkflow( }, }).config({ name: "refetch-cart" }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse({ cart: updatedCart[0], }) @@ -89275,11 +90505,15 @@ In the workflow, you: 1. Retrieve the cart details using the `useQueryGraphStep`. 2. Retrieve the product variant details using the `useQueryGraphStep`. 3. If the product is rentable, call the `validateRentalCartItemStep` to validate and retrieve rental data. -4. Prepare the item to add to the cart. +4. Acquire a lock on the cart using the `acquireLockStep`. +5. Prepare the item to add to the cart. - If it's a rentable product, you set the `unit_price` to the calculated rental price. - For non-rentable products, you don't specify the `unit_price`; Medusa will use the variant's price. -5. Add the item to the cart using the existing `addToCartWorkflow`. -6. Retrieve the updated cart details and return them in the workflow response. +6. Add the item to the cart using the existing `addToCartWorkflow`. +7. Retrieve the updated cart details. +8. Release the lock on the cart using the `releaseLockStep`. + +Finally, you return the updated cart in the workflow response. ### b. Add to Cart with Rental API Route @@ -89488,10 +90722,9 @@ The workflow will have the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve cart details. - [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent race conditions. -- [validateRentalStep](#validateRentalStep): Validate rental items in the cart. - [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md): Complete the cart and create the order. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve order details. -- [createRentalsForOrderStep](#createRentalsForOrderStep): Create rental records for rental items in the order. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve existing rentals for the order to ensure idempotency. - [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. You'll implement the `validateRentalStep` and `createRentalsStep` steps used in the workflow. The rest are provided by Medusa out-of-the-box. @@ -89511,6 +90744,7 @@ import { InferTypeOf } from "@medusajs/framework/types" import { RentalConfiguration } from "../../modules/rental/models/rental-configuration" import hasCartOverlap from "../../utils/has-cart-overlap" import validateRentalDates from "../../utils/validate-rental-dates" +import { cancelOrderWorkflow } from "@medusajs/medusa/core-flows" export type ValidateRentalInput = { rental_items: { @@ -89521,12 +90755,13 @@ export type ValidateRentalInput = { rental_start_date: Date rental_end_date: Date rental_days: number + order_id: string }[] } export const validateRentalStep = createStep( "validate-rental", - async ({ rental_items }: ValidateRentalInput, { container }) => { + async ({ rental_items, order_id }: ValidateRentalInput, { container }) => { const rentalModuleService: RentalModuleService = container.resolve(RENTAL_MODULE) for (let i = 0; i < rental_items.length; i++) { @@ -89611,7 +90846,18 @@ export const validateRentalStep = createStep( } } - return new StepResponse({ validated: true }) + return new StepResponse({ validated: true, order_id }) + }, + async (order_id, { container, context }) => { + if (!order_id) {return} + + cancelOrderWorkflow(container).run({ + input: { + order_id, + }, + context, + container, + }) } ) ``` @@ -89626,6 +90872,8 @@ You also check for overlaps between rental items in the cart and existing rental If any validation fails, you throw an appropriate error. If all validations pass, you return a `StepResponse` indicating success. +You also provide a compensation function that cancels the order if the validation fails after the order has been created. + #### createRentalsForOrderStep The `createRentalsForOrderStep` creates rental records for rental items in the order after it has been created. @@ -89707,17 +90955,18 @@ You can now create the `createRentalsWorkflow` that uses the above steps. Create the file `src/workflows/create-rentals.ts` with the following content: -```ts title="src/workflows/create-rentals.ts" badgeLabel="Medusa Application" badgeColor="green" collapsibleLines="1-18" expandButtonLabel="Show Imports" +```ts title="src/workflows/create-rentals.ts" badgeLabel="Medusa Application" badgeColor="green" collapsibleLines="1-21" expandButtonLabel="Show Imports" import { createWorkflow, WorkflowResponse, transform, + when, } from "@medusajs/framework/workflows-sdk" import { - completeCartWorkflow, - useQueryGraphStep, acquireLockStep, - releaseLockStep, + completeCartWorkflow, + releaseLockStep, + useQueryGraphStep, } from "@medusajs/medusa/core-flows" import { ValidateRentalInput, @@ -89779,18 +91028,12 @@ export const createRentalsWorkflow = createWorkflow( return rentalItemsList }) - const lockKey = transform({ - cart_id, - }, (data) => `cart_rentals_creation_${data.cart_id}`) - acquireLockStep({ - key: lockKey, + key: cart_id, + timeout: 2, + ttl: 10, }) - validateRentalStep({ - rental_items: rentalItems, - } as unknown as ValidateRentalInput) - const order = completeCartWorkflow.runAsStep({ input: { id: cart_id }, }) @@ -89809,12 +91052,30 @@ export const createRentalsWorkflow = createWorkflow( options: { throwIfKeyNotFound: true }, }).config({ name: "retrieve-order" }) - createRentalsForOrderStep({ - order: orders[0], - } as unknown as CreateRentalsForOrderInput) + const { data: rentals } = useQueryGraphStep({ + entity: "rental", + fields: [ + "id", + ], + filters: { order_id: order.id }, + }).config({ name: "retrieve-rentals" }) + + when( + { rentals, rentalItems }, + (data) => data.rentals.length === 0 && data.rentalItems.length > 0 + ) + .then(() => { + validateRentalStep({ + rental_items: rentalItems, + order_id: order.id, + } as unknown as ValidateRentalInput) + createRentalsForOrderStep({ + order: orders[0], + } as unknown as CreateRentalsForOrderInput) + }) releaseLockStep({ - key: lockKey, + key: cart_id, }) // @ts-ignore @@ -89832,10 +91093,13 @@ In the workflow, you: 1. Retrieve the cart details using the `useQueryGraphStep`. 2. Extract the rental items from the cart. 3. Acquire a lock on the cart to prevent race conditions. -4. Validate the rental items using the `validateRentalStep`. -5. Complete the cart and create the order using the existing `completeCartWorkflow`. -6. Retrieve the created order details using the `useQueryGraphStep`. -7. Create rental records for the rental items in the order using the `createRentalsForOrderStep`. +4. Complete the cart and create the order using the existing `completeCartWorkflow`. +5. Retrieve the created order details using the `useQueryGraphStep`. +6. Retrieve existing rentals for the order to ensure idempotency. + - This is essential to avoid creating duplicate rentals if the workflow is retried. +7. Perform a condition with `when` to check that there are no existing rentals for the order and there are rental items in the cart. If the condition is met, you: + 1. Validate the rental items in the cart using the `validateRentalStep`. + 2. Create rental records for the rental items in the order using the `createRentalsForOrderStep`. 8. Release the lock on the cart. 9. Return the created order in the workflow response. @@ -114152,10 +115416,10 @@ import { createOptionsInStrapiStep } from "./steps/create-options-in-strapi" import { useQueryGraphStep } from "@medusajs/medusa/core-flows" import { CreateOptionValuesInStrapiInput, - createOptionValuesInStrapiStep + createOptionValuesInStrapiStep, } from "./steps/create-option-values-in-strapi" import { - updateProductOptionValuesMetadataStep + updateProductOptionValuesMetadataStep, } from "./steps/update-product-option-values-metadata" export type CreateOptionsInStrapiWorkflowInput = { @@ -114572,7 +115836,7 @@ export const createVariantsInStrapiWorkflow = createWorkflow( }) const strapiVariants = when({ - variants + variants, }, (data) => !!(data.variants[0].product as any)?.strapi_product) .then(() => { const variantImages = transform({ @@ -114609,7 +115873,7 @@ export const createVariantsInStrapiWorkflow = createWorkflow( const variantsData = transform({ variants, strapiVariantImages, - strapiVariantThumbnail + strapiVariantThumbnail, }, (data) => { const varData = data.variants.map((variant) => ({ id: variant.id, @@ -115231,7 +116495,7 @@ export const handleStrapiWebhookWorkflow = createWorkflow( const variants = updateProductVariantsWorkflow.runAsStep({ input: { product_variants: [ - preparedData.data as unknown as UpsertProductVariantDTO + preparedData.data as unknown as UpsertProductVariantDTO, ], }, }) @@ -115280,7 +116544,7 @@ export const handleStrapiWebhookWorkflow = createWorkflow( // Clear the product cache for all affected products const productIds = transform({ variants }, (data) => { const uniqueProductIds = [ - ...new Set(data.variants.map((v) => v.product_id)) + ...new Set(data.variants.map((v) => v.product_id)), ] return uniqueProductIds as string[] }) @@ -116090,7 +117354,7 @@ Then, find the `Text` component wrapping the `{product.description}` and replace > @@ -118578,6 +119842,7 @@ To learn more about the commerce features that Medusa provides, check out Medusa - [retrieve](https://docs.medusajs.com/references/js_sdk/admin/Order/methods/js_sdk.admin.Order.retrieve/index.html.md) - [retrievePreview](https://docs.medusajs.com/references/js_sdk/admin/Order/methods/js_sdk.admin.Order.retrievePreview/index.html.md) - [update](https://docs.medusajs.com/references/js_sdk/admin/Order/methods/js_sdk.admin.Order.update/index.html.md) +- [updateOrderChange](https://docs.medusajs.com/references/js_sdk/admin/Order/methods/js_sdk.admin.Order.updateOrderChange/index.html.md) - [addItems](https://docs.medusajs.com/references/js_sdk/admin/OrderEdit/methods/js_sdk.admin.OrderEdit.addItems/index.html.md) - [cancelRequest](https://docs.medusajs.com/references/js_sdk/admin/OrderEdit/methods/js_sdk.admin.OrderEdit.cancelRequest/index.html.md) - [confirm](https://docs.medusajs.com/references/js_sdk/admin/OrderEdit/methods/js_sdk.admin.OrderEdit.confirm/index.html.md) @@ -124905,6 +126170,7 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [POST /admin/invites/{id}/resend](https://docs.medusajs.com/api/admin#invites_postinvitesidresend) - [GET /admin/notifications](https://docs.medusajs.com/api/admin#notifications_getnotifications) - [GET /admin/notifications/{id}](https://docs.medusajs.com/api/admin#notifications_getnotificationsid) +- [POST /admin/order-changes/{id}](https://docs.medusajs.com/api/admin#order-changes_postorderchangesid) - [POST /admin/order-edits](https://docs.medusajs.com/api/admin#order-edits_postorderedits) - [DELETE /admin/order-edits/{id}](https://docs.medusajs.com/api/admin#order-edits_deleteordereditsid) - [POST /admin/order-edits/{id}/confirm](https://docs.medusajs.com/api/admin#order-edits_postordereditsidconfirm) @@ -134902,8 +136168,10 @@ The add-to-cart workflow for bundled products has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of a bundle, its items, and their products and variants. - [prepareBundleCartDataStep](#prepareBundleCartDataStep): Validate and prepare the items to be added to the cart. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications - [addToCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/addToCartWorkflow/index.html.md): Add the items in the bundle to the cart. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of the cart. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. You only need to implement the second step, as the other steps are provided by Medusa's `@medusajs/medusa/core-flows` package. @@ -135008,14 +136276,16 @@ You can now create the workflow with the custom add-to-cart logic. To create the workflow, create the file `src/workflows/add-bundle-to-cart.ts` with the following content: -```ts title="src/workflows/add-bundle-to-cart.ts" highlights={addBundleToCartWorkflowHighlights} collapsibleLines="1-14" expandButtonLabel="Show Imports" +```ts title="src/workflows/add-bundle-to-cart.ts" highlights={addBundleToCartWorkflowHighlights} collapsibleLines="1-16" expandButtonLabel="Show Imports" import { createWorkflow, transform, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { - addToCartWorkflow, + acquireLockStep, + addToCartWorkflow, + releaseLockStep, useQueryGraphStep, } from "@medusajs/medusa/core-flows" import { @@ -135058,6 +136328,12 @@ export const addBundleToCartWorkflow = createWorkflow( items, } as unknown as PrepareBundleCartDataStepInput) + acquireLockStep({ + key: cart_id, + timeout: 2, + ttl: 10, + }) + addToCartWorkflow.runAsStep({ input: { cart_id, @@ -135071,6 +136347,10 @@ export const addBundleToCartWorkflow = createWorkflow( fields: ["id", "items.*"], }).config({ name: "refetch-cart" }) + releaseLockStep({ + key: cart_id, + }) + return new WorkflowResponse(updatedCarts[0]) } ) @@ -135082,8 +136362,10 @@ In the workflow, you: - Retrieve the bundle, its items, and their products and variants using the `useQueryGraphStep`. - Validate and prepare the items to be added to the cart using the `prepareBundleCartDataStep`. +- Acquire a lock on the cart using the `acquireLockStep`. - Add the items to the cart using the `addToCartWorkflow`. - Retrieve the updated cart using the `useQueryGraphStep`. +- Release the lock on the cart using the `releaseLockStep`. Finally, you return the updated cart. @@ -135832,21 +137114,25 @@ You'll start by creating a workflow that implements the logic to remove a bundle The workflow has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of the cart and its items. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [deleteLineItemsWorkflow](https://docs.medusajs.com/references/medusa-workflows/deleteLineItemsWorkflow/index.html.md): Remove the items in the bundle from the cart. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the updated cart. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. Medusa provides all these steps and workflows in its `@medusajs/medusa/core-flows` package. So, you can create the workflow right away. Create the file `src/workflows/remove-bundle-from-cart.ts` with the following content: -```ts title="src/workflows/remove-bundle-from-cart.ts" collapsibleLines="1-10" expandButtonLabel="Show Imports" highlights={removeBundleFromCartWorkflowHighlights} +```ts title="src/workflows/remove-bundle-from-cart.ts" collapsibleLines="1-12" expandButtonLabel="Show Imports" highlights={removeBundleFromCartWorkflowHighlights} import { createWorkflow, transform, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { + acquireLockStep, deleteLineItemsWorkflow, + releaseLockStep, useQueryGraphStep, } from "@medusajs/medusa/core-flows" @@ -135881,6 +137167,12 @@ export const removeBundleFromCartWorkflow = createWorkflow( }).map((item) => item!.id) }) + acquireLockStep({ + key: cart_id, + timeout: 2, + ttl: 10, + }) + deleteLineItemsWorkflow.runAsStep({ input: { cart_id, @@ -135900,6 +137192,10 @@ export const removeBundleFromCartWorkflow = createWorkflow( }, }).config({ name: "retrieve-cart" }) + releaseLockStep({ + key: cart_id, + }) + return new WorkflowResponse(updatedCarts[0]) } ) @@ -135911,8 +137207,10 @@ In the workflow, you: - Retrieve the cart and its items using the `useQueryGraphStep`. - Use `transform` to filter the items in the cart and return only the IDs of the items that belong to the bundle. +- Acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications. - Remove the items from the cart using the `deleteLineItemsWorkflow`. - Retrieve the updated cart using the `useQueryGraphStep`. +- Release the lock on the cart using the `releaseLockStep`. Finally, you return the updated cart. @@ -139387,6 +140685,8 @@ import { createRemoteLinkStep, createOrderFulfillmentWorkflow, emitEventStep, + acquireLockStep, + releaseLockStep, } from "@medusajs/medusa/core-flows" import { Modules, @@ -139395,6 +140695,7 @@ import createDigitalProductOrderStep, { CreateDigitalProductOrderStepInput, } from "./steps/create-digital-product-order" import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product" +import digitalProductOrderOrderLink from "../../links/digital-product-order" type WorkflowInput = { cart_id: string @@ -139403,6 +140704,11 @@ type WorkflowInput = { const createDigitalProductOrderWorkflow = createWorkflow( "create-digital-product-order", (input: WorkflowInput) => { + acquireLockStep({ + key: input.cart_id, + timeout: 30, + ttl: 120, + }) const { id } = completeCartWorkflow.runAsStep({ input: { id: input.cart_id, @@ -139426,19 +140732,31 @@ const createDigitalProductOrderWorkflow = createWorkflow( }, }) - const itemsWithDigitalProducts = transform({ - orders, - }, - (data) => { - return data.orders[0].items?.filter((item) => item?.variant?.digital_product !== undefined) + const { data: existingLinks } = useQueryGraphStep({ + entity: digitalProductOrderOrderLink.entryPoint, + fields: ["digital_product_order.id"], + filters: { order_id: id }, + }).config({ name: "retrieve-existing-links" }) + + const itemsWithDigitalProducts = transform( + { + orders, + }, + (data) => { + return data.orders[0].items?.filter( + (item) => item?.variant?.digital_product !== undefined + ) } ) const digital_product_order = when( - "create-digital-product-order-condition", - itemsWithDigitalProducts, - (itemsWithDigitalProducts) => { - return !!itemsWithDigitalProducts?.length + "create-digital-product-order-condition", + { itemsWithDigitalProducts, existingLinks }, + (data) => { + return ( + !!data.itemsWithDigitalProducts?.length && + data.existingLinks.length === 0 + ) } ).then(() => { const { @@ -139480,6 +140798,10 @@ const createDigitalProductOrderWorkflow = createWorkflow( return digital_product_order }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse({ order: orders[0], digital_product_order, @@ -139492,13 +140814,18 @@ export default createDigitalProductOrderWorkflow This creates the workflow `createDigitalProductOrderWorkflow`. It runs the following steps: -1. `completeCartWorkflow` as a step to create the Medusa order. -2. `useQueryGraphStep` to retrieve the order’s items with their associated variants and linked digital products. -3. Use `when` to check whether the order has digital products. If so: +1. `acquireLockStep` to acquire a lock on the cart ID. +2. `completeCartWorkflow` as a step to create the Medusa order. +3. `useQueryGraphStep` to retrieve the order’s items with their associated variants and linked digital products. +4. `useQueryGraphStep` to retrieve any existing links between the digital product order and the Medusa order. + - This is necessary to ensure the workflow is idempotent, meaning that if the workflow is executed multiple times for the same cart, it doesn’t create multiple digital product orders. +5. Use `transform` to filter the order’s items to only those having digital products. +6. Use `when` to check whether the order has digital products and no existing links. If so: 1. Use the `createDigitalProductOrderStep` to create the digital product order. 2. Use the `createRemoteLinkStep` to link the digital product order to the Medusa order. 3. Use the `createOrderFulfillmentWorkflow` to create a fulfillment for the digital products in the order. 4. Use the `emitEventStep` to emit a custom event. +7. `releaseLockStep` to release the lock on the cart ID. The workflow returns the Medusa order and the digital product order, if created. @@ -146673,13 +148000,13 @@ Make sure to replace `{token}` with the authenticated token of the vendor admin In this step, you’ll create a workflow that’s executed when the customer places an order. It has the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's details. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to avoid concurrent modifications. - [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md): Create the parent order from the cart. -- [groupVendorItemsStep](#groupvendoritemssstep): Group the items by their vendor. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve existing links between the order and variant to ensure idempotency. - [getOrderDetailWorkflow](https://docs.medusajs.com/references/medusa-workflows/getOrderDetailWorkflow/index.html.md): Retrieve the parent order's details. -- [createVendorOrdersStep](#createvendorordersstep): Create child orders for each vendor. -- [createRemoteLinkStep](https://docs.medusajs.com/references/helper-steps/createRemoteLinkStep/index.html.md): Create links between vendors and orders. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. -You only need to implement the third and fourth steps, as Medusa provides the rest of the steps in its `@medusajs/medusa/core-flows` package. +You only need to implement the `groupVendorItemsStep` and `createVendorOrdersStep` steps, as Medusa provides the rest of the steps in its `@medusajs/medusa/core-flows` package. ### groupVendorItemsStep @@ -146975,9 +148302,10 @@ Now that you have all the necessary steps, you can create the workflow. Create the workflow at the file `src/workflows/marketplace/create-vendor-orders/index.ts`: -```ts title="src/workflows/marketplace/create-vendor-orders/index.ts" collapsibleLines="1-13" expandMoreLabel="Show Imports" +```ts title="src/workflows/marketplace/create-vendor-orders/index.ts" collapsibleLines="1-17" expandMoreLabel="Show Imports" import { createWorkflow, + when, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { @@ -146985,9 +148313,12 @@ import { createRemoteLinkStep, completeCartWorkflow, getOrderDetailWorkflow, + acquireLockStep, + releaseLockStep, } from "@medusajs/medusa/core-flows" -import groupVendorItemsStep from "./steps/group-vendor-items" +import groupVendorItemsStep, { GroupVendorItemsStepInput } from "./steps/group-vendor-items" import createVendorOrdersStep from "./steps/create-vendor-orders" +import vendorOrderLink from "../../../links/vendor-order" type WorkflowInput = { cart_id: string @@ -147005,16 +148336,24 @@ const createVendorOrdersWorkflow = createWorkflow( }, }) + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) + const { id: orderId } = completeCartWorkflow.runAsStep({ input: { id: carts[0].id, }, }) - const { vendorsItems } = groupVendorItemsStep({ - cart: carts[0], - } as unknown as GroupVendorItemsStepInput) - + const { data: existingLinks } = useQueryGraphStep({ + entity: vendorOrderLink.entryPoint, + fields: ["vendor.id"], + filters: { order_id: orderId }, + }).config({ name: "retrieve-existing-links" }) + const order = getOrderDetailWorkflow.runAsStep({ input: { order_id: orderId, @@ -147033,19 +148372,36 @@ const createVendorOrdersWorkflow = createWorkflow( }, }) - const { - orders: vendorOrders, - linkDefs, - } = createVendorOrdersStep({ - parentOrder: order, - vendorsItems, + const vendorOrders = when( + "create-vendor-order-links", + { existingLinks }, + (data) => data.existingLinks.length === 0 + ).then(() => { + + const { vendorsItems } = groupVendorItemsStep({ + cart: carts[0], + } as unknown as GroupVendorItemsStepInput) + + const { + orders: vendorOrders, + linkDefs, + } = createVendorOrdersStep({ + parentOrder: order, + vendorsItems, + }) + + createRemoteLinkStep(linkDefs) + + return vendorOrders }) - createRemoteLinkStep(linkDefs) + releaseLockStep({ + key: input.cart_id, + }) return new WorkflowResponse({ - parent_order: order, - vendor_orders: vendorOrders, + order, + vendorOrders, }) } ) @@ -147056,11 +148412,16 @@ export default createVendorOrdersWorkflow The workflow receives the cart's ID as an input. In the workflow, you run the following steps: 1. `useQueryGraphStep` to retrieve the cart's details. -2. `completeCartWorkflow` to complete the cart and create a parent order. -3. `groupVendorItemsStep` to group the order's items by their vendor. -4. `getOrderDetailWorkflow` to retrieve the parent order's details. -5. `createVendorOrdersStep` to create child orders for each vendor's items. -6. `createRemoteLinkStep` to create the links returned by the previous step. +2. `acquireLockStep` to acquire a lock on the cart. +3. `completeCartWorkflow` to complete the cart and create a parent order. +4. `useQueryGraphStep` to retrieve existing links between the order and variant to ensure idempotency. + - This is essential, as the workflow might be executed multiple times for the same cart. So, if links already exist, you skip creating vendor orders again. +5. `getOrderDetailWorkflow` to retrieve the parent order's details. +6. Perform a condition with `when` to check if there are existing links. If not, you run the following steps: + - `groupVendorItemsStep` to group the items by their vendor. + - `createVendorOrdersStep` to create child orders for each vendor. + - `createRemoteLinkStep` to create the links returned by the previous step. +7. `releaseLockStep` to release the lock on the cart. You return the parent and vendor orders. @@ -148208,8 +149569,10 @@ The custom add-to-cart workflow will have the following steps: - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get the cart's region. - [getCustomPriceWorkflow](#getCustomPriceWorkflow): Get the custom price for the product variant. +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications. - [addToCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/addToCartWorkflow/index.html.md): Add the product variant to the cart with the custom price. - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get the cart's updated data. +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart. You already have all the necessary steps within the workflow, so you create it right away. @@ -148217,7 +149580,12 @@ Create the file `src/workflows/custom-add-to-cart.ts` with the following content ```ts title="src/workflows/custom-add-to-cart.ts" highlights={customAddToCartWorkflowHighlights} import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" -import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { + addToCartWorkflow, + acquireLockStep, + releaseLockStep, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" import { getCustomPriceWorkflow } from "./get-custom-price" type CustomAddToCartWorkflowInput = { @@ -148249,6 +149617,12 @@ export const customAddToCartWorkflow = createWorkflow( metadata: input.item.metadata, }, }) + + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) const itemData = transform({ item: input.item, @@ -148278,6 +149652,10 @@ export const customAddToCartWorkflow = createWorkflow( }, }).config({ name: "refetch-cart" }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse({ cart: updatedCart[0], }) @@ -148291,10 +149669,12 @@ In the workflow, you: 1. Retrieve the cart's region using the `useQueryGraphStep`. 2. Calculate the custom price using the `getCustomPriceWorkflow` that you created earlier. -3. Prepare the data of the item to add to the cart. +3. Acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications. +4. Prepare the data of the item to add to the cart. - You use the `transform` function because direct data manipulation isn't allowed in workflows. Refer to the [Data Manipulation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/variable-manipulation/index.html.md) guide to learn more. -4. Add the item to the cart using the `addToCartWorkflow`. -5. Refetch the updated cart using the `useQueryGraphStep` to return the cart data in the workflow's response. +5. Add the item to the cart using the `addToCartWorkflow`. +6. Refetch the updated cart using the `useQueryGraphStep` to return the cart data in the workflow's response. +7. Release the lock on the cart using the `releaseLockStep`. ### b. Create the Add-to-Cart API Route @@ -149493,6 +150873,9 @@ Create the file `src/workflows/create-subscription/index.ts` with the following import { createWorkflow, WorkflowResponse, + useQueryGraphStep, + acquireLockStep, + releaseLockStep, } from "@medusajs/framework/workflows-sdk" import { createRemoteLinkStep, @@ -149515,6 +150898,11 @@ type WorkflowInput = { const createSubscriptionWorkflow = createWorkflow( "create-subscription", (input: WorkflowInput) => { + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) const { id } = completeCartWorkflow.runAsStep({ input: { id: input.cart_id, @@ -149576,14 +150964,34 @@ const createSubscriptionWorkflow = createWorkflow( }, }) - const { subscription, linkDefs } = createSubscriptionStep({ - cart_id: input.cart_id, - order_id: orders[0].id, - customer_id: orders[0].customer_id!, - subscription_data: input.subscription_data, + const { data: existingLinks } = useQueryGraphStep({ + entity: subscriptionOrderLink.entryPoint, + fields: ["subscription.id"], + filters: { order_id: orders[0].id }, + }).config({ name: "retrieve-existing-links" }) + + const subscription = when( + "create-subscription-condition", + { existingLinks }, + (data) => data.existingLinks.length === 0 + ) + .then(() => { + + const { subscription, linkDefs } = createSubscriptionStep({ + cart_id: input.cart_id, + order_id: orders[0].id, + customer_id: orders[0].customer_id!, + subscription_data: input.subscription_data, + }) + + createRemoteLinkStep(linkDefs) + + return subscription }) - createRemoteLinkStep(linkDefs) + releaseLockStep({ + key: input.cart_id, + }) return new WorkflowResponse({ subscription: subscription, @@ -149597,10 +151005,14 @@ export default createSubscriptionWorkflow This workflow accepts the cart’s ID, along with the subscription details. It executes the following steps: -1. `completeCartWorkflow` from `@medusajs/medusa/core-flows` that completes a cart and creates an order. -2. `useQueryGraphStep` from `@medusajs/medusa/core-flows` to retrieve the order's details. [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) is a tool that allows you to retrieve data across modules. -3. `createSubscriptionStep`, which is the step you created previously. -4. `createRemoteLinkStep` from `@medusajs/medusa/core-flows`, which accepts links to create. These links are in the `linkDefs` array returned by the previous step. +1. [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md) to acquire a lock on the cart to prevent race conditions. +2. [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md) that completes a cart and creates an order. +3. [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md) to retrieve the order's details. [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) is a tool that allows you to retrieve data across modules. +4. [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md) again to check if a subscription already exists for the order. This is necessary to ensure idempotency in case the workflow is retried. +5. Use `when` to check if a subscription already exists for the order. If not, it executes the next steps: + 1. `createSubscriptionStep`, which is the step you created previously. + 2. `createRemoteLinkStep` which accepts links to create. These links are in the `linkDefs` array returned by the previous step. +6. [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md) to release the lock on the cart. The workflow returns the created subscription and order. @@ -149673,7 +151085,7 @@ export const POST = async ( res.json({ type: "order", - ...result, + order: result.order, }) } ``` @@ -149682,7 +151094,7 @@ Since the file exports a `POST` function, you're exposing a `POST` API route at In the route handler function, you retrieve the cart to access it's `metadata` property. If the subscription details aren't stored there, you throw an error. -Then, you use the `createSubscriptionWorkflow` you created to create the order, and return the created order and subscription in the response. +Then, you use the `createSubscriptionWorkflow` you created to create the order, and return the created order in the response. In the next step, you'll customize the Next.js Starter Storefront, allowing you to test out the subscription feature. @@ -149716,6 +151128,7 @@ In this step, you'll customize the checkout flow in the [Next.js Starter storefr 1. Add a subscription step to the checkout flow. 2. Pass the additional data that Stripe requires to later capture the payment when the subscription renews, as explained in the [Payment Flow Overview](#intermission-payment-flow-overview). +3. Change the complete cart action to use the custom API route you created in the previous step. ### Add Subscription Step @@ -149950,6 +151363,30 @@ If you're integrating with a custom payment provider, you can instead pass the r The payment method can now be used later to capture the payment when the subscription renews. +### Change Complete Cart Action + +Finally, you need to change the complete cart action to use the custom API route you created in the previous step. + +In `src/lib/data/cart.ts`, find the `placeOrder` function and change the `await sdk.store.cart.complete` call to the following: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="orange" +const cartRes = await sdk.client.fetch<{ + type: "cart" + cart: HttpTypes.StoreCart +} | { + type: "order" + order: HttpTypes.StoreOrder +}>( + `/store/carts/${id}/subscribe`, + { + method: "POST", + headers, + } +) +``` + +You change the request to send a `POST` request to the `/store/carts/[id]/subscribe` endpoint you created earlier. You can keep the rest of the `placeOrder` function as is. + ### Test Cart Completion and Subscription Creation To test out the cart completion flow: @@ -154473,7 +155910,7 @@ const handleStep2Submit = async () => { return price }).filter((price) => price.amount > 0), // Only include prices > 0 - })) + })).filter((variant) => variant.seat_count > 0) // Only create variants for row types with seats setIsLoading(true) try { @@ -154736,16 +156173,14 @@ You can edit the associated Medusa product to add images, descriptions, and othe *** -## Step 10: Validate Cart Before Checkout +## Step 10: Validate Ticket on Add to Cart -In this step, you'll add custom validation to core cart operations that ensures a seat isn't purchased more than once for the same date. +In this step, you'll add custom validation to the core add-to-cart operation that ensures a seat isn't purchased more than once for the same date. -Medusa implements cart operations in workflows. Specifically, you'll focus on the `addToCartWorkflow` and `completeCartWorkflow`. Medusa allows you to inject custom logic into workflows using [hooks](https://docs.medusajs.com/docs/learn/fundamentals/workflows/workflow-hooks/index.html.md). +Medusa implements cart operations in workflows. Specifically, you'll focus on the `addToCartWorkflow`. Medusa allows you to inject custom logic into workflows using [hooks](https://docs.medusajs.com/docs/learn/fundamentals/workflows/workflow-hooks/index.html.md). A workflow hook is a point in a workflow where you can inject custom functionality as a step function. -#### Add to Cart Validation Hook - To consume the `validate` hook of the `addToCartWorkflow` that holds the add-to-cart logic, create the file `src/workflows/hooks/add-to-cart-validation.ts` with the following content: ```ts title="src/workflows/hooks/add-to-cart-validation.ts" @@ -154843,91 +156278,7 @@ In the step function, you: If the hook throws an error, the add-to-cart operation will be aborted and the error message will be returned to the client. -#### Complete Cart Validation Hook - -Next, to consume the `validate` hook of the `completeCartWorkflow` that holds the checkout logic, create the file `src/workflows/hooks/complete-cart-validation.ts` with the following content: - -```ts title="src/workflows/hooks/complete-cart-validation.ts" -import { completeCartWorkflow } from "@medusajs/medusa/core-flows" -import { MedusaError } from "@medusajs/framework/utils" - -completeCartWorkflow.hooks.validate( - async ({ cart }, { container }) => { - const query = container.resolve("query") - - const { data: items } = await query.graph({ - entity: "line_item", - fields: ["id", "variant_id", "metadata", "quantity"], - filters: { - id: cart.items.map((item) => item.id).filter(Boolean) as string[], - }, - }) - // Get the product variant to check if it's a ticket product variant - const { data: productVariants } = await query.graph({ - entity: "product_variant", - fields: ["id", "product_id", "ticket_product_variant.purchases.*"], - filters: { - id: items.map((item) => item.variant_id).filter(Boolean) as string[], - }, - }) - - // Check for duplicate seats within the cart - const seatDateCombinations = new Set() - - for (const item of items) { - if (item.quantity !== 1) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "You can only purchase one ticket for a seat." - ) - } - const productVariant = productVariants.find( - (variant) => variant.id === item.variant_id - ) - - if (!productVariant || !item.metadata?.seat_number) {continue} - - if (!item.metadata?.show_date) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Show date is required for seat ${item.metadata?.seat_number} in product ${productVariant.product_id}` - ) - } - - // Create a unique key for seat and date combination - const seatDateKey = `${item.metadata?.seat_number}-${item.metadata?.show_date}` - - // Check if this seat-date combination already exists in the cart - if (seatDateCombinations.has(seatDateKey)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Duplicate seat ${item.metadata?.seat_number} found for show date ${item.metadata?.show_date} in cart` - ) - } - - // Add to the set to track this combination - seatDateCombinations.add(seatDateKey) - - // Check if seat has already been purchased - const existingPurchase = productVariant.ticket_product_variant?.purchases.find( - (purchase) => purchase?.seat_number === item.metadata?.seat_number - && purchase?.show_date === item.metadata?.show_date - ) - - if (existingPurchase) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Seat ${item.metadata?.seat_number} has already been purchased for show date ${item.metadata?.show_date}` - ) - } - } - } -) -``` - -Similar to the previous hook, you consume the `validate` hook of the `completeCartWorkflow` to validate that no seat is purchased more than once for the same date. - -You can test out both hooks when you [customize the storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/ticket-booking/example/storefront/index.html.md). +You can test out the hook when you [customize the storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/ticket-booking/example/storefront/index.html.md). *** @@ -154939,13 +156290,14 @@ In this step, you'll create a custom complete cart workflow that wraps the defau The custom workflow that completes the cart has the following steps: +- [acquireLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/acquireLockStep/index.html.md): Acquire a lock on the cart to prevent concurrent modifications - [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md): Complete the cart using Medusa's default completeCartWorkflow - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart details -- [createTicketPurchasesStep](#createTicketPurchasesStep): Create ticket purchases for each ticket product variant in the cart -- [createRemoteLinkStep](https://docs.medusajs.com/references/helper-steps/createRemoteLinkStep/index.html.md): Create links between the order and ticket purchases +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve existing ticket purchases to ensure idempotency - [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the order details +- [releaseLockStep](https://docs.medusajs.com/references/medusa-workflows/steps/releaseLockStep/index.html.md): Release the lock on the cart -You only need to implement the `createTicketPurchasesStep` step, as the other steps and workflows are provided by Medusa. +You only need to implement the `createTicketPurchasesStep` and `validateTicketOrderStep` steps, as the other steps and workflows are provided by Medusa. #### createTicketPurchasesStep @@ -155040,18 +156392,135 @@ In the step function, you prepare the ticket purchases to be created, create the In the compensation function, you delete the created ticket purchases if an error occurs in the workflow. +#### validateTicketOrderStep + +The `validateTicketOrderStep` validates that the tickets can be purchased based on their availability. + +To create the step, create the file `src/workflows/steps/validate-ticket-order.ts` with the following content: + +```ts title="src/workflows/steps/validate-ticket-order.ts" +import { MedusaError } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { cancelOrderWorkflow } from "@medusajs/medusa/core-flows" + +export type ValidateTicketOrderStepInput = { + items: { + id: string + variant_id: string + metadata: Record + quantity: number + variant?: { + id: string + product_id: string + ticket_product_variant?: { + purchases?: { + seat_number: string + show_date: Date + }[] + } + } + }[] + order_id: string +} + +export const validateTicketOrderStep = createStep( + "validate-ticket-order", + async ({ items, order_id }: ValidateTicketOrderStepInput, { container }) => { + // Check for duplicate seats within the cart + const seatDateCombinations = new Set() + + for (const item of items) { + if (item.quantity !== 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "You can only purchase one ticket for a seat." + ) + } + + if (!item.variant || !item.metadata?.seat_number) {continue} + + if (!item.metadata?.show_date) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Show date is required for seat ${item.metadata?.seat_number} in product ${item.variant.product_id}` + ) + } + + // Create a unique key for seat and date combination + const seatDateKey = `${item.metadata?.seat_number}-${item.metadata?.show_date}` + + // Check if this seat-date combination already exists in the cart + if (seatDateCombinations.has(seatDateKey)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Duplicate seat ${item.metadata?.seat_number} found for show date ${item.metadata?.show_date} in cart` + ) + } + + // Add to the set to track this combination + seatDateCombinations.add(seatDateKey) + + // Check if seat has already been purchased + const existingPurchase = item.variant.ticket_product_variant?.purchases?.find( + (purchase) => purchase?.seat_number === item.metadata?.seat_number + && purchase?.show_date === item.metadata?.show_date + ) + + if (existingPurchase) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Seat ${item.metadata?.seat_number} has already been purchased for show date ${item.metadata?.show_date}` + ) + } + } + + return new StepResponse({ validated: true }, order_id) + }, + async (order_id, { container, context }) => { + if (!order_id) {return} + + cancelOrderWorkflow(container).run({ + input: { + order_id, + }, + context, + container, + }) + } +) +``` + +The `validateTicketOrderStep` accepts the cart items and order ID as input. + +In the step function, you validate that: + +1. No seat is purchased more than once for the same date within the cart. +2. No seat has already been purchased for the same date. + +If any validation fails, you throw an error to abort the workflow. + +The step also has a compensation function that cancels the order if an error occurs later in the workflow. This is important to ensure that if ticket purchase creation fails, the order is not left in a completed state. + #### Custom Complete Cart Workflow You can now create the custom workflow that completes the cart and creates ticket purchases. Create the file `src/workflows/complete-cart-with-tickets.ts` with the following content: -```ts title="src/workflows/complete-cart-with-tickets.ts" -import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" -import { completeCartWorkflow, createRemoteLinkStep, useQueryGraphStep } from "@medusajs/medusa/core-flows" +```ts title="src/workflows/complete-cart-with-tickets.ts" collapsibleLines="1-14" expandButtonLabel="Show Imports" +import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { + completeCartWorkflow, + createRemoteLinkStep, + acquireLockStep, + releaseLockStep, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" import { createTicketPurchasesStep, CreateTicketPurchasesStepInput } from "./steps/create-ticket-purchases" import { TICKET_BOOKING_MODULE } from "../modules/ticket-booking" import { Modules } from "@medusajs/framework/utils" +import ticketPurchaseOrderLink from "../links/ticket-purchase-order" +import { validateTicketOrderStep, ValidateTicketOrderStepInput } from "./steps/validate-ticket-order" export type CompleteCartWithTicketsWorkflowInput = { cart_id: string @@ -155060,7 +156529,11 @@ export type CompleteCartWithTicketsWorkflowInput = { export const completeCartWithTicketsWorkflow = createWorkflow( "complete-cart-with-tickets", (input: CompleteCartWithTicketsWorkflowInput) => { - // Step 1: Complete the cart using Medusa's workflow + acquireLockStep({ + key: input.cart_id, + timeout: 2, + ttl: 10, + }) const order = completeCartWorkflow.runAsStep({ input: { id: input.cart_id, @@ -155076,7 +156549,9 @@ export const completeCartWithTicketsWorkflow = createWorkflow( "items.variant.options.option.*", "items.variant.ticket_product_variant.*", "items.variant.ticket_product_variant.ticket_product.*", + "items.variant.ticket_product_variant.purchases.*", "items.metadata", + "items.quantity", ], filters: { id: input.cart_id, @@ -155086,31 +156561,40 @@ export const completeCartWithTicketsWorkflow = createWorkflow( }, }) - // Step 2: Create ticket purchases for ticket products - const ticketPurchases = createTicketPurchasesStep({ - order_id: order.id, - cart: carts[0], - } as unknown as CreateTicketPurchasesStepInput) + const { data: existingLinks } = useQueryGraphStep({ + entity: ticketPurchaseOrderLink.entryPoint, + fields: ["ticket_purchase.id"], + filters: { order_id: order.id }, + }).config({ name: "retrieve-existing-links" }) - // Step 3: Link ticket purchases to the order - const linkData = transform({ - order, - ticketPurchases, - }, (data) => { - return data.ticketPurchases.map((purchase) => ({ - [TICKET_BOOKING_MODULE]: { - ticket_purchase_id: purchase.id, - }, - [Modules.ORDER]: { - order_id: data.order.id, - }, - })) + when({ existingLinks }, (data) => data.existingLinks.length === 0) + .then(() => { + validateTicketOrderStep({ + items: carts[0].items, + order_id: order.id, + } as unknown as ValidateTicketOrderStepInput) + const ticketPurchases = createTicketPurchasesStep({ + order_id: order.id, + cart: carts[0], + } as unknown as CreateTicketPurchasesStepInput) + + const linkData = transform({ + order, + ticketPurchases, + }, (data) => { + return data.ticketPurchases.map((purchase) => ({ + [TICKET_BOOKING_MODULE]: { + ticket_purchase_id: purchase.id, + }, + [Modules.ORDER]: { + order_id: data.order.id, + }, + })) + }) + + createRemoteLinkStep(linkData) }) - // Step 4: Create remote links - createRemoteLinkStep(linkData) - - // Step 5: Fetch order details const { data: refetchedOrder } = useQueryGraphStep({ entity: "order", fields: [ @@ -155134,6 +156618,10 @@ export const completeCartWithTicketsWorkflow = createWorkflow( }, }).config({ name: "refetch-order" }) + releaseLockStep({ + key: input.cart_id, + }) + return new WorkflowResponse({ order: refetchedOrder[0], }) @@ -155145,11 +156633,17 @@ The `completeCartWithTicketsWorkflow` accepts the cart ID as input. In the workflow function, you: -1. Complete the cart using Medusa's `completeCartWorkflow`. -2. Retrieve the cart details using the `useQueryGraphStep`. -3. Create ticket purchases for each ticket product variant in the cart using the `createTicketPurchasesStep`. -4. Create links between the order and the created ticket purchases using the `createRemoteLinkStep`. -5. Retrieve the order details using the `useQueryGraphStep`. +1. Acquire a lock on the cart to prevent concurrent modifications using the `acquireLockStep`. +2. Complete the cart using Medusa's `completeCartWorkflow`. +3. Retrieve the cart details using the `useQueryGraphStep`. +4. Retrieve existing ticket purchases linked to the order to ensure idempotency using the `useQueryGraphStep`. + - This is important because if the workflow is retried, you don't want to create duplicate ticket purchases. +5. Use `when` to check that there are no existing links between the order and ticket purchases. If so, you: + 1. Validate that the ticket order can be processed using the `validateTicketOrderStep`. + 2. Create ticket purchases for each ticket product variant in the cart using the `createTicketPurchasesStep`. + 3. Create links between the order and the created ticket purchases using the `createRemoteLinkStep`. +6. Retrieve the order details using the `useQueryGraphStep`. +7. Release the lock on the cart using the `releaseLockStep`. Finally, you return the order details. diff --git a/www/apps/book/sidebar.mjs b/www/apps/book/sidebar.mjs index ebc3c3bbd1..4c7d0f8343 100644 --- a/www/apps/book/sidebar.mjs +++ b/www/apps/book/sidebar.mjs @@ -679,6 +679,22 @@ export const sidebars = [ }, ], }, + { + type: "category", + title: "Best Practices", + children: [ + { + type: "link", + path: "/learn/best-practices/third-party-sync", + title: "Third-Party Syncing", + }, + { + type: "ref", + path: "/learn/fundamentals/scheduled-jobs/interval", + title: "Scheduled Job Intervals", + }, + ], + }, { type: "category", title: "Production",