chore(): start moving some packages to the core directory (#7215)

This commit is contained in:
Adrien de Peretti
2024-05-03 13:37:41 +02:00
committed by GitHub
parent fdee748eed
commit bbccd6481d
1436 changed files with 275 additions and 756 deletions
@@ -0,0 +1,26 @@
export const InventoryModule = {
__definition: {
key: "inventoryService",
registrationName: "inventoryService",
defaultPackage: false,
label: "InventoryService",
isRequired: false,
isQueryable: true,
dependencies: [],
defaultModuleDeclaration: {
scope: "internal",
resources: "shared",
},
},
__joinerConfig: {
serviceName: "inventoryService",
primaryKeys: ["id"],
linkableKeys: {
inventory_item_id: "InventoryItem",
inventory_level_id: "InventoryLevel",
reservation_item_id: "ReservationItem",
},
},
softDelete: jest.fn(() => {}),
}
@@ -0,0 +1,70 @@
export const InventoryStockLocationLink = {
__definition: {
key: "inventoryStockLocationLink",
registrationName: "inventoryStockLocationLink",
defaultPackage: "",
label: "inventoryStockLocationLink",
isRequired: false,
isQueryable: true,
defaultModuleDeclaration: {
scope: "internal",
resources: "shared",
},
},
__joinerConfig: {
serviceName: "inventoryStockLocationLink",
isLink: true,
alias: [
{
name: "inventory_level_stock_location",
},
{
name: "inventory_level_stock_locations",
},
],
primaryKeys: ["inventory_level_id", "stock_location_id"],
relationships: [
{
serviceName: "inventoryService",
primaryKey: "id",
foreignKey: "inventory_level_id",
alias: "inventory_level",
args: {},
},
{
serviceName: "stockLocationService",
primaryKey: "id",
foreignKey: "stock_location_id",
alias: "stock_location",
},
],
extends: [
{
serviceName: "inventoryService",
relationship: {
serviceName: "inventoryStockLocationLink",
primaryKey: "inventory_level_id",
foreignKey: "id",
alias: "inventory_location_items",
},
},
{
serviceName: "stockLocationService",
relationship: {
serviceName: "inventoryStockLocationLink",
primaryKey: "stock_location_id",
foreignKey: "id",
alias: "inventory_location_items",
},
},
],
},
create: jest.fn(
async (
primaryKeyOrBulkData: string | string[] | [string | string[], string][],
foreignKeyData?: string
) => {}
),
softDelete: jest.fn(() => {}),
}
@@ -0,0 +1,76 @@
export const ProductInventoryLinkModule = {
__definition: {
key: "productVariantInventoryInventoryItemLink",
registrationName: "productVariantInventoryInventoryItemLink",
defaultPackage: "",
label: "productVariantInventoryInventoryItemLink",
isRequired: false,
isQueryable: true,
defaultModuleDeclaration: {
scope: "internal",
resources: "shared",
},
},
__joinerConfig: {
serviceName: "productVariantInventoryInventoryItemLink",
isLink: true,
databaseConfig: {
tableName: "product_variant_inventory_item",
},
alias: [
{
name: "product_variant_inventory_item",
},
{
name: "product_variant_inventory_items",
},
],
primaryKeys: ["variant_id", "inventory_item_id"],
relationships: [
{
serviceName: "productService",
primaryKey: "id",
foreignKey: "variant_id",
alias: "variant",
args: {},
deleteCascade: true,
},
{
serviceName: "inventoryService",
primaryKey: "id",
foreignKey: "inventory_item_id",
alias: "inventory",
deleteCascade: true,
},
],
extends: [
{
serviceName: "productService",
relationship: {
serviceName: "productVariantInventoryInventoryItemLink",
primaryKey: "variant_id",
foreignKey: "id",
alias: "inventory_items",
isList: true,
},
},
{
serviceName: "inventoryService",
relationship: {
serviceName: "productVariantInventoryInventoryItemLink",
primaryKey: "inventory_item_id",
foreignKey: "id",
alias: "variant_link",
},
},
],
},
create: jest.fn(
async (
primaryKeyOrBulkData: string | string[] | [string | string[], string][],
foreignKeyData?: string
) => {}
),
softDelete: jest.fn(() => {}),
}
@@ -0,0 +1,23 @@
export const ProductModule = {
__definition: {
key: "productService",
registrationName: "productModuleService",
defaultPackage: false,
label: "ProductModuleService",
isRequired: false,
isQueryable: true,
dependencies: ["eventBusModuleService"],
defaultModuleDeclaration: {
scope: "internal",
resources: "shared",
},
},
__joinerConfig: {
serviceName: "productService",
primaryKeys: ["id", "handle"],
linkableKeys: { product_id: "Product", variant_id: "ProductVariant" },
alias: [],
},
softDelete: jest.fn(() => {}),
}
@@ -0,0 +1,23 @@
export const StockLocationModule = {
__definition: {
key: "stockLocationService",
registrationName: "stockLocationService",
defaultPackage: false,
label: "StockLocationService",
isRequired: false,
isQueryable: true,
dependencies: ["eventBusService"],
defaultModuleDeclaration: {
scope: "internal",
resources: "shared",
},
},
__joinerConfig: {
serviceName: "stockLocationService",
primaryKeys: ["id"],
linkableKeys: { stock_location_id: "StockLocation" },
alias: [],
},
softDelete: jest.fn(() => {}),
}
@@ -0,0 +1,111 @@
import { cleanGraphQLSchema } from "../utils/clean-graphql-schema"
describe("Clean Graphql Schema", function () {
it("Should keep the schema intact if all entities are available", function () {
const schemaStr = `
type Product {
id: ID!
title: String!
variants: [Variant]!
}
type Variant {
id: ID!
title: String!
product_id: ID!
product: Product!
}
`
const { schema, notFound } = cleanGraphQLSchema(schemaStr)
expect(schema.replace(/\s/g, "")).toEqual(schemaStr.replace(/\s/g, ""))
expect(notFound).toEqual({})
})
it("Should remove fields where the relation doesn't exist", function () {
const schemaStr = `
type Product {
id: ID!
title: String!
variants: [Variant!]!
profile: ShippingProfile!
}
`
const expectedStr = `
type Product {
id: ID!
title: String!
}
`
const { schema, notFound } = cleanGraphQLSchema(schemaStr)
expect(schema.replace(/\s/g, "")).toEqual(expectedStr.replace(/\s/g, ""))
expect(notFound).toEqual({
Product: { variants: "Variant", profile: "ShippingProfile" },
})
})
it("Should remove fields where the relation doesn't exist and flag extended entity where the main entity doesn't exist", function () {
const schemaStr = `
scalar JSON
type Product {
id: ID!
title: String!
variants: [Variant!]!
profile: ShippingProfile!
}
extend type Variant {
metadata: JSON
}
`
const expectedStr = `
scalar JSON
type Product {
id: ID!
title: String!
}
`
const { schema, notFound } = cleanGraphQLSchema(schemaStr)
expect(schema.replace(/\s/g, "")).toEqual(expectedStr.replace(/\s/g, ""))
expect(notFound).toEqual({
Product: { variants: "Variant", profile: "ShippingProfile" },
Variant: { __extended: "" },
})
})
it("Should remove fields from extend where the relation doesn't exist", function () {
const schemaStr = `
scalar JSON
type Product {
id: ID!
title: String!
variants: [Variant!]!
profile: ShippingProfile!
}
extend type Product {
variants: [Variant!]!
profile: ShippingProfile!
metadata: JSON
}
`
const expectedStr = `
scalar JSON
type Product {
id: ID!
title: String!
}
extend type Product {
metadata: JSON
}
`
const { schema, notFound } = cleanGraphQLSchema(schemaStr)
expect(schema.replace(/\s/g, "")).toEqual(expectedStr.replace(/\s/g, ""))
expect(notFound).toEqual({
Product: { variants: "Variant", profile: "ShippingProfile" },
})
})
})
@@ -0,0 +1,202 @@
import { InventoryModule } from "../__mocks__/inventory-module"
import { InventoryStockLocationLink } from "../__mocks__/inventory-stock-location-link"
import { ProductInventoryLinkModule } from "../__mocks__/product-inventory-link"
import { ProductModule } from "../__mocks__/product-module"
import { StockLocationModule } from "../__mocks__/stock-location-module"
import { RemoteLink } from "../remote-link"
const allModules = [
// modules
ProductModule,
InventoryModule,
StockLocationModule,
// links
ProductInventoryLinkModule,
InventoryStockLocationLink,
]
describe("Remote Link", function () {
it("Should get all loaded modules and compose their relationships", async function () {
const remoteLink = new RemoteLink(allModules as any)
const relations = remoteLink.getRelationships()
const prodInventoryLink = relations.get(
"productVariantInventoryInventoryItemLink"
)
const prodModule = relations.get("productService")
const inventoryModule = relations.get("inventoryService")
expect(prodInventoryLink?.get("variant_id")).toEqual(
expect.arrayContaining([
expect.objectContaining({
serviceName: "productService",
primaryKey: "id",
foreignKey: "variant_id",
alias: "variant",
deleteCascade: true,
isPrimary: false,
isForeign: true,
}),
])
)
expect(prodInventoryLink?.get("inventory_item_id")).toEqual(
expect.arrayContaining([
expect.objectContaining({
serviceName: "inventoryService",
primaryKey: "id",
foreignKey: "inventory_item_id",
alias: "inventory",
deleteCascade: true,
isPrimary: false,
isForeign: true,
}),
])
)
expect(prodModule?.get("variant_id")).toEqual(
expect.arrayContaining([
expect.objectContaining({
serviceName: "productVariantInventoryInventoryItemLink",
primaryKey: "variant_id",
foreignKey: "id",
alias: "inventory_items",
isList: true,
isPrimary: true,
isForeign: false,
}),
])
)
expect(inventoryModule?.get("inventory_item_id")).toEqual(
expect.arrayContaining([
expect.objectContaining({
serviceName: "productVariantInventoryInventoryItemLink",
primaryKey: "inventory_item_id",
foreignKey: "id",
alias: "variant_link",
isPrimary: true,
isForeign: false,
}),
])
)
})
it("Should call the correct link module to create relation between 2 keys", async function () {
const remoteLink = new RemoteLink(allModules as any)
await remoteLink.create([
{
productService: {
variant_id: "var_123",
},
inventoryService: {
inventory_item_id: "inv_123",
},
},
{
productService: {
variant_id: "var_abc",
},
inventoryService: {
inventory_item_id: "inv_abc",
},
},
{
inventoryService: {
inventory_level_id: "ilev_123",
},
stockLocationService: {
stock_location_id: "loc_123",
},
},
])
expect(ProductInventoryLinkModule.create).toBeCalledWith([
["var_123", "inv_123"],
["var_abc", "inv_abc"],
])
expect(InventoryStockLocationLink.create).toBeCalledWith([
["ilev_123", "loc_123"],
])
})
it("Should call delete in cascade all the modules involved in the link", async function () {
const remoteLink = new RemoteLink(allModules as any)
ProductInventoryLinkModule.softDelete.mockImplementation(() => {
return {
variant_id: ["var_123"],
inventory_item_id: ["inv_123"],
}
})
ProductModule.softDelete.mockImplementation(() => {
return {
product_id: ["prod_123", "prod_abc"],
variant_id: ["var_123", "var_abc"],
}
})
InventoryModule.softDelete.mockImplementation(() => {
return {
inventory_item_id: ["inv_123"],
inventory_level_id: ["ilev_123"],
}
})
InventoryStockLocationLink.softDelete.mockImplementation(() => {
return {
inventory_level_id: ["ilev_123"],
stock_location_id: ["loc_123"],
}
})
await remoteLink.delete({
productService: {
variant_id: "var_123",
},
})
expect(ProductInventoryLinkModule.softDelete).toBeCalledTimes(2)
expect(ProductModule.softDelete).toBeCalledTimes(1)
expect(InventoryModule.softDelete).toBeCalledTimes(1)
expect(InventoryStockLocationLink.softDelete).toBeCalledTimes(1)
expect(ProductInventoryLinkModule.softDelete).toHaveBeenNthCalledWith(
1,
{ variant_id: ["var_123"] },
{ returnLinkableKeys: ["variant_id", "inventory_item_id"] }
)
expect(ProductInventoryLinkModule.softDelete).toHaveBeenNthCalledWith(
2,
{ variant_id: ["var_abc"] },
{ returnLinkableKeys: ["variant_id", "inventory_item_id"] }
)
expect(ProductModule.softDelete).toBeCalledWith(
{ id: ["var_123"] },
{ returnLinkableKeys: ["product_id", "variant_id"] }
)
expect(InventoryModule.softDelete).toBeCalledWith(
{ id: ["inv_123"] },
{
returnLinkableKeys: [
"inventory_item_id",
"inventory_level_id",
"reservation_item_id",
],
}
)
expect(InventoryStockLocationLink.softDelete).toBeCalledWith(
{
inventory_level_id: ["ilev_123"],
},
{ returnLinkableKeys: ["inventory_level_id", "stock_location_id"] }
)
})
})
@@ -0,0 +1,109 @@
import { RemoteQuery } from "../remote-query"
describe("Remote query", () => {
it("should properly handle fields and relations transformation", () => {
let expand = {
fields: ["name", "age"],
expands: {
friend: {
fields: ["name"],
expands: {
ball: {
fields: ["*"],
},
},
},
},
}
let result = RemoteQuery.getAllFieldsAndRelations(expand)
expect(result).toEqual({
select: ["name", "age", "friend.name"],
relations: ["friend", "friend.ball"],
args: {
"": undefined,
friend: undefined,
"friend.ball": undefined,
},
})
expand = {
fields: [],
expands: {
friend: {
fields: ["name"],
expands: {
ball: {
fields: ["*"],
},
},
},
},
}
result = RemoteQuery.getAllFieldsAndRelations(expand)
expect(result).toEqual({
select: ["friend.name"],
relations: ["friend", "friend.ball"],
args: {
"": undefined,
friend: undefined,
"friend.ball": undefined,
},
})
expand = {
fields: [],
expands: {
friend: {
fields: ["*"],
expands: {
ball: {
fields: ["*"],
},
},
},
},
}
result = RemoteQuery.getAllFieldsAndRelations(expand)
expect(result).toEqual({
select: [],
relations: ["friend", "friend.ball"],
args: {
"": undefined,
friend: undefined,
"friend.ball": undefined,
},
})
expand = {
fields: [],
expands: {
friend: {
fields: [],
expands: {
ball: {
fields: ["*"],
},
},
},
},
}
result = RemoteQuery.getAllFieldsAndRelations(expand)
expect(result).toEqual({
select: [],
relations: ["friend", "friend.ball"],
args: {
"": undefined,
friend: undefined,
"friend.ball": undefined,
},
})
})
})
@@ -0,0 +1,110 @@
import { mergeTypeDefs } from "@graphql-tools/merge"
import { makeExecutableSchema } from "@graphql-tools/schema"
import { graphqlSchemaToFields } from "../../utils"
const userModule = `
type User {
id: ID!
name: String!
blabla: WHATEVER
}
type Post {
author: User!
}
`
const postModule = `
type Post {
id: ID!
title: String!
date: String
}
type User {
posts: [Post!]!
}
type WHATEVER {
random_field: String
post: Post
}
`
const mergedSchema = mergeTypeDefs([userModule, postModule])
const schema = makeExecutableSchema({
typeDefs: mergedSchema,
})
const types = schema.getTypeMap()
describe("graphqlSchemaToFields", function () {
it("Should get all fields of a given entity", async function () {
const fields = graphqlSchemaToFields(types, "User")
expect(fields).toEqual(expect.arrayContaining(["id", "name"]))
})
it("Should get all fields of a given entity and a relation", async function () {
const fields = graphqlSchemaToFields(types, "User", ["posts"])
expect(fields).toEqual(
expect.arrayContaining([
"id",
"name",
"posts.id",
"posts.title",
"posts.date",
])
)
})
it("Should get all fields of a given entity and many relations", async function () {
const fields = graphqlSchemaToFields(types, "User", [
"posts",
"blabla",
"blabla.post",
])
expect(fields).toEqual(
expect.arrayContaining([
"id",
"name",
"posts.id",
"posts.title",
"posts.date",
"blabla.random_field",
"blabla.post.id",
"blabla.post.title",
"blabla.post.date",
])
)
})
it("Should get all fields of a given entity and many relations limited to the relations given", async function () {
const fields = graphqlSchemaToFields(types, "User", ["posts", "blabla"])
expect(fields).toEqual(
expect.arrayContaining([
"id",
"name",
"posts.id",
"posts.title",
"posts.date",
"blabla.random_field",
])
)
})
it("Should get all fields of a given entity and many relations limited to the relations given if they exists", async function () {
const fields = graphqlSchemaToFields(types, "User", [
"posts",
"doNotExists",
])
expect(fields).toEqual(
expect.arrayContaining([
"id",
"name",
"posts.id",
"posts.title",
"posts.date",
])
)
})
})
@@ -0,0 +1,386 @@
import {
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleDefinition,
} from "@medusajs/types"
import { upperCaseFirst } from "@medusajs/utils"
export enum LinkModuleUtils {
REMOTE_QUERY = "remoteQuery",
REMOTE_LINK = "remoteLink",
}
// TODO: Remove this enum and use the one from @medusajs/utils
export enum Modules {
AUTH = "auth",
CACHE = "cacheService",
CART = "cart",
CUSTOMER = "customer",
EVENT_BUS = "eventBus",
INVENTORY = "inventoryService",
LINK = "linkModules",
PAYMENT = "payment",
PRICING = "pricingService",
PRODUCT = "productService",
PROMOTION = "promotion",
SALES_CHANNEL = "salesChannel",
TAX = "tax",
FULFILLMENT = "fulfillment",
STOCK_LOCATION = "stockLocationService",
USER = "user",
WORKFLOW_ENGINE = "workflows",
REGION = "region",
ORDER = "order",
API_KEY = "apiKey",
STORE = "store",
CURRENCY = "currency",
FILE = "file",
}
export enum ModuleRegistrationName {
AUTH = "authModuleService",
CACHE = "cacheService",
CART = "cartModuleService",
CUSTOMER = "customerModuleService",
EVENT_BUS = "eventBusModuleService",
INVENTORY = "inventoryService",
PAYMENT = "paymentModuleService",
PRICING = "pricingModuleService",
PRODUCT = "productModuleService",
PROMOTION = "promotionModuleService",
SALES_CHANNEL = "salesChannelModuleService",
FULFILLMENT = "fulfillmentModuleService",
STOCK_LOCATION = "stockLocationService",
TAX = "taxModuleService",
USER = "userModuleService",
WORKFLOW_ENGINE = "workflowsModuleService",
REGION = "regionModuleService",
ORDER = "orderModuleService",
API_KEY = "apiKeyModuleService",
STORE = "storeModuleService",
CURRENCY = "currencyModuleService",
FILE = "fileModuleService",
}
export const MODULE_PACKAGE_NAMES = {
[Modules.AUTH]: "@medusajs/auth",
[Modules.CACHE]: "@medusajs/cache-inmemory",
[Modules.CART]: "@medusajs/cart",
[Modules.CUSTOMER]: "@medusajs/customer",
[Modules.EVENT_BUS]: "@medusajs/event-bus-local",
[Modules.INVENTORY]: "@medusajs/inventory-next", // TODO: To be replaced when current `@medusajs/inventory` is deprecated
[Modules.LINK]: "@medusajs/link-modules",
[Modules.PAYMENT]: "@medusajs/payment",
[Modules.PRICING]: "@medusajs/pricing",
[Modules.PRODUCT]: "@medusajs/product",
[Modules.PROMOTION]: "@medusajs/promotion",
[Modules.SALES_CHANNEL]: "@medusajs/sales-channel",
[Modules.FULFILLMENT]: "@medusajs/fulfillment",
[Modules.STOCK_LOCATION]: "@medusajs/stock-location-next", // TODO: To be replaced when current `@medusajs/stock-location` is deprecated
[Modules.TAX]: "@medusajs/tax",
[Modules.USER]: "@medusajs/user",
[Modules.WORKFLOW_ENGINE]: "@medusajs/workflow-engine-inmemory",
[Modules.REGION]: "@medusajs/region",
[Modules.ORDER]: "@medusajs/order",
[Modules.API_KEY]: "@medusajs/api-key",
[Modules.STORE]: "@medusajs/store",
[Modules.CURRENCY]: "@medusajs/currency",
[Modules.FILE]: "@medusajs/file",
}
export const ModulesDefinition: { [key: string | Modules]: ModuleDefinition } =
{
[Modules.EVENT_BUS]: {
key: Modules.EVENT_BUS,
isLegacy: true,
registrationName: ModuleRegistrationName.EVENT_BUS,
defaultPackage: MODULE_PACKAGE_NAMES[Modules.EVENT_BUS],
label: upperCaseFirst(ModuleRegistrationName.EVENT_BUS),
isRequired: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.STOCK_LOCATION]: {
key: Modules.STOCK_LOCATION,
isLegacy: true,
registrationName: ModuleRegistrationName.STOCK_LOCATION,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.STOCK_LOCATION),
isRequired: false,
isQueryable: true,
dependencies: ["eventBusService"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.INVENTORY]: {
key: Modules.INVENTORY,
isLegacy: true,
registrationName: ModuleRegistrationName.INVENTORY,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.INVENTORY),
isRequired: false,
isQueryable: true,
dependencies: ["eventBusService"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.CACHE]: {
key: Modules.CACHE,
isLegacy: true,
registrationName: ModuleRegistrationName.CACHE,
defaultPackage: MODULE_PACKAGE_NAMES[Modules.CACHE],
label: upperCaseFirst(ModuleRegistrationName.CACHE),
isRequired: true,
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.PRODUCT]: {
key: Modules.PRODUCT,
registrationName: ModuleRegistrationName.PRODUCT,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.PRODUCT),
isRequired: false,
isQueryable: true,
dependencies: [ModuleRegistrationName.EVENT_BUS, "logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.PRICING]: {
key: Modules.PRICING,
registrationName: ModuleRegistrationName.PRICING,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.PRICING),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.PROMOTION]: {
key: Modules.PROMOTION,
registrationName: ModuleRegistrationName.PROMOTION,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.PROMOTION),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.AUTH]: {
key: Modules.AUTH,
registrationName: ModuleRegistrationName.AUTH,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.AUTH),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.WORKFLOW_ENGINE]: {
key: Modules.WORKFLOW_ENGINE,
registrationName: ModuleRegistrationName.WORKFLOW_ENGINE,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.WORKFLOW_ENGINE),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.SALES_CHANNEL]: {
key: Modules.SALES_CHANNEL,
registrationName: ModuleRegistrationName.SALES_CHANNEL,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.SALES_CHANNEL),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.FULFILLMENT]: {
key: Modules.FULFILLMENT,
registrationName: ModuleRegistrationName.FULFILLMENT,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.FULFILLMENT),
isRequired: false,
isQueryable: true,
dependencies: ["logger", "eventBusService"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.CART]: {
key: Modules.CART,
registrationName: ModuleRegistrationName.CART,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.CART),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.CUSTOMER]: {
key: Modules.CUSTOMER,
registrationName: ModuleRegistrationName.CUSTOMER,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.CUSTOMER),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.PAYMENT]: {
key: Modules.PAYMENT,
registrationName: ModuleRegistrationName.PAYMENT,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.PAYMENT),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.USER]: {
key: Modules.USER,
registrationName: ModuleRegistrationName.USER,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.USER),
isRequired: false,
isQueryable: true,
dependencies: [ModuleRegistrationName.EVENT_BUS, "logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.REGION]: {
key: Modules.REGION,
registrationName: ModuleRegistrationName.REGION,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.REGION),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.ORDER]: {
key: Modules.ORDER,
registrationName: ModuleRegistrationName.ORDER,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.ORDER),
isRequired: false,
isQueryable: true,
dependencies: ["logger", "eventBusService"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.TAX]: {
key: Modules.TAX,
registrationName: ModuleRegistrationName.TAX,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.TAX),
isRequired: false,
isQueryable: true,
dependencies: ["logger", "eventBusService"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.API_KEY]: {
key: Modules.API_KEY,
registrationName: ModuleRegistrationName.API_KEY,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.API_KEY),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.STORE]: {
key: Modules.STORE,
registrationName: ModuleRegistrationName.STORE,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.STORE),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.CURRENCY]: {
key: Modules.CURRENCY,
registrationName: ModuleRegistrationName.CURRENCY,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.CURRENCY),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.FILE]: {
key: Modules.FILE,
registrationName: ModuleRegistrationName.FILE,
defaultPackage: false,
label: upperCaseFirst(ModuleRegistrationName.FILE),
isRequired: false,
isQueryable: true,
dependencies: ["logger"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
export const MODULE_DEFINITIONS: ModuleDefinition[] =
Object.values(ModulesDefinition)
export default MODULE_DEFINITIONS
+8
View File
@@ -0,0 +1,8 @@
export * from "@medusajs/types/dist/modules-sdk"
export * from "./definitions"
export * from "./loaders"
export * from "./medusa-app"
export * from "./medusa-module"
export * from "./remote-link"
export * from "./remote-query"
export * from "./utils/initialize-factory"
@@ -0,0 +1,13 @@
const loader = ({}) => {
throw new Error("loader")
}
const service = class TestService {}
const migrations = []
const loaders = [loader]
export default {
service,
migrations,
loaders,
}
@@ -0,0 +1,11 @@
const service = class TestService {}
const migrations = []
const loaders = []
const models = []
export default {
service,
migrations,
loaders,
models,
}
@@ -0,0 +1,7 @@
const migrations = []
const loaders = []
export default {
migrations,
loaders,
}
@@ -0,0 +1,5 @@
const service = class TestService {}
export default {
services: [service],
}
@@ -0,0 +1,5 @@
const service = class TestService {}
export const defaultExport = {
services: [service],
}
@@ -0,0 +1,3 @@
export default {
loaders: [],
}
@@ -0,0 +1,2 @@
export const trackInstallation = jest.fn()
export const trackFeatureFlag = jest.fn()
@@ -0,0 +1,324 @@
import {
InternalModuleDeclaration,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
} from "@medusajs/types"
import { MedusaModule } from "../../medusa-module"
import { asValue } from "awilix"
const mockRegisterMedusaModule = jest.fn().mockImplementation(() => {
return {
moduleKey: {
definition: {
key: "moduleKey",
registrationName: "moduleKey",
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
})
const mockModuleLoader = jest.fn().mockImplementation(({ container }) => {
container.register({
moduleKey: asValue({}),
})
return Promise.resolve({})
})
jest.mock("./../../loaders", () => ({
registerMedusaModule: jest
.fn()
.mockImplementation((...args) => mockRegisterMedusaModule()),
moduleLoader: jest
.fn()
.mockImplementation((...args) => mockModuleLoader.apply(this, args)),
}))
describe("Medusa Modules", () => {
beforeEach(() => {
MedusaModule.clearInstances()
jest.resetModules()
jest.clearAllMocks()
})
it("should create singleton instances", async () => {
await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
})
expect(mockRegisterMedusaModule).toBeCalledTimes(1)
expect(mockModuleLoader).toBeCalledTimes(1)
await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
})
await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration,
})
expect(mockRegisterMedusaModule).toBeCalledTimes(2)
expect(mockModuleLoader).toBeCalledTimes(2)
})
it("should prevent the module being loaded multiple times under concurrent requests", async () => {
const load: any = []
for (let i = 5; i--; ) {
load.push(
MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
})
)
}
const intances = Promise.all(load)
expect(mockRegisterMedusaModule).toBeCalledTimes(1)
expect(mockModuleLoader).toBeCalledTimes(1)
expect(intances[(await intances).length - 1]).toBe(intances[0])
})
it("getModuleInstance should return the first instance of the module if there is none flagged as 'main'", async () => {
const moduleA = await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
})
const moduleB = await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration,
})
expect(MedusaModule.getModuleInstance("moduleKey")).toEqual(moduleA)
})
it("should return the module flagged as 'main' when multiple instances are available", async () => {
const moduleA = await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
})
const moduleB = await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
options: {
different_options: "abc",
},
} as InternalModuleDeclaration,
})
expect(MedusaModule.getModuleInstance("moduleKey")).toEqual(moduleB)
})
it("should retrieve the module by their given alias", async () => {
const moduleA = await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "mod_A",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
})
const moduleB = await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
alias: "mod_B",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration,
})
const moduleC = await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "mod_C",
options: {
moduleC: true,
},
} as InternalModuleDeclaration,
})
// main
expect(MedusaModule.getModuleInstance("moduleKey")).toEqual(moduleB)
expect(MedusaModule.getModuleInstance("moduleKey", "mod_A")).toEqual(
moduleA
)
expect(MedusaModule.getModuleInstance("moduleKey", "mod_B")).toEqual(
moduleB
)
expect(MedusaModule.getModuleInstance("moduleKey", "mod_C")).toEqual(
moduleC
)
})
it("should prevent two main modules being set as 'main'", async () => {
await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "mod_A",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
})
await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
alias: "mod_B",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration,
})
const moduleC = MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
alias: "mod_C",
options: {
moduleC: true,
},
} as InternalModuleDeclaration,
})
expect(moduleC).rejects.toThrow(
"Module moduleKey already have a 'main' registered."
)
})
it("should prevent the same alias be used for different instances of the same module", async () => {
await MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "module_alias",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration,
})
const moduleC = MedusaModule.bootstrap({
moduleKey: "moduleKey",
defaultPath: "@path",
declaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "module_alias",
options: {
moduleC: true,
},
} as InternalModuleDeclaration,
})
expect(moduleC).rejects.toThrow(
"Module moduleKey already registed as 'module_alias'. Please choose a different alias."
)
})
})
@@ -0,0 +1,279 @@
import {
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleResolution,
} from "@medusajs/types"
import { createMedusaContainer } from "@medusajs/utils"
import { EOL } from "os"
import { moduleLoader } from "../module-loader"
const logger = {
warn: jest.fn(),
error: jest.fn(),
} as any
describe("modules loader", () => {
let container
afterEach(() => {
jest.clearAllMocks()
})
beforeEach(() => {
container = createMedusaContainer()
})
it("should register the service as undefined in the container when no resolution path is given", async () => {
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: false,
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "testService",
label: "TestService",
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
await moduleLoader({ container, moduleResolutions, logger })
const testService = container.resolve(
moduleResolutions.testService.definition.key
)
expect(testService).toBe(undefined)
})
it("should register the service ", async () => {
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: "@modules/default",
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "testService",
label: "TestService",
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
await moduleLoader({ container, moduleResolutions, logger })
const testService = container.resolve(
moduleResolutions.testService.definition.key,
{}
)
/*
expect(trackInstallation).toHaveBeenCalledWith(
{
module: moduleResolutions.testService.definition.key,
resolution: moduleResolutions.testService.resolutionPath,
},
"module"
)
*/
expect(testService).toBeTruthy()
expect(typeof testService).toEqual("object")
})
it("should run the defined loaders and logs the errors if something fails", async () => {
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: "@modules/brokenloader",
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "testService",
label: "TestService",
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
await moduleLoader({ container, moduleResolutions, logger })
expect(logger.warn).toHaveBeenCalledWith(
`Could not resolve module: TestService. Error: Loaders for module TestService failed: loader${EOL}`
)
})
it("should log the errors if no service is defined", async () => {
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: "@modules/no-service",
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "testService",
label: "TestService",
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
await moduleLoader({ container, moduleResolutions, logger })
expect(logger.warn).toHaveBeenCalledWith(
`Could not resolve module: TestService. Error: No service found in module. Make sure your module exports a service.${EOL}`
)
})
it("should throw an error if no service is defined and the module is required", async () => {
expect.assertions(1)
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: "@modules/no-service",
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "testService",
label: "TestService",
isRequired: true,
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
try {
await moduleLoader({ container, moduleResolutions, logger })
} catch (err) {
expect(err.message).toEqual(
"No service found in module. Make sure your module exports a service."
)
}
})
it("should throw an error if the default package isn't found and the module is required", async () => {
expect.assertions(1)
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: "@medusajs/testService",
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "@medusajs/testService",
label: "TestService",
isRequired: true,
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
try {
await moduleLoader({ container, moduleResolutions, logger })
} catch (err) {
expect(err.message).toEqual(
`Make sure you have installed the default package: @medusajs/testService`
)
}
})
it("should throw an error if no scope is defined on the module declaration", async () => {
expect.assertions(1)
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: "@modules/no-service",
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "testService",
label: "TestService",
isRequired: true,
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
// @ts-ignore
moduleDeclaration: {
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
try {
await moduleLoader({ container, moduleResolutions, logger })
} catch (err) {
expect(err.message).toEqual(
"The module TestService has to define its scope (internal | external)"
)
}
})
it("should throw an error if the resources is not set when scope is defined as internal", async () => {
expect.assertions(1)
const moduleResolutions: Record<string, ModuleResolution> = {
testService: {
resolutionPath: "@modules/no-service",
definition: {
registrationName: "testService",
key: "testService",
defaultPackage: "testService",
label: "TestService",
isRequired: true,
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
// @ts-ignore
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
},
} as any,
}
try {
await moduleLoader({ container, moduleResolutions, logger })
} catch (err) {
expect(err.message).toEqual(
"The module TestService is missing its resources config"
)
}
})
})
@@ -0,0 +1,98 @@
import { createMedusaContainer } from "@medusajs/utils"
import { Lifetime, asFunction } from "awilix"
import { moduleProviderLoader } from "../module-provider-loader"
const logger = {
warn: jest.fn(),
error: jest.fn(),
} as any
describe("modules loader", () => {
let container
afterEach(() => {
jest.clearAllMocks()
})
beforeEach(() => {
container = createMedusaContainer()
})
it("should register the provider service", async () => {
const moduleProviders = [
{
resolve: "@plugins/default",
options: {},
},
]
await moduleProviderLoader({ container, providers: moduleProviders })
const testService = container.resolve("testService")
expect(testService).toBeTruthy()
expect(testService.constructor.name).toEqual("TestService")
})
it("should register the provider service with custom register fn", async () => {
const fn = async (klass, container, details) => {
container.register({
[`testServiceCustomRegistration`]: asFunction(
(cradle) => new klass(cradle, details.options),
{
lifetime: Lifetime.SINGLETON,
}
),
})
}
const moduleProviders = [
{
resolve: "@plugins/default",
options: {},
},
]
await moduleProviderLoader({
container,
providers: moduleProviders,
registerServiceFn: fn,
})
const testService = container.resolve("testServiceCustomRegistration")
expect(testService).toBeTruthy()
expect(testService.constructor.name).toEqual("TestService")
})
it("should log the errors if no service is defined", async () => {
const moduleProviders = [
{
resolve: "@plugins/no-service",
options: {},
},
]
try {
await moduleProviderLoader({ container, providers: moduleProviders })
} catch (error) {
expect(error.message).toBe(
"No services found in plugin @plugins/no-service -- make sure your plugin has a default export of services."
)
}
})
it("should throw if no default export is defined", async () => {
const moduleProviders = [
{
resolve: "@plugins/no-default",
options: {},
},
]
try {
await moduleProviderLoader({ container, providers: moduleProviders })
} catch (error) {
expect(error.message).toBe(
"No services found in plugin @plugins/no-default -- make sure your plugin has a default export of services."
)
}
})
})
@@ -0,0 +1,244 @@
import {
InternalModuleDeclaration,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleDefinition,
} from "@medusajs/types"
import { ModulesDefinition } from "../../definitions"
import { registerMedusaModule } from "../register-modules"
const RESOLVED_PACKAGE = "@medusajs/test-service-resolved"
jest.mock("resolve-cwd", () => jest.fn(() => RESOLVED_PACKAGE))
describe("module definitions loader", () => {
const defaultDefinition: ModuleDefinition = {
key: "testService",
registrationName: "testService",
defaultPackage: "@medusajs/test-service",
label: "TestService",
isLegacy: true,
isRequired: false,
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
}
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
// Clear module definitions
const allProperties = Object.getOwnPropertyNames(ModulesDefinition)
allProperties.forEach((property) => {
delete ModulesDefinition[property]
})
})
it("Resolves module with default definition given empty config", () => {
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key)
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: defaultDefinition.defaultPackage,
definition: defaultDefinition,
options: {},
moduleDeclaration: {
scope: "internal",
resources: "shared",
},
})
)
})
it("Resolves a custom module without pre-defined definition", () => {
const res = registerMedusaModule("customModulesABC", {
options: {
test: 123,
},
})
expect(res).toEqual({
customModulesABC: expect.objectContaining({
resolutionPath: "@medusajs/test-service-resolved",
definition: expect.objectContaining({
key: "customModulesABC",
label: "Custom: customModulesABC",
registrationName: "customModulesABC",
}),
moduleDeclaration: {
resources: "shared",
scope: "internal",
},
options: {
test: 123,
},
}),
})
})
describe("boolean config", () => {
it("Resolves module with no resolution path when given false", () => {
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, false)
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: false,
definition: defaultDefinition,
options: {},
})
)
})
it("Fails to resolve module with no resolution path when given false for a required module", () => {
expect.assertions(1)
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: { ...defaultDefinition, isRequired: true },
})
try {
registerMedusaModule(defaultDefinition.key, false)
} catch (err) {
expect(err.message).toEqual(
`Module: ${defaultDefinition.label} is required`
)
}
})
})
it("Module with no resolution path when not given custom resolution path, false as default package and required", () => {
const definition = {
...defaultDefinition,
defaultPackage: false as false,
isRequired: true,
}
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: definition,
})
const res = registerMedusaModule(defaultDefinition.key)
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: false,
definition: definition,
options: {},
moduleDeclaration: {
scope: "internal",
resources: "shared",
},
})
)
})
describe("string config", () => {
it("Resolves module with default definition given empty config", () => {
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(
defaultDefinition.key,
defaultDefinition.defaultPackage
)
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: RESOLVED_PACKAGE,
definition: defaultDefinition,
options: {},
moduleDeclaration: {
scope: "internal",
resources: "shared",
},
})
)
})
})
describe("object config", () => {
it("Resolves resolution path and provides empty options when none are provided", () => {
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, {
scope: MODULE_SCOPE.INTERNAL,
resolve: defaultDefinition.defaultPackage,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
} as InternalModuleDeclaration)
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: RESOLVED_PACKAGE,
definition: defaultDefinition,
options: {},
moduleDeclaration: {
scope: "internal",
resources: "isolated",
resolve: defaultDefinition.defaultPackage,
},
})
)
})
it("Resolves default resolution path and provides options when only options are provided", () => {
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, {
options: { test: 123 },
} as any)
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: defaultDefinition.defaultPackage,
definition: defaultDefinition,
options: { test: 123 },
moduleDeclaration: {
scope: "internal",
resources: "shared",
options: { test: 123 },
},
})
)
})
it("Resolves resolution path and provides options when only options are provided", () => {
Object.assign(ModulesDefinition, {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, {
resolve: defaultDefinition.defaultPackage,
options: { test: 123 },
scope: "internal",
resources: "isolated",
} as any)
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: RESOLVED_PACKAGE,
definition: defaultDefinition,
options: { test: 123 },
moduleDeclaration: {
scope: "internal",
resources: "isolated",
resolve: defaultDefinition.defaultPackage,
options: { test: 123 },
},
})
)
})
})
})
@@ -0,0 +1,4 @@
export * from "./module-loader"
export * from "./module-provider-loader"
export * from "./register-modules"
@@ -0,0 +1,99 @@
import {
Logger,
MedusaContainer,
MODULE_SCOPE,
ModuleResolution,
} from "@medusajs/types"
import { asValue } from "awilix"
import { EOL } from "os"
import { loadInternalModule } from "./utils"
export const moduleLoader = async ({
container,
moduleResolutions,
logger,
migrationOnly,
loaderOnly,
}: {
container: MedusaContainer
moduleResolutions: Record<string, ModuleResolution>
logger: Logger
migrationOnly?: boolean
loaderOnly?: boolean
}): Promise<void> => {
for (const resolution of Object.values(moduleResolutions ?? {})) {
const registrationResult = await loadModule(
container,
resolution,
logger!,
migrationOnly,
loaderOnly
)
if (registrationResult?.error) {
const { error } = registrationResult
if (resolution.definition.isRequired) {
logger?.error(
`Could not resolve required module: ${resolution.definition.label}. Error: ${error.message}${EOL}`
)
throw error
}
logger?.warn(
`Could not resolve module: ${resolution.definition.label}. Error: ${error.message}${EOL}`
)
}
}
}
async function loadModule(
container: MedusaContainer,
resolution: ModuleResolution,
logger: Logger,
migrationOnly?: boolean,
loaderOnly?: boolean
): Promise<{ error?: Error } | void> {
const modDefinition = resolution.definition
const registrationName = modDefinition.registrationName
const { scope, resources } = resolution.moduleDeclaration ?? ({} as any)
const canSkip =
!resolution.resolutionPath &&
!modDefinition.isRequired &&
!modDefinition.defaultPackage
if (scope === MODULE_SCOPE.EXTERNAL && !canSkip) {
// TODO: implement external Resolvers
// return loadExternalModule(...)
throw new Error("External Modules are not supported yet.")
}
if (!scope || (scope === MODULE_SCOPE.INTERNAL && !resources)) {
let message = `The module ${resolution.definition.label} has to define its scope (internal | external)`
if (scope === MODULE_SCOPE.INTERNAL && !resources) {
message = `The module ${resolution.definition.label} is missing its resources config`
}
container.register(registrationName, asValue(undefined))
return {
error: new Error(message),
}
}
if (resolution.resolutionPath === false) {
container.register(registrationName, asValue(undefined))
return
}
return await loadInternalModule(
container,
resolution,
logger,
migrationOnly,
loaderOnly
)
}
@@ -0,0 +1,80 @@
import { MedusaContainer, ModuleProvider } from "@medusajs/types"
import { isString, lowerCaseFirst, promiseAll } from "@medusajs/utils"
import { Lifetime, asFunction } from "awilix"
export async function moduleProviderLoader({
container,
providers,
registerServiceFn,
}: {
container: MedusaContainer
providers: ModuleProvider[]
registerServiceFn?: (
klass,
container: MedusaContainer,
pluginDetails: any
) => Promise<void>
}) {
if (!providers?.length) {
return
}
await promiseAll(
providers.map(async (pluginDetails) => {
await loadModuleProvider(container, pluginDetails, registerServiceFn)
})
)
}
export async function loadModuleProvider(
container: MedusaContainer,
provider: ModuleProvider,
registerServiceFn?: (klass, container, pluginDetails) => Promise<void>
) {
let loadedProvider: any
const pluginName = provider.resolve ?? provider.provider_name ?? ""
try {
loadedProvider = provider.resolve
if (isString(provider.resolve)) {
loadedProvider = await import(provider.resolve)
}
} catch (error) {
throw new Error(
`Unable to find plugin ${pluginName} -- perhaps you need to install its package?`
)
}
loadedProvider = (loadedProvider as any).default ?? loadedProvider
if (!loadedProvider?.services?.length) {
throw new Error(
`No services found in plugin ${provider.resolve} -- make sure your plugin has a default export of services.`
)
}
const services = await promiseAll(
loadedProvider.services.map(async (service) => {
const name = lowerCaseFirst(service.name)
if (registerServiceFn) {
// Used to register the specific type of service in the provider
await registerServiceFn(service, container, provider.options)
} else {
container.register({
[name]: asFunction(
(cradle) => new service(cradle, provider.options),
{
lifetime: service.LIFE_TIME || Lifetime.SCOPED,
}
),
})
}
return service
})
)
return services
}
@@ -0,0 +1,165 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleDefinition,
ModuleExports,
ModuleResolution,
} from "@medusajs/types"
import { isObject, isString } from "@medusajs/utils"
import resolveCwd from "resolve-cwd"
import { ModulesDefinition } from "../definitions"
export const registerMedusaModule = (
moduleKey: string,
moduleDeclaration?:
| Partial<InternalModuleDeclaration | ExternalModuleDeclaration>
| string
| false,
moduleExports?: ModuleExports,
definition?: ModuleDefinition
): Record<string, ModuleResolution> => {
const moduleResolutions = {} as Record<string, ModuleResolution>
const modDefinition = definition ?? ModulesDefinition[moduleKey]
const modDeclaration =
moduleDeclaration ??
(modDefinition?.defaultModuleDeclaration as InternalModuleDeclaration)
if (modDeclaration !== false && !modDeclaration) {
throw new Error(`Module: ${moduleKey} has no declaration.`)
}
if (
isObject(modDeclaration) &&
modDeclaration?.scope === MODULE_SCOPE.EXTERNAL
) {
// TODO: getExternalModuleResolution(...)
throw new Error("External Modules are not supported yet.")
}
if (modDefinition === undefined) {
moduleResolutions[moduleKey] = getCustomModuleResolution(
moduleKey,
moduleDeclaration as InternalModuleDeclaration
)
return moduleResolutions
}
moduleResolutions[moduleKey] = getInternalModuleResolution(
modDefinition,
moduleDeclaration as InternalModuleDeclaration,
moduleExports
)
return moduleResolutions
}
function getCustomModuleResolution(
key: string,
moduleConfig: InternalModuleDeclaration | string
): ModuleResolution {
const resolutionPath = resolveCwd(
isString(moduleConfig) ? moduleConfig : (moduleConfig.resolve as string)
)
const conf = isObject(moduleConfig)
? moduleConfig
: ({} as InternalModuleDeclaration)
const dependencies = conf?.dependencies ?? []
return {
resolutionPath,
definition: {
key,
label: `Custom: ${key}`,
isRequired: false,
defaultPackage: "",
dependencies,
registrationName: key,
defaultModuleDeclaration: {
resources: MODULE_RESOURCE_TYPE.SHARED,
scope: MODULE_SCOPE.INTERNAL,
},
},
moduleDeclaration: {
resources: conf?.resources ?? MODULE_RESOURCE_TYPE.SHARED,
scope: MODULE_SCOPE.INTERNAL,
},
dependencies,
options: conf?.options ?? {},
}
}
export const registerMedusaLinkModule = (
definition: ModuleDefinition,
moduleDeclaration: Partial<InternalModuleDeclaration>,
moduleExports?: ModuleExports
): Record<string, ModuleResolution> => {
const moduleResolutions = {} as Record<string, ModuleResolution>
moduleResolutions[definition.key] = getInternalModuleResolution(
definition,
moduleDeclaration as InternalModuleDeclaration,
moduleExports
)
return moduleResolutions
}
function getInternalModuleResolution(
definition: ModuleDefinition,
moduleConfig: InternalModuleDeclaration | string | false,
moduleExports?: ModuleExports
): ModuleResolution {
if (typeof moduleConfig === "boolean") {
if (!moduleConfig && definition.isRequired) {
throw new Error(`Module: ${definition.label} is required`)
}
if (!moduleConfig) {
return {
resolutionPath: false,
definition,
dependencies: [],
options: {},
}
}
}
const isObj = isObject(moduleConfig)
let resolutionPath = definition.defaultPackage
// If user added a module and it's overridable, we resolve that instead
const isStr = isString(moduleConfig)
if (isStr || (isObj && moduleConfig.resolve)) {
resolutionPath = !moduleExports
? resolveCwd(isStr ? moduleConfig : (moduleConfig.resolve as string))
: // Explicitly assign an empty string, later, we will check if the value is exactly false.
// This allows to continue the module loading while using the module exports instead of re importing the module itself during the process.
""
}
const moduleDeclaration = isObj ? moduleConfig : {}
const additionalDependencies = isObj ? moduleConfig.dependencies || [] : []
return {
resolutionPath,
definition,
dependencies: [
...new Set(
(definition.dependencies || []).concat(additionalDependencies)
),
],
moduleDeclaration: {
...(definition.defaultModuleDeclaration ?? {}),
...moduleDeclaration,
},
moduleExports,
options: isObj ? moduleConfig.options ?? {} : {},
}
}
@@ -0,0 +1 @@
export * from "./load-internal"
@@ -0,0 +1,162 @@
import {
InternalModuleDeclaration,
Logger,
MedusaContainer,
MODULE_RESOURCE_TYPE,
ModuleExports,
ModuleResolution,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
createMedusaContainer,
MedusaModuleType,
} from "@medusajs/utils"
import { asFunction, asValue } from "awilix"
export async function loadInternalModule(
container: MedusaContainer,
resolution: ModuleResolution,
logger: Logger,
migrationOnly?: boolean,
loaderOnly?: boolean
): Promise<{ error?: Error } | void> {
const registrationName = !loaderOnly
? resolution.definition.registrationName
: resolution.definition.registrationName + "__loaderOnly"
const { resources } =
resolution.moduleDeclaration as InternalModuleDeclaration
let loadedModule: ModuleExports
try {
// When loading manually, we pass the exports to be loaded, meaning that we do not need to import the package to find
// the exports. This is useful when a package export an initialize function which will bootstrap itself and therefore
// does not need to import the package that is currently being loaded as it would create a
// circular reference.
const modulePath = resolution.resolutionPath as string
if (resolution.moduleExports) {
loadedModule = resolution.moduleExports
} else {
loadedModule = await import(modulePath)
loadedModule = (loadedModule as any).default
}
} catch (error) {
if (
resolution.definition.isRequired &&
resolution.definition.defaultPackage
) {
return {
error: new Error(
`Make sure you have installed the default package: ${resolution.definition.defaultPackage}`
),
}
}
return { error }
}
if (!loadedModule?.service) {
container.register({
[registrationName]: asValue(undefined),
})
return {
error: new Error(
"No service found in module. Make sure your module exports a service."
),
}
}
if (migrationOnly) {
// Partially loaded module, only register the service __joinerConfig function to be able to resolve it later
const moduleService = {
__joinerConfig: loadedModule.service.prototype.__joinerConfig,
}
container.register({
[registrationName]: asValue(moduleService),
})
return
}
const localContainer = createMedusaContainer()
const dependencies = resolution?.dependencies ?? []
if (resources === MODULE_RESOURCE_TYPE.SHARED) {
dependencies.push(
ContainerRegistrationKeys.MANAGER,
ContainerRegistrationKeys.CONFIG_MODULE,
ContainerRegistrationKeys.LOGGER,
ContainerRegistrationKeys.PG_CONNECTION
)
}
for (const dependency of dependencies) {
localContainer.register(
dependency,
asFunction(() => {
return container.resolve(dependency, { allowUnregistered: true })
})
)
}
const moduleLoaders = loadedModule?.loaders ?? []
try {
for (const loader of moduleLoaders) {
await loader(
{
container: localContainer,
logger,
options: resolution.options,
dataLoaderOnly: loaderOnly,
},
resolution.moduleDeclaration as InternalModuleDeclaration
)
}
} catch (err) {
container.register({
[registrationName]: asValue(undefined),
})
return {
error: new Error(
`Loaders for module ${resolution.definition.label} failed: ${err.message}`
),
}
}
const moduleService = loadedModule.service
container.register({
[registrationName]: asFunction((cradle) => {
;(moduleService as any).__type = MedusaModuleType
return new moduleService(
localContainer.cradle,
resolution.options,
resolution.moduleDeclaration
)
}).singleton(),
})
if (loaderOnly) {
// The expectation is only to run the loader as standalone, so we do not need to register the service and we need to cleanup all services
const service = container.resolve(registrationName)
await service.__hooks?.onApplicationPrepareShutdown()
await service.__hooks?.onApplicationShutdown()
}
}
export async function loadModuleMigrations(
resolution: ModuleResolution,
moduleExports?: ModuleExports
): Promise<[Function | undefined, Function | undefined]> {
let loadedModule: ModuleExports
try {
loadedModule =
moduleExports ?? (await import(resolution.resolutionPath as string))
return [loadedModule.runMigrations, loadedModule.revertMigration]
} catch {
return [undefined, undefined]
}
}
+429
View File
@@ -0,0 +1,429 @@
import { mergeTypeDefs } from "@graphql-tools/merge"
import { makeExecutableSchema } from "@graphql-tools/schema"
import { RemoteFetchDataCallback } from "@medusajs/orchestration"
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
LoadedModule,
MedusaContainer,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleDefinition,
ModuleExports,
ModuleJoinerConfig,
ModuleServiceInitializeOptions,
RemoteJoinerOptions,
RemoteJoinerQuery,
RemoteQueryFunction,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
createMedusaContainer,
isObject,
isString,
ModulesSdkUtils,
promiseAll,
} from "@medusajs/utils"
import { asValue } from "awilix"
import {
MODULE_PACKAGE_NAMES,
ModuleRegistrationName,
Modules,
} from "./definitions"
import { MedusaModule } from "./medusa-module"
import { RemoteLink } from "./remote-link"
import { RemoteQuery } from "./remote-query"
import { cleanGraphQLSchema } from "./utils"
const LinkModulePackage = MODULE_PACKAGE_NAMES[Modules.LINK]
export type RunMigrationFn = (
options?: ModuleServiceInitializeOptions,
injectedDependencies?: Record<any, any>
) => Promise<void>
export type MedusaModuleConfig = {
[key: string | Modules]:
| string
| boolean
| Partial<InternalModuleDeclaration | ExternalModuleDeclaration>
}
export type SharedResources = {
database?: ModuleServiceInitializeOptions["database"] & {
/**
* {
* name?: string
* afterCreate?: Function
* min?: number
* max?: number
* refreshIdle?: boolean
* idleTimeoutMillis?: number
* reapIntervalMillis?: number
* returnToHead?: boolean
* priorityRange?: number
* log?: (message: string, logLevel: string) => void
* }
*/
pool?: Record<string, unknown>
}
}
export async function loadModules(
modulesConfig,
sharedContainer,
migrationOnly = false,
loaderOnly = false,
workerMode: "shared" | "worker" | "server" = "server"
) {
const allModules = {}
await Promise.all(
Object.keys(modulesConfig).map(async (moduleName) => {
const mod = modulesConfig[moduleName]
let path: string
let moduleExports: ModuleExports | undefined = undefined
let declaration: any = {}
let definition: Partial<ModuleDefinition> | undefined = undefined
if (isObject(mod)) {
const mod_ = mod as unknown as InternalModuleDeclaration
path = mod_.resolve ?? MODULE_PACKAGE_NAMES[moduleName]
definition = mod_.definition
moduleExports = !isString(mod_.resolve)
? (mod_.resolve as ModuleExports)
: undefined
declaration = { ...mod }
delete declaration.definition
} else {
path = MODULE_PACKAGE_NAMES[moduleName]
}
declaration.scope ??= MODULE_SCOPE.INTERNAL
if (
declaration.scope === MODULE_SCOPE.INTERNAL &&
!declaration.resources
) {
declaration.resources = MODULE_RESOURCE_TYPE.SHARED
}
const loaded = (await MedusaModule.bootstrap({
moduleKey: moduleName,
defaultPath: path,
declaration,
sharedContainer,
moduleDefinition: definition as ModuleDefinition,
moduleExports,
migrationOnly,
loaderOnly,
workerMode,
})) as LoadedModule
if (loaderOnly) {
return
}
const service = loaded[moduleName]
sharedContainer.register({
[service.__definition.registrationName]: asValue(service),
})
if (allModules[moduleName] && !Array.isArray(allModules[moduleName])) {
allModules[moduleName] = []
}
if (allModules[moduleName]) {
;(allModules[moduleName] as LoadedModule[]).push(loaded[moduleName])
} else {
allModules[moduleName] = loaded[moduleName]
}
})
)
return allModules
}
async function initializeLinks({
config,
linkModules,
injectedDependencies,
moduleExports,
}) {
try {
const { initialize, runMigrations } =
moduleExports ?? (await import(LinkModulePackage))
const linkResolution = await initialize(
config,
linkModules,
injectedDependencies
)
return { remoteLink: new RemoteLink(), linkResolution, runMigrations }
} catch (err) {
console.warn("Error initializing link modules.", err)
return {
remoteLink: undefined,
linkResolution: undefined,
runMigrations: undefined,
}
}
}
function isMedusaModule(mod) {
return typeof mod?.initialize === "function"
}
function cleanAndMergeSchema(loadedSchema) {
const { schema: cleanedSchema, notFound } = cleanGraphQLSchema(loadedSchema)
const mergedSchema = mergeTypeDefs(cleanedSchema)
return { schema: makeExecutableSchema({ typeDefs: mergedSchema }), notFound }
}
function getLoadedSchema(): string {
return MedusaModule.getAllJoinerConfigs()
.map((joinerConfig) => joinerConfig?.schema ?? "")
.join("\n")
}
function registerCustomJoinerConfigs(servicesConfig: ModuleJoinerConfig[]) {
for (const config of servicesConfig) {
if (!config.serviceName || config.isReadOnlyLink) {
continue
}
MedusaModule.setJoinerConfig(config.serviceName, config)
}
}
export type MedusaAppOutput = {
modules: Record<string, LoadedModule | LoadedModule[]>
link: RemoteLink | undefined
query: RemoteQueryFunction
entitiesMap?: Record<string, any>
notFound?: Record<string, Record<string, string>>
runMigrations: RunMigrationFn
onApplicationShutdown: () => Promise<void>
onApplicationPrepareShutdown: () => Promise<void>
}
export type MedusaAppOptions = {
workerMode?: "shared" | "worker" | "server"
sharedContainer?: MedusaContainer
sharedResourcesConfig?: SharedResources
loadedModules?: LoadedModule[]
servicesConfig?: ModuleJoinerConfig[]
modulesConfigPath?: string
modulesConfigFileName?: string
modulesConfig?: MedusaModuleConfig
linkModules?: ModuleJoinerConfig | ModuleJoinerConfig[]
remoteFetchData?: RemoteFetchDataCallback
injectedDependencies?: any
onApplicationStartCb?: () => void
/**
* Forces the modules bootstrapper to only run the modules loaders and return prematurely
*/
loaderOnly?: boolean
}
async function MedusaApp_({
sharedContainer,
sharedResourcesConfig,
servicesConfig,
modulesConfigPath,
modulesConfigFileName,
modulesConfig,
linkModules,
remoteFetchData,
injectedDependencies = {},
onApplicationStartCb,
migrationOnly = false,
loaderOnly = false,
workerMode = "server",
}: MedusaAppOptions & {
migrationOnly?: boolean
} = {}): Promise<MedusaAppOutput> {
const sharedContainer_ = createMedusaContainer({}, sharedContainer)
const onApplicationShutdown = async () => {
await promiseAll([
MedusaModule.onApplicationShutdown(),
sharedContainer_.dispose(),
])
}
const onApplicationPrepareShutdown = async () => {
await promiseAll([MedusaModule.onApplicationPrepareShutdown()])
}
const modules: MedusaModuleConfig =
modulesConfig ??
(
await import(
modulesConfigPath ??
process.cwd() + (modulesConfigFileName ?? "/modules-config")
)
).default
const dbData = ModulesSdkUtils.loadDatabaseConfig(
"medusa",
sharedResourcesConfig as ModuleServiceInitializeOptions,
true
)!
registerCustomJoinerConfigs(servicesConfig ?? [])
if (
sharedResourcesConfig?.database?.connection &&
!injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]
) {
injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] =
sharedResourcesConfig.database.connection
} else if (
dbData.clientUrl &&
!injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]
) {
injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] =
ModulesSdkUtils.createPgConnection({
...(sharedResourcesConfig?.database ?? {}),
...dbData,
})
}
// remove the link module from the modules
const linkModule = modules[LinkModulePackage] ?? modules[Modules.LINK]
delete modules[LinkModulePackage]
delete modules[Modules.LINK]
let linkModuleOptions = {}
if (isObject(linkModule)) {
linkModuleOptions = linkModule
}
for (const injectedDependency of Object.keys(injectedDependencies)) {
sharedContainer_.register({
[injectedDependency]: asValue(injectedDependencies[injectedDependency]),
})
}
const allModules = await loadModules(
modules,
sharedContainer_,
migrationOnly,
loaderOnly,
workerMode
)
if (loaderOnly) {
return {
onApplicationShutdown,
onApplicationPrepareShutdown,
modules: allModules,
link: undefined,
query: async () => {
throw new Error("Querying not allowed in loaderOnly mode")
},
runMigrations: async () => {
throw new Error("Migrations not allowed in loaderOnly mode")
},
}
}
// Share Event bus with link modules
injectedDependencies[ModuleRegistrationName.EVENT_BUS] =
sharedContainer_.resolve(ModuleRegistrationName.EVENT_BUS, {
allowUnregistered: true,
})
const { remoteLink, runMigrations: linkModuleMigration } =
await initializeLinks({
config: linkModuleOptions,
linkModules,
injectedDependencies,
moduleExports: isMedusaModule(linkModule) ? linkModule : undefined,
})
const loadedSchema = getLoadedSchema()
const { schema, notFound } = cleanAndMergeSchema(loadedSchema)
const remoteQuery = new RemoteQuery({
servicesConfig,
customRemoteFetchData: remoteFetchData,
})
const query = async (
query: string | RemoteJoinerQuery | object,
variables?: Record<string, unknown>,
options?: RemoteJoinerOptions
) => {
return await remoteQuery.query(query, variables, options)
}
const runMigrations: RunMigrationFn = async (
linkModuleOptions
): Promise<void> => {
for (const moduleName of Object.keys(allModules)) {
const moduleResolution = MedusaModule.getModuleResolutions(moduleName)
if (!moduleResolution.options?.database) {
moduleResolution.options ??= {}
moduleResolution.options.database = {
...(sharedResourcesConfig?.database ?? {}),
}
}
await MedusaModule.migrateUp(
moduleResolution.definition.key,
moduleResolution.resolutionPath as string,
moduleResolution.options,
moduleResolution.moduleExports
)
}
const linkModuleOpt = { ...(linkModuleOptions ?? {}) }
linkModuleOpt.database ??= {
...(sharedResourcesConfig?.database ?? {}),
}
linkModuleMigration &&
(await linkModuleMigration({
options: linkModuleOpt,
injectedDependencies,
}))
}
return {
onApplicationShutdown,
onApplicationPrepareShutdown,
modules: allModules,
link: remoteLink,
query,
entitiesMap: schema.getTypeMap(),
notFound,
runMigrations,
}
}
export async function MedusaApp(
options: MedusaAppOptions = {}
): Promise<MedusaAppOutput> {
try {
return await MedusaApp_(options)
} finally {
MedusaModule.onApplicationStart(options.onApplicationStartCb)
}
}
export async function MedusaAppMigrateUp(
options: MedusaAppOptions = {}
): Promise<void> {
const migrationOnly = true
const { runMigrations } = await MedusaApp_({
...options,
migrationOnly,
})
await runMigrations().finally(MedusaModule.clearInstances)
}
@@ -0,0 +1,580 @@
import {
ExternalModuleDeclaration,
IModuleService,
InternalModuleDeclaration,
LinkModuleDefinition,
LoadedModule,
MedusaContainer,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleBootstrapDeclaration,
ModuleDefinition,
ModuleExports,
ModuleJoinerConfig,
ModuleResolution,
} from "@medusajs/types"
import {
createMedusaContainer,
promiseAll,
simpleHash,
stringifyCircular,
} from "@medusajs/utils"
import { EOL } from "os"
import {
moduleLoader,
registerMedusaLinkModule,
registerMedusaModule,
} from "./loaders"
import { asValue } from "awilix"
import { loadModuleMigrations } from "./loaders/utils"
const logger: any = {
log: (a) => console.log(a),
info: (a) => console.log(a),
warn: (a) => console.warn(a),
error: (a) => console.error(a),
}
declare global {
interface MedusaModule {
getLoadedModules(
aliases?: Map<string, string>
): { [key: string]: LoadedModule }[]
getModuleInstance(moduleKey: string, alias?: string): LoadedModule
}
}
type ModuleAlias = {
key: string
hash: string
isLink: boolean
alias?: string
main?: boolean
}
export type ModuleBootstrapOptions = {
moduleKey: string
defaultPath: string
declaration?: ModuleBootstrapDeclaration
moduleExports?: ModuleExports
sharedContainer?: MedusaContainer
moduleDefinition?: ModuleDefinition
injectedDependencies?: Record<string, any>
/**
* In this mode, all instances are partially loaded, meaning that the module will not be fully loaded and the services will not be available.
* Don't forget to clear the instances (MedusaModule.clearInstances()) after the migration are done.
*/
migrationOnly?: boolean
/**
* Forces the modules bootstrapper to only run the modules loaders and return prematurely. This
* is meant for modules that have data loader. In a test env, in order to clear all data
* and load them back, we need to run those loader again
*/
loaderOnly?: boolean
workerMode?: "shared" | "worker" | "server"
}
export type LinkModuleBootstrapOptions = {
definition: LinkModuleDefinition
declaration?: InternalModuleDeclaration
moduleExports?: ModuleExports
injectedDependencies?: Record<string, any>
}
export class MedusaModule {
private static instances_: Map<string, { [key: string]: IModuleService }> =
new Map()
private static modules_: Map<string, ModuleAlias[]> = new Map()
private static loading_: Map<string, Promise<any>> = new Map()
private static joinerConfig_: Map<string, ModuleJoinerConfig> = new Map()
private static moduleResolutions_: Map<string, ModuleResolution> = new Map()
public static getLoadedModules(
aliases?: Map<string, string>
): { [key: string]: LoadedModule }[] {
return [...MedusaModule.modules_.entries()].map(([key]) => {
if (aliases?.has(key)) {
return MedusaModule.getModuleInstance(key, aliases.get(key))
}
return MedusaModule.getModuleInstance(key)
})
}
public static onApplicationStart(onApplicationStartCb?: () => void): void {
for (const instances of MedusaModule.instances_.values()) {
for (const instance of Object.values(instances) as IModuleService[]) {
if (instance?.__hooks) {
instance.__hooks?.onApplicationStart
?.bind(instance)()
.then(() => {
onApplicationStartCb?.()
})
.catch(() => {
// The module should handle this and log it
return void 0
})
}
}
}
}
public static async onApplicationShutdown(): Promise<void> {
await promiseAll(
[...MedusaModule.instances_.values()]
.map((instances) => {
return Object.values(instances).map((instance: IModuleService) => {
return instance.__hooks?.onApplicationShutdown
?.bind(instance)()
.catch(() => {
// The module should handle this and log it
return void 0
})
})
})
.flat()
)
}
public static async onApplicationPrepareShutdown(): Promise<void> {
await promiseAll(
[...MedusaModule.instances_.values()]
.map((instances) => {
return Object.values(instances).map((instance: IModuleService) => {
return instance.__hooks?.onApplicationPrepareShutdown
?.bind(instance)()
.catch(() => {
// The module should handle this and log it
return void 0
})
})
})
.flat()
)
}
public static clearInstances(): void {
MedusaModule.instances_.clear()
MedusaModule.modules_.clear()
MedusaModule.joinerConfig_.clear()
MedusaModule.moduleResolutions_.clear()
}
public static isInstalled(moduleKey: string, alias?: string): boolean {
if (alias) {
return (
MedusaModule.modules_.has(moduleKey) &&
MedusaModule.modules_.get(moduleKey)!.some((m) => m.alias === alias)
)
}
return MedusaModule.modules_.has(moduleKey)
}
public static getJoinerConfig(moduleKey: string): ModuleJoinerConfig {
return MedusaModule.joinerConfig_.get(moduleKey)!
}
public static getAllJoinerConfigs(): ModuleJoinerConfig[] {
return [...MedusaModule.joinerConfig_.values()]
}
public static getModuleResolutions(moduleKey: string): ModuleResolution {
return MedusaModule.moduleResolutions_.get(moduleKey)!
}
public static getAllModuleResolutions(): ModuleResolution[] {
return [...MedusaModule.moduleResolutions_.values()]
}
public static setModuleResolution(
moduleKey: string,
resolution: ModuleResolution
): ModuleResolution {
MedusaModule.moduleResolutions_.set(moduleKey, resolution)
return resolution
}
public static setJoinerConfig(
moduleKey: string,
config: ModuleJoinerConfig
): ModuleJoinerConfig {
MedusaModule.joinerConfig_.set(moduleKey, config)
return config
}
public static getModuleInstance(
moduleKey: string,
alias?: string
): any | undefined {
if (!MedusaModule.modules_.has(moduleKey)) {
return
}
let mod
const modules = MedusaModule.modules_.get(moduleKey)!
if (alias) {
mod = modules.find((m) => m.alias === alias)
return MedusaModule.instances_.get(mod?.hash)
}
mod = modules.find((m) => m.main) ?? modules[0]
return MedusaModule.instances_.get(mod?.hash)
}
private static registerModule(
moduleKey: string,
loadedModule: ModuleAlias
): void {
if (!MedusaModule.modules_.has(moduleKey)) {
MedusaModule.modules_.set(moduleKey, [])
}
const modules = MedusaModule.modules_.get(moduleKey)!
if (modules.some((m) => m.alias === loadedModule.alias)) {
throw new Error(
`Module ${moduleKey} already registed as '${loadedModule.alias}'. Please choose a different alias.`
)
}
if (loadedModule.main) {
if (modules.some((m) => m.main)) {
throw new Error(`Module ${moduleKey} already have a 'main' registered.`)
}
}
modules.push(loadedModule)
MedusaModule.modules_.set(moduleKey, modules!)
}
public static async bootstrap<T>({
moduleKey,
defaultPath,
declaration,
moduleExports,
sharedContainer,
moduleDefinition,
injectedDependencies,
migrationOnly,
loaderOnly,
workerMode,
}: ModuleBootstrapOptions): Promise<{
[key: string]: T
}> {
const hashKey = simpleHash(
stringifyCircular({ moduleKey, defaultPath, declaration })
)
if (!loaderOnly && MedusaModule.instances_.has(hashKey)) {
return MedusaModule.instances_.get(hashKey)! as {
[key: string]: T
}
}
if (!loaderOnly && MedusaModule.loading_.has(hashKey)) {
return MedusaModule.loading_.get(hashKey)
}
let finishLoading: any
let errorLoading: any
const loadingPromise = new Promise((resolve, reject) => {
finishLoading = resolve
errorLoading = reject
})
if (!loaderOnly) {
MedusaModule.loading_.set(hashKey, loadingPromise)
}
let modDeclaration =
declaration ??
({} as InternalModuleDeclaration | ExternalModuleDeclaration)
if (declaration?.scope !== MODULE_SCOPE.EXTERNAL) {
modDeclaration = {
scope: declaration?.scope || MODULE_SCOPE.INTERNAL,
resources: declaration?.resources || MODULE_RESOURCE_TYPE.ISOLATED,
resolve: defaultPath,
options: declaration?.options ?? declaration,
alias: declaration?.alias,
main: declaration?.main,
worker_mode: workerMode,
}
}
// TODO: Only do that while legacy modules sharing the manager exists then remove the ternary in favor of createMedusaContainer({}, globalContainer)
const container =
modDeclaration.scope === MODULE_SCOPE.INTERNAL &&
modDeclaration.resources === MODULE_RESOURCE_TYPE.SHARED
? sharedContainer ?? createMedusaContainer()
: createMedusaContainer({}, sharedContainer)
if (injectedDependencies) {
for (const service in injectedDependencies) {
container.register(service, asValue(injectedDependencies[service]))
if (!container.hasRegistration(service)) {
container.register(service, asValue(injectedDependencies[service]))
}
}
}
const moduleResolutions = registerMedusaModule(
moduleKey,
modDeclaration!,
moduleExports,
moduleDefinition
)
const logger_ =
container.resolve("logger", { allowUnregistered: true }) ?? logger
try {
await moduleLoader({
container,
moduleResolutions,
logger: logger_,
migrationOnly,
loaderOnly,
})
} catch (err) {
errorLoading(err)
throw err
}
const services = {}
if (loaderOnly) {
finishLoading(services)
return services
}
for (const resolution of Object.values(
moduleResolutions
) as ModuleResolution[]) {
const keyName = resolution.definition.key
const registrationName = resolution.definition.registrationName
services[keyName] = container.resolve(registrationName)
services[keyName].__definition = resolution.definition
if (resolution.definition.isQueryable) {
const joinerConfig: ModuleJoinerConfig = await services[
keyName
].__joinerConfig()
if (!joinerConfig.primaryKeys) {
logger_.warn(
`Primary keys are not defined by the module ${keyName}. Setting default primary key to 'id'${EOL}`
)
joinerConfig.primaryKeys = ["id"]
}
services[keyName].__joinerConfig = joinerConfig
MedusaModule.setJoinerConfig(keyName, joinerConfig)
}
MedusaModule.setModuleResolution(keyName, resolution)
MedusaModule.registerModule(keyName, {
key: keyName,
hash: hashKey,
alias: modDeclaration.alias ?? hashKey,
main: !!modDeclaration.main,
isLink: false,
})
}
MedusaModule.instances_.set(hashKey, services)
finishLoading(services)
MedusaModule.loading_.delete(hashKey)
return services
}
public static async bootstrapLink({
definition,
declaration,
moduleExports,
injectedDependencies,
}: LinkModuleBootstrapOptions): Promise<{
[key: string]: unknown
}> {
const moduleKey = definition.key
const hashKey = simpleHash(stringifyCircular({ moduleKey, declaration }))
if (MedusaModule.instances_.has(hashKey)) {
return { [moduleKey]: MedusaModule.instances_.get(hashKey) }
}
if (MedusaModule.loading_.has(hashKey)) {
return MedusaModule.loading_.get(hashKey)
}
let finishLoading: any
let errorLoading: any
MedusaModule.loading_.set(
hashKey,
new Promise((resolve, reject) => {
finishLoading = resolve
errorLoading = reject
})
)
let modDeclaration =
declaration ?? ({} as Partial<InternalModuleDeclaration>)
const moduleDefinition: ModuleDefinition = {
key: definition.key,
registrationName: definition.key,
dependencies: definition.dependencies,
defaultPackage: "",
label: definition.label,
isRequired: false,
isQueryable: true,
defaultModuleDeclaration: definition.defaultModuleDeclaration,
}
modDeclaration = {
resolve: "",
options: declaration,
alias: declaration?.alias,
main: declaration?.main,
}
const container = createMedusaContainer()
if (injectedDependencies) {
for (const service in injectedDependencies) {
container.register(service, asValue(injectedDependencies[service]))
}
}
const moduleResolutions = registerMedusaLinkModule(
moduleDefinition,
modDeclaration as InternalModuleDeclaration,
moduleExports
)
const logger_ =
container.resolve("logger", { allowUnregistered: true }) ?? logger
try {
await moduleLoader({
container,
moduleResolutions,
logger: logger_,
})
} catch (err) {
errorLoading(err)
throw err
}
const services = {}
for (const resolution of Object.values(
moduleResolutions
) as ModuleResolution[]) {
const keyName = resolution.definition.key
const registrationName = resolution.definition.registrationName
services[keyName] = container.resolve(registrationName)
services[keyName].__definition = resolution.definition
if (resolution.definition.isQueryable) {
const joinerConfig: ModuleJoinerConfig = await services[
keyName
].__joinerConfig()
services[keyName].__joinerConfig = joinerConfig
MedusaModule.setJoinerConfig(keyName, joinerConfig)
if (!joinerConfig.isLink) {
throw new Error(
"MedusaModule.bootstrapLink must be used only for Link Modules"
)
}
}
MedusaModule.setModuleResolution(keyName, resolution)
MedusaModule.registerModule(keyName, {
key: keyName,
hash: hashKey,
alias: modDeclaration.alias ?? hashKey,
main: !!modDeclaration.main,
isLink: true,
})
}
MedusaModule.instances_.set(hashKey, services)
finishLoading(services)
MedusaModule.loading_.delete(hashKey)
return services
}
public static async migrateUp(
moduleKey: string,
modulePath: string,
options?: Record<string, any>,
moduleExports?: ModuleExports
): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: modulePath,
options,
})
for (const mod in moduleResolutions) {
const [migrateUp] = await loadModuleMigrations(
moduleResolutions[mod],
moduleExports
)
if (typeof migrateUp === "function") {
await migrateUp({
options,
logger,
})
}
}
}
public static async migrateDown(
moduleKey: string,
modulePath: string,
options?: Record<string, any>,
moduleExports?: ModuleExports
): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: modulePath,
options,
})
for (const mod in moduleResolutions) {
const [, migrateDown] = await loadModuleMigrations(
moduleResolutions[mod],
moduleExports
)
if (typeof migrateDown === "function") {
await migrateDown({
options,
logger,
})
}
}
}
}
global.MedusaModule ??= MedusaModule
exports.MedusaModule = global.MedusaModule
@@ -0,0 +1,456 @@
import {
ILinkModule,
LoadedModule,
ModuleJoinerRelationship,
} from "@medusajs/types"
import { isObject, promiseAll, toPascalCase } from "@medusajs/utils"
import { Modules } from "./definitions"
import { MedusaModule } from "./medusa-module"
import { linkingErrorMessage } from "./utils/linking-error"
export type DeleteEntityInput = {
[moduleName: string | Modules]: Record<string, string | string[]>
}
export type RestoreEntityInput = DeleteEntityInput
type LinkDefinition = {
[moduleName: string]: {
[fieldName: string]: string
}
} & {
data?: Record<string, unknown>
}
type RemoteRelationship = ModuleJoinerRelationship & {
isPrimary: boolean
isForeign: boolean
}
type LoadedLinkModule = LoadedModule & ILinkModule
type DeleteEntities = { [key: string]: string[] }
type RemovedIds = {
[serviceName: string]: DeleteEntities
}
type RestoredIds = RemovedIds
type CascadeError = {
serviceName: string
method: String
args: any
error: Error
}
export class RemoteLink {
private modulesMap: Map<string, LoadedLinkModule> = new Map()
private relationsPairs: Map<string, LoadedLinkModule> = new Map()
private relations: Map<string, Map<string, RemoteRelationship[]>> = new Map()
constructor(modulesLoaded?: LoadedModule[]) {
if (!modulesLoaded?.length) {
modulesLoaded = MedusaModule.getLoadedModules().map(
(mod) => Object.values(mod)[0]
)
}
for (const mod of modulesLoaded) {
this.addModule(mod)
}
}
public addModule(mod: LoadedModule): void {
if (!mod.__definition.isQueryable || mod.__joinerConfig.isReadOnlyLink) {
return
}
const joinerConfig = mod.__joinerConfig
const serviceName = joinerConfig.isLink
? joinerConfig.serviceName!
: mod.__definition.key
if (this.modulesMap.has(serviceName)) {
throw new Error(
`Duplicated instance of module ${serviceName} is not allowed.`
)
}
if (joinerConfig.relationships?.length) {
if (joinerConfig.isLink) {
const [primary, foreign] = joinerConfig.relationships
const key = [
primary.serviceName,
primary.foreignKey,
foreign.serviceName,
foreign.foreignKey,
].join("-")
this.relationsPairs.set(key, mod as unknown as LoadedLinkModule)
}
for (const relationship of joinerConfig.relationships) {
if (joinerConfig.isLink && !relationship.deleteCascade) {
continue
}
this.addRelationship(serviceName, {
...relationship,
isPrimary: false,
isForeign: true,
})
}
}
if (joinerConfig.extends?.length) {
for (const service of joinerConfig.extends) {
const relationship = service.relationship
this.addRelationship(service.serviceName, {
...relationship,
serviceName: serviceName,
isPrimary: true,
isForeign: false,
})
}
}
this.modulesMap.set(serviceName, mod as unknown as LoadedLinkModule)
}
private addRelationship(
serviceName: string,
relationship: RemoteRelationship
): void {
const { primaryKey, foreignKey } = relationship
if (!this.relations.has(serviceName)) {
this.relations.set(serviceName, new Map())
}
const key = relationship.isPrimary ? primaryKey : foreignKey
const serviceMap = this.relations.get(serviceName)!
if (!serviceMap.has(key)) {
serviceMap.set(key, [])
}
serviceMap.get(key)!.push(relationship)
}
getLinkModule(
moduleA: string,
moduleAKey: string,
moduleB: string,
moduleBKey: string
) {
const key = [moduleA, moduleAKey, moduleB, moduleBKey].join("-")
return this.relationsPairs.get(key)
}
getRelationships(): Map<string, Map<string, RemoteRelationship[]>> {
return this.relations
}
private getLinkableKeys(mod: LoadedLinkModule) {
return (
(mod.__joinerConfig.linkableKeys &&
Object.keys(mod.__joinerConfig.linkableKeys)) ||
mod.__joinerConfig.primaryKeys ||
[]
)
}
private async executeCascade(
removedServices: DeleteEntityInput,
method: "softDelete" | "restore"
): Promise<[CascadeError[] | null, RemovedIds]> {
const removedIds: RemovedIds = {}
const returnIdsList: RemovedIds = {}
const processedIds: Record<string, Set<string>> = {}
const services = Object.keys(removedServices).map((serviceName) => {
const deleteKeys = {}
for (const field in removedServices[serviceName]) {
deleteKeys[field] = Array.isArray(removedServices[serviceName][field])
? removedServices[serviceName][field]
: [removedServices[serviceName][field]]
}
return { serviceName, deleteKeys }
})
const errors: CascadeError[] = []
const cascade = async (
services: { serviceName: string; deleteKeys: DeleteEntities }[],
isCascading: boolean = false
): Promise<RemovedIds> => {
if (errors.length) {
return returnIdsList
}
const servicePromises = services.map(async (serviceInfo) => {
const serviceRelations = this.relations.get(serviceInfo.serviceName)!
if (!serviceRelations) {
return
}
const values = serviceInfo.deleteKeys
const deletePromises: Promise<void>[] = []
for (const field in values) {
const relatedServices = serviceRelations.get(field)
if (!relatedServices || !values[field]?.length) {
continue
}
const relatedServicesPromises = relatedServices.map(
async (relatedService) => {
const { serviceName, primaryKey, args } = relatedService
const processedHash = `${serviceName}-${primaryKey}`
if (!processedIds[processedHash]) {
processedIds[processedHash] = new Set()
}
const unprocessedIds = values[field].filter(
(id) => !processedIds[processedHash].has(id)
)
if (!unprocessedIds.length) {
return
}
unprocessedIds.forEach((id) => {
processedIds[processedHash].add(id)
})
let cascadeDelKeys: DeleteEntities = {}
cascadeDelKeys[primaryKey] = unprocessedIds
const service: ILinkModule = this.modulesMap.get(serviceName)!
const returnFields = this.getLinkableKeys(
service as LoadedLinkModule
)
let deletedEntities: Record<string, string[]> = {}
try {
if (args?.methodSuffix) {
method += toPascalCase(args.methodSuffix)
}
const removed = await service[method](cascadeDelKeys, {
returnLinkableKeys: returnFields,
})
deletedEntities = removed as Record<string, string[]>
} catch (error) {
errors.push({
serviceName,
method,
args: cascadeDelKeys,
error: JSON.parse(
JSON.stringify(error, Object.getOwnPropertyNames(error))
),
})
return
}
if (Object.keys(deletedEntities).length === 0) {
return
}
removedIds[serviceName] = {
...deletedEntities,
}
if (!isCascading) {
returnIdsList[serviceName] = {
...deletedEntities,
}
} else {
const [mainKey] = returnFields
if (!returnIdsList[serviceName]) {
returnIdsList[serviceName] = {}
}
if (!returnIdsList[serviceName][mainKey]) {
returnIdsList[serviceName][mainKey] = []
}
returnIdsList[serviceName][mainKey] = [
...new Set(
returnIdsList[serviceName][mainKey].concat(
deletedEntities[mainKey]
)
),
]
}
Object.keys(deletedEntities).forEach((key) => {
deletedEntities[key].forEach((id) => {
const hash = `${serviceName}-${key}`
if (!processedIds[hash]) {
processedIds[hash] = new Set()
}
processedIds[hash].add(id)
})
})
await cascade(
[
{
serviceName: serviceName,
deleteKeys: deletedEntities as DeleteEntities,
},
],
true
)
}
)
deletePromises.push(...relatedServicesPromises)
}
await promiseAll(deletePromises)
})
await promiseAll(servicePromises)
return returnIdsList
}
const result = await cascade(services)
return [errors.length ? errors : null, result]
}
async create(link: LinkDefinition | LinkDefinition[]): Promise<unknown[]> {
const allLinks = Array.isArray(link) ? link : [link]
const serviceLinks = new Map<
string,
[string | string[], string, Record<string, unknown>?][]
>()
for (const rel of allLinks) {
const extraFields = rel.data
delete rel.data
const mods = Object.keys(rel)
if (mods.length > 2) {
throw new Error(`Only two modules can be linked.`)
}
const [moduleA, moduleB] = mods
const pk = Object.keys(rel[moduleA])
const moduleAKey = pk.join(",")
const moduleBKey = Object.keys(rel[moduleB]).join(",")
const service = this.getLinkModule(
moduleA,
moduleAKey,
moduleB,
moduleBKey
)
if (!service) {
throw new Error(
linkingErrorMessage({
moduleA,
moduleAKey,
moduleB,
moduleBKey,
type: "link",
})
)
} else if (!serviceLinks.has(service.__definition.key)) {
serviceLinks.set(service.__definition.key, [])
}
const pkValue =
pk.length === 1 ? rel[moduleA][pk[0]] : pk.map((k) => rel[moduleA][k])
const fields: unknown[] = [pkValue, rel[moduleB][moduleBKey]]
if (isObject(extraFields)) {
fields.push(extraFields)
}
serviceLinks.get(service.__definition.key)?.push(fields as any)
}
const promises: Promise<unknown[]>[] = []
for (const [serviceName, links] of serviceLinks) {
const service = this.modulesMap.get(serviceName)!
promises.push(service.create(links))
}
const created = await promiseAll(promises)
return created.flat()
}
async dismiss(link: LinkDefinition | LinkDefinition[]): Promise<unknown[]> {
const allLinks = Array.isArray(link) ? link : [link]
const serviceLinks = new Map<string, [string | string[], string][]>()
for (const rel of allLinks) {
const mods = Object.keys(rel)
if (mods.length > 2) {
throw new Error(`Only two modules can be linked.`)
}
const [moduleA, moduleB] = mods
const pk = Object.keys(rel[moduleA])
const moduleAKey = pk.join(",")
const moduleBKey = Object.keys(rel[moduleB]).join(",")
const service = this.getLinkModule(
moduleA,
moduleAKey,
moduleB,
moduleBKey
)
if (!service) {
throw new Error(
linkingErrorMessage({
moduleA,
moduleAKey,
moduleB,
moduleBKey,
type: "dismiss",
})
)
} else if (!serviceLinks.has(service.__definition.key)) {
serviceLinks.set(service.__definition.key, [])
}
const pkValue =
pk.length === 1 ? rel[moduleA][pk[0]] : pk.map((k) => rel[moduleA][k])
serviceLinks
.get(service.__definition.key)
?.push([pkValue, rel[moduleB][moduleBKey]])
}
const promises: Promise<unknown[]>[] = []
for (const [serviceName, links] of serviceLinks) {
const service = this.modulesMap.get(serviceName)!
promises.push(service.dismiss(links))
}
const created = await promiseAll(promises)
return created.flat()
}
async delete(
removedServices: DeleteEntityInput
): Promise<[CascadeError[] | null, RemovedIds]> {
return await this.executeCascade(removedServices, "softDelete")
}
async restore(
removedServices: DeleteEntityInput
): Promise<[CascadeError[] | null, RestoredIds]> {
return await this.executeCascade(removedServices, "restore")
}
}
@@ -0,0 +1,258 @@
import {
RemoteFetchDataCallback,
RemoteJoiner,
toRemoteJoinerQuery,
} from "@medusajs/orchestration"
import {
JoinerArgument,
JoinerRelationship,
JoinerServiceConfig,
LoadedModule,
ModuleJoinerConfig,
RemoteExpandProperty,
RemoteJoinerOptions,
RemoteJoinerQuery,
RemoteNestedExpands,
} from "@medusajs/types"
import { isString, toPascalCase } from "@medusajs/utils"
import { MedusaModule } from "./medusa-module"
export class RemoteQuery {
private remoteJoiner: RemoteJoiner
private modulesMap: Map<string, LoadedModule> = new Map()
private customRemoteFetchData?: RemoteFetchDataCallback
constructor({
modulesLoaded,
customRemoteFetchData,
servicesConfig = [],
}: {
modulesLoaded?: LoadedModule[]
customRemoteFetchData?: RemoteFetchDataCallback
servicesConfig?: ModuleJoinerConfig[]
}) {
const servicesConfig_ = [...servicesConfig]
if (!modulesLoaded?.length) {
modulesLoaded = MedusaModule.getLoadedModules().map(
(mod) => Object.values(mod)[0]
)
}
for (const mod of modulesLoaded) {
if (!mod.__definition.isQueryable) {
continue
}
const serviceName = mod.__definition.key
if (this.modulesMap.has(serviceName)) {
throw new Error(
`Duplicated instance of module ${serviceName} is not allowed.`
)
}
this.modulesMap.set(serviceName, mod)
servicesConfig_!.push(mod.__joinerConfig)
}
this.customRemoteFetchData = customRemoteFetchData
this.remoteJoiner = new RemoteJoiner(
servicesConfig_ as JoinerServiceConfig[],
this.remoteFetchData.bind(this),
{ autoCreateServiceNameAlias: false }
)
}
public setFetchDataCallback(
remoteFetchData: (
expand: RemoteExpandProperty,
keyField: string,
ids?: (unknown | unknown[])[],
relationship?: any
) => Promise<{
data: unknown[] | { [path: string]: unknown[] }
path?: string
}>
): void {
this.remoteJoiner.setFetchDataCallback(remoteFetchData)
}
public static getAllFieldsAndRelations(
expand: RemoteExpandProperty | RemoteNestedExpands[number],
prefix = "",
args: JoinerArgument = {} as JoinerArgument
): {
select?: string[]
relations: string[]
args: JoinerArgument
} {
expand = JSON.parse(JSON.stringify(expand))
let fields: Set<string> = new Set()
let relations: string[] = []
let shouldSelectAll = false
for (const field of expand.fields ?? []) {
if (field === "*") {
shouldSelectAll = true
break
}
fields.add(prefix ? `${prefix}.${field}` : field)
}
args[prefix] = expand.args
for (const property in expand.expands ?? {}) {
const newPrefix = prefix ? `${prefix}.${property}` : property
relations.push(newPrefix)
fields.delete(newPrefix)
const result = RemoteQuery.getAllFieldsAndRelations(
expand.expands![property],
newPrefix,
args
)
result.select?.forEach(fields.add, fields)
relations = relations.concat(result.relations)
}
const allFields = Array.from(fields)
const select =
allFields.length && !shouldSelectAll
? allFields
: shouldSelectAll
? undefined
: []
return { select, relations, args }
}
private hasPagination(options: { [attr: string]: unknown }): boolean {
if (!options) {
return false
}
const attrs = ["skip", "cursor"]
return Object.keys(options).some((key) => attrs.includes(key))
}
private buildPagination(options, count) {
return {
skip: options.skip,
take: options.take,
cursor: options.cursor,
// TODO: next cursor
count,
}
}
public async remoteFetchData(
expand: RemoteExpandProperty,
keyField: string,
ids?: (unknown | unknown[])[],
relationship?: JoinerRelationship
): Promise<{
data: unknown[] | { [path: string]: unknown }
path?: string
}> {
if (this.customRemoteFetchData) {
const resp = await this.customRemoteFetchData(expand, keyField, ids)
if (resp !== undefined) {
return resp
}
}
const serviceConfig = expand.serviceConfig
const service = this.modulesMap.get(serviceConfig.serviceName)!
let filters = {}
const options = {
...RemoteQuery.getAllFieldsAndRelations(expand),
}
const availableOptions = [
"skip",
"take",
"limit",
"offset",
"cursor",
"sort",
"order",
"withDeleted",
]
const availableOptionsAlias = new Map([
["limit", "take"],
["offset", "skip"],
])
for (const arg of expand.args || []) {
if (arg.name === "filters" && arg.value) {
filters = { ...filters, ...arg.value }
} else if (arg.name === "context" && arg.value) {
filters["context"] = arg.value
} else if (availableOptions.includes(arg.name)) {
const argName = availableOptionsAlias.has(arg.name)
? availableOptionsAlias.get(arg.name)!
: arg.name
options[argName] = arg.value
}
}
if (ids) {
filters[keyField] = ids
}
const hasPagination = this.hasPagination(options)
let methodName = hasPagination ? "listAndCount" : "list"
if (relationship?.args?.methodSuffix) {
methodName += toPascalCase(relationship.args.methodSuffix)
} else if (serviceConfig?.args?.methodSuffix) {
methodName += toPascalCase(serviceConfig.args.methodSuffix)
}
if (typeof service[methodName] !== "function") {
throw new Error(
`Method "${methodName}" does not exist on "${serviceConfig.serviceName}"`
)
}
const result = await service[methodName](filters, options)
if (hasPagination) {
const [data, count] = result
return {
data: {
rows: data,
metadata: this.buildPagination(options, count),
},
path: "rows",
}
}
return {
data: result,
}
}
public async query(
query: string | RemoteJoinerQuery | object,
variables?: Record<string, unknown>,
options?: RemoteJoinerOptions
): Promise<any> {
let finalQuery: RemoteJoinerQuery = query as RemoteJoinerQuery
if (isString(query)) {
finalQuery = RemoteJoiner.parseQuery(query, variables)
} else if (!isString(finalQuery?.service) && !isString(finalQuery?.alias)) {
finalQuery = toRemoteJoinerQuery(query, variables)
}
return await this.remoteJoiner.query(finalQuery, options)
}
}
@@ -0,0 +1,91 @@
import { Kind, parse, print, visit } from "graphql"
export function cleanGraphQLSchema(schema: string): {
schema: string
notFound: Record<string, Record<string, string>>
} {
const extractTypeNameAndKind = (type) => {
if (type.kind === Kind.NAMED_TYPE) {
return [type.name.value, type.kind]
}
if (type.kind === Kind.NON_NULL_TYPE || type.kind === Kind.LIST_TYPE) {
return extractTypeNameAndKind(type.type)
}
return [null, null]
}
const ast = parse(schema)
const typeNames = new Set(["String", "Int", "Float", "Boolean", "ID"])
const extendedTypes = new Set()
const kinds = [
Kind.OBJECT_TYPE_DEFINITION,
Kind.INTERFACE_TYPE_DEFINITION,
Kind.ENUM_TYPE_DEFINITION,
Kind.SCALAR_TYPE_DEFINITION,
Kind.INPUT_OBJECT_TYPE_DEFINITION,
Kind.UNION_TYPE_DEFINITION,
]
ast.definitions.forEach((def: any) => {
if (kinds.includes(def.kind)) {
typeNames.add(def.name.value)
} else if (def.kind === Kind.OBJECT_TYPE_EXTENSION) {
extendedTypes.add(def.name.value)
}
})
const nonExistingMap: Record<string, Record<string, string>> = {}
const parentStack: string[] = []
/*
Traverse the graph mapping all the entities + fields and removing the ones that don't exist.
Extensions are not removed, but marked with a "__extended" key if the main entity doesn't exist. (example: Link modules injecting fields into another module)
*/
const cleanedAst = visit(ast, {
ObjectTypeExtension: {
enter(node) {
const typeName = node.name.value
parentStack.push(typeName)
if (!typeNames.has(typeName)) {
nonExistingMap[typeName] ??= {}
nonExistingMap[typeName]["__extended"] = ""
return null
}
return
},
leave() {
parentStack.pop()
},
},
ObjectTypeDefinition: {
enter(node) {
parentStack.push(node.name.value)
},
leave() {
parentStack.pop()
},
},
FieldDefinition: {
leave(node) {
const [typeName, kind] = extractTypeNameAndKind(node.type)
if (!typeNames.has(typeName) && kind === Kind.NAMED_TYPE) {
const currentParent = parentStack[parentStack.length - 1]
nonExistingMap[currentParent] ??= {}
nonExistingMap[currentParent][node.name.value] = typeName
return null
}
return
},
},
})
// Return the schema and the map of non existing entities and fields
return {
schema: print(cleanedAst),
notFound: nonExistingMap,
}
}
@@ -0,0 +1,93 @@
import { GraphQLNamedType, GraphQLObjectType, isObjectType } from "graphql"
/**
* From graphql schema get all the fields for the requested type and relations
*
* @param schemaTypeMap
* @param typeName
* @param relations
*
* @example
*
* const userModule = `
* type User {
* id: ID!
* name: String!
* blabla: WHATEVER
* }
*
* type Post {
* author: User!
* }
* `
*
* const postModule = `
* type Post {
* id: ID!
* title: String!
* date: String
* }
*
* type User {
* posts: [Post!]!
* }
*
* type WHATEVER {
* random_field: String
* post: Post
* }
* `
*
* const mergedSchema = mergeTypeDefs([userModule, postModule])
* const schema = makeExecutableSchema({
* typeDefs: mergedSchema,
* })
*
* const fields = graphqlSchemaToFields(types, "User", ["posts"])
*
* console.log(fields)
*
* // [
* // "id",
* // "name",
* // "posts.id",
* // "posts.title",
* // "posts.date",
* // ]
*/
export function graphqlSchemaToFields(
schemaTypeMap: { [key: string]: GraphQLNamedType },
typeName: string,
relations: string[] = []
) {
const result: string[] = []
function traverseFields(typeName, parent = "") {
const type = schemaTypeMap[typeName]
if (!(type instanceof GraphQLObjectType)) {
return
}
const fields = type.getFields()
for (const fieldName in fields) {
const field = fields[fieldName]
let fieldType = field.type as any
while (fieldType.ofType) {
fieldType = fieldType.ofType
}
const composedField = parent ? `${parent}.${fieldName}` : fieldName
if (!isObjectType(fieldType)) {
result.push(composedField)
} else if (relations.includes(composedField)) {
traverseFields(fieldType.name, composedField)
}
}
}
traverseFields(typeName)
return result
}
@@ -0,0 +1,3 @@
export * from "./clean-graphql-schema"
export * from "./graphql-schema-to-fields"
export * from "./initialize-factory"
@@ -0,0 +1,44 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
ModuleExports,
ModuleServiceInitializeCustomDataLayerOptions,
ModuleServiceInitializeOptions,
} from "@medusajs/types"
import { MODULE_PACKAGE_NAMES } from "../definitions"
import { MedusaModule } from "../medusa-module"
/**
* Generate a initialize module factory that is exported by the module to be initialized manually
*
* @param moduleName
* @param moduleDefinition
*/
export function initializeFactory<T>({
moduleName,
moduleDefinition,
}: {
moduleName: string
moduleDefinition: ModuleExports
}) {
return async (
options?:
| ModuleServiceInitializeOptions
| ModuleServiceInitializeCustomDataLayerOptions
| ExternalModuleDeclaration
| InternalModuleDeclaration,
injectedDependencies?: any
) => {
const loaded = await MedusaModule.bootstrap<T>({
moduleKey: moduleName,
defaultPath: MODULE_PACKAGE_NAMES[moduleName],
declaration: options as
| InternalModuleDeclaration
| ExternalModuleDeclaration,
injectedDependencies,
moduleExports: moduleDefinition,
})
return loaded[moduleName] as T
}
}
@@ -0,0 +1,24 @@
const typeToMethod = new Map([
[`dismiss`, `dismiss`],
[`link`, `create`],
])
type LinkingErrorMessageInput = {
moduleA: string
moduleAKey: string
moduleB: string
moduleBKey: string
type: "dismiss" | "link"
}
/**
*
* Example: Module to dismiss salesChannel and apiKey by keys sales_channel_id and api_key_id was not found. Ensure the link exists, keys are correct, and the link is passed in the correct order to method 'remoteLink.dismiss'
*/
export const linkingErrorMessage = (input: LinkingErrorMessageInput) => {
const { moduleA, moduleB, moduleAKey, moduleBKey, type } = input
return `Module to type ${moduleA} and ${moduleB} by keys ${moduleAKey} and ${moduleBKey} was not found. Ensure the link exists, keys are correct, and link is passed in the correct order to method 'remoteLink.${typeToMethod.get(
type
)}'.
`
}