diff --git a/.changeset/afraid-moles-brake.md b/.changeset/afraid-moles-brake.md new file mode 100644 index 0000000000000..98938454dfcd7 --- /dev/null +++ b/.changeset/afraid-moles-brake.md @@ -0,0 +1,7 @@ +--- +"@medusajs/inventory": patch +"@medusajs/medusa": patch +"@medusajs/stock-location": patch +--- + +feat(medusa, stock-location, inventory): Allow modules to integrate with core diff --git a/packages/inventory/src/index.js b/packages/inventory/src/index.js deleted file mode 100644 index 4086f2f22019b..0000000000000 --- a/packages/inventory/src/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import ConnectionLoader from "./loaders/connection" -import InventoryService from "./services/inventory" -import * as SchemaMigration from "./migrations/schema-migrations/1665748086258-inventory_setup" - -export const service = InventoryService -export const migrations = [SchemaMigration] -export const loaders = [ConnectionLoader] diff --git a/packages/inventory/src/index.ts b/packages/inventory/src/index.ts new file mode 100644 index 0000000000000..7e4d161f33868 --- /dev/null +++ b/packages/inventory/src/index.ts @@ -0,0 +1,19 @@ +import ConnectionLoader from "./loaders/connection" +import InventoryService from "./services/inventory" +import * as InventoryModels from "./models" +import * as SchemaMigration from "./migrations/schema-migrations/1665748086258-inventory_setup" +import { ModuleExports } from "@medusajs/medusa" + +const service = InventoryService +const migrations = [SchemaMigration] +const loaders = [ConnectionLoader] +const models = Object.values(InventoryModels) + +const moduleDefinition: ModuleExports = { + service, + migrations, + loaders, + models, +} + +export default moduleDefinition diff --git a/packages/inventory/src/loaders/connection.ts b/packages/inventory/src/loaders/connection.ts index 60fffca0979d2..87dc6fa7d2129 100644 --- a/packages/inventory/src/loaders/connection.ts +++ b/packages/inventory/src/loaders/connection.ts @@ -1,22 +1,6 @@ -import { ConfigModule } from "@medusajs/medusa" -import { ConnectionOptions, createConnection } from "typeorm" -import { CONNECTION_NAME } from "../config" +import { ConfigurableModuleDeclaration, LoaderOptions } from "@medusajs/medusa" -import { ReservationItem, InventoryItem, InventoryLevel } from "../models" - -export default async ({ - configModule, -}: { - configModule: ConfigModule -}): Promise => { - await createConnection({ - name: CONNECTION_NAME, - type: configModule.projectConfig.database_type, - url: configModule.projectConfig.database_url, - database: configModule.projectConfig.database_database, - schema: configModule.projectConfig.database_schema, - extra: configModule.projectConfig.database_extra || {}, - entities: [ReservationItem, InventoryLevel, InventoryItem], - logging: configModule.projectConfig.database_logging || false, - } as ConnectionOptions) -} +export default async ( + { configModule }: LoaderOptions, + moduleDeclaration?: ConfigurableModuleDeclaration +): Promise => {} diff --git a/packages/inventory/src/services/inventory-item.ts b/packages/inventory/src/services/inventory-item.ts index c8e4c9306fa4b..4544ec019446c 100644 --- a/packages/inventory/src/services/inventory-item.ts +++ b/packages/inventory/src/services/inventory-item.ts @@ -1,4 +1,4 @@ -import { ILike, In, getConnection, DeepPartial, EntityManager } from "typeorm" +import { DeepPartial, EntityManager } from "typeorm" import { isDefined, MedusaError } from "medusa-core-utils" import { FindConfig, @@ -6,16 +6,19 @@ import { IEventBusService, FilterableInventoryItemProps, CreateInventoryItemInput, + InventoryItemDTO, + TransactionBaseService, } from "@medusajs/medusa" import { InventoryItem } from "../models" -import { CONNECTION_NAME } from "../config" +import { getListQuery } from "../utils/query" type InjectedDependencies = { eventBusService: IEventBusService + manager: EntityManager } -export default class InventoryItemService { +export default class InventoryItemService extends TransactionBaseService { static Events = { CREATED: "inventory-item.created", UPDATED: "inventory-item.updated", @@ -23,14 +26,18 @@ export default class InventoryItemService { } protected readonly eventBusService_: IEventBusService + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + constructor({ eventBusService, manager }: InjectedDependencies) { + super(arguments[0]) - constructor({ eventBusService }: InjectedDependencies) { this.eventBusService_ = eventBusService + this.manager_ = manager } private getManager(): EntityManager { - const connection = getConnection(CONNECTION_NAME) - return connection.manager + return this.transactionManager_ ?? this.manager_ } /** @@ -41,73 +48,11 @@ export default class InventoryItemService { async list( selector: FilterableInventoryItemProps = {}, config: FindConfig = { relations: [], skip: 0, take: 10 } - ): Promise { - const queryBuilder = this.getListQuery(selector, config) + ): Promise { + const queryBuilder = getListQuery(this.getManager(), selector, config) return await queryBuilder.getMany() } - private getListQuery( - selector: FilterableInventoryItemProps = {}, - config: FindConfig = { relations: [], skip: 0, take: 10 } - ) { - const manager = this.getManager() - const inventoryItemRepository = manager.getRepository(InventoryItem) - const query = buildQuery(selector, config) - - const queryBuilder = inventoryItemRepository.createQueryBuilder("inv_item") - - if (query.where.q) { - query.where.sku = ILike(`%${query.where.q as string}%`) - - delete query.where.q - } - - if ("location_id" in query.where) { - const locationIds = Array.isArray(selector.location_id) - ? selector.location_id - : [selector.location_id] - - queryBuilder.innerJoin( - "inventory_level", - "level", - "level.inventory_item_id = inv_item.id AND level.location_id IN (:...locationIds)", - { locationIds } - ) - - delete query.where.location_id - } - - if (query.take) { - queryBuilder.take(query.take) - } - - if (query.skip) { - queryBuilder.skip(query.skip) - } - - if (query.where) { - queryBuilder.where(query.where) - } - - if (query.select) { - queryBuilder.select(query.select.map((s) => "inv_item." + s)) - } - - if (query.order) { - const toSelect: string[] = [] - const parsed = Object.entries(query.order).reduce((acc, [k, v]) => { - const key = `inv_item.${k}` - toSelect.push(key) - acc[key] = v - return acc - }, {}) - queryBuilder.addSelect(toSelect) - queryBuilder.orderBy(parsed) - } - - return queryBuilder - } - /** * @param selector - Filter options for inventory items. * @param config - Configuration for query. @@ -116,8 +61,8 @@ export default class InventoryItemService { async listAndCount( selector: FilterableInventoryItemProps = {}, config: FindConfig = { relations: [], skip: 0, take: 10 } - ): Promise<[InventoryItem[], number]> { - const queryBuilder = this.getListQuery(selector, config) + ): Promise<[InventoryItemDTO[], number]> { + const queryBuilder = getListQuery(this.getManager(), selector, config) return await queryBuilder.getManyAndCount() } @@ -160,30 +105,33 @@ export default class InventoryItemService { * @return The newly created inventory item. */ async create(data: CreateInventoryItemInput): Promise { - const manager = this.getManager() - const itemRepository = manager.getRepository(InventoryItem) + return await this.atomicPhase_(async (manager) => { + const itemRepository = manager.getRepository(InventoryItem) + + const inventoryItem = itemRepository.create({ + sku: data.sku, + origin_country: data.origin_country, + metadata: data.metadata, + hs_code: data.hs_code, + mid_code: data.mid_code, + material: data.material, + weight: data.weight, + length: data.length, + height: data.height, + width: data.width, + requires_shipping: data.requires_shipping, + }) - const inventoryItem = itemRepository.create({ - sku: data.sku, - origin_country: data.origin_country, - metadata: data.metadata, - hs_code: data.hs_code, - mid_code: data.mid_code, - material: data.material, - weight: data.weight, - length: data.length, - height: data.height, - width: data.width, - requires_shipping: data.requires_shipping, - }) + const result = await itemRepository.save(inventoryItem) - const result = await itemRepository.save(inventoryItem) + await this.eventBusService_ + .withTransaction(manager) + .emit(InventoryItemService.Events.CREATED, { + id: result.id, + }) - await this.eventBusService_.emit(InventoryItemService.Events.CREATED, { - id: result.id, + return result }) - - return result } /** @@ -198,38 +146,44 @@ export default class InventoryItemService { "id" | "created_at" | "metadata" | "deleted_at" > ): Promise { - const manager = this.getManager() - const itemRepository = manager.getRepository(InventoryItem) + return await this.atomicPhase_(async (manager) => { + const itemRepository = manager.getRepository(InventoryItem) - const item = await this.retrieve(inventoryItemId) + const item = await this.retrieve(inventoryItemId) - const shouldUpdate = Object.keys(data).some((key) => { - return item[key] !== data[key] - }) + const shouldUpdate = Object.keys(data).some((key) => { + return item[key] !== data[key] + }) - if (shouldUpdate) { - itemRepository.merge(item, data) - await itemRepository.save(item) + if (shouldUpdate) { + itemRepository.merge(item, data) + await itemRepository.save(item) - await this.eventBusService_.emit(InventoryItemService.Events.UPDATED, { - id: item.id, - }) - } + await this.eventBusService_ + .withTransaction(manager) + .emit(InventoryItemService.Events.UPDATED, { + id: item.id, + }) + } - return item + return item + }) } /** * @param inventoryItemId - The id of the inventory item to delete. */ async delete(inventoryItemId: string): Promise { - const manager = this.getManager() - const itemRepository = manager.getRepository(InventoryItem) + await this.atomicPhase_(async (manager) => { + const itemRepository = manager.getRepository(InventoryItem) - await itemRepository.softRemove({ id: inventoryItemId }) + await itemRepository.softRemove({ id: inventoryItemId }) - await this.eventBusService_.emit(InventoryItemService.Events.DELETED, { - id: inventoryItemId, + await this.eventBusService_ + .withTransaction(manager) + .emit(InventoryItemService.Events.DELETED, { + id: inventoryItemId, + }) }) } } diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts index 8c48b08aaa80d..a46f51effefab 100644 --- a/packages/inventory/src/services/inventory-level.ts +++ b/packages/inventory/src/services/inventory-level.ts @@ -1,4 +1,4 @@ -import { getConnection, DeepPartial, EntityManager } from "typeorm" +import { DeepPartial, EntityManager } from "typeorm" import { isDefined, MedusaError } from "medusa-core-utils" import { FindConfig, @@ -10,10 +10,10 @@ import { } from "@medusajs/medusa" import { InventoryLevel } from "../models" -import { CONNECTION_NAME } from "../config" type InjectedDependencies = { eventBusService: IEventBusService + manager: EntityManager } export default class InventoryLevelService extends TransactionBaseService { @@ -28,20 +28,15 @@ export default class InventoryLevelService extends TransactionBaseService { protected readonly eventBusService_: IEventBusService - constructor({ eventBusService }: InjectedDependencies) { + constructor({ eventBusService, manager }: InjectedDependencies) { super(arguments[0]) this.eventBusService_ = eventBusService - this.manager_ = this.getManager() + this.manager_ = manager } private getManager(): EntityManager { - if (this.manager_) { - return this.transactionManager_ ?? this.manager_ - } - - const connection = getConnection(CONNECTION_NAME) - return connection.manager + return this.transactionManager_ ?? this.manager_ } /** @@ -118,7 +113,7 @@ export default class InventoryLevelService extends TransactionBaseService { * @return The created inventory level. */ async create(data: CreateInventoryLevelInput): Promise { - const result = await this.atomicPhase_(async (manager) => { + return await this.atomicPhase_(async (manager) => { const levelRepository = manager.getRepository(InventoryLevel) const inventoryLevel = levelRepository.create({ @@ -129,14 +124,15 @@ export default class InventoryLevelService extends TransactionBaseService { incoming_quantity: data.incoming_quantity, }) - return await levelRepository.save(inventoryLevel) - }) + const saved = await levelRepository.save(inventoryLevel) + await this.eventBusService_ + .withTransaction(manager) + .emit(InventoryLevelService.Events.CREATED, { + id: saved.id, + }) - await this.eventBusService_.emit(InventoryLevelService.Events.CREATED, { - id: result.id, + return saved }) - - return result } /** @@ -167,9 +163,11 @@ export default class InventoryLevelService extends TransactionBaseService { levelRepository.merge(item, data) await levelRepository.save(item) - await this.eventBusService_.emit(InventoryLevelService.Events.UPDATED, { - id: item.id, - }) + await this.eventBusService_ + .withTransaction(manager) + .emit(InventoryLevelService.Events.UPDATED, { + id: item.id, + }) } return item @@ -209,10 +207,12 @@ export default class InventoryLevelService extends TransactionBaseService { const levelRepository = manager.getRepository(InventoryLevel) await levelRepository.delete({ id: inventoryLevelId }) - }) - await this.eventBusService_.emit(InventoryLevelService.Events.DELETED, { - id: inventoryLevelId, + await this.eventBusService_ + .withTransaction(manager) + .emit(InventoryLevelService.Events.DELETED, { + id: inventoryLevelId, + }) }) } diff --git a/packages/inventory/src/services/inventory.ts b/packages/inventory/src/services/inventory.ts index fc4e4aeb8bc49..84c823ebc1047 100644 --- a/packages/inventory/src/services/inventory.ts +++ b/packages/inventory/src/services/inventory.ts @@ -14,6 +14,9 @@ import { InventoryItemDTO, ReservationItemDTO, InventoryLevelDTO, + TransactionBaseService, + ConfigurableModuleDeclaration, + MODULE_RESOURCE_TYPE, } from "@medusajs/medusa" import { @@ -21,26 +24,52 @@ import { ReservationItemService, InventoryLevelService, } from "./" +import { EntityManager } from "typeorm" type InjectedDependencies = { + manager: EntityManager eventBusService: IEventBusService } -export default class InventoryService implements IInventoryService { +export default class InventoryService + extends TransactionBaseService + implements IInventoryService +{ protected readonly eventBusService_: IEventBusService + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined protected readonly inventoryItemService_: InventoryItemService protected readonly reservationItemService_: ReservationItemService protected readonly inventoryLevelService_: InventoryLevelService - constructor({ eventBusService }: InjectedDependencies) { + constructor( + { eventBusService, manager }: InjectedDependencies, + options?: unknown, + moduleDeclaration?: ConfigurableModuleDeclaration + ) { + super(arguments[0]) + + if (moduleDeclaration?.resources !== MODULE_RESOURCE_TYPE.SHARED) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "At the moment this module can only be used with shared resources" + ) + } + this.eventBusService_ = eventBusService + this.manager_ = manager - this.inventoryItemService_ = new InventoryItemService({ eventBusService }) + this.inventoryItemService_ = new InventoryItemService({ + eventBusService, + manager, + }) this.inventoryLevelService_ = new InventoryLevelService({ eventBusService, + manager, }) this.reservationItemService_ = new ReservationItemService({ eventBusService, + manager, inventoryLevelService: this.inventoryLevelService_, }) } diff --git a/packages/inventory/src/services/reservation-item.ts b/packages/inventory/src/services/reservation-item.ts index 6599277d42002..a105a325402fa 100644 --- a/packages/inventory/src/services/reservation-item.ts +++ b/packages/inventory/src/services/reservation-item.ts @@ -1,4 +1,4 @@ -import { getConnection, DeepPartial, EntityManager } from "typeorm" +import { EntityManager } from "typeorm" import { isDefined, MedusaError } from "medusa-core-utils" import { FindConfig, @@ -16,6 +16,7 @@ import { InventoryLevelService } from "." type InjectedDependencies = { eventBusService: IEventBusService + manager: EntityManager inventoryLevelService: InventoryLevelService } @@ -34,22 +35,18 @@ export default class ReservationItemService extends TransactionBaseService { constructor({ eventBusService, + manager, inventoryLevelService, }: InjectedDependencies) { super(arguments[0]) - this.manager_ = this.getManager() + this.manager_ = manager this.eventBusService_ = eventBusService this.inventoryLevelService_ = inventoryLevelService } private getManager(): EntityManager { - if (this.manager_) { - return this.transactionManager_ ?? this.manager_ - } - - const connection = getConnection(CONNECTION_NAME) - return connection.manager + return this.transactionManager_ ?? this.manager_ } /** @@ -126,7 +123,7 @@ export default class ReservationItemService extends TransactionBaseService { * @return The created reservation item. */ async create(data: CreateReservationItemInput): Promise { - const result = await this.atomicPhase_(async (manager) => { + return await this.atomicPhase_(async (manager) => { const itemRepository = manager.getRepository(ReservationItem) const inventoryItem = itemRepository.create({ @@ -148,14 +145,14 @@ export default class ReservationItemService extends TransactionBaseService { ), ]) - return newInventoryItem - }) + await this.eventBusService_ + .withTransaction(manager) + .emit(ReservationItemService.Events.CREATED, { + id: newInventoryItem.id, + }) - await this.eventBusService_.emit(ReservationItemService.Events.CREATED, { - id: result.id, + return newInventoryItem }) - - return result } /** @@ -168,7 +165,7 @@ export default class ReservationItemService extends TransactionBaseService { reservationItemId: string, data: UpdateReservationItemInput ): Promise { - const updatedItem = await this.atomicPhase_(async (manager) => { + return await this.atomicPhase_(async (manager) => { const itemRepository = manager.getRepository(ReservationItem) const item = await this.retrieve(reservationItemId) @@ -196,14 +193,14 @@ export default class ReservationItemService extends TransactionBaseService { await Promise.all(ops) - return mergedItem - }) + await this.eventBusService_ + .withTransaction(manager) + .emit(ReservationItemService.Events.UPDATED, { + id: mergedItem.id, + }) - await this.eventBusService_.emit(ReservationItemService.Events.UPDATED, { - id: updatedItem.id, + return mergedItem }) - - return updatedItem } /** @@ -230,14 +227,13 @@ export default class ReservationItemService extends TransactionBaseService { ) } await Promise.all(ops) - }) - await this.eventBusService_.emit( - ReservationItemService.Events.DELETED_BY_LINE_ITEM, - { - line_item_id: lineItemId, - } - ) + await this.eventBusService_ + .withTransaction(manager) + .emit(ReservationItemService.Events.DELETED_BY_LINE_ITEM, { + line_item_id: lineItemId, + }) + }) } /** diff --git a/packages/inventory/src/utils/query.ts b/packages/inventory/src/utils/query.ts new file mode 100644 index 0000000000000..9fd0f644a99ed --- /dev/null +++ b/packages/inventory/src/utils/query.ts @@ -0,0 +1,69 @@ +import { EntityManager, ILike } from "typeorm" +import { + buildQuery, + FilterableInventoryItemProps, + FindConfig, +} from "@medusajs/medusa" +import { InventoryItem } from "../models" + +export function getListQuery( + manager: EntityManager, + selector: FilterableInventoryItemProps = {}, + config: FindConfig = { relations: [], skip: 0, take: 10 } +) { + const inventoryItemRepository = manager.getRepository(InventoryItem) + const query = buildQuery(selector, config) + + const queryBuilder = inventoryItemRepository.createQueryBuilder("inv_item") + + if (query.where.q) { + query.where.sku = ILike(`%${query.where.q as string}%`) + + delete query.where.q + } + + if ("location_id" in query.where) { + const locationIds = Array.isArray(selector.location_id) + ? selector.location_id + : [selector.location_id] + + queryBuilder.innerJoin( + "inventory_level", + "level", + "level.inventory_item_id = inv_item.id AND level.location_id IN (:...locationIds)", + { locationIds } + ) + + delete query.where.location_id + } + + if (query.take) { + queryBuilder.take(query.take) + } + + if (query.skip) { + queryBuilder.skip(query.skip) + } + + if (query.where) { + queryBuilder.where(query.where) + } + + if (query.select) { + queryBuilder.select(query.select.map((s) => "inv_item." + s)) + } + + if (query.order) { + const toSelect: string[] = [] + const parsed = Object.entries(query.order).reduce((acc, [k, v]) => { + const key = `inv_item.${k}` + toSelect.push(key) + acc[key] = v + return acc + }, {}) + queryBuilder.addSelect(toSelect) + queryBuilder.orderBy(parsed) + } + + return queryBuilder +} diff --git a/packages/medusa/src/interfaces/services/event-bus.ts b/packages/medusa/src/interfaces/services/event-bus.ts index 1eac78d5e10df..480d91a00362c 100644 --- a/packages/medusa/src/interfaces/services/event-bus.ts +++ b/packages/medusa/src/interfaces/services/event-bus.ts @@ -1,3 +1,6 @@ +import { EntityManager } from "typeorm" + export interface IEventBusService { emit(event: string, data: any): Promise + withTransaction(transactionManager?: EntityManager): this } diff --git a/packages/medusa/src/loaders/__mocks__/@modules/brokenloader.ts b/packages/medusa/src/loaders/__mocks__/@modules/brokenloader.ts index 7fc8599ea6a02..8f82dc3ac79c0 100644 --- a/packages/medusa/src/loaders/__mocks__/@modules/brokenloader.ts +++ b/packages/medusa/src/loaders/__mocks__/@modules/brokenloader.ts @@ -2,6 +2,12 @@ const loader = ({}) => { throw new Error("loader") } -export const service = class TestService {} -export const migrations = [] -export const loaders = [loader] +const service = class TestService {} +const migrations = [] +const loaders = [loader] + +export default { + service, + migrations, + loaders, +} diff --git a/packages/medusa/src/loaders/__mocks__/@modules/default.ts b/packages/medusa/src/loaders/__mocks__/@modules/default.ts index 469cf6030c077..76c10ad428055 100644 --- a/packages/medusa/src/loaders/__mocks__/@modules/default.ts +++ b/packages/medusa/src/loaders/__mocks__/@modules/default.ts @@ -1,3 +1,11 @@ -export const service = class TestService {} -export const migrations = [] -export const loaders = [] +const service = class TestService {} +const migrations = [] +const loaders = [] +const models = [] + +export default { + service, + migrations, + loaders, + models, +} diff --git a/packages/medusa/src/loaders/__mocks__/@modules/no-service.ts b/packages/medusa/src/loaders/__mocks__/@modules/no-service.ts index 9901daddec5a9..b1429045e63ec 100644 --- a/packages/medusa/src/loaders/__mocks__/@modules/no-service.ts +++ b/packages/medusa/src/loaders/__mocks__/@modules/no-service.ts @@ -1,2 +1,7 @@ -export const migrations = [] -export const loaders = [] +const migrations = [] +const loaders = [] + +export default { + migrations, + loaders, +} diff --git a/packages/medusa/src/loaders/__tests__/module-definitions.spec.ts b/packages/medusa/src/loaders/__tests__/module-definitions.spec.ts index 79537321c2fd1..300ae6259af0c 100644 --- a/packages/medusa/src/loaders/__tests__/module-definitions.spec.ts +++ b/packages/medusa/src/loaders/__tests__/module-definitions.spec.ts @@ -1,5 +1,9 @@ -// import resolveCwd from "resolve-cwd" -import { ConfigModule } from "../../types/global" +import { + ConfigModule, + ModuleDefinition, + MODULE_RESOURCE_TYPE, + MODULE_SCOPE, +} from "../../types/global" import ModuleDefinitionLoader from "../module-definitions" import MODULE_DEFINITIONS from "../module-definitions/definitions" @@ -7,13 +11,17 @@ const RESOLVED_PACKAGE = "@medusajs/test-service-resolved" jest.mock("resolve-cwd", () => jest.fn(() => RESOLVED_PACKAGE)) describe("module definitions loader", () => { - const defaultDefinition = { + const defaultDefinition: ModuleDefinition = { key: "testService", registrationName: "testService", defaultPackage: "@medusajs/test-service", label: "TestService", isRequired: false, canOverride: true, + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, } beforeEach(() => { @@ -33,6 +41,10 @@ describe("module definitions loader", () => { resolutionPath: defaultDefinition.defaultPackage, definition: defaultDefinition, options: {}, + moduleDeclaration: { + scope: "internal", + resources: "shared", + }, }) }) @@ -82,6 +94,10 @@ describe("module definitions loader", () => { resolutionPath: false, definition: definition, options: {}, + moduleDeclaration: { + scope: "internal", + resources: "shared", + }, }) }) }) @@ -100,6 +116,10 @@ describe("module definitions loader", () => { resolutionPath: RESOLVED_PACKAGE, definition: defaultDefinition, options: {}, + moduleDeclaration: { + scope: "internal", + resources: "shared", + }, }) }) }) @@ -112,6 +132,7 @@ describe("module definitions loader", () => { modules: { [defaultDefinition.key]: { resolve: defaultDefinition.defaultPackage, + resources: MODULE_RESOURCE_TYPE.ISOLATED, }, }, } as ConfigModule) @@ -120,6 +141,11 @@ describe("module definitions loader", () => { resolutionPath: RESOLVED_PACKAGE, definition: defaultDefinition, options: {}, + moduleDeclaration: { + scope: "internal", + resources: "isolated", + resolve: defaultDefinition.defaultPackage, + }, }) }) @@ -138,6 +164,11 @@ describe("module definitions loader", () => { resolutionPath: defaultDefinition.defaultPackage, definition: defaultDefinition, options: { test: 123 }, + moduleDeclaration: { + scope: "internal", + resources: "shared", + options: { test: 123 }, + }, }) }) @@ -149,6 +180,8 @@ describe("module definitions loader", () => { [defaultDefinition.key]: { resolve: defaultDefinition.defaultPackage, options: { test: 123 }, + scope: "internal", + resources: "isolated", }, }, } as unknown as ConfigModule) @@ -157,6 +190,12 @@ describe("module definitions loader", () => { resolutionPath: RESOLVED_PACKAGE, definition: defaultDefinition, options: { test: 123 }, + moduleDeclaration: { + scope: "internal", + resources: "isolated", + resolve: defaultDefinition.defaultPackage, + options: { test: 123 }, + }, }) }) }) diff --git a/packages/medusa/src/loaders/__tests__/module.spec.ts b/packages/medusa/src/loaders/__tests__/module.spec.ts index bc30821a9fe5f..d8a544cb00159 100644 --- a/packages/medusa/src/loaders/__tests__/module.spec.ts +++ b/packages/medusa/src/loaders/__tests__/module.spec.ts @@ -6,13 +6,13 @@ import { createContainer, Resolver, } from "awilix" -import { mkdirSync, rmSync, writeFileSync } from "fs" import Logger from "../logger" -import { resolve } from "path" import { ConfigModule, MedusaContainer, ModuleResolution, + MODULE_RESOURCE_TYPE, + MODULE_SCOPE, } from "../../types/global" import registerModules from "../module" import { trackInstallation } from "../__mocks__/medusa-telemetry" @@ -90,6 +90,10 @@ describe("modules loader", () => { key: "testService", defaultPackage: "testService", label: "TestService", + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, }, }, } @@ -114,6 +118,10 @@ describe("modules loader", () => { key: "testService", defaultPackage: "testService", label: "TestService", + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, }, }, } @@ -149,6 +157,10 @@ describe("modules loader", () => { key: "testService", defaultPackage: "testService", label: "TestService", + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, }, }, } @@ -177,6 +189,10 @@ describe("modules loader", () => { key: "testService", defaultPackage: "testService", label: "TestService", + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, }, }, } @@ -207,6 +223,10 @@ describe("modules loader", () => { defaultPackage: "testService", label: "TestService", isRequired: true, + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, }, }, } diff --git a/packages/medusa/src/loaders/index.ts b/packages/medusa/src/loaders/index.ts index 4db2727c947c6..5397000efca18 100644 --- a/packages/medusa/src/loaders/index.ts +++ b/packages/medusa/src/loaders/index.ts @@ -115,17 +115,6 @@ export default async ({ const rAct = Logger.success(repoActivity, "Repositories initialized") || {} track("REPOSITORIES_INIT_COMPLETED", { duration: rAct.duration }) - const dbActivity = Logger.activity(`Initializing database${EOL}`) - track("DATABASE_INIT_STARTED") - const dbConnection = await databaseLoader({ - container, - configModule, - }) - const dbAct = Logger.success(dbActivity, "Database initialized") || {} - track("DATABASE_INIT_COMPLETED", { duration: dbAct.duration }) - - container.register({ manager: asValue(dbConnection.manager) }) - const stratActivity = Logger.activity(`Initializing strategies${EOL}`) track("STRATEGIES_INIT_STARTED") strategiesLoader({ container, configModule, isTest }) @@ -138,6 +127,17 @@ export default async ({ const modAct = Logger.success(modulesActivity, "Modules initialized") || {} track("MODULES_INIT_COMPLETED", { duration: modAct.duration }) + const dbActivity = Logger.activity(`Initializing database${EOL}`) + track("DATABASE_INIT_STARTED") + const dbConnection = await databaseLoader({ + container, + configModule, + }) + const dbAct = Logger.success(dbActivity, "Database initialized") || {} + track("DATABASE_INIT_COMPLETED", { duration: dbAct.duration }) + + container.register({ manager: asValue(dbConnection.manager) }) + const servicesActivity = Logger.activity(`Initializing services${EOL}`) track("SERVICES_INIT_STARTED") servicesLoader({ container, configModule, isTest }) diff --git a/packages/medusa/src/loaders/module-definitions/definitions.ts b/packages/medusa/src/loaders/module-definitions/definitions.ts index 0b2a1e8eb4aa7..d532d36443697 100644 --- a/packages/medusa/src/loaders/module-definitions/definitions.ts +++ b/packages/medusa/src/loaders/module-definitions/definitions.ts @@ -1,4 +1,8 @@ -import { ModuleDefinition } from "../../types/global" +import { + ModuleDefinition, + MODULE_RESOURCE_TYPE, + MODULE_SCOPE, +} from "../../types/global" export const MODULE_DEFINITIONS: ModuleDefinition[] = [ { @@ -8,6 +12,10 @@ export const MODULE_DEFINITIONS: ModuleDefinition[] = [ label: "StockLocationService", isRequired: false, canOverride: true, + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, }, { key: "inventoryService", @@ -16,6 +24,10 @@ export const MODULE_DEFINITIONS: ModuleDefinition[] = [ label: "InventoryService", isRequired: false, canOverride: true, + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, }, ] diff --git a/packages/medusa/src/loaders/module-definitions/index.ts b/packages/medusa/src/loaders/module-definitions/index.ts index 294e21573fe75..68fb66aed1a4a 100644 --- a/packages/medusa/src/loaders/module-definitions/index.ts +++ b/packages/medusa/src/loaders/module-definitions/index.ts @@ -40,9 +40,16 @@ export default ({ modules }: ConfigModule) => { ) } + const moduleDeclaration = + typeof moduleConfiguration === "object" ? moduleConfiguration : {} + moduleResolutions[definition.key] = { resolutionPath, definition, + moduleDeclaration: { + ...definition.defaultModuleDeclaration, + ...moduleDeclaration, + }, options: typeof moduleConfiguration === "object" ? moduleConfiguration.options ?? {} diff --git a/packages/medusa/src/loaders/module.ts b/packages/medusa/src/loaders/module.ts index 927fb3004c9bf..f5071e88aa8b2 100644 --- a/packages/medusa/src/loaders/module.ts +++ b/packages/medusa/src/loaders/module.ts @@ -1,21 +1,26 @@ -import { asFunction, asValue } from "awilix" +import { asClass, asFunction, asValue } from "awilix" import { trackInstallation } from "medusa-telemetry" -import { ConfigModule, Logger, MedusaContainer } from "../types/global" +import { EntitySchema } from "typeorm" +import { + ClassConstructor, + ConfigModule, + LoaderOptions, + Logger, + MedusaContainer, + ModuleExports, + ModuleResolution, + MODULE_RESOURCE_TYPE, + MODULE_SCOPE, +} from "../types/global" import { ModulesHelper } from "../utils/module-helper" -type Options = { - container: MedusaContainer - configModule: ConfigModule - logger: Logger -} - export const moduleHelper = new ModulesHelper() const registerModule = async ( - container, - resolution, - configModule, - logger + container: MedusaContainer, + resolution: ModuleResolution, + configModule: ConfigModule, + logger: Logger ): Promise<{ error?: Error } | void> => { if (!resolution.resolutionPath) { container.register({ @@ -25,9 +30,9 @@ const registerModule = async ( return } - let loadedModule + let loadedModule: ModuleExports try { - loadedModule = await import(resolution.resolutionPath!) + loadedModule = (await import(resolution.resolutionPath!)).default } catch (error) { return { error } } @@ -42,15 +47,41 @@ const registerModule = async ( } } + if ( + resolution.moduleDeclaration?.scope === MODULE_SCOPE.INTERNAL && + resolution.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.SHARED + ) { + const moduleModels = loadedModule?.models || null + if (moduleModels) { + moduleModels.map((val: ClassConstructor) => { + container.registerAdd("db_entities", asValue(val)) + }) + } + } + + // TODO: "cradle" should only contain dependent Modules and the EntityManager if module scope is shared + container.register({ + [resolution.definition.registrationName]: asFunction((cradle) => { + return new moduleService( + cradle, + resolution.options, + resolution.moduleDeclaration + ) + }).singleton(), + }) + const moduleLoaders = loadedModule?.loaders || [] try { for (const loader of moduleLoaders) { - await loader({ - container, - configModule, - logger, - options: resolution.options, - }) + await loader( + { + container, + configModule, + logger, + options: resolution.options, + }, + resolution.moduleDeclaration + ) } } catch (err) { return { @@ -60,12 +91,6 @@ const registerModule = async ( } } - container.register({ - [resolution.definition.registrationName]: asFunction( - (cradle) => new moduleService(cradle, resolution.options) - ).singleton(), - }) - trackInstallation( { module: resolution.definition.key, @@ -79,7 +104,7 @@ export default async ({ container, configModule, logger, -}: Options): Promise => { +}: LoaderOptions): Promise => { const moduleResolutions = configModule?.moduleResolutions ?? {} for (const resolution of Object.values(moduleResolutions)) { @@ -87,18 +112,18 @@ export default async ({ container, resolution, configModule, - logger + logger! ) if (registrationResult?.error) { const { error } = registrationResult if (resolution.definition.isRequired) { - logger.warn( + logger?.warn( `Could not resolve required module: ${resolution.definition.label}. Error: ${error.message}` ) throw error } - logger.warn( + logger?.warn( `Could not resolve module: ${resolution.definition.label}. Error: ${error.message}` ) } diff --git a/packages/medusa/src/types/global.ts b/packages/medusa/src/types/global.ts index 8d9fabeff59f8..20cfb86c406c4 100644 --- a/packages/medusa/src/types/global.ts +++ b/packages/medusa/src/types/global.ts @@ -37,10 +37,38 @@ export type Logger = _Logger & { warn: (msg: string) => void } +export enum MODULE_SCOPE { + INTERNAL = "internal", + EXTERNAL = "external", +} + +export enum MODULE_RESOURCE_TYPE { + SHARED = "shared", + ISOLATED = "isolated", +} + +export type ConfigurableModuleDeclaration = { + scope: MODULE_SCOPE.INTERNAL + resources: MODULE_RESOURCE_TYPE + resolve?: string + options?: Record +} +/* +| { + scope: MODULE_SCOPE.external + server: { + type: "built-in" | "rest" | "tsrpc" | "grpc" | "gql" + url: string + options?: Record + } + } +*/ + export type ModuleResolution = { resolutionPath: string | false definition: ModuleDefinition options?: Record + moduleDeclaration?: ConfigurableModuleDeclaration } export type ModuleDefinition = { @@ -50,11 +78,26 @@ export type ModuleDefinition = { label: string canOverride?: boolean isRequired?: boolean + defaultModuleDeclaration: ConfigurableModuleDeclaration } -export type ConfigurableModuleDeclaration = { - resolve?: string +export type LoaderOptions = { + container: MedusaContainer + configModule: ConfigModule options?: Record + logger?: Logger +} + +export type Constructor = new (...args: any[]) => T + +export type ModuleExports = { + loaders: (( + options: LoaderOptions, + moduleDeclaration?: ConfigurableModuleDeclaration + ) => Promise)[] + service: Constructor + migrations?: any[] // TODO: revisit migrations type + models?: Constructor[] } export type ConfigModule = { @@ -77,7 +120,10 @@ export type ConfigModule = { admin_cors?: string } featureFlags: Record - modules?: Record + modules?: Record< + string, + false | string | Partial + > moduleResolutions?: Record plugins: ( | { diff --git a/packages/stock-location/src/index.js b/packages/stock-location/src/index.js deleted file mode 100644 index 072ac428df015..0000000000000 --- a/packages/stock-location/src/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import ConnectionLoader from "./loaders/connection" -import StockLocationService from "./services/stock-location" -import * as SchemaMigration from "./migrations/schema-migrations/1665749860179-setup" - -export const service = StockLocationService -export const migrations = [SchemaMigration] -export const loaders = [ConnectionLoader] diff --git a/packages/stock-location/src/index.ts b/packages/stock-location/src/index.ts new file mode 100644 index 0000000000000..955d114b775ef --- /dev/null +++ b/packages/stock-location/src/index.ts @@ -0,0 +1,20 @@ +import ConnectionLoader from "./loaders/connection" +import StockLocationService from "./services/stock-location" +import * as SchemaMigration from "./migrations/schema-migrations/1665749860179-setup" +import * as StockLocationModels from "./models" +import { ModuleExports } from "@medusajs/medusa" + +const service = StockLocationService +const migrations = [SchemaMigration] +const loaders = [ConnectionLoader] + +const models = Object.values(StockLocationModels) + +const moduleDefinition: ModuleExports = { + service, + migrations, + loaders, + models, +} + +export default moduleDefinition diff --git a/packages/stock-location/src/loaders/connection.ts b/packages/stock-location/src/loaders/connection.ts index 5cc2d64e64092..87dc6fa7d2129 100644 --- a/packages/stock-location/src/loaders/connection.ts +++ b/packages/stock-location/src/loaders/connection.ts @@ -1,22 +1,6 @@ -import { ConfigModule } from "@medusajs/medusa" -import { ConnectionOptions, createConnection } from "typeorm" -import { CONNECTION_NAME } from "../config" +import { ConfigurableModuleDeclaration, LoaderOptions } from "@medusajs/medusa" -import { StockLocation, StockLocationAddress } from "../models" - -export default async ({ - configModule, -}: { - configModule: ConfigModule -}): Promise => { - await createConnection({ - name: CONNECTION_NAME, - type: configModule.projectConfig.database_type, - url: configModule.projectConfig.database_url, - database: configModule.projectConfig.database_database, - schema: configModule.projectConfig.database_schema, - extra: configModule.projectConfig.database_extra || {}, - entities: [StockLocation, StockLocationAddress], - logging: configModule.projectConfig.database_logging || false, - } as ConnectionOptions) -} +export default async ( + { configModule }: LoaderOptions, + moduleDeclaration?: ConfigurableModuleDeclaration +): Promise => {} diff --git a/packages/stock-location/src/services/stock-location.ts b/packages/stock-location/src/services/stock-location.ts index d53468c5533b8..258ad8310d757 100644 --- a/packages/stock-location/src/services/stock-location.ts +++ b/packages/stock-location/src/services/stock-location.ts @@ -1,4 +1,4 @@ -import { getConnection, EntityManager } from "typeorm" +import { EntityManager } from "typeorm" import { isDefined, MedusaError } from "medusa-core-utils" import { FindConfig, @@ -9,12 +9,15 @@ import { StockLocationAddressInput, IEventBusService, setMetadata, + TransactionBaseService, + ConfigurableModuleDeclaration, + MODULE_RESOURCE_TYPE, } from "@medusajs/medusa" import { StockLocation, StockLocationAddress } from "../models" -import { CONNECTION_NAME } from "../config" type InjectedDependencies = { + manager: EntityManager eventBusService: IEventBusService } @@ -22,24 +25,38 @@ type InjectedDependencies = { * Service for managing stock locations. */ -export default class StockLocationService { +export default class StockLocationService extends TransactionBaseService { static Events = { CREATED: "stock-location.created", UPDATED: "stock-location.updated", DELETED: "stock-location.deleted", } - protected readonly manager_: EntityManager + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined protected readonly eventBusService_: IEventBusService - constructor({ eventBusService }: InjectedDependencies) { + constructor( + { eventBusService, manager }: InjectedDependencies, + options?: unknown, + moduleDeclaration?: ConfigurableModuleDeclaration + ) { + super(arguments[0]) + + if (moduleDeclaration?.resources !== MODULE_RESOURCE_TYPE.SHARED) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "At the moment this module can only be used with shared resources" + ) + } + this.eventBusService_ = eventBusService + this.manager_ = manager } private getManager(): EntityManager { - const connection = getConnection(CONNECTION_NAME) - return connection.manager + return this.transactionManager_ ?? this.manager_ } /** @@ -99,7 +116,7 @@ export default class StockLocationService { const locationRepo = manager.getRepository(StockLocation) const query = buildQuery({ id: stockLocationId }, config) - const loc = await locationRepo.findOne(query) + const [loc] = await locationRepo.find(query) if (!loc) { throw new MedusaError( @@ -117,8 +134,7 @@ export default class StockLocationService { * @returns {Promise} - The created stock location. */ async create(data: CreateStockLocationInput): Promise { - const defaultManager = this.getManager() - return await defaultManager.transaction(async (manager) => { + return await this.atomicPhase_(async (manager) => { const locationRepo = manager.getRepository(StockLocation) const loc = locationRepo.create({ @@ -147,9 +163,11 @@ export default class StockLocationService { const result = await locationRepo.save(loc) - await this.eventBusService_.emit(StockLocationService.Events.CREATED, { - id: result.id, - }) + await this.eventBusService_ + .withTransaction(manager) + .emit(StockLocationService.Events.CREATED, { + id: result.id, + }) return result }) @@ -166,17 +184,16 @@ export default class StockLocationService { stockLocationId: string, updateData: UpdateStockLocationInput ): Promise { - const defaultManager = this.getManager() - return await defaultManager.transaction(async (manager) => { + return await this.atomicPhase_(async (manager) => { const locationRepo = manager.getRepository(StockLocation) const item = await this.retrieve(stockLocationId) - const { address, metadata, ...data } = updateData + const { address, ...data } = updateData if (address) { if (item.address_id) { - await this.updateAddress(item.address_id, address, { manager }) + await this.updateAddress(item.address_id, address) } else { const locAddressRepo = manager.getRepository(StockLocationAddress) const locAddress = locAddressRepo.create(address) @@ -185,16 +202,20 @@ export default class StockLocationService { } } + const { metadata, ...fields } = updateData + + const toSave = locationRepo.merge(item, fields) if (metadata) { - item.metadata = setMetadata(item, metadata) + toSave.metadata = setMetadata(toSave, metadata) } - const toSave = locationRepo.merge(item, data) await locationRepo.save(toSave) - await this.eventBusService_.emit(StockLocationService.Events.UPDATED, { - id: stockLocationId, - }) + await this.eventBusService_ + .withTransaction(manager) + .emit(StockLocationService.Events.UPDATED, { + id: stockLocationId, + }) return item }) @@ -204,35 +225,42 @@ export default class StockLocationService { * Updates an address for a stock location. * @param {string} addressId - The ID of the address to update. * @param {StockLocationAddressInput} address - The update data for the address. - * @param {Object} context - Context for the update. - * @param {EntityManager} context.manager - The entity manager to use for the update. * @returns {Promise} - The updated stock location address. */ protected async updateAddress( addressId: string, - address: StockLocationAddressInput, - context: { manager?: EntityManager } = {} + address: StockLocationAddressInput ): Promise { - const manager = context.manager || this.getManager() - const locationAddressRepo = manager.getRepository(StockLocationAddress) - - const existingAddress = await locationAddressRepo.findOne(addressId) - if (!existingAddress) { + if (!isDefined(addressId)) { throw new MedusaError( MedusaError.Types.NOT_FOUND, - `StockLocation address with id ${addressId} was not found` + `"addressId" must be defined` ) } - const toSave = locationAddressRepo.merge(existingAddress, address) + return await this.atomicPhase_(async (manager) => { + const locationAddressRepo = manager.getRepository(StockLocationAddress) - const { metadata } = address - if (metadata) { - toSave.metadata = setMetadata(toSave, metadata) - } + const existingAddress = await locationAddressRepo.findOne({ + id: addressId, + }) + if (!existingAddress) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `StockLocation address with id ${addressId} was not found` + ) + } + + const { metadata, ...fields } = address + + const toSave = locationAddressRepo.merge(existingAddress, fields) + if (metadata) { + toSave.metadata = setMetadata(toSave, metadata) + } - return await locationAddressRepo.save(toSave) + return await locationAddressRepo.save(toSave) + }) } /** @@ -241,13 +269,16 @@ export default class StockLocationService { * @returns {Promise} - An empty promise. */ async delete(id: string): Promise { - const manager = this.getManager() - const locationRepo = manager.getRepository(StockLocation) + return await this.atomicPhase_(async (manager) => { + const locationRepo = manager.getRepository(StockLocation) - await locationRepo.softRemove({ id }) + await locationRepo.softRemove({ id }) - await this.eventBusService_.emit(StockLocationService.Events.DELETED, { - id, + await this.eventBusService_ + .withTransaction(manager) + .emit(StockLocationService.Events.DELETED, { + id, + }) }) } }