breaking: rework how links database migrations are managed (#8162)

This commit is contained in:
Adrien de Peretti
2024-07-22 09:42:23 +02:00
committed by GitHub
parent f435c6c7f6
commit f74fdcb644
24 changed files with 1090 additions and 264 deletions

View File

@@ -1,2 +0,0 @@
{
}

View File

@@ -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)
})

View File

@@ -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")

View File

@@ -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`,

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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 {

View File

@@ -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"

View 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>
}

View 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>
}

View File

@@ -34,7 +34,7 @@ type ExtraOptions = {
[key: string]: string
}
database?: {
table: string
table?: string
idPrefix?: string
extraColumns?: LinkModulesExtraFields
}

View File

@@ -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>
}

View File

@@ -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",

View 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

View File

@@ -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 })

View File

@@ -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)) {

View File

@@ -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,
})

View File

@@ -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",
},
})
})
})
},
})

View File

@@ -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/"],
}

View File

@@ -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",

View File

@@ -1,4 +1,5 @@
export * from "./initialize"
export * from "./migration"
export * from "./types"
export * from "./loaders"
export * from "./services"

View File

@@ -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)
}

View File

@@ -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))
}
}

View File

@@ -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"