feat(modules-sdk): Remote Query (#4463)

* feat: Remote Query
This commit is contained in:
Carlos R. L. Rodrigues
2023-07-19 15:35:36 -03:00
committed by GitHub
parent 95c538c675
commit 5a8a889c6d
57 changed files with 1286 additions and 423 deletions

View File

@@ -23,7 +23,8 @@
"typescript": "^4.4.4"
},
"dependencies": {
"@medusajs/types": "^1.8.8",
"@medusajs/orchestration": "^0.0.2",
"@medusajs/types": "^1.8.11",
"@medusajs/utils": "^1.9.1",
"awilix": "^8.0.0",
"resolve-cwd": "^3.0.0"

View File

@@ -9,7 +9,7 @@ export enum Modules {
STOCK_LOCATION = "stockLocationService",
INVENTORY = "inventoryService",
CACHE = "cacheService",
PRODUCT = "productModuleService",
PRODUCT = "productService",
}
export const ModulesDefinition: { [key: string]: ModuleDefinition } = {
@@ -33,6 +33,7 @@ export const ModulesDefinition: { [key: string]: ModuleDefinition } = {
label: "StockLocationService",
isRequired: false,
canOverride: true,
isQueryable: true,
dependencies: ["eventBusService"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
@@ -46,6 +47,7 @@ export const ModulesDefinition: { [key: string]: ModuleDefinition } = {
label: "InventoryService",
isRequired: false,
canOverride: true,
isQueryable: true,
dependencies: ["eventBusService"],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
@@ -66,11 +68,12 @@ export const ModulesDefinition: { [key: string]: ModuleDefinition } = {
},
[Modules.PRODUCT]: {
key: Modules.PRODUCT,
registrationName: Modules.PRODUCT,
registrationName: "productModuleService",
defaultPackage: false,
label: "ProductModuleService",
isRequired: false,
canOverride: true,
isQueryable: true,
dependencies: [],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.EXTERNAL,

View File

@@ -3,3 +3,4 @@ export * from "./definitions"
export * from "./loaders"
export * from "./medusa-module"
export * from "./module-helper"
export * from "./remote-query"

View File

@@ -3,27 +3,49 @@ import {
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
} from "@medusajs/types"
import { MedusaModule } from "../../medusa-module"
const mockRegisterMedusaModule = jest
.fn()
.mockImplementation(() => Promise.resolve([]))
const mockModuleLoader = jest.fn().mockImplementation(() => Promise.resolve({}))
import { MedusaModule } from "../../medusa-module"
import { asValue } from "awilix"
const mockRegisterMedusaModule = jest.fn().mockImplementation(() => {
return {
moduleKey: {
definition: {
key: "moduleKey",
registrationName: "moduleKey",
},
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
})
const mockModuleLoader = jest.fn().mockImplementation(({ container }) => {
container.register({
moduleKey: asValue({}),
})
return Promise.resolve({})
})
jest.mock("./../../loaders", () => ({
registerMedusaModule: jest
.fn()
.mockImplementation((...args) => mockRegisterMedusaModule()),
moduleLoader: jest.fn().mockImplementation((...args) => mockModuleLoader()),
moduleLoader: jest
.fn()
.mockImplementation((...args) => mockModuleLoader.apply(this, args)),
}))
describe("Medusa Module", () => {
describe("Medusa Modules", () => {
beforeEach(() => {
MedusaModule.clearInstances()
jest.resetModules()
jest.clearAllMocks()
})
it("MedusaModule bootstrap - Singleton instances", async () => {
it("should create singleton instances", async () => {
await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
@@ -57,4 +79,182 @@ describe("Medusa Module", () => {
expect(mockRegisterMedusaModule).toBeCalledTimes(2)
expect(mockModuleLoader).toBeCalledTimes(2)
})
it("should prevent the module being loaded multiple times under concurrent requests", async () => {
const load: any = []
for (let i = 5; i--; ) {
load.push(
MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration)
)
}
const intances = Promise.all(load)
expect(mockRegisterMedusaModule).toBeCalledTimes(1)
expect(mockModuleLoader).toBeCalledTimes(1)
expect(intances[(await intances).length - 1]).toBe(intances[0])
})
it("getModuleInstance should return the first instance of the module if there is none flagged as 'main'", async () => {
const moduleA = await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration)
const moduleB = await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration)
expect(MedusaModule.getModuleInstance("moduleKey")).toEqual(moduleA)
})
it("should return the module flagged as 'main' when multiple instances are available", async () => {
const moduleA = await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration)
const moduleB = await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
options: {
different_options: "abc",
},
} as InternalModuleDeclaration)
expect(MedusaModule.getModuleInstance("moduleKey")).toEqual(moduleB)
})
it("should retrieve the module by their given alias", async () => {
const moduleA = await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "mod_A",
options: {
abc: 123,
},
} as InternalModuleDeclaration)
const moduleB = await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
alias: "mod_B",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration)
const moduleC = await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "mod_C",
options: {
moduleC: true,
},
} as InternalModuleDeclaration)
// main
expect(MedusaModule.getModuleInstance("moduleKey")).toEqual(moduleB)
expect(MedusaModule.getModuleInstance("moduleKey", "mod_A")).toEqual(
moduleA
)
expect(MedusaModule.getModuleInstance("moduleKey", "mod_B")).toEqual(
moduleB
)
expect(MedusaModule.getModuleInstance("moduleKey", "mod_C")).toEqual(
moduleC
)
})
it("should prevent two main modules being set as 'main'", async () => {
await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "mod_A",
options: {
abc: 123,
},
} as InternalModuleDeclaration)
await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
alias: "mod_B",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration)
const moduleC = MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
main: true,
alias: "mod_C",
options: {
moduleC: true,
},
} as InternalModuleDeclaration)
expect(moduleC).rejects.toThrow(
"Module moduleKey already have a 'main' registered."
)
})
it("should prevent the same alias be used for different instances of the same module", async () => {
await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "module_alias",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration)
const moduleC = MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
alias: "module_alias",
options: {
moduleC: true,
},
} as InternalModuleDeclaration)
expect(moduleC).rejects.toThrow(
"Module moduleKey already registed as 'module_alias'. Please choose a different alias."
)
})
})

