feat(providers): locking redis (#9544)
This commit is contained in:
@@ -33,6 +33,7 @@ packages/*
|
|||||||
!packages/workflow-engine-inmemory
|
!packages/workflow-engine-inmemory
|
||||||
!packages/fulfillment
|
!packages/fulfillment
|
||||||
!packages/fulfillment-manual
|
!packages/fulfillment-manual
|
||||||
|
!packages/locking-redis
|
||||||
!packages/index
|
!packages/index
|
||||||
|
|
||||||
!packages/framework
|
!packages/framework
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ module.exports = {
|
|||||||
"./packages/modules/providers/file-s3/tsconfig.spec.json",
|
"./packages/modules/providers/file-s3/tsconfig.spec.json",
|
||||||
"./packages/modules/providers/fulfillment-manual/tsconfig.spec.json",
|
"./packages/modules/providers/fulfillment-manual/tsconfig.spec.json",
|
||||||
"./packages/modules/providers/payment-stripe/tsconfig.spec.json",
|
"./packages/modules/providers/payment-stripe/tsconfig.spec.json",
|
||||||
|
"./packages/modules/providers/locking-redis/tsconfig.spec.json",
|
||||||
|
|
||||||
"./packages/framework/tsconfig.json",
|
"./packages/framework/tsconfig.json",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ module.exports = {
|
|||||||
resolve: "@medusajs/cache-inmemory",
|
resolve: "@medusajs/cache-inmemory",
|
||||||
options: { ttl: 0 }, // Cache disabled
|
options: { ttl: 0 }, // Cache disabled
|
||||||
},
|
},
|
||||||
|
[Modules.LOCKING]: true,
|
||||||
[Modules.STOCK_LOCATION]: {
|
[Modules.STOCK_LOCATION]: {
|
||||||
resolve: "@medusajs/stock-location-next",
|
resolve: "@medusajs/stock-location-next",
|
||||||
options: {},
|
options: {},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { IInventoryService } from "@medusajs/framework/types"
|
|
||||||
import { MathBN, Modules } from "@medusajs/framework/utils"
|
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"
|
import { BigNumberInput } from "@medusajs/types"
|
||||||
|
|
||||||
export interface ReserveVariantInventoryStepInput {
|
export interface ReserveVariantInventoryStepInput {
|
||||||
@@ -22,34 +21,45 @@ export const reserveInventoryStepId = "reserve-inventory-step"
|
|||||||
export const reserveInventoryStep = createStep(
|
export const reserveInventoryStep = createStep(
|
||||||
reserveInventoryStepId,
|
reserveInventoryStepId,
|
||||||
async (data: ReserveVariantInventoryStepInput, { container }) => {
|
async (data: ReserveVariantInventoryStepInput, { container }) => {
|
||||||
const inventoryService = container.resolve<IInventoryService>(
|
const inventoryService = container.resolve(Modules.INVENTORY)
|
||||||
Modules.INVENTORY
|
|
||||||
)
|
|
||||||
|
|
||||||
const items = data.items.map((item) => ({
|
const locking = container.resolve(Modules.LOCKING)
|
||||||
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 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, {
|
return new StepResponse(reservations, {
|
||||||
reservations: reservations.map((r) => r.id),
|
reservations: reservations.map((r) => r.id),
|
||||||
|
inventoryItemIds,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async (data, { container }) => {
|
async (data, { container }) => {
|
||||||
if (!data) {
|
if (!data?.reservations?.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const inventoryService = container.resolve<IInventoryService>(
|
const inventoryService = container.resolve(Modules.INVENTORY)
|
||||||
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()
|
return new StepResponse()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IInventoryService, InventoryTypes } from "@medusajs/framework/types"
|
import { InventoryTypes } from "@medusajs/framework/types"
|
||||||
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
import { Modules } from "@medusajs/framework/utils"
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
@@ -10,22 +10,31 @@ export const createReservationsStepId = "create-reservations-step"
|
|||||||
export const createReservationsStep = createStep(
|
export const createReservationsStep = createStep(
|
||||||
createReservationsStepId,
|
createReservationsStepId,
|
||||||
async (data: InventoryTypes.CreateReservationItemInput[], { container }) => {
|
async (data: InventoryTypes.CreateReservationItemInput[], { container }) => {
|
||||||
const service = container.resolve<IInventoryService>(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(
|
const created = await locking.execute(inventoryItemIds, async () => {
|
||||||
created,
|
return await service.createReservationItems(data)
|
||||||
created.map((reservation) => reservation.id)
|
})
|
||||||
)
|
|
||||||
|
return new StepResponse(created, {
|
||||||
|
reservations: created.map((reservation) => reservation.id),
|
||||||
|
inventoryItemIds: inventoryItemIds,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async (createdIds, { container }) => {
|
async (data, { container }) => {
|
||||||
if (!createdIds?.length) {
|
if (!data?.reservations?.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = container.resolve<IInventoryService>(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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ async function loadModule(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return await loadInternalModule(
|
return await loadInternalModule({
|
||||||
container,
|
container,
|
||||||
resolution,
|
resolution,
|
||||||
logger,
|
logger,
|
||||||
migrationOnly,
|
migrationOnly,
|
||||||
loaderOnly
|
loaderOnly,
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -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)
|
||||||
+8
@@ -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],
|
||||||
|
})
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export class ModuleProviderService {
|
||||||
|
static identifier = "provider-1"
|
||||||
|
}
|
||||||
+8
@@ -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],
|
||||||
|
})
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export class ModuleProvider2Service {}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { InternalModuleDeclaration } from "@medusajs/types"
|
||||||
|
|
||||||
|
export class ModuleService {
|
||||||
|
constructor(
|
||||||
|
public container: Record<any, any>,
|
||||||
|
public moduleOptions: Record<any, any>,
|
||||||
|
public moduleDeclaration: InternalModuleDeclaration
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IModuleService, ModuleResolution } from "@medusajs/types"
|
import { IModuleService, ModuleResolution } from "@medusajs/types"
|
||||||
import { upperCaseFirst } from "@medusajs/utils"
|
import { createMedusaContainer, upperCaseFirst } from "@medusajs/utils"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import {
|
import {
|
||||||
ModuleWithDmlMixedWithoutJoinerConfigFixtures,
|
ModuleWithDmlMixedWithoutJoinerConfigFixtures,
|
||||||
@@ -7,79 +7,253 @@ import {
|
|||||||
ModuleWithJoinerConfigFixtures,
|
ModuleWithJoinerConfigFixtures,
|
||||||
ModuleWithoutJoinerConfigFixtures,
|
ModuleWithoutJoinerConfigFixtures,
|
||||||
} from "../__fixtures__"
|
} 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("load internal", () => {
|
||||||
describe("when loading the module resources from a path", () => {
|
describe("loadResources", () => {
|
||||||
test("should return the correct resources and generate the correct joiner config from a mix of DML entities and mikro orm entities", async () => {
|
describe("when loading the module resources from a path", () => {
|
||||||
const { ModuleService, EntityModel, dmlEntity } =
|
test("should return the correct resources and generate the correct joiner config from a mix of DML entities and mikro orm entities", async () => {
|
||||||
ModuleWithDmlMixedWithoutJoinerConfigFixtures
|
const { ModuleService, EntityModel, dmlEntity } =
|
||||||
|
ModuleWithDmlMixedWithoutJoinerConfigFixtures
|
||||||
|
|
||||||
const moduleResolution: ModuleResolution = {
|
const moduleResolution: ModuleResolution = {
|
||||||
resolutionPath: join(
|
resolutionPath: join(
|
||||||
__dirname,
|
__dirname,
|
||||||
"../__fixtures__/module-with-dml-mixed-without-joiner-config"
|
"../__fixtures__/module-with-dml-mixed-without-joiner-config"
|
||||||
),
|
),
|
||||||
definition: {
|
definition: {
|
||||||
key: "module-with-dml-mixed-without-joiner-config",
|
key: "module-with-dml-mixed-without-joiner-config",
|
||||||
label: "Module with DML mixed without joiner config",
|
label: "Module with DML mixed without joiner config",
|
||||||
defaultPackage: false,
|
defaultPackage: false,
|
||||||
defaultModuleDeclaration: {
|
defaultModuleDeclaration: {
|
||||||
scope: "internal",
|
scope: "internal",
|
||||||
resources: "shared",
|
resources: "shared",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(ModuleService.prototype as IModuleService).__joinerConfig
|
(ModuleService.prototype as IModuleService).__joinerConfig
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
|
|
||||||
const resources = await loadResources({
|
const resources = await loadResources({
|
||||||
moduleResolution,
|
moduleResolution,
|
||||||
discoveryPath: moduleResolution.resolutionPath as string,
|
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()
|
test("should return the correct resources and generate the correct joiner config from DML entities", async () => {
|
||||||
expect(resources.services).toHaveLength(1)
|
const { ModuleService, entityModel, dmlEntity } =
|
||||||
expect(resources.services[0]).toEqual(ModuleService)
|
ModuleWithDmlWithoutJoinerConfigFixtures
|
||||||
expect(resources.models).toHaveLength(2)
|
|
||||||
expect(resources.models).toEqual(
|
const moduleResolution: ModuleResolution = {
|
||||||
expect.arrayContaining([
|
resolutionPath: join(
|
||||||
expect.objectContaining({ name: upperCaseFirst(dmlEntity.name) }),
|
__dirname,
|
||||||
expect.objectContaining({ name: upperCaseFirst(EntityModel.name) }),
|
"../__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)
|
||||||
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(
|
expect(
|
||||||
(resources.moduleService.prototype as IModuleService).__joinerConfig
|
(resources.moduleService.prototype as IModuleService).__joinerConfig
|
||||||
).toBeDefined()
|
).toBeDefined()
|
||||||
|
|
||||||
const generatedJoinerConfig = (
|
const generatedJoinerConfig = (
|
||||||
resources.moduleService.prototype as IModuleService
|
resources.moduleService.prototype as IModuleService
|
||||||
).__joinerConfig?.()!
|
).__joinerConfig?.()!
|
||||||
|
|
||||||
expect(generatedJoinerConfig).toEqual(
|
expect(generatedJoinerConfig).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
serviceName: "module-with-dml-mixed-without-joiner-config",
|
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"],
|
primaryKeys: ["id"],
|
||||||
linkableKeys: {
|
linkableKeys: {
|
||||||
dml_entity_id: "DmlEntity",
|
entity2_id: "Entity2",
|
||||||
entity_model_id: "EntityModel",
|
entity_model_id: "EntityModel",
|
||||||
},
|
},
|
||||||
|
schema: "",
|
||||||
alias: [
|
alias: [
|
||||||
{
|
{
|
||||||
name: ["dml_entity", "dml_entities"],
|
name: ["entity2", "entity2s"],
|
||||||
entity: "DmlEntity",
|
entity: "Entity2",
|
||||||
args: {
|
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()
|
test("should return the correct resources and use the given joiner config", async () => {
|
||||||
expect(resources.services).toHaveLength(1)
|
const { ModuleService, EntityModel, Entity2 } =
|
||||||
expect(resources.services[0]).toEqual(ModuleService)
|
ModuleWithJoinerConfigFixtures
|
||||||
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(
|
const moduleResolution: ModuleResolution = {
|
||||||
(resources.moduleService.prototype as IModuleService).__joinerConfig
|
resolutionPath: join(
|
||||||
).toBeDefined()
|
__dirname,
|
||||||
|
"../__fixtures__/module-with-joiner-config"
|
||||||
const generatedJoinerConfig = (
|
),
|
||||||
resources.moduleService.prototype as IModuleService
|
definition: {
|
||||||
).__joinerConfig?.()!
|
key: "module-without-joiner-config",
|
||||||
|
label: "Module without joiner config",
|
||||||
expect(generatedJoinerConfig).toEqual(
|
defaultPackage: false,
|
||||||
expect.objectContaining({
|
defaultModuleDeclaration: {
|
||||||
serviceName: "module-with-dml-without-joiner-config",
|
scope: "internal",
|
||||||
primaryKeys: ["id"],
|
resources: "shared",
|
||||||
linkableKeys: {
|
},
|
||||||
entity_model_id: "EntityModel",
|
|
||||||
dml_entity_id: "DmlEntity",
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [
|
alias: [
|
||||||
{
|
{
|
||||||
name: ["entity_model", "entity_models"],
|
name: ["custom_name"],
|
||||||
entity: "EntityModel",
|
entity: "Custom",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "EntityModels",
|
methodSuffix: "Customs",
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: {
|
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
MedusaContainer,
|
MedusaContainer,
|
||||||
ModuleExports,
|
ModuleExports,
|
||||||
ModuleLoaderFunction,
|
ModuleLoaderFunction,
|
||||||
|
ModuleProvider,
|
||||||
|
ModuleProviderExports,
|
||||||
|
ModuleProviderLoaderFunction,
|
||||||
ModuleResolution,
|
ModuleResolution,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +18,8 @@ import {
|
|||||||
defineJoinerConfig,
|
defineJoinerConfig,
|
||||||
DmlEntity,
|
DmlEntity,
|
||||||
dynamicImport,
|
dynamicImport,
|
||||||
|
isString,
|
||||||
|
MedusaModuleProviderType,
|
||||||
MedusaModuleType,
|
MedusaModuleType,
|
||||||
ModulesSdkUtils,
|
ModulesSdkUtils,
|
||||||
toMikroOrmEntities,
|
toMikroOrmEntities,
|
||||||
@@ -29,7 +34,7 @@ type ModuleResource = {
|
|||||||
services: Function[]
|
services: Function[]
|
||||||
models: Function[]
|
models: Function[]
|
||||||
repositories: Function[]
|
repositories: Function[]
|
||||||
loaders: ModuleLoaderFunction[]
|
loaders: ModuleLoaderFunction[] | ModuleProviderLoaderFunction[]
|
||||||
moduleService: Constructor<any>
|
moduleService: Constructor<any>
|
||||||
normalizedPath: string
|
normalizedPath: string
|
||||||
}
|
}
|
||||||
@@ -39,22 +44,36 @@ type MigrationFunction = (
|
|||||||
moduleDeclaration?: InternalModuleDeclaration
|
moduleDeclaration?: InternalModuleDeclaration
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
|
||||||
|
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({
|
export async function resolveModuleExports({
|
||||||
resolution,
|
resolution,
|
||||||
}: {
|
}: {
|
||||||
resolution: ModuleResolution
|
resolution: ModuleResolution
|
||||||
}): Promise<
|
}): Promise<ResolvedModule | ResolvedModuleProvider | { error: any }> {
|
||||||
| (ModuleExports & {
|
|
||||||
discoveryPath: string
|
|
||||||
})
|
|
||||||
| { error: any }
|
|
||||||
> {
|
|
||||||
let resolvedModuleExports: ModuleExports
|
let resolvedModuleExports: ModuleExports
|
||||||
try {
|
try {
|
||||||
if (resolution.moduleExports) {
|
if (resolution.moduleExports) {
|
||||||
// TODO:
|
// 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
|
// 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
|
resolvedModuleExports.discoveryPath = resolution.resolutionPath as string
|
||||||
} else {
|
} else {
|
||||||
const module = await dynamicImport(resolution.resolutionPath as string)
|
const module = await dynamicImport(resolution.resolutionPath as string)
|
||||||
@@ -62,10 +81,12 @@ export async function resolveModuleExports({
|
|||||||
if ("discoveryPath" in module) {
|
if ("discoveryPath" in module) {
|
||||||
const reExportedLoadedModule = await dynamicImport(module.discoveryPath)
|
const reExportedLoadedModule = await dynamicImport(module.discoveryPath)
|
||||||
const discoveryPath = module.discoveryPath
|
const discoveryPath = module.discoveryPath
|
||||||
resolvedModuleExports = reExportedLoadedModule.default
|
resolvedModuleExports =
|
||||||
|
reExportedLoadedModule.default ?? reExportedLoadedModule
|
||||||
resolvedModuleExports.discoveryPath = discoveryPath as string
|
resolvedModuleExports.discoveryPath = discoveryPath as string
|
||||||
} else {
|
} else {
|
||||||
resolvedModuleExports = (module as { default: ModuleExports }).default
|
resolvedModuleExports =
|
||||||
|
(module as { default: ModuleExports }).default ?? module
|
||||||
resolvedModuleExports.discoveryPath =
|
resolvedModuleExports.discoveryPath =
|
||||||
resolution.resolutionPath as string
|
resolution.resolutionPath as string
|
||||||
}
|
}
|
||||||
@@ -90,13 +111,79 @@ export async function resolveModuleExports({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadInternalModule(
|
async function loadInternalProvider(
|
||||||
container: MedusaContainer,
|
args: {
|
||||||
resolution: ModuleResolution,
|
container: MedusaContainer
|
||||||
logger: Logger,
|
resolution: ModuleResolution
|
||||||
migrationOnly?: boolean,
|
logger: Logger
|
||||||
loaderOnly?: boolean
|
migrationOnly?: boolean
|
||||||
|
loaderOnly?: boolean
|
||||||
|
},
|
||||||
|
providers: ModuleProvider[]
|
||||||
): Promise<{ error?: Error } | void> {
|
): 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
|
const keyName = !loaderOnly
|
||||||
? resolution.definition.key
|
? resolution.definition.key
|
||||||
: resolution.definition.key + "__loaderOnly"
|
: 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({
|
container.register({
|
||||||
[keyName]: asValue(undefined),
|
[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 localContainer = createMedusaContainer()
|
||||||
|
|
||||||
const dependencies = resolution?.dependencies ?? []
|
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 loaders = moduleResources.loaders ?? loadedModule?.loaders ?? []
|
||||||
const error = await runLoaders(loaders, {
|
const error = await runLoaders(loaders, {
|
||||||
container,
|
container,
|
||||||
@@ -185,24 +301,56 @@ export async function loadInternalModule(
|
|||||||
resolution,
|
resolution,
|
||||||
loaderOnly,
|
loaderOnly,
|
||||||
keyName,
|
keyName,
|
||||||
|
providerOptions,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleService = moduleResources.moduleService ?? loadedModule.service
|
if (loadingProviders) {
|
||||||
|
const loadedProvider_ = loadedModule as ModuleProviderExports
|
||||||
|
|
||||||
container.register({
|
let moduleProviderServices = moduleResources.moduleService
|
||||||
[keyName]: asFunction((cradle) => {
|
? [moduleResources.moduleService]
|
||||||
;(moduleService as any).__type = MedusaModuleType
|
: loadedProvider_.services ?? loadedProvider_
|
||||||
return new moduleService(
|
|
||||||
localContainer.cradle,
|
if (!moduleProviderServices) {
|
||||||
resolution.options,
|
return
|
||||||
resolution.moduleDeclaration
|
}
|
||||||
|
|
||||||
|
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) {
|
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
|
// 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
|
revertMigration?: MigrationFunction
|
||||||
generateMigration?: MigrationFunction
|
generateMigration?: MigrationFunction
|
||||||
}> {
|
}> {
|
||||||
const loadedModule = await resolveModuleExports({
|
const mainLoadedModule = await resolveModuleExports({
|
||||||
resolution: { ...resolution, moduleExports },
|
resolution: { ...resolution, moduleExports },
|
||||||
})
|
})
|
||||||
|
|
||||||
if ("error" in loadedModule) {
|
const loadedServices = [mainLoadedModule] as (
|
||||||
throw loadedModule.error
|
| ResolvedModule
|
||||||
}
|
| ResolvedModuleProvider
|
||||||
|
)[]
|
||||||
|
|
||||||
try {
|
if (Array.isArray(resolution?.options?.providers)) {
|
||||||
let runMigrations = loadedModule.runMigrations
|
for (const provider of (resolution.options as any).providers) {
|
||||||
let revertMigration = loadedModule.revertMigration
|
const providerRes = provider.resolve as ModuleProviderExports
|
||||||
let generateMigration = loadedModule.generateMigration
|
|
||||||
|
|
||||||
if (!runMigrations || !revertMigration) {
|
const canLoadProvider =
|
||||||
const moduleResources = await loadResources({
|
providerRes && (isString(providerRes) || !providerRes?.services)
|
||||||
moduleResolution: resolution,
|
|
||||||
discoveryPath: loadedModule.discoveryPath,
|
|
||||||
loadedModuleLoaders: loadedModule?.loaders,
|
|
||||||
})
|
|
||||||
|
|
||||||
const migrationScriptOptions = {
|
if (!canLoadProvider) {
|
||||||
moduleName: resolution.definition.key,
|
continue
|
||||||
models: moduleResources.models,
|
|
||||||
pathToMigrations: join(moduleResources.normalizedPath, "migrations"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runMigrations ??= ModulesSdkUtils.buildMigrationScript(
|
const loadedProvider = await resolveModuleExports({
|
||||||
migrationScriptOptions
|
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(
|
if ("error" in mainLoadedModule) {
|
||||||
migrationScriptOptions
|
throw mainLoadedModule.error
|
||||||
)
|
}
|
||||||
|
|
||||||
generateMigration ??= ModulesSdkUtils.buildGenerateMigrationScript(
|
const runMigrationsFn: ((...args) => Promise<any>)[] = []
|
||||||
migrationScriptOptions
|
const revertMigrationFn: ((...args) => Promise<any>)[] = []
|
||||||
)
|
const generateMigrationFn: ((...args) => Promise<any>)[] = []
|
||||||
|
|
||||||
|
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 {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -308,7 +524,7 @@ export async function loadResources({
|
|||||||
moduleResolution: ModuleResolution
|
moduleResolution: ModuleResolution
|
||||||
discoveryPath: string
|
discoveryPath: string
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
loadedModuleLoaders?: ModuleLoaderFunction[]
|
loadedModuleLoaders?: ModuleLoaderFunction[] | ModuleProviderLoaderFunction[]
|
||||||
}): Promise<ModuleResource> {
|
}): Promise<ModuleResource> {
|
||||||
logger ??= console as unknown as Logger
|
logger ??= console as unknown as Logger
|
||||||
loadedModuleLoaders ??= []
|
loadedModuleLoaders ??= []
|
||||||
@@ -324,7 +540,8 @@ export async function loadResources({
|
|||||||
|
|
||||||
const [moduleService, services, models, repositories] = await Promise.all([
|
const [moduleService, services, models, repositories] = await Promise.all([
|
||||||
dynamicImport(modulePath).then((moduleExports) => {
|
dynamicImport(modulePath).then((moduleExports) => {
|
||||||
return moduleExports.default.service
|
const mod = moduleExports.default ?? moduleExports
|
||||||
|
return mod.service
|
||||||
}),
|
}),
|
||||||
importAllFromDir(resolve(normalizedPath, "services")).catch(
|
importAllFromDir(resolve(normalizedPath, "services")).catch(
|
||||||
defaultOnFail
|
defaultOnFail
|
||||||
@@ -365,11 +582,14 @@ export async function loadResources({
|
|||||||
migrationPath: normalizedPath + "/migrations",
|
migrationPath: normalizedPath + "/migrations",
|
||||||
})
|
})
|
||||||
|
|
||||||
generateJoinerConfigIfNecessary({
|
// if a module service is provided, we generate a joiner config
|
||||||
moduleResolution,
|
if (moduleService) {
|
||||||
service: moduleService,
|
generateJoinerConfigIfNecessary({
|
||||||
models: potentialModels,
|
moduleResolution,
|
||||||
})
|
service: moduleService,
|
||||||
|
models: potentialModels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
services: potentialServices,
|
services: potentialServices,
|
||||||
@@ -390,7 +610,15 @@ export async function loadResources({
|
|||||||
|
|
||||||
async function runLoaders(
|
async function runLoaders(
|
||||||
loaders: Function[] = [],
|
loaders: Function[] = [],
|
||||||
{ localContainer, container, logger, resolution, loaderOnly, keyName }
|
{
|
||||||
|
localContainer,
|
||||||
|
container,
|
||||||
|
logger,
|
||||||
|
resolution,
|
||||||
|
loaderOnly,
|
||||||
|
keyName,
|
||||||
|
providerOptions,
|
||||||
|
}
|
||||||
): Promise<void | { error: Error }> {
|
): Promise<void | { error: Error }> {
|
||||||
try {
|
try {
|
||||||
for (const loader of loaders) {
|
for (const loader of loaders) {
|
||||||
@@ -398,8 +626,9 @@ async function runLoaders(
|
|||||||
{
|
{
|
||||||
container: localContainer,
|
container: localContainer,
|
||||||
logger,
|
logger,
|
||||||
options: resolution.options,
|
options: providerOptions ?? resolution.options,
|
||||||
dataLoaderOnly: loaderOnly,
|
dataLoaderOnly: loaderOnly,
|
||||||
|
moduleOptions: providerOptions ? resolution.options : undefined,
|
||||||
},
|
},
|
||||||
resolution.moduleDeclaration as InternalModuleDeclaration
|
resolution.moduleDeclaration as InternalModuleDeclaration
|
||||||
)
|
)
|
||||||
@@ -418,14 +647,17 @@ async function runLoaders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prepareLoaders({
|
function prepareLoaders({
|
||||||
loadedModuleLoaders = [] as ModuleLoaderFunction[],
|
loadedModuleLoaders = [] as
|
||||||
|
| ModuleLoaderFunction[]
|
||||||
|
| ModuleProviderLoaderFunction[],
|
||||||
models,
|
models,
|
||||||
repositories,
|
repositories,
|
||||||
services,
|
services,
|
||||||
moduleResolution,
|
moduleResolution,
|
||||||
migrationPath,
|
migrationPath,
|
||||||
}) {
|
}) {
|
||||||
const finalLoaders: ModuleLoaderFunction[] = []
|
const finalLoaders: (ModuleLoaderFunction | ModuleProviderLoaderFunction)[] =
|
||||||
|
[]
|
||||||
|
|
||||||
const toObjectReducer = (acc, curr) => {
|
const toObjectReducer = (acc, curr) => {
|
||||||
acc[curr.name] = curr
|
acc[curr.name] = curr
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ async function MedusaApp_({
|
|||||||
modulePath: moduleResolution.resolutionPath as string,
|
modulePath: moduleResolution.resolutionPath as string,
|
||||||
container: sharedContainer,
|
container: sharedContainer,
|
||||||
options: moduleResolution.options,
|
options: moduleResolution.options,
|
||||||
moduleExports: moduleResolution.moduleExports,
|
moduleExports: moduleResolution.moduleExports as ModuleExports,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "revert") {
|
if (action === "revert") {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JoinerRelationship, JoinerServiceConfig } from "../joiner"
|
|||||||
import { MedusaContainer } from "../common"
|
import { MedusaContainer } from "../common"
|
||||||
import { RepositoryService } from "../dal"
|
import { RepositoryService } from "../dal"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { ModuleProviderExports } from "./module-provider"
|
||||||
import {
|
import {
|
||||||
RemoteQueryGraph,
|
RemoteQueryGraph,
|
||||||
RemoteQueryInput,
|
RemoteQueryInput,
|
||||||
@@ -86,7 +87,7 @@ export type ModuleResolution = {
|
|||||||
options?: Record<string, unknown>
|
options?: Record<string, unknown>
|
||||||
dependencies?: string[]
|
dependencies?: string[]
|
||||||
moduleDeclaration?: InternalModuleDeclaration | ExternalModuleDeclaration
|
moduleDeclaration?: InternalModuleDeclaration | ExternalModuleDeclaration
|
||||||
moduleExports?: ModuleExports
|
moduleExports?: ModuleExports | ModuleProviderExports
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleDefinition = {
|
export type ModuleDefinition = {
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
import { Constructor } from "./index"
|
import { Logger } from "../logger"
|
||||||
|
import {
|
||||||
|
Constructor,
|
||||||
|
InternalModuleDeclaration,
|
||||||
|
MedusaContainer,
|
||||||
|
} from "./index"
|
||||||
|
|
||||||
export type ModuleProviderExports = {
|
export type ProviderLoaderOptions<TOptions = Record<string, unknown>> = {
|
||||||
services: Constructor<any>[]
|
container: MedusaContainer
|
||||||
|
options?: TOptions
|
||||||
|
logger?: Logger
|
||||||
|
moduleOptions: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ModuleProviderExports<Service = any> = {
|
||||||
|
module?: string
|
||||||
|
services: Constructor<Service>[]
|
||||||
|
loaders?: ModuleProviderLoaderFunction[]
|
||||||
|
runMigrations?(
|
||||||
|
options: ProviderLoaderOptions<Service>,
|
||||||
|
moduleDeclaration?: any
|
||||||
|
): Promise<void>
|
||||||
|
revertMigration?(
|
||||||
|
options: ProviderLoaderOptions<Service>,
|
||||||
|
moduleDeclaration?: any
|
||||||
|
): Promise<void>
|
||||||
|
generateMigration?(
|
||||||
|
options: ProviderLoaderOptions<Service>,
|
||||||
|
moduleDeclaration?: any
|
||||||
|
): Promise<void>
|
||||||
|
/**
|
||||||
|
* 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<void>
|
||||||
|
|
||||||
export type ModuleProvider = {
|
export type ModuleProvider = {
|
||||||
resolve: string | ModuleProviderExports
|
resolve: string | ModuleProviderExports<any>
|
||||||
id: string
|
id: string
|
||||||
options?: Record<string, unknown>
|
options?: Record<string, unknown>
|
||||||
is_default?: boolean
|
is_default?: boolean
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ describe("defineConfig", function () {
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"resolve": "@medusajs/medusa/inventory-next",
|
"resolve": "@medusajs/medusa/inventory-next",
|
||||||
},
|
},
|
||||||
|
"locking": {
|
||||||
|
"resolve": "@medusajs/medusa/locking",
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"options": {
|
"options": {
|
||||||
"providers": [
|
"providers": [
|
||||||
@@ -213,6 +216,9 @@ describe("defineConfig", function () {
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"resolve": "@medusajs/medusa/inventory-next",
|
"resolve": "@medusajs/medusa/inventory-next",
|
||||||
},
|
},
|
||||||
|
"locking": {
|
||||||
|
"resolve": "@medusajs/medusa/locking",
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"options": {
|
"options": {
|
||||||
"providers": [
|
"providers": [
|
||||||
@@ -368,6 +374,9 @@ describe("defineConfig", function () {
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"resolve": "@medusajs/medusa/inventory-next",
|
"resolve": "@medusajs/medusa/inventory-next",
|
||||||
},
|
},
|
||||||
|
"locking": {
|
||||||
|
"resolve": "@medusajs/medusa/locking",
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"options": {
|
"options": {
|
||||||
"providers": [
|
"providers": [
|
||||||
@@ -524,6 +533,9 @@ describe("defineConfig", function () {
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"resolve": "@medusajs/medusa/inventory-next",
|
"resolve": "@medusajs/medusa/inventory-next",
|
||||||
},
|
},
|
||||||
|
"locking": {
|
||||||
|
"resolve": "@medusajs/medusa/locking",
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"options": {
|
"options": {
|
||||||
"providers": [
|
"providers": [
|
||||||
@@ -668,6 +680,9 @@ describe("defineConfig", function () {
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"resolve": "@medusajs/medusa/inventory-next",
|
"resolve": "@medusajs/medusa/inventory-next",
|
||||||
},
|
},
|
||||||
|
"locking": {
|
||||||
|
"resolve": "@medusajs/medusa/locking",
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"options": {
|
"options": {
|
||||||
"providers": [
|
"providers": [
|
||||||
@@ -812,6 +827,9 @@ describe("defineConfig", function () {
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"resolve": "@medusajs/medusa/inventory-next",
|
"resolve": "@medusajs/medusa/inventory-next",
|
||||||
},
|
},
|
||||||
|
"locking": {
|
||||||
|
"resolve": "@medusajs/medusa/locking",
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"options": {
|
"options": {
|
||||||
"providers": [
|
"providers": [
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
Modules,
|
Modules,
|
||||||
REVERSED_MODULE_PACKAGE_NAMES,
|
REVERSED_MODULE_PACKAGE_NAMES,
|
||||||
} from "../modules-sdk"
|
} from "../modules-sdk"
|
||||||
import { isString } from "./is-string"
|
|
||||||
import { resolveExports } from "./resolve-exports"
|
|
||||||
import { isObject } from "./is-object"
|
import { isObject } from "./is-object"
|
||||||
|
import { isString } from "./is-string"
|
||||||
import { normalizeImportPathWithSource } from "./normalize-import-path-with-source"
|
import { normalizeImportPathWithSource } from "./normalize-import-path-with-source"
|
||||||
|
import { resolveExports } from "./resolve-exports"
|
||||||
|
|
||||||
const DEFAULT_SECRET = "supersecret"
|
const DEFAULT_SECRET = "supersecret"
|
||||||
const DEFAULT_ADMIN_URL = "http://localhost:9000"
|
const DEFAULT_ADMIN_URL = "http://localhost:9000"
|
||||||
@@ -132,6 +132,7 @@ function resolveModules(
|
|||||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.CACHE] },
|
{ resolve: MODULE_PACKAGE_NAMES[Modules.CACHE] },
|
||||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.EVENT_BUS] },
|
{ resolve: MODULE_PACKAGE_NAMES[Modules.EVENT_BUS] },
|
||||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.WORKFLOW_ENGINE] },
|
{ 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.STOCK_LOCATION] },
|
||||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.INVENTORY] },
|
{ resolve: MODULE_PACKAGE_NAMES[Modules.INVENTORY] },
|
||||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.PRODUCT] },
|
{ resolve: MODULE_PACKAGE_NAMES[Modules.PRODUCT] },
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from "./graphql"
|
|
||||||
export * from "./api-key"
|
export * from "./api-key"
|
||||||
export * from "./auth"
|
export * from "./auth"
|
||||||
export * from "./bundles"
|
export * from "./bundles"
|
||||||
@@ -12,6 +11,7 @@ export * from "./exceptions"
|
|||||||
export * from "./feature-flags"
|
export * from "./feature-flags"
|
||||||
export * from "./file"
|
export * from "./file"
|
||||||
export * from "./fulfillment"
|
export * from "./fulfillment"
|
||||||
|
export * from "./graphql"
|
||||||
export * from "./inventory"
|
export * from "./inventory"
|
||||||
export * from "./link"
|
export * from "./link"
|
||||||
export * from "./modules-sdk"
|
export * from "./modules-sdk"
|
||||||
@@ -30,3 +30,4 @@ export * from "./totals/big-number"
|
|||||||
export * from "./user"
|
export * from "./user"
|
||||||
|
|
||||||
export const MedusaModuleType = Symbol.for("MedusaModule")
|
export const MedusaModuleType = Symbol.for("MedusaModule")
|
||||||
|
export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export * from "./medusa-service"
|
|||||||
export * from "./migration-scripts"
|
export * from "./migration-scripts"
|
||||||
export * from "./mikro-orm-cli-config-builder"
|
export * from "./mikro-orm-cli-config-builder"
|
||||||
export * from "./module"
|
export * from "./module"
|
||||||
|
export * from "./module-provider"
|
||||||
export * from "./query-context"
|
export * from "./query-context"
|
||||||
export * from "./types/links-config"
|
export * from "./types/links-config"
|
||||||
export * from "./types/medusa-service"
|
export * from "./types/medusa-service"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
"@medusajs/inventory-next": "^0.0.3",
|
"@medusajs/inventory-next": "^0.0.3",
|
||||||
"@medusajs/link-modules": "^0.2.11",
|
"@medusajs/link-modules": "^0.2.11",
|
||||||
"@medusajs/locking": "^0.0.1",
|
"@medusajs/locking": "^0.0.1",
|
||||||
|
"@medusajs/locking-redis": "^0.0.1",
|
||||||
"@medusajs/notification": "^0.1.2",
|
"@medusajs/notification": "^0.1.2",
|
||||||
"@medusajs/notification-local": "^0.0.1",
|
"@medusajs/notification-local": "^0.0.1",
|
||||||
"@medusajs/notification-sendgrid": "^0.0.1",
|
"@medusajs/notification-sendgrid": "^0.0.1",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -76,14 +76,28 @@ class RedisCacheService implements ICacheService {
|
|||||||
* @param key
|
* @param key
|
||||||
*/
|
*/
|
||||||
async invalidate(key: string): Promise<void> {
|
async invalidate(key: string): Promise<void> {
|
||||||
const keys = await this.redis.keys(this.getCacheKey(key))
|
const pattern = this.getCacheKey(key)
|
||||||
const pipeline = this.redis.pipeline()
|
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) {
|
if (keys.length > 0) {
|
||||||
pipeline.del(key)
|
const deletePipeline = this.redis.pipeline()
|
||||||
})
|
for (const key of keys) {
|
||||||
|
deletePipeline.del(key)
|
||||||
|
}
|
||||||
|
|
||||||
await pipeline.exec()
|
await deletePipeline.exec()
|
||||||
|
}
|
||||||
|
} while (cursor !== "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ILockingModule } from "@medusajs/framework/types"
|
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 { moduleIntegrationTestRunner } from "medusa-test-utils"
|
||||||
import { setTimeout } from "node:timers/promises"
|
import { setTimeout } from "node:timers/promises"
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ moduleIntegrationTestRunner<ILockingModule>({
|
|||||||
|
|
||||||
expect(userReleased).toBe(false)
|
expect(userReleased).toBe(false)
|
||||||
await expect(anotherUserLock).rejects.toThrowError(
|
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", {
|
const releasing = await service.release("key_name", {
|
||||||
@@ -82,16 +82,20 @@ moduleIntegrationTestRunner<ILockingModule>({
|
|||||||
ownerId: "user_id_000",
|
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(
|
await expect(service.acquire(keyToLock, user_2)).rejects.toThrowError(
|
||||||
`"${keyToLock}" is already locked.`
|
`Failed to acquire lock for key "${keyToLock}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(service.acquire(keyToLock, user_2)).rejects.toThrowError(
|
await expect(service.acquire(keyToLock, user_2)).rejects.toThrowError(
|
||||||
`"${keyToLock}" is already locked.`
|
`Failed to acquire lock for key "${keyToLock}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
await service.acquire(keyToLock, user_1)
|
await service.acquire(keyToLock, user_1)
|
||||||
@@ -104,6 +108,40 @@ moduleIntegrationTestRunner<ILockingModule>({
|
|||||||
const release = await service.release(keyToLock, user_1)
|
const release = await service.release(keyToLock, user_1)
|
||||||
expect(release).toBe(true)
|
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 () => {
|
it("should release lock in case of failure", async () => {
|
||||||
@@ -118,5 +156,48 @@ moduleIntegrationTestRunner<ILockingModule>({
|
|||||||
expect(fn_1).toBeCalledTimes(1)
|
expect(fn_1).toBeCalledTimes(1)
|
||||||
expect(fn_2).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)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
"url": "https://github.com/medusajs/medusa",
|
"url": "https://github.com/medusajs/medusa",
|
||||||
"directory": "packages/locking"
|
"directory": "packages/locking"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"!dist/**/__tests__",
|
||||||
|
"!dist/**/__mocks__",
|
||||||
|
"!dist/**/__fixtures__"
|
||||||
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Module, Modules } from "@medusajs/framework/utils"
|
import { Module, Modules } from "@medusajs/framework/utils"
|
||||||
import { LockingModuleService } from "@services"
|
import { default as loadProviders } from "./loaders/providers"
|
||||||
import loadProviders from "./loaders/providers"
|
import LockingModuleService from "./services/locking-module"
|
||||||
|
|
||||||
export default Module(Modules.LOCKING, {
|
export default Module(Modules.LOCKING, {
|
||||||
service: LockingModuleService,
|
service: LockingModuleService,
|
||||||
|
|||||||
@@ -11,19 +11,13 @@ import {
|
|||||||
LockingIdentifiersRegistrationName,
|
LockingIdentifiersRegistrationName,
|
||||||
LockingProviderRegistrationPrefix,
|
LockingProviderRegistrationPrefix,
|
||||||
} from "@types"
|
} from "@types"
|
||||||
import { Lifetime, asFunction, asValue } from "awilix"
|
import { Lifetime, aliasTo, asFunction, asValue } from "awilix"
|
||||||
import { InMemoryLockingProvider } from "../providers/in-memory"
|
import { InMemoryLockingProvider } from "../providers/in-memory"
|
||||||
|
|
||||||
const registrationFn = async (klass, container, pluginOptions) => {
|
const registrationFn = async (klass, container) => {
|
||||||
const key = LockingProviderService.getRegistrationIdentifier(klass)
|
const key = LockingProviderService.getRegistrationIdentifier(klass)
|
||||||
|
|
||||||
container.register({
|
container.register({
|
||||||
[LockingProviderRegistrationPrefix + key]: asFunction(
|
[LockingProviderRegistrationPrefix + key]: aliasTo("__providers__" + key),
|
||||||
(cradle) => new klass(cradle, pluginOptions.options),
|
|
||||||
{
|
|
||||||
lifetime: klass.LIFE_TIME || Lifetime.SINGLETON,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
container.registerAdd(LockingIdentifiersRegistrationName, asValue(key))
|
container.registerAdd(LockingIdentifiersRegistrationName, asValue(key))
|
||||||
|
|||||||
@@ -38,24 +38,27 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
|||||||
timeout?: number
|
timeout?: number
|
||||||
}
|
}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
keys = Array.isArray(keys) ? keys : [keys]
|
const timeout = Math.max(args?.timeout ?? 5, 1)
|
||||||
|
const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout
|
||||||
const timeoutSeconds = args?.timeout ?? 5
|
|
||||||
|
|
||||||
|
const cancellationToken = { cancelled: false }
|
||||||
const promises: Promise<any>[] = []
|
const promises: Promise<any>[] = []
|
||||||
if (timeoutSeconds > 0) {
|
if (timeoutSeconds > 0) {
|
||||||
promises.push(this.getTimeout(timeoutSeconds))
|
promises.push(this.getTimeout(timeoutSeconds, cancellationToken))
|
||||||
}
|
}
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.acquire(keys, {
|
this.acquire_(
|
||||||
awaitQueue: true,
|
keys,
|
||||||
})
|
{
|
||||||
|
expire: timeoutSeconds,
|
||||||
|
awaitQueue: true,
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await Promise.race(promises).catch(async (err) => {
|
await Promise.race(promises)
|
||||||
await this.release(keys)
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await job()
|
return await job()
|
||||||
@@ -71,6 +74,18 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
|||||||
expire?: number
|
expire?: number
|
||||||
awaitQueue?: boolean
|
awaitQueue?: boolean
|
||||||
}
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
return this.acquire_(keys, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire_(
|
||||||
|
keys: string | string[],
|
||||||
|
args?: {
|
||||||
|
ownerId?: string | null
|
||||||
|
expire?: number
|
||||||
|
awaitQueue?: boolean
|
||||||
|
},
|
||||||
|
cancellationToken?: { cancelled: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
keys = Array.isArray(keys) ? keys : [keys]
|
keys = Array.isArray(keys) ? keys : [keys]
|
||||||
const { ownerId, expire } = args ?? {}
|
const { ownerId, expire } = args ?? {}
|
||||||
@@ -100,7 +115,7 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lock.ownerId === ownerId) {
|
if (lock.ownerId !== null && lock.ownerId === ownerId) {
|
||||||
if (expire) {
|
if (expire) {
|
||||||
lock.expiration = now + expire * 1000
|
lock.expiration = now + expire * 1000
|
||||||
this.locks.set(key, lock)
|
this.locks.set(key, lock)
|
||||||
@@ -111,10 +126,14 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
|||||||
|
|
||||||
if (lock.currentPromise && args?.awaitQueue) {
|
if (lock.currentPromise && args?.awaitQueue) {
|
||||||
await lock.currentPromise.promise
|
await lock.currentPromise.promise
|
||||||
|
if (cancellationToken?.cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return this.acquire(keys, args)
|
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<void> {
|
private async getTimeout(
|
||||||
|
seconds: number,
|
||||||
|
cancellationToken: { cancelled: boolean }
|
||||||
|
): Promise<void> {
|
||||||
return new Promise((_, reject) => {
|
return new Promise((_, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
cancellationToken.cancelled = true
|
||||||
reject(new Error("Timed-out acquiring lock."))
|
reject(new Error("Timed-out acquiring lock."))
|
||||||
}, seconds * 1000)
|
}, seconds * 1000)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { EmailPassAuthService } from "./services/emailpass"
|
import { EmailPassAuthService } from "./services/emailpass"
|
||||||
|
|
||||||
const services = [EmailPassAuthService]
|
const services = [EmailPassAuthService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.AUTH, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { GithubAuthService } from "./services/github"
|
import { GithubAuthService } from "./services/github"
|
||||||
|
|
||||||
const services = [GithubAuthService]
|
const services = [GithubAuthService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.AUTH, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { GoogleAuthService } from "./services/google"
|
import { GoogleAuthService } from "./services/google"
|
||||||
|
|
||||||
const services = [GoogleAuthService]
|
const services = [GoogleAuthService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.AUTH, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { LocalFileService } from "./services/local-file"
|
import { LocalFileService } from "./services/local-file"
|
||||||
|
|
||||||
const services = [LocalFileService]
|
const services = [LocalFileService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.FILE, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { S3FileService } from "./services/s3-file"
|
import { S3FileService } from "./services/s3-file"
|
||||||
|
|
||||||
const services = [S3FileService]
|
const services = [S3FileService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.FILE, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { ManualFulfillmentService } from "./services/manual-fulfillment"
|
import { ManualFulfillmentService } from "./services/manual-fulfillment"
|
||||||
|
|
||||||
const services = [ManualFulfillmentService]
|
const services = [ManualFulfillmentService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.FULFILLMENT, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.DS_store
|
||||||
|
yarn.lock
|
||||||
@@ -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<ILockingModule>({
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const defineJestConfig = require("../../../../define_jest_config")
|
||||||
|
module.exports = defineJestConfig({
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@models": "<rootDir>/src/models",
|
||||||
|
"^@services": "<rootDir>/src/services",
|
||||||
|
"^@repositories": "<rootDir>/src/repositories",
|
||||||
|
"^@types": "<rootDir>/src/types",
|
||||||
|
"^@utils": "<rootDir>/src/utils",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -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<void> => {
|
||||||
|
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:"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<number>
|
||||||
|
releaseLock: (key: string, ownerId: string) => Promise<number>
|
||||||
|
}
|
||||||
|
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<T>(
|
||||||
|
keys: string | string[],
|
||||||
|
job: () => Promise<T>,
|
||||||
|
args?: {
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
): Promise<T> {
|
||||||
|
const timeout = Math.max(args?.timeout ?? this.waitLockingTimeout, 1)
|
||||||
|
const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout
|
||||||
|
|
||||||
|
const cancellationToken = { cancelled: false }
|
||||||
|
const promises: Promise<any>[] = []
|
||||||
|
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<void> {
|
||||||
|
return this.acquire_(keys, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire_(
|
||||||
|
keys: string | string[],
|
||||||
|
args?: {
|
||||||
|
ownerId?: string
|
||||||
|
expire?: number
|
||||||
|
awaitQueue?: boolean
|
||||||
|
},
|
||||||
|
cancellationToken?: { cancelled: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise(async (_, reject) => {
|
||||||
|
await setTimeout(seconds * 1000)
|
||||||
|
cancellationToken.cancelled = true
|
||||||
|
reject(new Error("Timed-out acquiring lock."))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { LocalNotificationService } from "./services/local"
|
import { LocalNotificationService } from "./services/local"
|
||||||
|
|
||||||
const services = [LocalNotificationService]
|
const services = [LocalNotificationService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.NOTIFICATION, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import { SendgridNotificationService } from "./services/sendgrid"
|
import { SendgridNotificationService } from "./services/sendgrid"
|
||||||
|
|
||||||
const services = [SendgridNotificationService]
|
const services = [SendgridNotificationService]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.NOTIFICATION, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||||
import {
|
import {
|
||||||
StripeBancontactService,
|
StripeBancontactService,
|
||||||
StripeBlikService,
|
StripeBlikService,
|
||||||
@@ -17,8 +17,6 @@ const services = [
|
|||||||
StripePrzelewy24Service,
|
StripePrzelewy24Service,
|
||||||
]
|
]
|
||||||
|
|
||||||
const providerExport: ModuleProviderExports = {
|
export default ModuleProvider(Modules.PAYMENT, {
|
||||||
services,
|
services,
|
||||||
}
|
})
|
||||||
|
|
||||||
export default providerExport
|
|
||||||
|
|||||||
@@ -5738,6 +5738,22 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
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":
|
"@medusajs/locking@^0.0.1, @medusajs/locking@workspace:packages/modules/locking":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@medusajs/locking@workspace:packages/modules/locking"
|
resolution: "@medusajs/locking@workspace:packages/modules/locking"
|
||||||
@@ -5873,6 +5889,7 @@ __metadata:
|
|||||||
"@medusajs/inventory-next": ^0.0.3
|
"@medusajs/inventory-next": ^0.0.3
|
||||||
"@medusajs/link-modules": ^0.2.11
|
"@medusajs/link-modules": ^0.2.11
|
||||||
"@medusajs/locking": ^0.0.1
|
"@medusajs/locking": ^0.0.1
|
||||||
|
"@medusajs/locking-redis": ^0.0.1
|
||||||
"@medusajs/notification": ^0.1.2
|
"@medusajs/notification": ^0.1.2
|
||||||
"@medusajs/notification-local": ^0.0.1
|
"@medusajs/notification-local": ^0.0.1
|
||||||
"@medusajs/notification-sendgrid": ^0.0.1
|
"@medusajs/notification-sendgrid": ^0.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user