Files
medusa-store/packages/core/modules-sdk/src/medusa-app.ts
Carlos R. L. Rodrigues 22276648ad feat: query.index (#11348)
What:
 - `query.index` helper. It queries the index module, and aggregate the rest of requested fields/relations if needed like `query.graph`.
 
Not covered in this PR:
 - Hydrate only sub entities returned by the query. Example: 1 out of 5 variants have returned, it should only hydrate the data of the single entity, currently it will merge all the variants of the product.
 - Generate types of indexed data
 
 example:
 ```ts
 const query = container.resolve(ContainerRegistrationKeys.QUERY)
        
 await query.index({
  entity: "product",
  fields: [
    "id",
    "description",
    "status",
    "variants.sku",
    "variants.barcode",
    "variants.material",
    "variants.options.value",
    "variants.prices.amount",
    "variants.prices.currency_code",
    "variants.inventory_items.inventory.sku",
    "variants.inventory_items.inventory.description",
  ],
  filters: {
    "variants.sku": { $like: "%-1" },
    "variants.prices.amount": { $gt: 30 },
  },
  pagination: {
    order: {
      "variants.prices.amount": "DESC",
    },
  },
})
```
This query return all products where at least one variant has the title ending in `-1` and at least one price bigger than `30`.
 
The Index Module only hold the data used to paginate and filter, and the returned object is:
```json
{
  "id": "prod_01JKEAM2GJZ14K64R0DHK0JE72",
  "title": null,
  "variants": [
    {
      "id": "variant_01JKEAM2HC89GWS95F6GF9C6YA",
      "sku": "extra-variant-1",
      "prices": [
        {
          "id": "price_01JKEAM2JADEWWX72F8QDP6QXT",
          "amount": 80,
          "currency_code": "USD"
        }
      ]
    }
  ]
}
```

All the rest of the fields will be hydrated from their respective modules, and the final result will be:

```json
{
  "id": "prod_01JKEAY2RJTF8TW9A23KTGY1GD",
  "description": "extra description",
  "status": "draft",
  "variants": [
    {
      "sku": "extra-variant-1",
      "barcode": null,
      "material": null,
      "id": "variant_01JKEAY2S945CRZ6X4QZJ7GVBJ",
      "options": [
        {
          "value": "Red"
        }
      ],
      "prices": [
        {
          "amount": 20,
          "currency_code": "CAD",
          "id": "price_01JKEAY2T2EEYSWZHPGG11B7W7"
        },
        {
          "amount": 80,
          "currency_code": "USD",
          "id": "price_01JKEAY2T2NJK2E5468RK84CAR"
        }
      ],
      "inventory_items": [
        {
          "variant_id": "variant_01JKEAY2S945CRZ6X4QZJ7GVBJ",
          "inventory_item_id": "iitem_01JKEAY2SNY2AWEHPZN0DDXVW6",
          "inventory": {
            "sku": "extra-variant-1",
            "description": "extra variant 1",
            "id": "iitem_01JKEAY2SNY2AWEHPZN0DDXVW6"
          }
        }
      ]
    }
  ]
}
```

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
2025-02-12 12:55:09 +00:00

650 lines
17 KiB
TypeScript

import { RemoteFetchDataCallback } from "@medusajs/orchestration"
import {
ExternalModuleDeclaration,
IIndexService,
ILinkMigrationsPlanner,
InternalModuleDeclaration,
LoadedModule,
MedusaContainer,
ModuleBootstrapDeclaration,
ModuleDefinition,
ModuleExports,
ModuleJoinerConfig,
ModuleServiceInitializeOptions,
RemoteQueryFunction,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
createMedusaContainer,
dynamicImport,
GraphQLUtils,
isObject,
isSharedConnectionSymbol,
isString,
MedusaError,
MODULE_PACKAGE_NAMES,
Modules,
ModulesSdkUtils,
promiseAll,
} from "@medusajs/utils"
import { asValue } from "awilix"
import { Link } from "./link"
import {
MedusaModule,
MigrationOptions,
ModuleBootstrapOptions,
RegisterModuleJoinerConfig,
} from "./medusa-module"
import { createQuery, RemoteQuery } from "./remote-query"
import { MODULE_SCOPE } from "./types"
const LinkModulePackage = MODULE_PACKAGE_NAMES[Modules.LINK]
export type RunMigrationFn = () => Promise<void>
export type RevertMigrationFn = (moduleNames: string[]) => Promise<void>
export type GenerateMigrations = (moduleNames: string[]) => Promise<void>
export type GetLinkExecutionPlanner = () => ILinkMigrationsPlanner
export type MedusaModuleConfig = {
[key: string | Modules]:
| string
| boolean
| Partial<InternalModuleDeclaration | ExternalModuleDeclaration>
}
export type SharedResources = {
database?: ModuleServiceInitializeOptions["database"] & {
/**
* {
* name?: string
* afterCreate?: Function
* min?: number
* max?: number
* refreshIdle?: boolean
* idleTimeoutMillis?: number
* reapIntervalMillis?: number
* returnToHead?: boolean
* priorityRange?: number
* log?: (message: string, logLevel: string) => void
* }
*/
pool?: Record<string, unknown>
}
}
export async function loadModules(args: {
modulesConfig: MedusaModuleConfig
sharedContainer: MedusaContainer
sharedResourcesConfig?: SharedResources
migrationOnly?: boolean
loaderOnly?: boolean
workerMode?: "shared" | "worker" | "server"
}) {
const {
modulesConfig,
sharedContainer,
sharedResourcesConfig,
migrationOnly = false,
loaderOnly = false,
workerMode = "shared" as ModuleBootstrapOptions["workerMode"],
} = args
const allModules = {} as any
const modulesToLoad: {
moduleKey: string
defaultPath: string
declaration: InternalModuleDeclaration | ExternalModuleDeclaration
sharedContainer: MedusaContainer
moduleDefinition: ModuleDefinition
moduleExports?: ModuleExports
}[] = []
for (const moduleName of Object.keys(modulesConfig)) {
const mod = modulesConfig[moduleName]
let path: string
let moduleExports: ModuleExports | undefined = undefined
let declaration: any = {}
let definition: Partial<ModuleDefinition> | undefined = undefined
// TODO: We are keeping mod === false for backward compatibility for now
if (mod === false || (isObject(mod) && "disable" in mod && mod.disable)) {
continue
}
if (isObject(mod)) {
const mod_ = mod as unknown as InternalModuleDeclaration
path = mod_.resolve ?? MODULE_PACKAGE_NAMES[moduleName]
definition = mod_.definition
moduleExports = !isString(mod_.resolve)
? (mod_.resolve as ModuleExports)
: undefined
declaration = { ...mod }
delete declaration.definition
} else {
path = MODULE_PACKAGE_NAMES[moduleName]
}
declaration.scope ??= MODULE_SCOPE.INTERNAL
if (declaration.scope === MODULE_SCOPE.INTERNAL) {
declaration.options ??= {}
if (!declaration.options.database) {
declaration.options[isSharedConnectionSymbol] = true
}
declaration.options.database ??= {
...sharedResourcesConfig?.database,
}
declaration.options.database.debug ??=
sharedResourcesConfig?.database?.debug
}
modulesToLoad.push({
moduleKey: moduleName,
defaultPath: path,
declaration,
sharedContainer,
moduleDefinition: definition as ModuleDefinition,
moduleExports,
})
}
const loaded = (await MedusaModule.bootstrapAll(modulesToLoad, {
migrationOnly,
loaderOnly,
workerMode,
})) as LoadedModule[]
if (loaderOnly) {
return allModules
}
for (const { moduleKey } of modulesToLoad) {
const service = loaded.find((loadedModule) => loadedModule[moduleKey])?.[
moduleKey
]
if (!service) {
throw new Error(`Module ${moduleKey} could not be loaded.`)
}
sharedContainer.register({
[service.__definition.key]: asValue(service),
})
if (allModules[moduleKey] && !Array.isArray(allModules[moduleKey])) {
allModules[moduleKey] = []
}
if (allModules[moduleKey]) {
;(allModules[moduleKey] as LoadedModule[]).push(service)
} else {
allModules[moduleKey] = service
}
}
return allModules
}
async function initializeLinks({
config,
linkModules,
injectedDependencies,
moduleExports,
}) {
try {
let resources = moduleExports
if (!resources) {
const module = await dynamicImport(LinkModulePackage)
if ("discoveryPath" in module) {
const reExportedLoadedModule = await dynamicImport(module.discoveryPath)
resources = reExportedLoadedModule.default ?? reExportedLoadedModule
}
}
const { initialize, getMigrationPlanner } = resources
const linkResolution = await initialize(
config,
linkModules,
injectedDependencies
)
return {
remoteLink: new Link(),
linkResolution,
getMigrationPlanner,
}
} catch (err) {
console.warn("Error initializing link modules.", err)
return {
remoteLink: undefined,
linkResolution: undefined,
getMigrationPlanner: () => void 0,
}
}
}
function isMedusaModule(mod) {
return typeof mod?.initialize === "function"
}
function cleanAndMergeSchema(loadedSchema) {
const defaultMedusaSchema = `
scalar DateTime
scalar JSON
`
const { schema: cleanedSchema, notFound } = GraphQLUtils.cleanGraphQLSchema(
defaultMedusaSchema + loadedSchema
)
const mergedSchema = GraphQLUtils.mergeTypeDefs(cleanedSchema)
return {
schema: GraphQLUtils.makeExecutableSchema({ typeDefs: mergedSchema }),
notFound,
}
}
function getLoadedSchema(): string {
return MedusaModule.getAllJoinerConfigs()
.map((joinerConfig) => joinerConfig?.schema ?? "")
.join("\n")
}
function registerCustomJoinerConfigs(servicesConfig: ModuleJoinerConfig[]) {
for (const config of servicesConfig) {
if (!config.serviceName || config.isReadOnlyLink) {
continue
}
MedusaModule.setJoinerConfig(config.serviceName, config)
}
}
export type MedusaAppOutput = {
modules: Record<string, LoadedModule | LoadedModule[]>
link: Link | undefined
query: RemoteQueryFunction
entitiesMap?: Record<string, any>
gqlSchema?: GraphQLUtils.GraphQLSchema
notFound?: Record<string, Record<string, string>>
runMigrations: RunMigrationFn
revertMigrations: RevertMigrationFn
generateMigrations: GenerateMigrations
linkMigrationExecutionPlanner: GetLinkExecutionPlanner
onApplicationShutdown: () => Promise<void>
onApplicationPrepareShutdown: () => Promise<void>
onApplicationStart: () => Promise<void>
sharedContainer?: MedusaContainer
}
export type MedusaAppOptions = {
workerMode?: "shared" | "worker" | "server"
sharedContainer?: MedusaContainer
sharedResourcesConfig?: SharedResources
loadedModules?: LoadedModule[]
servicesConfig?: ModuleJoinerConfig[]
modulesConfigPath?: string
modulesConfigFileName?: string
modulesConfig?: MedusaModuleConfig
linkModules?: RegisterModuleJoinerConfig | RegisterModuleJoinerConfig[]
remoteFetchData?: RemoteFetchDataCallback
injectedDependencies?: any
onApplicationStartCb?: () => void
/**
* Forces the modules bootstrapper to only run the modules loaders and return prematurely
*/
loaderOnly?: boolean
}
async function MedusaApp_({
sharedContainer,
sharedResourcesConfig,
servicesConfig,
modulesConfigPath,
modulesConfigFileName,
modulesConfig,
linkModules,
remoteFetchData,
injectedDependencies = {},
migrationOnly = false,
loaderOnly = false,
workerMode = "shared",
}: MedusaAppOptions & {
migrationOnly?: boolean
} = {}): Promise<MedusaAppOutput> {
const sharedContainer_ = createMedusaContainer({}, sharedContainer)
const onApplicationShutdown = async () => {
await promiseAll([
MedusaModule.onApplicationShutdown(),
sharedContainer_.dispose(),
])
}
const onApplicationPrepareShutdown = async () => {
await promiseAll([MedusaModule.onApplicationPrepareShutdown()])
}
const onApplicationStart = async () => {
await MedusaModule.onApplicationStart()
}
const modules: MedusaModuleConfig =
modulesConfig ??
(
await dynamicImport(
modulesConfigPath ??
process.cwd() + (modulesConfigFileName ?? "/modules-config")
)
).default
const dbData = ModulesSdkUtils.loadDatabaseConfig(
"medusa",
sharedResourcesConfig as ModuleServiceInitializeOptions,
true
)!
registerCustomJoinerConfigs(servicesConfig ?? [])
if (
sharedResourcesConfig?.database?.connection &&
!injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]
) {
injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] =
sharedResourcesConfig.database.connection
} else if (
dbData.clientUrl &&
!injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]
) {
injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] =
ModulesSdkUtils.createPgConnection({
...(sharedResourcesConfig?.database ?? {}),
...dbData,
})
}
// remove the link module from the modules
const linkModule = modules[LinkModulePackage] ?? modules[Modules.LINK]
delete modules[LinkModulePackage]
delete modules[Modules.LINK]
let linkModuleOrOptions:
| Partial<ModuleServiceInitializeOptions>
| Partial<ModuleBootstrapDeclaration> = {}
if (isObject(linkModule)) {
linkModuleOrOptions = linkModule
}
for (const injectedDependency of Object.keys(injectedDependencies)) {
sharedContainer_.register({
[injectedDependency]: asValue(injectedDependencies[injectedDependency]),
})
}
const allModules = await loadModules({
modulesConfig: modules,
sharedContainer: sharedContainer_,
sharedResourcesConfig: { database: dbData },
migrationOnly,
loaderOnly,
workerMode,
})
if (loaderOnly) {
async function query(...args: any[]) {
throw new Error("Querying not allowed in loaderOnly mode")
}
query.graph = query
query.gql = query
return {
onApplicationShutdown,
onApplicationPrepareShutdown,
onApplicationStart,
modules: allModules,
link: undefined,
query: query as unknown as RemoteQueryFunction,
runMigrations: async () => {
throw new Error("Migrations not allowed in loaderOnly mode")
},
revertMigrations: async () => {
throw new Error("Revert migrations not allowed in loaderOnly mode")
},
generateMigrations: async () => {
throw new Error("Generate migrations not allowed in loaderOnly mode")
},
linkMigrationExecutionPlanner: () => {
throw new Error(
"Migrations planner is not avaibable in loaderOnly mode"
)
},
}
}
// Share Event bus with link modules
injectedDependencies[Modules.EVENT_BUS] = sharedContainer_.resolve(
Modules.EVENT_BUS,
{
allowUnregistered: true,
}
)
linkModules ??= []
if (!Array.isArray(linkModules)) {
linkModules = [linkModules]
}
linkModules.push(...MedusaModule.getCustomLinks())
const allLoadedJoinerConfigs = MedusaModule.getAllJoinerConfigs()
for (let linkIdx = 0; linkIdx < linkModules.length; linkIdx++) {
const customLink: any = linkModules[linkIdx]
if (typeof customLink === "function") {
linkModules[linkIdx] = customLink(allLoadedJoinerConfigs)
}
}
const { remoteLink, getMigrationPlanner } = await initializeLinks({
config: linkModuleOrOptions,
linkModules,
injectedDependencies,
moduleExports: isMedusaModule(linkModule) ? linkModule : undefined,
})
const loadedSchema = getLoadedSchema()
const { schema, notFound } = cleanAndMergeSchema(loadedSchema)
const entitiesMap = schema.getTypeMap() as unknown as Map<string, any>
const remoteQuery = new RemoteQuery({
servicesConfig,
customRemoteFetchData: remoteFetchData,
entitiesMap,
})
const applyMigration = async ({
modulesNames,
action = "run",
}: {
modulesNames: string[]
action?: "run" | "revert" | "generate"
}) => {
const moduleResolutions = modulesNames.map((moduleName) => {
return {
moduleName,
resolution: MedusaModule.getModuleResolutions(moduleName),
}
})
const missingModules = moduleResolutions
.filter(({ resolution }) => !resolution)
.map(({ moduleName }) => moduleName)
if (missingModules.length) {
const error = new MedusaError(
MedusaError.Types.UNKNOWN_MODULES,
`Cannot ${action} migrations for unknown module(s) ${missingModules.join(
","
)}`,
MedusaError.Codes.UNKNOWN_MODULES
)
error["allModules"] = Object.keys(allModules)
throw error
}
for (const { resolution: moduleResolution } of moduleResolutions) {
if (
!moduleResolution.options?.database &&
moduleResolution.moduleDeclaration?.scope === MODULE_SCOPE.INTERNAL
) {
moduleResolution.options ??= {}
moduleResolution.options.database = {
...(sharedResourcesConfig?.database ?? {}),
}
;(moduleResolution as any).options.database.debug ??=
sharedResourcesConfig?.database?.debug
}
const migrationOptions: MigrationOptions = {
moduleKey: moduleResolution.definition.key,
modulePath: moduleResolution.resolutionPath as string,
container: sharedContainer,
options: moduleResolution.options,
moduleExports: moduleResolution.moduleExports as ModuleExports,
}
if (action === "revert") {
await MedusaModule.migrateDown(migrationOptions)
} else if (action === "run") {
await MedusaModule.migrateUp(migrationOptions)
} else {
await MedusaModule.migrateGenerate(migrationOptions)
}
}
}
const runMigrations: RunMigrationFn = async (): Promise<void> => {
await applyMigration({
modulesNames: Object.keys(allModules),
})
}
const revertMigrations: RevertMigrationFn = async (
modulesNames
): Promise<void> => {
await applyMigration({
modulesNames,
action: "revert",
})
}
const generateMigrations: GenerateMigrations = async (
modulesNames
): Promise<void> => {
await applyMigration({
modulesNames,
action: "generate",
})
}
const getMigrationPlannerFn = () => {
const options: Partial<ModuleServiceInitializeOptions> =
"scope" in linkModuleOrOptions
? { ...linkModuleOrOptions.options }
: {
...(linkModuleOrOptions as Partial<ModuleServiceInitializeOptions>),
}
options.database ??= {
...sharedResourcesConfig?.database,
}
options.database.debug ??= sharedResourcesConfig?.database?.debug
return getMigrationPlanner(options, linkModules)
}
const indexModule = sharedContainer_.resolve(Modules.INDEX, {
allowUnregistered: true,
}) as IIndexService
return {
onApplicationShutdown,
onApplicationPrepareShutdown,
onApplicationStart,
modules: allModules,
link: remoteLink,
query: createQuery({
remoteQuery,
indexModule,
}) as any, // TODO: rm any once we remove the old RemoteQueryFunction and rely on the Query object instead,
entitiesMap,
gqlSchema: schema,
notFound,
runMigrations,
revertMigrations,
generateMigrations,
linkMigrationExecutionPlanner: getMigrationPlannerFn,
sharedContainer: sharedContainer_,
}
}
export async function MedusaApp(
options: MedusaAppOptions = {}
): Promise<MedusaAppOutput> {
return await MedusaApp_(options)
}
export async function MedusaAppMigrateUp(
options: MedusaAppOptions = {}
): Promise<void> {
const migrationOnly = true
const { runMigrations } = await MedusaApp_({
...options,
migrationOnly,
})
await runMigrations().finally(MedusaModule.clearInstances)
}
export async function MedusaAppMigrateDown(
moduleNames: string[],
options: MedusaAppOptions = {}
): Promise<void> {
const migrationOnly = true
const { revertMigrations } = await MedusaApp_({
...options,
migrationOnly,
})
await revertMigrations(moduleNames).finally(MedusaModule.clearInstances)
}
export async function MedusaAppMigrateGenerate(
moduleNames: string[],
options: MedusaAppOptions = {}
): Promise<void> {
const migrationOnly = true
const { generateMigrations } = await MedusaApp_({
...options,
migrationOnly,
})
await generateMigrations(moduleNames).finally(MedusaModule.clearInstances)
}
export async function MedusaAppGetLinksExecutionPlanner(
options: MedusaAppOptions = {}
): Promise<ILinkMigrationsPlanner> {
const migrationOnly = true
const { linkMigrationExecutionPlanner } = await MedusaApp_({
...options,
migrationOnly,
})
return linkMigrationExecutionPlanner()
}