feat(link-modules,modules-sdk, utils, types, products) - Remote Link and Link modules (#4695)
What:
- Definition of all Modules links
- `link-modules` package to manage the creation of all pre-defined link or custom ones
```typescript
import { initialize as iniInventory } from "@medusajs/inventory";
import { initialize as iniProduct } from "@medusajs/product";
import {
initialize as iniLinks,
runMigrations as migrateLinks
} from "@medusajs/link-modules";
await Promise.all([iniInventory(), iniProduct()]);
await migrateLinks(); // create tables based on previous loaded modules
await iniLinks(); // load link based on previous loaded modules
await iniLinks(undefined, [
{
serviceName: "product_custom_translation_service_link",
isLink: true,
databaseConfig: {
tableName: "product_transalations",
},
alias: [
{
name: "translations",
},
],
primaryKeys: ["id", "product_id", "translation_id"],
relationships: [
{
serviceName: Modules.PRODUCT,
primaryKey: "id",
foreignKey: "product_id",
alias: "product",
},
{
serviceName: "custom_translation_service",
primaryKey: "id",
foreignKey: "translation_id",
alias: "transalation",
deleteCascade: true,
},
],
extends: [
{
serviceName: Modules.PRODUCT,
relationship: {
serviceName: "product_custom_translation_service_link",
primaryKey: "product_id",
foreignKey: "id",
alias: "translations",
isList: true,
},
},
{
serviceName: "custom_translation_service",
relationship: {
serviceName: "product_custom_translation_service_link",
primaryKey: "product_id",
foreignKey: "id",
alias: "product_link",
},
},
],
},
]); // custom links
```
Remote Link
```typescript
import { RemoteLink, Modules } from "@medusajs/modules-sdk";
// [...] initialize modules and links
const remoteLink = new RemoteLink();
// upsert the relationship
await remoteLink.create({ // one (object) or many (array)
[Modules.PRODUCT]: {
variant_id: "var_abc",
},
[Modules.INVENTORY]: {
inventory_item_id: "iitem_abc",
},
data: { // optional additional fields
required_quantity: 5
}
});
// dismiss (doesn't cascade)
await remoteLink.dismiss({ // one (object) or many (array)
[Modules.PRODUCT]: {
variant_id: "var_abc",
},
[Modules.INVENTORY]: {
inventory_item_id: "iitem_abc",
},
});
// delete
await remoteLink.delete({
// every key is a module
[Modules.PRODUCT]: {
// every key is a linkable field
variant_id: "var_abc", // single or multiple values
},
});
// restore
await remoteLink.restore({
// every key is a module
[Modules.PRODUCT]: {
// every key is a linkable field
variant_id: "var_abc", // single or multiple values
},
});
```
Co-authored-by: Riqwan Thamir <5105988+riqwan@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
bc4c9e0d32
commit
4d16acf5f0
27
packages/modules-sdk/src/__mocks__/inventory-module.ts
Normal file
27
packages/modules-sdk/src/__mocks__/inventory-module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const InventoryModule = {
|
||||
__definition: {
|
||||
key: "inventoryService",
|
||||
registrationName: "inventoryService",
|
||||
defaultPackage: false,
|
||||
label: "InventoryService",
|
||||
isRequired: false,
|
||||
canOverride: true,
|
||||
isQueryable: true,
|
||||
dependencies: [],
|
||||
defaultModuleDeclaration: {
|
||||
scope: "internal",
|
||||
resources: "shared",
|
||||
},
|
||||
},
|
||||
__joinerConfig: {
|
||||
serviceName: "inventoryService",
|
||||
primaryKeys: ["id"],
|
||||
linkableKeys: [
|
||||
"inventory_item_id",
|
||||
"inventory_level_id",
|
||||
"reservation_item_id",
|
||||
],
|
||||
},
|
||||
|
||||
softDelete: jest.fn(() => {}),
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export const InventoryStockLocationLink = {
|
||||
__definition: {
|
||||
key: "inventoryStockLocationLink",
|
||||
registrationName: "inventoryStockLocationLink",
|
||||
defaultPackage: "",
|
||||
label: "inventoryStockLocationLink",
|
||||
canOverride: true,
|
||||
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(() => {}),
|
||||
}
|
||||
77
packages/modules-sdk/src/__mocks__/product-inventory-link.ts
Normal file
77
packages/modules-sdk/src/__mocks__/product-inventory-link.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const ProductInventoryLinkModule = {
|
||||
__definition: {
|
||||
key: "productVariantInventoryInventoryItemLink",
|
||||
registrationName: "productVariantInventoryInventoryItemLink",
|
||||
defaultPackage: "",
|
||||
label: "productVariantInventoryInventoryItemLink",
|
||||
canOverride: true,
|
||||
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(() => {}),
|
||||
}
|
||||
24
packages/modules-sdk/src/__mocks__/product-module.ts
Normal file
24
packages/modules-sdk/src/__mocks__/product-module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const ProductModule = {
|
||||
__definition: {
|
||||
key: "productService",
|
||||
registrationName: "productModuleService",
|
||||
defaultPackage: false,
|
||||
label: "ProductModuleService",
|
||||
isRequired: false,
|
||||
canOverride: true,
|
||||
isQueryable: true,
|
||||
dependencies: ["eventBusModuleService"],
|
||||
defaultModuleDeclaration: {
|
||||
scope: "internal",
|
||||
resources: "shared",
|
||||
},
|
||||
},
|
||||
__joinerConfig: {
|
||||
serviceName: "productService",
|
||||
primaryKeys: ["id", "handle"],
|
||||
linkableKeys: ["product_id", "variant_id"],
|
||||
alias: [],
|
||||
},
|
||||
|
||||
softDelete: jest.fn(() => {}),
|
||||
}
|
||||
24
packages/modules-sdk/src/__mocks__/stock-location-module.ts
Normal file
24
packages/modules-sdk/src/__mocks__/stock-location-module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const StockLocationModule = {
|
||||
__definition: {
|
||||
key: "stockLocationService",
|
||||
registrationName: "stockLocationService",
|
||||
defaultPackage: false,
|
||||
label: "StockLocationService",
|
||||
isRequired: false,
|
||||
canOverride: true,
|
||||
isQueryable: true,
|
||||
dependencies: ["eventBusService"],
|
||||
defaultModuleDeclaration: {
|
||||
scope: "internal",
|
||||
resources: "shared",
|
||||
},
|
||||
},
|
||||
__joinerConfig: {
|
||||
serviceName: "stockLocationService",
|
||||
primaryKeys: ["id"],
|
||||
linkableKeys: ["stock_location_id"],
|
||||
alias: [],
|
||||
},
|
||||
|
||||
softDelete: jest.fn(() => {}),
|
||||
}
|
||||
202
packages/modules-sdk/src/__tests__/remote-link.spec.ts
Normal file
202
packages/modules-sdk/src/__tests__/remote-link.spec.ts
Normal file
@@ -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"] }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +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 "./module-helper"
|
||||
export * from "./remote-link"
|
||||
export * from "./remote-query"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
Logger,
|
||||
MedusaContainer,
|
||||
MODULE_SCOPE,
|
||||
MedusaContainer,
|
||||
ModuleResolution,
|
||||
} from "@medusajs/types"
|
||||
|
||||
import { asValue } from "awilix"
|
||||
import { EOL } from "os"
|
||||
import { ModulesHelper } from "../module-helper"
|
||||
@@ -11,53 +12,6 @@ import { loadInternalModule } from "./utils"
|
||||
|
||||
export const moduleHelper = new ModulesHelper()
|
||||
|
||||
async function loadModule(
|
||||
container: MedusaContainer,
|
||||
resolution: ModuleResolution,
|
||||
logger: Logger
|
||||
): 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)
|
||||
}
|
||||
|
||||
export const moduleLoader = async ({
|
||||
container,
|
||||
moduleResolutions,
|
||||
@@ -94,7 +48,48 @@ export const moduleLoader = async ({
|
||||
}, {})
|
||||
)
|
||||
|
||||
container.register({
|
||||
modulesHelper: asValue(moduleHelper),
|
||||
})
|
||||
container.register("modulesHelper", asValue(moduleHelper))
|
||||
}
|
||||
|
||||
async function loadModule(
|
||||
container: MedusaContainer,
|
||||
resolution: ModuleResolution,
|
||||
logger: Logger
|
||||
): 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)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
ModuleExports,
|
||||
ModuleResolution,
|
||||
} from "@medusajs/types"
|
||||
|
||||
import { isObject } from "@medusajs/utils"
|
||||
import resolveCwd from "resolve-cwd"
|
||||
import MODULE_DEFINITIONS from "../definitions"
|
||||
import { MODULE_DEFINITIONS, ModulesDefinition } from "../definitions"
|
||||
|
||||
export const registerModules = (
|
||||
modules?: Record<
|
||||
@@ -46,34 +47,57 @@ export const registerModules = (
|
||||
|
||||
export const registerMedusaModule = (
|
||||
moduleKey: string,
|
||||
moduleDeclaration: InternalModuleDeclaration | ExternalModuleDeclaration,
|
||||
moduleDeclaration:
|
||||
| Partial<InternalModuleDeclaration | ExternalModuleDeclaration>
|
||||
| string
|
||||
| false,
|
||||
moduleExports?: ModuleExports,
|
||||
definition?: ModuleDefinition
|
||||
): Record<string, ModuleResolution> => {
|
||||
const moduleResolutions = {} as Record<string, ModuleResolution>
|
||||
|
||||
const modDefinition = definition ?? ModulesDefinition[moduleKey]
|
||||
|
||||
if (modDefinition === undefined) {
|
||||
throw new Error(`Module: ${moduleKey} is not defined.`)
|
||||
}
|
||||
|
||||
if (
|
||||
isObject(moduleDeclaration) &&
|
||||
moduleDeclaration?.scope === MODULE_SCOPE.EXTERNAL
|
||||
) {
|
||||
// TODO: getExternalModuleResolution(...)
|
||||
throw new Error("External Modules are not supported yet.")
|
||||
}
|
||||
|
||||
moduleResolutions[moduleKey] = getInternalModuleResolution(
|
||||
modDefinition,
|
||||
moduleDeclaration as InternalModuleDeclaration,
|
||||
moduleExports
|
||||
)
|
||||
|
||||
return moduleResolutions
|
||||
}
|
||||
|
||||
export const registerMedusaLinkModule = (
|
||||
definition: ModuleDefinition,
|
||||
moduleDeclaration: Partial<InternalModuleDeclaration>,
|
||||
moduleExports?: ModuleExports
|
||||
): Record<string, ModuleResolution> => {
|
||||
const moduleResolutions = {} as Record<string, ModuleResolution>
|
||||
|
||||
for (const definition of MODULE_DEFINITIONS) {
|
||||
if (definition.key !== moduleKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (moduleDeclaration.scope === MODULE_SCOPE.EXTERNAL) {
|
||||
// TODO: getExternalModuleResolution(...)
|
||||
throw new Error("External Modules are not supported yet.")
|
||||
}
|
||||
|
||||
moduleResolutions[definition.key] = getInternalModuleResolution(
|
||||
definition,
|
||||
moduleDeclaration as InternalModuleDeclaration,
|
||||
moduleExports
|
||||
)
|
||||
}
|
||||
moduleResolutions[definition.key] = getInternalModuleResolution(
|
||||
definition,
|
||||
moduleDeclaration as InternalModuleDeclaration,
|
||||
moduleExports
|
||||
)
|
||||
|
||||
return moduleResolutions
|
||||
}
|
||||
|
||||
function getInternalModuleResolution(
|
||||
definition: ModuleDefinition,
|
||||
moduleConfig: InternalModuleDeclaration | false | string,
|
||||
moduleConfig: InternalModuleDeclaration | string | false,
|
||||
moduleExports?: ModuleExports
|
||||
): ModuleResolution {
|
||||
if (typeof moduleConfig === "boolean") {
|
||||
@@ -116,7 +140,7 @@ function getInternalModuleResolution(
|
||||
),
|
||||
],
|
||||
moduleDeclaration: {
|
||||
...definition.defaultModuleDeclaration,
|
||||
...(definition.defaultModuleDeclaration ?? {}),
|
||||
...moduleDeclaration,
|
||||
},
|
||||
moduleExports,
|
||||
|
||||
@@ -2,9 +2,9 @@ import {
|
||||
Constructor,
|
||||
InternalModuleDeclaration,
|
||||
Logger,
|
||||
MedusaContainer,
|
||||
MODULE_RESOURCE_TYPE,
|
||||
MODULE_SCOPE,
|
||||
MedusaContainer,
|
||||
ModuleExports,
|
||||
ModuleResolution,
|
||||
} from "@medusajs/types"
|
||||
@@ -30,9 +30,14 @@ export async function loadInternalModule(
|
||||
// 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.
|
||||
loadedModule =
|
||||
resolution.moduleExports ??
|
||||
(await import(resolution.resolutionPath as string)).default
|
||||
const path = resolution.resolutionPath as string
|
||||
|
||||
if (resolution.moduleExports) {
|
||||
loadedModule = resolution.moduleExports
|
||||
} else {
|
||||
loadedModule = await import(path)
|
||||
loadedModule = (loadedModule as any).default
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
resolution.definition.isRequired &&
|
||||
|
||||
183
packages/modules-sdk/src/medusa-app.ts
Normal file
183
packages/modules-sdk/src/medusa-app.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { RemoteFetchDataCallback } from "@medusajs/orchestration"
|
||||
import {
|
||||
LoadedModule,
|
||||
MODULE_RESOURCE_TYPE,
|
||||
MODULE_SCOPE,
|
||||
ModuleConfig,
|
||||
ModuleJoinerConfig,
|
||||
ModuleServiceInitializeOptions,
|
||||
RemoteJoinerQuery,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
ModulesSdkUtils,
|
||||
isObject,
|
||||
} from "@medusajs/utils"
|
||||
import { MODULE_PACKAGE_NAMES, Modules } from "./definitions"
|
||||
import { MedusaModule } from "./medusa-module"
|
||||
import { RemoteLink } from "./remote-link"
|
||||
import { RemoteQuery } from "./remote-query"
|
||||
|
||||
export type MedusaModuleConfig = (Partial<ModuleConfig> | Modules)[]
|
||||
export type SharedResources = {
|
||||
database?: ModuleServiceInitializeOptions["database"] & {
|
||||
pool?: {
|
||||
name?: string
|
||||
afterCreate?: Function
|
||||
min?: number
|
||||
max?: number
|
||||
refreshIdle?: boolean
|
||||
idleTimeoutMillis?: number
|
||||
reapIntervalMillis?: number
|
||||
returnToHead?: boolean
|
||||
priorityRange?: number
|
||||
log?: (message: string, logLevel: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function MedusaApp({
|
||||
sharedResourcesConfig,
|
||||
modulesConfigPath,
|
||||
modulesConfig,
|
||||
linkModules,
|
||||
remoteFetchData,
|
||||
}: {
|
||||
sharedResourcesConfig?: SharedResources
|
||||
loadedModules?: LoadedModule[]
|
||||
modulesConfigPath?: string
|
||||
modulesConfig?: MedusaModuleConfig
|
||||
linkModules?: ModuleJoinerConfig | ModuleJoinerConfig[]
|
||||
remoteFetchData?: RemoteFetchDataCallback
|
||||
} = {}): Promise<{
|
||||
modules: Record<string, LoadedModule | LoadedModule[]>
|
||||
link: RemoteLink | undefined
|
||||
query: (
|
||||
query: string | RemoteJoinerQuery,
|
||||
variables?: Record<string, unknown>
|
||||
) => Promise<any>
|
||||
}> {
|
||||
const modules: MedusaModuleConfig =
|
||||
modulesConfig ??
|
||||
(await import(process.cwd() + (modulesConfigPath ?? "/modules-config")))
|
||||
.default
|
||||
|
||||
const injectedDependencies: any = {}
|
||||
|
||||
const dbData = ModulesSdkUtils.loadDatabaseConfig(
|
||||
"medusa",
|
||||
sharedResourcesConfig as ModuleServiceInitializeOptions,
|
||||
true
|
||||
)!
|
||||
const { pool } = sharedResourcesConfig?.database ?? {}
|
||||
|
||||
if (dbData?.clientUrl) {
|
||||
const { knex } = await import("knex")
|
||||
const dbConnection = knex({
|
||||
client: "pg",
|
||||
searchPath: dbData.schema || "public",
|
||||
connection: {
|
||||
connectionString: dbData.clientUrl,
|
||||
ssl: (dbData.driverOptions?.connection as any).ssl! ?? false,
|
||||
},
|
||||
pool: {
|
||||
// https://knexjs.org/guide/#pool
|
||||
...(pool ?? {}),
|
||||
min: pool?.min ?? 0,
|
||||
},
|
||||
})
|
||||
|
||||
injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] = dbConnection
|
||||
}
|
||||
|
||||
const allModules: Record<string, LoadedModule | LoadedModule[]> = {}
|
||||
|
||||
await Promise.all(
|
||||
modules.map(async (mod: Partial<ModuleConfig> | Modules) => {
|
||||
let key: Modules | string = mod as Modules
|
||||
let path: string
|
||||
let declaration: any = {}
|
||||
|
||||
if (isObject(mod)) {
|
||||
if (!mod.module) {
|
||||
throw new Error(
|
||||
`Module ${JSON.stringify(mod)} is missing module name.`
|
||||
)
|
||||
}
|
||||
|
||||
key = mod.module
|
||||
path = mod.path ?? MODULE_PACKAGE_NAMES[key]
|
||||
|
||||
declaration = { ...mod }
|
||||
delete declaration.definition
|
||||
} else {
|
||||
path = MODULE_PACKAGE_NAMES[mod as Modules]
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error(`Module ${key} is missing path.`)
|
||||
}
|
||||
|
||||
declaration.scope ??= MODULE_SCOPE.INTERNAL
|
||||
|
||||
if (
|
||||
declaration.scope === MODULE_SCOPE.INTERNAL &&
|
||||
!declaration.resources
|
||||
) {
|
||||
declaration.resources = MODULE_RESOURCE_TYPE.SHARED
|
||||
}
|
||||
|
||||
const loaded = (await MedusaModule.bootstrap(
|
||||
key,
|
||||
path,
|
||||
declaration,
|
||||
undefined,
|
||||
injectedDependencies,
|
||||
isObject(mod) ? mod.definition : undefined
|
||||
)) as LoadedModule
|
||||
|
||||
if (allModules[key] && !Array.isArray(allModules[key])) {
|
||||
allModules[key] = []
|
||||
}
|
||||
|
||||
if (allModules[key]) {
|
||||
;(allModules[key] as LoadedModule[]).push(loaded[key])
|
||||
} else {
|
||||
allModules[key] = loaded[key]
|
||||
}
|
||||
|
||||
return loaded
|
||||
})
|
||||
)
|
||||
|
||||
let link: RemoteLink | undefined = undefined
|
||||
let query: (
|
||||
query: string | RemoteJoinerQuery,
|
||||
variables?: Record<string, unknown>
|
||||
) => Promise<any>
|
||||
|
||||
try {
|
||||
const { initialize: initializeLinks } = await import(
|
||||
"@medusajs/link-modules" as string
|
||||
)
|
||||
await initializeLinks({}, linkModules, injectedDependencies)
|
||||
|
||||
link = new RemoteLink()
|
||||
} catch (err) {
|
||||
console.warn("Error initializing link modules.", err)
|
||||
}
|
||||
|
||||
const remoteQuery = new RemoteQuery(undefined, remoteFetchData)
|
||||
query = async (
|
||||
query: string | RemoteJoinerQuery,
|
||||
variables?: Record<string, unknown>
|
||||
) => {
|
||||
return await remoteQuery.query(query, variables)
|
||||
}
|
||||
|
||||
return {
|
||||
modules: allModules,
|
||||
link,
|
||||
query,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
ExternalModuleDeclaration,
|
||||
InternalModuleDeclaration,
|
||||
LinkModuleDefinition,
|
||||
LoadedModule,
|
||||
MODULE_RESOURCE_TYPE,
|
||||
MODULE_SCOPE,
|
||||
ModuleDefinition,
|
||||
ModuleExports,
|
||||
ModuleJoinerConfig,
|
||||
ModuleResolution,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
@@ -12,7 +15,11 @@ import {
|
||||
simpleHash,
|
||||
stringifyCircular,
|
||||
} from "@medusajs/utils"
|
||||
import { moduleLoader, registerMedusaModule } from "./loaders"
|
||||
import {
|
||||
moduleLoader,
|
||||
registerMedusaLinkModule,
|
||||
registerMedusaModule,
|
||||
} from "./loaders"
|
||||
|
||||
import { asValue } from "awilix"
|
||||
import { loadModuleMigrations } from "./loaders/utils"
|
||||
@@ -36,6 +43,7 @@ declare global {
|
||||
type ModuleAlias = {
|
||||
key: string
|
||||
hash: string
|
||||
isLink: boolean
|
||||
alias?: string
|
||||
main?: boolean
|
||||
}
|
||||
@@ -125,7 +133,8 @@ export class MedusaModule {
|
||||
defaultPath: string,
|
||||
declaration?: InternalModuleDeclaration | ExternalModuleDeclaration,
|
||||
moduleExports?: ModuleExports,
|
||||
injectedDependencies?: Record<string, any>
|
||||
injectedDependencies?: Record<string, any>,
|
||||
moduleDefinition?: ModuleDefinition
|
||||
): Promise<{
|
||||
[key: string]: T
|
||||
}> {
|
||||
@@ -157,10 +166,10 @@ export class MedusaModule {
|
||||
|
||||
if (declaration?.scope !== MODULE_SCOPE.EXTERNAL) {
|
||||
modDeclaration = {
|
||||
scope: MODULE_SCOPE.INTERNAL,
|
||||
resources: MODULE_RESOURCE_TYPE.ISOLATED,
|
||||
scope: declaration?.scope || MODULE_SCOPE.INTERNAL,
|
||||
resources: declaration?.resources || MODULE_RESOURCE_TYPE.ISOLATED,
|
||||
resolve: defaultPath,
|
||||
options: declaration,
|
||||
options: declaration?.options ?? declaration,
|
||||
alias: declaration?.alias,
|
||||
main: declaration?.main,
|
||||
}
|
||||
@@ -177,6 +186,118 @@ export class MedusaModule {
|
||||
const moduleResolutions = registerMedusaModule(
|
||||
moduleKey,
|
||||
modDeclaration!,
|
||||
moduleExports,
|
||||
moduleDefinition
|
||||
)
|
||||
|
||||
try {
|
||||
await moduleLoader({
|
||||
container,
|
||||
moduleResolutions,
|
||||
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.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: LinkModuleDefinition,
|
||||
declaration?: InternalModuleDeclaration,
|
||||
moduleExports?: ModuleExports,
|
||||
injectedDependencies?: Record<string, any>
|
||||
): Promise<{
|
||||
[key: string]: unknown
|
||||
}> {
|
||||
const moduleKey = definition.key
|
||||
const hashKey = simpleHash(stringifyCircular({ moduleKey, declaration }))
|
||||
|
||||
if (MedusaModule.instances_.has(hashKey)) {
|
||||
return 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,
|
||||
canOverride: true,
|
||||
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
|
||||
)
|
||||
|
||||
@@ -203,9 +324,17 @@ export class MedusaModule {
|
||||
services[keyName].__definition = resolution.definition
|
||||
|
||||
if (resolution.definition.isQueryable) {
|
||||
services[keyName].__joinerConfig = await services[
|
||||
const joinerConfig: ModuleJoinerConfig = await services[
|
||||
keyName
|
||||
].__joinerConfig()
|
||||
|
||||
services[keyName].__joinerConfig = joinerConfig
|
||||
|
||||
if (!joinerConfig.isLink) {
|
||||
throw new Error(
|
||||
"MedusaModule.bootstrapLink must be used only for Link Modules"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MedusaModule.registerModule(keyName, {
|
||||
@@ -213,6 +342,7 @@ export class MedusaModule {
|
||||
hash: hashKey,
|
||||
alias: modDeclaration.alias ?? hashKey,
|
||||
main: !!modDeclaration.main,
|
||||
isLink: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
439
packages/modules-sdk/src/remote-link.ts
Normal file
439
packages/modules-sdk/src/remote-link.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import {
|
||||
ILinkModule,
|
||||
LoadedModule,
|
||||
ModuleJoinerRelationship,
|
||||
} from "@medusajs/types"
|
||||
|
||||
import { isObject, toPascalCase } from "@medusajs/utils"
|
||||
import { MedusaModule } from "./medusa-module"
|
||||
|
||||
export type DeleteEntityInput = {
|
||||
[moduleName: string]: { [linkableKey: 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 ?? 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 Promise.all(deletePromises)
|
||||
})
|
||||
|
||||
await Promise.all(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(
|
||||
`Module to link ${moduleA}[${moduleAKey}] and ${moduleB}[${moduleBKey}] was not found.`
|
||||
)
|
||||
} 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 Promise.all(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(
|
||||
`Module to dismiss link ${moduleA}[${moduleAKey}] and ${moduleB}[${moduleBKey}] was not found.`
|
||||
)
|
||||
} 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 Promise.all(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")
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,23 @@ import {
|
||||
JoinerRelationship,
|
||||
JoinerServiceConfig,
|
||||
LoadedModule,
|
||||
ModuleJoinerConfig,
|
||||
RemoteExpandProperty,
|
||||
RemoteJoinerQuery,
|
||||
} from "@medusajs/types"
|
||||
|
||||
import { RemoteFetchDataCallback, RemoteJoiner } from "@medusajs/orchestration"
|
||||
import { isString, toPascalCase } from "@medusajs/utils"
|
||||
import { MedusaModule } from "./medusa-module"
|
||||
import { RemoteJoiner } from "@medusajs/orchestration"
|
||||
import { toPascalCase } from "@medusajs/utils"
|
||||
|
||||
export class RemoteQuery {
|
||||
private remoteJoiner: RemoteJoiner
|
||||
private modulesMap: Map<string, LoadedModule> = new Map()
|
||||
private customRemoteFetchData?: RemoteFetchDataCallback
|
||||
|
||||
constructor(
|
||||
modulesLoaded?: LoadedModule[],
|
||||
remoteFetchData?: (
|
||||
expand: RemoteExpandProperty,
|
||||
keyField: string,
|
||||
ids?: (unknown | unknown[])[],
|
||||
relationship?: JoinerRelationship
|
||||
) => Promise<{
|
||||
data: unknown[] | { [path: string]: unknown[] }
|
||||
path?: string
|
||||
}>
|
||||
customRemoteFetchData?: RemoteFetchDataCallback
|
||||
) {
|
||||
if (!modulesLoaded?.length) {
|
||||
modulesLoaded = MedusaModule.getLoadedModules().map(
|
||||
@@ -31,25 +26,28 @@ export class RemoteQuery {
|
||||
)
|
||||
}
|
||||
|
||||
const servicesConfig: JoinerServiceConfig[] = []
|
||||
const servicesConfig: ModuleJoinerConfig[] = []
|
||||
for (const mod of modulesLoaded) {
|
||||
if (!mod.__definition.isQueryable) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.modulesMap.has(mod.__definition.key)) {
|
||||
const serviceName = mod.__definition.key
|
||||
|
||||
if (this.modulesMap.has(serviceName)) {
|
||||
throw new Error(
|
||||
`Duplicated instance of module ${mod.__definition.key} is not allowed.`
|
||||
`Duplicated instance of module ${serviceName} is not allowed.`
|
||||
)
|
||||
}
|
||||
|
||||
this.modulesMap.set(mod.__definition.key, mod)
|
||||
this.modulesMap.set(serviceName, mod)
|
||||
servicesConfig.push(mod.__joinerConfig)
|
||||
}
|
||||
|
||||
this.customRemoteFetchData = customRemoteFetchData
|
||||
this.remoteJoiner = new RemoteJoiner(
|
||||
servicesConfig,
|
||||
remoteFetchData ?? this.remoteFetchData.bind(this)
|
||||
servicesConfig as JoinerServiceConfig[],
|
||||
this.remoteFetchData.bind(this)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,14 +67,20 @@ export class RemoteQuery {
|
||||
|
||||
private static getAllFieldsAndRelations(
|
||||
data: any,
|
||||
prefix = ""
|
||||
): { select: string[]; relations: string[] } {
|
||||
prefix = "",
|
||||
args: Record<string, unknown[]> = {}
|
||||
): {
|
||||
select: string[]
|
||||
relations: string[]
|
||||
args: Record<string, unknown[]>
|
||||
} {
|
||||
let fields: Set<string> = new Set()
|
||||
let relations: string[] = []
|
||||
|
||||
data.fields?.forEach((field: string) => {
|
||||
fields.add(prefix ? `${prefix}.${field}` : field)
|
||||
})
|
||||
args[prefix] = data.args
|
||||
|
||||
if (data.expands) {
|
||||
for (const property in data.expands) {
|
||||
@@ -87,7 +91,8 @@ export class RemoteQuery {
|
||||
|
||||
const result = RemoteQuery.getAllFieldsAndRelations(
|
||||
data.expands[property],
|
||||
newPrefix
|
||||
newPrefix,
|
||||
args
|
||||
)
|
||||
|
||||
result.select.forEach(fields.add, fields)
|
||||
@@ -95,7 +100,7 @@ export class RemoteQuery {
|
||||
}
|
||||
}
|
||||
|
||||
return { select: [...fields], relations }
|
||||
return { select: [...fields], relations, args }
|
||||
}
|
||||
|
||||
private hasPagination(options: { [attr: string]: unknown }): boolean {
|
||||
@@ -126,6 +131,13 @@ export class RemoteQuery {
|
||||
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)!
|
||||
|
||||
@@ -196,9 +208,14 @@ export class RemoteQuery {
|
||||
}
|
||||
}
|
||||
|
||||
public async query(query: string, variables: any = {}): Promise<any> {
|
||||
return await this.remoteJoiner.query(
|
||||
RemoteJoiner.parseQuery(query, variables)
|
||||
)
|
||||
public async query(
|
||||
query: string | RemoteJoinerQuery,
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<any> {
|
||||
const finalQuery = isString(query)
|
||||
? RemoteJoiner.parseQuery(query, variables)
|
||||
: query
|
||||
|
||||
return await this.remoteJoiner.query(finalQuery)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user