All skills
medusajs avatar

/building-with-medusa

@34c71b3 official
by Medusamedusajs/medusa-agent-skills225 stars
29

Load automatically when planning, researching, or implementing ANY Medusa backend features (custom modules, API routes, workflows, data models, module links, business logic). REQUIRED for all Medusa backend work in ALL modes (planning, implementation, exploration). Contains architectural patterns, best practices, and critical rules that MCP servers don't provide.

Use this Skill: https://skilld.dev/gh/medusajs/medusa-agent-skills/building-with-medusa

This session only. Nothing lands on disk.

referencescheduled-jobs.md

≈3.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Scheduled Jobs

Scheduled jobs are asynchronous functions that run automatically at specified intervals during the Medusa application's runtime. Use them for tasks like syncing products to third-party services, sending periodic reports, or cleaning up stale data.

Contents

When to Use Scheduled Jobs

Use scheduled jobs when you need to perform actions periodically:

  • ✅ Syncing data with third-party services on a schedule
  • ✅ Sending periodic reports (daily, weekly)
  • ✅ Cleaning up stale data (expired carts, old sessions)
  • ✅ Generating batch exports
  • ✅ Recalculating aggregated data

Don't use scheduled jobs for:

  • ❌ Reacting to events (use subscribers instead)
  • ❌ One-time tasks (use workflows directly)
  • ❌ Real-time processing (use API routes + workflows)

Scheduled Jobs vs Subscribers:

  • Scheduled Job: Finds carts updated >24h ago and sends emails (polling pattern)
  • Subscriber: Reacts to order.created and sends an email (event-driven)

For most use cases, subscribers are preferred when you need to react to specific events.

Creating a Scheduled Job

Create a TypeScript file in the src/jobs/ directory:

// src/jobs/sync-products.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"

export default async function syncProductsJob(container: MedusaContainer) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)

  logger.info("Starting product sync...")

  // Resolve services from container
  const productService = container.resolve(Modules.PRODUCT)
  const myService = container.resolve("my-custom-service")

  try {
    // Your job logic here
    const products = await productService.listProducts({ active: true })

    for (const product of products) {
      // Process each product
      await myService.syncToExternalSystem(product)
    }

    logger.info("Product sync completed successfully")
  } catch (error) {
    logger.error(`Product sync failed: ${error.message}`)
    // Don't throw - let the job complete and retry on next schedule
  }
}

export const config = {
  name: "sync-products-daily", // Unique name for the job
  schedule: "0 0 * * *", // Cron expression: midnight daily
}

Configuration Options

export const config = {
  name: "my-job", // Required: unique identifier
  schedule: "* * * * *", // Required: cron expression
  numberOfExecutions: 3, // Optional: limit total scheduled executions
}

Configuration Properties

  • name (required): Unique identifier for the job across your application
  • schedule (required): Cron expression defining when to run
  • numberOfExecutions (optional): Maximum number of times to execute the job according to its schedule

⚠️ CRITICAL - Understanding numberOfExecutions:

numberOfExecutions limits how many times the job runs on its schedule, NOT immediately on server start.

// ❌ WRONG UNDERSTANDING: This will NOT run immediately on server start
export const config = {
  name: "test-job",
  schedule: "0 0 * * *", // Daily at midnight
  numberOfExecutions: 1, // Will run ONCE at the next midnight, not now!
}

// ✅ CORRECT: To test a job immediately, use a frequent schedule
export const config = {
  name: "test-job",
  schedule: "* * * * *", // Every minute
  numberOfExecutions: 1, // Will run once at the next minute
}

// ✅ CORRECT: Testing with multiple runs
export const config = {
  name: "test-job",
  schedule: "*/5 * * * *", // Every 5 minutes
  numberOfExecutions: 3, // Will run 3 times (at 0, 5, 10 minutes), then stop
}

Key points:

  • The job waits for the first scheduled time before executing
  • numberOfExecutions: 1 with a daily schedule means it runs once the next day
  • To test immediately, use a frequent schedule like "* * * * *" (every minute)
  • After reaching numberOfExecutions, the job stops running permanently

Executing Workflows in Scheduled Jobs

⚠️ BEST PRACTICE: Use workflows for mutations in scheduled jobs. This ensures proper error handling and rollback capabilities.

// src/jobs/send-weekly-newsletter.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { sendNewsletterWorkflow } from "../workflows/send-newsletter"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export default async function sendNewsletterJob(container: MedusaContainer) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const query = container.resolve(ContainerRegistrationKeys.QUERY)

  logger.info("Sending weekly newsletter...")

  try {
    // Query for data
    const { data: customers } = await query.graph({
      entity: "customer",
      fields: ["id", "email"],
      filters: {
        newsletter_subscribed: true,
      },
    })

    logger.info(`Found ${customers.length} subscribers`)

    // Execute workflow
    await sendNewsletterWorkflow(container).run({
      input: {
        customer_ids: customers.map((c) => c.id),
      },
    })

    logger.info("Newsletter sent successfully")
  } catch (error) {
    logger.error(`Newsletter job failed: ${error.message}`)
  }
}

export const config = {
  name: "send-weekly-newsletter",
  schedule: "0 0 * * 0", // Every Sunday at midnight
}

Cron Expression Examples

Cron format: minute hour day-of-month month day-of-week

// Every minute
schedule: "* * * * *"

// Every 5 minutes
schedule: "*/5 * * * *"

// Every hour at minute 0
schedule: "0 * * * *"

// Every day at midnight (00:00)
schedule: "0 0 * * *"

// Every day at 2:30 AM
schedule: "30 2 * * *"