View File

@@ -1,17 +1,21 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
JoinerServiceConfig,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleDefinition,
ModuleExports,
ModuleResolution,
} from "@medusajs/types"
import {
createMedusaContainer,
simpleHash,
stringifyCircular,
} from "@medusajs/utils"
import { asValue } from "awilix"
import { moduleLoader, registerMedusaModule } from "./loaders"
import { asValue } from "awilix"
import { loadModuleMigrations } from "./loaders/utils"
const logger: any = {
@@ -21,19 +25,105 @@ const logger: any = {
error: (a) => console.error(a),
}
declare global {
interface MedusaModule {
getLoadedModules(): Map<string, any>
}
}
type ModuleAlias = {
key: string
hash: string
alias?: string
main?: boolean
}
export class MedusaModule {
private static instances_: Map<string, any> = new Map()
private static modules_: Map<string, ModuleAlias[]> = new Map()
private static loading_: Map<string, Promise<any>> = new Map()
public static getLoadedModules(): Map<
string,
any & {
__joinerConfig: JoinerServiceConfig
__definition: ModuleDefinition
}
> {
return MedusaModule.instances_
}
public static clearInstances(): void {
MedusaModule.instances_.clear()
MedusaModule.modules_.clear()
}
public static async bootstrap(
public static isInstalled(moduleKey: string, alias?: string): boolean {
if (alias) {
return (
MedusaModule.modules_.has(moduleKey) &&
MedusaModule.modules_.get(moduleKey)!.some((m) => m.alias === alias)
)
}
return MedusaModule.modules_.has(moduleKey)
}
public static getModuleInstance(
moduleKey: string,
alias?: string
): any | undefined {
if (!MedusaModule.modules_.has(moduleKey)) {
return
}
let mod
const modules = MedusaModule.modules_.get(moduleKey)!
if (alias) {
mod = modules.find((m) => m.alias === alias)
return MedusaModule.instances_.get(mod?.hash)
}
mod = modules.find((m) => m.main) ?? modules[0]
return MedusaModule.instances_.get(mod?.hash)
}
private static registerModule(
moduleKey: string,
loadedModule: ModuleAlias
): void {
if (!MedusaModule.modules_.has(moduleKey)) {
MedusaModule.modules_.set(moduleKey, [])
}
const modules = MedusaModule.modules_.get(moduleKey)!
if (modules.some((m) => m.alias === loadedModule.alias)) {
throw new Error(
`Module ${moduleKey} already registed as '${loadedModule.alias}'. Please choose a different alias.`
)
}
if (loadedModule.main) {
if (modules.some((m) => m.main)) {
throw new Error(`Module ${moduleKey} already have a 'main' registered.`)
}
}
modules.push(loadedModule)
MedusaModule.modules_.set(moduleKey, modules!)
}
public static async bootstrap<T>(
moduleKey: string,
defaultPath: string,
declaration?: InternalModuleDeclaration | ExternalModuleDeclaration,
moduleExports?: ModuleExports,
injectedDependencies?: Record<string, any>
): Promise<{
[key: string]: any
[key: string]: T
}> {
const hashKey = simpleHash(
stringifyCircular({ moduleKey, defaultPath, declaration })
@@ -43,13 +133,32 @@ export class MedusaModule {
return MedusaModule.instances_.get(hashKey)
}
let modDeclaration = declaration
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 InternalModuleDeclaration | ExternalModuleDeclaration)
if (declaration?.scope !== MODULE_SCOPE.EXTERNAL) {
modDeclaration = {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: defaultPath,
options: declaration,
alias: declaration?.alias,
main: declaration?.main,
}
}
@@ -67,22 +176,45 @@ export class MedusaModule {
moduleExports
)
await moduleLoader({
container,
moduleResolutions,
logger,
})
try {
await moduleLoader({
container,
moduleResolutions,
logger,
})
} catch (err) {
errorLoading(err)
throw err
}
const services = {}
for (const resolution of Object.values(moduleResolutions)) {
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) {
services[keyName].__joinerConfig = await services[
keyName
].__joinerConfig()
}
MedusaModule.registerModule(keyName, {
key: keyName,
hash: hashKey,
alias: modDeclaration.alias ?? hashKey,
main: !!modDeclaration.main,
})
}
MedusaModule.instances_.set(hashKey, services)
finishLoading(services)
MedusaModule.loading_.delete(hashKey)
return services
}

View File

@@ -0,0 +1,208 @@
import { RemoteJoiner } from "@medusajs/orchestration"
import {
JoinerRelationship,
JoinerServiceConfig,
ModuleDefinition,
RemoteExpandProperty,
} from "@medusajs/types"
import { toPascalCase } from "@medusajs/utils"
import { MedusaModule } from "./medusa-module"
export class RemoteQuery {
private remoteJoiner: RemoteJoiner
private modulesMap: Map<string, any> = new Map()
constructor(
modulesLoaded?: (any & {
__joinerConfig: JoinerServiceConfig
__definition: ModuleDefinition
})[],
remoteFetchData?: (
expand: RemoteExpandProperty,
keyField: string,
ids?: (unknown | unknown[])[],
relationship?: JoinerRelationship
) => Promise<{
data: unknown[] | { [path: string]: unknown[] }
path?: string
}>
) {
if (!modulesLoaded?.length) {
modulesLoaded = [...MedusaModule.getLoadedModules().entries()].map(
([, mod]) => mod
)
}
const servicesConfig: JoinerServiceConfig[] = []
for (const modService of modulesLoaded) {
const mod: any = Object.values(modService)[0]
if (!mod.__definition.isQueryable) {
continue
}
if (this.modulesMap.has(mod.__definition.key)) {
throw new Error(
`Duplicated instance of module ${mod.__definition.key} is not allowed.`
)
}
this.modulesMap.set(mod.__definition.key, mod)
servicesConfig.push(mod.__joinerConfig)
}
this.remoteJoiner = new RemoteJoiner(
servicesConfig,
remoteFetchData ?? this.remoteFetchData.bind(this)
)
}
public setFetchDataCallback(
remoteFetchData: (
expand: RemoteExpandProperty,
keyField: string,
ids?: (unknown | unknown[])[],
relationship?: any
) => Promise<{
data: unknown[] | { [path: string]: unknown[] }
path?: string
}>
): void {
this.remoteJoiner.setFetchDataCallback(remoteFetchData)
}
private static getAllFieldsAndRelations(
data: any,
prefix = ""
): { select: string[]; relations: string[] } {
let fields: Set<string> = new Set()
let relations: string[] = []
data.fields?.forEach((field: string) => {
fields.add(prefix ? `${prefix}.${field}` : field)
})
if (data.expands) {
for (const property in data.expands) {
const newPrefix = prefix ? `${prefix}.${property}` : property
relations.push(newPrefix)
fields.delete(newPrefix)
const result = RemoteQuery.getAllFieldsAndRelations(
data.expands[property],
newPrefix
)
result.select.forEach(fields.add, fields)
relations = relations.concat(result.relations)
}
}
return { select: [...fields], relations }
}
private hasPagination(options: { [attr: string]: unknown }): boolean {
if (!options) {
return false
}
const attrs = ["skip", "cursor"]
return Object.keys(options).some((key) => attrs.includes(key))
}
private buildPagination(options, count) {
return {
skip: options.skip,
take: options.take,
cursor: options.cursor,
// TODO: next cursor
count,
}
}
public async remoteFetchData(
expand: RemoteExpandProperty,
keyField: string,
ids?: (unknown | unknown[])[],
relationship?: JoinerRelationship
): Promise<{
data: unknown[] | { [path: string]: unknown }
path?: string
}> {
const serviceConfig = expand.serviceConfig
const service = this.modulesMap.get(serviceConfig.serviceName)
let filters = {}
const options = {
...RemoteQuery.getAllFieldsAndRelations(expand),
}
const availableOptions = [
"skip",
"take",
"limit",
"offset",
"cursor",
"sort",
]
const availableOptionsAlias = new Map([
["limit", "take"],
["offset", "skip"],
])
for (const arg of expand.args || []) {
if (arg.name === "filters" && arg.value) {
filters = { ...arg.value }
} else if (availableOptions.includes(arg.name)) {
const argName = availableOptionsAlias.has(arg.name)
? availableOptionsAlias.get(arg.name)!
: arg.name
options[argName] = arg.value
}
}
if (ids) {
filters[keyField] = ids
}
const hasPagination = this.hasPagination(options)
let methodName = hasPagination ? "listAndCount" : "list"
if (relationship?.args?.methodSuffix) {
methodName += toPascalCase(relationship.args.methodSuffix)
} else if (serviceConfig?.args?.methodSuffix) {
methodName += toPascalCase(serviceConfig.args.methodSuffix)
}
if (typeof service[methodName] !== "function") {
throw new Error(
`Method "${methodName}" does not exist on "${serviceConfig.serviceName}"`
)
}
const result = await service[methodName](filters, options)
if (hasPagination) {
const [data, count] = result
return {
data: {
rows: data,
metadata: this.buildPagination(options, count),
},
path: "rows",
}
}
return {
data: result,
}
}
public async query(query: string, variables: any = {}): Promise<any> {
return await this.remoteJoiner.query(
RemoteJoiner.parseQuery(query, variables)
)
}
}