
Medusajs Developer
- 50 installs
- 12 repo stars
- Updated June 28, 2026
- greedychipmunk/agent-skills
Expert guidance for MedusaJS v2.15+ commerce development: custom modules, API routes, data models, workflows, scheduled jobs, and integrations.
About
A specialized agent for building scalable MedusaJS v2.15+ e-commerce solutions, covering custom modules, module links, API endpoints, and third-party plugin development. A developer uses it to build or extend a Medusa commerce platform.
- Custom modules with DML data models and automatic CRUD services
- Works with Medusa's 18 built-in commerce modules and module links
Medusajs Developer by the numbers
- 50 all-time installs (skills.sh)
- Ranked #3,241 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/greedychipmunk/agent-skills --skill medusajs-developerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 50 |
|---|---|
| repo stars | ★ 12 |
| Last updated | June 28, 2026 |
| Repository | greedychipmunk/agent-skills ↗ |
What it does
Expert guidance for MedusaJS v2.15+ commerce development: custom modules, API routes, data models, workflows, scheduled jobs, and integrations.
Files
MedusaJS Developer Agent Skill
An expert agent specializing in MedusaJS v2.15+ development, focusing on building scalable e-commerce solutions with custom modules, API integrations, and third-party plugins.
Core Capabilities
1. Custom Module Development
- Data Models: Create and manage data models using MedusaJS DML
- Module Services: Implement service layers with automatic CRUD operations
- Module Configuration: Set up proper module structure and exports
- Database Migrations: Generate and manage database schema changes
2. API Route Development
- Custom Endpoints: Create REST API routes in
src/api/[route-name]/route.ts - HTTP Methods: Implement GET, POST, PUT, DELETE handlers
- Request/Response Handling: Manage MedusaRequest and MedusaResponse objects
- Authentication: Integrate with MedusaJS auth systems
3. Commerce Module Integration
- 18 Built-in Modules: Work with API Key, Auth, Cart, Customer, Order, Payment, Product, Pricing, Promotion, Tax, and more
- Module Links: Create relationships between different modules
- Custom Fields: Extend existing modules with additional data fields
- Module Composition: Combine multiple modules for complex workflows
4. Workflow & Automation
- Scheduled Jobs: Create recurring tasks with cron expressions
- Event Handling: Implement subscribers for asynchronous operations
- Business Logic: Orchestrate complex commerce workflows
- Background Processing: Handle long-running operations efficiently
5. Third-Party Integrations
- Payment Providers: Integrate custom payment gateways
- External APIs: Connect with shipping, tax, and inventory services
- Webhooks: Handle incoming webhooks from external systems
- Data Synchronization: Sync data with external platforms
Medusa v2.15+ Updates
Auth & Security
- Medusa v2.15+ includes built-in MFA primitives for auth flows.
- Prefer module-managed TOTP, SMS, and recovery code challenges instead of rolling your own OTP storage.
- Wire MFA into the Auth module by treating it as part of sign-in, enrollment, challenge, verify, and recovery flows.
// Pseudocode: branch on auth result and ask the Auth module to challenge/verify MFA
const result = await authModule.authenticate(credentials)
if (result.mfa_required) {
await authModule.mfa.challenge({
user_id: result.user_id,
method: "totp", // "sms" | "recovery_code"
})
}Promotions
- Promotion workflows can consume context hooks for conditional application.
- Pass contextual data such as
customer_group,sales_channel_id,region, or campaign metadata through workflows so promotion rules can evaluate it. - Example: apply a wholesale promotion only when
context.customer_group === "wholesale".
Catalog Search
- Products now support native SKU search.
- Use SKU filters in product search requests when looking up variants by merchant-facing or fulfillment-facing identifiers.
- Example: search by SKU and keyword together to narrow a catalog query.
GET /store/products?query=hoodie&sku=HD-001-BLK-MCloud & Platform
- Use
mcloud proxyfor secure local-to-cloud tunneling when debugging Cloud environments or testing webhooks against a local app. - Use it when a third-party service needs a public callback URL but you want to keep the backend local.
Data Model Notes
- Float attributes are now aligned in the data model; prefer the updated attribute types when modeling custom number fields.
Critical Migration Notes
- Medusa v2.15.2 includes the MikroORM v6.6.12 security update and fixes the v6.13.6 snapshot regression.
- If you're on v2.13.6+, upgrade before running production migrations and clean stale snapshots first:
npx medusa db:migrate --clean-snapshots- Pin all
@medusajs/*packages to the same release line during the upgrade and avoid partial version drift.
Development Patterns
Module Structure
// src/modules/my-module/models/post.ts
import { model } from "@medusajs/framework/utils"
const Post = model.define("post", {
id: model.id().primaryKey(),
title: model.text(),
content: model.text().nullable(),
published: model.boolean().default(false)
})
export default PostAPI Route Example
// src/api/posts/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const postService = req.scope.resolve("postService")
const posts = await postService.listPosts()
res.json({ posts })
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const postService = req.scope.resolve("postService")
const post = await postService.createPost(req.body)
res.json({ post })
}Scheduled Job Example
// src/jobs/sync-inventory.ts
import { MedusaContainer } from "@medusajs/framework/types"
export default async function syncInventoryJob(container: MedusaContainer) {
const inventoryService = container.resolve("inventoryService")
await inventoryService.syncWithExternalProvider()
}
export const config = {
name: "sync-inventory",
schedule: "0 */6 * * *" // Every 6 hours
}Best Practices
1. Project Setup
- Use MedusaJS CLI for project initialization
- Follow TypeScript best practices
- Implement proper error handling
- Set up comprehensive testing
2. Module Design
- Keep modules focused on single domains
- Use clear naming conventions
- Implement proper validation
- Document module interfaces
3. API Design
- Follow RESTful conventions
- Use proper HTTP status codes
- Implement pagination for list endpoints
- Validate input data thoroughly
4. Performance Optimization
- Use database indexes appropriately
- Implement caching strategies
- Optimize database queries
- Handle large datasets efficiently
5. Integration Patterns
- Use environment variables for configuration
- Implement retry mechanisms for external calls
- Handle rate limiting gracefully
- Log integration activities properly
Common Tasks
Creating a New Module
1. Create module directory structure 2. Define data models with proper relationships 3. Implement service layer with business logic 4. Generate and run database migrations 5. Create API routes for module operations 6. Add comprehensive tests
Setting Up Third-Party Integration
1. Install necessary dependencies 2. Configure environment variables 3. Create service for external API communication 4. Implement webhook handlers if needed 5. Add error handling and logging 6. Test integration thoroughly
Implementing Custom Workflow
1. Identify business process steps 2. Create necessary data models 3. Implement workflow orchestration 4. Add event handlers for state changes 5. Create monitoring and alerting 6. Document workflow behavior
Troubleshooting
Common Issues
- Migration Failures: Check model definitions and database constraints
- Service Resolution: Verify module exports and dependency injection
- API Errors: Validate request/response formats and authentication
- Performance Issues: Analyze database queries and implement caching
Debugging Strategies
- Use MedusaJS debugging tools
- Check application logs for errors
- Verify database schema matches models
- Test API endpoints with proper headers
- Monitor external service responses
Code Templates
This skill includes production-ready code templates in the templates/ directory based on official MedusaJS documentation and best practices.
Available Templates
module-complete.ts
Complete custom module structure with:
- Multiple data models with various property types
- One-to-many and many-to-many relationships
- Main service with custom methods
- Additional services with dependency injection
Use case: Creating custom modules (Blog, Brand, Restaurant, etc.)
api-route-complete.ts
Complete REST API route with:
- GET, POST, PUT, DELETE handlers
- Zod validation schemas
- Authentication and validation middlewares
- Error handling and logging
- Query integration for related data
Use case: Exposing module functionality via API endpoints
workflow-complete.ts
Complete workflow with:
- Multiple steps with compensation functions
- Data transformation between steps
- Conditional execution
- Integration with Medusa modules
Use case: Complex business logic with rollback requirements
subscriber-complete.ts
Event subscriber patterns:
- Basic event handling
- Workflow execution in subscribers
- Multi-event subscribers
- Retry logic and error handling
Use case: Responding to Medusa events (order.placed, product.created, etc.)
module-link.ts
Module link patterns:
- Basic links between modules
- List links (one-to-many)
- Custom columns in link tables
- Creating, dismissing, and querying links
Use case: Creating relationships between different modules
scheduled-job.ts
Scheduled job patterns:
- Basic scheduled tasks
- Batch processing
- External API integration
- Common cron patterns
Use case: Recurring automated tasks (sync, cleanup, reports)
Template Usage Example
# Copy template to your project
cp templates/module-complete.ts src/modules/brand/
# Customize for your needs
# - Rename identifiers
# - Add/remove properties
# - Implement business logic
# Generate and run migrations
./scripts/generate-migration.sh brand
./scripts/run-migrations.shCommon Template Combinations
E-commerce Extension: 1. module-complete.ts → Create custom module 2. module-link.ts → Link to Product module 3. api-route-complete.ts → Create API endpoints 4. workflow-complete.ts → Implement business logic 5. subscriber-complete.ts → Handle events
Data Synchronization: 1. workflow-complete.ts → Sync workflow 2. scheduled-job.ts → Run periodically 3. subscriber-complete.ts → Trigger on events
See templates/README.md for detailed documentation, best practices, and more examples.
Helper Scripts
This skill includes a collection of helper scripts in the scripts/ directory to streamline common MedusaJS development tasks.
Database Management Scripts
db-setup.sh
Creates a database, runs migrations, and syncs links in one command.
./scripts/db-setup.sh [database-name]Example:
./scripts/db-setup.sh medusa-storegenerate-migration.sh
Generates migration files for specified modules.
./scripts/generate-migration.sh <module-name> [additional-modules...]Examples:
./scripts/generate-migration.sh blog
./scripts/generate-migration.sh blog product-customrun-migrations.sh
Runs all pending migrations with optional flags to skip links or data migrations.
./scripts/run-migrations.sh [--skip-links] [--skip-data]Examples:
./scripts/run-migrations.sh
./scripts/run-migrations.sh --skip-linksrollback-migration.sh
Reverts the last migration for specified modules with safety confirmation.
./scripts/rollback-migration.sh <module-name> [additional-modules...]Development & Build Scripts
dev-server.sh
Starts the Medusa application in development mode with hot reloading.
./scripts/dev-server.sh [--host HOST] [--port PORT]Examples:
./scripts/dev-server.sh
./scripts/dev-server.sh --host 0.0.0.0 --port 9001build-production.sh
Creates a production-ready build of the Medusa application or admin only.
./scripts/build-production.sh [--admin-only]Examples:
./scripts/build-production.sh
./scripts/build-production.sh --admin-onlystart-production.sh
Starts the built Medusa application in production mode.
./scripts/start-production.shpredeploy.sh
Runs migrations and syncs links before deployment (for CI/CD pipelines).
./scripts/predeploy.shTesting Scripts
setup-testing.sh
Installs and configures Jest and Medusa testing tools, creates test directories and configuration files.
./scripts/setup-testing.shrun-tests.sh
Runs integration and unit tests with options for different test types.
./scripts/run-tests.sh [http|modules|unit|all]Examples:
./scripts/run-tests.sh all
./scripts/run-tests.sh http
./scripts/run-tests.sh modules
./scripts/run-tests.sh unitScaffolding Scripts
create-module.sh
Creates the basic structure for a new custom module with service and model directories.
./scripts/create-module.sh <module-name>Example:
./scripts/create-module.sh blogGenerated Structure:
src/modules/<module-name>/
├── index.ts
├── service.ts
├── models/
└── __tests__/create-api-route.sh
Creates a new API route with basic CRUD operations (GET, POST, PUT, DELETE).
./scripts/create-api-route.sh <route-name>Example:
./scripts/create-api-route.sh postsGenerated Endpoints:
GET /api/<route-name>- List all itemsPOST /api/<route-name>- Create new itemGET /api/<route-name>/:id- Get single itemPUT /api/<route-name>/:id- Update itemDELETE /api/<route-name>/:id- Delete item
create-scheduled-job.sh
Creates a new scheduled job with cron configuration template.
./scripts/create-scheduled-job.sh <job-name>Example:
./scripts/create-scheduled-job.sh sync-inventoryCommon Cron Patterns:
"0 0 * * *"- Daily at midnight"0 */6 * * *"- Every 6 hours"*/15 * * * *"- Every 15 minutes"0 9 * * 1"- Every Monday at 9 AM
Plugin Development Scripts
plugin-develop.sh
Starts a development server for a plugin with auto-reload (run from plugin directory).
./scripts/plugin-develop.shplugin-build.sh
Builds a plugin for publishing to NPM (run from plugin directory).
./scripts/plugin-build.shCommon Workflows
Creating a New Feature Module
# 1. Create the module structure
./scripts/create-module.sh my-feature
# 2. Add data models in src/modules/my-feature/models/
# 3. Update service.ts with your models
# 4. Generate migrations
./scripts/generate-migration.sh my-feature
# 5. Run migrations
./scripts/run-migrations.sh
# 6. Create API routes
./scripts/create-api-route.sh my-feature
# 7. Write tests
# Add tests in src/modules/my-feature/__tests__/
# 8. Run tests
./scripts/run-tests.sh modulesSetting Up a New Project
# 1. Create new project
npx create-medusa-app@latest my-store
# 2. Setup database
./scripts/db-setup.sh my-store-db
# 3. Setup testing environment
./scripts/setup-testing.sh
# 4. Start development server
./scripts/dev-server.shDeployment Workflow
# 1. Run tests
./scripts/run-tests.sh all
# 2. Build for production
./scripts/build-production.sh
# 3. In CI/CD pipeline, run predeploy
./scripts/predeploy.sh
# 4. Start production server
./scripts/start-production.shResources
- Official MedusaJS Documentation
- Community Discord and Forums
- GitHub Repository and Examples
- Plugin Marketplace
- Developer Tools and CLI Commands
This skill enables comprehensive MedusaJS development with focus on maintainable, scalable e-commerce solutions.
Version: 2.0 Last Updated: May 2026
MedusaJS API Development Patterns
Custom API Routes
Basic Route Structure
// src/api/[route-name]/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
// Handler implementation
res.json({ data: "response" })
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
// Handler implementation
res.json({ created: "resource" })
}Request/Response Patterns
Accessing Services
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const productService = req.scope.resolve("productService")
const products = await productService.listProducts()
res.json({ products })
}Query Parameters
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { limit = 20, offset = 0, q } = req.query
const productService = req.scope.resolve("productService")
const products = await productService.listProducts({
limit: Number(limit),
offset: Number(offset),
q: q as string
})
res.json({ products, count: products.length })
}Request Body Validation
import { z } from "zod"
const createProductSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
price: z.number().positive()
})
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const validatedData = createProductSchema.parse(req.body)
const productService = req.scope.resolve("productService")
const product = await productService.createProduct(validatedData)
res.status(201).json({ product })
}Error Handling
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
try {
const { id } = req.params
const productService = req.scope.resolve("productService")
const product = await productService.retrieveProduct(id)
if (!product) {
return res.status(404).json({
error: "Product not found",
code: "PRODUCT_NOT_FOUND"
})
}
res.json({ product })
} catch (error) {
res.status(500).json({
error: "Internal server error",
message: error.message
})
}
}Authentication Patterns
Admin API Routes
// src/api/admin/custom/route.ts
import { authenticated } from "@medusajs/framework/http"
export const GET = authenticated(async (req: MedusaRequest, res: MedusaResponse) => {
// Only authenticated admin users can access
const userId = req.auth.actor_id
res.json({ message: `Hello admin ${userId}` })
})Store API Routes with Customer Auth
// src/api/store/profile/route.ts
import { authenticatedCustomer } from "@medusajs/framework/http"
export const GET = authenticatedCustomer(async (req: MedusaRequest, res: MedusaResponse) => {
const customerId = req.auth.actor_id
const customerService = req.scope.resolve("customerService")
const customer = await customerService.retrieveCustomer(customerId)
res.json({ customer })
})Middleware Patterns
Custom Middleware
// src/api/middleware.ts
import { MiddlewareRoute } from "@medusajs/framework/http"
export const middlewares: MiddlewareRoute[] = [
{
matcher: "/custom/*",
middlewares: [
async (req, res, next) => {
// Custom logic
console.log(`Request to ${req.path}`)
next()
}
]
}
]Rate Limiting
import rateLimit from "express-rate-limit"
export const middlewares: MiddlewareRoute[] = [
{
matcher: "/api/*",
middlewares: [
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: "Too many requests from this IP"
})
]
}
]Response Formatting
Standard Success Response
const successResponse = (data: any, message?: string) => ({
success: true,
data,
message: message || "Operation completed successfully"
})
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const products = await productService.listProducts()
res.json(successResponse(products, "Products retrieved"))
}Pagination Response
const paginatedResponse = (data: any[], total: number, limit: number, offset: number) => ({
data,
pagination: {
total,
limit,
offset,
pages: Math.ceil(total / limit),
current_page: Math.floor(offset / limit) + 1
}
})Error Response
const errorResponse = (message: string, code?: string, details?: any) => ({
success: false,
error: {
message,
code: code || "UNKNOWN_ERROR",
details
}
})Testing API Routes
Unit Tests
// __tests__/api/products/route.test.ts
import { GET } from "../../../src/api/products/route"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
describe("GET /products", () => {
it("should return products list", async () => {
const mockReq = {
scope: {
resolve: jest.fn().mockReturnValue({
listProducts: jest.fn().mockResolvedValue([])
})
}
} as unknown as MedusaRequest
const mockRes = {
json: jest.fn()
} as unknown as MedusaResponse
await GET(mockReq, mockRes)
expect(mockRes.json).toHaveBeenCalledWith({ products: [] })
})
})Integration Tests
import request from "supertest"
import { app } from "../setup-test"
describe("Products API", () => {
it("should create a product", async () => {
const response = await request(app)
.post("/api/products")
.send({
title: "Test Product",
price: 100
})
.expect(201)
expect(response.body.product).toBeDefined()
expect(response.body.product.title).toBe("Test Product")
})
})MedusaJS Commerce Modules Reference
Core Commerce Modules
API Key Module
Manages API keys for external integrations and authentication.
// Usage example
const apiKeyService = container.resolve("apiKeyService")
// Create API key
const apiKey = await apiKeyService.createApiKey({
title: "Payment Gateway Integration",
type: "secret", // or "publishable"
created_by: userId
})
// Validate API key
const isValid = await apiKeyService.validateApiKey(token)Auth Module
Handles authentication and authorization for admin and customer users.
// Admin authentication
const authService = container.resolve("authService")
// Create auth user
const authUser = await authService.createAuthUser({
provider_id: "emailpass",
user_metadata: { email: "admin@example.com" }
})
// Authenticate
const session = await authService.authenticate({
provider_id: "emailpass",
provider_metadata: {
email: "admin@example.com",
password: "password"
}
})Cart Module
Manages shopping cart functionality including items, promotions, and calculations.
const cartService = container.resolve("cartService")
// Create cart
const cart = await cartService.createCart({
currency_code: "usd",
region_id: "reg_01"
})
// Add line item
await cartService.addLineItem(cart.id, {
variant_id: "variant_01",
quantity: 2
})
// Apply promotion
await cartService.addPromotions(cart.id, ["promo_01"])
// Calculate totals
const calculatedCart = await cartService.calculateTotals(cart.id)Customer Module
Manages customer accounts, profiles, and customer groups.
const customerService = container.resolve("customerService")
// Create customer
const customer = await customerService.createCustomer({
email: "customer@example.com",
first_name: "John",
last_name: "Doe",
phone: "+1234567890"
})
// Add to customer group
await customerService.addCustomerToGroup(customer.id, "vip_customers")
// Create address
await customerService.createCustomerAddress(customer.id, {
first_name: "John",
last_name: "Doe",
address_1: "123 Main St",
city: "New York",
country_code: "US",
postal_code: "10001"
})Order Module
Handles order creation, management, and fulfillment processes.
const orderService = container.resolve("orderService")
// Create order from cart
const order = await orderService.createFromCart(cart.id)
// Update order status
await orderService.updateOrder(order.id, {
status: "processing"
})
// Create fulfillment
await orderService.createFulfillment(order.id, {
items: [
{
id: lineItemId,
quantity: 1
}
],
shipping_option_id: "so_01"
})
// Cancel order
await orderService.cancelOrder(order.id, {
reason: "customer_request"
})Payment Module
Manages payment processing, refunds, and payment provider integrations.
const paymentService = container.resolve("paymentService")
// Create payment collection
const paymentCollection = await paymentService.createPaymentCollection({
currency_code: "usd",
amount: 10000, // $100.00 in cents
region_id: "reg_01"
})
// Create payment session
const paymentSession = await paymentService.createPaymentSession({
payment_collection_id: paymentCollection.id,
provider_id: "stripe",
amount: 10000,
currency_code: "usd"
})
// Capture payment
await paymentService.capturePayment({
payment_id: payment.id,
amount: 10000
})Product Module
Manages product catalog including variants, options, and inventory.
const productService = container.resolve("productService")
// Create product
const product = await productService.createProduct({
title: "Sample T-Shirt",
subtitle: "Comfortable cotton t-shirt",
description: "High-quality cotton t-shirt in multiple colors",
handle: "sample-t-shirt",
status: "published",
thumbnail: "https://example.com/image.jpg",
categories: [{ id: "cat_clothing" }],
tags: [{ value: "clothing" }, { value: "cotton" }]
})
// Create product variant
await productService.createProductVariant(product.id, {
title: "Small / Red",
sku: "TSHIRT-SM-RED",
barcode: "1234567890",
options: [
{ option_id: "size_option", value: "Small" },
{ option_id: "color_option", value: "Red" }
],
prices: [
{
currency_code: "usd",
amount: 2500 // $25.00
}
]
})
// Update inventory
await productService.updateInventory(variant.id, {
quantity: 100,
allow_backorder: false
})Pricing Module
Handles dynamic pricing, price lists, and currency management.
const pricingService = container.resolve("pricingService")
// Create price list
const priceList = await pricingService.createPriceList({
name: "VIP Customer Pricing",
description: "Special pricing for VIP customers",
type: "sale",
status: "active",
starts_at: new Date(),
ends_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
customer_groups: [{ id: "vip_customers" }]
})
// Add prices to list
await pricingService.addPriceListPrices(priceList.id, [
{
variant_id: "variant_01",
currency_code: "usd",
amount: 2250, // $22.50 (10% discount)
min_quantity: 1
}
])
// Calculate pricing context
const pricing = await pricingService.calculatePrices({
variant_ids: ["variant_01"],
currency_code: "usd",
customer_id: "customer_01",
region_id: "reg_01"
})Promotion Module
Manages discount codes, promotions, and marketing campaigns.
const promotionService = container.resolve("promotionService")
// Create promotion
const promotion = await promotionService.createPromotion({
code: "SUMMER2024",
type: "standard",
is_automatic: false,
starts_at: new Date(),
ends_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
usage_limit: 100,
rules: [
{
type: "rules.spend",
operator: "gte",
values: [{ value: 5000 }] // Minimum $50 spend
}
],
actions: [
{
type: "actions.percentage",
value: 20 // 20% discount
}
]
})
// Apply promotion to cart
await promotionService.addPromotionsToCart(cart.id, [promotion.code])
// Check promotion eligibility
const eligibility = await promotionService.checkPromotion({
promotion_code: "SUMMER2024",
cart_id: cart.id
})Tax Module
Handles tax calculations, tax rates, and tax provider integrations.
const taxService = container.resolve("taxService")
// Create tax rate
const taxRate = await taxService.createTaxRate({
name: "Sales Tax",
code: "SALES_TAX_NY",
rate: 8.25, // 8.25%
region_id: "reg_ny",
tax_region_id: "tax_reg_ny"
})
// Calculate tax for cart
const taxLines = await taxService.calculateTax({
cart_id: cart.id,
shipping_address: {
country_code: "US",
province: "NY",
postal_code: "10001"
}
})
// Create tax region
const taxRegion = await taxService.createTaxRegion({
country_code: "US",
province_code: "NY",
parent_id: "tax_reg_us",
metadata: { state_name: "New York" }
})Module Integration Patterns
Cross-Module Communication
// Using events for loose coupling
export default async function orderCreatedHandler({
event,
container
}: SubscriberArgs<{ id: string }>) {
const orderService = container.resolve("orderService")
const inventoryService = container.resolve("inventoryService")
const customerService = container.resolve("customerService")
const order = await orderService.retrieveOrder(event.data.id, {
relations: ["items", "customer"]
})
// Update inventory
for (const item of order.items) {
await inventoryService.adjustInventory(item.variant_id, -item.quantity)
}
// Update customer stats
await customerService.updateCustomerStats(order.customer_id, {
total_spent: order.total,
order_count: 1
})
}Service Composition
// Composite service using multiple modules
class CheckoutService {
constructor(
private cartService: CartService,
private paymentService: PaymentService,
private orderService: OrderService,
private customerService: CustomerService,
private promotionService: PromotionService,
private taxService: TaxService
) {}
async processCheckout(cartId: string, checkoutData: CheckoutData) {
// 1. Validate cart and apply promotions
const cart = await this.cartService.retrieveCart(cartId)
await this.promotionService.validatePromotions(cart.id)
// 2. Calculate final totals including tax
const taxLines = await this.taxService.calculateTax({
cart_id: cart.id,
shipping_address: checkoutData.shipping_address
})
// 3. Create payment collection
const paymentCollection = await this.paymentService.createPaymentCollection({
currency_code: cart.currency_code,
amount: cart.total + taxLines.total,
region_id: cart.region_id
})
// 4. Process payment
const payment = await this.paymentService.processPayment({
payment_collection_id: paymentCollection.id,
payment_method: checkoutData.payment_method
})
// 5. Create order
const order = await this.orderService.createFromCart(cart.id, {
payment_collection_id: paymentCollection.id
})
// 6. Update customer profile
if (checkoutData.customer_id) {
await this.customerService.updateLastOrder(
checkoutData.customer_id,
order.id
)
}
return order
}
}Custom Module Extensions
// Extending commerce modules with custom fields
const ExtendedProduct = model.define("product", {
// Extend product model
seo_title: model.text().nullable(),
seo_description: model.text().nullable(),
custom_attributes: model.json().nullable(),
supplier_info: model.json().nullable(),
environmental_rating: model.enum(["A", "B", "C", "D", "F"]).nullable()
})
// Custom service extending ProductService
class ExtendedProductService extends ProductService {
async updateSEOInfo(productId: string, seoData: {
seo_title?: string
seo_description?: string
}) {
return await this.productRepository.update(productId, seoData)
}
async getProductsBySustainabilityRating(rating: string) {
return await this.productRepository.find({
where: { environmental_rating: rating }
})
}
}MedusaJS Third-Party Integrations Guide
Integration Architecture
Service-Based Integration Pattern
// src/services/external-integration.ts
abstract class ExternalIntegrationService {
protected apiKey: string
protected baseUrl: string
protected timeout: number = 30000
constructor(config: {
apiKey: string
baseUrl: string
timeout?: number
}) {
this.apiKey = config.apiKey
this.baseUrl = config.baseUrl
this.timeout = config.timeout || 30000
}
protected async makeRequest<T>(
endpoint: string,
options: {
method?: "GET" | "POST" | "PUT" | "DELETE"
data?: any
headers?: Record<string, string>
} = {}
): Promise<T> {
const { method = "GET", data, headers = {} } = options
try {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.apiKey}`,
...headers
},
body: data ? JSON.stringify(data) : undefined,
signal: AbortSignal.timeout(this.timeout)
})
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error(`Integration error for ${endpoint}:`, error)
throw error
}
}
protected abstract validateConfig(): boolean
abstract healthCheck(): Promise<boolean>
}Payment Gateway Integration
Stripe Integration
// src/services/stripe-payment.ts
import Stripe from "stripe"
import { ExternalIntegrationService } from "./external-integration"
class StripePaymentService extends ExternalIntegrationService {
private stripe: Stripe
constructor(config: { secretKey: string; webhookSecret: string }) {
super({
apiKey: config.secretKey,
baseUrl: "https://api.stripe.com/v1"
})
this.stripe = new Stripe(config.secretKey, {
apiVersion: "2023-10-16"
})
}
validateConfig(): boolean {
return !!this.apiKey && this.apiKey.startsWith("sk_")
}
async healthCheck(): Promise<boolean> {
try {
await this.stripe.balance.retrieve()
return true
} catch {
return false
}
}
async createPaymentIntent(data: {
amount: number
currency: string
customer?: string
metadata?: Record<string, string>
}) {
return await this.stripe.paymentIntents.create({
amount: data.amount,
currency: data.currency,
customer: data.customer,
metadata: data.metadata,
automatic_payment_methods: { enabled: true }
})
}
async confirmPayment(paymentIntentId: string) {
return await this.stripe.paymentIntents.confirm(paymentIntentId)
}
async createRefund(chargeId: string, amount?: number) {
return await this.stripe.refunds.create({
charge: chargeId,
amount
})
}
async handleWebhook(payload: string, signature: string) {
const event = this.stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
switch (event.type) {
case "payment_intent.succeeded":
await this.handlePaymentSuccess(event.data.object)
break
case "payment_intent.payment_failed":
await this.handlePaymentFailure(event.data.object)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
}
private async handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
const orderId = paymentIntent.metadata.order_id
if (orderId) {
const orderService = container.resolve("orderService")
await orderService.updateOrder(orderId, { payment_status: "captured" })
}
}
private async handlePaymentFailure(paymentIntent: Stripe.PaymentIntent) {
const orderId = paymentIntent.metadata.order_id
if (orderId) {
const orderService = container.resolve("orderService")
await orderService.updateOrder(orderId, { payment_status: "failed" })
}
}
}PayPal Integration
// src/services/paypal-payment.ts
class PayPalPaymentService extends ExternalIntegrationService {
private clientId: string
private clientSecret: string
constructor(config: {
clientId: string
clientSecret: string
environment: "sandbox" | "live"
}) {
const baseUrl = config.environment === "sandbox"
? "https://api.sandbox.paypal.com"
: "https://api.paypal.com"
super({ apiKey: "", baseUrl })
this.clientId = config.clientId
this.clientSecret = config.clientSecret
}
validateConfig(): boolean {
return !!this.clientId && !!this.clientSecret
}
async healthCheck(): Promise<boolean> {
try {
await this.getAccessToken()
return true
} catch {
return false
}
}
private async getAccessToken(): Promise<string> {
const auth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")
const response = await fetch(`${this.baseUrl}/v1/oauth2/token`, {
method: "POST",
headers: {
"Authorization": `Basic ${auth}`,
"Content-Type": "application/x-www-form-urlencoded"
},
body: "grant_type=client_credentials"
})
const data = await response.json()
return data.access_token
}
async createOrder(orderData: {
amount: string
currency: string
reference_id: string
}) {
const accessToken = await this.getAccessToken()
return await this.makeRequest("/v2/checkout/orders", {
method: "POST",
headers: { "Authorization": `Bearer ${accessToken}` },
data: {
intent: "CAPTURE",
purchase_units: [{
reference_id: orderData.reference_id,
amount: {
currency_code: orderData.currency,
value: orderData.amount
}
}]
}
})
}
async captureOrder(orderId: string) {
const accessToken = await this.getAccessToken()
return await this.makeRequest(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
headers: { "Authorization": `Bearer ${accessToken}` }
})
}
}Shipping Provider Integration
Shippo Integration
// src/services/shippo-shipping.ts
class ShippoShippingService extends ExternalIntegrationService {
constructor(apiKey: string) {
super({
apiKey,
baseUrl: "https://api.goshippo.com"
})
}
validateConfig(): boolean {
return !!this.apiKey && this.apiKey.startsWith("shippo_")
}
async healthCheck(): Promise<boolean> {
try {
await this.makeRequest("/", {
headers: { "Authorization": `ShippoToken ${this.apiKey}` }
})
return true
} catch {
return false
}
}
async createShipment(data: {
from_address: Address
to_address: Address
parcels: Parcel[]
async?: boolean
}) {
return await this.makeRequest("/shipments/", {
method: "POST",
headers: { "Authorization": `ShippoToken ${this.apiKey}` },
data
})
}
async getRates(shipmentId: string) {
return await this.makeRequest(`/shipments/${shipmentId}/rates/`, {
headers: { "Authorization": `ShippoToken ${this.apiKey}` }
})
}
async createLabel(rateId: string) {
return await this.makeRequest("/transactions/", {
method: "POST",
headers: { "Authorization": `ShippoToken ${this.apiKey}` },
data: {
rate: rateId,
label_file_type: "PDF"
}
})
}
async trackShipment(carrier: string, trackingNumber: string) {
return await this.makeRequest(`/tracks/${carrier}/${trackingNumber}/`, {
headers: { "Authorization": `ShippoToken ${this.apiKey}` }
})
}
}
interface Address {
name: string
street1: string
street2?: string
city: string
state: string
zip: string
country: string
}
interface Parcel {
length: string
width: string
height: string
distance_unit: "in" | "cm"
weight: string
mass_unit: "lb" | "kg"
}Inventory Management Integration
Inventory Service Integration
// src/services/inventory-sync.ts
class InventoryManagementService extends ExternalIntegrationService {
constructor(config: {
apiKey: string
baseUrl: string
storeId: string
}) {
super(config)
this.storeId = config.storeId
}
private storeId: string
validateConfig(): boolean {
return !!this.apiKey && !!this.storeId
}
async healthCheck(): Promise<boolean> {
try {
await this.makeRequest(`/stores/${this.storeId}/health`)
return true
} catch {
return false
}
}
async syncInventory(variants: Array<{
sku: string
quantity: number
location_id?: string
}>) {
const batchSize = 50
const results = []
for (let i = 0; i < variants.length; i += batchSize) {
const batch = variants.slice(i, i + batchSize)
const result = await this.makeRequest(`/stores/${this.storeId}/inventory/sync`, {
method: "POST",
data: { variants: batch }
})
results.push(...result.updated_variants)
}
return results
}
async getInventoryLevels(skus: string[]) {
return await this.makeRequest(`/stores/${this.storeId}/inventory`, {
method: "POST",
data: { skus }
})
}
async reserveInventory(items: Array<{
sku: string
quantity: number
reservation_id: string
}>) {
return await this.makeRequest(`/stores/${this.storeId}/inventory/reserve`, {
method: "POST",
data: { items }
})
}
async releaseReservation(reservationId: string) {
return await this.makeRequest(`/stores/${this.storeId}/inventory/release/${reservationId}`, {
method: "DELETE"
})
}
}Email Service Integration
SendGrid Integration
// src/services/sendgrid-email.ts
import sgMail from "@sendgrid/mail"
class SendGridEmailService {
constructor(apiKey: string) {
sgMail.setApiKey(apiKey)
}
async sendTransactionalEmail(data: {
to: string
template_id: string
dynamic_template_data: Record<string, any>
from?: string
}) {
const msg = {
to: data.to,
from: data.from || process.env.DEFAULT_FROM_EMAIL,
templateId: data.template_id,
dynamicTemplateData: data.dynamic_template_data
}
try {
await sgMail.send(msg)
return { success: true }
} catch (error) {
console.error("SendGrid error:", error)
throw error
}
}
async sendOrderConfirmation(order: any, customerEmail: string) {
return await this.sendTransactionalEmail({
to: customerEmail,
template_id: process.env.SENDGRID_ORDER_CONFIRMATION_TEMPLATE!,
dynamic_template_data: {
order_number: order.display_id,
total: order.total / 100, // Convert cents to dollars
items: order.items.map(item => ({
title: item.title,
quantity: item.quantity,
price: item.unit_price / 100
})),
shipping_address: order.shipping_address
}
})
}
async sendShippingNotification(order: any, trackingNumber: string) {
return await this.sendTransactionalEmail({
to: order.email,
template_id: process.env.SENDGRID_SHIPPING_TEMPLATE!,
dynamic_template_data: {
order_number: order.display_id,
tracking_number: trackingNumber,
carrier: order.shipping_methods[0]?.shipping_option?.name
}
})
}
}Webhook Handler Patterns
Generic Webhook Handler
// src/api/webhooks/[provider]/route.ts
import crypto from "crypto"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const { provider } = req.params
const signature = req.headers["x-webhook-signature"] as string
const payload = req.body
// Verify webhook signature
if (!verifyWebhookSignature(payload, signature, provider)) {
return res.status(401).json({ error: "Invalid signature" })
}
const webhookService = req.scope.resolve("webhookService")
try {
await webhookService.processWebhook(provider, payload)
res.status(200).json({ received: true })
} catch (error) {
console.error(`Webhook processing error for ${provider}:`, error)
res.status(500).json({ error: "Webhook processing failed" })
}
}
function verifyWebhookSignature(
payload: any,
signature: string,
provider: string
): boolean {
const secret = process.env[`${provider.toUpperCase()}_WEBHOOK_SECRET`]
if (!secret) {
console.error(`No webhook secret found for provider: ${provider}`)
return false
}
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(JSON.stringify(payload))
.digest("hex")
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)
}Webhook Processing Service
// src/services/webhook.ts
class WebhookService {
constructor(
private eventBusService: any,
private stripeService: StripePaymentService,
private shippoService: ShippoShippingService
) {}
async processWebhook(provider: string, payload: any) {
switch (provider) {
case "stripe":
await this.handleStripeWebhook(payload)
break
case "shippo":
await this.handleShippoWebhook(payload)
break
case "inventory":
await this.handleInventoryWebhook(payload)
break
default:
throw new Error(`Unsupported webhook provider: ${provider}`)
}
}
private async handleStripeWebhook(payload: any) {
switch (payload.type) {
case "payment_intent.succeeded":
await this.eventBusService.emit("payment.captured", {
payment_id: payload.data.object.id,
order_id: payload.data.object.metadata.order_id
})
break
case "payment_intent.payment_failed":
await this.eventBusService.emit("payment.failed", {
payment_id: payload.data.object.id,
order_id: payload.data.object.metadata.order_id,
failure_reason: payload.data.object.last_payment_error?.message
})
break
}
}
private async handleShippoWebhook(payload: any) {
if (payload.event === "track_updated") {
await this.eventBusService.emit("shipment.updated", {
tracking_number: payload.data.tracking_number,
status: payload.data.tracking_status,
carrier: payload.data.carrier
})
}
}
private async handleInventoryWebhook(payload: any) {
if (payload.event === "inventory.updated") {
await this.eventBusService.emit("inventory.level_changed", {
sku: payload.data.sku,
quantity: payload.data.available_quantity,
location: payload.data.location_id
})
}
}
}Configuration Management
Integration Configuration
// src/config/integrations.ts
export interface IntegrationConfig {
enabled: boolean
provider: string
credentials: Record<string, string>
settings: Record<string, any>
}
export const integrationConfigs: Record<string, IntegrationConfig> = {
payment_stripe: {
enabled: process.env.STRIPE_ENABLED === "true",
provider: "stripe",
credentials: {
secret_key: process.env.STRIPE_SECRET_KEY!,
publishable_key: process.env.STRIPE_PUBLISHABLE_KEY!,
webhook_secret: process.env.STRIPE_WEBHOOK_SECRET!
},
settings: {
capture_method: "automatic",
payment_methods: ["card", "apple_pay", "google_pay"]
}
},
shipping_shippo: {
enabled: process.env.SHIPPO_ENABLED === "true",
provider: "shippo",
credentials: {
api_key: process.env.SHIPPO_API_KEY!
},
settings: {
default_currency: "USD",
async_shipments: true,
carriers: ["usps", "ups", "fedex"]
}
},
email_sendgrid: {
enabled: process.env.SENDGRID_ENABLED === "true",
provider: "sendgrid",
credentials: {
api_key: process.env.SENDGRID_API_KEY!
},
settings: {
templates: {
order_confirmation: process.env.SENDGRID_ORDER_TEMPLATE!,
shipping_notification: process.env.SENDGRID_SHIPPING_TEMPLATE!,
password_reset: process.env.SENDGRID_PASSWORD_RESET_TEMPLATE!
}
}
}
}Testing Integration Services
Integration Tests
// __tests__/integrations/stripe.test.ts
describe("Stripe Integration", () => {
let stripeService: StripePaymentService
beforeEach(() => {
stripeService = new StripePaymentService({
secretKey: "sk_test_...",
webhookSecret: "whsec_test_..."
})
})
describe("createPaymentIntent", () => {
it("should create payment intent successfully", async () => {
const paymentIntent = await stripeService.createPaymentIntent({
amount: 2000,
currency: "usd",
metadata: { order_id: "order_123" }
})
expect(paymentIntent.id).toMatch(/^pi_/)
expect(paymentIntent.amount).toBe(2000)
expect(paymentIntent.currency).toBe("usd")
})
})
describe("webhook handling", () => {
it("should process payment success webhook", async () => {
const payload = {
type: "payment_intent.succeeded",
data: {
object: {
id: "pi_test_123",
metadata: { order_id: "order_123" }
}
}
}
const spy = jest.spyOn(eventBusService, "emit")
await stripeService.handleWebhook(
JSON.stringify(payload),
"valid_signature"
)
expect(spy).toHaveBeenCalledWith("payment.captured", {
payment_id: "pi_test_123",
order_id: "order_123"
})
})
})
})MedusaJS Module Development Guide
Module Structure
Basic Module Setup
src/modules/my-module/
├── models/ # Data models
├── services/ # Business logic
├── index.ts # Module exports
└── migrations/ # Database migrationsData Models
Model Definition
// src/modules/blog/models/post.ts
import { model } from "@medusajs/framework/utils"
const Post = model.define("post", {
id: model.id().primaryKey(),
title: model.text(),
content: model.text().nullable(),
slug: model.text().searchable(),
published: model.boolean().default(false),
published_at: model.dateTime().nullable(),
author_id: model.text(),
category_id: model.text().nullable(),
tags: model.json().nullable(),
metadata: model.json().nullable(),
})
export default PostModel Relationships
// One-to-Many relationship
const Author = model.define("author", {
id: model.id().primaryKey(),
name: model.text(),
email: model.text().searchable(),
bio: model.text().nullable(),
})
const Post = model.define("post", {
id: model.id().primaryKey(),
title: model.text(),
author_id: model.text(),
author: model.belongsTo(() => Author, {
mappedBy: "author_id"
})
})
// Many-to-Many relationship
const Tag = model.define("tag", {
id: model.id().primaryKey(),
name: model.text(),
posts: model.manyToMany(() => Post, {
mappedBy: "post_tags",
joinColumn: "tag_id",
inverseJoinColumn: "post_id"
})
})Model Validation
import { z } from "zod"
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
slug: z.string().regex(/^[a-z0-9-]+$/),
published: z.boolean().default(false),
tags: z.array(z.string()).optional()
})
const Post = model.define("post", {
// ... model definition
}, {
validation: PostSchema
})Service Layer
Basic Service
// src/modules/blog/services/post.ts
import { MedusaService } from "@medusajs/framework/utils"
class PostService extends MedusaService({
Post: () => import("../models/post").then(m => m.default)
}) {
async createPost(data: {
title: string
content?: string
slug: string
author_id: string
}) {
const post = await this.postRepository.create({
...data,
published: false,
published_at: null
})
return await this.postRepository.save(post)
}
async publishPost(postId: string) {
const post = await this.postRepository.findOne({
where: { id: postId }
})
if (!post) {
throw new Error("Post not found")
}
post.published = true
post.published_at = new Date()
return await this.postRepository.save(post)
}
async listPosts(options: {
published?: boolean
author_id?: string
limit?: number
offset?: number
} = {}) {
const { published, author_id, limit = 20, offset = 0 } = options
const where: any = {}
if (published !== undefined) where.published = published
if (author_id) where.author_id = author_id
return await this.postRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { created_at: "DESC" }
})
}
async getPostBySlug(slug: string) {
return await this.postRepository.findOne({
where: { slug },
relations: ["author"]
})
}
async searchPosts(query: string, options: {
limit?: number
offset?: number
} = {}) {
const { limit = 20, offset = 0 } = options
return await this.postRepository.findAndCount({
where: [
{ title: { contains: query } },
{ content: { contains: query } }
],
take: limit,
skip: offset
})
}
}
export default PostServiceService with External Integration
// src/modules/blog/services/post-analytics.ts
import { MedusaService } from "@medusajs/framework/utils"
class PostAnalyticsService extends MedusaService({
Post: () => import("../models/post").then(m => m.default)
}) {
private analyticsClient = new AnalyticsClient(
process.env.ANALYTICS_API_KEY
)
async trackPostView(postId: string, viewerInfo: {
ip: string
userAgent: string
referrer?: string
}) {
const post = await this.postRepository.findOne({
where: { id: postId }
})
if (!post || !post.published) return
// Track in external analytics service
await this.analyticsClient.trackEvent({
event: "post_view",
properties: {
post_id: postId,
post_title: post.title,
post_slug: post.slug,
...viewerInfo
}
})
// Update local view count
await this.postRepository.update(
{ id: postId },
{ view_count: () => "view_count + 1" }
)
}
async getPostMetrics(postId: string) {
const metrics = await this.analyticsClient.getMetrics({
filters: { post_id: postId },
metrics: ["views", "unique_visitors", "engagement_time"]
})
return metrics
}
}Module Configuration
Module Index
// src/modules/blog/index.ts
import PostService from "./services/post"
import AuthorService from "./services/author"
import TagService from "./services/tag"
export const blogModuleDefinition = {
key: "blog",
registrationName: "blogService",
defaultPackage: false,
label: "Blog Module",
dependencies: ["eventBusService"],
defaultModuleDeclaration: {
resolve: "./modules/blog",
options: {
// Module configuration options
enableAnalytics: true,
cacheEnabled: true,
maxPostsPerPage: 50
}
}
}
export default blogModuleDefinition
export {
PostService,
AuthorService,
TagService
}Module Options
// src/modules/blog/types.ts
export interface BlogModuleOptions {
enableAnalytics?: boolean
cacheEnabled?: boolean
maxPostsPerPage?: number
allowedImageFormats?: string[]
autoGenerateSlug?: boolean
}Database Migrations
Generated Migration
// src/modules/blog/migrations/1234567890123-CreatePost.ts
import { Migration } from '@medusajs/framework/utils'
export const migration: Migration = {
name: "CreatePost1234567890123",
async up(queryRunner) {
await queryRunner.query(`
CREATE TABLE "post" (
"id" character varying NOT NULL,
"title" character varying NOT NULL,
"content" text,
"slug" character varying NOT NULL,
"published" boolean NOT NULL DEFAULT false,
"published_at" TIMESTAMP WITH TIME ZONE,
"author_id" character varying NOT NULL,
"view_count" integer NOT NULL DEFAULT 0,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deleted_at" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_post" PRIMARY KEY ("id"),
CONSTRAINT "UQ_post_slug" UNIQUE ("slug")
)
`)
await queryRunner.query(`
CREATE INDEX "IDX_post_published" ON "post" ("published")
`)
await queryRunner.query(`
CREATE INDEX "IDX_post_author_id" ON "post" ("author_id")
`)
},
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_post_author_id"`)
await queryRunner.query(`DROP INDEX "IDX_post_published"`)
await queryRunner.query(`DROP TABLE "post"`)
}
}Custom Migration Commands
# Generate migration for module
npx medusa db:generate blog
# Run migrations
npx medusa db:migrate
# Rollback migration
npx medusa db:migrate:rollbackEvent Handling
Event Subscribers
// src/modules/blog/subscribers/post-events.ts
import { SubscriberConfig, SubscriberArgs } from "@medusajs/framework"
export default async function postEventHandler({
event,
container
}: SubscriberArgs<{ id: string }>) {
const postService = container.resolve("postService")
const eventBusService = container.resolve("eventBusService")
const post = await postService.retrievePost(event.data.id)
if (event.name === "post.published") {
// Send notifications
await eventBusService.emit("notification.send", {
type: "post_published",
recipients: ["subscribers"],
data: { post }
})
// Update search index
await container.resolve("searchService").indexPost(post)
}
}
export const config: SubscriberConfig = {
event: ["post.created", "post.published", "post.deleted"]
}Testing Modules
Service Tests
// __tests__/modules/blog/services/post.test.ts
import PostService from "../../../../src/modules/blog/services/post"
describe("PostService", () => {
let service: PostService
beforeEach(() => {
service = new PostService({
postRepository: mockPostRepository
})
})
describe("createPost", () => {
it("should create a post with correct data", async () => {
const postData = {
title: "Test Post",
content: "Test content",
slug: "test-post",
author_id: "author-1"
}
const createdPost = await service.createPost(postData)
expect(createdPost.title).toBe("Test Post")
expect(createdPost.published).toBe(false)
})
})
describe("publishPost", () => {
it("should publish an existing post", async () => {
const postId = "post-1"
mockPostRepository.findOne.mockResolvedValue({
id: postId,
published: false
})
const result = await service.publishPost(postId)
expect(result.published).toBe(true)
expect(result.published_at).toBeInstanceOf(Date)
})
it("should throw error for non-existent post", async () => {
mockPostRepository.findOne.mockResolvedValue(null)
await expect(service.publishPost("invalid-id"))
.rejects
.toThrow("Post not found")
})
})
})Integration Tests
// __tests__/modules/blog/integration/post-workflow.test.ts
describe("Post Publishing Workflow", () => {
it("should complete full post lifecycle", async () => {
// Create author
const author = await authorService.createAuthor({
name: "John Doe",
email: "john@example.com"
})
// Create post
const post = await postService.createPost({
title: "Integration Test Post",
slug: "integration-test-post",
author_id: author.id
})
expect(post.published).toBe(false)
// Publish post
const publishedPost = await postService.publishPost(post.id)
expect(publishedPost.published).toBe(true)
expect(publishedPost.published_at).toBeDefined()
// Verify post is searchable
const searchResults = await postService.searchPosts("Integration")
expect(searchResults[1]).toBe(1) // count
expect(searchResults[0][0].id).toBe(post.id)
})
})MedusaJS Testing Patterns
Testing Setup
Test Configuration
// jest.config.js
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
setupFilesAfterEnv: ["<rootDir>/test/setup.ts"],
testMatch: [
"**/__tests__/**/*.test.ts",
"**/test/**/*.test.ts"
],
moduleNameMapping: {
"^@/(.*)$": "<rootDir>/src/$1",
"^@test/(.*)$": "<rootDir>/test/$1"
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/index.ts"
],
coverageDirectory: "coverage",
coverageReporters: ["text", "html", "lcov"]
}Test Setup File
// test/setup.ts
import { MockContainer } from "@test/mocks/container"
import { MockDatabase } from "@test/mocks/database"
// Global test setup
beforeAll(async () => {
// Setup test database
await MockDatabase.setup()
})
afterAll(async () => {
// Cleanup test database
await MockDatabase.cleanup()
})
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks()
})
// Mock external services
jest.mock("stripe", () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
paymentIntents: {
create: jest.fn(),
retrieve: jest.fn(),
confirm: jest.fn()
},
webhooks: {
constructEvent: jest.fn()
}
}))
}))
// Mock container resolution
jest.mock("@medusajs/framework/utils", () => ({
...jest.requireActual("@medusajs/framework/utils"),
MedusaService: jest.fn().mockImplementation(() => MockContainer)
}))Unit Testing
Service Testing
// __tests__/services/product.test.ts
import ProductService from "@/modules/product/services/product"
import { MockRepository } from "@test/mocks/repository"
describe("ProductService", () => {
let productService: ProductService
let mockProductRepository: MockRepository
let mockVariantRepository: MockRepository
beforeEach(() => {
mockProductRepository = new MockRepository()
mockVariantRepository = new MockRepository()
productService = new ProductService({
productRepository: mockProductRepository,
variantRepository: mockVariantRepository
})
})
describe("createProduct", () => {
it("should create a product with valid data", async () => {
const productData = {
title: "Test Product",
description: "Test description",
handle: "test-product",
status: "published"
}
const expectedProduct = {
id: "prod_1",
...productData,
created_at: new Date(),
updated_at: new Date()
}
mockProductRepository.create.mockResolvedValue(expectedProduct)
mockProductRepository.save.mockResolvedValue(expectedProduct)
const result = await productService.createProduct(productData)
expect(mockProductRepository.create).toHaveBeenCalledWith(productData)
expect(mockProductRepository.save).toHaveBeenCalledWith(expectedProduct)
expect(result).toEqual(expectedProduct)
})
it("should throw error for duplicate handle", async () => {
const productData = {
title: "Test Product",
handle: "existing-handle"
}
mockProductRepository.findOne.mockResolvedValue({ id: "existing_prod" })
await expect(productService.createProduct(productData))
.rejects
.toThrow("Product with handle 'existing-handle' already exists")
})
it("should validate required fields", async () => {
const invalidData = { description: "Missing title" }
await expect(productService.createProduct(invalidData as any))
.rejects
.toThrow("Title is required")
})
})
describe("updateProduct", () => {
it("should update existing product", async () => {
const productId = "prod_1"
const updateData = { title: "Updated Title" }
const existingProduct = {
id: productId,
title: "Original Title",
handle: "test-product"
}
const updatedProduct = { ...existingProduct, ...updateData }
mockProductRepository.findOne.mockResolvedValue(existingProduct)
mockProductRepository.save.mockResolvedValue(updatedProduct)
const result = await productService.updateProduct(productId, updateData)
expect(result.title).toBe("Updated Title")
expect(mockProductRepository.save).toHaveBeenCalledWith(updatedProduct)
})
it("should throw error for non-existent product", async () => {
const productId = "non_existent"
mockProductRepository.findOne.mockResolvedValue(null)
await expect(productService.updateProduct(productId, {}))
.rejects
.toThrow("Product not found")
})
})
describe("searchProducts", () => {
it("should search products by title", async () => {
const searchTerm = "test"
const mockProducts = [
{ id: "prod_1", title: "Test Product 1" },
{ id: "prod_2", title: "Test Product 2" }
]
mockProductRepository.findAndCount.mockResolvedValue([mockProducts, 2])
const result = await productService.searchProducts(searchTerm)
expect(result.products).toHaveLength(2)
expect(result.count).toBe(2)
expect(mockProductRepository.findAndCount).toHaveBeenCalledWith({
where: [
{ title: { contains: searchTerm } },
{ description: { contains: searchTerm } }
],
take: 20,
skip: 0
})
})
})
})API Route Testing
// __tests__/api/products/route.test.ts
import { GET, POST } from "@/api/products/route"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
describe("Products API", () => {
let mockReq: Partial<MedusaRequest>
let mockRes: Partial<MedusaResponse>
let mockProductService: any
beforeEach(() => {
mockProductService = {
listProducts: jest.fn(),
createProduct: jest.fn(),
retrieveProduct: jest.fn()
}
mockReq = {
scope: {
resolve: jest.fn().mockReturnValue(mockProductService)
},
query: {},
body: {},
params: {}
}
mockRes = {
json: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn()
}
})
describe("GET /products", () => {
it("should return products list", async () => {
const mockProducts = [
{ id: "prod_1", title: "Product 1" },
{ id: "prod_2", title: "Product 2" }
]
mockProductService.listProducts.mockResolvedValue(mockProducts)
await GET(mockReq as MedusaRequest, mockRes as MedusaResponse)
expect(mockRes.json).toHaveBeenCalledWith({
products: mockProducts
})
})
it("should handle query parameters", async () => {
mockReq.query = { limit: "10", offset: "5", q: "test" }
mockProductService.listProducts.mockResolvedValue([])
await GET(mockReq as MedusaRequest, mockRes as MedusaResponse)
expect(mockProductService.listProducts).toHaveBeenCalledWith({
limit: 10,
offset: 5,
q: "test"
})
})
it("should handle service errors", async () => {
mockProductService.listProducts.mockRejectedValue(new Error("Database error"))
await GET(mockReq as MedusaRequest, mockRes as MedusaResponse)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Internal server error",
message: "Database error"
})
})
})
describe("POST /products", () => {
it("should create a new product", async () => {
const productData = {
title: "New Product",
description: "Product description",
handle: "new-product"
}
const createdProduct = { id: "prod_new", ...productData }
mockReq.body = productData
mockProductService.createProduct.mockResolvedValue(createdProduct)
await POST(mockReq as MedusaRequest, mockRes as MedusaResponse)
expect(mockProductService.createProduct).toHaveBeenCalledWith(productData)
expect(mockRes.status).toHaveBeenCalledWith(201)
expect(mockRes.json).toHaveBeenCalledWith({
product: createdProduct
})
})
it("should validate request body", async () => {
mockReq.body = { description: "Missing title" }
await POST(mockReq as MedusaRequest, mockRes as MedusaResponse)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Validation error",
details: expect.any(Array)
})
})
})
})Integration Testing
Database Integration Tests
// __tests__/integration/product.test.ts
import { initializeTestDatabase, cleanupTestDatabase } from "@test/helpers/database"
import { createTestApplication } from "@test/helpers/application"
import { ProductService } from "@/modules/product/services/product"
describe("Product Integration Tests", () => {
let app: any
let productService: ProductService
beforeAll(async () => {
await initializeTestDatabase()
app = await createTestApplication()
productService = app.container.resolve("productService")
})
afterAll(async () => {
await cleanupTestDatabase()
})
beforeEach(async () => {
await app.database.query("TRUNCATE TABLE product CASCADE")
})
it("should create and retrieve product with variants", async () => {
// Create product
const product = await productService.createProduct({
title: "Integration Test Product",
description: "Test product for integration testing",
handle: "integration-test-product"
})
expect(product.id).toBeDefined()
expect(product.title).toBe("Integration Test Product")
// Create variants
const variant1 = await productService.createProductVariant(product.id, {
title: "Small",
sku: "INT-TEST-SM",
prices: [{ currency_code: "usd", amount: 2000 }]
})
const variant2 = await productService.createProductVariant(product.id, {
title: "Large",
sku: "INT-TEST-LG",
prices: [{ currency_code: "usd", amount: 2500 }]
})
// Retrieve product with variants
const retrievedProduct = await productService.retrieveProduct(product.id, {
relations: ["variants", "variants.prices"]
})
expect(retrievedProduct.variants).toHaveLength(2)
expect(retrievedProduct.variants[0].sku).toMatch(/INT-TEST-(SM|LG)/)
expect(retrievedProduct.variants[0].prices).toHaveLength(1)
})
it("should handle product search correctly", async () => {
// Create test products
await Promise.all([
productService.createProduct({
title: "Red Shirt",
description: "A red cotton shirt",
handle: "red-shirt"
}),
productService.createProduct({
title: "Blue Shirt",
description: "A blue cotton shirt",
handle: "blue-shirt"
}),
productService.createProduct({
title: "Red Pants",
description: "Red cotton pants",
handle: "red-pants"
})
])
// Search for "red" products
const redProducts = await productService.searchProducts("red")
expect(redProducts.products).toHaveLength(2)
// Search for "shirt" products
const shirtProducts = await productService.searchProducts("shirt")
expect(shirtProducts.products).toHaveLength(2)
// Search for "blue" products
const blueProducts = await productService.searchProducts("blue")
expect(blueProducts.products).toHaveLength(1)
})
})API Integration Tests
// __tests__/integration/api/products.test.ts
import request from "supertest"
import { createTestApplication } from "@test/helpers/application"
describe("Products API Integration", () => {
let app: any
beforeAll(async () => {
app = await createTestApplication()
})
beforeEach(async () => {
await app.database.query("TRUNCATE TABLE product CASCADE")
})
describe("Product CRUD Operations", () => {
it("should complete full product lifecycle", async () => {
// Create product
const createResponse = await request(app)
.post("/api/products")
.send({
title: "API Test Product",
description: "Product created via API test",
handle: "api-test-product"
})
.expect(201)
const productId = createResponse.body.product.id
expect(productId).toBeDefined()
// Retrieve product
const getResponse = await request(app)
.get(`/api/products/${productId}`)
.expect(200)
expect(getResponse.body.product.title).toBe("API Test Product")
// Update product
const updateResponse = await request(app)
.put(`/api/products/${productId}`)
.send({
title: "Updated API Test Product",
status: "published"
})
.expect(200)
expect(updateResponse.body.product.title).toBe("Updated API Test Product")
expect(updateResponse.body.product.status).toBe("published")
// List products
const listResponse = await request(app)
.get("/api/products")
.expect(200)
expect(listResponse.body.products).toHaveLength(1)
expect(listResponse.body.products[0].id).toBe(productId)
// Delete product
await request(app)
.delete(`/api/products/${productId}`)
.expect(204)
// Verify deletion
await request(app)
.get(`/api/products/${productId}`)
.expect(404)
})
it("should handle validation errors", async () => {
const response = await request(app)
.post("/api/products")
.send({
description: "Product without title"
})
.expect(400)
expect(response.body.error).toContain("validation")
})
it("should search products correctly", async () => {
// Create test products
await Promise.all([
request(app)
.post("/api/products")
.send({
title: "Red T-Shirt",
handle: "red-tshirt"
}),
request(app)
.post("/api/products")
.send({
title: "Blue T-Shirt",
handle: "blue-tshirt"
})
])
// Search for T-Shirt
const searchResponse = await request(app)
.get("/api/products?q=T-Shirt")
.expect(200)
expect(searchResponse.body.products).toHaveLength(2)
})
})
})Mock Utilities
Repository Mocks
// test/mocks/repository.ts
export class MockRepository {
find = jest.fn()
findOne = jest.fn()
findAndCount = jest.fn()
create = jest.fn()
save = jest.fn()
update = jest.fn()
delete = jest.fn()
remove = jest.fn()
count = jest.fn()
reset() {
Object.values(this).forEach(mock => {
if (typeof mock === 'function' && 'mockClear' in mock) {
mock.mockClear()
}
})
}
}Service Mocks
// test/mocks/services.ts
export const createMockProductService = () => ({
listProducts: jest.fn(),
retrieveProduct: jest.fn(),
createProduct: jest.fn(),
updateProduct: jest.fn(),
deleteProduct: jest.fn(),
searchProducts: jest.fn(),
createProductVariant: jest.fn(),
updateProductVariant: jest.fn(),
deleteProductVariant: jest.fn()
})
export const createMockOrderService = () => ({
listOrders: jest.fn(),
retrieveOrder: jest.fn(),
createOrder: jest.fn(),
updateOrder: jest.fn(),
cancelOrder: jest.fn(),
createFulfillment: jest.fn(),
createPayment: jest.fn()
})
export const createMockCustomerService = () => ({
listCustomers: jest.fn(),
retrieveCustomer: jest.fn(),
createCustomer: jest.fn(),
updateCustomer: jest.fn(),
deleteCustomer: jest.fn(),
addCustomerToGroup: jest.fn(),
removeCustomerFromGroup: jest.fn()
})Test Helpers
// test/helpers/database.ts
import { DataSource } from "typeorm"
let testDataSource: DataSource
export async function initializeTestDatabase() {
testDataSource = new DataSource({
type: "sqlite",
database: ":memory:",
entities: ["src/**/*.entity.ts"],
synchronize: true,
logging: false
})
await testDataSource.initialize()
return testDataSource
}
export async function cleanupTestDatabase() {
if (testDataSource?.isInitialized) {
await testDataSource.destroy()
}
}
export function getTestDataSource() {
return testDataSource
}// test/helpers/application.ts
import { createMedusaApp } from "@medusajs/framework"
export async function createTestApplication() {
const app = await createMedusaApp({
database: {
type: "sqlite",
database: ":memory:",
synchronize: true
},
redis: {
host: "localhost",
port: 6379,
db: 1 // Use different DB for tests
}
})
await app.initialize()
return app
}Performance Testing
Load Testing
// __tests__/performance/products.test.ts
describe("Product Performance Tests", () => {
let app: any
let productService: any
beforeAll(async () => {
app = await createTestApplication()
productService = app.container.resolve("productService")
})
it("should handle bulk product creation efficiently", async () => {
const startTime = Date.now()
const promises = Array.from({ length: 100 }, (_, index) =>
productService.createProduct({
title: `Performance Test Product ${index}`,
handle: `performance-test-${index}`,
description: "Bulk created product for performance testing"
})
)
await Promise.all(promises)
const endTime = Date.now()
const duration = endTime - startTime
console.log(`Created 100 products in ${duration}ms`)
expect(duration).toBeLessThan(5000) // Should complete in under 5 seconds
})
it("should search large product catalog efficiently", async () => {
// Create large dataset
await Promise.all(
Array.from({ length: 1000 }, (_, index) =>
productService.createProduct({
title: `Product ${index}`,
handle: `product-${index}`,
description: `Description for product ${index}`
})
)
)
const startTime = Date.now()
const results = await productService.searchProducts("Product", {
limit: 50,
offset: 0
})
const endTime = Date.now()
const duration = endTime - startTime
console.log(`Searched 1000 products in ${duration}ms`)
expect(duration).toBeLessThan(1000) // Should complete in under 1 second
expect(results.products).toHaveLength(50)
})
})Memory Testing
// __tests__/performance/memory.test.ts
describe("Memory Usage Tests", () => {
it("should not have memory leaks in product operations", async () => {
const getMemoryUsage = () => process.memoryUsage().heapUsed
const initialMemory = getMemoryUsage()
// Perform many operations
for (let i = 0; i < 1000; i++) {
await productService.createProduct({
title: `Memory Test Product ${i}`,
handle: `memory-test-${i}`
})
await productService.deleteProduct(`memory-test-${i}`)
}
// Force garbage collection
if (global.gc) global.gc()
const finalMemory = getMemoryUsage()
const memoryIncrease = finalMemory - initialMemory
console.log(`Memory increase: ${memoryIncrease / 1024 / 1024} MB`)
// Memory increase should be minimal
expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024) // 50 MB
})
})MedusaJS Workflows and Scheduled Jobs
Scheduled Jobs
Basic Job Structure
// src/jobs/cleanup-expired-carts.ts
import { MedusaContainer } from "@medusajs/framework/types"
export default async function cleanupExpiredCartsJob(container: MedusaContainer) {
console.log("Starting expired carts cleanup...")
const cartService = container.resolve("cartService")
const eventBusService = container.resolve("eventBusService")
// Find carts older than 7 days with no activity
const expiredCarts = await cartService.listCarts({
created_at: {
lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
},
completed_at: null
})
let deletedCount = 0
for (const cart of expiredCarts) {
try {
await cartService.deleteCart(cart.id)
deletedCount++
// Emit event for tracking
await eventBusService.emit("cart.expired", {
cart_id: cart.id,
created_at: cart.created_at
})
} catch (error) {
console.error(`Failed to delete cart ${cart.id}:`, error)
}
}
console.log(`Cleanup completed. Deleted ${deletedCount} expired carts.`)
}
export const config = {
name: "cleanup-expired-carts",
schedule: "0 2 * * *" // Run daily at 2 AM
}Inventory Synchronization Job
// src/jobs/sync-inventory.ts
import { MedusaContainer } from "@medusajs/framework/types"
export default async function syncInventoryJob(container: MedusaContainer) {
console.log("Starting inventory synchronization...")
const productService = container.resolve("productService")
const inventoryService = container.resolve("inventoryService")
const externalInventoryService = container.resolve("externalInventoryService")
try {
// Get all product variants
const variants = await productService.listProductVariants({
take: 1000 // Process in batches
})
// Get external inventory levels
const skus = variants.map(variant => variant.sku).filter(Boolean)
const externalInventory = await externalInventoryService.getInventoryLevels(skus)
const updates = []
for (const variant of variants) {
if (!variant.sku) continue
const externalLevel = externalInventory.find(item => item.sku === variant.sku)
if (!externalLevel) continue
const currentLevel = await inventoryService.getInventoryLevel(variant.inventory_item_id)
if (currentLevel.stocked_quantity !== externalLevel.quantity) {
updates.push({
inventory_item_id: variant.inventory_item_id,
location_id: externalLevel.location_id,
stocked_quantity: externalLevel.quantity
})
}
}
// Apply updates in batches
const batchSize = 50
for (let i = 0; i < updates.length; i += batchSize) {
const batch = updates.slice(i, i + batchSize)
await inventoryService.updateInventoryLevels(batch)
}
console.log(`Inventory sync completed. Updated ${updates.length} items.`)
} catch (error) {
console.error("Inventory sync failed:", error)
// Send alert for critical failures
const notificationService = container.resolve("notificationService")
await notificationService.sendAlert({
type: "inventory_sync_failure",
message: error.message,
severity: "high"
})
}
}
export const config = {
name: "sync-inventory",
schedule: "0 */6 * * *" // Every 6 hours
}Order Processing Job
// src/jobs/process-pending-orders.ts
import { MedusaContainer } from "@medusajs/framework/types"
export default async function processPendingOrdersJob(container: MedusaContainer) {
console.log("Processing pending orders...")
const orderService = container.resolve("orderService")
const paymentService = container.resolve("paymentService")
const fulfillmentService = container.resolve("fulfillmentService")
const emailService = container.resolve("emailService")
// Get orders that are paid but not fulfilled
const pendingOrders = await orderService.listOrders({
payment_status: "captured",
fulfillment_status: "not_fulfilled"
})
for (const order of pendingOrders) {
try {
// Check if all items are in stock
const canFulfill = await fulfillmentService.canFulfillOrder(order.id)
if (canFulfill) {
// Create fulfillment
const fulfillment = await fulfillmentService.createFulfillment(order.id, {
items: order.items.map(item => ({
id: item.id,
quantity: item.quantity
}))
})
// Generate shipping label if needed
if (order.shipping_methods.length > 0) {
const label = await fulfillmentService.createShippingLabel(fulfillment.id)
// Send shipping notification
await emailService.sendShippingNotification(
order,
label.tracking_number
)
}
console.log(`Order ${order.display_id} fulfilled successfully`)
} else {
// Log orders that can't be fulfilled
console.log(`Order ${order.display_id} cannot be fulfilled - insufficient inventory`)
// Optional: Send notification to admin
const notificationService = container.resolve("notificationService")
await notificationService.notifyAdmin({
type: "insufficient_inventory",
order_id: order.id,
display_id: order.display_id
})
}
} catch (error) {
console.error(`Failed to process order ${order.display_id}:`, error)
}
}
console.log(`Processed ${pendingOrders.length} pending orders`)
}
export const config = {
name: "process-pending-orders",
schedule: "*/15 * * * *" // Every 15 minutes during business hours
}Analytics Data Collection Job
// src/jobs/collect-analytics-data.ts
import { MedusaContainer } from "@medusajs/framework/types"
export default async function collectAnalyticsDataJob(container: MedusaContainer) {
console.log("Collecting analytics data...")
const orderService = container.resolve("orderService")
const productService = container.resolve("productService")
const customerService = container.resolve("customerService")
const analyticsService = container.resolve("analyticsService")
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
yesterday.setHours(0, 0, 0, 0)
const today = new Date(yesterday)
today.setDate(today.getDate() + 1)
try {
// Collect daily metrics
const [orders, newCustomers, topProducts] = await Promise.all([
// Orders metrics
orderService.listOrders({
created_at: {
gte: yesterday,
lt: today
}
}),
// New customers
customerService.listCustomers({
created_at: {
gte: yesterday,
lt: today
}
}),
// Top selling products
productService.getTopSellingProducts({
period: "daily",
date: yesterday
})
])
const metrics = {
date: yesterday.toISOString().split('T')[0],
orders: {
count: orders.length,
total_revenue: orders.reduce((sum, order) => sum + order.total, 0),
average_order_value: orders.length > 0
? orders.reduce((sum, order) => sum + order.total, 0) / orders.length
: 0
},
customers: {
new_count: newCustomers.length,
returning_count: orders.filter(order =>
!newCustomers.find(customer => customer.id === order.customer_id)
).length
},
products: {
top_selling: topProducts.slice(0, 10).map(product => ({
id: product.id,
title: product.title,
units_sold: product.units_sold,
revenue: product.revenue
}))
}
}
// Store metrics
await analyticsService.storeDailyMetrics(metrics)
// Send daily report to admins
const reportService = container.resolve("reportService")
await reportService.sendDailyReport(metrics)
console.log(`Analytics data collected for ${metrics.date}`)
} catch (error) {
console.error("Analytics collection failed:", error)
}
}
export const config = {
name: "collect-analytics-data",
schedule: "0 1 * * *" // Daily at 1 AM
}Event-Driven Workflows
Order Workflow
// src/workflows/order-processing.ts
import {
createWorkflow,
WorkflowResponse,
createStep,
StepResponse
} from "@medusajs/framework/workflows-sdk"
// Individual steps
const validateOrderStep = createStep(
"validate-order",
async (input: { order_id: string }, { container }) => {
const orderService = container.resolve("orderService")
const order = await orderService.retrieveOrder(input.order_id)
if (!order) {
throw new Error("Order not found")
}
if (order.payment_status !== "captured") {
throw new Error("Payment not captured")
}
return new StepResponse({ order })
}
)
const checkInventoryStep = createStep(
"check-inventory",
async (input: { order: any }, { container }) => {
const inventoryService = container.resolve("inventoryService")
const insufficientItems = []
for (const item of input.order.items) {
const level = await inventoryService.getInventoryLevel(item.variant.inventory_item_id)
if (level.stocked_quantity < item.quantity) {
insufficientItems.push({
variant_id: item.variant_id,
requested: item.quantity,
available: level.stocked_quantity
})
}
}
if (insufficientItems.length > 0) {
return new StepResponse(
{ canFulfill: false, insufficientItems },
{ canFulfill: false, insufficientItems }
)
}
return new StepResponse({ canFulfill: true })
}
)
const reserveInventoryStep = createStep(
"reserve-inventory",
async (input: { order: any }, { container }) => {
const inventoryService = container.resolve("inventoryService")
const reservations = []
for (const item of input.order.items) {
const reservation = await inventoryService.createReservation({
inventory_item_id: item.variant.inventory_item_id,
quantity: item.quantity,
location_id: item.variant.manage_inventory ? undefined : "default_location"
})
reservations.push(reservation)
}
return new StepResponse({ reservations })
},
// Compensation function (rollback)
async (input: { reservations: any[] }, { container }) => {
const inventoryService = container.resolve("inventoryService")
for (const reservation of input.reservations) {
await inventoryService.deleteReservation(reservation.id)
}
}
)
const createFulfillmentStep = createStep(
"create-fulfillment",
async (input: { order: any }, { container }) => {
const fulfillmentService = container.resolve("fulfillmentService")
const fulfillment = await fulfillmentService.createFulfillment(input.order.id, {
items: input.order.items.map(item => ({
id: item.id,
quantity: item.quantity
}))
})
return new StepResponse({ fulfillment })
}
)
const sendNotificationStep = createStep(
"send-notification",
async (input: { order: any; fulfillment: any }, { container }) => {
const emailService = container.resolve("emailService")
await emailService.sendFulfillmentNotification({
order: input.order,
fulfillment: input.fulfillment
})
return new StepResponse({ notificationSent: true })
}
)
// Compose workflow
export const orderProcessingWorkflow = createWorkflow(
"order-processing",
function (input: { order_id: string }) {
const { order } = validateOrderStep({ order_id: input.order_id })
const inventoryCheck = checkInventoryStep({ order })
// Conditional logic
const reservation = reserveInventoryStep({ order })
const fulfillment = createFulfillmentStep({ order })
const notification = sendNotificationStep({ order, fulfillment })
return new WorkflowResponse({
order,
fulfillment,
inventoryReserved: true,
notificationSent: notification.notificationSent
})
}
)Customer Registration Workflow
// src/workflows/customer-registration.ts
const validateCustomerDataStep = createStep(
"validate-customer-data",
async (input: {
email: string
password: string
first_name: string
last_name: string
}, { container }) => {
const customerService = container.resolve("customerService")
// Check if email already exists
const existingCustomer = await customerService.retrieveByEmail(input.email)
if (existingCustomer) {
throw new Error("Customer with this email already exists")
}
// Validate password strength
if (input.password.length < 8) {
throw new Error("Password must be at least 8 characters long")
}
return new StepResponse(input)
}
)
const createCustomerStep = createStep(
"create-customer",
async (input: {
email: string
password: string
first_name: string
last_name: string
}, { container }) => {
const customerService = container.resolve("customerService")
const customer = await customerService.createCustomer({
email: input.email,
first_name: input.first_name,
last_name: input.last_name
})
return new StepResponse({ customer })
}
)
const createAuthUserStep = createStep(
"create-auth-user",
async (input: {
customer: any
email: string
password: string
}, { container }) => {
const authService = container.resolve("authService")
const authUser = await authService.createAuthUser({
provider_id: "emailpass",
user_metadata: {
customer_id: input.customer.id,
email: input.email
},
provider_metadata: {
email: input.email,
password: input.password
}
})
return new StepResponse({ authUser })
}
)
const sendWelcomeEmailStep = createStep(
"send-welcome-email",
async (input: { customer: any }, { container }) => {
const emailService = container.resolve("emailService")
await emailService.sendWelcomeEmail({
to: input.customer.email,
customerName: `${input.customer.first_name} ${input.customer.last_name}`
})
return new StepResponse({ emailSent: true })
}
)
const assignToCustomerGroupStep = createStep(
"assign-customer-group",
async (input: { customer: any }, { container }) => {
const customerService = container.resolve("customerService")
// Assign to default customer group
await customerService.addCustomerToGroup(input.customer.id, "default_customers")
return new StepResponse({ groupAssigned: true })
}
)
export const customerRegistrationWorkflow = createWorkflow(
"customer-registration",
function (input: {
email: string
password: string
first_name: string
last_name: string
}) {
const validatedData = validateCustomerDataStep(input)
const { customer } = createCustomerStep(validatedData)
const { authUser } = createAuthUserStep({
customer,
email: input.email,
password: input.password
})
const welcomeEmail = sendWelcomeEmailStep({ customer })
const groupAssignment = assignToCustomerGroupStep({ customer })
return new WorkflowResponse({
customer,
authUser,
emailSent: welcomeEmail.emailSent,
groupAssigned: groupAssignment.groupAssigned
})
}
)Workflow Execution
Triggering Workflows
// src/api/workflows/execute/route.ts
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const { workflow_id, input } = req.body
try {
let result
switch (workflow_id) {
case "order-processing":
result = await orderProcessingWorkflow(req.scope).run(input)
break
case "customer-registration":
result = await customerRegistrationWorkflow(req.scope).run(input)
break
default:
return res.status(400).json({
error: `Unknown workflow: ${workflow_id}`
})
}
res.json({
success: true,
result: result.result,
transaction_id: result.transaction.id
})
} catch (error) {
console.error(`Workflow execution failed: ${error.message}`)
res.status(500).json({
error: "Workflow execution failed",
details: error.message
})
}
}Workflow Monitoring
// src/services/workflow-monitor.ts
class WorkflowMonitorService {
constructor(private container: any) {}
async getWorkflowStatus(transactionId: string) {
const workflowEngine = this.container.resolve("workflowEngine")
return await workflowEngine.getTransaction(transactionId)
}
async retryFailedWorkflow(transactionId: string) {
const workflowEngine = this.container.resolve("workflowEngine")
return await workflowEngine.retryTransaction(transactionId)
}
async getFailedWorkflows(limit = 50) {
const workflowEngine = this.container.resolve("workflowEngine")
return await workflowEngine.listTransactions({
status: "failed",
limit
})
}
async cancelWorkflow(transactionId: string) {
const workflowEngine = this.container.resolve("workflowEngine")
return await workflowEngine.cancelTransaction(transactionId)
}
}Cron Expression Examples
// Common cron patterns for scheduled jobs
export const cronExpressions = {
// Every minute
everyMinute: "* * * * *",
// Every 5 minutes
every5Minutes: "*/5 * * * *",
// Every 15 minutes
every15Minutes: "*/15 * * * *",
// Every hour at minute 0
hourly: "0 * * * *",
// Every 6 hours
every6Hours: "0 */6 * * *",
// Daily at 2:00 AM
daily2AM: "0 2 * * *",
// Daily at midnight
dailyMidnight: "0 0 * * *",
// Weekly on Sunday at 3:00 AM
weeklySunday: "0 3 * * 0",
// Monthly on the 1st at 4:00 AM
monthlyFirst: "0 4 1 * *",
// Weekdays only at 9:00 AM
weekdays9AM: "0 9 * * 1-5",
// Business hours every 30 minutes
businessHours: "*/30 9-17 * * 1-5"
}Error Handling and Monitoring
Job Error Handling
// src/jobs/base-job.ts
export abstract class BaseJob {
protected maxRetries = 3
protected retryDelay = 5000 // 5 seconds
abstract execute(container: MedusaContainer): Promise<void>
async run(container: MedusaContainer) {
let attempt = 0
while (attempt < this.maxRetries) {
try {
await this.execute(container)
break
} catch (error) {
attempt++
console.error(`Job failed (attempt ${attempt}/${this.maxRetries}):`, error)
if (attempt === this.maxRetries) {
await this.handleFinalFailure(error, container)
throw error
}
await new Promise(resolve => setTimeout(resolve, this.retryDelay))
}
}
}
protected async handleFinalFailure(error: Error, container: MedusaContainer) {
const notificationService = container.resolve("notificationService")
await notificationService.sendAlert({
type: "job_failure",
jobName: this.constructor.name,
error: error.message,
severity: "high"
})
}
}#!/bin/bash
# Production Build Script for MedusaJS
# Creates a production-ready build of the Medusa application
# Usage: ./scripts/build-production.sh [--admin-only]
set -e
ARGS=""
# Check for admin-only flag
if [[ "$*" == *"--admin-only"* ]]; then
ARGS="--admin-only"
echo "Building admin only for separate hosting..."
else
echo "Building full Medusa application for production..."
fi
npx medusa build $ARGS
if [[ "$*" == *"--admin-only"* ]]; then
echo "Admin build completed!"
echo "Build output: ./build"
echo "You can now deploy the admin separately (e.g., to Vercel)"
else
echo "Production build completed!"
echo "Build output: ./.medusa/server"
echo ""
echo "Next steps:"
echo "1. cd .medusa/server && npm install"
echo "2. Copy your .env file: cp ../../.env .env.production"
echo "3. Set NODE_ENV=production"
echo "4. Start the server: npm run start"
fi
#!/bin/bash
# Create API Route Script for MedusaJS
# Creates a new API route with basic CRUD operations
# Usage: ./scripts/create-api-route.sh <route-name>
set -e
if [ $# -eq 0 ]; then
echo "Error: Route name required"
echo "Usage: ./scripts/create-api-route.sh <route-name>"
echo "Example: ./scripts/create-api-route.sh posts"
exit 1
fi
ROUTE_NAME=$1
ROUTE_DIR="src/api/$ROUTE_NAME"
# Check if route already exists
if [ -d "$ROUTE_DIR" ]; then
echo "Error: API route '$ROUTE_NAME' already exists at $ROUTE_DIR"
exit 1
fi
echo "Creating API route: $ROUTE_NAME"
# Create directory
mkdir -p "$ROUTE_DIR"
# Create route.ts with basic CRUD operations
cat > "$ROUTE_DIR/route.ts" << 'EOF'
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
/**
* GET handler - List all items
*/
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
try {
// TODO: Implement your logic here
// Example: const service = req.scope.resolve("yourService")
// const items = await service.list()
res.json({
message: "GET request successful",
// items
})
} catch (error) {
res.status(500).json({
error: error.message
})
}
}
/**
* POST handler - Create a new item
*/
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
try {
// TODO: Implement your logic here
// Example: const service = req.scope.resolve("yourService")
// const item = await service.create(req.body)
res.status(201).json({
message: "POST request successful",
// item
})
} catch (error) {
res.status(500).json({
error: error.message
})
}
}
EOF
# Create [id]/route.ts for single item operations
mkdir -p "$ROUTE_DIR/[id]"
cat > "$ROUTE_DIR/[id]/route.ts" << 'EOF'
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
/**
* GET handler - Get a single item by ID
*/
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
try {
const { id } = req.params
// TODO: Implement your logic here
// Example: const service = req.scope.resolve("yourService")
// const item = await service.retrieve(id)
res.json({
message: `GET request for ID: ${id}`,
// item
})
} catch (error) {
res.status(500).json({
error: error.message
})
}
}
/**
* PUT handler - Update an item by ID
*/
export const PUT = async (req: MedusaRequest, res: MedusaResponse) => {
try {
const { id } = req.params
// TODO: Implement your logic here
// Example: const service = req.scope.resolve("yourService")
// const item = await service.update(id, req.body)
res.json({
message: `PUT request for ID: ${id}`,
// item
})
} catch (error) {
res.status(500).json({
error: error.message
})
}
}
/**
* DELETE handler - Delete an item by ID
*/
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
try {
const { id } = req.params
// TODO: Implement your logic here
// Example: const service = req.scope.resolve("yourService")
// await service.delete(id)
res.json({
message: `DELETE request for ID: ${id}`,
deleted: true
})
} catch (error) {
res.status(500).json({
error: error.message
})
}
}
EOF
echo ""
echo "API route '$ROUTE_NAME' created successfully!"
echo "Location: $ROUTE_DIR"
echo ""
echo "Available endpoints:"
echo " GET /api/$ROUTE_NAME"
echo " POST /api/$ROUTE_NAME"
echo " GET /api/$ROUTE_NAME/:id"
echo " PUT /api/$ROUTE_NAME/:id"
echo " DELETE /api/$ROUTE_NAME/:id"
echo ""
echo "Next steps:"
echo "1. Implement your business logic in the route handlers"
echo "2. Resolve your service using req.scope.resolve()"
echo "3. Test your endpoints with a REST client"
#!/bin/bash
# Create Module Script for MedusaJS
# Creates the basic structure for a new custom module
# Usage: ./scripts/create-module.sh <module-name>
set -e
if [ $# -eq 0 ]; then
echo "Error: Module name required"
echo "Usage: ./scripts/create-module.sh <module-name>"
echo "Example: ./scripts/create-module.sh blog"
exit 1
fi
MODULE_NAME=$1
MODULE_DIR="src/modules/$MODULE_NAME"
# Check if module already exists
if [ -d "$MODULE_DIR" ]; then
echo "Error: Module '$MODULE_NAME' already exists at $MODULE_DIR"
exit 1
fi
echo "Creating module: $MODULE_NAME"
# Create directory structure
mkdir -p "$MODULE_DIR/models"
mkdir -p "$MODULE_DIR/__tests__"
# Create index.ts
cat > "$MODULE_DIR/index.ts" << EOF
import { Module } from "@medusajs/framework/utils"
import ${MODULE_NAME^}ModuleService from "./service"
export const ${MODULE_NAME^^}_MODULE = "${MODULE_NAME}"
export default Module(${MODULE_NAME^^}_MODULE, {
service: ${MODULE_NAME^}ModuleService,
})
EOF
# Create service.ts
cat > "$MODULE_DIR/service.ts" << EOF
import { MedusaService } from "@medusajs/framework/utils"
class ${MODULE_NAME^}ModuleService extends MedusaService({
// Add models here
// Example: Post,
}) {
// Add custom methods here
}
export default ${MODULE_NAME^}ModuleService
EOF
# Create a sample model file
cat > "$MODULE_DIR/models/.gitkeep" << EOF
# Add your data models here
# Example: post.ts, author.ts, etc.
EOF
echo ""
echo "Module '$MODULE_NAME' created successfully!"
echo "Location: $MODULE_DIR"
echo ""
echo "Next steps:"
echo "1. Create data models in $MODULE_DIR/models/"
echo "2. Update service.ts to include your models"
echo "3. Generate migrations: ./scripts/generate-migration.sh $MODULE_NAME"
echo "4. Run migrations: ./scripts/run-migrations.sh"
#!/bin/bash
# Create Scheduled Job Script for MedusaJS
# Creates a new scheduled job with cron configuration
# Usage: ./scripts/create-scheduled-job.sh <job-name>
set -e
if [ $# -eq 0 ]; then
echo "Error: Job name required"
echo "Usage: ./scripts/create-scheduled-job.sh <job-name>"
echo "Example: ./scripts/create-scheduled-job.sh sync-inventory"
exit 1
fi
JOB_NAME=$1
JOBS_DIR="src/jobs"
JOB_FILE="$JOBS_DIR/$JOB_NAME.ts"
# Create jobs directory if it doesn't exist
mkdir -p "$JOBS_DIR"
# Check if job already exists
if [ -f "$JOB_FILE" ]; then
echo "Error: Job '$JOB_NAME' already exists at $JOB_FILE"
exit 1
fi
echo "Creating scheduled job: $JOB_NAME"
# Create job file
cat > "$JOB_FILE" << 'EOF'
import { MedusaContainer } from "@medusajs/framework/types"
/**
* Scheduled job handler
* This function will be executed according to the schedule defined below
*/
export default async function jobHandler(container: MedusaContainer) {
const logger = container.resolve("logger")
try {
logger.info("Starting scheduled job...")
// TODO: Implement your job logic here
// Example: const service = container.resolve("yourService")
// await service.performScheduledTask()
logger.info("Scheduled job completed successfully")
} catch (error) {
logger.error(`Scheduled job failed: ${error.message}`)
throw error
}
}
/**
* Job configuration
*/
export const config = {
name: "job-name",
// Cron schedule examples:
// "0 0 * * *" - Daily at midnight
// "0 */6 * * *" - Every 6 hours
// "*/15 * * * *" - Every 15 minutes
// "0 9 * * 1" - Every Monday at 9 AM
// "0 0 1 * *" - First day of every month at midnight
schedule: "0 0 * * *", // TODO: Set your schedule
}
EOF
# Replace job-name placeholder
sed -i.bak "s/job-name/$JOB_NAME/g" "$JOB_FILE" && rm "$JOB_FILE.bak"
echo ""
echo "Scheduled job '$JOB_NAME' created successfully!"
echo "Location: $JOB_FILE"
echo ""
echo "Next steps:"
echo "1. Implement your job logic in the jobHandler function"
echo "2. Configure the cron schedule in the config object"
echo "3. Restart your Medusa server to activate the job"
echo ""
echo "Cron schedule format: minute hour day month weekday"
echo "Visit https://crontab.guru/ for help with cron expressions"
#!/bin/bash
# Database Setup Script for MedusaJS
# Creates a database, runs migrations, and syncs links
# Usage: ./scripts/db-setup.sh [database-name]
set -e
DB_NAME=${1:-medusa-store}
echo "Setting up database: $DB_NAME"
# Create database and run migrations
npx medusa db:setup --db "$DB_NAME"
echo "Database setup completed successfully!"
echo "Database name: $DB_NAME"
#!/bin/bash
# Development Server Script for MedusaJS
# Starts the Medusa application in development mode with hot reloading
# Usage: ./scripts/dev-server.sh [--host HOST] [--port PORT]
set -e
ARGS=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--host|-h)
ARGS="$ARGS --host $2"
shift 2
;;
--port|-p)
ARGS="$ARGS --port $2"
shift 2
;;
*)
echo "Unknown option: $1"
echo "Usage: ./scripts/dev-server.sh [--host HOST] [--port PORT]"
exit 1
;;
esac
done
echo "Starting Medusa development server..."
echo "The server will watch for file changes and auto-restart"
echo "Admin dashboard will be available with hot reloading"
echo ""
npx medusa develop $ARGS
#!/bin/bash
# Generate Migration Script for MedusaJS
# Generates migration files for specified modules
# Usage: ./scripts/generate-migration.sh <module-name> [additional-modules...]
set -e
if [ $# -eq 0 ]; then
echo "Error: Module name(s) required"
echo "Usage: ./scripts/generate-migration.sh <module-name> [additional-modules...]"
echo "Example: ./scripts/generate-migration.sh blog"
echo "Example: ./scripts/generate-migration.sh blog product-custom"
exit 1
fi
echo "Generating migrations for module(s): $@"
npx medusa db:generate "$@"
echo "Migration generation completed!"
echo "Check the migrations directory in your module(s) for generated files"
#!/bin/bash
# Plugin Build Script for MedusaJS
# Builds a plugin for publishing to NPM
# Usage: ./scripts/plugin-build.sh (run from plugin directory)
set -e
# Check if we're in a plugin directory
if [ ! -f "package.json" ]; then
echo "Error: package.json not found. Are you in a plugin directory?"
exit 1
fi
echo "Building plugin for production..."
npx medusa plugin:build
echo ""
echo "Plugin build completed!"
echo "Build output: ./dist"
echo ""
echo "Next steps:"
echo "1. Test the plugin build"
echo "2. Update package.json version"
echo "3. Publish to NPM: npm publish"
#!/bin/bash
# Plugin Development Script for MedusaJS
# Starts a development server for a plugin with auto-reload
# Usage: ./scripts/plugin-develop.sh (run from plugin directory)
set -e
# Check if we're in a plugin directory
if [ ! -f "package.json" ]; then
echo "Error: package.json not found. Are you in a plugin directory?"
exit 1
fi
echo "Starting plugin development server..."
echo "Changes will be automatically published to local package registry"
echo ""
npx medusa plugin:develop
#!/bin/bash
# Pre-deployment Script for MedusaJS
# Runs migrations and syncs links before starting the application
# Usage: ./scripts/predeploy.sh
set -e
echo "Running pre-deployment tasks..."
# Run migrations with safe options
echo "1. Running database migrations..."
npx medusa db:migrate --safe
echo "Pre-deployment completed successfully!"
echo "Application is ready to start"
MedusaJS Developer Scripts
A collection of helper scripts to streamline MedusaJS development workflows. All scripts are based on official MedusaJS CLI commands and best practices.
Quick Start
All scripts are located in the scripts/ directory and can be executed from the root of your MedusaJS project.
# Make scripts executable (if needed)
chmod +x scripts/*.sh
# Example: Create a new module
./scripts/create-module.sh blogScript Categories
🗄️ Database Management
| Script | Purpose | Usage |
|---|---|---|
db-setup.sh | Setup database with migrations | ./scripts/db-setup.sh [db-name] |
generate-migration.sh | Generate migration files | ./scripts/generate-migration.sh <module-name> |
run-migrations.sh | Run pending migrations | ./scripts/run-migrations.sh [--skip-links] |
rollback-migration.sh | Rollback module migrations | ./scripts/rollback-migration.sh <module-name> |
🚀 Development & Build
| Script | Purpose | Usage |
|---|---|---|
dev-server.sh | Start dev server with hot reload | ./scripts/dev-server.sh [--host HOST] [--port PORT] |
build-production.sh | Build for production | ./scripts/build-production.sh [--admin-only] |
start-production.sh | Start production server | ./scripts/start-production.sh |
predeploy.sh | Pre-deployment tasks (CI/CD) | ./scripts/predeploy.sh |
🧪 Testing
| Script | Purpose | Usage |
|---|---|---|
setup-testing.sh | Configure Jest & test tools | ./scripts/setup-testing.sh |
run-tests.sh | Run tests (http/modules/unit) | `./scripts/run-tests.sh [http\ |
🏗️ Scaffolding
| Script | Purpose | Usage |
|---|---|---|
create-module.sh | Create new custom module | ./scripts/create-module.sh <module-name> |
create-api-route.sh | Create CRUD API route | ./scripts/create-api-route.sh <route-name> |
create-scheduled-job.sh | Create cron job | ./scripts/create-scheduled-job.sh <job-name> |
🔌 Plugin Development
| Script | Purpose | Usage |
|---|---|---|
plugin-develop.sh | Dev server for plugins | ./scripts/plugin-develop.sh |
plugin-build.sh | Build plugin for NPM | ./scripts/plugin-build.sh |
Common Workflows
New Module Development
# Create module
./scripts/create-module.sh product-reviews
# Add models in src/modules/product-reviews/models/
# Generate & run migrations
./scripts/generate-migration.sh product-reviews
./scripts/run-migrations.sh
# Create API endpoints
./scripts/create-api-route.sh product-reviews
# Test the module
./scripts/run-tests.sh modulesAPI Development
# Create API route
./scripts/create-api-route.sh notifications
# Edit route handlers in src/api/notifications/route.ts
# Start dev server to test
./scripts/dev-server.sh
# Test endpoints at http://localhost:9000/api/notificationsAdding Scheduled Tasks
# Create scheduled job
./scripts/create-scheduled-job.sh daily-report
# Edit job logic in src/jobs/daily-report.ts
# Configure cron schedule in config object
# Restart dev server
./scripts/dev-server.shProduction Deployment
# Run full test suite
./scripts/run-tests.sh all
# Build for production
./scripts/build-production.sh
# Deploy and run predeploy tasks
./scripts/predeploy.sh
# Start production server
./scripts/start-production.shEnvironment Variables
Ensure you have these environment variables configured:
# Database
DATABASE_URL=postgres://user:password@localhost:5432/medusa-db
# Server
PORT=9000
HOST=localhost
# CORS
STORE_CORS=http://localhost:8000
ADMIN_CORS=http://localhost:7001
AUTH_CORS=http://localhost:8000,http://localhost:7001
# Secrets (generate secure random strings)
JWT_SECRET=your-jwt-secret
COOKIE_SECRET=your-cookie-secret
# Redis (for production)
REDIS_URL=redis://localhost:6379Cron Schedule Reference
Common cron patterns for scheduled jobs:
"* * * * *" # Every minute
"*/5 * * * *" # Every 5 minutes
"*/15 * * * *" # Every 15 minutes
"0 * * * *" # Every hour
"0 */6 * * *" # Every 6 hours
"0 0 * * *" # Daily at midnight
"0 9 * * *" # Daily at 9 AM
"0 0 * * 0" # Weekly on Sunday
"0 0 1 * *" # Monthly on 1st day
"0 9 * * 1-5" # Weekdays at 9 AMVisit crontab.guru for help creating cron expressions.
Requirements
- Node.js 18+
- PostgreSQL database
- MedusaJS v2.x
- Git (for version control)
Troubleshooting
Permission Denied
chmod +x scripts/*.shScript Not Found
Ensure you're running scripts from the project root:
# Wrong
cd scripts && ./db-setup.sh
# Correct
./scripts/db-setup.shMigration Errors
# Rollback and try again
./scripts/rollback-migration.sh <module-name>
./scripts/generate-migration.sh <module-name>
./scripts/run-migrations.shContributing
These scripts follow official MedusaJS CLI commands and patterns. For issues or improvements, refer to:
License
MIT - Use freely in your MedusaJS projects.
#!/bin/bash
# Rollback Migration Script for MedusaJS
# Reverts the last migrations for specified modules
# Usage: ./scripts/rollback-migration.sh <module-name> [additional-modules...]
set -e
if [ $# -eq 0 ]; then
echo "Error: Module name(s) required"
echo "Usage: ./scripts/rollback-migration.sh <module-name> [additional-modules...]"
echo "Example: ./scripts/rollback-migration.sh blog"
echo "Example: ./scripts/rollback-migration.sh blog product-custom"
exit 1
fi
echo "Rolling back migrations for module(s): $@"
echo "WARNING: This will revert the last migration for the specified module(s)"
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Rollback cancelled"
exit 1
fi
npx medusa db:rollback "$@"
echo "Rollback completed!"
#!/bin/bash
# Run Migrations Script for MedusaJS
# Runs all pending migrations, syncs links, and runs data migration scripts
# Usage: ./scripts/run-migrations.sh [--skip-links] [--skip-data]
set -e
echo "Running database migrations..."
ARGS=""
# Check for skip links flag
if [[ "$*" == *"--skip-links"* ]]; then
ARGS="$ARGS --skip-links"
echo "Skipping link synchronization"
fi
# Check for skip data flag
if [[ "$*" == *"--skip-data"* ]]; then
ARGS="$ARGS --skip-data"
echo "Skipping data migration scripts"
fi
npx medusa db:migrate $ARGS
echo "Migrations completed successfully!"
#!/bin/bash
# Run Tests Script for MedusaJS
# Runs integration and unit tests
# Usage: ./scripts/run-tests.sh [http|modules|unit|all]
set -e
TEST_TYPE=${1:-all}
run_http_tests() {
echo "Running HTTP integration tests..."
TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit
}
run_module_tests() {
echo "Running module integration tests..."
TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit
}
run_unit_tests() {
echo "Running unit tests..."
TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit
}
case $TEST_TYPE in
http)
run_http_tests
;;
modules)
run_module_tests
;;
unit)
run_unit_tests
;;
all)
echo "Running all tests..."
echo ""
run_http_tests
echo ""
run_module_tests
echo ""
run_unit_tests
;;
*)
echo "Unknown test type: $TEST_TYPE"
echo "Usage: ./scripts/run-tests.sh [http|modules|unit|all]"
exit 1
;;
esac
echo ""
echo "All tests completed!"
#!/bin/bash
# Testing Setup Script for MedusaJS
# Installs and configures Jest and Medusa testing tools
# Usage: ./scripts/setup-testing.sh
set -e
echo "Setting up testing environment for MedusaJS..."
# Install testing dependencies
echo "1. Installing testing dependencies..."
npm install --save-dev @medusajs/test-utils@latest jest @types/jest @swc/jest
# Create jest.config.js
echo "2. Creating jest.config.js..."
cat > jest.config.js << 'EOF'
const { loadEnv } = require("@medusajs/framework/utils")
loadEnv("test", process.cwd())
module.exports = {
transform: {
"^.+\\.[jt]s$": [
"@swc/jest",
{
jsc: {
parser: { syntax: "typescript", decorators: true },
target: "es2021",
},
},
],
},
testEnvironment: "node",
moduleFileExtensions: ["js", "ts", "json"],
modulePathIgnorePatterns: ["dist/"],
setupFiles: ["./integration-tests/setup.js"],
}
if (process.env.TEST_TYPE === "integration:http") {
module.exports.testMatch = ["**/integration-tests/http/*.spec.[jt]s"]
} else if (process.env.TEST_TYPE === "integration:modules") {
module.exports.testMatch = ["**/src/modules/*/__tests__/**/*.[jt]s"]
} else if (process.env.TEST_TYPE === "unit") {
module.exports.testMatch = ["**/src/**/__tests__/**/*.unit.spec.[jt]s"]
}
EOF
# Create integration-tests directory and setup file
echo "3. Creating integration-tests directory..."
mkdir -p integration-tests/http
cat > integration-tests/setup.js << 'EOF'
const { MetadataStorage } = require("@medusajs/framework/mikro-orm/core")
MetadataStorage.clear()
EOF
# Add test scripts to package.json if they don't exist
echo "4. Adding test scripts to package.json..."
echo ""
echo "Add the following scripts to your package.json:"
echo ""
echo '"scripts": {'
echo ' "test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",'
echo ' "test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",'
echo ' "test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit"'
echo '}'
echo ""
echo "Testing setup completed!"
echo "You can now create tests in:"
echo " - integration-tests/http/ for API route tests"
echo " - src/modules/<module-name>/__tests__/ for module tests"
echo " - src/**/__tests__/ for unit tests"
#!/bin/bash
# Start Production Server Script for MedusaJS
# Starts the built Medusa application in production mode
# Usage: ./scripts/start-production.sh
set -e
BUILD_DIR=".medusa/server"
if [ ! -d "$BUILD_DIR" ]; then
echo "Error: Build directory not found at $BUILD_DIR"
echo "Please run: ./scripts/build-production.sh first"
exit 1
fi
echo "Starting Medusa production server..."
cd "$BUILD_DIR"
# Install dependencies if not already installed
if [ ! -d "node_modules" ]; then
echo "Installing dependencies..."
npm install
fi
# Copy .env file if it doesn't exist
if [ ! -f ".env.production" ] && [ -f "../../.env" ]; then
echo "Copying environment variables..."
cp ../../.env .env.production
fi
# Set NODE_ENV and start
export NODE_ENV=production
npm run start
/**
* Complete Module Template
*
* This template shows a complete custom module structure with:
* - Multiple data models with various property types
* - One-to-many and many-to-many relationships
* - Main service extending MedusaService
* - Additional custom service
* - Module configuration and exports
*
* Usage: Copy and adapt this structure for your custom module
* Location: src/modules/[module-name]/
*/
// ============================================================================
// DATA MODELS
// ============================================================================
// src/modules/[module-name]/models/main-entity.ts
import { model } from "@medusajs/framework/utils"
import { RelatedEntity } from "./related-entity"
import { Tag } from "./tag"
export enum EntityStatus {
ACTIVE = "active",
INACTIVE = "inactive",
ARCHIVED = "archived"
}
const MainEntity = model.define("main_entity", {
// Primary Key
id: model.id().primaryKey(),
// Basic Properties
name: model.text(),
handle: model.text().unique(),
description: model.text().nullable(),
// Numeric Properties
order: model.number().default(0),
price: model.bigNumber(),
// Boolean Properties
is_active: model.boolean().default(true),
// Enum Property
status: model.enum(EntityStatus).default(EntityStatus.ACTIVE),
// JSON Property
metadata: model.json().nullable(),
// Date Properties
published_at: model.dateTime().nullable(),
// Indexes
search_terms: model.text().nullable().searchable(),
// One-to-Many Relationship (this entity has many related entities)
related_entities: model.hasMany(() => RelatedEntity, {
mappedBy: "main_entity"
}),
// Many-to-Many Relationship (this entity has many tags, tags have many entities)
tags: model.manyToMany(() => Tag, {
mappedBy: "main_entities",
pivotTable: "main_entity_tag",
joinColumn: "main_entity_id",
inverseJoinColumn: "tag_id"
})
})
export default MainEntity
// ----------------------------------------------------------------------------
// src/modules/[module-name]/models/related-entity.ts
import { model } from "@medusajs/framework/utils"
import { MainEntity } from "./main-entity"
const RelatedEntity = model.define("related_entity", {
id: model.id().primaryKey(),
title: model.text(),
content: model.text().nullable(),
// Foreign key will be auto-generated as main_entity_id
main_entity: model.belongsTo(() => MainEntity, {
mappedBy: "related_entities"
})
})
export default RelatedEntity
// ----------------------------------------------------------------------------
// src/modules/[module-name]/models/tag.ts
import { model } from "@medusajs/framework/utils"
import { MainEntity } from "./main-entity"
const Tag = model.define("tag", {
id: model.id().primaryKey(),
name: model.text(),
slug: model.text().unique(),
// Many-to-Many (tags have many main entities)
main_entities: model.manyToMany(() => MainEntity, {
mappedBy: "tags"
})
})
export default Tag
// ============================================================================
// SERVICES
// ============================================================================
// src/modules/[module-name]/services/custom-service.ts
export class CustomService {
private apiClient: any
constructor({ logger }: { logger: any }) {
// Initialize custom service
this.apiClient = null // Initialize your API client, etc.
}
async performCustomOperation(data: any): Promise<any> {
// Custom business logic
return {
success: true,
data
}
}
}
// ----------------------------------------------------------------------------
// src/modules/[module-name]/services/index.ts
export * from "./custom-service"
// ----------------------------------------------------------------------------
// src/modules/[module-name]/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import MainEntity from "./models/main-entity"
import RelatedEntity from "./models/related-entity"
import Tag from "./models/tag"
import { CustomService } from "./services"
type InjectedDependencies = {
customService: CustomService
}
class ModuleService extends MedusaService({
MainEntity,
RelatedEntity,
Tag
}) {
private customService: CustomService
constructor(
{ customService }: InjectedDependencies,
...args: any[]
) {
super(...args)
this.customService = customService
}
// Custom method example
async getActiveMainEntities() {
return await this.listMainEntities({
is_active: true
})
}
// Custom method using injected service
async processEntityWithCustomLogic(entityId: string, data: any) {
const entity = await this.retrieveMainEntity(entityId)
const result = await this.customService.performCustomOperation({
entity,
data
})
return result
}
}
export default ModuleService
// ============================================================================
// MODULE CONFIGURATION
// ============================================================================
// src/modules/[module-name]/index.ts
import ModuleService from "./service"
import { Module } from "@medusajs/framework/utils"
export const MODULE_NAME = "moduleService"
export default Module(MODULE_NAME, {
service: ModuleService
})
// ============================================================================
// REGISTRATION IN medusa-config.ts
// ============================================================================
/*
Add to medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"
module.exports = defineConfig({
// ... other config
modules: [
{
resolve: "./src/modules/[module-name]"
}
]
})
*/
// ============================================================================
// USAGE EXAMPLES
// ============================================================================
/*
// In an API route or workflow step:
import { MODULE_NAME } from "../modules/[module-name]"
import ModuleService from "../modules/[module-name]/service"
// Resolve the service
const moduleService: ModuleService = req.scope.resolve(MODULE_NAME)
// Create a main entity with related entities
const mainEntity = await moduleService.createMainEntities({
name: "Example Entity",
handle: "example-entity",
description: "An example entity",
status: "active",
metadata: {
custom_field: "value"
}
})
// Create related entity
const relatedEntity = await moduleService.createRelatedEntities({
title: "Related Item",
content: "Some content",
main_entity_id: mainEntity.id
})
// List with filters
const entities = await moduleService.listMainEntities({
is_active: true
}, {
relations: ["related_entities", "tags"]
})
// Update
await moduleService.updateMainEntities({
id: mainEntity.id,
is_active: false
})
// Delete
await moduleService.deleteMainEntities(mainEntity.id)
*/