breaking: rework how links database migrations are managed (#8162)
This commit is contained in:
committed by
GitHub
parent
f435c6c7f6
commit
f74fdcb644
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>
|
||||
export type RevertMigrationFn = (moduleNames: string[]) => Promise<void>
|
||||
export type GenerateMigrations = (moduleNames: string[]) => Promise<void>
|
||||
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<void>
|
||||
onApplicationPrepareShutdown: () => Promise<void>
|
||||
onApplicationStart: () => Promise<void>
|
||||
@@ -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<void> => {
|
||||
await applyMigration({
|
||||
modulesNames,
|
||||
action: "revert",
|
||||
})
|
||||
}
|
||||
|
||||
const generateMigrations: GenerateMigrations = async (
|
||||
modulesNames
|
||||
): Promise<void> => {
|
||||
await applyMigration({
|
||||
modulesNames,
|
||||
action: "generate",
|
||||
})
|
||||
}
|
||||
|
||||
const getMigrationPlannerFn = () => {
|
||||
const options: Partial<ModuleServiceInitializeOptions> =
|
||||
"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<void> => {
|
||||
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<ModuleServiceInitializeOptions> =
|
||||
"scope" in linkModuleOrOptions
|
||||
? { ...linkModuleOrOptions.options }
|
||||
: {
|
||||
...(linkModuleOrOptions as Partial<ModuleServiceInitializeOptions>),
|
||||
}
|
||||
|
||||
options.database ??= {
|
||||
...sharedResourcesConfig?.database,
|
||||
}
|
||||
|
||||
await revertLinkModuleMigration(
|
||||
{
|
||||
options,
|
||||
injectedDependencies,
|
||||
},
|
||||
linkModules
|
||||
)*/
|
||||
}
|
||||
|
||||
const generateMigrations: GenerateMigrations = async (
|
||||
modulesNames
|
||||
): Promise<void> => {
|
||||
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<ILinkMigrationsPlanner> {
|
||||
const migrationOnly = true
|
||||
|
||||
const { linkMigrationExecutionPlanner } = await MedusaApp_({
|
||||
...options,
|
||||
migrationOnly,
|
||||
})
|
||||
|
||||
return linkMigrationExecutionPlanner()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
config?: FindConfig<unknown>,
|
||||
sharedContext?: Context
|
||||
): Promise<unknown[]>
|
||||
|
||||
listAndCount(
|
||||
filters?: Record<string, unknown>,
|
||||
config?: FindConfig<unknown>,
|
||||
sharedContext?: Context
|
||||
): Promise<[unknown[], number]>
|
||||
|
||||
create(
|
||||
primaryKeyOrBulkData:
|
||||
| string
|
||||
| string[]
|
||||
| [string | string[], string, Record<string, unknown>?][],
|
||||
foreignKeyData?: string,
|
||||
sharedContext?: Context
|
||||
): Promise<unknown[]>
|
||||
|
||||
dismiss(
|
||||
primaryKeyOrBulkData: string | string[] | [string | string[], string][],
|
||||
foreignKeyData?: string,
|
||||
sharedContext?: Context
|
||||
): Promise<unknown[]>
|
||||
|
||||
delete(data: unknown | unknown[], sharedContext?: Context): Promise<void>
|
||||
|
||||
softDelete(
|
||||
data: unknown | unknown[],
|
||||
config?: SoftDeleteReturn,
|
||||
sharedContext?: Context
|
||||
): Promise<Record<string, unknown[]> | void>
|
||||
|
||||
restore(
|
||||
data: unknown | unknown[],
|
||||
config?: RestoreReturn,
|
||||
sharedContext?: Context
|
||||
): Promise<Record<string, unknown[]> | void>
|
||||
}
|
||||
export * from "./service"
|
||||
export * from "./migrations"
|
||||
|
||||
37
packages/core/types/src/link-modules/migrations.ts
Normal file
37
packages/core/types/src/link-modules/migrations.ts
Normal file
@@ -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<LinkMigrationsPlannerAction[]>
|
||||
executePlan(actions: LinkMigrationsPlannerAction[]): Promise<void>
|
||||
}
|
||||
47
packages/core/types/src/link-modules/service.ts
Normal file
47
packages/core/types/src/link-modules/service.ts
Normal file
@@ -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<string, unknown>,
|
||||
config?: FindConfig<unknown>,
|
||||
sharedContext?: Context
|
||||
): Promise<unknown[]>
|
||||
|
||||
listAndCount(
|
||||
filters?: Record<string, unknown>,
|
||||
config?: FindConfig<unknown>,
|
||||
sharedContext?: Context
|
||||
): Promise<[unknown[], number]>
|
||||
|
||||
create(
|
||||
primaryKeyOrBulkData:
|
||||
| string
|
||||
| string[]
|
||||
| [string | string[], string, Record<string, unknown>?][],
|
||||
foreignKeyData?: string,
|
||||
sharedContext?: Context
|
||||
): Promise<unknown[]>
|
||||
|
||||
dismiss(
|
||||
primaryKeyOrBulkData: string | string[] | [string | string[], string][],
|
||||
foreignKeyData?: string,
|
||||
sharedContext?: Context
|
||||
): Promise<unknown[]>
|
||||
|
||||
delete(data: unknown | unknown[], sharedContext?: Context): Promise<void>
|
||||
|
||||
softDelete(
|
||||
data: unknown | unknown[],
|
||||
config?: SoftDeleteReturn,
|
||||
sharedContext?: Context
|
||||
): Promise<Record<string, unknown[]> | void>
|
||||
|
||||
restore(
|
||||
data: unknown | unknown[],
|
||||
config?: RestoreReturn,
|
||||
sharedContext?: Context
|
||||
): Promise<Record<string, unknown[]> | void>
|
||||
}
|
||||
@@ -34,7 +34,7 @@ type ExtraOptions = {
|
||||
[key: string]: string
|
||||
}
|
||||
database?: {
|
||||
table: string
|
||||
table?: string
|
||||
idPrefix?: string
|
||||
extraColumns?: LinkModulesExtraFields
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export type ModelsConfigTemplate = { [key: string]: ModelDTOConfig }
|
||||
|
||||
export type ModelConfigurationsToConfigTemplate<T extends ModelEntries> = {
|
||||
[Key in keyof T]: {
|
||||
dto: T[Key] extends IDmlEntity<any, any>
|
||||
dto: T[Key] extends DmlEntity<any, any>
|
||||
? InferEntityType<T[Key]>
|
||||
: T[Key] extends Constructor<any>
|
||||
? InstanceType<T[Key]>
|
||||
@@ -261,7 +261,8 @@ type InferModelFromConfig<T> = {
|
||||
: never
|
||||
}
|
||||
|
||||
export type MedusaServiceReturnType<ModelsConfig extends Record<any, any>> = {
|
||||
new (...args: any[]): AbstractModuleService<ModelsConfig>
|
||||
$modelObjects: InferModelFromConfig<ModelsConfig>
|
||||
}
|
||||
export type MedusaServiceReturnType<ModelsConfig extends Record<string, any>> =
|
||||
{
|
||||
new (...args: any[]): AbstractModuleService<ModelsConfig>
|
||||
$modelObjects: InferModelFromConfig<ModelsConfig>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
176
packages/medusa/src/commands/links.ts
Normal file
176
packages/medusa/src/commands/links.ts
Normal file
@@ -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(
|
||||
" <space> select, <a> select all, <i> inverse, <enter> 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
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<ILinkMigrationsPlanner> {
|
||||
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)) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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<ILinkModule>({
|
||||
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",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,10 @@
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
"^@models": "<rootDir>/src/models",
|
||||
"^@services": "<rootDir>/src/services",
|
||||
"^@repositories": "<rootDir>/src/repositories",
|
||||
"^@types": "<rootDir>/src/types",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.[jt]s$": [
|
||||
"@swc/jest",
|
||||
@@ -12,4 +18,5 @@ module.exports = {
|
||||
},
|
||||
testEnvironment: `node`,
|
||||
moduleFileExtensions: [`js`, `ts`],
|
||||
modulePathIgnorePatterns: ["dist/"],
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./initialize"
|
||||
export * from "./migration"
|
||||
export * from "./types"
|
||||
export * from "./loaders"
|
||||
export * from "./services"
|
||||
|
||||
@@ -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<LoaderOptions<ModuleServiceInitializeOptions>, "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<string>()
|
||||
@@ -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<LoaderOptions<ModuleServiceInitializeOptions>, "container">,
|
||||
modulesDefinition?: ModuleJoinerConfig[]
|
||||
) {
|
||||
await applyMigrationUpOrDown({ options, logger }, modulesDefinition)
|
||||
}
|
||||
|
||||
export async function revertMigrations(
|
||||
{
|
||||
options,
|
||||
logger,
|
||||
}: Omit<LoaderOptions<ModuleServiceInitializeOptions>, "container">,
|
||||
modulesDefinition?: ModuleJoinerConfig[]
|
||||
) {
|
||||
await applyMigrationUpOrDown({ options, logger }, modulesDefinition, true)
|
||||
return new MigrationsExecutionPlanner(allLinksToLoad, options)
|
||||
}
|
||||
|
||||
@@ -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<typeof ModulesSdkUtils.loadDatabaseConfig>
|
||||
|
||||
export function getMigration(
|
||||
joinerConfig: ModuleJoinerConfig,
|
||||
serviceName: string,
|
||||
primary: JoinerRelationship,
|
||||
foreign: JoinerRelationship
|
||||
) {
|
||||
return async function runMigrations(
|
||||
{
|
||||
options,
|
||||
logger,
|
||||
}: Pick<
|
||||
LoaderOptions<ModuleServiceInitializeOptions>,
|
||||
"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<PostgreSqlDriver>
|
||||
): Promise<void> {
|
||||
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<PostgreSqlDriver>
|
||||
) {
|
||||
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<PostgreSqlDriver>,
|
||||
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<PostgreSqlDriver>,
|
||||
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<PostgreSqlDriver>
|
||||
): 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<LinkMigrationsPlannerAction> {
|
||||
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<ModuleServiceInitializeOptions>,
|
||||
"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<void> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
90
yarn.lock
90
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<compat/typescript>, typescript@patch:typescript@^4.1.3#~builtin<compat/typescript>":
|
||||
version: 4.9.5
|
||||
resolution: "typescript@patch:typescript@npm%3A4.9.5#~builtin<compat/typescript>::version=4.9.5&hash=7ad353"
|
||||
@@ -30076,6 +30147,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript@patch:typescript@^5.5.0#~builtin<compat/typescript>":
|
||||
version: 5.5.3
|
||||
resolution: "typescript@patch:typescript@npm%3A5.5.3#~builtin<compat/typescript>::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"
|
||||
|
||||
Reference in New Issue
Block a user