From f74fdcb6446aa9213f8c2454acbb4bab02c5d115 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 22 Jul 2024 09:42:23 +0200 Subject: [PATCH] breaking: rework how links database migrations are managed (#8162) --- .vscode/settings.json | 2 - .../modules/__tests__/link-modules/index.ts | 9 +- packages/cli/medusa-cli/cli.js | 2 +- packages/cli/medusa-cli/src/create-cli.ts | 17 + .../src/medusa-test-runner-utils/use-db.ts | 17 + packages/core/modules-sdk/src/medusa-app.ts | 107 ++-- .../core/modules-sdk/src/medusa-module.ts | 1 + packages/core/types/src/link-modules/index.ts | 49 +- .../core/types/src/link-modules/migrations.ts | 37 ++ .../core/types/src/link-modules/service.ts | 47 ++ .../core/utils/src/modules-sdk/define-link.ts | 2 +- .../src/modules-sdk/types/medusa-service.ts | 11 +- packages/medusa/package.json | 2 + packages/medusa/src/commands/links.ts | 176 +++++++ packages/medusa/src/commands/migrate.ts | 2 +- packages/medusa/src/loaders/medusa-app.ts | 55 +- .../__fixtures__/migrations.ts | 44 ++ .../__tests__/migrations.spec.ts | 152 ++++++ packages/modules/link-modules/jest.config.js | 7 + packages/modules/link-modules/package.json | 4 +- packages/modules/link-modules/src/index.ts | 1 + .../link-modules/src/initialize/index.ts | 52 +- .../link-modules/src/migration/index.ts | 468 ++++++++++++++---- yarn.lock | 90 +++- 24 files changed, 1090 insertions(+), 264 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 packages/core/types/src/link-modules/migrations.ts create mode 100644 packages/core/types/src/link-modules/service.ts create mode 100644 packages/medusa/src/commands/links.ts create mode 100644 packages/modules/link-modules/integration-tests/__fixtures__/migrations.ts create mode 100644 packages/modules/link-modules/integration-tests/__tests__/migrations.spec.ts diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7a73a41bfd..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} \ No newline at end of file diff --git a/integration-tests/modules/__tests__/link-modules/index.ts b/integration-tests/modules/__tests__/link-modules/index.ts index 96b550a7d1..f64c64a5e0 100644 --- a/integration-tests/modules/__tests__/link-modules/index.ts +++ b/integration-tests/modules/__tests__/link-modules/index.ts @@ -1,18 +1,16 @@ -import { initialize, runMigrations } from "@medusajs/link-modules" +import { getMigrationPlanner, initialize } from "@medusajs/link-modules" import { MedusaModule, ModuleJoinerConfig } from "@medusajs/modules-sdk" import { medusaIntegrationTestRunner } from "medusa-test-utils" jest.setTimeout(5000000) medusaIntegrationTestRunner({ - testSuite: ({ getContainer, dbConfig: { clientUrl } }) => { + testSuite: ({ dbConfig: { clientUrl } }) => { let DB_URL - let container let links beforeAll(async () => { DB_URL = clientUrl - container = getContainer() const linkDefinition: ModuleJoinerConfig[] = [ { @@ -73,7 +71,8 @@ medusaIntegrationTestRunner({ ] }) as any) - await runMigrations({ options: dbConfig }, linkDefinition) + const planner = getMigrationPlanner(dbConfig, linkDefinition) + await planner.executePlan(await planner.createPlan()) links = await initialize(dbConfig, linkDefinition) }) diff --git a/packages/cli/medusa-cli/cli.js b/packages/cli/medusa-cli/cli.js index 1156e8cd64..c838a76c43 100755 --- a/packages/cli/medusa-cli/cli.js +++ b/packages/cli/medusa-cli/cli.js @@ -1,6 +1,6 @@ #!/usr/bin/env node try { - require('ts-node').register({}) + require("ts-node").register({}) } catch {} require("dotenv").config() require("./dist/index.js") diff --git a/packages/cli/medusa-cli/src/create-cli.ts b/packages/cli/medusa-cli/src/create-cli.ts index 779e43ee4c..02790986a3 100644 --- a/packages/cli/medusa-cli/src/create-cli.ts +++ b/packages/cli/medusa-cli/src/create-cli.ts @@ -190,6 +190,23 @@ function buildLocalCommands(cli, isLocalProject) { }) ), }) + .command({ + command: `links [action]`, + desc: `Manage migrations for the links from the core, your project and packages`, + builder: { + action: { + demand: true, + description: "The action to perform on links", + choices: ["sync"], + }, + }, + handler: handlerP( + getCommandHandler(`links`, (args, cmd) => { + process.env.NODE_ENV = process.env.NODE_ENV || `development` + return cmd(args) + }) + ), + }) .command({ command: `develop`, desc: `Start development server. Watches file and rebuilds when something changes`, diff --git a/packages/core/medusa-test-utils/src/medusa-test-runner-utils/use-db.ts b/packages/core/medusa-test-utils/src/medusa-test-runner-utils/use-db.ts index 9f29260d65..f868a16df7 100644 --- a/packages/core/medusa-test-utils/src/medusa-test-runner-utils/use-db.ts +++ b/packages/core/medusa-test-utils/src/medusa-test-runner-utils/use-db.ts @@ -40,8 +40,25 @@ export async function initDb({ try { const { runMedusaAppMigrations, + getLinksExecutionPlanner, } = require("@medusajs/medusa/dist/loaders/medusa-app") await runMedusaAppMigrations({ configModule, container }) + const planner = await getLinksExecutionPlanner({ + configModule, + container, + }) + + const actionPlan = await planner.createPlan() + await planner.executePlan(actionPlan) + + /** + * cleanup temporary created resources for the migrations + * @internal I didnt find a god place to put that, should we eventually add a close function + * to the planner to handle that part? so that you would do planner.close() and it will handle the cleanup + * automatically just like we usually do for the classic migrations actions + */ + const { MedusaModule } = require("@medusajs/modules-sdk") + MedusaModule.clearInstances() } catch (err) { console.error("Something went wrong while running the migrations") throw err diff --git a/packages/core/modules-sdk/src/medusa-app.ts b/packages/core/modules-sdk/src/medusa-app.ts index f0663be82e..8f179c257e 100644 --- a/packages/core/modules-sdk/src/medusa-app.ts +++ b/packages/core/modules-sdk/src/medusa-app.ts @@ -1,9 +1,10 @@ import { mergeTypeDefs } from "@graphql-tools/merge" import { makeExecutableSchema } from "@graphql-tools/schema" import { RemoteFetchDataCallback } from "@medusajs/orchestration" -import type { +import { ConfigModule, ExternalModuleDeclaration, + ILinkMigrationsPlanner, InternalModuleDeclaration, LoadedModule, Logger, @@ -38,8 +39,8 @@ import { } from "./medusa-module" import { RemoteLink } from "./remote-link" import { RemoteQuery } from "./remote-query" -import { MODULE_RESOURCE_TYPE, MODULE_SCOPE } from "./types" import { cleanGraphQLSchema } from "./utils" +import { MODULE_RESOURCE_TYPE, MODULE_SCOPE } from "./types" const LinkModulePackage = MODULE_PACKAGE_NAMES[Modules.LINK] @@ -56,6 +57,7 @@ declare module "@medusajs/types" { export type RunMigrationFn = () => Promise export type RevertMigrationFn = (moduleNames: string[]) => Promise export type GenerateMigrations = (moduleNames: string[]) => Promise +export type GetLinkExecutionPlanner = () => ILinkMigrationsPlanner export type MedusaModuleConfig = { [key: string | Modules]: @@ -164,7 +166,7 @@ async function initializeLinks({ moduleExports, }) { try { - const { initialize, runMigrations, revertMigrations } = + const { initialize, getMigrationPlanner } = moduleExports ?? (await import(LinkModulePackage)) const linkResolution = await initialize( @@ -176,16 +178,14 @@ async function initializeLinks({ return { remoteLink: new RemoteLink(), linkResolution, - runMigrations, - revertMigrations, + getMigrationPlanner, } } catch (err) { console.warn("Error initializing link modules.", err) return { remoteLink: undefined, linkResolution: undefined, - runMigrations: () => void 0, - revertMigrations: () => void 0, + getMigrationPlanner: () => void 0, } } } @@ -231,6 +231,7 @@ export type MedusaAppOutput = { runMigrations: RunMigrationFn revertMigrations: RevertMigrationFn generateMigrations: GenerateMigrations + linkMigrationExecutionPlanner: GetLinkExecutionPlanner onApplicationShutdown: () => Promise onApplicationPrepareShutdown: () => Promise onApplicationStart: () => Promise @@ -369,6 +370,11 @@ async function MedusaApp_({ generateMigrations: async () => { throw new Error("Generate migrations not allowed in loaderOnly mode") }, + linkMigrationExecutionPlanner: () => { + throw new Error( + "Migrations planner is not avaibable in loaderOnly mode" + ) + }, } } @@ -392,11 +398,7 @@ async function MedusaApp_({ } } - const { - remoteLink, - runMigrations: linkModuleMigration, - revertMigrations: revertLinkModuleMigration, - } = await initializeLinks({ + const { remoteLink, getMigrationPlanner } = await initializeLinks({ config: linkModuleOrOptions, linkModules, injectedDependencies, @@ -479,7 +481,27 @@ async function MedusaApp_({ await applyMigration({ modulesNames: Object.keys(allModules), }) + } + const revertMigrations: RevertMigrationFn = async ( + modulesNames + ): Promise => { + await applyMigration({ + modulesNames, + action: "revert", + }) + } + + const generateMigrations: GenerateMigrations = async ( + modulesNames + ): Promise => { + await applyMigration({ + modulesNames, + action: "generate", + }) + } + + const getMigrationPlannerFn = () => { const options: Partial = "scope" in linkModuleOrOptions ? { ...linkModuleOrOptions.options } @@ -491,52 +513,7 @@ async function MedusaApp_({ ...sharedResourcesConfig?.database, } - await linkModuleMigration( - { - options, - injectedDependencies, - }, - linkModules - ) - } - - const revertMigrations: RevertMigrationFn = async ( - modulesNames - ): Promise => { - await applyMigration({ - modulesNames, - action: "revert", - }) - - // TODO: Temporarely disabling this part until we discussed a more appropriate approach to sync the link - // Currently it would revert all link as soon as the revert is run - /*const options: Partial = - "scope" in linkModuleOrOptions - ? { ...linkModuleOrOptions.options } - : { - ...(linkModuleOrOptions as Partial), - } - - options.database ??= { - ...sharedResourcesConfig?.database, - } - - await revertLinkModuleMigration( - { - options, - injectedDependencies, - }, - linkModules - )*/ - } - - const generateMigrations: GenerateMigrations = async ( - modulesNames - ): Promise => { - await applyMigration({ - modulesNames, - action: "generate", - }) + return getMigrationPlanner(options, linkModules) } return { @@ -551,6 +528,7 @@ async function MedusaApp_({ runMigrations, revertMigrations, generateMigrations, + linkMigrationExecutionPlanner: getMigrationPlannerFn, sharedContainer: sharedContainer_, } } @@ -601,3 +579,16 @@ export async function MedusaAppMigrateGenerate( await generateMigrations(moduleNames).finally(MedusaModule.clearInstances) } + +export async function MedusaAppGetLinksExecutionPlanner( + options: MedusaAppOptions = {} +): Promise { + const migrationOnly = true + + const { linkMigrationExecutionPlanner } = await MedusaApp_({ + ...options, + migrationOnly, + }) + + return linkMigrationExecutionPlanner() +} diff --git a/packages/core/modules-sdk/src/medusa-module.ts b/packages/core/modules-sdk/src/medusa-module.ts index bb4091e56f..c0833f2888 100644 --- a/packages/core/modules-sdk/src/medusa-module.ts +++ b/packages/core/modules-sdk/src/medusa-module.ts @@ -170,6 +170,7 @@ class MedusaModule { MedusaModule.modules_.clear() MedusaModule.joinerConfig_.clear() MedusaModule.moduleResolutions_.clear() + MedusaModule.customLinks_.length = 0 } public static isInstalled(moduleKey: string, alias?: string): boolean { diff --git a/packages/core/types/src/link-modules/index.ts b/packages/core/types/src/link-modules/index.ts index cc97a4b1e6..3fffd9000b 100644 --- a/packages/core/types/src/link-modules/index.ts +++ b/packages/core/types/src/link-modules/index.ts @@ -1,47 +1,2 @@ -import { FindConfig } from "../common" -import { RestoreReturn, SoftDeleteReturn } from "../dal" -import { IModuleService } from "../modules-sdk" -import { Context } from "../shared-context" - -export interface ILinkModule extends IModuleService { - list( - filters?: Record, - config?: FindConfig, - sharedContext?: Context - ): Promise - - listAndCount( - filters?: Record, - config?: FindConfig, - sharedContext?: Context - ): Promise<[unknown[], number]> - - create( - primaryKeyOrBulkData: - | string - | string[] - | [string | string[], string, Record?][], - foreignKeyData?: string, - sharedContext?: Context - ): Promise - - dismiss( - primaryKeyOrBulkData: string | string[] | [string | string[], string][], - foreignKeyData?: string, - sharedContext?: Context - ): Promise - - delete(data: unknown | unknown[], sharedContext?: Context): Promise - - softDelete( - data: unknown | unknown[], - config?: SoftDeleteReturn, - sharedContext?: Context - ): Promise | void> - - restore( - data: unknown | unknown[], - config?: RestoreReturn, - sharedContext?: Context - ): Promise | void> -} +export * from "./service" +export * from "./migrations" diff --git a/packages/core/types/src/link-modules/migrations.ts b/packages/core/types/src/link-modules/migrations.ts new file mode 100644 index 0000000000..d80e38fd64 --- /dev/null +++ b/packages/core/types/src/link-modules/migrations.ts @@ -0,0 +1,37 @@ +/** + * Link descriptor containing metadata about the link's + * modules and models. + */ +export type PlannerActionLinkDescriptor = { + fromModule: string + toModule: string + fromModel: string + toModel: string +} + +/** + * A list of actions prepared and executed by + * the "ILinkMigrationsPlanner". + */ +export type LinkMigrationsPlannerAction = + | { + action: "create" | "update" | "notify" + linkDescriptor: PlannerActionLinkDescriptor + sql: string + tableName: string + } + | { + action: "noop" + tableName: string + linkDescriptor: PlannerActionLinkDescriptor + } + | { + action: "delete" + linkDescriptor: PlannerActionLinkDescriptor + tableName: string + } + +export interface ILinkMigrationsPlanner { + createPlan(): Promise + executePlan(actions: LinkMigrationsPlannerAction[]): Promise +} diff --git a/packages/core/types/src/link-modules/service.ts b/packages/core/types/src/link-modules/service.ts new file mode 100644 index 0000000000..cc97a4b1e6 --- /dev/null +++ b/packages/core/types/src/link-modules/service.ts @@ -0,0 +1,47 @@ +import { FindConfig } from "../common" +import { RestoreReturn, SoftDeleteReturn } from "../dal" +import { IModuleService } from "../modules-sdk" +import { Context } from "../shared-context" + +export interface ILinkModule extends IModuleService { + list( + filters?: Record, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCount( + filters?: Record, + config?: FindConfig, + sharedContext?: Context + ): Promise<[unknown[], number]> + + create( + primaryKeyOrBulkData: + | string + | string[] + | [string | string[], string, Record?][], + foreignKeyData?: string, + sharedContext?: Context + ): Promise + + dismiss( + primaryKeyOrBulkData: string | string[] | [string | string[], string][], + foreignKeyData?: string, + sharedContext?: Context + ): Promise + + delete(data: unknown | unknown[], sharedContext?: Context): Promise + + softDelete( + data: unknown | unknown[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + restore( + data: unknown | unknown[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> +} diff --git a/packages/core/utils/src/modules-sdk/define-link.ts b/packages/core/utils/src/modules-sdk/define-link.ts index 2c046f0412..62e6a8f73a 100644 --- a/packages/core/utils/src/modules-sdk/define-link.ts +++ b/packages/core/utils/src/modules-sdk/define-link.ts @@ -34,7 +34,7 @@ type ExtraOptions = { [key: string]: string } database?: { - table: string + table?: string idPrefix?: string extraColumns?: LinkModulesExtraFields } diff --git a/packages/core/utils/src/modules-sdk/types/medusa-service.ts b/packages/core/utils/src/modules-sdk/types/medusa-service.ts index 3b0451765f..2f76cc26ac 100644 --- a/packages/core/utils/src/modules-sdk/types/medusa-service.ts +++ b/packages/core/utils/src/modules-sdk/types/medusa-service.ts @@ -41,7 +41,7 @@ export type ModelsConfigTemplate = { [key: string]: ModelDTOConfig } export type ModelConfigurationsToConfigTemplate = { [Key in keyof T]: { - dto: T[Key] extends IDmlEntity + dto: T[Key] extends DmlEntity ? InferEntityType : T[Key] extends Constructor ? InstanceType @@ -261,7 +261,8 @@ type InferModelFromConfig = { : never } -export type MedusaServiceReturnType> = { - new (...args: any[]): AbstractModuleService - $modelObjects: InferModelFromConfig -} +export type MedusaServiceReturnType> = + { + new (...args: any[]): AbstractModuleService + $modelObjects: InferModelFromConfig + } diff --git a/packages/medusa/package.json b/packages/medusa/package.json index f04bfcc17d..1574ade71f 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -42,6 +42,7 @@ "test": "jest --silent --bail --maxWorkers=50% --forceExit" }, "dependencies": { + "@inquirer/checkbox": "^2.3.11", "@medusajs/admin-sdk": "0.0.1", "@medusajs/core-flows": "^0.0.9", "@medusajs/link-modules": "^0.2.11", @@ -55,6 +56,7 @@ "awilix": "^8.0.0", "body-parser": "^1.19.0", "boxen": "^5.0.1", + "chalk": "^4.0.0", "chokidar": "^3.4.2", "compression": "^1.7.4", "connect-redis": "^5.0.0", diff --git a/packages/medusa/src/commands/links.ts b/packages/medusa/src/commands/links.ts new file mode 100644 index 0000000000..f26a09b54f --- /dev/null +++ b/packages/medusa/src/commands/links.ts @@ -0,0 +1,176 @@ +import boxen from "boxen" +import chalk from "chalk" +import checkbox from "@inquirer/checkbox" + +import Logger from "../loaders/logger" +import { initializeContainer } from "../loaders" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { getResolvedPlugins } from "../loaders/helpers/resolve-plugins" +import { resolvePluginsLinks } from "../loaders/helpers/resolve-plugins-links" +import { getLinksExecutionPlanner } from "../loaders/medusa-app" +import { LinkMigrationsPlannerAction } from "@medusajs/types" + +type Action = "sync" + +/** + * Groups action tables by their "action" property + * @param actionPlan LinkMigrationsPlannerAction + */ +function groupByActionPlan(actionPlan: LinkMigrationsPlannerAction[]) { + return actionPlan.reduce((acc, action) => { + acc[action.action] ??= [] + acc[action.action].push(action) + return acc + }, {} as Record<"noop" | "notify" | "create" | "update" | "delete", LinkMigrationsPlannerAction[]>) +} + +/** + * Creates the link description for printing it to the + * console + * + * @param action: LinkMigrationsPlannerAction + */ +function buildLinkDescription(action: LinkMigrationsPlannerAction) { + const { linkDescriptor } = action + const from = chalk.yellow( + `${linkDescriptor.fromModule}.${linkDescriptor.fromModel}` + ) + const to = chalk.yellow( + `${linkDescriptor.toModule}.${linkDescriptor.toModel}` + ) + const table = chalk.dim(`(${action.tableName})`) + + return `${from} <> ${to} ${table}` +} + +/** + * Logs the actions of a given action type with a nice border and + * a title + */ +function logActions( + title: string, + actionsOrContext: LinkMigrationsPlannerAction[] +) { + const actionsList = actionsOrContext + .map((action) => ` - ${buildLinkDescription(action)}`) + .join("\n") + + console.log(boxen(`${title}\n${actionsList}`, { padding: 1 })) +} + +/** + * Displays a prompt to select tables that must be impacted with + * action + */ +async function askForLinkActionsToPerform( + message: string, + actions: LinkMigrationsPlannerAction[] +) { + console.log(boxen(message, { borderColor: "red", padding: 1 })) + + return await checkbox({ + message: "Select tables to act upon", + instructions: chalk.dim( + " select, select all, inverse, submit" + ), + choices: actions.map((action) => { + return { + name: buildLinkDescription(action), + value: action, + checked: false, + } + }), + }) +} + +const main = async function ({ directory }) { + const args = process.argv + args.shift() + args.shift() + args.shift() + + const action = args[0] as Action + + if (action !== "sync") { + return process.exit() + } + + try { + const container = await initializeContainer(directory) + + const configModule = container.resolve( + ContainerRegistrationKeys.CONFIG_MODULE + ) + + const plugins = getResolvedPlugins(directory, configModule, true) || [] + const pluginLinks = await resolvePluginsLinks(plugins, container) + + const planner = await getLinksExecutionPlanner({ + configModule, + linkModules: pluginLinks, + container, + }) + + Logger.info("Syncing links...") + + const actionPlan = await planner.createPlan() + const groupActionPlan = groupByActionPlan(actionPlan) + + if (groupActionPlan.delete?.length) { + groupActionPlan.delete = await askForLinkActionsToPerform( + `Select the tables to ${chalk.red( + "DELETE" + )}. The following links have been removed`, + groupActionPlan.delete + ) + } + + if (groupActionPlan.notify?.length) { + const answer = await askForLinkActionsToPerform( + `Select the tables to ${chalk.red( + "UPDATE" + )}. The following links have been updated`, + groupActionPlan.notify + ) + + groupActionPlan.update ??= [] + groupActionPlan.update.push( + ...answer.map((action) => { + return { + ...action, + action: "update", + } as LinkMigrationsPlannerAction + }) + ) + } + + const toCreate = groupActionPlan.create ?? [] + const toUpdate = groupActionPlan.update ?? [] + const toDelete = groupActionPlan.delete ?? [] + const actionsToExecute = [...toCreate, ...toUpdate, ...toDelete] + + await planner.executePlan(actionsToExecute) + + if (toCreate.length) { + logActions("Created following links tables", toCreate) + } + if (toUpdate.length) { + logActions("Updated following links tables", toUpdate) + } + if (toDelete.length) { + logActions("Deleted following links tables", toDelete) + } + + if (actionsToExecute.length) { + Logger.info("Links sync completed") + } else { + Logger.info("Database already up-to-date") + } + process.exit() + } catch (e) { + Logger.error(e) + process.exit(1) + } +} + +export default main diff --git a/packages/medusa/src/commands/migrate.ts b/packages/medusa/src/commands/migrate.ts index 41f147e675..eb48072aec 100644 --- a/packages/medusa/src/commands/migrate.ts +++ b/packages/medusa/src/commands/migrate.ts @@ -40,7 +40,7 @@ const main = async function ({ directory }) { args.shift() args.shift() - const action = args[0] as "run" | "revert" | "generate" | "show" + const action = args[0] as Action const modules = args.splice(1) validateInputArgs({ action, modules }) diff --git a/packages/medusa/src/loaders/medusa-app.ts b/packages/medusa/src/loaders/medusa-app.ts index 9d2ce7f234..ffee5b7e08 100644 --- a/packages/medusa/src/loaders/medusa-app.ts +++ b/packages/medusa/src/loaders/medusa-app.ts @@ -1,5 +1,6 @@ import { MedusaApp, + MedusaAppGetLinksExecutionPlanner, MedusaAppMigrateDown, MedusaAppMigrateGenerate, MedusaAppMigrateUp, @@ -10,6 +11,7 @@ import { import { CommonTypes, ConfigModule, + ILinkMigrationsPlanner, InternalModuleDeclaration, LoadedModule, MedusaContainer, @@ -134,6 +136,57 @@ export async function runMedusaAppMigrations({ } } +/** + * Return an instance of the link module migration planner. + * + * @param configModule + * @param container + * @param linkModules + */ +export async function getLinksExecutionPlanner({ + configModule, + container, + linkModules, +}: { + configModule: { + modules?: CommonTypes.ConfigModule["modules"] + projectConfig: CommonTypes.ConfigModule["projectConfig"] + } + linkModules?: MedusaAppOptions["linkModules"] + container: MedusaContainer +}): Promise { + const injectedDependencies = { + [ContainerRegistrationKeys.PG_CONNECTION]: container.resolve( + ContainerRegistrationKeys.PG_CONNECTION + ), + [ContainerRegistrationKeys.LOGGER]: container.resolve( + ContainerRegistrationKeys.LOGGER + ), + } + + const sharedResourcesConfig = { + database: { + clientUrl: + injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]?.client + ?.config?.connection?.connectionString ?? + configModule.projectConfig.databaseUrl, + driverOptions: configModule.projectConfig.databaseDriverOptions, + debug: !!(configModule.projectConfig.databaseLogging ?? false), + }, + } + const configModules = mergeDefaultModules(configModule.modules) + + const migrationOptions = { + modulesConfig: configModules, + sharedContainer: container, + linkModules, + sharedResourcesConfig, + injectedDependencies, + } + + return await MedusaAppGetLinksExecutionPlanner(migrationOptions) +} + export const loadMedusaApp = async ( { container, @@ -200,7 +253,7 @@ export const loadMedusaApp = async ( ) } - // Register all unresolved modules as undefined to be present in the container with undefined value by defaul + // Register all unresolved modules as undefined to be present in the container with undefined value by default // but still resolvable for (const moduleDefinition of Object.values(ModulesDefinition)) { if (!container.hasRegistration(moduleDefinition.registrationName)) { diff --git a/packages/modules/link-modules/integration-tests/__fixtures__/migrations.ts b/packages/modules/link-modules/integration-tests/__fixtures__/migrations.ts new file mode 100644 index 0000000000..cc911bd43d --- /dev/null +++ b/packages/modules/link-modules/integration-tests/__fixtures__/migrations.ts @@ -0,0 +1,44 @@ +import { + defineJoinerConfig, + MedusaService, + model, + Module, +} from "@medusajs/utils" + +export const User = model.define("user", { + id: model.id().primaryKey(), + name: model.text(), +}) + +export const Car = model.define("car", { + id: model.id().primaryKey(), + name: model.text(), +}) + +export const userJoinerConfig = defineJoinerConfig("User", { + models: [User], +}) + +export const carJoinerConfig = defineJoinerConfig("Car", { + models: [Car], +}) + +export class UserService extends MedusaService({ User }) { + constructor() { + super(...arguments) + } +} + +export class CarService extends MedusaService({ Car }) { + constructor() { + super(...arguments) + } +} + +export const UserModule = Module("User", { + service: UserService, +}) + +export const CarModule = Module("Car", { + service: CarService, +}) diff --git a/packages/modules/link-modules/integration-tests/__tests__/migrations.spec.ts b/packages/modules/link-modules/integration-tests/__tests__/migrations.spec.ts new file mode 100644 index 0000000000..ea5a2bc1ec --- /dev/null +++ b/packages/modules/link-modules/integration-tests/__tests__/migrations.spec.ts @@ -0,0 +1,152 @@ +import { MigrationsExecutionPlanner } from "../../src" +import { MedusaModule, ModuleJoinerConfig } from "@medusajs/modules-sdk" +import { + Car, + carJoinerConfig, + CarModule, + User, + userJoinerConfig, + UserModule, +} from "../__fixtures__/migrations" +import { defineLink, isObject, Modules } from "@medusajs/utils" +import { moduleIntegrationTestRunner } from "medusa-test-utils" +import { ILinkModule } from "@medusajs/types" + +jest.setTimeout(30000) + +MedusaModule.setJoinerConfig(userJoinerConfig.serviceName, userJoinerConfig) +MedusaModule.setJoinerConfig(carJoinerConfig.serviceName, carJoinerConfig) + +moduleIntegrationTestRunner({ + moduleName: Modules.LINK, + moduleModels: [User, Car], + testSuite: ({ dbConfig }) => { + describe("MigrationsExecutionPlanner", () => { + test("should generate an execution plan", async () => { + defineLink(UserModule.linkable.user, CarModule.linkable.car) + + MedusaModule.getCustomLinks().forEach((linkDefinition: any) => { + MedusaModule.setCustomLink( + linkDefinition(MedusaModule.getAllJoinerConfigs()) + ) + }) + + /** + * Expect a create plan + */ + + let joinerConfigs = MedusaModule.getCustomLinks().filter( + (link): link is ModuleJoinerConfig => isObject(link) + ) + + let planner = new MigrationsExecutionPlanner(joinerConfigs, { + database: dbConfig, + }) + + let actionPlan = await planner.createPlan() + await planner.executePlan(actionPlan) + + expect(actionPlan).toHaveLength(1) + expect(actionPlan[0]).toEqual({ + action: "create", + linkDescriptor: { + fromModule: "User", + toModule: "Car", + fromModel: "user", + toModel: "car", + }, + tableName: "User_user_Car_car", + sql: 'create table "User_user_Car_car" ("user_id" varchar(255) not null, "car_id" varchar(255) not null, "id" varchar(255) not null, "created_at" timestamptz(0) not null default CURRENT_TIMESTAMP, "updated_at" timestamptz(0) not null default CURRENT_TIMESTAMP, "deleted_at" timestamptz(0) null, constraint "User_user_Car_car_pkey" primary key ("user_id", "car_id"));\ncreate index "IDX_car_id_-8c9667b4" on "User_user_Car_car" ("car_id");\ncreate index "IDX_id_-8c9667b4" on "User_user_Car_car" ("id");\ncreate index "IDX_user_id_-8c9667b4" on "User_user_Car_car" ("user_id");\ncreate index "IDX_deleted_at_-8c9667b4" on "User_user_Car_car" ("deleted_at");\n\n', + }) + + /** + * Expect an update plan + */ + ;(MedusaModule as any).customLinks_.length = 0 + + defineLink(UserModule.linkable.user, CarModule.linkable.car, { + database: { + extraColumns: { + data: { + type: "json", + }, + }, + }, + }) + + MedusaModule.getCustomLinks().forEach((linkDefinition: any) => { + MedusaModule.setCustomLink( + linkDefinition(MedusaModule.getAllJoinerConfigs()) + ) + }) + + joinerConfigs = MedusaModule.getCustomLinks().filter( + (link): link is ModuleJoinerConfig => isObject(link) + ) + + planner = new MigrationsExecutionPlanner(joinerConfigs, { + database: dbConfig, + }) + + actionPlan = await planner.createPlan() + await planner.executePlan(actionPlan) + + expect(actionPlan).toHaveLength(1) + expect(actionPlan[0]).toEqual({ + action: "update", + linkDescriptor: { + fromModule: "User", + toModule: "Car", + fromModel: "user", + toModel: "car", + }, + tableName: "User_user_Car_car", + sql: 'alter table "User_user_Car_car" add column "data" jsonb not null;\n\n', + }) + + /** + * Expect a noop plan + */ + + actionPlan = await planner.createPlan() + await planner.executePlan(actionPlan) + + expect(actionPlan).toHaveLength(1) + expect(actionPlan[0]).toEqual({ + action: "noop", + linkDescriptor: { + fromModule: "User", + toModule: "Car", + fromModel: "user", + toModel: "car", + }, + tableName: "User_user_Car_car", + }) + + /** + * Expect a delete plan + */ + + joinerConfigs = [] + + planner = new MigrationsExecutionPlanner(joinerConfigs, { + database: dbConfig, + }) + + actionPlan = await planner.createPlan() + + expect(actionPlan).toHaveLength(1) + expect(actionPlan[0]).toEqual({ + action: "delete", + tableName: "User_user_Car_car", + linkDescriptor: { + toModel: "car", + toModule: "Car", + fromModel: "user", + fromModule: "User", + }, + }) + }) + }) + }, +}) diff --git a/packages/modules/link-modules/jest.config.js b/packages/modules/link-modules/jest.config.js index 3671181174..d125300e58 100644 --- a/packages/modules/link-modules/jest.config.js +++ b/packages/modules/link-modules/jest.config.js @@ -1,4 +1,10 @@ module.exports = { + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + }, transform: { "^.+\\.[jt]s$": [ "@swc/jest", @@ -12,4 +18,5 @@ module.exports = { }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], + modulePathIgnorePatterns: ["dist/"], } diff --git a/packages/modules/link-modules/package.json b/packages/modules/link-modules/package.json index 11fe220c40..41bf6d18c6 100644 --- a/packages/modules/link-modules/package.json +++ b/packages/modules/link-modules/package.json @@ -26,7 +26,7 @@ "prepare": "cross-env NODE_ENV=production yarn run build", "build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json", "test": "jest --passWithNoTests --runInBand --bail --forceExit -- src", - "test:integration": "jest --passWithNoTests" + "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts" }, "devDependencies": { "@medusajs/types": "^1.11.16", @@ -36,7 +36,7 @@ "rimraf": "^5.0.1", "ts-node": "^10.9.1", "tsc-alias": "^1.8.6", - "typescript": "^5.1.6" + "typescript": "^5.5.0" }, "dependencies": { "@medusajs/modules-sdk": "^1.12.11", diff --git a/packages/modules/link-modules/src/index.ts b/packages/modules/link-modules/src/index.ts index 4bbfce7a57..0dc1db4d6f 100644 --- a/packages/modules/link-modules/src/index.ts +++ b/packages/modules/link-modules/src/index.ts @@ -1,4 +1,5 @@ export * from "./initialize" +export * from "./migration" export * from "./types" export * from "./loaders" export * from "./services" diff --git a/packages/modules/link-modules/src/initialize/index.ts b/packages/modules/link-modules/src/initialize/index.ts index a3ae4ff458..b2bf09be72 100644 --- a/packages/modules/link-modules/src/initialize/index.ts +++ b/packages/modules/link-modules/src/initialize/index.ts @@ -8,7 +8,6 @@ import { ExternalModuleDeclaration, ILinkModule, LinkModuleDefinition, - LoaderOptions, ModuleExports, ModuleJoinerConfig, ModuleServiceInitializeCustomDataLayerOptions, @@ -23,7 +22,7 @@ import { toPascalCase, } from "@medusajs/utils" import * as linkDefinitions from "../definitions" -import { getMigration, getRevertMigration } from "../migration" +import { MigrationsExecutionPlanner } from "../migration" import { InitializeModuleInjectableDependencies } from "../types" import { composeLinkName, @@ -38,7 +37,7 @@ export const initialize = async ( | ModuleServiceInitializeCustomDataLayerOptions | ExternalModuleDeclaration | InternalModuleDeclaration, - modulesDefinition?: ModuleJoinerConfig[], + pluginLinksDefinitions?: ModuleJoinerConfig[], injectedDependencies?: InitializeModuleInjectableDependencies ): Promise<{ [link: string]: ILinkModule }> => { const allLinks = {} @@ -47,7 +46,7 @@ export const initialize = async ( ) const allLinksToLoad = Object.values(linkDefinitions).concat( - modulesDefinition ?? [] + pluginLinksDefinitions ?? [] ) for (const linkDefinition of allLinksToLoad) { @@ -186,20 +185,24 @@ export const initialize = async ( return allLinks } -async function applyMigrationUpOrDown( - { - options, - logger, - }: Omit, "container">, - modulesDefinition?: ModuleJoinerConfig[], - revert = false +/** + * Prepare an execution plan and run the migrations accordingly. + * It includes creating, updating, deleting the tables according to the execution plan. + * If any unsafe sql is identified then we will notify the user to act manually. + * + * @param options + * @param pluginLinksDefinition + */ +export function getMigrationPlanner( + options: ModuleServiceInitializeOptions, + pluginLinksDefinition?: ModuleJoinerConfig[] ) { const modulesLoadedKeys = MedusaModule.getLoadedModules().map( (mod) => Object.keys(mod)[0] ) const allLinksToLoad = Object.values(linkDefinitions).concat( - modulesDefinition ?? [] + pluginLinksDefinition ?? [] ) const allLinks = new Set() @@ -237,30 +240,7 @@ async function applyMigrationUpOrDown( ) { continue } - - const migrate = revert - ? getRevertMigration(definition, serviceKey, primary, foreign) - : getMigration(definition, serviceKey, primary, foreign) - await migrate({ options, logger }) } -} -export async function runMigrations( - { - options, - logger, - }: Omit, "container">, - modulesDefinition?: ModuleJoinerConfig[] -) { - await applyMigrationUpOrDown({ options, logger }, modulesDefinition) -} - -export async function revertMigrations( - { - options, - logger, - }: Omit, "container">, - modulesDefinition?: ModuleJoinerConfig[] -) { - await applyMigrationUpOrDown({ options, logger }, modulesDefinition, true) + return new MigrationsExecutionPlanner(allLinksToLoad, options) } diff --git a/packages/modules/link-modules/src/migration/index.ts b/packages/modules/link-modules/src/migration/index.ts index 076ffe951d..8f32c0b91e 100644 --- a/packages/modules/link-modules/src/migration/index.ts +++ b/packages/modules/link-modules/src/migration/index.ts @@ -1,134 +1,394 @@ import { - JoinerRelationship, - LoaderOptions, - Logger, + ILinkMigrationsPlanner, + LinkMigrationsPlannerAction, ModuleJoinerConfig, ModuleServiceInitializeOptions, + PlannerActionLinkDescriptor, } from "@medusajs/types" + import { generateEntity } from "../utils" +import { EntitySchema, MikroORM } from "@mikro-orm/core" +import { DatabaseSchema, PostgreSqlDriver } from "@mikro-orm/postgresql" +import { + arrayDifference, + DALUtils, + ModulesSdkUtils, + promiseAll, +} from "@medusajs/utils" -import { DALUtils, ModulesSdkUtils } from "@medusajs/utils" +/** + * The migrations execution planner creates a plan of SQL queries + * to be executed to keep link modules database state in sync + * with the links defined inside the user application. + */ +export class MigrationsExecutionPlanner implements ILinkMigrationsPlanner { + /** + * Database options for the module service + */ + #dbConfig: ReturnType -export function getMigration( - joinerConfig: ModuleJoinerConfig, - serviceName: string, - primary: JoinerRelationship, - foreign: JoinerRelationship -) { - return async function runMigrations( - { - options, - logger, - }: Pick< - LoaderOptions, - "options" | "logger" - > = {} as any + /** + * The set of commands that are unsafe to execute automatically when + * performing "alter table" + */ + #unsafeSQLCommands = ["alter column", "drop column"] + + /** + * On-the-fly computed set of entities for the user provided joinerConfig and the link it is coming from + */ + #linksEntities: { + linkDescriptor: PlannerActionLinkDescriptor + entity: EntitySchema + }[] + + /** + * The table that keeps a track of tables generated by the link + * module. + */ + protected tableName = "link_module_migrations" + + constructor( + joinerConfig: ModuleJoinerConfig[], + options?: ModuleServiceInitializeOptions ) { - logger ??= console as unknown as Logger + this.#dbConfig = ModulesSdkUtils.loadDatabaseConfig("link_modules", options) + this.#linksEntities = joinerConfig + .map((config) => { + if (config.isReadOnlyLink) { + return + } - const dbData = ModulesSdkUtils.loadDatabaseConfig("link_modules", options) - const entity = generateEntity(joinerConfig, primary, foreign) - const pathToMigrations = __dirname + "/../migrations" + const [primary, foreign] = config.relationships ?? [] + const linkDescriptor: PlannerActionLinkDescriptor = { + fromModule: primary.serviceName, + toModule: foreign.serviceName, + fromModel: primary.alias, + toModel: foreign.alias, + } - const orm = await DALUtils.mikroOrmCreateConnection( - dbData, - [entity], - pathToMigrations - ) + return { + entity: generateEntity(config, primary, foreign), + linkDescriptor, + } + }) + .filter((item) => !!item) + } - const tableName = entity.meta.collection + /** + * Initializes the ORM using the normalized dbConfig and set + * of provided entities + */ + protected async createORM(entities: EntitySchema[] = []) { + return await DALUtils.mikroOrmCreateConnection(this.#dbConfig, entities, "") + } - let hasTable = false - try { + /** + * Ensure the table to track link modules migrations + * exists. + * + * @param orm MikroORM + */ + protected async ensureMigrationsTable( + orm: MikroORM + ): Promise { + await orm.em.getDriver().getConnection().execute(` + CREATE TABLE IF NOT EXISTS "${this.tableName}" ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(255) NOT NULL UNIQUE, + link_descriptor JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `) + } + + /** + * Ensure the migrations table is in sync + * + * @param orm + * @protected + */ + protected async ensureMigrationsTableUpToDate( + orm: MikroORM + ) { + const existingTables: string[] = ( await orm.em + .getDriver() .getConnection() - .execute(`SELECT 1 FROM "${tableName}" LIMIT 0`) - hasTable = true - } catch {} + .execute< + { + table_name: string + }[] + >( + ` + SELECT table_name + FROM information_schema.tables; + ` + ) + ) + .map(({ table_name }) => table_name) + .filter((tableName) => + this.#linksEntities.some( + ({ entity }) => entity.meta.collection === tableName + ) + ) + + if (!existingTables.length) { + return + } + + const orderedDescriptors = existingTables.map((tableName) => { + return this.#linksEntities.find( + ({ entity }) => entity.meta.collection === tableName + )!.linkDescriptor + }) + + const positionalArgs = new Array(existingTables.length) + .fill("(?, ?)") + .join(", ") + await orm.em + .getDriver() + .getConnection() + .execute( + ` + INSERT INTO ${this.tableName} (table_name, link_descriptor) VALUES ${positionalArgs} ON CONFLICT DO NOTHING; + `, + existingTables.flatMap((tableName, index) => [ + tableName, + JSON.stringify(orderedDescriptors[index]), + ]) + ) + } + + /** + * Insert tuple to the migrations table and create the link table + * + * @param orm + * @param action + * @protected + */ + protected async createLinkTable( + orm: MikroORM, + action: LinkMigrationsPlannerAction & { + linkDescriptor: PlannerActionLinkDescriptor + sql: string + } + ) { + const { tableName, linkDescriptor, sql } = action + + await orm.em + .getDriver() + .getConnection() + .execute( + ` + INSERT INTO "${this.tableName}" (table_name, link_descriptor) VALUES (?, ?); + ${sql} + `, + [tableName, linkDescriptor] + ) + } + + /** + * Drops the link table and untracks it from the "link_modules_migrations" + * table. + */ + protected async dropLinkTable( + orm: MikroORM, + tableName: string + ) { + await orm.em.getDriver().getConnection().execute(` + DROP TABLE IF EXISTS "${tableName}"; + DELETE FROM "${this.tableName}" WHERE table_name = '${tableName}'; + `) + } + + /** + * Returns an array of table names that have been tracked during + * the last run. In short, these tables were created by the + * link modules migrations runner. + * + * @param orm MikroORM + */ + protected async getTrackedLinksTables( + orm: MikroORM + ): Promise< + { table_name: string; link_descriptor: PlannerActionLinkDescriptor }[] + > { + const results = await orm.em.getDriver().getConnection().execute< + { + table_name: string + link_descriptor: PlannerActionLinkDescriptor + }[] + >(` + SELECT table_name, link_descriptor from "${this.tableName}" + `) + + return results.map((tuple) => ({ + table_name: tuple.table_name, + link_descriptor: tuple.link_descriptor, + })) + } + + /** + * Returns the migration plan for a specific link entity. + */ + protected async getEntityMigrationPlan( + linkDescriptor: PlannerActionLinkDescriptor, + entity: EntitySchema, + trackedLinksTables: string[] + ): Promise { + const tableName = entity.meta.collection + const orm = await this.createORM([entity]) const generator = orm.getSchemaGenerator() - if (hasTable) { - /* const updateSql = await generator.getUpdateSchemaSQL() - const entityUpdates = updateSql - .split(";") - .map((sql) => sql.trim()) - .filter((sql) => - sql.toLowerCase().includes(`alter table "${tableName.toLowerCase()}"`) - ) + const platform = orm.em.getPlatform() + const connection = orm.em.getConnection() + const schemaName = this.#dbConfig.schema || "public" - if (entityUpdates.length > 0) { - try { - await generator.execute(entityUpdates.join(";")) - logger.info(`Link module "${serviceName}" migration executed`) - } catch (error) { - logger.error( - `Link module "${serviceName}" migration failed to run - Error: ${error.errros ?? error}` - ) - } - } else { - logger.info(`Skipping "${tableName}" migration.`) - }*/ - // Note: Temporarily skipping this for handling no logs on the CI. Bring this back if necessary. - // logger.info( - // `Link module('${serviceName}'): Table already exists. Write your own migration if needed.` - // ) - } else { - try { - await generator.createSchema() - - logger.info(`Link module('${serviceName}'): Migration executed`) - } catch (error) { - logger.error( - `Link module('${serviceName}'): Migration failed - Error: ${ - error.errros ?? error - }` - ) + /** + * If the table name for the entity has not been + * managed by us earlier, then we should create + * it. + */ + if (!trackedLinksTables.includes(tableName)) { + return { + action: "create", + linkDescriptor, + tableName, + sql: await generator.getCreateSchemaSQL(), } } - await orm.close() - } -} + /** + * Pre-fetching information schema from the database and using that + * as the way to compute the update diff. + * + * @note + * The "loadInformationSchema" mutates the "dbSchema" argument provided + * to it as the first argument. + */ + const dbSchema = new DatabaseSchema(platform, schemaName) + await platform + .getSchemaHelper?.() + ?.loadInformationSchema(dbSchema, connection, [ + { + table_name: tableName, + schema_name: schemaName, + }, + ]) -export function getRevertMigration( - joinerConfig: ModuleJoinerConfig, - serviceName: string, - primary: JoinerRelationship, - foreign: JoinerRelationship -) { - return async function revertMigrations( - { - options, - logger, - }: Pick< - LoaderOptions, - "options" | "logger" - > = {} as any - ) { - logger ??= console as unknown as Logger + const updateSQL = await generator.getUpdateSchemaSQL({ + fromSchema: dbSchema, + }) - const dbData = ModulesSdkUtils.loadDatabaseConfig("link_modules", options) - const entity = generateEntity(joinerConfig, primary, foreign) - const pathToMigrations = __dirname + "/../migrations" + /** + * Entity is upto-date and hence we do not have to perform + * any updates on it. + */ + if (!updateSQL.length) { + return { + action: "noop", + linkDescriptor, + tableName, + } + } - const orm = await DALUtils.mikroOrmCreateConnection( - dbData, - [entity], - pathToMigrations - ) + const usesUnsafeCommands = this.#unsafeSQLCommands.some((fragment) => { + return updateSQL.match(new RegExp(`${fragment}`, "ig")) + }) try { - const migrator = orm.getMigrator() - await migrator.down() - logger.info(`Link module "${serviceName}" migration executed`) - } catch (error) { - logger.error( - `Link module "${serviceName}" migration failed to run - Error: ${ - error.errros ?? error - }` + return { + action: usesUnsafeCommands ? "notify" : "update", + linkDescriptor, + tableName, + sql: updateSQL, + } + } finally { + await orm.close(true) + } + } + + /** + * Creates a plan to executed in order to keep the database state in + * sync with the user-defined links. + * + * This method only creates a plan and does not change the database + * state. You must call the "executePlan" method for that. + */ + async createPlan() { + const orm = await this.createORM() + await this.ensureMigrationsTable(orm) + + const executionActions: LinkMigrationsPlannerAction[] = [] + + await this.ensureMigrationsTableUpToDate(orm) + + const trackedTables = await this.getTrackedLinksTables(orm) + const trackedTablesNames = trackedTables.map(({ table_name }) => table_name) + + /** + * Looping through the new set of entities and generating + * execution plan for them + */ + for (let { entity, linkDescriptor } of this.#linksEntities) { + executionActions.push( + await this.getEntityMigrationPlan( + linkDescriptor, + entity, + trackedTablesNames + ) ) } - await orm.close() + const linksTableNames = this.#linksEntities.map( + ({ entity }) => entity.meta.collection + ) + + /** + * Finding the tables to be removed + */ + const tablesToRemove = arrayDifference(trackedTablesNames, linksTableNames) + tablesToRemove.forEach((tableToRemove) => { + executionActions.push({ + action: "delete", + tableName: tableToRemove, + linkDescriptor: trackedTables.find( + ({ table_name }) => tableToRemove === table_name + )!.link_descriptor, + }) + }) + + try { + return executionActions + } finally { + await orm.close(true) + } + } + + /** + * Executes the actionsPlan actions where the action is one of 'create' | 'update' | 'delete'. + * 'noop' and 'notify' actions are implicitly ignored. If a notify action needs to be + * executed, you can mutate its action to 'update', in that scenario it means that an unsafe + * update sql (from our point of view) will be executed and some data could be lost. + * + * @param actionPlan + */ + async executePlan(actionPlan: LinkMigrationsPlannerAction[]): Promise { + const orm = await this.createORM() + + await promiseAll( + actionPlan.map(async (action) => { + switch (action.action) { + case "delete": + return await this.dropLinkTable(orm, action.tableName) + case "create": + return await this.createLinkTable(orm, action) + case "update": + return await orm.em.getDriver().getConnection().execute(action.sql) + default: + return + } + }) + ).finally(() => orm.close(true)) } } diff --git a/yarn.lock b/yarn.lock index dfcf575449..9445a6f478 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3625,6 +3625,19 @@ __metadata: languageName: node linkType: hard +"@inquirer/checkbox@npm:^2.3.11": + version: 2.3.11 + resolution: "@inquirer/checkbox@npm:2.3.11" + dependencies: + "@inquirer/core": ^9.0.3 + "@inquirer/figures": ^1.0.4 + "@inquirer/type": ^1.5.0 + ansi-escapes: ^4.3.2 + yoctocolors-cjs: ^2.1.2 + checksum: 688b011a80e156a35fb51a31d9ed114216abcad78e3d0a490fe98154a7293fa8f7e80315cad390c75fc10e6f7e2910fbd353dd6b07dd39f7b2e9694eb42bad86 + languageName: node + linkType: hard + "@inquirer/confirm@npm:^3.0.0": version: 3.1.7 resolution: "@inquirer/confirm@npm:3.1.7" @@ -3656,6 +3669,27 @@ __metadata: languageName: node linkType: hard +"@inquirer/core@npm:^9.0.3": + version: 9.0.3 + resolution: "@inquirer/core@npm:9.0.3" + dependencies: + "@inquirer/figures": ^1.0.4 + "@inquirer/type": ^1.5.0 + "@types/mute-stream": ^0.0.4 + "@types/node": ^20.14.11 + "@types/wrap-ansi": ^3.0.0 + ansi-escapes: ^4.3.2 + cli-spinners: ^2.9.2 + cli-width: ^4.1.0 + mute-stream: ^1.0.0 + signal-exit: ^4.1.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^6.2.0 + yoctocolors-cjs: ^2.1.2 + checksum: 1929e2df237dd2384bb023cc51b4f5aae2732b99a78a76bf404ff7e0d500587ced2e02114071114ad28f7f57b0dfcc534e4f5fbaf8adecac825798450a57daec + languageName: node + linkType: hard + "@inquirer/figures@npm:^1.0.1": version: 1.0.1 resolution: "@inquirer/figures@npm:1.0.1" @@ -3663,6 +3697,13 @@ __metadata: languageName: node linkType: hard +"@inquirer/figures@npm:^1.0.4": + version: 1.0.4 + resolution: "@inquirer/figures@npm:1.0.4" + checksum: f3d8ade38f4895eb6cfc61e14e7bfaa25b2ff95ce9195587e161d89c05e1beeb8666d2115d900d5ba5e652325fff14ad3a7b973f36c1e8796653068ef3c01a23 + languageName: node + linkType: hard + "@inquirer/type@npm:^1.3.1": version: 1.3.1 resolution: "@inquirer/type@npm:1.3.1" @@ -3670,6 +3711,15 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^1.5.0": + version: 1.5.0 + resolution: "@inquirer/type@npm:1.5.0" + dependencies: + mute-stream: ^1.0.0 + checksum: 6a2379af9ca7227ae577f952c29f6736142b2925c2460d110504002633902f70c145e6df04783c32d22f475e464ad203465234f5e2ef33f85eb719bd573032fb + languageName: node + linkType: hard + "@internationalized/date@npm:^3.5.4": version: 3.5.4 resolution: "@internationalized/date@npm:3.5.4" @@ -4689,7 +4739,7 @@ __metadata: rimraf: ^5.0.1 ts-node: ^10.9.1 tsc-alias: ^1.8.6 - typescript: ^5.1.6 + typescript: ^5.5.0 languageName: unknown linkType: soft @@ -4769,6 +4819,7 @@ __metadata: version: 0.0.0-use.local resolution: "@medusajs/medusa@workspace:packages/medusa" dependencies: + "@inquirer/checkbox": ^2.3.11 "@medusajs/admin-sdk": 0.0.1 "@medusajs/core-flows": ^0.0.9 "@medusajs/link-modules": ^0.2.11 @@ -4789,6 +4840,7 @@ __metadata: awilix: ^8.0.0 body-parser: ^1.19.0 boxen: ^5.0.1 + chalk: ^4.0.0 chokidar: ^3.4.2 compression: ^1.7.4 connect-redis: ^5.0.0 @@ -12094,6 +12146,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.14.11": + version: 20.14.11 + resolution: "@types/node@npm:20.14.11" + dependencies: + undici-types: ~5.26.4 + checksum: 5306becc0ff41d81b1e31524bd376e958d0741d1ce892dffd586b9ae0cb6553c62b0d62abd16da8bea6b9a2c17572d360450535d7c073794b0cef9cb4e39691e + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -30026,6 +30087,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.5.0": + version: 5.5.3 + resolution: "typescript@npm:5.5.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: f52c71ccbc7080b034b9d3b72051d563601a4815bf3e39ded188e6ce60813f75dbedf11ad15dd4d32a12996a9ed8c7155b46c93a9b9c9bad1049766fe614bbdd + languageName: node + linkType: hard + "typescript@patch:typescript@4.9.5#~builtin, typescript@patch:typescript@^4.1.3#~builtin": version: 4.9.5 resolution: "typescript@patch:typescript@npm%3A4.9.5#~builtin::version=4.9.5&hash=7ad353" @@ -30076,6 +30147,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@^5.5.0#~builtin": + version: 5.5.3 + resolution: "typescript@patch:typescript@npm%3A5.5.3#~builtin::version=5.5.3&hash=7ad353" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 5a437c416251334deeaf29897157032311f3f126547cfdc4b133768b606cb0e62bcee733bb97cf74c42fe7268801aea1392d8e40988cdef112e9546eba4c03c5 + languageName: node + linkType: hard + "ua-parser-js@npm:^1.0.35": version: 1.0.37 resolution: "ua-parser-js@npm:1.0.37" @@ -31476,6 +31557,13 @@ __metadata: languageName: node linkType: hard +"yoctocolors-cjs@npm:^2.1.2": + version: 2.1.2 + resolution: "yoctocolors-cjs@npm:2.1.2" + checksum: a0e36eb88fea2c7981eab22d1ba45e15d8d268626e6c4143305e2c1628fa17ebfaa40cd306161a8ce04c0a60ee0262058eab12567493d5eb1409780853454c6f + languageName: node + linkType: hard + "z-schema@npm:~5.0.2": version: 5.0.5 resolution: "z-schema@npm:5.0.5"