Feat(medusa, cli): plugin db generate (#10988)

RESOLVES FRMW-2875

**What**
Allow to generate migration for plugins. Migration generation defer from project migration generation and therefore we choose to separate responsibility entirely.

The flow is fairly simple, the user run `npx medusa plugin:db:generate` and the script will scan for all available modules in the plugins, gather their models information and generate the appropriate migrations and snapshot (for later generation)

Co-authored-by: Harminder Virk <1706381+thetutlage@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-01-17 13:05:46 +01:00
committed by GitHub
parent 5582bd2038
commit 0cfaab5bb1
9 changed files with 272 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/medusa": patch
"@medusajs/cli": patch
---
Feat(medusa, cli): plugin db generate

View File

@@ -238,6 +238,16 @@ function buildLocalCommands(cli, isLocalProject) {
})
),
})
.command({
command: "plugin:db:generate",
desc: "Generate migrations for a given module",
handler: handlerP(
getCommandHandler("plugin/db/generate", (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
return cmd(args)
})
),
})
.command({
command: "db:sync-links",
desc: "Sync database schema with the links defined by your application and Medusa core",

View File

@@ -40,7 +40,8 @@
"watch": "tsc --build --watch",
"build": "rimraf dist && tsc --build",
"serve": "node dist/app.js",
"test": "jest --silent=false --bail --maxWorkers=50% --forceExit"
"test": "jest --runInBand --bail --forceExit --testPathIgnorePatterns='/integration-tests/' -- src/**/__tests__/**/*.ts",
"test:integration": "jest --forceExit -- src/**/integration-tests/**/__tests__/**/*.ts"
},
"devDependencies": {
"@medusajs/framework": "^2.2.0",

View File

@@ -0,0 +1,135 @@
import { logger } from "@medusajs/framework/logger"
import {
defineMikroOrmCliConfig,
DmlEntity,
dynamicImport,
} from "@medusajs/framework/utils"
import { dirname, join } from "path"
import { MetadataStorage } from "@mikro-orm/core"
import { MikroORM } from "@mikro-orm/postgresql"
import { glob } from "glob"
const TERMINAL_SIZE = process.stdout.columns
/**
* Generate migrations for all scanned modules in a plugin
*/
const main = async function ({ directory }) {
try {
const moduleDescriptors = [] as {
serviceName: string
migrationsPath: string
entities: any[]
}[]
const modulePaths = glob.sync(
join(directory, "src", "modules", "*", "index.ts")
)
for (const path of modulePaths) {
const moduleDirname = dirname(path)
const serviceName = await getModuleServiceName(path)
const entities = await getEntitiesForModule(moduleDirname)
moduleDescriptors.push({
serviceName,
migrationsPath: join(moduleDirname, "migrations"),
entities,
})
}
/**
* Generating migrations
*/
logger.info("Generating migrations...")
await generateMigrations(moduleDescriptors)
console.log(new Array(TERMINAL_SIZE).join("-"))
logger.info("Migrations generated")
process.exit()
} catch (error) {
console.log(new Array(TERMINAL_SIZE).join("-"))
logger.error(error.message, error)
process.exit(1)
}
}
async function getEntitiesForModule(path: string) {
const entities = [] as any[]
const entityPaths = glob.sync(join(path, "models", "*.ts"), {
ignore: ["**/index.{js,ts}"],
})
for (const entityPath of entityPaths) {
const entityExports = await dynamicImport(entityPath)
const validEntities = Object.values(entityExports).filter(
(potentialEntity) => {
return (
DmlEntity.isDmlEntity(potentialEntity) ||
!!MetadataStorage.getMetadataFromDecorator(potentialEntity as any)
)
}
)
entities.push(...validEntities)
}
return entities
}
async function getModuleServiceName(path: string) {
const moduleExport = await dynamicImport(path)
if (!moduleExport.default) {
throw new Error("The module should default export the `Module()`")
}
return (moduleExport.default.service as any).prototype.__joinerConfig()
.serviceName
}
async function generateMigrations(
moduleDescriptors: {
serviceName: string
migrationsPath: string
entities: any[]
}[] = []
) {
const DB_HOST = process.env.DB_HOST ?? "localhost"
const DB_USERNAME = process.env.DB_USERNAME ?? ""
const DB_PASSWORD = process.env.DB_PASSWORD ?? ""
for (const moduleDescriptor of moduleDescriptors) {
logger.info(
`Generating migrations for module ${moduleDescriptor.serviceName}...`
)
const mikroOrmConfig = defineMikroOrmCliConfig(
moduleDescriptor.serviceName,
{
entities: moduleDescriptor.entities,
host: DB_HOST,
user: DB_USERNAME,
password: DB_PASSWORD,
migrations: {
path: moduleDescriptor.migrationsPath,
},
}
)
const orm = await MikroORM.init(mikroOrmConfig)
const migrator = orm.getMigrator()
const result = await migrator.createMigration()
if (result.fileName) {
logger.info(`Migration created: ${result.fileName}`)
} else {
logger.info(`No migration created`)
}
}
}
export default main

View File

@@ -0,0 +1,5 @@
import { MedusaService, Module } from "@medusajs/framework/utils"
export const module1 = Module("module1", {
service: class Module1Service extends MedusaService({}) {},
})

View File

@@ -0,0 +1,8 @@
import { model } from "@medusajs/framework/utils"
const model1 = model.define("module_model_1", {
id: model.id().primaryKey(),
name: model.text(),
})
export default model1

View File

@@ -0,0 +1,5 @@
import { MedusaService, Module } from "@medusajs/framework/utils"
export default Module("module1", {
service: class Module1Service extends MedusaService({}) {},
})

View File

@@ -0,0 +1,8 @@
import { model } from "@medusajs/framework/utils"
const model1 = model.define("module_model_1", {
id: model.id().primaryKey(),
name: model.text(),
})
export default model1

View File

@@ -0,0 +1,93 @@
import { logger } from "@medusajs/framework/logger"
import { FileSystem } from "@medusajs/framework/utils"
import { join } from "path"
import main from "../../generate"
jest.mock("@medusajs/framework/logger")
describe("plugin-generate", () => {
beforeEach(() => {
jest.clearAllMocks()
jest
.spyOn(process, "exit")
.mockImplementation((code?: string | number | null) => {
return code as never
})
})
afterEach(async () => {
const module1 = new FileSystem(
join(
__dirname,
"..",
"__fixtures__",
"plugins-1",
"src",
"modules",
"module-1"
)
)
await module1.remove("migrations")
})
describe("main function", () => {
it("should successfully generate migrations when valid modules are found", async () => {
await main({
directory: join(__dirname, "..", "__fixtures__", "plugins-1"),
})
expect(logger.info).toHaveBeenNthCalledWith(1, "Generating migrations...")
expect(logger.info).toHaveBeenNthCalledWith(
2,
"Generating migrations for module module1..."
)
expect(logger.info).toHaveBeenNthCalledWith(
3,
expect.stringContaining("Migration created")
)
expect(logger.info).toHaveBeenNthCalledWith(4, "Migrations generated")
expect(process.exit).toHaveBeenCalledWith()
})
it("should handle case when no migrations are needed", async () => {
await main({
directory: join(__dirname, "..", "__fixtures__", "plugins-1"),
})
jest.clearAllMocks()
await main({
directory: join(__dirname, "..", "__fixtures__", "plugins-1"),
})
expect(logger.info).toHaveBeenNthCalledWith(1, "Generating migrations...")
expect(logger.info).toHaveBeenNthCalledWith(
2,
"Generating migrations for module module1..."
)
expect(logger.info).toHaveBeenNthCalledWith(
3,
expect.stringContaining("No migration created")
)
expect(logger.info).toHaveBeenNthCalledWith(4, "Migrations generated")
expect(process.exit).toHaveBeenCalledWith()
})
it("should handle error when module has no default export", async () => {
await main({
directory: join(
__dirname,
"..",
"__fixtures__",
"plugins-1-no-default"
),
})
expect(logger.error).toHaveBeenCalledWith(
"The module should default export the `Module()`",
new Error("The module should default export the `Module()`")
)
expect(process.exit).toHaveBeenCalledWith(1)
})
})
})