From 4a03bdbb86820f424f062f273a0d24011e7552e3 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Tue, 15 Oct 2024 12:40:24 -0300 Subject: [PATCH] feat(providers): locking redis (#9544) --- .eslintignore | 1 + .eslintrc.js | 1 + integration-tests/modules/medusa-config.js | 1 + .../src/cart/steps/reserve-inventory.ts | 46 +- .../reservation/steps/create-reservations.ts | 33 +- .../modules-sdk/src/loaders/module-loader.ts | 6 +- .../module-with-providers/index.ts | 11 + .../module-with-providers/provider-1/index.ts | 8 + .../provider-1/services/provider-service.ts | 3 + .../module-with-providers/provider-2/index.ts | 8 + .../provider-2/services/provider-service.ts | 1 + .../services/module-service.ts | 9 + .../utils/__tests__/load-internal.spec.ts | 671 ++++++++++-------- .../src/loaders/utils/load-internal.ts | 392 +++++++--- packages/core/modules-sdk/src/medusa-app.ts | 2 +- packages/core/types/src/modules-sdk/index.ts | 3 +- .../types/src/modules-sdk/module-provider.ts | 44 +- .../common/__tests__/define-config.spec.ts | 18 + .../core/utils/src/common/define-config.ts | 5 +- packages/core/utils/src/index.ts | 3 +- packages/core/utils/src/modules-sdk/index.ts | 1 + .../utils/src/modules-sdk/module-provider.ts | 19 + packages/medusa/package.json | 1 + packages/medusa/src/modules/locking-redis.ts | 6 + .../cache-redis/src/services/redis-cache.ts | 26 +- .../integration-tests/__tests__/index.spec.ts | 97 ++- packages/modules/locking/package.json | 6 + packages/modules/locking/src/index.ts | 4 +- .../modules/locking/src/loaders/providers.ts | 12 +- .../locking/src/providers/in-memory.ts | 49 +- .../providers/auth-emailpass/src/index.ts | 8 +- .../providers/auth-github/src/index.ts | 8 +- .../providers/auth-google/src/index.ts | 8 +- .../modules/providers/file-local/src/index.ts | 8 +- .../modules/providers/file-s3/src/index.ts | 8 +- .../providers/fulfillment-manual/src/index.ts | 8 +- .../providers/locking-redis/.gitignore | 4 + .../integration-tests/__tests__/index.spec.ts | 220 ++++++ .../providers/locking-redis/jest.config.js | 10 + .../providers/locking-redis/package.json | 48 ++ .../providers/locking-redis/src/index.ts | 11 + .../locking-redis/src/loaders/index.ts | 41 ++ .../locking-redis/src/services/redis-lock.ts | 280 ++++++++ .../locking-redis/src/types/index.ts | 45 ++ .../providers/locking-redis/tsconfig.json | 12 + .../providers/notification-local/src/index.ts | 8 +- .../notification-sendgrid/src/index.ts | 8 +- .../providers/payment-stripe/src/index.ts | 8 +- yarn.lock | 17 + 49 files changed, 1764 insertions(+), 483 deletions(-) create mode 100644 packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/index.ts create mode 100644 packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/index.ts create mode 100644 packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/services/provider-service.ts create mode 100644 packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/index.ts create mode 100644 packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/services/provider-service.ts create mode 100644 packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/services/module-service.ts create mode 100644 packages/core/utils/src/modules-sdk/module-provider.ts create mode 100644 packages/medusa/src/modules/locking-redis.ts create mode 100644 packages/modules/providers/locking-redis/.gitignore create mode 100644 packages/modules/providers/locking-redis/integration-tests/__tests__/index.spec.ts create mode 100644 packages/modules/providers/locking-redis/jest.config.js create mode 100644 packages/modules/providers/locking-redis/package.json create mode 100644 packages/modules/providers/locking-redis/src/index.ts create mode 100644 packages/modules/providers/locking-redis/src/loaders/index.ts create mode 100644 packages/modules/providers/locking-redis/src/services/redis-lock.ts create mode 100644 packages/modules/providers/locking-redis/src/types/index.ts create mode 100644 packages/modules/providers/locking-redis/tsconfig.json diff --git a/.eslintignore b/.eslintignore index 66fa041a02..ed0ba98e31 100644 --- a/.eslintignore +++ b/.eslintignore @@ -33,6 +33,7 @@ packages/* !packages/workflow-engine-inmemory !packages/fulfillment !packages/fulfillment-manual +!packages/locking-redis !packages/index !packages/framework diff --git a/.eslintrc.js b/.eslintrc.js index f960018932..02c2ee5f16 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -134,6 +134,7 @@ module.exports = { "./packages/modules/providers/file-s3/tsconfig.spec.json", "./packages/modules/providers/fulfillment-manual/tsconfig.spec.json", "./packages/modules/providers/payment-stripe/tsconfig.spec.json", + "./packages/modules/providers/locking-redis/tsconfig.spec.json", "./packages/framework/tsconfig.json", ], diff --git a/integration-tests/modules/medusa-config.js b/integration-tests/modules/medusa-config.js index 08f890690b..df3b3e6ae3 100644 --- a/integration-tests/modules/medusa-config.js +++ b/integration-tests/modules/medusa-config.js @@ -62,6 +62,7 @@ module.exports = { resolve: "@medusajs/cache-inmemory", options: { ttl: 0 }, // Cache disabled }, + [Modules.LOCKING]: true, [Modules.STOCK_LOCATION]: { resolve: "@medusajs/stock-location-next", options: {}, diff --git a/packages/core/core-flows/src/cart/steps/reserve-inventory.ts b/packages/core/core-flows/src/cart/steps/reserve-inventory.ts index 66f4645d27..b749933467 100644 --- a/packages/core/core-flows/src/cart/steps/reserve-inventory.ts +++ b/packages/core/core-flows/src/cart/steps/reserve-inventory.ts @@ -1,6 +1,5 @@ -import { IInventoryService } from "@medusajs/framework/types" import { MathBN, Modules } from "@medusajs/framework/utils" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" import { BigNumberInput } from "@medusajs/types" export interface ReserveVariantInventoryStepInput { @@ -22,34 +21,45 @@ export const reserveInventoryStepId = "reserve-inventory-step" export const reserveInventoryStep = createStep( reserveInventoryStepId, async (data: ReserveVariantInventoryStepInput, { container }) => { - const inventoryService = container.resolve( - Modules.INVENTORY - ) + const inventoryService = container.resolve(Modules.INVENTORY) - const items = data.items.map((item) => ({ - line_item_id: item.id, - inventory_item_id: item.inventory_item_id, - quantity: MathBN.mult(item.required_quantity, item.quantity), - allow_backorder: item.allow_backorder, - location_id: item.location_ids[0], - })) + const locking = container.resolve(Modules.LOCKING) - const reservations = await inventoryService.createReservationItems(items) + const inventoryItemIds: string[] = [] + + const items = data.items.map((item) => { + inventoryItemIds.push(item.inventory_item_id) + + return { + line_item_id: item.id, + inventory_item_id: item.inventory_item_id, + quantity: MathBN.mult(item.required_quantity, item.quantity), + allow_backorder: item.allow_backorder, + location_id: item.location_ids[0], + } + }) + + const reservations = await locking.execute(inventoryItemIds, async () => { + return await inventoryService.createReservationItems(items) + }) return new StepResponse(reservations, { reservations: reservations.map((r) => r.id), + inventoryItemIds, }) }, async (data, { container }) => { - if (!data) { + if (!data?.reservations?.length) { return } - const inventoryService = container.resolve( - Modules.INVENTORY - ) + const inventoryService = container.resolve(Modules.INVENTORY) + const locking = container.resolve(Modules.LOCKING) - await inventoryService.deleteReservationItems(data.reservations) + const inventoryItemIds = data.inventoryItemIds + await locking.execute(inventoryItemIds, async () => { + await inventoryService.deleteReservationItems(data.reservations) + }) return new StepResponse() } diff --git a/packages/core/core-flows/src/reservation/steps/create-reservations.ts b/packages/core/core-flows/src/reservation/steps/create-reservations.ts index 2c01d0ce69..d186798b23 100644 --- a/packages/core/core-flows/src/reservation/steps/create-reservations.ts +++ b/packages/core/core-flows/src/reservation/steps/create-reservations.ts @@ -1,5 +1,5 @@ -import { IInventoryService, InventoryTypes } from "@medusajs/framework/types" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { InventoryTypes } from "@medusajs/framework/types" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" import { Modules } from "@medusajs/framework/utils" @@ -10,22 +10,31 @@ export const createReservationsStepId = "create-reservations-step" export const createReservationsStep = createStep( createReservationsStepId, async (data: InventoryTypes.CreateReservationItemInput[], { container }) => { - const service = container.resolve(Modules.INVENTORY) + const service = container.resolve(Modules.INVENTORY) + const locking = container.resolve(Modules.LOCKING) - const created = await service.createReservationItems(data) + const inventoryItemIds = data.map((item) => item.inventory_item_id) - return new StepResponse( - created, - created.map((reservation) => reservation.id) - ) + const created = await locking.execute(inventoryItemIds, async () => { + return await service.createReservationItems(data) + }) + + return new StepResponse(created, { + reservations: created.map((reservation) => reservation.id), + inventoryItemIds: inventoryItemIds, + }) }, - async (createdIds, { container }) => { - if (!createdIds?.length) { + async (data, { container }) => { + if (!data?.reservations?.length) { return } - const service = container.resolve(Modules.INVENTORY) + const service = container.resolve(Modules.INVENTORY) + const locking = container.resolve(Modules.LOCKING) - await service.deleteReservationItems(createdIds) + const inventoryItemIds = data.inventoryItemIds + await locking.execute(inventoryItemIds, async () => { + await service.deleteReservationItems(data.reservations) + }) } ) diff --git a/packages/core/modules-sdk/src/loaders/module-loader.ts b/packages/core/modules-sdk/src/loaders/module-loader.ts index 05a9dd207c..c0ec8b24f1 100644 --- a/packages/core/modules-sdk/src/loaders/module-loader.ts +++ b/packages/core/modules-sdk/src/loaders/module-loader.ts @@ -82,11 +82,11 @@ async function loadModule( return } - return await loadInternalModule( + return await loadInternalModule({ container, resolution, logger, migrationOnly, - loaderOnly - ) + loaderOnly, + }) } diff --git a/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/index.ts b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/index.ts new file mode 100644 index 0000000000..3adb26aca9 --- /dev/null +++ b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/index.ts @@ -0,0 +1,11 @@ +import { ModuleExports } from "@medusajs/types" +import { ModuleService } from "./services/module-service" +import { Module } from "@medusajs/utils" + +const moduleExports: ModuleExports = { + service: ModuleService, +} + +export * from "./services/module-service" + +export default Module("module-with-providers", moduleExports) diff --git a/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/index.ts b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/index.ts new file mode 100644 index 0000000000..4775c124aa --- /dev/null +++ b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/index.ts @@ -0,0 +1,8 @@ +import { ModuleProviderService } from "./services/provider-service" +import { ModuleProvider } from "@medusajs/utils" + +export * from "./services/provider-service" + +export default ModuleProvider("provider-1", { + services: [ModuleProviderService], +}) diff --git a/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/services/provider-service.ts b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/services/provider-service.ts new file mode 100644 index 0000000000..328deec074 --- /dev/null +++ b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-1/services/provider-service.ts @@ -0,0 +1,3 @@ +export class ModuleProviderService { + static identifier = "provider-1" +} diff --git a/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/index.ts b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/index.ts new file mode 100644 index 0000000000..209d35ace6 --- /dev/null +++ b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/index.ts @@ -0,0 +1,8 @@ +import { ModuleProvider2Service } from "./services/provider-service" +import { ModuleProvider } from "@medusajs/utils" + +export * from "./services/provider-service" + +export default ModuleProvider("provider-2", { + services: [ModuleProvider2Service], +}) diff --git a/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/services/provider-service.ts b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/services/provider-service.ts new file mode 100644 index 0000000000..7e581fe2b8 --- /dev/null +++ b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/provider-2/services/provider-service.ts @@ -0,0 +1 @@ +export class ModuleProvider2Service {} diff --git a/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/services/module-service.ts b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/services/module-service.ts new file mode 100644 index 0000000000..dab412f7fc --- /dev/null +++ b/packages/core/modules-sdk/src/loaders/utils/__fixtures__/module-with-providers/services/module-service.ts @@ -0,0 +1,9 @@ +import { InternalModuleDeclaration } from "@medusajs/types" + +export class ModuleService { + constructor( + public container: Record, + public moduleOptions: Record, + public moduleDeclaration: InternalModuleDeclaration + ) {} +} diff --git a/packages/core/modules-sdk/src/loaders/utils/__tests__/load-internal.spec.ts b/packages/core/modules-sdk/src/loaders/utils/__tests__/load-internal.spec.ts index f2bcfdd5f1..cef3444c60 100644 --- a/packages/core/modules-sdk/src/loaders/utils/__tests__/load-internal.spec.ts +++ b/packages/core/modules-sdk/src/loaders/utils/__tests__/load-internal.spec.ts @@ -1,5 +1,5 @@ import { IModuleService, ModuleResolution } from "@medusajs/types" -import { upperCaseFirst } from "@medusajs/utils" +import { createMedusaContainer, upperCaseFirst } from "@medusajs/utils" import { join } from "path" import { ModuleWithDmlMixedWithoutJoinerConfigFixtures, @@ -7,79 +7,253 @@ import { ModuleWithJoinerConfigFixtures, ModuleWithoutJoinerConfigFixtures, } from "../__fixtures__" -import { loadResources } from "../load-internal" +import { + getProviderRegistrationKey, + loadInternalModule, + loadResources, +} from "../load-internal" +import { ModuleProviderService as ModuleServiceWithProviderProvider1 } from "../__fixtures__/module-with-providers/provider-1" +import { ModuleProvider2Service as ModuleServiceWithProviderProvider2 } from "../__fixtures__/module-with-providers/provider-2" +import { ModuleService as ModuleServiceWithProvider } from "../__fixtures__/module-with-providers" -describe("load internal - load resources", () => { - describe("when loading the module resources from a path", () => { - test("should return the correct resources and generate the correct joiner config from a mix of DML entities and mikro orm entities", async () => { - const { ModuleService, EntityModel, dmlEntity } = - ModuleWithDmlMixedWithoutJoinerConfigFixtures +describe("load internal", () => { + describe("loadResources", () => { + describe("when loading the module resources from a path", () => { + test("should return the correct resources and generate the correct joiner config from a mix of DML entities and mikro orm entities", async () => { + const { ModuleService, EntityModel, dmlEntity } = + ModuleWithDmlMixedWithoutJoinerConfigFixtures - const moduleResolution: ModuleResolution = { - resolutionPath: join( - __dirname, - "../__fixtures__/module-with-dml-mixed-without-joiner-config" - ), - definition: { - key: "module-with-dml-mixed-without-joiner-config", - label: "Module with DML mixed without joiner config", - defaultPackage: false, - defaultModuleDeclaration: { - scope: "internal", - resources: "shared", + const moduleResolution: ModuleResolution = { + resolutionPath: join( + __dirname, + "../__fixtures__/module-with-dml-mixed-without-joiner-config" + ), + definition: { + key: "module-with-dml-mixed-without-joiner-config", + label: "Module with DML mixed without joiner config", + defaultPackage: false, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, }, - }, - } + } - expect( - (ModuleService.prototype as IModuleService).__joinerConfig - ).toBeUndefined() + expect( + (ModuleService.prototype as IModuleService).__joinerConfig + ).toBeUndefined() - const resources = await loadResources({ - moduleResolution, - discoveryPath: moduleResolution.resolutionPath as string, + const resources = await loadResources({ + moduleResolution, + discoveryPath: moduleResolution.resolutionPath as string, + }) + + expect(resources).toBeDefined() + expect(resources.services).toHaveLength(1) + expect(resources.services[0]).toEqual(ModuleService) + expect(resources.models).toHaveLength(2) + expect(resources.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: upperCaseFirst(dmlEntity.name) }), + expect.objectContaining({ name: upperCaseFirst(EntityModel.name) }), + ]) + ) + expect(resources.repositories).toHaveLength(0) + expect(resources.loaders).toHaveLength(2) + expect(resources.loaders).toEqual([ + expect.objectContaining({ name: "connectionLoader" }), + expect.objectContaining({ name: "containerLoader" }), + ]) + expect(resources.moduleService).toEqual(ModuleService) + + expect( + (resources.moduleService.prototype as IModuleService).__joinerConfig + ).toBeDefined() + + const generatedJoinerConfig = ( + resources.moduleService.prototype as IModuleService + ).__joinerConfig?.()! + + expect(generatedJoinerConfig).toEqual( + expect.objectContaining({ + serviceName: "module-with-dml-mixed-without-joiner-config", + primaryKeys: ["id"], + linkableKeys: { + dml_entity_id: "DmlEntity", + entity_model_id: "EntityModel", + }, + alias: [ + { + name: ["dml_entity", "dml_entities"], + entity: "DmlEntity", + args: { + methodSuffix: "DmlEntities", + }, + }, + { + name: ["entity_model", "entity_models"], + entity: "EntityModel", + args: { + methodSuffix: "EntityModels", + }, + }, + ], + }) + ) }) - expect(resources).toBeDefined() - expect(resources.services).toHaveLength(1) - expect(resources.services[0]).toEqual(ModuleService) - expect(resources.models).toHaveLength(2) - expect(resources.models).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: upperCaseFirst(dmlEntity.name) }), - expect.objectContaining({ name: upperCaseFirst(EntityModel.name) }), + test("should return the correct resources and generate the correct joiner config from DML entities", async () => { + const { ModuleService, entityModel, dmlEntity } = + ModuleWithDmlWithoutJoinerConfigFixtures + + const moduleResolution: ModuleResolution = { + resolutionPath: join( + __dirname, + "../__fixtures__/module-with-dml-without-joiner-config" + ), + definition: { + key: "module-with-dml-without-joiner-config", + label: "Module with DML without joiner config", + defaultPackage: false, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + } + + expect( + (ModuleService.prototype as IModuleService).__joinerConfig + ).toBeUndefined() + + const resources = await loadResources({ + moduleResolution, + discoveryPath: moduleResolution.resolutionPath as string, + }) + + expect(resources).toBeDefined() + expect(resources.services).toHaveLength(1) + expect(resources.services[0]).toEqual(ModuleService) + expect(resources.models).toHaveLength(2) + expect(resources.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: upperCaseFirst(dmlEntity.name) }), + expect.objectContaining({ name: upperCaseFirst(entityModel.name) }), + ]) + ) + expect(resources.repositories).toHaveLength(0) + expect(resources.loaders).toHaveLength(2) + expect(resources.loaders).toEqual([ + expect.objectContaining({ name: "connectionLoader" }), + expect.objectContaining({ name: "containerLoader" }), ]) - ) - expect(resources.repositories).toHaveLength(0) - expect(resources.loaders).toHaveLength(2) - expect(resources.loaders).toEqual([ - expect.objectContaining({ name: "connectionLoader" }), - expect.objectContaining({ name: "containerLoader" }), - ]) - expect(resources.moduleService).toEqual(ModuleService) + expect(resources.moduleService).toEqual(ModuleService) - expect( - (resources.moduleService.prototype as IModuleService).__joinerConfig - ).toBeDefined() + expect( + (resources.moduleService.prototype as IModuleService).__joinerConfig + ).toBeDefined() - const generatedJoinerConfig = ( - resources.moduleService.prototype as IModuleService - ).__joinerConfig?.()! + const generatedJoinerConfig = ( + resources.moduleService.prototype as IModuleService + ).__joinerConfig?.()! - expect(generatedJoinerConfig).toEqual( - expect.objectContaining({ - serviceName: "module-with-dml-mixed-without-joiner-config", + expect(generatedJoinerConfig).toEqual( + expect.objectContaining({ + serviceName: "module-with-dml-without-joiner-config", + primaryKeys: ["id"], + linkableKeys: { + entity_model_id: "EntityModel", + dml_entity_id: "DmlEntity", + }, + alias: [ + { + name: ["entity_model", "entity_models"], + entity: "EntityModel", + args: { + methodSuffix: "EntityModels", + }, + }, + { + name: ["dml_entity", "dml_entities"], + entity: "DmlEntity", + args: { + methodSuffix: "DmlEntities", + }, + }, + ], + }) + ) + }) + + test("should return the correct resources and generate the correct joiner config from mikro orm entities", async () => { + const { ModuleService, EntityModel, Entity2 } = + ModuleWithoutJoinerConfigFixtures + + const moduleResolution: ModuleResolution = { + resolutionPath: join( + __dirname, + "../__fixtures__/module-without-joiner-config" + ), + definition: { + key: "module-without-joiner-config", + label: "Module without joiner config", + defaultPackage: false, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + } + + expect( + (ModuleService.prototype as IModuleService).__joinerConfig + ).toBeUndefined() + + const resources = await loadResources({ + moduleResolution, + discoveryPath: moduleResolution.resolutionPath as string, + }) + + expect(resources).toBeDefined() + expect(resources.services).toHaveLength(1) + expect(resources.services[0]).toEqual(ModuleService) + expect(resources.models).toHaveLength(2) + expect(resources.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: upperCaseFirst(EntityModel.name) }), + expect.objectContaining({ name: upperCaseFirst(Entity2.name) }), + ]) + ) + expect(resources.repositories).toHaveLength(0) + expect(resources.loaders).toHaveLength(2) + expect(resources.loaders).toEqual([ + expect.objectContaining({ name: "connectionLoader" }), + expect.objectContaining({ name: "containerLoader" }), + ]) + expect(resources.moduleService).toEqual(ModuleService) + + expect( + (resources.moduleService.prototype as IModuleService).__joinerConfig + ).toBeDefined() + + const generatedJoinerConfig = ( + resources.moduleService.prototype as IModuleService + ).__joinerConfig?.()! + + expect(generatedJoinerConfig).toEqual({ + serviceName: "module-without-joiner-config", primaryKeys: ["id"], linkableKeys: { - dml_entity_id: "DmlEntity", + entity2_id: "Entity2", entity_model_id: "EntityModel", }, + schema: "", alias: [ { - name: ["dml_entity", "dml_entities"], - entity: "DmlEntity", + name: ["entity2", "entity2s"], + entity: "Entity2", args: { - methodSuffix: "DmlEntities", + methodSuffix: "Entity2s", }, }, { @@ -91,240 +265,181 @@ describe("load internal - load resources", () => { }, ], }) - ) - }) - - test("should return the correct resources and generate the correct joiner config from DML entities", async () => { - const { ModuleService, entityModel, dmlEntity } = - ModuleWithDmlWithoutJoinerConfigFixtures - - const moduleResolution: ModuleResolution = { - resolutionPath: join( - __dirname, - "../__fixtures__/module-with-dml-without-joiner-config" - ), - definition: { - key: "module-with-dml-without-joiner-config", - label: "Module with DML without joiner config", - defaultPackage: false, - defaultModuleDeclaration: { - scope: "internal", - resources: "shared", - }, - }, - } - - expect( - (ModuleService.prototype as IModuleService).__joinerConfig - ).toBeUndefined() - - const resources = await loadResources({ - moduleResolution, - discoveryPath: moduleResolution.resolutionPath as string, }) - expect(resources).toBeDefined() - expect(resources.services).toHaveLength(1) - expect(resources.services[0]).toEqual(ModuleService) - expect(resources.models).toHaveLength(2) - expect(resources.models).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: upperCaseFirst(dmlEntity.name) }), - expect.objectContaining({ name: upperCaseFirst(entityModel.name) }), - ]) - ) - expect(resources.repositories).toHaveLength(0) - expect(resources.loaders).toHaveLength(2) - expect(resources.loaders).toEqual([ - expect.objectContaining({ name: "connectionLoader" }), - expect.objectContaining({ name: "containerLoader" }), - ]) - expect(resources.moduleService).toEqual(ModuleService) + test("should return the correct resources and use the given joiner config", async () => { + const { ModuleService, EntityModel, Entity2 } = + ModuleWithJoinerConfigFixtures - expect( - (resources.moduleService.prototype as IModuleService).__joinerConfig - ).toBeDefined() - - const generatedJoinerConfig = ( - resources.moduleService.prototype as IModuleService - ).__joinerConfig?.()! - - expect(generatedJoinerConfig).toEqual( - expect.objectContaining({ - serviceName: "module-with-dml-without-joiner-config", - primaryKeys: ["id"], - linkableKeys: { - entity_model_id: "EntityModel", - dml_entity_id: "DmlEntity", + const moduleResolution: ModuleResolution = { + resolutionPath: join( + __dirname, + "../__fixtures__/module-with-joiner-config" + ), + definition: { + key: "module-without-joiner-config", + label: "Module without joiner config", + defaultPackage: false, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, }, + } + + expect( + (ModuleService.prototype as IModuleService).__joinerConfig + ).toBeDefined() + + const resources = await loadResources({ + moduleResolution, + discoveryPath: moduleResolution.resolutionPath as string, + }) + + expect(resources).toBeDefined() + expect(resources.services).toHaveLength(1) + expect(resources.services[0]).toEqual(ModuleService) + expect(resources.models).toHaveLength(2) + expect(resources.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: upperCaseFirst(EntityModel.name) }), + expect.objectContaining({ name: upperCaseFirst(Entity2.name) }), + ]) + ) + expect(resources.repositories).toHaveLength(0) + expect(resources.loaders).toHaveLength(2) + expect(resources.loaders).toEqual([ + expect.objectContaining({ name: "connectionLoader" }), + expect.objectContaining({ name: "containerLoader" }), + ]) + expect(resources.moduleService).toEqual(ModuleService) + + const generatedJoinerConfig = ( + resources.moduleService.prototype as IModuleService + ).__joinerConfig?.()! + + expect(generatedJoinerConfig).toEqual({ + serviceName: "module-service", + primaryKeys: ["id"], + linkableKeys: {}, + schema: "", alias: [ { - name: ["entity_model", "entity_models"], - entity: "EntityModel", + name: ["custom_name"], + entity: "Custom", args: { - methodSuffix: "EntityModels", - }, - }, - { - name: ["dml_entity", "dml_entities"], - entity: "DmlEntity", - args: { - methodSuffix: "DmlEntities", + methodSuffix: "Customs", }, }, ], }) - ) - }) - - test("should return the correct resources and generate the correct joiner config from mikro orm entities", async () => { - const { ModuleService, EntityModel, Entity2 } = - ModuleWithoutJoinerConfigFixtures - - const moduleResolution: ModuleResolution = { - resolutionPath: join( - __dirname, - "../__fixtures__/module-without-joiner-config" - ), - definition: { - key: "module-without-joiner-config", - label: "Module without joiner config", - defaultPackage: false, - defaultModuleDeclaration: { - scope: "internal", - resources: "shared", - }, - }, - } - - expect( - (ModuleService.prototype as IModuleService).__joinerConfig - ).toBeUndefined() - - const resources = await loadResources({ - moduleResolution, - discoveryPath: moduleResolution.resolutionPath as string, - }) - - expect(resources).toBeDefined() - expect(resources.services).toHaveLength(1) - expect(resources.services[0]).toEqual(ModuleService) - expect(resources.models).toHaveLength(2) - expect(resources.models).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: upperCaseFirst(EntityModel.name) }), - expect.objectContaining({ name: upperCaseFirst(Entity2.name) }), - ]) - ) - expect(resources.repositories).toHaveLength(0) - expect(resources.loaders).toHaveLength(2) - expect(resources.loaders).toEqual([ - expect.objectContaining({ name: "connectionLoader" }), - expect.objectContaining({ name: "containerLoader" }), - ]) - expect(resources.moduleService).toEqual(ModuleService) - - expect( - (resources.moduleService.prototype as IModuleService).__joinerConfig - ).toBeDefined() - - const generatedJoinerConfig = ( - resources.moduleService.prototype as IModuleService - ).__joinerConfig?.()! - - expect(generatedJoinerConfig).toEqual({ - serviceName: "module-without-joiner-config", - primaryKeys: ["id"], - linkableKeys: { - entity2_id: "Entity2", - entity_model_id: "EntityModel", - }, - schema: "", - alias: [ - { - name: ["entity2", "entity2s"], - entity: "Entity2", - args: { - methodSuffix: "Entity2s", - }, - }, - { - name: ["entity_model", "entity_models"], - entity: "EntityModel", - args: { - methodSuffix: "EntityModels", - }, - }, - ], - }) - }) - - test("should return the correct resources and use the given joiner config", async () => { - const { ModuleService, EntityModel, Entity2 } = - ModuleWithJoinerConfigFixtures - - const moduleResolution: ModuleResolution = { - resolutionPath: join( - __dirname, - "../__fixtures__/module-with-joiner-config" - ), - definition: { - key: "module-without-joiner-config", - label: "Module without joiner config", - defaultPackage: false, - defaultModuleDeclaration: { - scope: "internal", - resources: "shared", - }, - }, - } - - expect( - (ModuleService.prototype as IModuleService).__joinerConfig - ).toBeDefined() - - const resources = await loadResources({ - moduleResolution, - discoveryPath: moduleResolution.resolutionPath as string, - }) - - expect(resources).toBeDefined() - expect(resources.services).toHaveLength(1) - expect(resources.services[0]).toEqual(ModuleService) - expect(resources.models).toHaveLength(2) - expect(resources.models).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: upperCaseFirst(EntityModel.name) }), - expect.objectContaining({ name: upperCaseFirst(Entity2.name) }), - ]) - ) - expect(resources.repositories).toHaveLength(0) - expect(resources.loaders).toHaveLength(2) - expect(resources.loaders).toEqual([ - expect.objectContaining({ name: "connectionLoader" }), - expect.objectContaining({ name: "containerLoader" }), - ]) - expect(resources.moduleService).toEqual(ModuleService) - - const generatedJoinerConfig = ( - resources.moduleService.prototype as IModuleService - ).__joinerConfig?.()! - - expect(generatedJoinerConfig).toEqual({ - serviceName: "module-service", - primaryKeys: ["id"], - linkableKeys: {}, - schema: "", - alias: [ - { - name: ["custom_name"], - entity: "Custom", - args: { - methodSuffix: "Customs", - }, - }, - ], }) }) }) + + describe("loadInternalModule", () => { + test("should load the module and its providers using their identifier", async () => { + const moduleResolution: ModuleResolution = { + resolutionPath: join( + __dirname, + "../__fixtures__/module-with-providers" + ), + moduleDeclaration: { + scope: "internal", + resources: "shared", + }, + definition: { + key: "module-with-providers", + label: "Module with providers", + defaultPackage: false, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + options: { + providers: [ + { + resolve: join( + __dirname, + "../__fixtures__/module-with-providers/provider-1" + ), + id: "provider-1-id", + options: { + api_key: "test", + }, + }, + ], + }, + } + + const container = createMedusaContainer() + await loadInternalModule({ + container: container, + resolution: moduleResolution, + logger: console as any, + }) + + const moduleService = container.resolve(moduleResolution.definition.key) + const provider = (moduleService as any).container[ + getProviderRegistrationKey( + ModuleServiceWithProviderProvider1.identifier + ) + ] + + expect(moduleService).toBeInstanceOf(ModuleServiceWithProvider) + expect(provider).toBeInstanceOf(ModuleServiceWithProviderProvider1) + }) + + test("should load the module and its providers using the provided id", async () => { + const moduleResolution: ModuleResolution = { + resolutionPath: join( + __dirname, + "../__fixtures__/module-with-providers" + ), + moduleDeclaration: { + scope: "internal", + resources: "shared", + }, + definition: { + key: "module-with-providers", + label: "Module with providers", + defaultPackage: false, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + options: { + providers: [ + { + resolve: join( + __dirname, + "../__fixtures__/module-with-providers/provider-2" + ), + id: "provider-2-id", + options: { + api_key: "test", + }, + }, + ], + }, + } + + const container = createMedusaContainer() + await loadInternalModule({ + container: container, + resolution: moduleResolution, + logger: console as any, + }) + + const moduleService = container.resolve(moduleResolution.definition.key) + const provider = (moduleService as any).container[ + getProviderRegistrationKey(moduleResolution.options!.providers![0].id) + ] + + expect(moduleService).toBeInstanceOf(ModuleServiceWithProvider) + expect(provider).toBeInstanceOf(ModuleServiceWithProviderProvider2) + }) + }) }) diff --git a/packages/core/modules-sdk/src/loaders/utils/load-internal.ts b/packages/core/modules-sdk/src/loaders/utils/load-internal.ts index 04417a20b4..630114424a 100644 --- a/packages/core/modules-sdk/src/loaders/utils/load-internal.ts +++ b/packages/core/modules-sdk/src/loaders/utils/load-internal.ts @@ -7,6 +7,9 @@ import { MedusaContainer, ModuleExports, ModuleLoaderFunction, + ModuleProvider, + ModuleProviderExports, + ModuleProviderLoaderFunction, ModuleResolution, } from "@medusajs/types" import { @@ -15,6 +18,8 @@ import { defineJoinerConfig, DmlEntity, dynamicImport, + isString, + MedusaModuleProviderType, MedusaModuleType, ModulesSdkUtils, toMikroOrmEntities, @@ -29,7 +34,7 @@ type ModuleResource = { services: Function[] models: Function[] repositories: Function[] - loaders: ModuleLoaderFunction[] + loaders: ModuleLoaderFunction[] | ModuleProviderLoaderFunction[] moduleService: Constructor normalizedPath: string } @@ -39,22 +44,36 @@ type MigrationFunction = ( moduleDeclaration?: InternalModuleDeclaration ) => Promise +type ResolvedModule = ModuleExports & { + discoveryPath: string +} + +type ResolvedModuleProvider = ModuleProviderExports & { + discoveryPath: string +} + +export const moduleProviderRegistrationKeyPrefix = "__providers__" + +/** + * Return the key used to register a module provider in the container + * @param {string} moduleKey + * @return {string} + */ +export function getProviderRegistrationKey(moduleKey: string): string { + return moduleProviderRegistrationKeyPrefix + moduleKey +} + export async function resolveModuleExports({ resolution, }: { resolution: ModuleResolution -}): Promise< - | (ModuleExports & { - discoveryPath: string - }) - | { error: any } -> { +}): Promise { let resolvedModuleExports: ModuleExports try { if (resolution.moduleExports) { // TODO: // If we want to benefit from the auto load mechanism, even if the module exports is provided, we need to ask for the module path - resolvedModuleExports = resolution.moduleExports + resolvedModuleExports = resolution.moduleExports as ModuleExports resolvedModuleExports.discoveryPath = resolution.resolutionPath as string } else { const module = await dynamicImport(resolution.resolutionPath as string) @@ -62,10 +81,12 @@ export async function resolveModuleExports({ if ("discoveryPath" in module) { const reExportedLoadedModule = await dynamicImport(module.discoveryPath) const discoveryPath = module.discoveryPath - resolvedModuleExports = reExportedLoadedModule.default + resolvedModuleExports = + reExportedLoadedModule.default ?? reExportedLoadedModule resolvedModuleExports.discoveryPath = discoveryPath as string } else { - resolvedModuleExports = (module as { default: ModuleExports }).default + resolvedModuleExports = + (module as { default: ModuleExports }).default ?? module resolvedModuleExports.discoveryPath = resolution.resolutionPath as string } @@ -90,13 +111,79 @@ export async function resolveModuleExports({ } } -export async function loadInternalModule( - container: MedusaContainer, - resolution: ModuleResolution, - logger: Logger, - migrationOnly?: boolean, - loaderOnly?: boolean +async function loadInternalProvider( + args: { + container: MedusaContainer + resolution: ModuleResolution + logger: Logger + migrationOnly?: boolean + loaderOnly?: boolean + }, + providers: ModuleProvider[] ): Promise<{ error?: Error } | void> { + const { container, resolution, logger, migrationOnly } = args + + const errors: { error?: Error }[] = [] + for (const provider of providers) { + const providerRes = provider.resolve as ModuleProviderExports + + const canLoadProvider = + providerRes && (isString(providerRes) || !providerRes?.services) + + if (!canLoadProvider) { + continue + } + + const res = await loadInternalModule({ + container, + resolution: { + ...resolution, + moduleExports: !isString(providerRes) ? providerRes : undefined, + definition: { + ...resolution.definition, + key: provider.id, + }, + resolutionPath: isString(provider.resolve) ? provider.resolve : false, + }, + logger, + migrationOnly, + loadingProviders: true, + }) + + if (res) { + errors.push(res) + } + } + + const errorMessages = errors.map((e) => e.error?.message).join("\n") + return errors.length + ? { + error: { + name: "ModuleProviderError", + message: `Errors while loading module providers for module ${resolution.definition.key}:\n${errorMessages}`, + stack: errors.map((e) => e.error?.stack).join("\n"), + }, + } + : undefined +} + +export async function loadInternalModule(args: { + container: MedusaContainer + resolution: ModuleResolution + logger: Logger + migrationOnly?: boolean + loaderOnly?: boolean + loadingProviders?: boolean +}): Promise<{ error?: Error } | void> { + const { + container, + resolution, + logger, + migrationOnly, + loaderOnly, + loadingProviders, + } = args + const keyName = !loaderOnly ? resolution.definition.key : resolution.definition.key + "__loaderOnly" @@ -121,7 +208,12 @@ export async function loadInternalModule( }) } - if (!loadedModule?.service && !moduleResources.moduleService) { + const loadedModule_ = loadedModule as ModuleExports + if ( + !loadingProviders && + !loadedModule_?.service && + !moduleResources.moduleService + ) { container.register({ [keyName]: asValue(undefined), }) @@ -133,20 +225,6 @@ export async function loadInternalModule( } } - if (migrationOnly) { - const moduleService_ = moduleResources.moduleService ?? loadedModule.service - - // Partially loaded module, only register the service __joinerConfig function to be able to resolve it later - const moduleService = { - __joinerConfig: moduleService_.prototype.__joinerConfig, - } - - container.register({ - [keyName]: asValue(moduleService), - }) - return - } - const localContainer = createMedusaContainer() const dependencies = resolution?.dependencies ?? [] @@ -177,6 +255,44 @@ export async function loadInternalModule( ) } + // if module has providers, load them + let providerOptions: any = undefined + if (!loadingProviders) { + const providers = (resolution?.options?.providers as any[]) ?? [] + + const res = await loadInternalProvider( + { + ...args, + container: localContainer, + }, + providers + ) + + if (res?.error) { + return res + } + } else { + providerOptions = (resolution?.options?.providers as any[]).find( + (p) => p.id === resolution.definition.key + )?.options + } + + if (migrationOnly && !loadingProviders) { + const moduleService_ = + moduleResources.moduleService ?? loadedModule_.service + + // Partially loaded module, only register the service __joinerConfig function to be able to resolve it later + const moduleService = { + __joinerConfig: moduleService_.prototype.__joinerConfig, + } + + container.register({ + [keyName]: asValue(moduleService), + }) + + return + } + const loaders = moduleResources.loaders ?? loadedModule?.loaders ?? [] const error = await runLoaders(loaders, { container, @@ -185,24 +301,56 @@ export async function loadInternalModule( resolution, loaderOnly, keyName, + providerOptions, }) if (error) { return error } - const moduleService = moduleResources.moduleService ?? loadedModule.service + if (loadingProviders) { + const loadedProvider_ = loadedModule as ModuleProviderExports - container.register({ - [keyName]: asFunction((cradle) => { - ;(moduleService as any).__type = MedusaModuleType - return new moduleService( - localContainer.cradle, - resolution.options, - resolution.moduleDeclaration + let moduleProviderServices = moduleResources.moduleService + ? [moduleResources.moduleService] + : loadedProvider_.services ?? loadedProvider_ + + if (!moduleProviderServices) { + return + } + + for (const moduleProviderService of moduleProviderServices) { + const modProvider_ = moduleProviderService as any + + modProvider_.identifier ??= keyName + modProvider_.__type = MedusaModuleProviderType + const registrationKey = getProviderRegistrationKey( + modProvider_.identifier ) - }).singleton(), - }) + container.register({ + [registrationKey]: asFunction((cradle) => { + ;(moduleProviderService as any).__type = MedusaModuleType + return new moduleProviderService( + localContainer.cradle, + resolution.options, + resolution.moduleDeclaration + ) + }).singleton(), + }) + } + } else { + const moduleService = moduleResources.moduleService ?? loadedModule_.service + container.register({ + [keyName]: asFunction((cradle) => { + ;(moduleService as any).__type = MedusaModuleType + return new moduleService( + localContainer.cradle, + resolution.options, + resolution.moduleDeclaration + ) + }).singleton(), + }) + } if (loaderOnly) { // The expectation is only to run the loader as standalone, so we do not need to register the service and we need to cleanup all services @@ -220,46 +368,114 @@ export async function loadModuleMigrations( revertMigration?: MigrationFunction generateMigration?: MigrationFunction }> { - const loadedModule = await resolveModuleExports({ + const mainLoadedModule = await resolveModuleExports({ resolution: { ...resolution, moduleExports }, }) - if ("error" in loadedModule) { - throw loadedModule.error - } + const loadedServices = [mainLoadedModule] as ( + | ResolvedModule + | ResolvedModuleProvider + )[] - try { - let runMigrations = loadedModule.runMigrations - let revertMigration = loadedModule.revertMigration - let generateMigration = loadedModule.generateMigration + if (Array.isArray(resolution?.options?.providers)) { + for (const provider of (resolution.options as any).providers) { + const providerRes = provider.resolve as ModuleProviderExports - if (!runMigrations || !revertMigration) { - const moduleResources = await loadResources({ - moduleResolution: resolution, - discoveryPath: loadedModule.discoveryPath, - loadedModuleLoaders: loadedModule?.loaders, - }) + const canLoadProvider = + providerRes && (isString(providerRes) || !providerRes?.services) - const migrationScriptOptions = { - moduleName: resolution.definition.key, - models: moduleResources.models, - pathToMigrations: join(moduleResources.normalizedPath, "migrations"), + if (!canLoadProvider) { + continue } - runMigrations ??= ModulesSdkUtils.buildMigrationScript( - migrationScriptOptions - ) + const loadedProvider = await resolveModuleExports({ + resolution: { + ...resolution, + moduleExports: !isString(providerRes) ? providerRes : undefined, + definition: { + ...resolution.definition, + key: provider.id, + }, + resolutionPath: isString(provider.resolve) ? provider.resolve : false, + }, + }) + loadedServices.push(loadedProvider as ResolvedModuleProvider) + } + } - revertMigration ??= ModulesSdkUtils.buildRevertMigrationScript( - migrationScriptOptions - ) + if ("error" in mainLoadedModule) { + throw mainLoadedModule.error + } - generateMigration ??= ModulesSdkUtils.buildGenerateMigrationScript( - migrationScriptOptions - ) + const runMigrationsFn: ((...args) => Promise)[] = [] + const revertMigrationFn: ((...args) => Promise)[] = [] + const generateMigrationFn: ((...args) => Promise)[] = [] + + try { + const migrationScripts: any[] = [] + for (const loadedModule of loadedServices) { + let runMigrationsCustom = loadedModule.runMigrations + let revertMigrationCustom = loadedModule.revertMigration + let generateMigrationCustom = loadedModule.generateMigration + + runMigrationsCustom && runMigrationsFn.push(runMigrationsCustom) + revertMigrationCustom && revertMigrationFn.push(revertMigrationCustom) + generateMigrationCustom && + generateMigrationFn.push(generateMigrationCustom) + + if (!runMigrationsCustom || !revertMigrationCustom) { + const moduleResources = await loadResources({ + moduleResolution: resolution, + discoveryPath: loadedModule.discoveryPath, + loadedModuleLoaders: loadedModule?.loaders, + }) + + migrationScripts.push({ + moduleName: resolution.definition.key, + models: moduleResources.models, + pathToMigrations: join(moduleResources.normalizedPath, "migrations"), + }) + } + + for (const migrationScriptOptions of migrationScripts) { + const migrationUp = + runMigrationsCustom ?? + ModulesSdkUtils.buildMigrationScript(migrationScriptOptions) + runMigrationsFn.push(migrationUp) + + const migrationDown = + revertMigrationCustom ?? + ModulesSdkUtils.buildRevertMigrationScript(migrationScriptOptions) + revertMigrationFn.push(migrationDown) + + const genMigration = + generateMigrationCustom ?? + ModulesSdkUtils.buildGenerateMigrationScript(migrationScriptOptions) + generateMigrationFn.push(genMigration) + } } - return { runMigrations, revertMigration, generateMigration } + const runMigrations = async (...args) => { + for (const migration of runMigrationsFn.filter(Boolean)) { + await migration.apply(migration, args) + } + } + const revertMigration = async (...args) => { + for (const migration of revertMigrationFn.filter(Boolean)) { + await migration.apply(migration, args) + } + } + const generateMigration = async (...args) => { + for (const migration of generateMigrationFn.filter(Boolean)) { + await migration.apply(migration, args) + } + } + + return { + runMigrations, + revertMigration, + generateMigration, + } } catch { return {} } @@ -308,7 +524,7 @@ export async function loadResources({ moduleResolution: ModuleResolution discoveryPath: string logger?: Logger - loadedModuleLoaders?: ModuleLoaderFunction[] + loadedModuleLoaders?: ModuleLoaderFunction[] | ModuleProviderLoaderFunction[] }): Promise { logger ??= console as unknown as Logger loadedModuleLoaders ??= [] @@ -324,7 +540,8 @@ export async function loadResources({ const [moduleService, services, models, repositories] = await Promise.all([ dynamicImport(modulePath).then((moduleExports) => { - return moduleExports.default.service + const mod = moduleExports.default ?? moduleExports + return mod.service }), importAllFromDir(resolve(normalizedPath, "services")).catch( defaultOnFail @@ -365,11 +582,14 @@ export async function loadResources({ migrationPath: normalizedPath + "/migrations", }) - generateJoinerConfigIfNecessary({ - moduleResolution, - service: moduleService, - models: potentialModels, - }) + // if a module service is provided, we generate a joiner config + if (moduleService) { + generateJoinerConfigIfNecessary({ + moduleResolution, + service: moduleService, + models: potentialModels, + }) + } return { services: potentialServices, @@ -390,7 +610,15 @@ export async function loadResources({ async function runLoaders( loaders: Function[] = [], - { localContainer, container, logger, resolution, loaderOnly, keyName } + { + localContainer, + container, + logger, + resolution, + loaderOnly, + keyName, + providerOptions, + } ): Promise { try { for (const loader of loaders) { @@ -398,8 +626,9 @@ async function runLoaders( { container: localContainer, logger, - options: resolution.options, + options: providerOptions ?? resolution.options, dataLoaderOnly: loaderOnly, + moduleOptions: providerOptions ? resolution.options : undefined, }, resolution.moduleDeclaration as InternalModuleDeclaration ) @@ -418,14 +647,17 @@ async function runLoaders( } function prepareLoaders({ - loadedModuleLoaders = [] as ModuleLoaderFunction[], + loadedModuleLoaders = [] as + | ModuleLoaderFunction[] + | ModuleProviderLoaderFunction[], models, repositories, services, moduleResolution, migrationPath, }) { - const finalLoaders: ModuleLoaderFunction[] = [] + const finalLoaders: (ModuleLoaderFunction | ModuleProviderLoaderFunction)[] = + [] const toObjectReducer = (acc, curr) => { acc[curr.name] = curr diff --git a/packages/core/modules-sdk/src/medusa-app.ts b/packages/core/modules-sdk/src/medusa-app.ts index 0b7e4ad904..0c737fbe7d 100644 --- a/packages/core/modules-sdk/src/medusa-app.ts +++ b/packages/core/modules-sdk/src/medusa-app.ts @@ -510,7 +510,7 @@ async function MedusaApp_({ modulePath: moduleResolution.resolutionPath as string, container: sharedContainer, options: moduleResolution.options, - moduleExports: moduleResolution.moduleExports, + moduleExports: moduleResolution.moduleExports as ModuleExports, } if (action === "revert") { diff --git a/packages/core/types/src/modules-sdk/index.ts b/packages/core/types/src/modules-sdk/index.ts index e953c9de25..6713449154 100644 --- a/packages/core/types/src/modules-sdk/index.ts +++ b/packages/core/types/src/modules-sdk/index.ts @@ -3,6 +3,7 @@ import { JoinerRelationship, JoinerServiceConfig } from "../joiner" import { MedusaContainer } from "../common" import { RepositoryService } from "../dal" import { Logger } from "../logger" +import { ModuleProviderExports } from "./module-provider" import { RemoteQueryGraph, RemoteQueryInput, @@ -86,7 +87,7 @@ export type ModuleResolution = { options?: Record dependencies?: string[] moduleDeclaration?: InternalModuleDeclaration | ExternalModuleDeclaration - moduleExports?: ModuleExports + moduleExports?: ModuleExports | ModuleProviderExports } export type ModuleDefinition = { diff --git a/packages/core/types/src/modules-sdk/module-provider.ts b/packages/core/types/src/modules-sdk/module-provider.ts index 33524247ab..851154fd66 100644 --- a/packages/core/types/src/modules-sdk/module-provider.ts +++ b/packages/core/types/src/modules-sdk/module-provider.ts @@ -1,11 +1,47 @@ -import { Constructor } from "./index" +import { Logger } from "../logger" +import { + Constructor, + InternalModuleDeclaration, + MedusaContainer, +} from "./index" -export type ModuleProviderExports = { - services: Constructor[] +export type ProviderLoaderOptions> = { + container: MedusaContainer + options?: TOptions + logger?: Logger + moduleOptions: Record } +export type ModuleProviderExports = { + module?: string + services: Constructor[] + loaders?: ModuleProviderLoaderFunction[] + runMigrations?( + options: ProviderLoaderOptions, + moduleDeclaration?: any + ): Promise + revertMigration?( + options: ProviderLoaderOptions, + moduleDeclaration?: any + ): Promise + generateMigration?( + options: ProviderLoaderOptions, + moduleDeclaration?: any + ): Promise + /** + * Explicitly set the the true location of the module resources. + * Can be used to re-export the module from a different location and specify its original location. + */ + discoveryPath?: string +} + +export type ModuleProviderLoaderFunction = ( + options: ProviderLoaderOptions, + moduleDeclaration?: InternalModuleDeclaration +) => Promise + export type ModuleProvider = { - resolve: string | ModuleProviderExports + resolve: string | ModuleProviderExports id: string options?: Record is_default?: boolean diff --git a/packages/core/utils/src/common/__tests__/define-config.spec.ts b/packages/core/utils/src/common/__tests__/define-config.spec.ts index eaea563ed0..efb414002d 100644 --- a/packages/core/utils/src/common/__tests__/define-config.spec.ts +++ b/packages/core/utils/src/common/__tests__/define-config.spec.ts @@ -66,6 +66,9 @@ describe("defineConfig", function () { "inventory": { "resolve": "@medusajs/medusa/inventory-next", }, + "locking": { + "resolve": "@medusajs/medusa/locking", + }, "notification": { "options": { "providers": [ @@ -213,6 +216,9 @@ describe("defineConfig", function () { "inventory": { "resolve": "@medusajs/medusa/inventory-next", }, + "locking": { + "resolve": "@medusajs/medusa/locking", + }, "notification": { "options": { "providers": [ @@ -368,6 +374,9 @@ describe("defineConfig", function () { "inventory": { "resolve": "@medusajs/medusa/inventory-next", }, + "locking": { + "resolve": "@medusajs/medusa/locking", + }, "notification": { "options": { "providers": [ @@ -524,6 +533,9 @@ describe("defineConfig", function () { "inventory": { "resolve": "@medusajs/medusa/inventory-next", }, + "locking": { + "resolve": "@medusajs/medusa/locking", + }, "notification": { "options": { "providers": [ @@ -668,6 +680,9 @@ describe("defineConfig", function () { "inventory": { "resolve": "@medusajs/medusa/inventory-next", }, + "locking": { + "resolve": "@medusajs/medusa/locking", + }, "notification": { "options": { "providers": [ @@ -812,6 +827,9 @@ describe("defineConfig", function () { "inventory": { "resolve": "@medusajs/medusa/inventory-next", }, + "locking": { + "resolve": "@medusajs/medusa/locking", + }, "notification": { "options": { "providers": [ diff --git a/packages/core/utils/src/common/define-config.ts b/packages/core/utils/src/common/define-config.ts index db2e489910..9f83dbdf35 100644 --- a/packages/core/utils/src/common/define-config.ts +++ b/packages/core/utils/src/common/define-config.ts @@ -8,10 +8,10 @@ import { Modules, REVERSED_MODULE_PACKAGE_NAMES, } from "../modules-sdk" -import { isString } from "./is-string" -import { resolveExports } from "./resolve-exports" import { isObject } from "./is-object" +import { isString } from "./is-string" import { normalizeImportPathWithSource } from "./normalize-import-path-with-source" +import { resolveExports } from "./resolve-exports" const DEFAULT_SECRET = "supersecret" const DEFAULT_ADMIN_URL = "http://localhost:9000" @@ -132,6 +132,7 @@ function resolveModules( { resolve: MODULE_PACKAGE_NAMES[Modules.CACHE] }, { resolve: MODULE_PACKAGE_NAMES[Modules.EVENT_BUS] }, { resolve: MODULE_PACKAGE_NAMES[Modules.WORKFLOW_ENGINE] }, + { resolve: MODULE_PACKAGE_NAMES[Modules.LOCKING] }, { resolve: MODULE_PACKAGE_NAMES[Modules.STOCK_LOCATION] }, { resolve: MODULE_PACKAGE_NAMES[Modules.INVENTORY] }, { resolve: MODULE_PACKAGE_NAMES[Modules.PRODUCT] }, diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 6510e4470c..5142f288da 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -1,4 +1,3 @@ -export * from "./graphql" export * from "./api-key" export * from "./auth" export * from "./bundles" @@ -12,6 +11,7 @@ export * from "./exceptions" export * from "./feature-flags" export * from "./file" export * from "./fulfillment" +export * from "./graphql" export * from "./inventory" export * from "./link" export * from "./modules-sdk" @@ -30,3 +30,4 @@ export * from "./totals/big-number" export * from "./user" export const MedusaModuleType = Symbol.for("MedusaModule") +export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider") diff --git a/packages/core/utils/src/modules-sdk/index.ts b/packages/core/utils/src/modules-sdk/index.ts index d5542028da..c2df880e87 100644 --- a/packages/core/utils/src/modules-sdk/index.ts +++ b/packages/core/utils/src/modules-sdk/index.ts @@ -15,6 +15,7 @@ export * from "./medusa-service" export * from "./migration-scripts" export * from "./mikro-orm-cli-config-builder" export * from "./module" +export * from "./module-provider" export * from "./query-context" export * from "./types/links-config" export * from "./types/medusa-service" diff --git a/packages/core/utils/src/modules-sdk/module-provider.ts b/packages/core/utils/src/modules-sdk/module-provider.ts new file mode 100644 index 0000000000..4381fd952b --- /dev/null +++ b/packages/core/utils/src/modules-sdk/module-provider.ts @@ -0,0 +1,19 @@ +import { ModuleProviderExports } from "@medusajs/types" + +/** + * Wrapper to build the module provider export + * + * @param serviceName // The name of the module the provider is for + * @param services // The array of services that the module provides + * @param loaders // The loaders that the module provider provides + */ +export function ModuleProvider( + serviceName: string, + { services, loaders }: ModuleProviderExports +): ModuleProviderExports { + return { + module: serviceName, + services, + loaders, + } +} diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 5c510c282a..1d611aa7f1 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -84,6 +84,7 @@ "@medusajs/inventory-next": "^0.0.3", "@medusajs/link-modules": "^0.2.11", "@medusajs/locking": "^0.0.1", + "@medusajs/locking-redis": "^0.0.1", "@medusajs/notification": "^0.1.2", "@medusajs/notification-local": "^0.0.1", "@medusajs/notification-sendgrid": "^0.0.1", diff --git a/packages/medusa/src/modules/locking-redis.ts b/packages/medusa/src/modules/locking-redis.ts new file mode 100644 index 0000000000..4c389a39e0 --- /dev/null +++ b/packages/medusa/src/modules/locking-redis.ts @@ -0,0 +1,6 @@ +import RedisLockingProvider from "@medusajs/locking-redis" + +export * from "@medusajs/locking-redis" + +export default RedisLockingProvider +export const discoveryPath = require.resolve("@medusajs/locking-redis") diff --git a/packages/modules/cache-redis/src/services/redis-cache.ts b/packages/modules/cache-redis/src/services/redis-cache.ts index b794dffdba..217af91fa1 100644 --- a/packages/modules/cache-redis/src/services/redis-cache.ts +++ b/packages/modules/cache-redis/src/services/redis-cache.ts @@ -76,14 +76,28 @@ class RedisCacheService implements ICacheService { * @param key */ async invalidate(key: string): Promise { - const keys = await this.redis.keys(this.getCacheKey(key)) - const pipeline = this.redis.pipeline() + const pattern = this.getCacheKey(key) + let cursor = "0" + do { + const result = await this.redis.scan( + cursor, + "MATCH", + pattern, + "COUNT", + 100 + ) + cursor = result[0] + const keys = result[1] - keys.forEach(function (key) { - pipeline.del(key) - }) + if (keys.length > 0) { + const deletePipeline = this.redis.pipeline() + for (const key of keys) { + deletePipeline.del(key) + } - await pipeline.exec() + await deletePipeline.exec() + } + } while (cursor !== "0") } /** diff --git a/packages/modules/locking/integration-tests/__tests__/index.spec.ts b/packages/modules/locking/integration-tests/__tests__/index.spec.ts index d6c44467e7..a264d95ac0 100644 --- a/packages/modules/locking/integration-tests/__tests__/index.spec.ts +++ b/packages/modules/locking/integration-tests/__tests__/index.spec.ts @@ -1,5 +1,5 @@ import { ILockingModule } from "@medusajs/framework/types" -import { Modules } from "@medusajs/framework/utils" +import { Modules, promiseAll } from "@medusajs/framework/utils" import { moduleIntegrationTestRunner } from "medusa-test-utils" import { setTimeout } from "node:timers/promises" @@ -63,7 +63,7 @@ moduleIntegrationTestRunner({ expect(userReleased).toBe(false) await expect(anotherUserLock).rejects.toThrowError( - `"key_name" is already locked.` + `Failed to acquire lock for key "key_name"` ) const releasing = await service.release("key_name", { @@ -82,16 +82,20 @@ moduleIntegrationTestRunner({ ownerId: "user_id_000", } - expect(service.acquire(keyToLock, user_1)).resolves.toBeUndefined() + await expect( + service.acquire(keyToLock, user_1) + ).resolves.toBeUndefined() - expect(service.acquire(keyToLock, user_1)).resolves.toBeUndefined() + await expect( + service.acquire(keyToLock, user_1) + ).resolves.toBeUndefined() - expect(service.acquire(keyToLock, user_2)).rejects.toThrowError( - `"${keyToLock}" is already locked.` + await expect(service.acquire(keyToLock, user_2)).rejects.toThrowError( + `Failed to acquire lock for key "${keyToLock}"` ) - expect(service.acquire(keyToLock, user_2)).rejects.toThrowError( - `"${keyToLock}" is already locked.` + await expect(service.acquire(keyToLock, user_2)).rejects.toThrowError( + `Failed to acquire lock for key "${keyToLock}"` ) await service.acquire(keyToLock, user_1) @@ -104,6 +108,40 @@ moduleIntegrationTestRunner({ const release = await service.release(keyToLock, user_1) expect(release).toBe(true) }) + + it("should fail to acquire the same key when no owner is provided", async () => { + const keyToLock = "mySpecialKey" + + const user_2 = { + ownerId: "user_id_000", + } + + await expect(service.acquire(keyToLock)).resolves.toBeUndefined() + + await expect(service.acquire(keyToLock)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await expect(service.acquire(keyToLock)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await expect(service.acquire(keyToLock, user_2)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await expect(service.acquire(keyToLock, user_2)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + const releaseNotLocked = await service.release(keyToLock, { + ownerId: "user_id_000", + }) + expect(releaseNotLocked).toBe(false) + + const release = await service.release(keyToLock) + expect(release).toBe(true) + }) }) it("should release lock in case of failure", async () => { @@ -118,5 +156,48 @@ moduleIntegrationTestRunner({ expect(fn_1).toBeCalledTimes(1) expect(fn_2).toBeCalledTimes(1) }) + + it("should release lock in case of timeout failure", async () => { + const fn_1 = jest.fn(async () => { + await setTimeout(1010) + return "fn_1" + }) + + const fn_2 = jest.fn(async () => { + return "fn_2" + }) + + const fn_3 = jest.fn(async () => { + return "fn_3" + }) + + const ops = [ + service + .execute("lock_key", fn_1, { + timeout: 1, + }) + .catch((e) => e), + + service + .execute("lock_key", fn_2, { + timeout: 1, + }) + .catch((e) => e), + + service + .execute("lock_key", fn_3, { + timeout: 2, + }) + .catch((e) => e), + ] + + const res = await promiseAll(ops) + + expect(res).toEqual(["fn_1", Error("Timed-out acquiring lock."), "fn_3"]) + + expect(fn_1).toHaveBeenCalledTimes(1) + expect(fn_2).toHaveBeenCalledTimes(0) + expect(fn_3).toHaveBeenCalledTimes(1) + }) }, }) diff --git a/packages/modules/locking/package.json b/packages/modules/locking/package.json index a78a0bdea0..15e20f26cb 100644 --- a/packages/modules/locking/package.json +++ b/packages/modules/locking/package.json @@ -8,6 +8,12 @@ "url": "https://github.com/medusajs/medusa", "directory": "packages/locking" }, + "files": [ + "dist", + "!dist/**/__tests__", + "!dist/**/__mocks__", + "!dist/**/__fixtures__" + ], "publishConfig": { "access": "public" }, diff --git a/packages/modules/locking/src/index.ts b/packages/modules/locking/src/index.ts index d432f2fa58..86a0ba40fe 100644 --- a/packages/modules/locking/src/index.ts +++ b/packages/modules/locking/src/index.ts @@ -1,6 +1,6 @@ import { Module, Modules } from "@medusajs/framework/utils" -import { LockingModuleService } from "@services" -import loadProviders from "./loaders/providers" +import { default as loadProviders } from "./loaders/providers" +import LockingModuleService from "./services/locking-module" export default Module(Modules.LOCKING, { service: LockingModuleService, diff --git a/packages/modules/locking/src/loaders/providers.ts b/packages/modules/locking/src/loaders/providers.ts index 7e59bb9c90..be2b39cc5b 100644 --- a/packages/modules/locking/src/loaders/providers.ts +++ b/packages/modules/locking/src/loaders/providers.ts @@ -11,19 +11,13 @@ import { LockingIdentifiersRegistrationName, LockingProviderRegistrationPrefix, } from "@types" -import { Lifetime, asFunction, asValue } from "awilix" +import { Lifetime, aliasTo, asFunction, asValue } from "awilix" import { InMemoryLockingProvider } from "../providers/in-memory" -const registrationFn = async (klass, container, pluginOptions) => { +const registrationFn = async (klass, container) => { const key = LockingProviderService.getRegistrationIdentifier(klass) - container.register({ - [LockingProviderRegistrationPrefix + key]: asFunction( - (cradle) => new klass(cradle, pluginOptions.options), - { - lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, - } - ), + [LockingProviderRegistrationPrefix + key]: aliasTo("__providers__" + key), }) container.registerAdd(LockingIdentifiersRegistrationName, asValue(key)) diff --git a/packages/modules/locking/src/providers/in-memory.ts b/packages/modules/locking/src/providers/in-memory.ts index acb327bb84..2374f8c442 100644 --- a/packages/modules/locking/src/providers/in-memory.ts +++ b/packages/modules/locking/src/providers/in-memory.ts @@ -38,24 +38,27 @@ export class InMemoryLockingProvider implements ILockingProvider { timeout?: number } ): Promise { - keys = Array.isArray(keys) ? keys : [keys] - - const timeoutSeconds = args?.timeout ?? 5 + const timeout = Math.max(args?.timeout ?? 5, 1) + const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout + const cancellationToken = { cancelled: false } const promises: Promise[] = [] if (timeoutSeconds > 0) { - promises.push(this.getTimeout(timeoutSeconds)) + promises.push(this.getTimeout(timeoutSeconds, cancellationToken)) } promises.push( - this.acquire(keys, { - awaitQueue: true, - }) + this.acquire_( + keys, + { + expire: timeoutSeconds, + awaitQueue: true, + }, + cancellationToken + ) ) - await Promise.race(promises).catch(async (err) => { - await this.release(keys) - }) + await Promise.race(promises) try { return await job() @@ -71,6 +74,18 @@ export class InMemoryLockingProvider implements ILockingProvider { expire?: number awaitQueue?: boolean } + ): Promise { + return this.acquire_(keys, args) + } + + async acquire_( + keys: string | string[], + args?: { + ownerId?: string | null + expire?: number + awaitQueue?: boolean + }, + cancellationToken?: { cancelled: boolean } ): Promise { keys = Array.isArray(keys) ? keys : [keys] const { ownerId, expire } = args ?? {} @@ -100,7 +115,7 @@ export class InMemoryLockingProvider implements ILockingProvider { continue } - if (lock.ownerId === ownerId) { + if (lock.ownerId !== null && lock.ownerId === ownerId) { if (expire) { lock.expiration = now + expire * 1000 this.locks.set(key, lock) @@ -111,10 +126,14 @@ export class InMemoryLockingProvider implements ILockingProvider { if (lock.currentPromise && args?.awaitQueue) { await lock.currentPromise.promise + if (cancellationToken?.cancelled) { + return + } + return this.acquire(keys, args) } - throw new Error(`"${key}" is already locked.`) + throw new Error(`Failed to acquire lock for key "${key}"`) } } @@ -166,9 +185,13 @@ export class InMemoryLockingProvider implements ILockingProvider { } } - private async getTimeout(seconds: number): Promise { + private async getTimeout( + seconds: number, + cancellationToken: { cancelled: boolean } + ): Promise { return new Promise((_, reject) => { setTimeout(() => { + cancellationToken.cancelled = true reject(new Error("Timed-out acquiring lock.")) }, seconds * 1000) }) diff --git a/packages/modules/providers/auth-emailpass/src/index.ts b/packages/modules/providers/auth-emailpass/src/index.ts index 1b5c666fb3..02f35016eb 100644 --- a/packages/modules/providers/auth-emailpass/src/index.ts +++ b/packages/modules/providers/auth-emailpass/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { EmailPassAuthService } from "./services/emailpass" const services = [EmailPassAuthService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.AUTH, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/auth-github/src/index.ts b/packages/modules/providers/auth-github/src/index.ts index 70fde0eefd..190e1639db 100644 --- a/packages/modules/providers/auth-github/src/index.ts +++ b/packages/modules/providers/auth-github/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { GithubAuthService } from "./services/github" const services = [GithubAuthService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.AUTH, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/auth-google/src/index.ts b/packages/modules/providers/auth-google/src/index.ts index 9245ab8756..4cea9bfbe5 100644 --- a/packages/modules/providers/auth-google/src/index.ts +++ b/packages/modules/providers/auth-google/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { GoogleAuthService } from "./services/google" const services = [GoogleAuthService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.AUTH, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/file-local/src/index.ts b/packages/modules/providers/file-local/src/index.ts index 92f1b8dbad..2803b6c867 100644 --- a/packages/modules/providers/file-local/src/index.ts +++ b/packages/modules/providers/file-local/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { LocalFileService } from "./services/local-file" const services = [LocalFileService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.FILE, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/file-s3/src/index.ts b/packages/modules/providers/file-s3/src/index.ts index 741be94297..4851232c6f 100644 --- a/packages/modules/providers/file-s3/src/index.ts +++ b/packages/modules/providers/file-s3/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { S3FileService } from "./services/s3-file" const services = [S3FileService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.FILE, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/fulfillment-manual/src/index.ts b/packages/modules/providers/fulfillment-manual/src/index.ts index f7652183da..93b20068a8 100644 --- a/packages/modules/providers/fulfillment-manual/src/index.ts +++ b/packages/modules/providers/fulfillment-manual/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { ManualFulfillmentService } from "./services/manual-fulfillment" const services = [ManualFulfillmentService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.FULFILLMENT, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/locking-redis/.gitignore b/packages/modules/providers/locking-redis/.gitignore new file mode 100644 index 0000000000..83cb36a41e --- /dev/null +++ b/packages/modules/providers/locking-redis/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +.DS_store +yarn.lock diff --git a/packages/modules/providers/locking-redis/integration-tests/__tests__/index.spec.ts b/packages/modules/providers/locking-redis/integration-tests/__tests__/index.spec.ts new file mode 100644 index 0000000000..960282039a --- /dev/null +++ b/packages/modules/providers/locking-redis/integration-tests/__tests__/index.spec.ts @@ -0,0 +1,220 @@ +import { ILockingModule } from "@medusajs/framework/types" +import { Modules, promiseAll } from "@medusajs/framework/utils" +import { moduleIntegrationTestRunner } from "medusa-test-utils" +import { setTimeout } from "node:timers/promises" + +jest.setTimeout(5000) + +const providerId = "locking-redis" +moduleIntegrationTestRunner({ + moduleName: Modules.LOCKING, + moduleOptions: { + providers: [ + { + id: providerId, + resolve: require.resolve("../../src"), + is_default: true, + options: { + redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379", + }, + }, + ], + }, + testSuite: ({ service }) => { + describe("Locking Module Service", () => { + let stock = 5 + function replenishStock() { + stock = 5 + } + function hasStock() { + return stock > 0 + } + async function reduceStock() { + await setTimeout(10) + stock-- + } + async function buy() { + if (hasStock()) { + await reduceStock() + return true + } + return false + } + + beforeEach(async () => { + await service.releaseAll() + }) + + it("should execute functions respecting the key locked", async () => { + // 10 parallel calls to buy should oversell the stock + const prom: any[] = [] + for (let i = 0; i < 10; i++) { + prom.push(buy()) + } + await Promise.all(prom) + expect(stock).toBe(-5) + + replenishStock() + + // 10 parallel calls to buy with lock should not oversell the stock + const promWLock: any[] = [] + for (let i = 0; i < 10; i++) { + promWLock.push(service.execute("item_1", buy)) + } + await Promise.all(promWLock) + + expect(stock).toBe(0) + }) + + it("should acquire lock and release it", async () => { + await service.acquire("key_name", { + ownerId: "user_id_123", + }) + + const userReleased = await service.release("key_name", { + ownerId: "user_id_456", + }) + const anotherUserLock = service.acquire("key_name", { + ownerId: "user_id_456", + }) + + expect(userReleased).toBe(false) + await expect(anotherUserLock).rejects.toThrow( + `Failed to acquire lock for key "key_name"` + ) + + const releasing = await service.release("key_name", { + ownerId: "user_id_123", + }) + + expect(releasing).toBe(true) + }) + + it("should acquire lock and release it during parallel calls", async () => { + const keyToLock = "mySpecialKey" + const user_1 = { + ownerId: "user_id_456", + } + const user_2 = { + ownerId: "user_id_000", + } + + await expect( + service.acquire(keyToLock, user_1) + ).resolves.toBeUndefined() + + await expect( + service.acquire(keyToLock, user_1) + ).resolves.toBeUndefined() + + await expect(service.acquire(keyToLock, user_2)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await expect(service.acquire(keyToLock, user_2)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await service.acquire(keyToLock, user_1) + + const releaseNotLocked = await service.release(keyToLock, { + ownerId: "user_id_000", + }) + expect(releaseNotLocked).toBe(false) + + const release = await service.release(keyToLock, user_1) + expect(release).toBe(true) + }) + + it("should fail to acquire the same key when no owner is provided", async () => { + const keyToLock = "mySpecialKey" + + const user_2 = { + ownerId: "user_id_000", + } + + await expect(service.acquire(keyToLock)).resolves.toBeUndefined() + + await expect(service.acquire(keyToLock)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await expect(service.acquire(keyToLock)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await expect(service.acquire(keyToLock, user_2)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + await expect(service.acquire(keyToLock, user_2)).rejects.toThrow( + `Failed to acquire lock for key "${keyToLock}"` + ) + + const releaseNotLocked = await service.release(keyToLock, { + ownerId: "user_id_000", + }) + expect(releaseNotLocked).toBe(false) + + const release = await service.release(keyToLock) + expect(release).toBe(true) + }) + }) + + it("should release lock in case of failure", async () => { + const fn_1 = jest.fn(async () => { + throw new Error("Error") + }) + const fn_2 = jest.fn(async () => {}) + + await service.execute("lock_key", fn_1).catch(() => {}) + await service.execute("lock_key", fn_2).catch(() => {}) + + expect(fn_1).toHaveBeenCalledTimes(1) + expect(fn_2).toHaveBeenCalledTimes(1) + }) + + it("should release lock in case of timeout failure", async () => { + const fn_1 = jest.fn(async () => { + await setTimeout(1010) + return "fn_1" + }) + + const fn_2 = jest.fn(async () => { + return "fn_2" + }) + + const fn_3 = jest.fn(async () => { + return "fn_3" + }) + + const ops = [ + service + .execute("lock_key", fn_1, { + timeout: 1, + }) + .catch((e) => e), + + service + .execute("lock_key", fn_2, { + timeout: 1, + }) + .catch((e) => e), + + service + .execute("lock_key", fn_3, { + timeout: 5, + }) + .catch((e) => e), + ] + + const res = await promiseAll(ops) + + expect(res).toEqual(["fn_1", Error("Timed-out acquiring lock."), "fn_3"]) + + expect(fn_1).toHaveBeenCalledTimes(1) + expect(fn_2).toHaveBeenCalledTimes(0) + expect(fn_3).toHaveBeenCalledTimes(1) + }) + }, +}) diff --git a/packages/modules/providers/locking-redis/jest.config.js b/packages/modules/providers/locking-redis/jest.config.js new file mode 100644 index 0000000000..818699559a --- /dev/null +++ b/packages/modules/providers/locking-redis/jest.config.js @@ -0,0 +1,10 @@ +const defineJestConfig = require("../../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + "^@utils": "/src/utils", + }, +}) diff --git a/packages/modules/providers/locking-redis/package.json b/packages/modules/providers/locking-redis/package.json new file mode 100644 index 0000000000..ae05355d09 --- /dev/null +++ b/packages/modules/providers/locking-redis/package.json @@ -0,0 +1,48 @@ +{ + "name": "@medusajs/locking-redis", + "version": "0.0.1", + "description": "Redis Lock for Medusa", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/locking-redis" + }, + "files": [ + "dist", + "!dist/**/__tests__", + "!dist/**/__mocks__", + "!dist/**/__fixtures__" + ], + "engines": { + "node": ">=20" + }, + "author": "Medusa", + "license": "MIT", + "devDependencies": { + "@medusajs/framework": "^0.0.1", + "@swc/core": "^1.7.28", + "@swc/jest": "^0.2.36", + "jest": "^29.7.0", + "rimraf": "^5.0.1", + "typescript": "^5.6.2" + }, + "peerDependencies": { + "@medusajs/framework": "^0.0.1" + }, + "dependencies": { + "ioredis": "^5.4.1" + }, + "scripts": { + "watch": "tsc --build --watch", + "watch:test": "tsc --build tsconfig.spec.json --watch", + "resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json", + "build": "rimraf dist && tsc --build && npm run resolve:aliases", + "test": "jest --passWithNoTests src", + "test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.spec.ts" + }, + "keywords": [ + "medusa-providers", + "medusa-providers-locking" + ] +} diff --git a/packages/modules/providers/locking-redis/src/index.ts b/packages/modules/providers/locking-redis/src/index.ts new file mode 100644 index 0000000000..ef5860a634 --- /dev/null +++ b/packages/modules/providers/locking-redis/src/index.ts @@ -0,0 +1,11 @@ +import { ModuleProvider, Modules } from "@medusajs/framework/utils" +import Loader from "./loaders" +import { RedisLockingProvider } from "./services/redis-lock" + +const services = [RedisLockingProvider] +const loaders = [Loader] + +export default ModuleProvider(Modules.LOCKING, { + services, + loaders, +}) diff --git a/packages/modules/providers/locking-redis/src/loaders/index.ts b/packages/modules/providers/locking-redis/src/loaders/index.ts new file mode 100644 index 0000000000..5f8ad5ce12 --- /dev/null +++ b/packages/modules/providers/locking-redis/src/loaders/index.ts @@ -0,0 +1,41 @@ +import { Modules } from "@medusajs/framework/utils" +import { ProviderLoaderOptions } from "@medusajs/types" +import { RedisCacheModuleOptions } from "@types" +import { asValue } from "awilix" +import Redis from "ioredis" + +export default async ({ + container, + logger, + options, + moduleOptions, +}: ProviderLoaderOptions): Promise => { + const { redisUrl, redisOptions, namespace } = + options as RedisCacheModuleOptions + + if (!redisUrl) { + throw Error( + `No "redisUrl" provided in "${Modules.LOCKING}" module, "locking-redis" provider options. It is required for the "locking-redis" Module provider.` + ) + } + + const connection = new Redis(redisUrl, { + // Lazy connect to properly handle connection errors + lazyConnect: true, + ...(redisOptions ?? {}), + }) + + try { + await connection.connect() + logger?.info(`Connection to Redis in "locking-redis" provider established`) + } catch (err) { + logger?.error( + `An error occurred while connecting to Redis in provider "locking-redis": ${err}` + ) + } + + container.register({ + redisClient: asValue(connection), + prefix: asValue(namespace ?? "medusa_lock:"), + }) +} diff --git a/packages/modules/providers/locking-redis/src/services/redis-lock.ts b/packages/modules/providers/locking-redis/src/services/redis-lock.ts new file mode 100644 index 0000000000..81c1e36517 --- /dev/null +++ b/packages/modules/providers/locking-redis/src/services/redis-lock.ts @@ -0,0 +1,280 @@ +import { promiseAll } from "@medusajs/framework/utils" +import { ILockingProvider } from "@medusajs/types" +import { RedisCacheModuleOptions } from "@types" +import { Redis } from "ioredis" +import { setTimeout } from "node:timers/promises" + +export class RedisLockingProvider implements ILockingProvider { + static identifier = "locking-redis" + + protected redisClient: Redis & { + acquireLock: ( + key: string, + ownerId: string, + ttl: number, + awaitQueue?: boolean + ) => Promise + releaseLock: (key: string, ownerId: string) => Promise + } + protected keyNamePrefix: string + protected waitLockingTimeout: number = 5 + protected defaultRetryInterval: number = 5 + protected maximumRetryInterval: number = 200 + + constructor({ redisClient, prefix }, options: RedisCacheModuleOptions) { + this.redisClient = redisClient + this.keyNamePrefix = prefix ?? "medusa_lock:" + + if (!isNaN(+options?.waitLockingTimeout!)) { + this.waitLockingTimeout = +options.waitLockingTimeout! + } + + if (!isNaN(+options?.defaultRetryInterval!)) { + this.defaultRetryInterval = +options.defaultRetryInterval! + } + + if (!isNaN(+options?.maximumRetryInterval!)) { + this.maximumRetryInterval = +options.maximumRetryInterval! + } + + // Define the custom command for acquiring locks + this.redisClient.defineCommand("acquireLock", { + numberOfKeys: 1, + lua: ` + local key = KEYS[1] + local ownerId = ARGV[1] + local ttl = tonumber(ARGV[2]) + local awaitQueue = ARGV[3] == 'true' + + local setArgs = {key, ownerId, 'NX'} + if ttl > 0 then + table.insert(setArgs, 'EX') + table.insert(setArgs, ttl) + end + + local setResult = redis.call('SET', unpack(setArgs)) + + if setResult then + return 1 + elseif not awaitQueue then + -- Key already exists; retrieve the current ownerId + local currentOwnerId = redis.call('GET', key) + if currentOwnerId == '*' then + return 0 + elseif currentOwnerId == ownerId then + setArgs = {key, ownerId, 'XX'} + if ttl > 0 then + table.insert(setArgs, 'EX') + table.insert(setArgs, ttl) + end + redis.call('SET', unpack(setArgs)) + return 1 + else + return 0 + end + else + return 0 + end + + `, + }) + + // Define the custom command for releasing locks + this.redisClient.defineCommand("releaseLock", { + numberOfKeys: 1, + lua: ` + local key = KEYS[1] + local ownerId = ARGV[1] + + if redis.call('GET', key) == ownerId then + return redis.call('DEL', key) + else + return 0 + end + `, + }) + } + + private getKeyName(key: string): string { + return `${this.keyNamePrefix}${key}` + } + + async execute( + keys: string | string[], + job: () => Promise, + args?: { + timeout?: number + } + ): Promise { + const timeout = Math.max(args?.timeout ?? this.waitLockingTimeout, 1) + const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout + + const cancellationToken = { cancelled: false } + const promises: Promise[] = [] + if (timeoutSeconds > 0) { + promises.push(this.getTimeout(timeoutSeconds, cancellationToken)) + } + + promises.push( + this.acquire_( + keys, + { + awaitQueue: true, + expire: args?.timeout ? timeoutSeconds : 0, + }, + cancellationToken + ) + ) + + await Promise.race(promises) + + try { + return await job() + } finally { + await this.release(keys) + } + } + + async acquire( + keys: string | string[], + args?: { + ownerId?: string + expire?: number + awaitQueue?: boolean + } + ): Promise { + return this.acquire_(keys, args) + } + + async acquire_( + keys: string | string[], + args?: { + ownerId?: string + expire?: number + awaitQueue?: boolean + }, + cancellationToken?: { cancelled: boolean } + ): Promise { + keys = Array.isArray(keys) ? keys : [keys] + + const timeout = Math.max(args?.expire ?? this.waitLockingTimeout, 1) + const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout + let retryTimes = 0 + + const ownerId = args?.ownerId ?? "*" + const awaitQueue = args?.awaitQueue ?? false + + const acquirePromises = keys.map(async (key) => { + const errMessage = `Failed to acquire lock for key "${key}"` + const keyName = this.getKeyName(key) + + const acquireLock = async () => { + while (true) { + if (cancellationToken?.cancelled) { + throw new Error(errMessage) + } + + const result = await this.redisClient.acquireLock( + keyName, + ownerId, + args?.expire ? timeoutSeconds : 0, + awaitQueue + ) + + if (result === 1) { + break + } else { + if (awaitQueue) { + // Wait for a short period before retrying + await setTimeout( + Math.min( + this.defaultRetryInterval + + (retryTimes / 10) * this.defaultRetryInterval, + this.maximumRetryInterval + ) + ) + retryTimes++ + } else { + throw new Error(errMessage) + } + } + } + } + + await acquireLock() + }) + + await promiseAll(acquirePromises) + } + + async release( + keys: string | string[], + args?: { + ownerId?: string | null + } + ): Promise { + const ownerId = args?.ownerId ?? "*" + keys = Array.isArray(keys) ? keys : [keys] + + const releasePromises = keys.map(async (key) => { + const keyName = this.getKeyName(key) + const result = await this.redisClient.releaseLock(keyName, ownerId) + return result === 1 + }) + + const results = await promiseAll(releasePromises) + + return results.every((released) => released) + } + + async releaseAll(args?: { ownerId?: string | null }): Promise { + const ownerId = args?.ownerId ?? "*" + + const pattern = `${this.keyNamePrefix}*` + let cursor = "0" + + do { + const result = await this.redisClient.scan( + cursor, + "MATCH", + pattern, + "COUNT", + 100 + ) + cursor = result[0] + const keys = result[1] + + if (keys.length > 0) { + const pipeline = this.redisClient.pipeline() + + keys.forEach((key) => { + pipeline.get(key) + }) + + const currentOwners = await pipeline.exec() + + const deletePipeline = this.redisClient.pipeline() + keys.forEach((key, idx) => { + const currentOwner = currentOwners?.[idx]?.[1] + + if (currentOwner === ownerId) { + deletePipeline.del(key) + } + }) + + await deletePipeline.exec() + } + } while (cursor !== "0") + } + + private async getTimeout( + seconds: number, + cancellationToken: { cancelled: boolean } + ): Promise { + return new Promise(async (_, reject) => { + await setTimeout(seconds * 1000) + cancellationToken.cancelled = true + reject(new Error("Timed-out acquiring lock.")) + }) + } +} diff --git a/packages/modules/providers/locking-redis/src/types/index.ts b/packages/modules/providers/locking-redis/src/types/index.ts new file mode 100644 index 0000000000..702e0df1ec --- /dev/null +++ b/packages/modules/providers/locking-redis/src/types/index.ts @@ -0,0 +1,45 @@ +import { RedisOptions } from "ioredis" + +/** + * Module config type + */ +export type RedisCacheModuleOptions = { + /** + * Time to keep data in cache (in seconds) + */ + ttl?: number + + /** + * Redis connection string + */ + redisUrl?: string + + /** + * Redis client options + */ + redisOptions?: RedisOptions + + /** + * Prefix for event keys + * @default `medusa_lock:` + */ + namespace?: string + + /** + * Time to wait for lock (in seconds) + * @default 5 + */ + waitLockingTimeout?: number + + /** + * Default retry interval (in milliseconds) + * @default 5 + */ + defaultRetryInterval?: number + + /** + * Maximum retry interval (in milliseconds) + * @default 200 + */ + maximumRetryInterval?: number +} diff --git a/packages/modules/providers/locking-redis/tsconfig.json b/packages/modules/providers/locking-redis/tsconfig.json new file mode 100644 index 0000000000..90f3a70b38 --- /dev/null +++ b/packages/modules/providers/locking-redis/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../../_tsconfig.base.json", + "compilerOptions": { + "paths": { + "@models": ["./src/models"], + "@services": ["./src/services"], + "@repositories": ["./src/repositories"], + "@types": ["./src/types"], + "@utils": ["./src/utils"] + } + } +} diff --git a/packages/modules/providers/notification-local/src/index.ts b/packages/modules/providers/notification-local/src/index.ts index c0efc3f3e7..7695781064 100644 --- a/packages/modules/providers/notification-local/src/index.ts +++ b/packages/modules/providers/notification-local/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { LocalNotificationService } from "./services/local" const services = [LocalNotificationService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.NOTIFICATION, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/notification-sendgrid/src/index.ts b/packages/modules/providers/notification-sendgrid/src/index.ts index f49098cb2d..f083881d10 100644 --- a/packages/modules/providers/notification-sendgrid/src/index.ts +++ b/packages/modules/providers/notification-sendgrid/src/index.ts @@ -1,10 +1,8 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { SendgridNotificationService } from "./services/sendgrid" const services = [SendgridNotificationService] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.NOTIFICATION, { services, -} - -export default providerExport +}) diff --git a/packages/modules/providers/payment-stripe/src/index.ts b/packages/modules/providers/payment-stripe/src/index.ts index 5d7614763e..771c6b2b53 100644 --- a/packages/modules/providers/payment-stripe/src/index.ts +++ b/packages/modules/providers/payment-stripe/src/index.ts @@ -1,4 +1,4 @@ -import { ModuleProviderExports } from "@medusajs/framework/types" +import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { StripeBancontactService, StripeBlikService, @@ -17,8 +17,6 @@ const services = [ StripePrzelewy24Service, ] -const providerExport: ModuleProviderExports = { +export default ModuleProvider(Modules.PAYMENT, { services, -} - -export default providerExport +}) diff --git a/yarn.lock b/yarn.lock index 7be54ccba1..d465b522f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5738,6 +5738,22 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/locking-redis@^0.0.1, @medusajs/locking-redis@workspace:packages/modules/providers/locking-redis": + version: 0.0.0-use.local + resolution: "@medusajs/locking-redis@workspace:packages/modules/providers/locking-redis" + dependencies: + "@medusajs/framework": ^0.0.1 + "@swc/core": ^1.7.28 + "@swc/jest": ^0.2.36 + ioredis: ^5.4.1 + jest: ^29.7.0 + rimraf: ^5.0.1 + typescript: ^5.6.2 + peerDependencies: + "@medusajs/framework": ^0.0.1 + languageName: unknown + linkType: soft + "@medusajs/locking@^0.0.1, @medusajs/locking@workspace:packages/modules/locking": version: 0.0.0-use.local resolution: "@medusajs/locking@workspace:packages/modules/locking" @@ -5873,6 +5889,7 @@ __metadata: "@medusajs/inventory-next": ^0.0.3 "@medusajs/link-modules": ^0.2.11 "@medusajs/locking": ^0.0.1 + "@medusajs/locking-redis": ^0.0.1 "@medusajs/notification": ^0.1.2 "@medusajs/notification-local": ^0.0.1 "@medusajs/notification-sendgrid": ^0.0.1