// Every Sunday at midnight
schedule: "0 0 * * 0"

// Every Monday at 9 AM
schedule: "0 9 * * 1"

// First day of every month at midnight
schedule: "0 0 1 * *"

// Every weekday (Mon-Fri) at 6 PM
schedule: "0 18 * * 1-5"

// Every 6 hours
schedule: "0 */6 * * *"

Tip: Use crontab.guru to build and validate cron expressions.

Best Practices

1. Always Use Logging

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)

  logger.info("Job started")

  try {
    // Job logic
    logger.info("Job completed successfully")
  } catch (error) {
    logger.error(`Job failed: ${error.message}`, { error })
  }
}

2. Handle Errors Gracefully

Don't throw errors at the top level - log them and let the job complete:

// ❌ BAD: Throws and stops execution
export default async function myJob(container: MedusaContainer) {
  const service = container.resolve("my-service")
  const items = await service.getItems() // Might throw
  // Job stops if this throws
}

// ✅ GOOD: Catches errors and logs
export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)

  try {
    const service = container.resolve("my-service")
    const items = await service.getItems()
    // Process items
  } catch (error) {
    logger.error(`Job failed: ${error.message}`)
    // Job completes, will retry on next schedule
  }
}

3. Make Jobs Idempotent

Design jobs to be safely re-runnable:

// ✅ GOOD: Idempotent job
export default async function syncProducts(container: MedusaContainer) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const myService = container.resolve("my-service")

  // Check what's already synced
  const lastSyncTime = await myService.getLastSyncTime()

  // Only sync products updated since last sync
  const { data: products } = await query.graph({
    entity: "product",
    filters: {
      updated_at: { $gte: lastSyncTime },
    },
  })

  // Sync products (upsert, don't insert)
  for (const product of products) {
    await myService.upsertToExternalSystem(product)
  }

  // Update last sync time
  await myService.setLastSyncTime(new Date())
}

4. Use Workflows for Mutations

// ✅ GOOD: Uses workflow for mutations
import { deleteCartsWorkflow } from "../workflows/delete-carts"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export default async function cleanupExpiredCarts(container: MedusaContainer) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const query = container.resolve(ContainerRegistrationKeys.QUERY)

  // Find expired carts
  const { data: carts } = await query.graph({
    entity: "cart",
    fields: ["id"],
    filters: {
      updated_at: {
        $lte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
      },
    },
  })

  logger.info(`Found ${carts.length} expired carts`)

  // Use workflow for deletion (import at top of file)
  await deleteCartsWorkflow(container).run({
    input: {
      cart_ids: carts.map((c) => c.id),
    },
  })

  logger.info("Expired carts cleaned up")
}

5. Add Metrics/Monitoring

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const startTime = Date.now()

  try {
    // Job logic
    const processed = 100 // Track what you processed

    const duration = Date.now() - startTime
    logger.info(`Job completed: ${processed} items in ${duration}ms`)
  } catch (error) {
    logger.error(`Job failed after ${Date.now() - startTime}ms`)
  }
}

6. Test with Limited Executions

When testing, use a frequent schedule with limited executions:

// ✅ CORRECT: Frequent schedule for immediate testing
export const config = {
  name: "test-job",
  schedule: "* * * * *", // Every minute
  numberOfExecutions: 3, // Run 3 times (next 3 minutes), then stop
}

// ❌ WRONG: This won't help with testing
export const config = {
  name: "test-job",
  schedule: "0 0 * * *", // Daily at midnight
  numberOfExecutions: 1, // Will only run ONCE at next midnight, not useful for testing
}

Remember: numberOfExecutions doesn't make the job run immediately - it limits how many times it runs on its schedule.

Complete Example: Abandoned Cart Email Job

// src/jobs/send-abandoned-cart-emails.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { sendAbandonedCartEmailWorkflow } from "../workflows/send-abandoned-cart-email"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export default async function abandonedCartEmailJob(
  container: MedusaContainer
) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const query = container.resolve(ContainerRegistrationKeys.QUERY)

  logger.info("Starting abandoned cart email job...")

  try {
    // Find carts updated more than 24 hours ago that haven't completed
    const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)

    const { data: carts } = await query.graph({
      entity: "cart",
      fields: ["id", "email", "customer_id"],
      filters: {
        updated_at: {
          $lte: twentyFourHoursAgo,
        },
        completed_at: null,
        email: { $ne: null }, // Must have email
      },
    })

    logger.info(`Found ${carts.length} abandoned carts`)

    // Process in batches
    for (const cart of carts) {
      try {
        await sendAbandonedCartEmailWorkflow(container).run({
          input: {
            cart_id: cart.id,
            email: cart.email,
          },
        })
        logger.info(`Sent email for cart ${cart.id}`)
      } catch (error) {
        logger.error(`Failed to send email for cart ${cart.id}: ${error.message}`)
        // Continue with other carts
      }
    }

    logger.info("Abandoned cart email job completed")
  } catch (error) {
    logger.error(`Abandoned cart job failed: ${error.message}`)
  }
}

export const config = {
  name: "send-abandoned-cart-emails",
  schedule: "0 */6 * * *", // Every 6 hours
}

Source: SKILL.md on GitHub

1 warning16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    This skill is a comprehensive development guide for building MedusaJS backend applications. It provides detailed architectural patterns, best practices, and implementation checklists for modules, workflows, API routes, and data models. No malicious patterns or security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    9/14 files flagged

  • ZeroLeaks5mo

    2 findings · Score: 80/100

Signed by skilld at 34c71b3. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 2 months ago

README badge

README badge for medusajs/medusa-agent-skills/building-with-medusa