chore(): Autoload module resources (#7291)

**What**
- automatically build and consume connection and container loader if not exported by the module
- therefore load the services and repositories automatically, including baseRepository
- automatically build run and revert migrations if not provided
- cleaup modules to remove extra unnecessary bits and pieces
- remove the `initializeFactory` in favor of using `medusaApp`

Should drastically improve the module building DX by removing a lot of boilerplate to handle by the user, that plus the base entity should simplify quite a lot the flow cc @shahednasser 

**Note**
I had to choose a way to identify connection and container loader from the exported loader from the module. I decided to go with named function `connectionLoader` and `containerLoader`, also, now the factories will return named function so if the user use the factories we are providing to build those loaders, the function will also be named and identified
This commit is contained in:
Adrien de Peretti
2024-05-13 12:12:36 +00:00
committed by GitHub
parent 728c5ee53c
commit 63623422fe
112 changed files with 520 additions and 2320 deletions
-1
View File
@@ -5,4 +5,3 @@ export * from "./medusa-app"
export * from "./medusa-module"
export * from "./remote-link"
export * from "./remote-query"
export * from "./utils/initialize-factory"
@@ -137,11 +137,9 @@ function getInternalModuleResolution(
// If user added a module and it's overridable, we resolve that instead
const isStr = isString(moduleConfig)
if (isStr || (isObj && moduleConfig.resolve)) {
resolutionPath = !moduleExports
? resolveCwd(isStr ? moduleConfig : (moduleConfig.resolve as string))
: // Explicitly assign an empty string, later, we will check if the value is exactly false.
// This allows to continue the module loading while using the module exports instead of re importing the module itself during the process.
""
resolutionPath = resolveCwd(
isStr ? moduleConfig : (moduleConfig.resolve as string)
)
}
const moduleDeclaration = isObj ? moduleConfig : {}
@@ -1,17 +1,32 @@
import {
Constructor,
InternalModuleDeclaration,
Logger,
MedusaContainer,
MODULE_RESOURCE_TYPE,
ModuleExports,
ModuleLoaderFunction,
ModuleResolution,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
createMedusaContainer,
MedusaModuleType,
ModulesSdkUtils,
} from "@medusajs/utils"
import { asFunction, asValue } from "awilix"
import { join, resolve } from "path"
import { statSync } from "fs"
import { readdir } from "fs/promises"
type ModuleResource = {
services: Function[]
models: Function[]
repositories: Function[]
loaders: ModuleLoaderFunction[]
moduleService: Constructor<any>
normalizedPath: string
}
export async function loadInternalModule(
container: MedusaContainer,
@@ -36,6 +51,8 @@ export async function loadInternalModule(
const modulePath = resolution.resolutionPath as string
if (resolution.moduleExports) {
// TODO:
// If we want to benefit from the auto load mechanism, even if the module exports is provided, we need to ask for the module path
loadedModule = resolution.moduleExports
} else {
loadedModule = await import(modulePath)
@@ -56,7 +73,17 @@ export async function loadInternalModule(
return { error }
}
if (!loadedModule?.service) {
let moduleResources = {} as ModuleResource
if (resolution.resolutionPath) {
moduleResources = await loadResources(
loadedModule?.loaders ?? [],
resolution,
logger
)
}
if (!loadedModule?.service && !moduleResources.moduleService) {
container.register({
[registrationName]: asValue(undefined),
})
@@ -69,9 +96,11 @@ export async function loadInternalModule(
}
if (migrationOnly) {
const moduleService_ = moduleResources.moduleService ?? loadedModule.service
// Partially loaded module, only register the service __joinerConfig function to be able to resolve it later
const moduleService = {
__joinerConfig: loadedModule.service.prototype.__joinerConfig,
__joinerConfig: moduleService_.prototype.__joinerConfig,
}
container.register({
[registrationName]: asValue(moduleService),
@@ -100,32 +129,21 @@ export async function loadInternalModule(
)
}
const moduleLoaders = loadedModule?.loaders ?? []
try {
for (const loader of moduleLoaders) {
await loader(
{
container: localContainer,
logger,
options: resolution.options,
dataLoaderOnly: loaderOnly,
},
resolution.moduleDeclaration as InternalModuleDeclaration
)
}
} catch (err) {
container.register({
[registrationName]: asValue(undefined),
})
const loaders = moduleResources.loaders ?? loadedModule?.loaders ?? []
const error = await runLoaders(loaders, {
container,
localContainer,
logger,
resolution,
loaderOnly,
registrationName,
})
return {
error: new Error(
`Loaders for module ${resolution.definition.label} failed: ${err.message}`
),
}
if (error) {
return error
}
const moduleService = loadedModule.service
const moduleService = moduleResources.moduleService ?? loadedModule.service
container.register({
[registrationName]: asFunction((cradle) => {
@@ -155,8 +173,229 @@ export async function loadModuleMigrations(
loadedModule =
moduleExports ?? (await import(resolution.resolutionPath as string))
return [loadedModule.runMigrations, loadedModule.revertMigration]
let runMigrations = loadedModule.runMigrations
let revertMigration = loadedModule.revertMigration
// Generate migration scripts if they are not present
if (!runMigrations || !revertMigration) {
const moduleResources = await loadResources(
loadedModule?.loaders ?? [],
resolution,
console as unknown as Logger
)
const migrationScriptOptions = {
moduleName: resolution.definition.key,
models: moduleResources.models,
pathToMigrations: moduleResources.normalizedPath + "/dist/migrations",
}
runMigrations ??= ModulesSdkUtils.buildMigrationScript(
migrationScriptOptions
)
revertMigration ??= ModulesSdkUtils.buildRevertMigrationScript(
migrationScriptOptions
)
}
return [runMigrations, revertMigration]
} catch {
return [undefined, undefined]
}
}
async function importAllFromDir(path: string) {
let filesToLoad: string[] = []
await readdir(path).then((files) => {
files.forEach((file) => {
if (file !== "index.js" && file.endsWith(".js")) {
const filePath = join(path, file)
const stats = statSync(filePath)
if (stats.isDirectory()) {
// TODO: should we handle that? dont think so but I put that here for discussion
} else if (stats.isFile()) {
filesToLoad.push(filePath)
}
}
})
return filesToLoad
})
return (
await Promise.all(filesToLoad.map((filePath) => import(filePath)))
).flatMap((value) => {
return Object.values(value)
})
}
async function loadResources(
loadedModuleLoaders: ModuleLoaderFunction[],
moduleResolution: ModuleResolution,
logger: Logger
): Promise<ModuleResource> {
const modulePath = moduleResolution.resolutionPath as string
let normalizedPath = modulePath.replace("dist/", "").replace("index.js", "")
normalizedPath = resolve(normalizedPath)
try {
const defaultOnFail = () => {
return []
}
const [moduleService, services, models, repositories] = await Promise.all([
import(modulePath).then((moduleExports) => moduleExports.default.service),
importAllFromDir(resolve(normalizedPath, "dist", "services")).catch(
defaultOnFail
),
importAllFromDir(resolve(normalizedPath, "dist", "models")).catch(
defaultOnFail
),
importAllFromDir(resolve(normalizedPath, "dist", "repositories")).catch(
defaultOnFail
),
])
const cleanupResources = (resources) => {
return Object.values(resources).filter(
(resource): resource is Function => {
return typeof resource === "function"
}
)
}
const potentialServices = [...new Set(cleanupResources(services))]
const potentialModels = [...new Set(cleanupResources(models))]
const potentialRepositories = [...new Set(cleanupResources(repositories))]
const finalLoaders = prepareLoaders({
loadedModuleLoaders,
models: potentialModels,
repositories: potentialRepositories,
services: potentialServices,
moduleResolution,
migrationPath: normalizedPath + "/dist/migrations",
})
return {
services: potentialServices,
models: potentialModels,
repositories: potentialRepositories,
loaders: finalLoaders,
moduleService,
normalizedPath
}
} catch (e) {
logger.warn(
`Unable to load resources for module ${modulePath} automagically. ${e.message}`
)
return {} as ModuleResource
}
}
async function runLoaders(
loaders: Function[] = [],
{
localContainer,
container,
logger,
resolution,
loaderOnly,
registrationName,
}
): Promise<void | { error: Error }> {
try {
for (const loader of loaders) {
await loader(
{
container: localContainer,
logger,
options: resolution.options,
dataLoaderOnly: loaderOnly,
},
resolution.moduleDeclaration as InternalModuleDeclaration
)
}
} catch (err) {
container.register({
[registrationName]: asValue(undefined),
})
return {
error: new Error(
`Loaders for module ${resolution.definition.label} failed: ${err.message}`
),
}
}
}
function prepareLoaders({
loadedModuleLoaders,
models,
repositories,
services,
moduleResolution,
migrationPath,
}) {
const finalLoaders: ModuleLoaderFunction[] = []
const toObjectReducer = (acc, curr) => {
acc[curr.name] = curr
return acc
}
/*
* If no connectionLoader function is provided, create a default connection loader.
* TODO: Validate naming convention
*/
const connectionLoaderName = "connectionLoader"
const containerLoader = "containerLoader"
const hasConnectionLoader = loadedModuleLoaders.some(
(l) => l.name === connectionLoaderName
)
if (!hasConnectionLoader && models.length > 0) {
const connectionLoader = ModulesSdkUtils.mikroOrmConnectionLoaderFactory({
moduleName: moduleResolution.definition.key,
moduleModels: models,
migrationsPath: migrationPath, //normalizedPath + "/dist/migrations",
})
finalLoaders.push(connectionLoader)
}
const hasContainerLoader = loadedModuleLoaders.some(
(l) => l.name === containerLoader
)
if (!hasContainerLoader) {
const containerLoader = ModulesSdkUtils.moduleContainerLoaderFactory({
moduleModels: models.reduce(toObjectReducer, {}),
moduleRepositories: repositories.reduce(toObjectReducer, {}),
moduleServices: services.reduce(toObjectReducer, {}),
})
finalLoaders.push(containerLoader)
}
finalLoaders.push(
...loadedModuleLoaders.filter((loader) => {
if (
loader.name !== connectionLoaderName &&
loader.name !== containerLoader
) {
return true
}
return (
(loader.name === containerLoader && hasContainerLoader) ||
(loader.name === connectionLoaderName && hasConnectionLoader)
)
})
)
return finalLoaders
}
@@ -1,3 +1,2 @@
export * from "./clean-graphql-schema"
export * from "./graphql-schema-to-fields"
export * from "./initialize-factory"
@@ -1,44 +0,0 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
ModuleExports,
ModuleServiceInitializeCustomDataLayerOptions,
ModuleServiceInitializeOptions,
} from "@medusajs/types"
import { MODULE_PACKAGE_NAMES } from "../definitions"
import { MedusaModule } from "../medusa-module"
/**
* Generate a initialize module factory that is exported by the module to be initialized manually
*
* @param moduleName
* @param moduleDefinition
*/
export function initializeFactory<T>({
moduleName,
moduleDefinition,
}: {
moduleName: string
moduleDefinition: ModuleExports
}) {
return async (
options?:
| ModuleServiceInitializeOptions
| ModuleServiceInitializeCustomDataLayerOptions
| ExternalModuleDeclaration
| InternalModuleDeclaration,
injectedDependencies?: any
) => {
const loaded = await MedusaModule.bootstrap<T>({
moduleKey: moduleName,
defaultPath: MODULE_PACKAGE_NAMES[moduleName],
declaration: options as
| InternalModuleDeclaration
| ExternalModuleDeclaration,
injectedDependencies,
moduleExports: moduleDefinition,
})
return loaded[moduleName] as T
}
}