feat(migrations): CLI generate command (#8103)

This commit is contained in:
Adrien de Peretti
2024-07-12 13:12:49 +02:00
committed by GitHub
parent 4c2e9a3239
commit 104b00d4e9
14 changed files with 341 additions and 164 deletions

View File

@@ -11,7 +11,9 @@ const { dropDatabase } = require("pg-god")
const { DataSource } = require("typeorm")
const dbFactory = require("./use-template-db")
const { ContainerRegistrationKeys } = require("@medusajs/utils")
const { migrateMedusaApp } = require("@medusajs/medusa/dist/loaders/medusa-app")
const {
runMedusaAppMigrations,
} = require("@medusajs/medusa/dist/loaders/medusa-app")
const DB_HOST = process.env.DB_HOST
const DB_USERNAME = process.env.DB_USERNAME
@@ -181,10 +183,7 @@ module.exports = {
instance.setPgConnection(pgConnection)
await migrateMedusaApp(
{ configModule, container },
{ registerInContainer: false }
)
await runMedusaAppMigrations({ configModule, container })
}
return dbDataSource

View File

@@ -176,10 +176,10 @@ function buildLocalCommands(cli, isLocalProject) {
action: {
demand: true,
description: "The action to perform on migrations",
choices: ["run", "revert", "show"],
choices: ["run", "revert", "show", "generate"],
},
modules: {
description: "Revert migrations for defined modules",
description: "Modules for which to run the action (revert, generate)",
demand: false,
},
},

View File

@@ -39,9 +39,9 @@ export async function initDb({
try {
const {
migrateMedusaApp,
runMedusaAppMigrations,
} = require("@medusajs/medusa/dist/loaders/medusa-app")
await migrateMedusaApp({ configModule, container })
await runMedusaAppMigrations({ configModule, container })
} catch (err) {
console.error("Something went wrong while running the migrations")
throw err

View File

@@ -2,6 +2,7 @@ import {
Constructor,
IModuleService,
InternalModuleDeclaration,
LoaderOptions,
Logger,
MedusaContainer,
ModuleExports,
@@ -32,6 +33,11 @@ type ModuleResource = {
normalizedPath: string
}
type MigrationFunction = (
options: LoaderOptions<any>,
moduleDeclaration?: InternalModuleDeclaration
) => Promise<void>
export async function loadInternalModule(
container: MedusaContainer,
resolution: ModuleResolution,
@@ -171,7 +177,11 @@ export async function loadInternalModule(
export async function loadModuleMigrations(
resolution: ModuleResolution,
moduleExports?: ModuleExports
): Promise<[Function | undefined, Function | undefined]> {
): Promise<{
runMigrations?: MigrationFunction
revertMigration?: MigrationFunction
generateMigration?: MigrationFunction
}> {
let loadedModule: ModuleExports
try {
loadedModule =
@@ -179,6 +189,7 @@ export async function loadModuleMigrations(
let runMigrations = loadedModule.runMigrations
let revertMigration = loadedModule.revertMigration
let generateMigration = loadedModule.generateMigration
if (!runMigrations || !revertMigration) {
const moduleResources = await loadResources(
@@ -189,6 +200,7 @@ export async function loadModuleMigrations(
const migrationScriptOptions = {
moduleName: resolution.definition.key,
models: moduleResources.models,
pathToMigrations: join(moduleResources.normalizedPath, "migrations"),
}
@@ -199,11 +211,15 @@ export async function loadModuleMigrations(
revertMigration ??= ModulesSdkUtils.buildRevertMigrationScript(
migrationScriptOptions
)
generateMigration ??= ModulesSdkUtils.buildGenerateMigrationScript(
migrationScriptOptions
)
}
return [runMigrations, revertMigration]
return { runMigrations, revertMigration, generateMigration }
} catch {
return [undefined, undefined]
return {}
}
}

View File

@@ -31,7 +31,11 @@ import {
import { asValue } from "awilix"
import type { Knex } from "knex"
import { MODULE_PACKAGE_NAMES } from "./definitions"
import { MedusaModule, RegisterModuleJoinerConfig } from "./medusa-module"
import {
MedusaModule,
MigrationOptions,
RegisterModuleJoinerConfig,
} from "./medusa-module"
import { RemoteLink } from "./remote-link"
import { RemoteQuery } from "./remote-query"
import { MODULE_RESOURCE_TYPE, MODULE_SCOPE } from "./types"
@@ -51,6 +55,7 @@ declare module "@medusajs/types" {
export type RunMigrationFn = () => Promise<void>
export type RevertMigrationFn = (moduleNames: string[]) => Promise<void>
export type GenerateMigrations = (moduleNames: string[]) => Promise<void>
export type MedusaModuleConfig = {
[key: string | Modules]:
@@ -225,6 +230,7 @@ export type MedusaAppOutput = {
notFound?: Record<string, Record<string, string>>
runMigrations: RunMigrationFn
revertMigrations: RevertMigrationFn
generateMigrations: GenerateMigrations
onApplicationShutdown: () => Promise<void>
onApplicationPrepareShutdown: () => Promise<void>
sharedContainer?: MedusaContainer
@@ -354,6 +360,9 @@ async function MedusaApp_({
revertMigrations: async () => {
throw new Error("Revert migrations not allowed in loaderOnly mode")
},
generateMigrations: async () => {
throw new Error("Generate migrations not allowed in loaderOnly mode")
},
}
}
@@ -406,10 +415,10 @@ async function MedusaApp_({
const applyMigration = async ({
modulesNames,
revert = false,
action = "run",
}: {
modulesNames: string[]
revert?: boolean
action?: "run" | "revert" | "generate"
}) => {
const moduleResolutions = modulesNames.map((moduleName) => {
return {
@@ -423,7 +432,6 @@ async function MedusaApp_({
.map(({ moduleName }) => moduleName)
if (missingModules.length) {
const action = revert ? "revert" : "run"
const error = new MedusaError(
MedusaError.Types.UNKNOWN_MODULES,
`Cannot ${action} migrations for unknown module(s) ${missingModules.join(
@@ -443,22 +451,20 @@ async function MedusaApp_({
}
}
if (revert) {
await MedusaModule.migrateDown(
moduleResolution.definition.key,
moduleResolution.resolutionPath as string,
sharedContainer,
moduleResolution.options,
moduleResolution.moduleExports
)
const migrationOptions: MigrationOptions = {
moduleKey: moduleResolution.definition.key,
modulePath: moduleResolution.resolutionPath as string,
container: sharedContainer,
options: moduleResolution.options,
moduleExports: moduleResolution.moduleExports,
}
if (action === "revert") {
await MedusaModule.migrateDown(migrationOptions)
} else if (action === "run") {
await MedusaModule.migrateUp(migrationOptions)
} else {
await MedusaModule.migrateUp(
moduleResolution.definition.key,
moduleResolution.resolutionPath as string,
sharedContainer,
moduleResolution.options,
moduleResolution.moduleExports
)
await MedusaModule.migrateGenerate(migrationOptions)
}
}
}
@@ -493,7 +499,7 @@ async function MedusaApp_({
): Promise<void> => {
await applyMigration({
modulesNames,
revert: true,
action: "revert",
})
const options: Partial<ModuleServiceInitializeOptions> =
@@ -516,6 +522,15 @@ async function MedusaApp_({
)
}
const generateMigrations: GenerateMigrations = async (
modulesNames
): Promise<void> => {
await applyMigration({
modulesNames,
action: "generate",
})
}
return {
onApplicationShutdown,
onApplicationPrepareShutdown,
@@ -526,6 +541,7 @@ async function MedusaApp_({
notFound,
runMigrations,
revertMigrations,
generateMigrations,
sharedContainer: sharedContainer_,
}
}
@@ -566,3 +582,17 @@ export async function MedusaAppMigrateDown(
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)
}

View File

@@ -52,6 +52,14 @@ type ModuleAlias = {
main?: boolean
}
export type MigrationOptions = {
moduleKey: string
modulePath: string
container?: MedusaContainer
options?: Record<string, any>
moduleExports?: ModuleExports
}
export type ModuleBootstrapOptions = {
moduleKey: string
defaultPath: string
@@ -536,13 +544,13 @@ class MedusaModule {
return services
}
public static async migrateUp(
moduleKey: string,
modulePath: string,
container?: MedusaContainer,
options?: Record<string, any>,
moduleExports?: ModuleExports
): Promise<void> {
public static async migrateGenerate({
options,
container,
moduleExports,
moduleKey,
modulePath,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
@@ -555,28 +563,31 @@ class MedusaModule {
allowUnregistered: true,
}) ?? logger
container ??= createMedusaContainer()
for (const mod in moduleResolutions) {
const [migrateUp] = await loadModuleMigrations(
const { generateMigration } = await loadModuleMigrations(
moduleResolutions[mod],
moduleExports
)
if (typeof migrateUp === "function") {
await migrateUp({
if (typeof generateMigration === "function") {
await generateMigration({
options,
container: container!,
logger: logger_,
})
}
}
}
public static async migrateDown(
moduleKey: string,
modulePath: string,
container?: MedusaContainer,
options?: Record<string, any>,
moduleExports?: ModuleExports
): Promise<void> {
public static async migrateUp({
options,
container,
moduleExports,
moduleKey,
modulePath,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
@@ -589,15 +600,55 @@ class MedusaModule {
allowUnregistered: true,
}) ?? logger
container ??= createMedusaContainer()
for (const mod in moduleResolutions) {
const [, migrateDown] = await loadModuleMigrations(
const { runMigrations } = await loadModuleMigrations(
moduleResolutions[mod],
moduleExports
)
if (typeof migrateDown === "function") {
await migrateDown({
if (typeof runMigrations === "function") {
await runMigrations({
options,
container: container!,
logger: logger_,
})
}
}
}
public static async migrateDown({
options,
container,
moduleExports,
moduleKey,
modulePath,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: modulePath,
options,
})
const logger_ =
container?.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? logger
container ??= createMedusaContainer()
for (const mod in moduleResolutions) {
const { revertMigration } = await loadModuleMigrations(
moduleResolutions[mod],
moduleExports
)
if (typeof revertMigration === "function") {
await revertMigration({
options,
container: container!,
logger: logger_,
})
}

View File

@@ -235,6 +235,10 @@ export type ModuleExports<T = Constructor<any>> = {
options: LoaderOptions<any>,
moduleDeclaration?: InternalModuleDeclaration
): Promise<void>
generateMigration?(
options: LoaderOptions<any>,
moduleDeclaration?: InternalModuleDeclaration
): Promise<void>
}
export interface ModuleServiceInitializeOptions {

View File

@@ -6,6 +6,8 @@ import {
} from "@mikro-orm/migrations"
import { MikroORM, MikroORMOptions } from "@mikro-orm/core"
import { PostgreSqlDriver } from "@mikro-orm/postgresql"
import { dirname } from "path"
import { access, mkdir, writeFile } from "fs/promises"
/**
* Events emitted by the migrations class
@@ -56,7 +58,9 @@ export class Migrations extends EventEmitter<MigrationsEvents> {
async generate(): Promise<MigrationResult> {
const connection = await this.#getConnection()
const migrator = connection.getMigrator()
try {
await this.ensureSnapshot(migrator["snapshotPath"])
return await migrator.createMigration()
} finally {
await connection.close(true)
@@ -134,4 +138,35 @@ export class Migrations extends EventEmitter<MigrationsEvents> {
await connection.close(true)
}
}
/**
* Generate a default snapshot file if it does not already exists. This
* prevent from creating a database to manage the migrations and instead
* rely on the snapshot.
*
* @param snapshotPath
* @protected
*/
protected async ensureSnapshot(snapshotPath: string): Promise<void> {
await mkdir(dirname(snapshotPath), { recursive: true })
const doesFileExists = await access(snapshotPath)
.then(() => true)
.catch(() => false)
if (doesFileExists) {
return
}
const emptySnapshotContent = JSON.stringify(
{
tables: [],
namespaces: [],
},
null,
2
)
await writeFile(snapshotPath, emptySnapshotContent, "utf-8")
}
}

View File

@@ -1,7 +1,6 @@
import { join } from "path"
import { setTimeout } from "timers/promises"
import { MetadataStorage } from "@mikro-orm/core"
import { createDatabase, dropDatabase } from "pg-god"
import { Migrations } from "../../index"
import { FileSystem } from "../../../common"
@@ -22,22 +21,12 @@ const pgGodCredentials = {
host: DB_HOST,
}
// TODO: Reenable once flakiness is taken care of
describe.skip("Generate migrations", () => {
describe("Generate migrations", () => {
beforeEach(async () => {
await dropDatabase(
{ databaseName: dbName, errorIfNonExist: false },
pgGodCredentials
)
await fs.cleanup()
await createDatabase({ databaseName: dbName }, pgGodCredentials)
})
afterEach(async () => {
await dropDatabase(
{ databaseName: dbName, errorIfNonExist: false },
pgGodCredentials
)
await fs.cleanup()
MetadataStorage.clear()
}, 300 * 1000)

View File

@@ -1,3 +1,4 @@
export * from "./migration-down"
export * from "./migration-up"
export * from "./migration-generate"
export * from "./seed"

View File

@@ -0,0 +1,63 @@
import { LoaderOptions, Logger, ModulesSdkTypes } from "@medusajs/types"
import { mikroOrmCreateConnection } from "../../dal"
import { loadDatabaseConfig } from "../load-module-database-config"
import { Migrations } from "../../migrations"
import { toMikroOrmEntities } from "../../dml"
const TERMINAL_SIZE = process.stdout.columns
/**
* Utility function to build a migration generation script that will generate the migrations.
* Only used in mikro orm based modules.
* @param moduleName
* @param models
* @param pathToMigrations
*/
export function buildGenerateMigrationScript({
moduleName,
models,
pathToMigrations,
}) {
/**
* This script is only valid for mikro orm managers. If a user provide a custom manager
* he is in charge of running the migrations.
* @param options
* @param logger
* @param moduleDeclaration
*/
return async function ({
options,
logger,
}: Pick<
LoaderOptions<ModulesSdkTypes.ModuleServiceInitializeOptions>,
"options" | "logger"
> = {}) {
logger ??= console as unknown as Logger
console.log(new Array(TERMINAL_SIZE).join("-"))
console.log("")
logger.info(`MODULE: ${moduleName}`)
const dbData = loadDatabaseConfig(moduleName, options)!
const normalizedModels = toMikroOrmEntities(models)
const orm = await mikroOrmCreateConnection(
dbData,
normalizedModels,
pathToMigrations
)
const migrations = new Migrations(orm)
try {
const { fileName } = await migrations.generate()
if (fileName) {
logger.info(`Generated successfully (${fileName}).`)
} else {
logger.info(`Skipped. No changes detected in your models.`)
}
} catch (error) {
logger.error(`Failed with error ${error.message}`, error)
}
}
}

View File

@@ -1,5 +1,5 @@
import Logger from "../loaders/logger"
import { migrateMedusaApp, revertMedusaApp } from "../loaders/medusa-app"
import { runMedusaAppMigrations } from "../loaders/medusa-app"
import { initializeContainer } from "../loaders"
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/utils"
import { getResolvedPlugins } from "../loaders/helpers/resolve-plugins"
@@ -7,13 +7,44 @@ import { resolvePluginsLinks } from "../loaders/helpers/resolve-plugins-links"
const TERMINAL_SIZE = process.stdout.columns
type Action = "run" | "revert" | "generate" | "show"
function validateInputArgs({
action,
modules,
}: {
action: Action
modules: string[]
}) {
const actionsRequiringModules = ["revert", "generate"]
if (modules.length && !actionsRequiringModules.includes(action)) {
Logger.error(
`<modules> cannot be specified with the "${action}" action. Please remove the <modules> argument and try again.`
)
process.exit(1)
}
if (!modules.length && actionsRequiringModules.includes(action)) {
Logger.error(
"Please provide the modules for which you want to revert migrations"
)
Logger.error(`For example: "npx medusa migration revert <moduleName>"`)
process.exit(1)
}
}
const main = async function ({ directory }) {
const args = process.argv
args.shift()
args.shift()
args.shift()
const action = args[0]
const action = args[0] as "run" | "revert" | "generate" | "show"
const modules = args.splice(1)
validateInputArgs({ action, modules })
const container = await initializeContainer(directory)
const configModule = container.resolve(
@@ -26,33 +57,26 @@ const main = async function ({ directory }) {
if (action === "run") {
Logger.info("Running migrations...")
await migrateMedusaApp({
await runMedusaAppMigrations({
configModule,
linkModules: pluginLinks,
container,
action: "run",
})
console.log(new Array(TERMINAL_SIZE).join("-"))
Logger.info("Migrations completed")
process.exit()
} else if (action === "revert") {
const modulesToRevert = args.slice(1)
if (!modulesToRevert.length) {
Logger.error(
"Please provide the modules for which you want to revert migrations"
)
Logger.error(`For example: "npx medusa migration revert <moduleName>"`)
process.exit(1)
}
Logger.info("Reverting migrations...")
try {
await revertMedusaApp({
modulesToRevert,
await runMedusaAppMigrations({
moduleNames: modules,
configModule,
linkModules: pluginLinks,
container,
action: "revert",
})
console.log(new Array(TERMINAL_SIZE).join("-"))
Logger.info("Migrations reverted")
@@ -70,6 +94,20 @@ const main = async function ({ directory }) {
}
process.exit(1)
}
} else if (action === "generate") {
Logger.info("Generating migrations...")
await runMedusaAppMigrations({
moduleNames: modules,
configModule,
linkModules: pluginLinks,
container,
action: "generate",
})
console.log(new Array(TERMINAL_SIZE).join("-"))
Logger.info("Migrations generated")
process.exit()
} else if (action === "show") {
Logger.info("Action not supported yet")
process.exit(0)

View File

@@ -126,11 +126,11 @@ export const runIsolatedModulesMigration = async (configModule) => {
continue
}
await MedusaModule.migrateUp(
moduleResolution.definition.key,
moduleResolution.resolutionPath,
moduleResolution.options
)
await MedusaModule.migrateUp({
moduleKey: moduleResolution.definition.key,
modulePath: moduleResolution.resolutionPath,
options: moduleResolution.options,
})
}
}
@@ -151,10 +151,10 @@ export const revertIsolatedModulesMigration = async (configModule) => {
continue
}
await MedusaModule.migrateDown(
moduleResolution.definition.key,
moduleResolution.resolutionPath,
moduleResolution.options
)
await MedusaModule.migrateDown({
moduleKey: moduleResolution.definition.key,
modulePath: moduleResolution.resolutionPath,
options: moduleResolution.options,
})
}
}

View File

@@ -1,6 +1,7 @@
import {
MedusaApp,
MedusaAppMigrateDown,
MedusaAppMigrateGenerate,
MedusaAppMigrateUp,
MedusaAppOptions,
MedusaAppOutput,
@@ -57,12 +58,21 @@ export function mergeDefaultModules(
return configModules
}
async function runMedusaAppMigrations({
/**
* Run, Revert or Generate the migrations for the medusa app.
*
* @param configModule
* @param container
* @param moduleNames
* @param linkModules
* @param action
*/
export async function runMedusaAppMigrations({
configModule,
container,
moduleNames,
revert = false,
linkModules,
action = "run",
}: {
configModule: {
modules?: CommonTypes.ConfigModule["modules"]
@@ -73,11 +83,11 @@ async function runMedusaAppMigrations({
} & (
| {
moduleNames?: never
revert: false
action: "run"
}
| {
moduleNames: string[]
revert: true
action: "revert" | "generate"
}
)): Promise<void> {
const injectedDependencies = {
@@ -101,82 +111,23 @@ async function runMedusaAppMigrations({
}
const configModules = mergeDefaultModules(configModule.modules)
if (revert) {
await MedusaAppMigrateDown(moduleNames!, {
modulesConfig: configModules,
sharedContainer: container,
linkModules,
sharedResourcesConfig,
injectedDependencies,
})
const migrationOptions = {
modulesConfig: configModules,
sharedContainer: container,
linkModules,
sharedResourcesConfig,
injectedDependencies,
}
if (action === "revert") {
await MedusaAppMigrateDown(moduleNames!, migrationOptions)
} else if (action === "run") {
await MedusaAppMigrateUp(migrationOptions)
} else {
await MedusaAppMigrateUp({
modulesConfig: configModules,
sharedContainer: container,
linkModules,
sharedResourcesConfig,
injectedDependencies,
})
await MedusaAppMigrateGenerate(moduleNames!, migrationOptions)
}
}
/**
*
* @param configModule The config module
* @param linkModules Custom links from the plugins
* @param container The medusa container
*/
export async function migrateMedusaApp({
configModule,
linkModules,
container,
}: {
configModule: {
modules?: CommonTypes.ConfigModule["modules"]
projectConfig: CommonTypes.ConfigModule["projectConfig"]
}
container: MedusaContainer
linkModules?: MedusaAppOptions["linkModules"]
}): Promise<void> {
await runMedusaAppMigrations({
configModule,
container,
linkModules,
revert: false,
})
}
/**
*
* @param modulesToRevert An array of modules for which you want to revert
* migrations
* @param configModule The config module
* @param linkModules Custom links from the plugins
* @param container The medusa container
*/
export async function revertMedusaApp({
modulesToRevert,
configModule,
linkModules,
container,
}: {
modulesToRevert: string[]
configModule: {
modules?: CommonTypes.ConfigModule["modules"]
projectConfig: CommonTypes.ConfigModule["projectConfig"]
}
container: MedusaContainer
linkModules?: MedusaAppOptions["linkModules"]
}): Promise<void> {
await runMedusaAppMigrations({
moduleNames: modulesToRevert,
configModule,
container,
revert: true,
linkModules,
})
}
export const loadMedusaApp = async (
{
container,