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:
Carlos R. L. Rodrigues
2023-08-30 11:31:32 -03:00
committed by GitHub
parent bc4c9e0d32
commit 4d16acf5f0
97 changed files with 3540 additions and 424 deletions

View 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(() => {}),
}

View File

@@ -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(() => {}),
}

View 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(() => {}),
}

View 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(() => {}),
}

View 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(() => {}),
}

View 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"] }
)
})
})

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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 &&

View 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,
}
}

View File

@@ -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,
})
}

View 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")
}
}

View File

@@ -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)
}
}