diff --git a/packages/cli/medusa-cli/src/create-cli.ts b/packages/cli/medusa-cli/src/create-cli.ts index 02575e26ad..68831ad27f 100644 --- a/packages/cli/medusa-cli/src/create-cli.ts +++ b/packages/cli/medusa-cli/src/create-cli.ts @@ -142,6 +142,26 @@ function buildLocalCommands(cli, isLocalProject) { }) ), }) + .command({ + command: "db:sync-links", + desc: "Sync database schema with the links defined by your application and Medusa core", + builder: (builder) => { + builder.option("execute-all", { + type: "boolean", + describe: "Skip prompts and execute all (including unsafe) actions", + }) + builder.option("execute-safe", { + type: "boolean", + describe: "Skip prompts and execute only safe actions", + }) + }, + handler: handlerP( + getCommandHandler("db/sync-links", (args, cmd) => { + process.env.NODE_ENV = process.env.NODE_ENV || `development` + return cmd(args) + }) + ), + }) .command({ command: `telemetry`, describe: `Enable or disable collection of anonymous usage data.`, diff --git a/packages/medusa/src/commands/db/sync-links.ts b/packages/medusa/src/commands/db/sync-links.ts new file mode 100644 index 0000000000..6f9bfac917 --- /dev/null +++ b/packages/medusa/src/commands/db/sync-links.ts @@ -0,0 +1,185 @@ +import boxen from "boxen" +import chalk from "chalk" +import { join } from "path" +import checkbox from "@inquirer/checkbox" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { LinkMigrationsPlannerAction } from "@medusajs/types" +import { LinkLoader, logger, MedusaAppLoader } from "@medusajs/framework" + +import { ensureDbExists } from "../utils" +import { initializeContainer } from "../../loaders" +import { getResolvedPlugins } from "../../loaders/helpers/resolve-plugins" + +/** + * 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, executeSafe, executeAll }) { + try { + const container = await initializeContainer(directory) + await ensureDbExists(container) + + const configModule = container.resolve( + ContainerRegistrationKeys.CONFIG_MODULE + ) + + const medusaAppLoader = new MedusaAppLoader() + + const plugins = getResolvedPlugins(directory, configModule, true) || [] + const linksSourcePaths = plugins.map((plugin) => + join(plugin.resolve, "links") + ) + await new LinkLoader(linksSourcePaths).load() + + const planner = await medusaAppLoader.getLinksExecutionPlanner() + + logger.info("Syncing links...") + + const actionPlan = await planner.createPlan() + const groupActionPlan = groupByActionPlan(actionPlan) + + if (groupActionPlan.delete?.length) { + /** + * Do not delete anything when "--execute-safe" flag + * is used. And only prompt when "--execute-all" + * flag isn't used either + */ + if (executeSafe) { + groupActionPlan.delete = [] + } else if (!executeAll) { + groupActionPlan.delete = await askForLinkActionsToPerform( + `Select the tables to ${chalk.red( + "DELETE" + )}. The following links have been removed`, + groupActionPlan.delete + ) + } + } + + if (groupActionPlan.notify?.length) { + let answer = groupActionPlan.notify + + /** + * Do not update anything when "--execute-safe" flag + * is used. And only prompt when "--execute-all" + * flag isn't used either. + */ + if (executeSafe) { + answer = [] + } else if (!executeAll) { + 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/links.ts b/packages/medusa/src/commands/links.ts index c5feba7708..9dfe8391f0 100644 --- a/packages/medusa/src/commands/links.ts +++ b/packages/medusa/src/commands/links.ts @@ -1,176 +1,10 @@ -import boxen from "boxen" -import chalk from "chalk" -import checkbox from "@inquirer/checkbox" +import syncLinks from "./db/sync-links" -import { LinkLoader, logger, MedusaAppLoader } from "@medusajs/framework" -import { initializeContainer } from "../loaders" -import { ContainerRegistrationKeys } from "@medusajs/utils" -import { getResolvedPlugins } from "../loaders/helpers/resolve-plugins" -import { LinkMigrationsPlannerAction } from "@medusajs/types" -import { join } from "path" - -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") { +const main = async function (argv) { + if (argv.action !== "sync") { return process.exit() } - - try { - const container = await initializeContainer(directory) - - const configModule = container.resolve( - ContainerRegistrationKeys.CONFIG_MODULE - ) - - const medusaAppLoader = new MedusaAppLoader() - - const plugins = getResolvedPlugins(directory, configModule, true) || [] - const linksSourcePaths = plugins.map((plugin) => - join(plugin.resolve, "links") - ) - await new LinkLoader(linksSourcePaths).load() - - const planner = await medusaAppLoader.getLinksExecutionPlanner() - - 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) - } + await syncLinks(argv) } export default main diff --git a/packages/medusa/src/commands/utils/index.ts b/packages/medusa/src/commands/utils/index.ts new file mode 100644 index 0000000000..c78b4a1001 --- /dev/null +++ b/packages/medusa/src/commands/utils/index.ts @@ -0,0 +1,21 @@ +import { logger } from "@medusajs/framework" +import { MedusaContainer } from "@medusajs/types" +import { ContainerRegistrationKeys } from "@medusajs/utils" + +export async function ensureDbExists(container: MedusaContainer) { + const pgConnection = container.resolve( + ContainerRegistrationKeys.PG_CONNECTION + ) + + try { + await pgConnection.raw("SELECT 1 + 1;") + } catch (error) { + if (error.code === "3D000") { + logger.error(`Cannot sync links. ${error.message.replace("error: ", "")}`) + logger.info(`Run command "db:create" to create the database`) + } else { + logger.error(error) + } + process.exit(1) + } +}