feat(medusa, stock-location, inventory): Allow modules to integrate with core (#2997)

* feat: module shared resources
This commit is contained in:
Carlos R. L. Rodrigues
2023-01-13 10:39:43 -03:00
committed by GitHub
parent b3e4be7208
commit 9dbccd9ca7
24 changed files with 577 additions and 327 deletions

View File

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

View File

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

View File

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

View File

@@ -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<void> => {
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<void> => {}

View File

@@ -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<InventoryItem> = { relations: [], skip: 0, take: 10 }
): Promise<InventoryItem[]> {
const queryBuilder = this.getListQuery(selector, config)
): Promise<InventoryItemDTO[]> {
const queryBuilder = getListQuery(this.getManager(), selector, config)
return await queryBuilder.getMany()
}
private getListQuery(
selector: FilterableInventoryItemProps = {},
config: FindConfig<InventoryItem> = { 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<InventoryItem> = { 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<InventoryItem> {
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)
await this.eventBusService_
.withTransaction(manager)
.emit(InventoryItemService.Events.CREATED, {
id: result.id,
})
return result
})
const result = await itemRepository.save(inventoryItem)
await this.eventBusService_.emit(InventoryItemService.Events.CREATED, {
id: result.id,
})
return result
}
/**
@@ -198,38 +146,44 @@ export default class InventoryItemService {
"id" | "created_at" | "metadata" | "deleted_at"
>
): Promise<InventoryItem> {
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]
})
if (shouldUpdate) {
itemRepository.merge(item, data)
await itemRepository.save(item)
await this.eventBusService_.emit(InventoryItemService.Events.UPDATED, {
id: item.id,
const shouldUpdate = Object.keys(data).some((key) => {
return item[key] !== data[key]
})
}
return item
if (shouldUpdate) {
itemRepository.merge(item, data)
await itemRepository.save(item)
await this.eventBusService_
.withTransaction(manager)
.emit(InventoryItemService.Events.UPDATED, {
id: item.id,
})
}
return item
})
}
/**
* @param inventoryItemId - The id of the inventory item to delete.
*/
async delete(inventoryItemId: string): Promise<void> {
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,
})
})
}
}

View File

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

View File

@@ -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) {
this.eventBusService_ = eventBusService
constructor(
{ eventBusService, manager }: InjectedDependencies,
options?: unknown,
moduleDeclaration?: ConfigurableModuleDeclaration
) {
super(arguments[0])
this.inventoryItemService_ = new InventoryItemService({ eventBusService })
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,
manager,
})
this.inventoryLevelService_ = new InventoryLevelService({
eventBusService,
manager,
})
this.reservationItemService_ = new ReservationItemService({
eventBusService,
manager,
inventoryLevelService: this.inventoryLevelService_,
})
}

View File

@@ -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<ReservationItem> {
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 {
),
])
await this.eventBusService_
.withTransaction(manager)
.emit(ReservationItemService.Events.CREATED, {
id: newInventoryItem.id,
})
return newInventoryItem
})
await this.eventBusService_.emit(ReservationItemService.Events.CREATED, {
id: result.id,
})
return result
}
/**
@@ -168,7 +165,7 @@ export default class ReservationItemService extends TransactionBaseService {
reservationItemId: string,
data: UpdateReservationItemInput
): Promise<ReservationItem> {
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)
await this.eventBusService_
.withTransaction(manager)
.emit(ReservationItemService.Events.UPDATED, {
id: mergedItem.id,
})
return mergedItem
})
await this.eventBusService_.emit(ReservationItemService.Events.UPDATED, {
id: updatedItem.id,
})
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,
})
})
}
/**

View File

@@ -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<InventoryItem> = { 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
}

View File

@@ -1,3 +1,6 @@
import { EntityManager } from "typeorm"
export interface IEventBusService {
emit(event: string, data: any): Promise<void>
withTransaction(transactionManager?: EntityManager): this
}

View File

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

View File

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

View File

@@ -1,2 +1,7 @@
export const migrations = []
export const loaders = []
const migrations = []
const loaders = []
export default {
migrations,
loaders,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<unknown>) => {
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<void> => {
}: LoaderOptions): Promise<void> => {
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}`
)
}

View File

@@ -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<string, unknown>
}
/*
| {
scope: MODULE_SCOPE.external
server: {
type: "built-in" | "rest" | "tsrpc" | "grpc" | "gql"
url: string
options?: Record<string, unknown>
}
}
*/
export type ModuleResolution = {
resolutionPath: string | false
definition: ModuleDefinition
options?: Record<string, unknown>
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<string, unknown>
logger?: Logger
}
export type Constructor<T> = new (...args: any[]) => T
export type ModuleExports = {
loaders: ((
options: LoaderOptions,
moduleDeclaration?: ConfigurableModuleDeclaration
) => Promise<void>)[]
service: Constructor<any>
migrations?: any[] // TODO: revisit migrations type
models?: Constructor<any>[]
}
export type ConfigModule = {
@@ -77,7 +120,10 @@ export type ConfigModule = {
admin_cors?: string
}
featureFlags: Record<string, boolean | string>
modules?: Record<string, false | string | ConfigurableModuleDeclaration>
modules?: Record<
string,
false | string | Partial<ConfigurableModuleDeclaration>
>
moduleResolutions?: Record<string, ModuleResolution>
plugins: (
| {

View File

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

View File

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

View File

@@ -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<void> => {
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<void> => {}

View File

@@ -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<StockLocation>} - The created stock location.
*/
async create(data: CreateStockLocationInput): Promise<StockLocation> {
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<StockLocation> {
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<StockLocationAddress>} - The updated stock location address.
*/
protected async updateAddress(
addressId: string,
address: StockLocationAddressInput,
context: { manager?: EntityManager } = {}
address: StockLocationAddressInput
): Promise<StockLocationAddress> {
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`
)
}
return await locationAddressRepo.save(toSave)
const { metadata, ...fields } = address
const toSave = locationAddressRepo.merge(existingAddress, fields)
if (metadata) {
toSave.metadata = setMetadata(toSave, metadata)
}
return await locationAddressRepo.save(toSave)
})
}
/**
@@ -241,13 +269,16 @@ export default class StockLocationService {
* @returns {Promise<void>} - An empty promise.
*/
async delete(id: string): Promise<void> {
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,
})
})
}
}