From 4d16acf5f096b5656b645f510f9c971e7c2dc9ef Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Wed, 30 Aug 2023 11:31:32 -0300 Subject: [PATCH] feat(link-modules,modules-sdk, utils, types, products) - Remote Link and Link modules (#4695) What: - Definition of all Modules links - `link-modules` package to manage the creation of all pre-defined link or custom ones ```typescript import { initialize as iniInventory } from "@medusajs/inventory"; import { initialize as iniProduct } from "@medusajs/product"; import { initialize as iniLinks, runMigrations as migrateLinks } from "@medusajs/link-modules"; await Promise.all([iniInventory(), iniProduct()]); await migrateLinks(); // create tables based on previous loaded modules await iniLinks(); // load link based on previous loaded modules await iniLinks(undefined, [ { serviceName: "product_custom_translation_service_link", isLink: true, databaseConfig: { tableName: "product_transalations", }, alias: [ { name: "translations", }, ], primaryKeys: ["id", "product_id", "translation_id"], relationships: [ { serviceName: Modules.PRODUCT, primaryKey: "id", foreignKey: "product_id", alias: "product", }, { serviceName: "custom_translation_service", primaryKey: "id", foreignKey: "translation_id", alias: "transalation", deleteCascade: true, }, ], extends: [ { serviceName: Modules.PRODUCT, relationship: { serviceName: "product_custom_translation_service_link", primaryKey: "product_id", foreignKey: "id", alias: "translations", isList: true, }, }, { serviceName: "custom_translation_service", relationship: { serviceName: "product_custom_translation_service_link", primaryKey: "product_id", foreignKey: "id", alias: "product_link", }, }, ], }, ]); // custom links ``` Remote Link ```typescript import { RemoteLink, Modules } from "@medusajs/modules-sdk"; // [...] initialize modules and links const remoteLink = new RemoteLink(); // upsert the relationship await remoteLink.create({ // one (object) or many (array) [Modules.PRODUCT]: { variant_id: "var_abc", }, [Modules.INVENTORY]: { inventory_item_id: "iitem_abc", }, data: { // optional additional fields required_quantity: 5 } }); // dismiss (doesn't cascade) await remoteLink.dismiss({ // one (object) or many (array) [Modules.PRODUCT]: { variant_id: "var_abc", }, [Modules.INVENTORY]: { inventory_item_id: "iitem_abc", }, }); // delete await remoteLink.delete({ // every key is a module [Modules.PRODUCT]: { // every key is a linkable field variant_id: "var_abc", // single or multiple values }, }); // restore await remoteLink.restore({ // every key is a module [Modules.PRODUCT]: { // every key is a linkable field variant_id: "var_abc", // single or multiple values }, }); ``` Co-authored-by: Riqwan Thamir <5105988+riqwan@users.noreply.github.com> --- .changeset/poor-ants-deliver.md | 7 + .changeset/selfish-needles-beg.md | 9 + .changeset/warm-bikes-enjoy.md | 13 + .../plugins/__tests__/link-modules/index.ts | 188 ++++++++ packages/cache-inmemory/jest.config.js | 14 +- packages/cache-inmemory/package.json | 6 +- packages/cache-redis/jest.config.js | 14 +- packages/cache-redis/package.json | 6 +- packages/event-bus-local/jest.config.js | 16 +- packages/event-bus-local/package.json | 6 +- packages/event-bus-redis/jest.config.js | 16 +- packages/event-bus-redis/package.json | 6 +- packages/inventory/jest.config.js | 14 +- packages/inventory/package.json | 6 +- packages/inventory/src/joiner-config.ts | 9 +- packages/inventory/src/services/inventory.ts | 4 +- packages/link-modules/.gitignore | 6 + packages/link-modules/jest.config.js | 13 + packages/link-modules/package.json | 46 ++ .../link-modules/src/definitions/index.ts | 2 + .../inventory-level-stock-location.ts | 19 + .../product-variant-inventory-item.ts | 66 +++ packages/link-modules/src/index.ts | 4 + packages/link-modules/src/initialize/index.ts | 190 ++++++++ .../src/initialize/module-definition.ts | 24 + packages/link-modules/src/links.ts | 11 + .../link-modules/src/loaders/connection.ts | 31 ++ .../link-modules/src/loaders/container.ts | 40 ++ packages/link-modules/src/loaders/index.ts | 26 ++ packages/link-modules/src/migration/index.ts | 78 ++++ .../link-modules/src/repositories/index.ts | 2 + .../link-modules/src/repositories/link.ts | 104 +++++ .../src/services/dynamic-service-class.ts | 22 + packages/link-modules/src/services/index.ts | 3 + .../src/services/link-module-service.ts | 301 ++++++++++++ packages/link-modules/src/services/link.ts | 117 +++++ packages/link-modules/src/types/index.ts | 5 + .../src/utils/compose-link-name.ts | 9 + .../link-modules/src/utils/generate-entity.ts | 110 +++++ packages/link-modules/src/utils/index.ts | 12 + packages/link-modules/tsconfig.json | 35 ++ packages/link-modules/tsconfig.spec.json | 5 + .../{ => src}/test/utils/utils.test.ts | 2 +- packages/medusa-js/tsconfig.spec.json | 8 +- packages/modules-sdk/jest.config.js | 14 +- packages/modules-sdk/package.json | 6 +- .../src/__mocks__/inventory-module.ts | 27 ++ .../inventory-stock-location-link.ts | 71 +++ .../src/__mocks__/product-inventory-link.ts | 77 +++ .../src/__mocks__/product-module.ts | 24 + .../src/__mocks__/stock-location-module.ts | 24 + .../src/__tests__/remote-link.spec.ts | 202 ++++++++ packages/modules-sdk/src/index.ts | 2 + .../modules-sdk/src/loaders/module-loader.ts | 97 ++-- .../src/loaders/register-modules.ts | 64 ++- .../src/loaders/utils/load-internal.ts | 13 +- packages/modules-sdk/src/medusa-app.ts | 183 ++++++++ packages/modules-sdk/src/medusa-module.ts | 142 +++++- packages/modules-sdk/src/remote-link.ts | 439 ++++++++++++++++++ packages/modules-sdk/src/remote-query.ts | 67 ++- packages/orchestration/jest.config.js | 14 +- packages/orchestration/package.json | 6 +- .../src/__tests__/joiner/graphql-ast.ts | 81 ++++ .../orchestration/src/joiner/graphql-ast.ts | 47 +- .../orchestration/src/joiner/remote-joiner.ts | 126 ++--- .../pricing/integration-tests/setup-env.js | 2 +- .../__tests__/services/product/index.ts | 8 +- packages/product/jest.config.js | 14 +- packages/product/package.json | 6 +- packages/product/src/initialize/index.ts | 5 +- packages/product/src/joiner-config.ts | 9 +- .../src/services/product-module-service.ts | 79 ++-- packages/product/src/services/product.ts | 6 +- packages/stock-location/jest.config.js | 14 +- packages/stock-location/package.json | 6 +- packages/stock-location/src/joiner-config.ts | 5 +- .../src/services/stock-location.ts | 4 +- packages/types/package.json | 2 +- packages/types/src/dal/repository-service.ts | 15 +- packages/types/src/index.ts | 1 + packages/types/src/inventory/service.ts | 6 +- packages/types/src/joiner/index.ts | 10 +- packages/types/src/link-modules/index.ts | 49 ++ packages/types/src/modules-sdk/index.ts | 93 +++- packages/types/src/product/service.ts | 15 +- packages/types/src/stock-location/service.ts | 9 +- packages/utils/jest.config.js | 14 +- packages/utils/package.json | 6 +- .../src/dal/mikro-orm/mikro-orm-repository.ts | 54 ++- packages/utils/src/dal/repository.ts | 5 +- packages/utils/src/dal/utils.ts | 4 +- .../__tests__/load-database-config.spec.ts | 31 +- .../load-module-database-config.ts | 27 +- .../loaders/mikro-orm-connection-loader.ts | 3 +- packages/workflows/jest.config.js | 14 +- packages/workflows/package.json | 6 +- yarn.lock | 91 ++-- 97 files changed, 3540 insertions(+), 424 deletions(-) create mode 100644 .changeset/poor-ants-deliver.md create mode 100644 .changeset/selfish-needles-beg.md create mode 100644 .changeset/warm-bikes-enjoy.md create mode 100644 integration-tests/plugins/__tests__/link-modules/index.ts create mode 100644 packages/link-modules/.gitignore create mode 100644 packages/link-modules/jest.config.js create mode 100644 packages/link-modules/package.json create mode 100644 packages/link-modules/src/definitions/index.ts create mode 100644 packages/link-modules/src/definitions/inventory-level-stock-location.ts create mode 100644 packages/link-modules/src/definitions/product-variant-inventory-item.ts create mode 100644 packages/link-modules/src/index.ts create mode 100644 packages/link-modules/src/initialize/index.ts create mode 100644 packages/link-modules/src/initialize/module-definition.ts create mode 100644 packages/link-modules/src/links.ts create mode 100644 packages/link-modules/src/loaders/connection.ts create mode 100644 packages/link-modules/src/loaders/container.ts create mode 100644 packages/link-modules/src/loaders/index.ts create mode 100644 packages/link-modules/src/migration/index.ts create mode 100644 packages/link-modules/src/repositories/index.ts create mode 100644 packages/link-modules/src/repositories/link.ts create mode 100644 packages/link-modules/src/services/dynamic-service-class.ts create mode 100644 packages/link-modules/src/services/index.ts create mode 100644 packages/link-modules/src/services/link-module-service.ts create mode 100644 packages/link-modules/src/services/link.ts create mode 100644 packages/link-modules/src/types/index.ts create mode 100644 packages/link-modules/src/utils/compose-link-name.ts create mode 100644 packages/link-modules/src/utils/generate-entity.ts create mode 100644 packages/link-modules/src/utils/index.ts create mode 100644 packages/link-modules/tsconfig.json create mode 100644 packages/link-modules/tsconfig.spec.json rename packages/medusa-js/{ => src}/test/utils/utils.test.ts (93%) create mode 100644 packages/modules-sdk/src/__mocks__/inventory-module.ts create mode 100644 packages/modules-sdk/src/__mocks__/inventory-stock-location-link.ts create mode 100644 packages/modules-sdk/src/__mocks__/product-inventory-link.ts create mode 100644 packages/modules-sdk/src/__mocks__/product-module.ts create mode 100644 packages/modules-sdk/src/__mocks__/stock-location-module.ts create mode 100644 packages/modules-sdk/src/__tests__/remote-link.spec.ts create mode 100644 packages/modules-sdk/src/medusa-app.ts create mode 100644 packages/modules-sdk/src/remote-link.ts create mode 100644 packages/types/src/link-modules/index.ts diff --git a/.changeset/poor-ants-deliver.md b/.changeset/poor-ants-deliver.md new file mode 100644 index 0000000000..5b680f3a83 --- /dev/null +++ b/.changeset/poor-ants-deliver.md @@ -0,0 +1,7 @@ +--- +"@medusajs/link-modules": patch +"@medusajs/modules-sdk": patch +"@medusajs/types": patch +--- + +Add extra fields to link modules diff --git a/.changeset/selfish-needles-beg.md b/.changeset/selfish-needles-beg.md new file mode 100644 index 0000000000..736af1ea9f --- /dev/null +++ b/.changeset/selfish-needles-beg.md @@ -0,0 +1,9 @@ +--- +"@medusajs/orchestration": minor +"@medusajs/link-modules": minor +"@medusajs/modules-sdk": minor +"@medusajs/types": minor +"@medusajs/utils": minor +--- + +Medusa App Loader diff --git a/.changeset/warm-bikes-enjoy.md b/.changeset/warm-bikes-enjoy.md new file mode 100644 index 0000000000..2eaaf951b4 --- /dev/null +++ b/.changeset/warm-bikes-enjoy.md @@ -0,0 +1,13 @@ +--- +"@medusajs/stock-location": minor +"@medusajs/orchestration": minor +"@medusajs/link-modules": minor +"@medusajs/modules-sdk": minor +"@medusajs/inventory": minor +"@medusajs/product": minor +"@medusajs/medusa": minor +"@medusajs/types": minor +"@medusajs/utils": minor +--- + +introduce @medusajs/link-modules diff --git a/integration-tests/plugins/__tests__/link-modules/index.ts b/integration-tests/plugins/__tests__/link-modules/index.ts new file mode 100644 index 0000000000..8539788b71 --- /dev/null +++ b/integration-tests/plugins/__tests__/link-modules/index.ts @@ -0,0 +1,188 @@ +import { initialize, runMigrations } from "@medusajs/link-modules" +import { MedusaModule, ModuleJoinerConfig } from "@medusajs/modules-sdk" + +jest.setTimeout(5000000) + +const DB_HOST = process.env.DB_HOST +const DB_USERNAME = process.env.DB_USERNAME +const DB_PASSWORD = process.env.DB_PASSWORD +const DB_NAME = process.env.DB_TEMP_NAME +const DB_URL = `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}` + +describe("Link Modules", () => { + let links + const linkDefinition: ModuleJoinerConfig[] = [ + { + serviceName: "linkServiceName", + isLink: true, + databaseConfig: { + tableName: "linkTableName", + idPrefix: "prefix", + extraFields: { + extra_field: { + type: "integer", + defaultValue: "-1", + }, + another_field: { + type: "string", + nullable: true, + }, + }, + }, + relationships: [ + { + serviceName: "moduleA", + primaryKey: "id", + foreignKey: "product_id", + alias: "product", + }, + { + serviceName: "moduleB", + primaryKey: "id", + foreignKey: "inventory_item_id", + alias: "inventory", + }, + ], + }, + ] + const dbConfig = { + database: { + clientUrl: DB_URL, + }, + } + + beforeAll(async () => { + jest.spyOn(MedusaModule, "getLoadedModules").mockImplementation((() => { + return [{ moduleA: [{}] }, { moduleB: [{}] }] + }) as any) + + await runMigrations({ options: dbConfig }, linkDefinition) + links = await initialize(dbConfig, linkDefinition) + }) + + afterAll(async () => { + jest.clearAllMocks() + }) + + it("Should insert values in a declared link", async function () { + // simple + await links.linkServiceName.create("modA_id", "modB_id") + + // extra fields + await links.linkServiceName.create("123", "abc", { + extra_field: 333, + another_field: "value**", + }) + + // bulk + await links.linkServiceName.create([ + ["111", "aaa", { another_field: "test" }], + ["222", "bbb"], + ["333", "ccc", { extra_field: 2 }], + ["444", "bbb"], + ]) + + const values = await links.linkServiceName.list() + + expect(values).toEqual([ + { + product_id: "modA_id", + inventory_item_id: "modB_id", + id: expect.stringMatching("prefix_.+"), + extra_field: -1, + another_field: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + }, + expect.objectContaining({ + product_id: "123", + inventory_item_id: "abc", + id: expect.stringMatching("prefix_.+"), + extra_field: 333, + another_field: "value**", + }), + expect.objectContaining({ + product_id: "111", + inventory_item_id: "aaa", + extra_field: -1, + another_field: "test", + }), + expect.objectContaining({ + product_id: "222", + inventory_item_id: "bbb", + extra_field: -1, + another_field: null, + }), + expect.objectContaining({ + product_id: "333", + inventory_item_id: "ccc", + id: expect.stringMatching("prefix_.+"), + extra_field: 2, + }), + expect.objectContaining({ + product_id: "444", + inventory_item_id: "bbb", + }), + ]) + }) + + it("Should dismiss the link of a given pair of keys", async function () { + // simple + const dismissSingle = await links.linkServiceName.dismiss( + "modA_id", + "modB_id" + ) + + // bulk + const dismissMulti = await links.linkServiceName.dismiss([ + ["111", "aaa"], + ["333", "ccc"], + ]) + + expect(dismissSingle).toEqual([ + expect.objectContaining({ + product_id: "modA_id", + inventory_item_id: "modB_id", + deleted_at: expect.any(Date), + }), + ]) + + expect(dismissMulti).toEqual([ + expect.objectContaining({ + product_id: "111", + inventory_item_id: "aaa", + deleted_at: expect.any(Date), + }), + expect.objectContaining({ + product_id: "333", + inventory_item_id: "ccc", + deleted_at: expect.any(Date), + }), + ]) + }) + + it("Should delete all the links related to a given key", async function () { + await links.linkServiceName.softDelete({ + inventory_item_id: "bbb", + }) + + const values = await links.linkServiceName.list( + { inventory_item_id: "bbb" }, + { withDeleted: true } + ) + + expect(values).toEqual([ + expect.objectContaining({ + product_id: "222", + inventory_item_id: "bbb", + deleted_at: expect.any(Date), + }), + expect.objectContaining({ + product_id: "444", + inventory_item_id: "bbb", + deleted_at: expect.any(Date), + }), + ]) + }) +}) diff --git a/packages/cache-inmemory/jest.config.js b/packages/cache-inmemory/jest.config.js index 1b626a0af1..2fd636dce6 100644 --- a/packages/cache-inmemory/jest.config.js +++ b/packages/cache-inmemory/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.spec.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/cache-inmemory/package.json b/packages/cache-inmemory/package.json index 5a2bccc062..15b4bce9f9 100644 --- a/packages/cache-inmemory/package.json +++ b/packages/cache-inmemory/package.json @@ -19,10 +19,10 @@ "devDependencies": { "@medusajs/types": "^1.8.8", "cross-env": "^5.2.1", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "scripts": { "watch": "tsc --build --watch", diff --git a/packages/cache-redis/jest.config.js b/packages/cache-redis/jest.config.js index 1b626a0af1..2fd636dce6 100644 --- a/packages/cache-redis/jest.config.js +++ b/packages/cache-redis/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.spec.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/cache-redis/package.json b/packages/cache-redis/package.json index 28fe389c1a..6171208269 100644 --- a/packages/cache-redis/package.json +++ b/packages/cache-redis/package.json @@ -19,10 +19,10 @@ "devDependencies": { "@medusajs/types": "^1.8.8", "cross-env": "^5.2.1", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "scripts": { "watch": "tsc --build --watch", diff --git a/packages/event-bus-local/jest.config.js b/packages/event-bus-local/jest.config.js index 14cd7ed6e7..2fd636dce6 100644 --- a/packages/event-bus-local/jest.config.js +++ b/packages/event-bus-local/jest.config.js @@ -1,13 +1,13 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.spec.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, - moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], + moduleFileExtensions: [`js`, `ts`], } diff --git a/packages/event-bus-local/package.json b/packages/event-bus-local/package.json index 6d4f1a956a..8625028d49 100644 --- a/packages/event-bus-local/package.json +++ b/packages/event-bus-local/package.json @@ -19,10 +19,10 @@ "devDependencies": { "@medusajs/types": "^1.8.10", "cross-env": "^5.2.1", - "jest": "^25.5.2", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "scripts": { "watch": "tsc --build --watch", diff --git a/packages/event-bus-redis/jest.config.js b/packages/event-bus-redis/jest.config.js index 14cd7ed6e7..2fd636dce6 100644 --- a/packages/event-bus-redis/jest.config.js +++ b/packages/event-bus-redis/jest.config.js @@ -1,13 +1,13 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.spec.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, - moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], + moduleFileExtensions: [`js`, `ts`], } diff --git a/packages/event-bus-redis/package.json b/packages/event-bus-redis/package.json index 0420faefa8..b188d8c54b 100644 --- a/packages/event-bus-redis/package.json +++ b/packages/event-bus-redis/package.json @@ -19,11 +19,11 @@ "devDependencies": { "@medusajs/types": "^1.8.10", "cross-env": "^5.2.1", - "jest": "^25.5.2", + "jest": "^29.6.3", "medusa-test-utils": "^1.1.40", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "scripts": { "watch": "tsc --build --watch", diff --git a/packages/inventory/jest.config.js b/packages/inventory/jest.config.js index 1b626a0af1..2fd636dce6 100644 --- a/packages/inventory/jest.config.js +++ b/packages/inventory/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.spec.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/inventory/package.json b/packages/inventory/package.json index c161207aeb..e512924b03 100644 --- a/packages/inventory/package.json +++ b/packages/inventory/package.json @@ -19,10 +19,10 @@ "devDependencies": { "@medusajs/types": "^1.8.11", "cross-env": "^5.2.1", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "dependencies": { "@medusajs/modules-sdk": "^1.8.8", diff --git a/packages/inventory/src/joiner-config.ts b/packages/inventory/src/joiner-config.ts index 00ce5d586c..d6d20bbe1e 100644 --- a/packages/inventory/src/joiner-config.ts +++ b/packages/inventory/src/joiner-config.ts @@ -1,9 +1,14 @@ import { Modules } from "@medusajs/modules-sdk" -import { JoinerServiceConfig } from "@medusajs/types" +import { ModuleJoinerConfig } from "@medusajs/types" -export const joinerConfig: JoinerServiceConfig = { +export const joinerConfig: ModuleJoinerConfig = { serviceName: Modules.INVENTORY, primaryKeys: ["id"], + linkableKeys: [ + "inventory_item_id", + "inventory_level_id", + "reservation_item_id", + ], alias: [ { name: "inventory_items", diff --git a/packages/inventory/src/services/inventory.ts b/packages/inventory/src/services/inventory.ts index 33e7d23021..ae5ca91b3f 100644 --- a/packages/inventory/src/services/inventory.ts +++ b/packages/inventory/src/services/inventory.ts @@ -11,8 +11,8 @@ import { IInventoryService, InventoryItemDTO, InventoryLevelDTO, - JoinerServiceConfig, MODULE_RESOURCE_TYPE, + ModuleJoinerConfig, ReservationItemDTO, SharedContext, UpdateInventoryLevelInput, @@ -59,7 +59,7 @@ export default class InventoryService implements IInventoryService { this.reservationItemService_ = reservationItemService } - __joinerConfig(): JoinerServiceConfig { + __joinerConfig(): ModuleJoinerConfig { return joinerConfig } diff --git a/packages/link-modules/.gitignore b/packages/link-modules/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/link-modules/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/link-modules/jest.config.js b/packages/link-modules/jest.config.js new file mode 100644 index 0000000000..2fd636dce6 --- /dev/null +++ b/packages/link-modules/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + transform: { + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], +} diff --git a/packages/link-modules/package.json b/packages/link-modules/package.json new file mode 100644 index 0000000000..233fb018b6 --- /dev/null +++ b/packages/link-modules/package.json @@ -0,0 +1,46 @@ +{ + "name": "@medusajs/link-modules", + "version": "0.0.1", + "description": "Medusa Default Link Modules Package and Definitions", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/link-modules" + }, + "publishConfig": { + "access": "public" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "watch": "tsc --build --watch", + "watch:test": "tsc --build tsconfig.spec.json --watch", + "prepare": "cross-env NODE_ENV=production yarn run build", + "build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json", + "test": "jest --passWithNoTests --runInBand --bail --forceExit", + "test:integration": "jest --passWithNoTests" + }, + "devDependencies": { + "cross-env": "^5.2.1", + "jest": "^29.6.3", + "pg-god": "^1.0.12", + "rimraf": "^5.0.1", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "tsc-alias": "^1.8.6", + "typescript": "^5.1.6" + }, + "dependencies": { + "@medusajs/modules-sdk": "^1.8.8", + "@medusajs/types": "^1.8.11", + "@medusajs/utils": "^1.9.2", + "@mikro-orm/core": "5.7.12", + "@mikro-orm/postgresql": "5.7.12", + "awilix": "^8.0.0" + } +} diff --git a/packages/link-modules/src/definitions/index.ts b/packages/link-modules/src/definitions/index.ts new file mode 100644 index 0000000000..af95cb10df --- /dev/null +++ b/packages/link-modules/src/definitions/index.ts @@ -0,0 +1,2 @@ +export * from "./inventory-level-stock-location" +export * from "./product-variant-inventory-item" diff --git a/packages/link-modules/src/definitions/inventory-level-stock-location.ts b/packages/link-modules/src/definitions/inventory-level-stock-location.ts new file mode 100644 index 0000000000..e7c6eb4e6a --- /dev/null +++ b/packages/link-modules/src/definitions/inventory-level-stock-location.ts @@ -0,0 +1,19 @@ +import { ModuleJoinerConfig } from "@medusajs/types" +import { Modules } from "@medusajs/modules-sdk" + +export const InventoryLevelStockLocation: ModuleJoinerConfig = { + isLink: true, + isReadOnlyLink: true, + extends: [ + { + serviceName: Modules.INVENTORY, + relationship: { + serviceName: Modules.STOCK_LOCATION, + primaryKey: "id", + foreignKey: "location_id", + alias: "stock_locations", + isList: true, + }, + }, + ], +} diff --git a/packages/link-modules/src/definitions/product-variant-inventory-item.ts b/packages/link-modules/src/definitions/product-variant-inventory-item.ts new file mode 100644 index 0000000000..5901b71325 --- /dev/null +++ b/packages/link-modules/src/definitions/product-variant-inventory-item.ts @@ -0,0 +1,66 @@ +import { Modules } from "@medusajs/modules-sdk" +import { ModuleJoinerConfig } from "@medusajs/types" +import { LINKS } from "../links" + +export const ProductVariantInventoryItem: ModuleJoinerConfig = { + serviceName: LINKS.ProductVariantInventoryItem, + isLink: true, + databaseConfig: { + tableName: "product_variant_inventory_item", + idPrefix: "pvitem", + extraFields: { + required_quantity: { + type: "integer", + defaultValue: "1", + }, + }, + }, + alias: [ + { + name: "product_variant_inventory_item", + }, + { + name: "product_variant_inventory_items", + }, + ], + primaryKeys: ["id", "variant_id", "inventory_item_id"], + relationships: [ + { + serviceName: Modules.PRODUCT, + primaryKey: "id", + foreignKey: "variant_id", + alias: "variant", + args: { + methodSuffix: "Variants", + }, + }, + { + serviceName: Modules.INVENTORY, + primaryKey: "id", + foreignKey: "inventory_item_id", + alias: "inventory", + deleteCascade: true, + }, + ], + extends: [ + { + serviceName: Modules.PRODUCT, + relationship: { + serviceName: LINKS.ProductVariantInventoryItem, + primaryKey: "variant_id", + foreignKey: "id", + alias: "inventory_items", + isList: true, + }, + }, + { + serviceName: Modules.INVENTORY, + relationship: { + serviceName: LINKS.ProductVariantInventoryItem, + primaryKey: "inventory_item_id", + foreignKey: "id", + alias: "variant_link", + }, + }, + ], +} diff --git a/packages/link-modules/src/index.ts b/packages/link-modules/src/index.ts new file mode 100644 index 0000000000..3acb2f2c61 --- /dev/null +++ b/packages/link-modules/src/index.ts @@ -0,0 +1,4 @@ +export * from "./initialize" +export * from "./types" +export * from "./loaders" +export * from "./services" diff --git a/packages/link-modules/src/initialize/index.ts b/packages/link-modules/src/initialize/index.ts new file mode 100644 index 0000000000..d4e3be2c0b --- /dev/null +++ b/packages/link-modules/src/initialize/index.ts @@ -0,0 +1,190 @@ +import { InternalModuleDeclaration, MedusaModule } from "@medusajs/modules-sdk" +import { + ExternalModuleDeclaration, + ILinkModule, + LinkModuleDefinition, + LoaderOptions, + MODULE_RESOURCE_TYPE, + MODULE_SCOPE, + ModuleExports, + ModuleJoinerConfig, + ModuleServiceInitializeCustomDataLayerOptions, + ModuleServiceInitializeOptions, +} from "@medusajs/types" +import { + ContainerRegistrationKeys, + lowerCaseFirst, + simpleHash, +} from "@medusajs/utils" +import * as linkDefinitions from "../definitions" +import { getMigration } from "../migration" +import { InitializeModuleInjectableDependencies } from "../types" +import { composeLinkName } from "../utils" +import { getLinkModuleDefinition } from "./module-definition" + +export const initialize = async ( + options?: + | ModuleServiceInitializeOptions + | ModuleServiceInitializeCustomDataLayerOptions + | ExternalModuleDeclaration + | InternalModuleDeclaration, + modulesDefinition?: ModuleJoinerConfig[], + injectedDependencies?: InitializeModuleInjectableDependencies +): Promise<{ [link: string]: ILinkModule }> => { + const allLinks = {} + const modulesLoadedKeys = MedusaModule.getLoadedModules().map( + (mod) => Object.keys(mod)[0] + ) + + const allLinksToLoad = Object.values(linkDefinitions).concat( + modulesDefinition ?? [] + ) + + for (const linkDefinition of allLinksToLoad) { + const definition = JSON.parse(JSON.stringify(linkDefinition)) + + const [primary, foreign] = definition.relationships ?? [] + + if (definition.relationships?.length !== 2 && !definition.isReadOnlyLink) { + throw new Error( + `Link module ${definition.serviceName} can only link 2 modules.` + ) + } else if ( + foreign?.foreignKey?.split(",").length > 1 && + !definition.isReadOnlyLink + ) { + throw new Error(`Foreign key cannot be a composed key.`) + } + + const serviceKey = !definition.isReadOnlyLink + ? lowerCaseFirst( + definition.serviceName ?? + composeLinkName( + primary.serviceName, + primary.foreignKey, + foreign.serviceName, + foreign.foreignKey + ) + ) + : simpleHash(JSON.stringify(definition.extends)) + + if (modulesLoadedKeys.includes(serviceKey)) { + continue + } else if (serviceKey in allLinks) { + throw new Error(`Link module ${serviceKey} already defined.`) + } + + if (definition.isReadOnlyLink) { + const extended: any[] = [] + for (const extension of definition.extends ?? []) { + if ( + modulesLoadedKeys.includes(extension.serviceName) && + modulesLoadedKeys.includes(extension.relationship.serviceName) + ) { + extended.push(extension) + } + } + + definition.extends = extended + if (extended.length === 0) { + continue + } + } else if ( + !modulesLoadedKeys.includes(primary.serviceName) || + !modulesLoadedKeys.includes(foreign.serviceName) + ) { + // TODO: This should be uncommented when all modules are done + // continue + } + + const moduleDefinition = getLinkModuleDefinition( + definition, + primary, + foreign + ) as ModuleExports + + const linkModuleDefinition: LinkModuleDefinition = { + key: serviceKey, + registrationName: serviceKey, + label: serviceKey, + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: injectedDependencies?.[ + ContainerRegistrationKeys.PG_CONNECTION + ] + ? MODULE_RESOURCE_TYPE.SHARED + : MODULE_RESOURCE_TYPE.ISOLATED, + }, + } + + const loaded = await MedusaModule.bootstrapLink( + linkModuleDefinition, + options as InternalModuleDeclaration, + moduleDefinition, + injectedDependencies + ) + + allLinks[serviceKey as string] = Object.values(loaded)[0] + } + + return allLinks +} + +export async function runMigrations( + { + options, + logger, + }: Omit, "container">, + modulesDefinition?: ModuleJoinerConfig[] +) { + const modulesLoadedKeys = MedusaModule.getLoadedModules().map( + (mod) => Object.keys(mod)[0] + ) + + const allLinksToLoad = Object.values(linkDefinitions).concat( + modulesDefinition ?? [] + ) + + const allLinks = new Set() + for (const definition of allLinksToLoad) { + if (definition.isReadOnlyLink) { + continue + } + + if (definition.relationships?.length !== 2) { + throw new Error( + `Link module ${definition.serviceName} must have 2 relationships.` + ) + } + + const [primary, foreign] = definition.relationships ?? [] + const serviceKey = lowerCaseFirst( + definition.serviceName ?? + composeLinkName( + primary.serviceName, + primary.foreignKey, + foreign.serviceName, + foreign.foreignKey + ) + ) + + if (modulesLoadedKeys.includes(serviceKey)) { + continue + } else if (allLinks.has(serviceKey)) { + throw new Error(`Link module ${serviceKey} already exists.`) + } + + allLinks.add(serviceKey) + + if ( + !modulesLoadedKeys.includes(primary.serviceName) || + !modulesLoadedKeys.includes(foreign.serviceName) + ) { + // TODO: This should be uncommented when all modules are done + // continue + } + + const migrate = getMigration(definition, serviceKey, primary, foreign) + await migrate({ options, logger }) + } +} diff --git a/packages/link-modules/src/initialize/module-definition.ts b/packages/link-modules/src/initialize/module-definition.ts new file mode 100644 index 0000000000..a4a2b06300 --- /dev/null +++ b/packages/link-modules/src/initialize/module-definition.ts @@ -0,0 +1,24 @@ +import { + JoinerRelationship, + ModuleExports, + ModuleJoinerConfig, +} from "@medusajs/types" +import { getModuleService, getReadOnlyModuleService } from "@services" +import { getLoaders } from "../loaders" + +export function getLinkModuleDefinition( + joinerConfig: ModuleJoinerConfig, + primary: JoinerRelationship, + foreign: JoinerRelationship +): ModuleExports { + return { + service: joinerConfig.isReadOnlyLink + ? getReadOnlyModuleService(joinerConfig) + : getModuleService(joinerConfig), + loaders: getLoaders({ + joinerConfig, + primary, + foreign, + }), + } +} diff --git a/packages/link-modules/src/links.ts b/packages/link-modules/src/links.ts new file mode 100644 index 0000000000..d59af9c986 --- /dev/null +++ b/packages/link-modules/src/links.ts @@ -0,0 +1,11 @@ +import { Modules } from "@medusajs/modules-sdk" +import { composeLinkName } from "./utils" + +export const LINKS = { + ProductVariantInventoryItem: composeLinkName( + Modules.PRODUCT, + "variant_id", + Modules.INVENTORY, + "inventory_item_id" + ), +} diff --git a/packages/link-modules/src/loaders/connection.ts b/packages/link-modules/src/loaders/connection.ts new file mode 100644 index 0000000000..987d5eec7f --- /dev/null +++ b/packages/link-modules/src/loaders/connection.ts @@ -0,0 +1,31 @@ +import { + InternalModuleDeclaration, + LoaderOptions, + ModuleServiceInitializeCustomDataLayerOptions, + ModuleServiceInitializeOptions, +} from "@medusajs/modules-sdk" +import { ModulesSdkUtils } from "@medusajs/utils" + +import { EntitySchema } from "@mikro-orm/core" + +export function connectionLoader(entity: EntitySchema) { + return async ( + { + options, + container, + logger, + }: LoaderOptions< + | ModuleServiceInitializeOptions + | ModuleServiceInitializeCustomDataLayerOptions + >, + moduleDeclaration?: InternalModuleDeclaration + ): Promise => { + await ModulesSdkUtils.mikroOrmConnectionLoader({ + entities: [entity], + container, + options, + moduleDeclaration, + logger, + }) + } +} diff --git a/packages/link-modules/src/loaders/container.ts b/packages/link-modules/src/loaders/container.ts new file mode 100644 index 0000000000..c563cadd82 --- /dev/null +++ b/packages/link-modules/src/loaders/container.ts @@ -0,0 +1,40 @@ +import { BaseRepository, getLinkRepository } from "@repositories" +import { LinkService, getModuleService } from "@services" + +import { LoaderOptions } from "@medusajs/modules-sdk" +import { + InternalModuleDeclaration, + ModuleJoinerConfig, + ModulesSdkTypes, +} from "@medusajs/types" +import { asClass, asValue } from "awilix" + +export function containerLoader(entity, joinerConfig: ModuleJoinerConfig) { + return async ( + { + options, + container, + }: LoaderOptions< + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + >, + moduleDeclaration?: InternalModuleDeclaration + ): Promise => { + const [primary, foreign] = joinerConfig.relationships! + + container.register({ + joinerConfig: asValue(joinerConfig), + primaryKey: asValue(primary.foreignKey.split(",")), + foreignKey: asValue(foreign.foreignKey), + extraFields: asValue( + Object.keys(joinerConfig.databaseConfig?.extraFields || {}) + ), + + linkModuleService: asClass(getModuleService(joinerConfig)).singleton(), + linkService: asClass(LinkService).singleton(), + + baseRepository: asClass(BaseRepository).singleton(), + linkRepository: asClass(getLinkRepository(entity)).singleton(), + }) + } +} diff --git a/packages/link-modules/src/loaders/index.ts b/packages/link-modules/src/loaders/index.ts new file mode 100644 index 0000000000..98cc5ccf04 --- /dev/null +++ b/packages/link-modules/src/loaders/index.ts @@ -0,0 +1,26 @@ +import { + JoinerRelationship, + ModuleJoinerConfig, + ModuleLoaderFunction, +} from "@medusajs/types" + +import { generateEntity } from "../utils" +import { connectionLoader } from "./connection" +import { containerLoader } from "./container" + +export function getLoaders({ + joinerConfig, + primary, + foreign, +}: { + joinerConfig: ModuleJoinerConfig + primary: JoinerRelationship + foreign: JoinerRelationship +}): ModuleLoaderFunction[] { + if (joinerConfig.isReadOnlyLink) { + return [] + } + + const entity = generateEntity(joinerConfig, primary, foreign) + return [connectionLoader(entity), containerLoader(entity, joinerConfig)] +} diff --git a/packages/link-modules/src/migration/index.ts b/packages/link-modules/src/migration/index.ts new file mode 100644 index 0000000000..45463850bc --- /dev/null +++ b/packages/link-modules/src/migration/index.ts @@ -0,0 +1,78 @@ +import { + JoinerRelationship, + LoaderOptions, + Logger, + ModuleJoinerConfig, + ModuleServiceInitializeOptions, +} from "@medusajs/types" +import { generateEntity } from "../utils" + +import { DALUtils, ModulesSdkUtils } from "@medusajs/utils" + +export function getMigration( + joinerConfig: ModuleJoinerConfig, + serviceName: string, + primary: JoinerRelationship, + foreign: JoinerRelationship +) { + return async function runMigrations( + { + options, + logger, + }: Pick< + LoaderOptions, + "options" | "logger" + > = {} as any + ) { + logger ??= console as unknown as Logger + + const dbData = ModulesSdkUtils.loadDatabaseConfig("link_modules", options) + const entity = generateEntity(joinerConfig, primary, foreign) + + const orm = await DALUtils.mikroOrmCreateConnection(dbData, [entity]) + + const tableName = entity.meta.collection + + let hasTable = false + try { + await orm.em.getConnection().execute(`SELECT 1 FROM ${tableName} LIMIT 0`) + hasTable = true + } catch {} + + const generator = orm.getSchemaGenerator() + if (hasTable) { + const updateSql = await generator.getUpdateSchemaSQL() + const entityUpdates = updateSql + .split(";") + .map((sql) => sql.trim()) + .filter((sql) => + sql.toLowerCase().includes(`alter table "${tableName.toLowerCase()}"`) + ) + + if (entityUpdates.length > 0) { + try { + await generator.execute(entityUpdates.join(";")) + logger.info(`Link module "${serviceName}" migration executed`) + } catch (error) { + logger.error( + `Link module "${serviceName}" migration failed to run - Error: ${error}` + ) + } + } else { + logger.info(`Skipping "${tableName}" migration.`) + } + } else { + try { + await generator.createSchema() + + logger.info(`Link module "${serviceName}" migration executed`) + } catch (error) { + logger.error( + `Link module "${serviceName}" migration failed to run - Error: ${error}` + ) + } + } + + await orm.close() + } +} diff --git a/packages/link-modules/src/repositories/index.ts b/packages/link-modules/src/repositories/index.ts new file mode 100644 index 0000000000..7f222e85fd --- /dev/null +++ b/packages/link-modules/src/repositories/index.ts @@ -0,0 +1,2 @@ +export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" +export { getLinkRepository } from "./link" diff --git a/packages/link-modules/src/repositories/link.ts b/packages/link-modules/src/repositories/link.ts new file mode 100644 index 0000000000..66f2f1e14b --- /dev/null +++ b/packages/link-modules/src/repositories/link.ts @@ -0,0 +1,104 @@ +import { Context, FindOptions, ModuleJoinerConfig } from "@medusajs/types" +import { + EntitySchema, + LoadStrategy, + FilterQuery as MikroFilterQuery, + FindOptions as MikroOptions, +} from "@mikro-orm/core" + +import { + MikroOrmAbstractBaseRepository, + generateEntityId, +} from "@medusajs/utils" +import { SqlEntityManager } from "@mikro-orm/postgresql" + +export function getLinkRepository(model: EntitySchema) { + return class LinkRepository extends MikroOrmAbstractBaseRepository { + readonly manager_: SqlEntityManager + readonly model_: EntitySchema + readonly joinerConfig_: ModuleJoinerConfig + + constructor({ + manager, + joinerConfig, + }: { + manager: SqlEntityManager + joinerConfig: ModuleJoinerConfig + }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager + this.model_ = model + this.joinerConfig_ = joinerConfig + } + + async find( + findOptions: FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.find( + this.model_, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async findAndCount( + findOptions: FindOptions = { where: {} }, + context: Context = {} + ): Promise<[object[], number]> { + const manager = this.getActiveManager(context) + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.findAndCount( + this.model_, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async delete(data: any, context: Context = {}): Promise { + const filter = {} + for (const key in data) { + filter[key] = { + $in: Array.isArray(data[key]) ? data[key] : [data[key]], + } + } + + const manager = this.getActiveManager(context) + await manager.nativeDelete(this.model_, data, {}) + } + + async create(data: object[], context: Context = {}): Promise { + const manager = this.getActiveManager(context) + + const links = data.map((link: any) => { + link.id = generateEntityId( + link.id, + this.joinerConfig_.databaseConfig?.idPrefix ?? "link" + ) + + return manager.create(this.model_, link) + }) + + await manager.upsertMany(this.model_, links) + + return links + } + } +} diff --git a/packages/link-modules/src/services/dynamic-service-class.ts b/packages/link-modules/src/services/dynamic-service-class.ts new file mode 100644 index 0000000000..a06db9e8a3 --- /dev/null +++ b/packages/link-modules/src/services/dynamic-service-class.ts @@ -0,0 +1,22 @@ +import { Constructor, ILinkModule, ModuleJoinerConfig } from "@medusajs/types" +import { LinkModuleService } from "@services" + +export function getModuleService( + joinerConfig: ModuleJoinerConfig +): Constructor { + const joinerConfig_ = JSON.parse(JSON.stringify(joinerConfig)) + delete joinerConfig_.databaseConfig + return class LinkService extends LinkModuleService { + override __joinerConfig(): ModuleJoinerConfig { + return joinerConfig_ as ModuleJoinerConfig + } + } +} + +export function getReadOnlyModuleService(joinerConfig: ModuleJoinerConfig) { + return class ReadOnlyLinkService { + __joinerConfig(): ModuleJoinerConfig { + return joinerConfig as ModuleJoinerConfig + } + } +} diff --git a/packages/link-modules/src/services/index.ts b/packages/link-modules/src/services/index.ts new file mode 100644 index 0000000000..402b787300 --- /dev/null +++ b/packages/link-modules/src/services/index.ts @@ -0,0 +1,3 @@ +export * from "./dynamic-service-class" +export { default as LinkService } from "./link" +export { default as LinkModuleService } from "./link-module-service" diff --git a/packages/link-modules/src/services/link-module-service.ts b/packages/link-modules/src/services/link-module-service.ts new file mode 100644 index 0000000000..1569afdc79 --- /dev/null +++ b/packages/link-modules/src/services/link-module-service.ts @@ -0,0 +1,301 @@ +import { + Context, + DAL, + FindConfig, + ILinkModule, + InternalModuleDeclaration, + ModuleJoinerConfig, + RestoreReturn, + SoftDeleteReturn, +} from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MapToConfig, + MedusaContext, + MedusaError, + ModulesSdkUtils, + mapObjectTo, +} from "@medusajs/utils" +import { LinkService } from "@services" +import { shouldForceTransaction } from "../utils" + +type InjectedDependencies = { + baseRepository: DAL.RepositoryService + linkService: LinkService + primaryKey: string | string[] + foreignKey: string + extraFields: string[] +} + +export default class LinkModuleService implements ILinkModule { + protected baseRepository_: DAL.RepositoryService + protected readonly linkService_: LinkService + protected primaryKey_: string[] + protected foreignKey_: string + protected extraFields_: string[] + + constructor( + { + baseRepository, + linkService, + primaryKey, + foreignKey, + extraFields, + }: InjectedDependencies, + readonly moduleDeclaration: InternalModuleDeclaration + ) { + this.baseRepository_ = baseRepository + this.linkService_ = linkService + this.primaryKey_ = !Array.isArray(primaryKey) ? [primaryKey] : primaryKey + this.foreignKey_ = foreignKey + this.extraFields_ = extraFields + } + + __joinerConfig(): ModuleJoinerConfig { + return {} as ModuleJoinerConfig + } + + private buildData( + primaryKeyData: string | string[], + foreignKeyData: string, + extra: Record = {} + ) { + if (this.primaryKey_.length > 1) { + if ( + !Array.isArray(primaryKeyData) || + primaryKeyData.length !== this.primaryKey_.length + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Primary key data must be an array ${this.primaryKey_.length} values` + ) + } + } + + const pk = this.primaryKey_.join(",") + return { + [pk]: primaryKeyData, + [this.foreignKey_]: foreignKeyData, + ...extra, + } + } + + private isValidKeyName(name: string) { + return this.primaryKey_.concat(this.foreignKey_).includes(name) + } + + private validateFields(data: any) { + const keys = Object.keys(data) + if (!keys.every((k) => this.isValidKeyName(k))) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid field name provided. Valid field names are ${this.primaryKey_.concat( + this.foreignKey_ + )}` + ) + } + } + + @InjectManager("baseRepository_") + async retrieve( + primaryKeyData: string | string[], + foreignKeyData: string, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const filter = this.buildData(primaryKeyData, foreignKeyData) + const queryOptions = ModulesSdkUtils.buildQuery(filter) + const entry = await this.linkService_.list(queryOptions, {}, sharedContext) + + if (!entry?.length) { + const pk = this.primaryKey_.join(",") + const errMessage = `${pk}[${primaryKeyData}] and ${this.foreignKey_}[${foreignKeyData}]` + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Entry ${errMessage} was not found` + ) + } + + return entry[0] + } + + @InjectManager("baseRepository_") + async list( + filters: Record = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const rows = await this.linkService_.list(filters, config, sharedContext) + + return await this.baseRepository_.serialize(rows) + } + + @InjectManager("baseRepository_") + async listAndCount( + filters: Record = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[unknown[], number]> { + const [rows, count] = await this.linkService_.listAndCount( + filters, + config, + sharedContext + ) + + return [await this.baseRepository_.serialize(rows), count] + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + async create( + primaryKeyOrBulkData: + | string + | string[] + | [string | string[], string, Record][], + foreignKeyData?: string, + extraFields?: Record, + @MedusaContext() sharedContext: Context = {} + ) { + const data: unknown[] = [] + if (foreignKeyData === undefined && Array.isArray(primaryKeyOrBulkData)) { + for (const [primaryKey, foreignKey, extra] of primaryKeyOrBulkData) { + data.push( + this.buildData( + primaryKey as string | string[], + foreignKey as string, + extra as Record + ) + ) + } + } else { + data.push( + this.buildData( + primaryKeyOrBulkData as string | string[], + foreignKeyData!, + extraFields + ) + ) + } + + const links = await this.linkService_.create(data, sharedContext) + + return await this.baseRepository_.serialize(links) + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + async dismiss( + primaryKeyOrBulkData: string | string[] | [string | string[], string][], + foreignKeyData?: string, + @MedusaContext() sharedContext: Context = {} + ) { + const data: unknown[] = [] + if (foreignKeyData === undefined && Array.isArray(primaryKeyOrBulkData)) { + for (const [primaryKey, foreignKey] of primaryKeyOrBulkData) { + data.push(this.buildData(primaryKey, foreignKey as string)) + } + } else { + data.push( + this.buildData( + primaryKeyOrBulkData as string | string[], + foreignKeyData! + ) + ) + } + + const links = await this.linkService_.dismiss(data, sharedContext) + + return await this.baseRepository_.serialize(links) + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + async delete( + data: any, + @MedusaContext() sharedContext: Context = {} + ): Promise { + this.validateFields(data) + + await this.linkService_.delete(data, sharedContext) + } + + async softDelete( + data: any, + { returnLinkableKeys }: SoftDeleteReturn = {}, + sharedContext: Context = {} + ): Promise | void> { + this.validateFields(data) + + let [, cascadedEntitiesMap] = await this.softDelete_(data, sharedContext) + + const pk = this.primaryKey_.join(",") + const entityNameToLinkableKeysMap: MapToConfig = { + LinkModel: [ + { mapTo: pk, valueFrom: pk }, + { mapTo: this.foreignKey_, valueFrom: this.foreignKey_ }, + ], + } + + let mappedCascadedEntitiesMap + if (returnLinkableKeys) { + // Map internal table/column names to their respective external linkable keys + // eg: product.id = product_id, variant.id = variant_id + mappedCascadedEntitiesMap = mapObjectTo>( + cascadedEntitiesMap, + entityNameToLinkableKeysMap, + { + pick: returnLinkableKeys, + } + ) + } + + return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0 + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + protected async softDelete_( + data: any, + @MedusaContext() sharedContext: Context = {} + ): Promise<[string[], Record]> { + return await this.linkService_.softDelete(data, sharedContext) + } + + async restore( + data: any, + { returnLinkableKeys }: RestoreReturn = {}, + sharedContext: Context = {} + ): Promise | void> { + this.validateFields(data) + + let [, cascadedEntitiesMap] = await this.restore_(data, sharedContext) + + const pk = this.primaryKey_.join(",") + const entityNameToLinkableKeysMap: MapToConfig = { + LinkModel: [ + { mapTo: pk, valueFrom: pk }, + { mapTo: this.foreignKey_, valueFrom: this.foreignKey_ }, + ], + } + + let mappedCascadedEntitiesMap + if (returnLinkableKeys) { + // Map internal table/column names to their respective external linkable keys + // eg: product.id = product_id, variant.id = variant_id + mappedCascadedEntitiesMap = mapObjectTo>( + cascadedEntitiesMap, + entityNameToLinkableKeysMap, + { + pick: returnLinkableKeys, + } + ) + } + + return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0 + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + async restore_( + data: any, + @MedusaContext() sharedContext: Context = {} + ): Promise<[string[], Record]> { + return await this.linkService_.restore(data, sharedContext) + } +} diff --git a/packages/link-modules/src/services/link.ts b/packages/link-modules/src/services/link.ts new file mode 100644 index 0000000000..b93b2d2775 --- /dev/null +++ b/packages/link-modules/src/services/link.ts @@ -0,0 +1,117 @@ +import { Context, FindConfig } from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, +} from "@medusajs/utils" +import { doNotForceTransaction } from "../utils" + +type InjectedDependencies = { + linkRepository: any +} + +export default class LinkService { + protected readonly linkRepository_: any + + constructor({ linkRepository }: InjectedDependencies) { + this.linkRepository_ = linkRepository + } + + @InjectManager("linkRepository_") + async list( + filters: unknown = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery( + filters as any, + config + ) + return await this.linkRepository_.find(queryOptions, sharedContext) + } + + @InjectManager("linkRepository_") + async listAndCount( + filters = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + return await this.linkRepository_.findAndCount(queryOptions, sharedContext) + } + + @InjectTransactionManager(doNotForceTransaction, "linkRepository_") + async create( + data: unknown[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.linkRepository_.create(data, { + transactionManager: sharedContext.transactionManager, + }) + } + + @InjectTransactionManager(doNotForceTransaction, "linkRepository_") + async dismiss( + data: unknown[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const filter: any = [] + for (const pair of data) { + filter.push({ + $and: Object.entries(pair as object).map(([key, value]) => ({ + [key]: value, + })), + }) + } + + const [rows] = await this.linkRepository_.softDelete( + { $or: filter }, + { + transactionManager: sharedContext.transactionManager, + } + ) + + return rows + } + + @InjectTransactionManager(doNotForceTransaction, "linkRepository_") + async delete( + data: unknown, + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.linkRepository_.delete(data, { + transactionManager: sharedContext.transactionManager, + }) + } + + @InjectTransactionManager(doNotForceTransaction, "linkRepository_") + async softDelete( + data: any, + @MedusaContext() sharedContext: Context = {} + ): Promise<[string[], Record]> { + const filter = {} + for (const key in data) { + filter[key] = { $in: Array.isArray(data[key]) ? data[key] : [data[key]] } + } + + return await this.linkRepository_.softDelete(filter, { + transactionManager: sharedContext.transactionManager, + }) + } + + @InjectTransactionManager(doNotForceTransaction, "linkRepository_") + async restore( + data: any, + @MedusaContext() sharedContext: Context = {} + ): Promise<[string[], Record]> { + const filter = {} + for (const key in data) { + filter[key] = { $in: Array.isArray(data[key]) ? data[key] : [data[key]] } + } + + return await this.linkRepository_.restore(data, { + transactionManager: sharedContext.transactionManager, + }) + } +} diff --git a/packages/link-modules/src/types/index.ts b/packages/link-modules/src/types/index.ts new file mode 100644 index 0000000000..0f252977b0 --- /dev/null +++ b/packages/link-modules/src/types/index.ts @@ -0,0 +1,5 @@ +import { Logger } from "@medusajs/types" + +export type InitializeModuleInjectableDependencies = { + logger?: Logger +} diff --git a/packages/link-modules/src/utils/compose-link-name.ts b/packages/link-modules/src/utils/compose-link-name.ts new file mode 100644 index 0000000000..63ccf052cf --- /dev/null +++ b/packages/link-modules/src/utils/compose-link-name.ts @@ -0,0 +1,9 @@ +import { lowerCaseFirst, toPascalCase } from "@medusajs/utils" + +export const composeLinkName = (...args) => { + return lowerCaseFirst(toPascalCase(composeTableName(...args.concat("link")))) +} + +export const composeTableName = (...args) => { + return args.map((name) => name.replace(/(_id|Service)$/gi, "")).join("_") +} diff --git a/packages/link-modules/src/utils/generate-entity.ts b/packages/link-modules/src/utils/generate-entity.ts new file mode 100644 index 0000000000..19f49a96fc --- /dev/null +++ b/packages/link-modules/src/utils/generate-entity.ts @@ -0,0 +1,110 @@ +import { JoinerRelationship, ModuleJoinerConfig } from "@medusajs/types" +import { + SoftDeletableFilterKey, + mikroOrmSoftDeletableFilterOptions, + simpleHash, +} from "@medusajs/utils" + +import { EntitySchema } from "@mikro-orm/core" +import { composeTableName } from "./compose-link-name" + +function getClass(...properties) { + return class LinkModel { + constructor(...values) { + properties.forEach((name, idx) => { + this[name] = values[idx] + }) + } + } +} + +export function generateEntity( + joinerConfig: ModuleJoinerConfig, + primary: JoinerRelationship, + foreign: JoinerRelationship +) { + const fieldNames = primary.foreignKey.split(",").concat(foreign.foreignKey) + + const tableName = + joinerConfig.databaseConfig?.tableName ?? + composeTableName( + primary.serviceName, + primary.foreignKey, + foreign.serviceName, + foreign.foreignKey + ) + + const fields = fieldNames.reduce((acc, curr) => { + acc[curr] = { + type: "string", + nullable: false, + primary: true, + } + return acc + }, {}) + + const extraFields = joinerConfig.databaseConfig?.extraFields ?? {} + + for (const column in extraFields) { + fieldNames.push(column) + + fields[column] = { + type: extraFields[column].type, + nullable: !!extraFields[column].nullable, + defaultRaw: extraFields[column].defaultValue, + ...(extraFields[column].options ?? {}), + } + } + + const hashTableName = simpleHash(tableName) + + return new EntitySchema({ + class: getClass( + ...fieldNames.concat("created_at", "updated_at", "deleted_at") + ) as any, + tableName, + properties: { + id: { + type: "string", + nullable: false, + }, + ...fields, + created_at: { + type: "Date", + nullable: false, + defaultRaw: "CURRENT_TIMESTAMP", + }, + updated_at: { + type: "Date", + nullable: false, + defaultRaw: "CURRENT_TIMESTAMP", + }, + deleted_at: { type: "Date", nullable: true }, + }, + filters: { + [SoftDeletableFilterKey]: mikroOrmSoftDeletableFilterOptions, + }, + indexes: [ + { + properties: ["id"], + name: "IDX_id_" + hashTableName, + }, + { + properties: primary.foreignKey.split(","), + name: + "IDX_" + + primary.foreignKey.split(",").join("_") + + "_" + + hashTableName, + }, + { + properties: foreign.foreignKey, + name: "IDX_" + foreign.foreignKey + "_" + hashTableName, + }, + { + properties: ["deleted_at"], + name: "IDX_deleted_at_" + hashTableName, + }, + ], + }) +} diff --git a/packages/link-modules/src/utils/index.ts b/packages/link-modules/src/utils/index.ts new file mode 100644 index 0000000000..8b19fd9ba5 --- /dev/null +++ b/packages/link-modules/src/utils/index.ts @@ -0,0 +1,12 @@ +import { MODULE_RESOURCE_TYPE } from "@medusajs/types" + +export * from "./compose-link-name" +export * from "./generate-entity" + +export function shouldForceTransaction(target: any): boolean { + return target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED +} + +export function doNotForceTransaction(): boolean { + return false +} diff --git a/packages/link-modules/tsconfig.json b/packages/link-modules/tsconfig.json new file mode 100644 index 0000000000..3e2c178076 --- /dev/null +++ b/packages/link-modules/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "target": "es2020", + "outDir": "./dist", + "esModuleInterop": true, + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "sourceMap": false, + "noImplicitReturns": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "allowJs": true, + "skipLibCheck": true, + "downlevelIteration": true, // to use ES5 specific tooling + "baseUrl": ".", + "resolveJsonModule": true, + "paths": { + "@services": ["./src/services"], + "@repositories": ["./src/repositories"] + } + }, + "include": ["src"], + "exclude": [ + "dist", + "./src/**/__tests__", + "./src/**/__mocks__", + "./src/**/__fixtures__", + "node_modules" + ] +} diff --git a/packages/link-modules/tsconfig.spec.json b/packages/link-modules/tsconfig.spec.json new file mode 100644 index 0000000000..b887bbfa39 --- /dev/null +++ b/packages/link-modules/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "integration-tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/medusa-js/test/utils/utils.test.ts b/packages/medusa-js/src/test/utils/utils.test.ts similarity index 93% rename from packages/medusa-js/test/utils/utils.test.ts rename to packages/medusa-js/src/test/utils/utils.test.ts index 055bab6130..1cdbee3ad6 100644 --- a/packages/medusa-js/test/utils/utils.test.ts +++ b/packages/medusa-js/src/test/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { stringifyNullProperties } from "../../src/utils" +const { stringifyNullProperties } = require("../../utils") describe("stringifyNullProperties", () => { test("returns empty object on no props", () => { diff --git a/packages/medusa-js/tsconfig.spec.json b/packages/medusa-js/tsconfig.spec.json index f378406440..a108b800db 100644 --- a/packages/medusa-js/tsconfig.spec.json +++ b/packages/medusa-js/tsconfig.spec.json @@ -1,4 +1,10 @@ { "extends": "./tsconfig.json", - "include": ["test"], + "include": [ + "./src/**/*", + "index.d.ts", + "./src/**/__tests__", + "./src/**/__mocks__" + ], + "exclude": ["node_modules"] } diff --git a/packages/modules-sdk/jest.config.js b/packages/modules-sdk/jest.config.js index 7de5bf104a..2fd636dce6 100644 --- a/packages/modules-sdk/jest.config.js +++ b/packages/modules-sdk/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/modules-sdk/package.json b/packages/modules-sdk/package.json index f639ccb511..411b2a33fc 100644 --- a/packages/modules-sdk/package.json +++ b/packages/modules-sdk/package.json @@ -18,10 +18,10 @@ "license": "MIT", "devDependencies": { "cross-env": "^5.2.1", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "dependencies": { "@medusajs/orchestration": "^0.2.0", diff --git a/packages/modules-sdk/src/__mocks__/inventory-module.ts b/packages/modules-sdk/src/__mocks__/inventory-module.ts new file mode 100644 index 0000000000..882ac30d19 --- /dev/null +++ b/packages/modules-sdk/src/__mocks__/inventory-module.ts @@ -0,0 +1,27 @@ +export const InventoryModule = { + __definition: { + key: "inventoryService", + registrationName: "inventoryService", + defaultPackage: false, + label: "InventoryService", + isRequired: false, + canOverride: true, + isQueryable: true, + dependencies: [], + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + __joinerConfig: { + serviceName: "inventoryService", + primaryKeys: ["id"], + linkableKeys: [ + "inventory_item_id", + "inventory_level_id", + "reservation_item_id", + ], + }, + + softDelete: jest.fn(() => {}), +} diff --git a/packages/modules-sdk/src/__mocks__/inventory-stock-location-link.ts b/packages/modules-sdk/src/__mocks__/inventory-stock-location-link.ts new file mode 100644 index 0000000000..46d6c58283 --- /dev/null +++ b/packages/modules-sdk/src/__mocks__/inventory-stock-location-link.ts @@ -0,0 +1,71 @@ +export const InventoryStockLocationLink = { + __definition: { + key: "inventoryStockLocationLink", + registrationName: "inventoryStockLocationLink", + defaultPackage: "", + label: "inventoryStockLocationLink", + canOverride: true, + isRequired: false, + isQueryable: true, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + __joinerConfig: { + serviceName: "inventoryStockLocationLink", + isLink: true, + alias: [ + { + name: "inventory_level_stock_location", + }, + { + name: "inventory_level_stock_locations", + }, + ], + primaryKeys: ["inventory_level_id", "stock_location_id"], + relationships: [ + { + serviceName: "inventoryService", + primaryKey: "id", + foreignKey: "inventory_level_id", + alias: "inventory_level", + args: {}, + }, + { + serviceName: "stockLocationService", + primaryKey: "id", + foreignKey: "stock_location_id", + alias: "stock_location", + }, + ], + extends: [ + { + serviceName: "inventoryService", + relationship: { + serviceName: "inventoryStockLocationLink", + primaryKey: "inventory_level_id", + foreignKey: "id", + alias: "inventory_location_items", + }, + }, + { + serviceName: "stockLocationService", + relationship: { + serviceName: "inventoryStockLocationLink", + primaryKey: "stock_location_id", + foreignKey: "id", + alias: "inventory_location_items", + }, + }, + ], + }, + + create: jest.fn( + async ( + primaryKeyOrBulkData: string | string[] | [string | string[], string][], + foreignKeyData?: string + ) => {} + ), + softDelete: jest.fn(() => {}), +} diff --git a/packages/modules-sdk/src/__mocks__/product-inventory-link.ts b/packages/modules-sdk/src/__mocks__/product-inventory-link.ts new file mode 100644 index 0000000000..71eeac5ced --- /dev/null +++ b/packages/modules-sdk/src/__mocks__/product-inventory-link.ts @@ -0,0 +1,77 @@ +export const ProductInventoryLinkModule = { + __definition: { + key: "productVariantInventoryInventoryItemLink", + registrationName: "productVariantInventoryInventoryItemLink", + defaultPackage: "", + label: "productVariantInventoryInventoryItemLink", + canOverride: true, + isRequired: false, + isQueryable: true, + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + __joinerConfig: { + serviceName: "productVariantInventoryInventoryItemLink", + isLink: true, + databaseConfig: { + tableName: "product_variant_inventory_item", + }, + alias: [ + { + name: "product_variant_inventory_item", + }, + { + name: "product_variant_inventory_items", + }, + ], + primaryKeys: ["variant_id", "inventory_item_id"], + relationships: [ + { + serviceName: "productService", + primaryKey: "id", + foreignKey: "variant_id", + alias: "variant", + args: {}, + deleteCascade: true, + }, + { + serviceName: "inventoryService", + primaryKey: "id", + foreignKey: "inventory_item_id", + alias: "inventory", + deleteCascade: true, + }, + ], + extends: [ + { + serviceName: "productService", + relationship: { + serviceName: "productVariantInventoryInventoryItemLink", + primaryKey: "variant_id", + foreignKey: "id", + alias: "inventory_items", + isList: true, + }, + }, + { + serviceName: "inventoryService", + relationship: { + serviceName: "productVariantInventoryInventoryItemLink", + primaryKey: "inventory_item_id", + foreignKey: "id", + alias: "variant_link", + }, + }, + ], + }, + + create: jest.fn( + async ( + primaryKeyOrBulkData: string | string[] | [string | string[], string][], + foreignKeyData?: string + ) => {} + ), + softDelete: jest.fn(() => {}), +} diff --git a/packages/modules-sdk/src/__mocks__/product-module.ts b/packages/modules-sdk/src/__mocks__/product-module.ts new file mode 100644 index 0000000000..64e971a174 --- /dev/null +++ b/packages/modules-sdk/src/__mocks__/product-module.ts @@ -0,0 +1,24 @@ +export const ProductModule = { + __definition: { + key: "productService", + registrationName: "productModuleService", + defaultPackage: false, + label: "ProductModuleService", + isRequired: false, + canOverride: true, + isQueryable: true, + dependencies: ["eventBusModuleService"], + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + __joinerConfig: { + serviceName: "productService", + primaryKeys: ["id", "handle"], + linkableKeys: ["product_id", "variant_id"], + alias: [], + }, + + softDelete: jest.fn(() => {}), +} diff --git a/packages/modules-sdk/src/__mocks__/stock-location-module.ts b/packages/modules-sdk/src/__mocks__/stock-location-module.ts new file mode 100644 index 0000000000..bbf215587e --- /dev/null +++ b/packages/modules-sdk/src/__mocks__/stock-location-module.ts @@ -0,0 +1,24 @@ +export const StockLocationModule = { + __definition: { + key: "stockLocationService", + registrationName: "stockLocationService", + defaultPackage: false, + label: "StockLocationService", + isRequired: false, + canOverride: true, + isQueryable: true, + dependencies: ["eventBusService"], + defaultModuleDeclaration: { + scope: "internal", + resources: "shared", + }, + }, + __joinerConfig: { + serviceName: "stockLocationService", + primaryKeys: ["id"], + linkableKeys: ["stock_location_id"], + alias: [], + }, + + softDelete: jest.fn(() => {}), +} diff --git a/packages/modules-sdk/src/__tests__/remote-link.spec.ts b/packages/modules-sdk/src/__tests__/remote-link.spec.ts new file mode 100644 index 0000000000..781b467525 --- /dev/null +++ b/packages/modules-sdk/src/__tests__/remote-link.spec.ts @@ -0,0 +1,202 @@ +import { InventoryModule } from "../__mocks__/inventory-module" +import { InventoryStockLocationLink } from "../__mocks__/inventory-stock-location-link" +import { ProductInventoryLinkModule } from "../__mocks__/product-inventory-link" +import { ProductModule } from "../__mocks__/product-module" +import { StockLocationModule } from "../__mocks__/stock-location-module" + +import { RemoteLink } from "../remote-link" + +const allModules = [ + // modules + ProductModule, + InventoryModule, + StockLocationModule, + // links + ProductInventoryLinkModule, + InventoryStockLocationLink, +] +describe("Remote Link", function () { + it("Should get all loaded modules and compose their relationships", async function () { + const remoteLink = new RemoteLink(allModules as any) + + const relations = remoteLink.getRelationships() + + const prodInventoryLink = relations.get( + "productVariantInventoryInventoryItemLink" + ) + const prodModule = relations.get("productService") + const inventoryModule = relations.get("inventoryService") + + expect(prodInventoryLink?.get("variant_id")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + serviceName: "productService", + primaryKey: "id", + foreignKey: "variant_id", + alias: "variant", + deleteCascade: true, + isPrimary: false, + isForeign: true, + }), + ]) + ) + + expect(prodInventoryLink?.get("inventory_item_id")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + serviceName: "inventoryService", + primaryKey: "id", + foreignKey: "inventory_item_id", + alias: "inventory", + deleteCascade: true, + isPrimary: false, + isForeign: true, + }), + ]) + ) + + expect(prodModule?.get("variant_id")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + serviceName: "productVariantInventoryInventoryItemLink", + primaryKey: "variant_id", + foreignKey: "id", + alias: "inventory_items", + isList: true, + isPrimary: true, + isForeign: false, + }), + ]) + ) + + expect(inventoryModule?.get("inventory_item_id")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + serviceName: "productVariantInventoryInventoryItemLink", + primaryKey: "inventory_item_id", + foreignKey: "id", + alias: "variant_link", + isPrimary: true, + isForeign: false, + }), + ]) + ) + }) + + it("Should call the correct link module to create relation between 2 keys", async function () { + const remoteLink = new RemoteLink(allModules as any) + + await remoteLink.create([ + { + productService: { + variant_id: "var_123", + }, + inventoryService: { + inventory_item_id: "inv_123", + }, + }, + { + productService: { + variant_id: "var_abc", + }, + inventoryService: { + inventory_item_id: "inv_abc", + }, + }, + { + inventoryService: { + inventory_level_id: "ilev_123", + }, + stockLocationService: { + stock_location_id: "loc_123", + }, + }, + ]) + + expect(ProductInventoryLinkModule.create).toBeCalledWith([ + ["var_123", "inv_123"], + ["var_abc", "inv_abc"], + ]) + expect(InventoryStockLocationLink.create).toBeCalledWith([ + ["ilev_123", "loc_123"], + ]) + }) + + it("Should call delete in cascade all the modules involved in the link", async function () { + const remoteLink = new RemoteLink(allModules as any) + + ProductInventoryLinkModule.softDelete.mockImplementation(() => { + return { + variant_id: ["var_123"], + inventory_item_id: ["inv_123"], + } + }) + + ProductModule.softDelete.mockImplementation(() => { + return { + product_id: ["prod_123", "prod_abc"], + variant_id: ["var_123", "var_abc"], + } + }) + + InventoryModule.softDelete.mockImplementation(() => { + return { + inventory_item_id: ["inv_123"], + inventory_level_id: ["ilev_123"], + } + }) + + InventoryStockLocationLink.softDelete.mockImplementation(() => { + return { + inventory_level_id: ["ilev_123"], + stock_location_id: ["loc_123"], + } + }) + + await remoteLink.delete({ + productService: { + variant_id: "var_123", + }, + }) + + expect(ProductInventoryLinkModule.softDelete).toBeCalledTimes(2) + expect(ProductModule.softDelete).toBeCalledTimes(1) + expect(InventoryModule.softDelete).toBeCalledTimes(1) + expect(InventoryStockLocationLink.softDelete).toBeCalledTimes(1) + + expect(ProductInventoryLinkModule.softDelete).toHaveBeenNthCalledWith( + 1, + { variant_id: ["var_123"] }, + { returnLinkableKeys: ["variant_id", "inventory_item_id"] } + ) + + expect(ProductInventoryLinkModule.softDelete).toHaveBeenNthCalledWith( + 2, + { variant_id: ["var_abc"] }, + { returnLinkableKeys: ["variant_id", "inventory_item_id"] } + ) + + expect(ProductModule.softDelete).toBeCalledWith( + { id: ["var_123"] }, + { returnLinkableKeys: ["product_id", "variant_id"] } + ) + + expect(InventoryModule.softDelete).toBeCalledWith( + { id: ["inv_123"] }, + { + returnLinkableKeys: [ + "inventory_item_id", + "inventory_level_id", + "reservation_item_id", + ], + } + ) + + expect(InventoryStockLocationLink.softDelete).toBeCalledWith( + { + inventory_level_id: ["ilev_123"], + }, + { returnLinkableKeys: ["inventory_level_id", "stock_location_id"] } + ) + }) +}) diff --git a/packages/modules-sdk/src/index.ts b/packages/modules-sdk/src/index.ts index b2737e11e6..5c27d7aebd 100644 --- a/packages/modules-sdk/src/index.ts +++ b/packages/modules-sdk/src/index.ts @@ -1,6 +1,8 @@ export * from "@medusajs/types/dist/modules-sdk" export * from "./definitions" export * from "./loaders" +export * from "./medusa-app" export * from "./medusa-module" export * from "./module-helper" +export * from "./remote-link" export * from "./remote-query" diff --git a/packages/modules-sdk/src/loaders/module-loader.ts b/packages/modules-sdk/src/loaders/module-loader.ts index afc282915b..0b04f74062 100644 --- a/packages/modules-sdk/src/loaders/module-loader.ts +++ b/packages/modules-sdk/src/loaders/module-loader.ts @@ -1,9 +1,10 @@ import { Logger, - MedusaContainer, MODULE_SCOPE, + MedusaContainer, ModuleResolution, } from "@medusajs/types" + import { asValue } from "awilix" import { EOL } from "os" import { ModulesHelper } from "../module-helper" @@ -11,53 +12,6 @@ import { loadInternalModule } from "./utils" export const moduleHelper = new ModulesHelper() -async function loadModule( - container: MedusaContainer, - resolution: ModuleResolution, - logger: Logger -): Promise<{ error?: Error } | void> { - const modDefinition = resolution.definition - const registrationName = modDefinition.registrationName - - const { scope, resources } = resolution.moduleDeclaration ?? ({} as any) - - const canSkip = - !resolution.resolutionPath && - !modDefinition.isRequired && - !modDefinition.defaultPackage - - if (scope === MODULE_SCOPE.EXTERNAL && !canSkip) { - // TODO: implement external Resolvers - // return loadExternalModule(...) - throw new Error("External Modules are not supported yet.") - } - - if (!scope || (scope === MODULE_SCOPE.INTERNAL && !resources)) { - let message = `The module ${resolution.definition.label} has to define its scope (internal | external)` - if (scope === MODULE_SCOPE.INTERNAL && !resources) { - message = `The module ${resolution.definition.label} is missing its resources config` - } - - container.register({ - [registrationName]: asValue(undefined), - }) - - return { - error: new Error(message), - } - } - - if (resolution.resolutionPath === false) { - container.register({ - [registrationName]: asValue(undefined), - }) - - return - } - - return await loadInternalModule(container, resolution, logger) -} - export const moduleLoader = async ({ container, moduleResolutions, @@ -94,7 +48,48 @@ export const moduleLoader = async ({ }, {}) ) - container.register({ - modulesHelper: asValue(moduleHelper), - }) + container.register("modulesHelper", asValue(moduleHelper)) +} + +async function loadModule( + container: MedusaContainer, + resolution: ModuleResolution, + logger: Logger +): Promise<{ error?: Error } | void> { + const modDefinition = resolution.definition + const registrationName = modDefinition.registrationName + + const { scope, resources } = resolution.moduleDeclaration ?? ({} as any) + + const canSkip = + !resolution.resolutionPath && + !modDefinition.isRequired && + !modDefinition.defaultPackage + + if (scope === MODULE_SCOPE.EXTERNAL && !canSkip) { + // TODO: implement external Resolvers + // return loadExternalModule(...) + throw new Error("External Modules are not supported yet.") + } + + if (!scope || (scope === MODULE_SCOPE.INTERNAL && !resources)) { + let message = `The module ${resolution.definition.label} has to define its scope (internal | external)` + if (scope === MODULE_SCOPE.INTERNAL && !resources) { + message = `The module ${resolution.definition.label} is missing its resources config` + } + + container.register(registrationName, asValue(undefined)) + + return { + error: new Error(message), + } + } + + if (resolution.resolutionPath === false) { + container.register(registrationName, asValue(undefined)) + + return + } + + return await loadInternalModule(container, resolution, logger) } diff --git a/packages/modules-sdk/src/loaders/register-modules.ts b/packages/modules-sdk/src/loaders/register-modules.ts index 7b101192cd..9cc2ffd75d 100644 --- a/packages/modules-sdk/src/loaders/register-modules.ts +++ b/packages/modules-sdk/src/loaders/register-modules.ts @@ -6,9 +6,10 @@ import { ModuleExports, ModuleResolution, } from "@medusajs/types" + import { isObject } from "@medusajs/utils" import resolveCwd from "resolve-cwd" -import MODULE_DEFINITIONS from "../definitions" +import { MODULE_DEFINITIONS, ModulesDefinition } from "../definitions" export const registerModules = ( modules?: Record< @@ -46,34 +47,57 @@ export const registerModules = ( export const registerMedusaModule = ( moduleKey: string, - moduleDeclaration: InternalModuleDeclaration | ExternalModuleDeclaration, + moduleDeclaration: + | Partial + | string + | false, + moduleExports?: ModuleExports, + definition?: ModuleDefinition +): Record => { + const moduleResolutions = {} as Record + + const modDefinition = definition ?? ModulesDefinition[moduleKey] + + if (modDefinition === undefined) { + throw new Error(`Module: ${moduleKey} is not defined.`) + } + + if ( + isObject(moduleDeclaration) && + moduleDeclaration?.scope === MODULE_SCOPE.EXTERNAL + ) { + // TODO: getExternalModuleResolution(...) + throw new Error("External Modules are not supported yet.") + } + + moduleResolutions[moduleKey] = getInternalModuleResolution( + modDefinition, + moduleDeclaration as InternalModuleDeclaration, + moduleExports + ) + + return moduleResolutions +} + +export const registerMedusaLinkModule = ( + definition: ModuleDefinition, + moduleDeclaration: Partial, moduleExports?: ModuleExports ): Record => { const moduleResolutions = {} as Record - for (const definition of MODULE_DEFINITIONS) { - if (definition.key !== moduleKey) { - continue - } - - if (moduleDeclaration.scope === MODULE_SCOPE.EXTERNAL) { - // TODO: getExternalModuleResolution(...) - throw new Error("External Modules are not supported yet.") - } - - moduleResolutions[definition.key] = getInternalModuleResolution( - definition, - moduleDeclaration as InternalModuleDeclaration, - moduleExports - ) - } + moduleResolutions[definition.key] = getInternalModuleResolution( + definition, + moduleDeclaration as InternalModuleDeclaration, + moduleExports + ) return moduleResolutions } function getInternalModuleResolution( definition: ModuleDefinition, - moduleConfig: InternalModuleDeclaration | false | string, + moduleConfig: InternalModuleDeclaration | string | false, moduleExports?: ModuleExports ): ModuleResolution { if (typeof moduleConfig === "boolean") { @@ -116,7 +140,7 @@ function getInternalModuleResolution( ), ], moduleDeclaration: { - ...definition.defaultModuleDeclaration, + ...(definition.defaultModuleDeclaration ?? {}), ...moduleDeclaration, }, moduleExports, diff --git a/packages/modules-sdk/src/loaders/utils/load-internal.ts b/packages/modules-sdk/src/loaders/utils/load-internal.ts index 0e0429384b..810d3698cf 100644 --- a/packages/modules-sdk/src/loaders/utils/load-internal.ts +++ b/packages/modules-sdk/src/loaders/utils/load-internal.ts @@ -2,9 +2,9 @@ import { Constructor, InternalModuleDeclaration, Logger, - MedusaContainer, MODULE_RESOURCE_TYPE, MODULE_SCOPE, + MedusaContainer, ModuleExports, ModuleResolution, } from "@medusajs/types" @@ -30,9 +30,14 @@ export async function loadInternalModule( // the exports. This is useful when a package export an initialize function which will bootstrap itself and therefore // does not need to import the package that is currently being loaded as it would create a // circular reference. - loadedModule = - resolution.moduleExports ?? - (await import(resolution.resolutionPath as string)).default + const path = resolution.resolutionPath as string + + if (resolution.moduleExports) { + loadedModule = resolution.moduleExports + } else { + loadedModule = await import(path) + loadedModule = (loadedModule as any).default + } } catch (error) { if ( resolution.definition.isRequired && diff --git a/packages/modules-sdk/src/medusa-app.ts b/packages/modules-sdk/src/medusa-app.ts new file mode 100644 index 0000000000..ccbde5a4fd --- /dev/null +++ b/packages/modules-sdk/src/medusa-app.ts @@ -0,0 +1,183 @@ +import { RemoteFetchDataCallback } from "@medusajs/orchestration" +import { + LoadedModule, + MODULE_RESOURCE_TYPE, + MODULE_SCOPE, + ModuleConfig, + ModuleJoinerConfig, + ModuleServiceInitializeOptions, + RemoteJoinerQuery, +} from "@medusajs/types" +import { + ContainerRegistrationKeys, + ModulesSdkUtils, + isObject, +} from "@medusajs/utils" +import { MODULE_PACKAGE_NAMES, Modules } from "./definitions" +import { MedusaModule } from "./medusa-module" +import { RemoteLink } from "./remote-link" +import { RemoteQuery } from "./remote-query" + +export type MedusaModuleConfig = (Partial | Modules)[] +export type SharedResources = { + database?: ModuleServiceInitializeOptions["database"] & { + pool?: { + name?: string + afterCreate?: Function + min?: number + max?: number + refreshIdle?: boolean + idleTimeoutMillis?: number + reapIntervalMillis?: number + returnToHead?: boolean + priorityRange?: number + log?: (message: string, logLevel: string) => void + } + } +} + +export async function MedusaApp({ + sharedResourcesConfig, + modulesConfigPath, + modulesConfig, + linkModules, + remoteFetchData, +}: { + sharedResourcesConfig?: SharedResources + loadedModules?: LoadedModule[] + modulesConfigPath?: string + modulesConfig?: MedusaModuleConfig + linkModules?: ModuleJoinerConfig | ModuleJoinerConfig[] + remoteFetchData?: RemoteFetchDataCallback +} = {}): Promise<{ + modules: Record + link: RemoteLink | undefined + query: ( + query: string | RemoteJoinerQuery, + variables?: Record + ) => Promise +}> { + const modules: MedusaModuleConfig = + modulesConfig ?? + (await import(process.cwd() + (modulesConfigPath ?? "/modules-config"))) + .default + + const injectedDependencies: any = {} + + const dbData = ModulesSdkUtils.loadDatabaseConfig( + "medusa", + sharedResourcesConfig as ModuleServiceInitializeOptions, + true + )! + const { pool } = sharedResourcesConfig?.database ?? {} + + if (dbData?.clientUrl) { + const { knex } = await import("knex") + const dbConnection = knex({ + client: "pg", + searchPath: dbData.schema || "public", + connection: { + connectionString: dbData.clientUrl, + ssl: (dbData.driverOptions?.connection as any).ssl! ?? false, + }, + pool: { + // https://knexjs.org/guide/#pool + ...(pool ?? {}), + min: pool?.min ?? 0, + }, + }) + + injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] = dbConnection + } + + const allModules: Record = {} + + await Promise.all( + modules.map(async (mod: Partial | Modules) => { + let key: Modules | string = mod as Modules + let path: string + let declaration: any = {} + + if (isObject(mod)) { + if (!mod.module) { + throw new Error( + `Module ${JSON.stringify(mod)} is missing module name.` + ) + } + + key = mod.module + path = mod.path ?? MODULE_PACKAGE_NAMES[key] + + declaration = { ...mod } + delete declaration.definition + } else { + path = MODULE_PACKAGE_NAMES[mod as Modules] + } + + if (!path) { + throw new Error(`Module ${key} is missing path.`) + } + + declaration.scope ??= MODULE_SCOPE.INTERNAL + + if ( + declaration.scope === MODULE_SCOPE.INTERNAL && + !declaration.resources + ) { + declaration.resources = MODULE_RESOURCE_TYPE.SHARED + } + + const loaded = (await MedusaModule.bootstrap( + key, + path, + declaration, + undefined, + injectedDependencies, + isObject(mod) ? mod.definition : undefined + )) as LoadedModule + + if (allModules[key] && !Array.isArray(allModules[key])) { + allModules[key] = [] + } + + if (allModules[key]) { + ;(allModules[key] as LoadedModule[]).push(loaded[key]) + } else { + allModules[key] = loaded[key] + } + + return loaded + }) + ) + + let link: RemoteLink | undefined = undefined + let query: ( + query: string | RemoteJoinerQuery, + variables?: Record + ) => Promise + + try { + const { initialize: initializeLinks } = await import( + "@medusajs/link-modules" as string + ) + await initializeLinks({}, linkModules, injectedDependencies) + + link = new RemoteLink() + } catch (err) { + console.warn("Error initializing link modules.", err) + } + + const remoteQuery = new RemoteQuery(undefined, remoteFetchData) + query = async ( + query: string | RemoteJoinerQuery, + variables?: Record + ) => { + return await remoteQuery.query(query, variables) + } + + return { + modules: allModules, + link, + query, + } +} diff --git a/packages/modules-sdk/src/medusa-module.ts b/packages/modules-sdk/src/medusa-module.ts index 8d87599a02..72f4865aa8 100644 --- a/packages/modules-sdk/src/medusa-module.ts +++ b/packages/modules-sdk/src/medusa-module.ts @@ -1,10 +1,13 @@ import { ExternalModuleDeclaration, InternalModuleDeclaration, + LinkModuleDefinition, LoadedModule, MODULE_RESOURCE_TYPE, MODULE_SCOPE, + ModuleDefinition, ModuleExports, + ModuleJoinerConfig, ModuleResolution, } from "@medusajs/types" import { @@ -12,7 +15,11 @@ import { simpleHash, stringifyCircular, } from "@medusajs/utils" -import { moduleLoader, registerMedusaModule } from "./loaders" +import { + moduleLoader, + registerMedusaLinkModule, + registerMedusaModule, +} from "./loaders" import { asValue } from "awilix" import { loadModuleMigrations } from "./loaders/utils" @@ -36,6 +43,7 @@ declare global { type ModuleAlias = { key: string hash: string + isLink: boolean alias?: string main?: boolean } @@ -125,7 +133,8 @@ export class MedusaModule { defaultPath: string, declaration?: InternalModuleDeclaration | ExternalModuleDeclaration, moduleExports?: ModuleExports, - injectedDependencies?: Record + injectedDependencies?: Record, + moduleDefinition?: ModuleDefinition ): Promise<{ [key: string]: T }> { @@ -157,10 +166,10 @@ export class MedusaModule { if (declaration?.scope !== MODULE_SCOPE.EXTERNAL) { modDeclaration = { - scope: MODULE_SCOPE.INTERNAL, - resources: MODULE_RESOURCE_TYPE.ISOLATED, + scope: declaration?.scope || MODULE_SCOPE.INTERNAL, + resources: declaration?.resources || MODULE_RESOURCE_TYPE.ISOLATED, resolve: defaultPath, - options: declaration, + options: declaration?.options ?? declaration, alias: declaration?.alias, main: declaration?.main, } @@ -177,6 +186,118 @@ export class MedusaModule { const moduleResolutions = registerMedusaModule( moduleKey, modDeclaration!, + moduleExports, + moduleDefinition + ) + + try { + await moduleLoader({ + container, + moduleResolutions, + logger, + }) + } catch (err) { + errorLoading(err) + throw err + } + + const services = {} + + for (const resolution of Object.values( + moduleResolutions + ) as ModuleResolution[]) { + const keyName = resolution.definition.key + const registrationName = resolution.definition.registrationName + + services[keyName] = container.resolve(registrationName) + services[keyName].__definition = resolution.definition + + if (resolution.definition.isQueryable) { + const joinerConfig: ModuleJoinerConfig = await services[ + keyName + ].__joinerConfig() + + services[keyName].__joinerConfig = joinerConfig + } + + MedusaModule.registerModule(keyName, { + key: keyName, + hash: hashKey, + alias: modDeclaration.alias ?? hashKey, + main: !!modDeclaration.main, + isLink: false, + }) + } + + MedusaModule.instances_.set(hashKey, services) + finishLoading(services) + MedusaModule.loading_.delete(hashKey) + + return services + } + + public static async bootstrapLink( + definition: LinkModuleDefinition, + declaration?: InternalModuleDeclaration, + moduleExports?: ModuleExports, + injectedDependencies?: Record + ): Promise<{ + [key: string]: unknown + }> { + const moduleKey = definition.key + const hashKey = simpleHash(stringifyCircular({ moduleKey, declaration })) + + if (MedusaModule.instances_.has(hashKey)) { + return MedusaModule.instances_.get(hashKey) + } + + if (MedusaModule.loading_.has(hashKey)) { + return MedusaModule.loading_.get(hashKey) + } + + let finishLoading: any + let errorLoading: any + MedusaModule.loading_.set( + hashKey, + new Promise((resolve, reject) => { + finishLoading = resolve + errorLoading = reject + }) + ) + + let modDeclaration = + declaration ?? ({} as Partial) + + const moduleDefinition: ModuleDefinition = { + key: definition.key, + registrationName: definition.key, + dependencies: definition.dependencies, + defaultPackage: "", + label: definition.label, + canOverride: true, + isRequired: false, + isQueryable: true, + defaultModuleDeclaration: definition.defaultModuleDeclaration, + } + + modDeclaration = { + resolve: "", + options: declaration, + alias: declaration?.alias, + main: declaration?.main, + } + + const container = createMedusaContainer() + + if (injectedDependencies) { + for (const service in injectedDependencies) { + container.register(service, asValue(injectedDependencies[service])) + } + } + + const moduleResolutions = registerMedusaLinkModule( + moduleDefinition, + modDeclaration as InternalModuleDeclaration, moduleExports ) @@ -203,9 +324,17 @@ export class MedusaModule { services[keyName].__definition = resolution.definition if (resolution.definition.isQueryable) { - services[keyName].__joinerConfig = await services[ + const joinerConfig: ModuleJoinerConfig = await services[ keyName ].__joinerConfig() + + services[keyName].__joinerConfig = joinerConfig + + if (!joinerConfig.isLink) { + throw new Error( + "MedusaModule.bootstrapLink must be used only for Link Modules" + ) + } } MedusaModule.registerModule(keyName, { @@ -213,6 +342,7 @@ export class MedusaModule { hash: hashKey, alias: modDeclaration.alias ?? hashKey, main: !!modDeclaration.main, + isLink: true, }) } diff --git a/packages/modules-sdk/src/remote-link.ts b/packages/modules-sdk/src/remote-link.ts new file mode 100644 index 0000000000..e100439f67 --- /dev/null +++ b/packages/modules-sdk/src/remote-link.ts @@ -0,0 +1,439 @@ +import { + ILinkModule, + LoadedModule, + ModuleJoinerRelationship, +} from "@medusajs/types" + +import { isObject, toPascalCase } from "@medusajs/utils" +import { MedusaModule } from "./medusa-module" + +export type DeleteEntityInput = { + [moduleName: string]: { [linkableKey: string]: string | string[] } +} +export type RestoreEntityInput = DeleteEntityInput + +type LinkDefinition = { + [moduleName: string]: { + [fieldName: string]: string + } +} & { + data?: Record +} + +type RemoteRelationship = ModuleJoinerRelationship & { + isPrimary: boolean + isForeign: boolean +} + +type LoadedLinkModule = LoadedModule & ILinkModule +type DeleteEntities = { [key: string]: string[] } +type RemovedIds = { + [serviceName: string]: DeleteEntities +} +type RestoredIds = RemovedIds + +type CascadeError = { + serviceName: string + method: String + args: any + error: Error +} + +export class RemoteLink { + private modulesMap: Map = new Map() + private relationsPairs: Map = new Map() + private relations: Map> = new Map() + + constructor(modulesLoaded?: LoadedModule[]) { + if (!modulesLoaded?.length) { + modulesLoaded = MedusaModule.getLoadedModules().map( + (mod) => Object.values(mod)[0] + ) + } + + for (const mod of modulesLoaded) { + this.addModule(mod) + } + } + + public addModule(mod: LoadedModule): void { + if (!mod.__definition.isQueryable || mod.__joinerConfig.isReadOnlyLink) { + return + } + + const joinerConfig = mod.__joinerConfig + + const serviceName = joinerConfig.isLink + ? joinerConfig.serviceName! + : mod.__definition.key + + if (this.modulesMap.has(serviceName)) { + throw new Error( + `Duplicated instance of module ${serviceName} is not allowed.` + ) + } + + if (joinerConfig.relationships?.length) { + if (joinerConfig.isLink) { + const [primary, foreign] = joinerConfig.relationships + const key = [ + primary.serviceName, + primary.foreignKey, + foreign.serviceName, + foreign.foreignKey, + ].join("-") + this.relationsPairs.set(key, mod as unknown as LoadedLinkModule) + } + for (const relationship of joinerConfig.relationships) { + if (joinerConfig.isLink && !relationship.deleteCascade) { + continue + } + + this.addRelationship(serviceName, { + ...relationship, + isPrimary: false, + isForeign: true, + }) + } + } + + if (joinerConfig.extends?.length) { + for (const service of joinerConfig.extends) { + const relationship = service.relationship + this.addRelationship(service.serviceName, { + ...relationship, + serviceName: serviceName, + isPrimary: true, + isForeign: false, + }) + } + } + + this.modulesMap.set(serviceName, mod as unknown as LoadedLinkModule) + } + + private addRelationship( + serviceName: string, + relationship: RemoteRelationship + ): void { + const { primaryKey, foreignKey } = relationship + + if (!this.relations.has(serviceName)) { + this.relations.set(serviceName, new Map()) + } + + const key = relationship.isPrimary ? primaryKey : foreignKey + const serviceMap = this.relations.get(serviceName)! + if (!serviceMap.has(key)) { + serviceMap.set(key, []) + } + + serviceMap.get(key)!.push(relationship) + } + + getLinkModule( + moduleA: string, + moduleAKey: string, + moduleB: string, + moduleBKey: string + ) { + const key = [moduleA, moduleAKey, moduleB, moduleBKey].join("-") + return this.relationsPairs.get(key) + } + + getRelationships(): Map> { + return this.relations + } + + private getLinkableKeys(mod: LoadedLinkModule) { + return ( + mod.__joinerConfig.linkableKeys ?? mod.__joinerConfig.primaryKeys ?? [] + ) + } + + private async executeCascade( + removedServices: DeleteEntityInput, + method: "softDelete" | "restore" + ): Promise<[CascadeError[] | null, RemovedIds]> { + const removedIds: RemovedIds = {} + const returnIdsList: RemovedIds = {} + const processedIds: Record> = {} + + const services = Object.keys(removedServices).map((serviceName) => { + const deleteKeys = {} + + for (const field in removedServices[serviceName]) { + deleteKeys[field] = Array.isArray(removedServices[serviceName][field]) + ? removedServices[serviceName][field] + : [removedServices[serviceName][field]] + } + + return { serviceName, deleteKeys } + }) + + const errors: CascadeError[] = [] + const cascade = async ( + services: { serviceName: string; deleteKeys: DeleteEntities }[], + isCascading: boolean = false + ): Promise => { + if (errors.length) { + return returnIdsList + } + + const servicePromises = services.map(async (serviceInfo) => { + const serviceRelations = this.relations.get(serviceInfo.serviceName)! + + if (!serviceRelations) { + return + } + + const values = serviceInfo.deleteKeys + + const deletePromises: Promise[] = [] + + for (const field in values) { + const relatedServices = serviceRelations.get(field) + + if (!relatedServices || !values[field]?.length) { + continue + } + + const relatedServicesPromises = relatedServices.map( + async (relatedService) => { + const { serviceName, primaryKey, args } = relatedService + const processedHash = `${serviceName}-${primaryKey}` + + if (!processedIds[processedHash]) { + processedIds[processedHash] = new Set() + } + + const unprocessedIds = values[field].filter( + (id) => !processedIds[processedHash].has(id) + ) + + if (!unprocessedIds.length) { + return + } + + unprocessedIds.forEach((id) => { + processedIds[processedHash].add(id) + }) + + let cascadeDelKeys: DeleteEntities = {} + cascadeDelKeys[primaryKey] = unprocessedIds + const service: ILinkModule = this.modulesMap.get(serviceName)! + + const returnFields = this.getLinkableKeys( + service as LoadedLinkModule + ) + + let deletedEntities: Record = {} + + try { + if (args?.methodSuffix) { + method += toPascalCase(args.methodSuffix) + } + + const removed = await service[method](cascadeDelKeys, { + returnLinkableKeys: returnFields, + }) + + deletedEntities = removed as Record + } catch (error) { + errors.push({ + serviceName, + method, + args: cascadeDelKeys, + error: JSON.parse( + JSON.stringify(error, Object.getOwnPropertyNames(error)) + ), + }) + return + } + + if (Object.keys(deletedEntities).length === 0) { + return + } + + removedIds[serviceName] = { + ...deletedEntities, + } + + if (!isCascading) { + returnIdsList[serviceName] = { + ...deletedEntities, + } + } else { + const [mainKey] = returnFields + + if (!returnIdsList[serviceName]) { + returnIdsList[serviceName] = {} + } + if (!returnIdsList[serviceName][mainKey]) { + returnIdsList[serviceName][mainKey] = [] + } + + returnIdsList[serviceName][mainKey] = [ + ...new Set( + returnIdsList[serviceName][mainKey].concat( + deletedEntities[mainKey] + ) + ), + ] + } + + Object.keys(deletedEntities).forEach((key) => { + deletedEntities[key].forEach((id) => { + const hash = `${serviceName}-${key}` + if (!processedIds[hash]) { + processedIds[hash] = new Set() + } + + processedIds[hash].add(id) + }) + }) + + await cascade( + [ + { + serviceName: serviceName, + deleteKeys: deletedEntities as DeleteEntities, + }, + ], + true + ) + } + ) + + deletePromises.push(...relatedServicesPromises) + } + + await Promise.all(deletePromises) + }) + + await Promise.all(servicePromises) + return returnIdsList + } + + const result = await cascade(services) + + return [errors.length ? errors : null, result] + } + + async create(link: LinkDefinition | LinkDefinition[]): Promise { + const allLinks = Array.isArray(link) ? link : [link] + const serviceLinks = new Map< + string, + [string | string[], string, Record?][] + >() + + for (const rel of allLinks) { + const extraFields = rel.data + delete rel.data + + const mods = Object.keys(rel) + if (mods.length > 2) { + throw new Error(`Only two modules can be linked.`) + } + + const [moduleA, moduleB] = mods + const pk = Object.keys(rel[moduleA]) + const moduleAKey = pk.join(",") + const moduleBKey = Object.keys(rel[moduleB]).join(",") + + const service = this.getLinkModule( + moduleA, + moduleAKey, + moduleB, + moduleBKey + ) + + if (!service) { + throw new Error( + `Module to link ${moduleA}[${moduleAKey}] and ${moduleB}[${moduleBKey}] was not found.` + ) + } else if (!serviceLinks.has(service.__definition.key)) { + serviceLinks.set(service.__definition.key, []) + } + + const pkValue = + pk.length === 1 ? rel[moduleA][pk[0]] : pk.map((k) => rel[moduleA][k]) + + const fields: unknown[] = [pkValue, rel[moduleB][moduleBKey]] + if (isObject(extraFields)) { + fields.push(extraFields) + } + + serviceLinks.get(service.__definition.key)?.push(fields as any) + } + + const promises: Promise[] = [] + for (const [serviceName, links] of serviceLinks) { + const service = this.modulesMap.get(serviceName)! + promises.push(service.create(links)) + } + + const created = await Promise.all(promises) + return created.flat() + } + + async dismiss(link: LinkDefinition | LinkDefinition[]): Promise { + const allLinks = Array.isArray(link) ? link : [link] + const serviceLinks = new Map() + + for (const rel of allLinks) { + const mods = Object.keys(rel) + if (mods.length > 2) { + throw new Error(`Only two modules can be linked.`) + } + + const [moduleA, moduleB] = mods + const pk = Object.keys(rel[moduleA]) + const moduleAKey = pk.join(",") + const moduleBKey = Object.keys(rel[moduleB]).join(",") + + const service = this.getLinkModule( + moduleA, + moduleAKey, + moduleB, + moduleBKey + ) + + if (!service) { + throw new Error( + `Module to dismiss link ${moduleA}[${moduleAKey}] and ${moduleB}[${moduleBKey}] was not found.` + ) + } else if (!serviceLinks.has(service.__definition.key)) { + serviceLinks.set(service.__definition.key, []) + } + + const pkValue = + pk.length === 1 ? rel[moduleA][pk[0]] : pk.map((k) => rel[moduleA][k]) + + serviceLinks + .get(service.__definition.key) + ?.push([pkValue, rel[moduleB][moduleBKey]]) + } + + const promises: Promise[] = [] + for (const [serviceName, links] of serviceLinks) { + const service = this.modulesMap.get(serviceName)! + promises.push(service.dismiss(links)) + } + + const created = await Promise.all(promises) + return created.flat() + } + + async delete( + removedServices: DeleteEntityInput + ): Promise<[CascadeError[] | null, RemovedIds]> { + return await this.executeCascade(removedServices, "softDelete") + } + + async restore( + removedServices: DeleteEntityInput + ): Promise<[CascadeError[] | null, RestoredIds]> { + return await this.executeCascade(removedServices, "restore") + } +} diff --git a/packages/modules-sdk/src/remote-query.ts b/packages/modules-sdk/src/remote-query.ts index 9dd2d9d48e..43f48879be 100644 --- a/packages/modules-sdk/src/remote-query.ts +++ b/packages/modules-sdk/src/remote-query.ts @@ -2,28 +2,23 @@ import { JoinerRelationship, JoinerServiceConfig, LoadedModule, + ModuleJoinerConfig, RemoteExpandProperty, + RemoteJoinerQuery, } from "@medusajs/types" +import { RemoteFetchDataCallback, RemoteJoiner } from "@medusajs/orchestration" +import { isString, toPascalCase } from "@medusajs/utils" import { MedusaModule } from "./medusa-module" -import { RemoteJoiner } from "@medusajs/orchestration" -import { toPascalCase } from "@medusajs/utils" export class RemoteQuery { private remoteJoiner: RemoteJoiner private modulesMap: Map = new Map() + private customRemoteFetchData?: RemoteFetchDataCallback constructor( modulesLoaded?: LoadedModule[], - remoteFetchData?: ( - expand: RemoteExpandProperty, - keyField: string, - ids?: (unknown | unknown[])[], - relationship?: JoinerRelationship - ) => Promise<{ - data: unknown[] | { [path: string]: unknown[] } - path?: string - }> + customRemoteFetchData?: RemoteFetchDataCallback ) { if (!modulesLoaded?.length) { modulesLoaded = MedusaModule.getLoadedModules().map( @@ -31,25 +26,28 @@ export class RemoteQuery { ) } - const servicesConfig: JoinerServiceConfig[] = [] + const servicesConfig: ModuleJoinerConfig[] = [] for (const mod of modulesLoaded) { if (!mod.__definition.isQueryable) { continue } - if (this.modulesMap.has(mod.__definition.key)) { + const serviceName = mod.__definition.key + + if (this.modulesMap.has(serviceName)) { throw new Error( - `Duplicated instance of module ${mod.__definition.key} is not allowed.` + `Duplicated instance of module ${serviceName} is not allowed.` ) } - this.modulesMap.set(mod.__definition.key, mod) + this.modulesMap.set(serviceName, mod) servicesConfig.push(mod.__joinerConfig) } + this.customRemoteFetchData = customRemoteFetchData this.remoteJoiner = new RemoteJoiner( - servicesConfig, - remoteFetchData ?? this.remoteFetchData.bind(this) + servicesConfig as JoinerServiceConfig[], + this.remoteFetchData.bind(this) ) } @@ -69,14 +67,20 @@ export class RemoteQuery { private static getAllFieldsAndRelations( data: any, - prefix = "" - ): { select: string[]; relations: string[] } { + prefix = "", + args: Record = {} + ): { + select: string[] + relations: string[] + args: Record + } { let fields: Set = new Set() let relations: string[] = [] data.fields?.forEach((field: string) => { fields.add(prefix ? `${prefix}.${field}` : field) }) + args[prefix] = data.args if (data.expands) { for (const property in data.expands) { @@ -87,7 +91,8 @@ export class RemoteQuery { const result = RemoteQuery.getAllFieldsAndRelations( data.expands[property], - newPrefix + newPrefix, + args ) result.select.forEach(fields.add, fields) @@ -95,7 +100,7 @@ export class RemoteQuery { } } - return { select: [...fields], relations } + return { select: [...fields], relations, args } } private hasPagination(options: { [attr: string]: unknown }): boolean { @@ -126,6 +131,13 @@ export class RemoteQuery { data: unknown[] | { [path: string]: unknown } path?: string }> { + if (this.customRemoteFetchData) { + const resp = await this.customRemoteFetchData(expand, keyField, ids) + if (resp !== undefined) { + return resp + } + } + const serviceConfig = expand.serviceConfig const service = this.modulesMap.get(serviceConfig.serviceName)! @@ -196,9 +208,14 @@ export class RemoteQuery { } } - public async query(query: string, variables: any = {}): Promise { - return await this.remoteJoiner.query( - RemoteJoiner.parseQuery(query, variables) - ) + public async query( + query: string | RemoteJoinerQuery, + variables?: Record + ): Promise { + const finalQuery = isString(query) + ? RemoteJoiner.parseQuery(query, variables) + : query + + return await this.remoteJoiner.query(finalQuery) } } diff --git a/packages/orchestration/jest.config.js b/packages/orchestration/jest.config.js index 7de5bf104a..2fd636dce6 100644 --- a/packages/orchestration/jest.config.js +++ b/packages/orchestration/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/orchestration/package.json b/packages/orchestration/package.json index c98f023aa7..a566f161f8 100644 --- a/packages/orchestration/package.json +++ b/packages/orchestration/package.json @@ -19,10 +19,10 @@ "devDependencies": { "@medusajs/types": "^1.10.2", "cross-env": "^5.2.1", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "dependencies": { "@medusajs/utils": "^1.9.6", diff --git a/packages/orchestration/src/__tests__/joiner/graphql-ast.ts b/packages/orchestration/src/__tests__/joiner/graphql-ast.ts index c096ee3082..078d30229f 100644 --- a/packages/orchestration/src/__tests__/joiner/graphql-ast.ts +++ b/packages/orchestration/src/__tests__/joiner/graphql-ast.ts @@ -274,4 +274,85 @@ describe("RemoteJoiner.parseQuery", () => { ], }) }) + + it("Nested query with fields and directives", async () => { + const graphqlQuery = ` + query { + order(regularArgs: 123) { + id + number @include(if: "date > '2020-01-01'") + date + products { + product_id + variant_id + variant @count { + name @lowerCase + sku @include(if: "name == 'test'") + } + } + } + } + ` + const parser = new GraphQLParser(graphqlQuery) + const rjQuery = parser.parseQuery() + + expect(rjQuery).toEqual({ + alias: "order", + fields: ["id", "number", "date", "products"], + expands: [ + { + property: "products", + fields: ["product_id", "variant_id", "variant"], + directives: { + variant: [ + { + name: "count", + }, + ], + }, + }, + { + property: "products.variant", + fields: ["name", "sku"], + directives: { + name: [ + { + name: "lowerCase", + }, + ], + sku: [ + { + name: "include", + args: [ + { + name: "if", + value: "name == 'test'", + }, + ], + }, + ], + }, + }, + ], + args: [ + { + name: "regularArgs", + value: 123, + }, + ], + directives: { + number: [ + { + name: "include", + args: [ + { + name: "if", + value: "date > '2020-01-01'", + }, + ], + }, + ], + }, + }) + }) }) diff --git a/packages/orchestration/src/joiner/graphql-ast.ts b/packages/orchestration/src/joiner/graphql-ast.ts index aa3e1d0983..e172c54da0 100644 --- a/packages/orchestration/src/joiner/graphql-ast.ts +++ b/packages/orchestration/src/joiner/graphql-ast.ts @@ -1,6 +1,7 @@ import { RemoteJoinerQuery } from "@medusajs/types" import { ArgumentNode, + DirectiveNode, DocumentNode, FieldNode, Kind, @@ -15,18 +16,24 @@ interface Argument { value?: unknown } +interface Directive { + name: string + args?: Argument[] +} + interface Entity { property: string fields: string[] args?: Argument[] + directives?: { [field: string]: Directive[] } } class GraphQLParser { private ast: DocumentNode - constructor(input: string, private variables?: { [key: string]: unknown }) { + constructor(input: string, private variables: Record = {}) { this.ast = parse(input) - this.variables = variables || {} + this.variables = variables } private parseValueNode(valueNode: ValueNode): unknown { @@ -75,6 +82,33 @@ class GraphQLParser { }) } + private parseDirectives(directives: readonly DirectiveNode[]): Directive[] { + return directives.map((directive) => ({ + name: directive.name.value, + args: this.parseArguments(directive.arguments || []), + })) + } + + private createDirectivesMap(selectionSet: SelectionSetNode): + | { + [field: string]: Directive[] + } + | undefined { + const directivesMap: { [field: string]: Directive[] } = {} + let hasDirectives = false + selectionSet.selections.forEach((field) => { + const fieldName = (field as FieldNode).name.value + const fieldDirectives = this.parseDirectives( + (field as FieldNode).directives || [] + ) + if (fieldDirectives.length > 0) { + hasDirectives = true + directivesMap[fieldName] = fieldDirectives + } + }) + return hasDirectives ? directivesMap : undefined + } + private extractEntities( node: SelectionSetNode, parentName = "", @@ -98,7 +132,8 @@ class GraphQLParser { fields: fieldNode.selectionSet.selections.map( (field) => (field as FieldNode).name.value ), - args: this.parseArguments(fieldNode.arguments!), + args: this.parseArguments(fieldNode.arguments || []), + directives: this.createDirectivesMap(fieldNode.selectionSet), } entities.push(nestedEntity) @@ -126,8 +161,8 @@ class GraphQLParser { const rootFieldNode = queryDefinition.selectionSet .selections[0] as FieldNode - const propName = rootFieldNode.name.value + const remoteJoinConfig: RemoteJoinerQuery = { alias: propName, fields: [], @@ -142,7 +177,9 @@ class GraphQLParser { remoteJoinConfig.fields = rootFieldNode.selectionSet.selections.map( (field) => (field as FieldNode).name.value ) - + remoteJoinConfig.directives = this.createDirectivesMap( + rootFieldNode.selectionSet + ) remoteJoinConfig.expands = this.extractEntities( rootFieldNode.selectionSet, propName, diff --git a/packages/orchestration/src/joiner/remote-joiner.ts b/packages/orchestration/src/joiner/remote-joiner.ts index 978908f575..42dca68421 100644 --- a/packages/orchestration/src/joiner/remote-joiner.ts +++ b/packages/orchestration/src/joiner/remote-joiner.ts @@ -2,14 +2,27 @@ import { JoinerRelationship, JoinerServiceConfig, JoinerServiceConfigAlias, + ModuleJoinerConfig, RemoteExpandProperty, RemoteJoinerQuery, RemoteNestedExpands, } from "@medusajs/types" + import { isDefined } from "@medusajs/utils" import GraphQLParser from "./graphql-ast" const BASE_PATH = "_root" + +export type RemoteFetchDataCallback = ( + expand: RemoteExpandProperty, + keyField: string, + ids?: (unknown | unknown[])[], + relationship?: any +) => Promise<{ + data: unknown[] | { [path: string]: unknown } + path?: string +}> + export class RemoteJoiner { private serviceConfigCache: Map = new Map() @@ -18,7 +31,7 @@ export class RemoteJoiner { fields: string[], expands?: RemoteNestedExpands ): Record { - if (!fields) { + if (!fields || !data) { return data } @@ -78,44 +91,29 @@ export class RemoteJoiner { }, {}) } - static parseQuery(graphqlQuery: string, variables?: any): RemoteJoinerQuery { + static parseQuery( + graphqlQuery: string, + variables?: Record + ): RemoteJoinerQuery { const parser = new GraphQLParser(graphqlQuery, variables) return parser.parseQuery() } constructor( - private serviceConfigs: JoinerServiceConfig[], - private remoteFetchData: ( - expand: RemoteExpandProperty, - keyField: string, - ids?: (unknown | unknown[])[], - relationship?: any - ) => Promise<{ - data: unknown[] | { [path: string]: unknown } - path?: string - }> + private serviceConfigs: ModuleJoinerConfig[], + private remoteFetchData: RemoteFetchDataCallback ) { this.serviceConfigs = this.buildReferences(serviceConfigs) } - public setFetchDataCallback( - remoteFetchData: ( - expand: RemoteExpandProperty, - keyField: string, - ids?: (unknown | unknown[])[], - relationship?: any - ) => Promise<{ - data: unknown[] | { [path: string]: unknown } - path?: string - }> - ): void { + public setFetchDataCallback(remoteFetchData: RemoteFetchDataCallback): void { this.remoteFetchData = remoteFetchData } - private buildReferences(serviceConfigs: JoinerServiceConfig[]) { + private buildReferences(serviceConfigs: ModuleJoinerConfig[]) { const expandedRelationships: Map = new Map() for (const service of serviceConfigs) { - if (this.serviceConfigCache.has(service.serviceName)) { + if (this.serviceConfigCache.has(service.serviceName!)) { throw new Error(`Service "${service.serviceName}" is already defined.`) } @@ -124,38 +122,42 @@ export class RemoteJoiner { } // add aliases - if (!service.alias) { - service.alias = [{ name: service.serviceName.toLowerCase() }] - } else if (!Array.isArray(service.alias)) { - service.alias = [service.alias] - } - - // self-reference - for (const alias of service.alias) { - if (this.serviceConfigCache.has(`alias_${alias.name}}`)) { - const defined = this.serviceConfigCache.get(`alias_${alias.name}}`) - throw new Error( - `Cannot add alias "${alias.name}" for "${service.serviceName}". It is already defined for Service "${defined?.serviceName}".` - ) + const isReadOnlyDefinition = + service.serviceName === undefined || service.isReadOnlyLink + if (!isReadOnlyDefinition) { + if (!service.alias) { + service.alias = [{ name: service.serviceName!.toLowerCase() }] + } else if (!Array.isArray(service.alias)) { + service.alias = [service.alias] } - const args = - service.args || alias.args - ? { ...service.args, ...alias.args } - : undefined + // self-reference + for (const alias of service.alias) { + if (this.serviceConfigCache.has(`alias_${alias.name}}`)) { + const defined = this.serviceConfigCache.get(`alias_${alias.name}}`) + throw new Error( + `Cannot add alias "${alias.name}" for "${service.serviceName}". It is already defined for Service "${defined?.serviceName}".` + ) + } - service.relationships?.push({ - alias: alias.name, - foreignKey: alias.name + "_id", - primaryKey: "id", - serviceName: service.serviceName, - args, - }) - this.cacheServiceConfig(serviceConfigs, undefined, alias.name) + const args = + service.args || alias.args + ? { ...service.args, ...alias.args } + : undefined + + service.relationships?.push({ + alias: alias.name, + foreignKey: alias.name + "_id", + primaryKey: "id", + serviceName: service.serviceName!, + args, + }) + this.cacheServiceConfig(serviceConfigs, undefined, alias.name) + } + + this.cacheServiceConfig(serviceConfigs, service.serviceName) } - this.cacheServiceConfig(serviceConfigs, service.serviceName) - if (!service.extends) { continue } @@ -295,7 +297,7 @@ export class RemoteJoiner { Map, string, Set - ][] = [[items, query, parsedExpands, "", new Set()]] + ][] = [[items, query, parsedExpands, BASE_PATH, new Set()]] while (stack.length > 0) { const [ @@ -307,9 +309,7 @@ export class RemoteJoiner { ] = stack.pop()! for (const [expandedPath, expand] of currentParsedExpands.entries()) { - const isImmediateChildPath = - expandedPath.startsWith(basePath) && - expandedPath.split(".").length === basePath.split(".").length + 1 + const isImmediateChildPath = basePath === expand.parent if (!isImmediateChildPath || resolvedPaths.has(expandedPath)) { continue @@ -433,7 +433,8 @@ export class RemoteJoiner { item[relationship.alias] = item[field] .map((id) => { if (relationship.isList && !Array.isArray(relatedDataMap[id])) { - relatedDataMap[id] = [relatedDataMap[id]] + relatedDataMap[id] = + relatedDataMap[id] !== undefined ? [relatedDataMap[id]] : [] } return relatedDataMap[id] @@ -441,7 +442,10 @@ export class RemoteJoiner { .filter((relatedItem) => relatedItem !== undefined) } else { if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) { - relatedDataMap[itemKey] = [relatedDataMap[itemKey]] + relatedDataMap[itemKey] = + relatedDataMap[itemKey] !== undefined + ? [relatedDataMap[itemKey]] + : [] } item[relationship.alias] = relatedDataMap[itemKey] @@ -539,13 +543,13 @@ export class RemoteJoiner { serviceConfig: currentServiceConfig, fields, args, + parent: [BASE_PATH, ...currentPath].join("."), }) } currentPath.push(prop) } } - return parsedExpands } @@ -566,7 +570,7 @@ export class RemoteJoiner { for (const [path, expand] of sortedParsedExpands.entries()) { const currentServiceName = expand.serviceConfig.serviceName - let parentPath = path.split(".").slice(0, -1).join(".") + let parentPath = expand.parent // Check if the parentPath was merged before while (mergedPaths.has(parentPath)) { @@ -580,6 +584,7 @@ export class RemoteJoiner { if (parentExpand.serviceConfig.serviceName === currentServiceName) { const nestedKeys = path.split(".").slice(parentPath.split(".").length) + let targetExpand: any = parentExpand for (let key of nestedKeys) { @@ -633,6 +638,7 @@ export class RemoteJoiner { const parsedExpands = this.parseExpands( { property: "", + parent: "", serviceConfig: serviceConfig, fields: queryObj.fields, args: otherArgs, diff --git a/packages/pricing/integration-tests/setup-env.js b/packages/pricing/integration-tests/setup-env.js index d3f7bdc412..d7aa972c88 100644 --- a/packages/pricing/integration-tests/setup-env.js +++ b/packages/pricing/integration-tests/setup-env.js @@ -1,6 +1,6 @@ if (typeof process.env.DB_TEMP_NAME === "undefined") { const tempName = parseInt(process.env.JEST_WORKER_ID || "1") - process.env.DB_TEMP_NAME = `medusa-integration-${tempName}` + process.env.DB_TEMP_NAME = `medusa-pricing-integration-${tempName}` } process.env.MEDUSA_PRICING_DB_SCHEMA = "public" diff --git a/packages/product/integration-tests/__tests__/services/product/index.ts b/packages/product/integration-tests/__tests__/services/product/index.ts index 7f989ee2d4..228a8731da 100644 --- a/packages/product/integration-tests/__tests__/services/product/index.ts +++ b/packages/product/integration-tests/__tests__/services/product/index.ts @@ -13,12 +13,12 @@ import { } from "../../../__fixtures__/product/data" import { ProductDTO, ProductTypes } from "@medusajs/types" +import { kebabCase } from "@medusajs/utils" +import { SqlEntityManager } from "@mikro-orm/postgresql" import { ProductRepository } from "@repositories" import { ProductService } from "@services" -import { SqlEntityManager } from "@mikro-orm/postgresql" -import { TestDatabase } from "../../../utils" import { createProductCategories } from "../../../__fixtures__/product-category" -import { kebabCase } from "@medusajs/utils" +import { TestDatabase } from "../../../utils" jest.setTimeout(30000) @@ -513,7 +513,7 @@ describe("Product Service", () => { const products = await service.create([data]) const product = products[0] await service.softDelete([product.id]) - const restoreProducts = await service.restore([product.id]) + const [restoreProducts] = await service.restore([product.id]) expect(restoreProducts).toHaveLength(1) expect(restoreProducts[0].deleted_at).toBeNull() diff --git a/packages/product/jest.config.js b/packages/product/jest.config.js index dab6097238..860ba90a49 100644 --- a/packages/product/jest.config.js +++ b/packages/product/jest.config.js @@ -4,14 +4,14 @@ module.exports = { "^@services": "/src/services", "^@repositories": "/src/repositories", }, - globals: { - "ts-jest": { - tsConfig: "tsconfig.spec.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.spec.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/product/package.json b/packages/product/package.json index 1d7e352e3d..407772d6d8 100644 --- a/packages/product/package.json +++ b/packages/product/package.json @@ -39,14 +39,14 @@ "@mikro-orm/cli": "5.7.12", "cross-env": "^5.2.1", "faker": "^6.6.6", - "jest": "^25.5.4", + "jest": "^29.6.3", "medusa-test-utils": "^1.1.40", "pg-god": "^1.0.12", "rimraf": "^3.0.2", - "ts-jest": "^25.5.1", + "ts-jest": "^29.1.1", "ts-node": "^10.9.1", "tsc-alias": "^1.8.6", - "typescript": "^4.4.4" + "typescript": "^5.1.6" }, "dependencies": { "@medusajs/modules-sdk": "^1.9.2", diff --git a/packages/product/src/initialize/index.ts b/packages/product/src/initialize/index.ts index 20ed47a5ee..c231243009 100644 --- a/packages/product/src/initialize/index.ts +++ b/packages/product/src/initialize/index.ts @@ -1,13 +1,14 @@ import { ExternalModuleDeclaration, InternalModuleDeclaration, - MedusaModule, MODULE_PACKAGE_NAMES, + MedusaModule, Modules, } from "@medusajs/modules-sdk" import { IProductModuleService, ModulesSdkTypes } from "@medusajs/types" -import { moduleDefinition } from "../module-definition" + import { InitializeModuleInjectableDependencies } from "../types" +import { moduleDefinition } from "../module-definition" export const initialize = async ( options?: diff --git a/packages/product/src/joiner-config.ts b/packages/product/src/joiner-config.ts index f4b1bf92a7..3a83ef298b 100644 --- a/packages/product/src/joiner-config.ts +++ b/packages/product/src/joiner-config.ts @@ -1,5 +1,6 @@ import { Modules } from "@medusajs/modules-sdk" -import { JoinerServiceConfig } from "@medusajs/types" +import { ModuleJoinerConfig } from "@medusajs/types" +import { MapToConfig } from "@medusajs/utils" import { Product, ProductCategory, @@ -10,10 +11,9 @@ import { ProductVariant, } from "@models" import ProductImage from "./models/product-image" -import { MapToConfig } from "@medusajs/utils" export enum LinkableKeys { - PRODUCT_ID = "product_id", + PRODUCT_ID = "product_id", // Main service ID must the first PRODUCT_HANDLE = "product_handle", VARIANT_ID = "variant_id", VARIANT_SKU = "variant_sku", @@ -55,9 +55,10 @@ export const entityNameToLinkableKeysMap: MapToConfig = { ], } -export const joinerConfig: JoinerServiceConfig = { +export const joinerConfig: ModuleJoinerConfig = { serviceName: Modules.PRODUCT, primaryKeys: ["id", "handle"], + linkableKeys: Object.values(LinkableKeys), alias: [ { name: "product", diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index 116f0c6f0d..93b3521129 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -1,12 +1,15 @@ import { - ProductCategoryService, - ProductCollectionService, - ProductOptionService, - ProductService, - ProductTagService, - ProductTypeService, - ProductVariantService, -} from "@services" + Context, + CreateProductOnlyDTO, + DAL, + FindConfig, + IEventBusModuleService, + InternalModuleDeclaration, + ModuleJoinerConfig, + ProductTypes, + RestoreReturn, + SoftDeleteReturn, +} from "@medusajs/types" import { Image, Product, @@ -18,15 +21,14 @@ import { ProductVariant, } from "@models" import { - Context, - CreateProductOnlyDTO, - DAL, - FindConfig, - IEventBusModuleService, - InternalModuleDeclaration, - JoinerServiceConfig, - ProductTypes, -} from "@medusajs/types" + ProductCategoryService, + ProductCollectionService, + ProductOptionService, + ProductService, + ProductTagService, + ProductTypeService, + ProductVariantService, +} from "@services" import ProductImageService from "./product-image" @@ -136,7 +138,7 @@ export default class ProductModuleService< this.eventBusModuleService_ = eventBusModuleService } - __joinerConfig(): JoinerServiceConfig { + __joinerConfig(): ModuleJoinerConfig { return joinerConfig } @@ -1071,11 +1073,7 @@ export default class ProductModuleService< > >( productIds: string[], - { - returnLinkableKeys, - }: { returnLinkableKeys?: TReturnableLinkableKeys[] } = { - returnLinkableKeys: [], - }, + { returnLinkableKeys }: SoftDeleteReturn = {}, sharedContext: Context = {} ): Promise, string[]> | void> { const [products, cascadedEntitiesMap] = await this.softDelete_( @@ -1098,10 +1096,12 @@ export default class ProductModuleService< let mappedCascadedEntitiesMap if (returnLinkableKeys) { + // Map internal table/column names to their respective external linkable keys + // eg: product.id = product_id, variant.id = variant_id mappedCascadedEntitiesMap = mapObjectTo< Record, string[]> >(cascadedEntitiesMap, entityNameToLinkableKeysMap, { - pick: returnLinkableKeys as string[], + pick: returnLinkableKeys, }) } @@ -1116,22 +1116,39 @@ export default class ProductModuleService< return await this.productService_.softDelete(productIds, sharedContext) } - async restore( + async restore< + TReturnableLinkableKeys extends string = Lowercase< + keyof typeof LinkableKeys + > + >( productIds: string[], + { returnLinkableKeys }: RestoreReturn = {}, sharedContext: Context = {} - ): Promise { - const products = await this.restore_(productIds, sharedContext) + ): Promise, string[]> | void> { + const [_, cascadedEntitiesMap] = await this.restore_( + productIds, + sharedContext + ) - return this.baseRepository_.serialize(products, { - populate: true, - }) + let mappedCascadedEntitiesMap + if (returnLinkableKeys) { + // Map internal table/column names to their respective external linkable keys + // eg: product.id = product_id, variant.id = variant_id + mappedCascadedEntitiesMap = mapObjectTo< + Record, string[]> + >(cascadedEntitiesMap, entityNameToLinkableKeysMap, { + pick: returnLinkableKeys, + }) + } + + return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0 } @InjectTransactionManager(shouldForceTransaction, "baseRepository_") async restore_( productIds: string[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise<[TProduct[], Record]> { return await this.productService_.restore(productIds, sharedContext) } } diff --git a/packages/product/src/services/product.ts b/packages/product/src/services/product.ts index e445ec54fb..491d734861 100644 --- a/packages/product/src/services/product.ts +++ b/packages/product/src/services/product.ts @@ -1,4 +1,3 @@ -import { Product } from "@models" import { Context, DAL, @@ -9,12 +8,13 @@ import { import { InjectManager, InjectTransactionManager, - isDefined, MedusaContext, MedusaError, ModulesSdkUtils, ProductUtils, + isDefined, } from "@medusajs/utils" +import { Product } from "@models" import { ProductRepository } from "@repositories" import { ProductServiceTypes } from "../types/services" @@ -178,7 +178,7 @@ export default class ProductService { async restore( productIds: string[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise<[TEntity[], Record]> { return await this.productRepository_.restore(productIds, { transactionManager: sharedContext.transactionManager, }) diff --git a/packages/stock-location/jest.config.js b/packages/stock-location/jest.config.js index 1b626a0af1..701550aa4b 100644 --- a/packages/stock-location/jest.config.js +++ b/packages/stock-location/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.spec.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.spec.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/stock-location/package.json b/packages/stock-location/package.json index a6a6f4c69b..1c0d470c0a 100644 --- a/packages/stock-location/package.json +++ b/packages/stock-location/package.json @@ -19,10 +19,10 @@ "devDependencies": { "@medusajs/types": "^1.8.8", "cross-env": "^5.2.1", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "dependencies": { "@medusajs/modules-sdk": "^1.8.8", diff --git a/packages/stock-location/src/joiner-config.ts b/packages/stock-location/src/joiner-config.ts index b71126429f..a083262757 100644 --- a/packages/stock-location/src/joiner-config.ts +++ b/packages/stock-location/src/joiner-config.ts @@ -1,9 +1,10 @@ +import { ModuleJoinerConfig } from "@medusajs/types" import { Modules } from "@medusajs/modules-sdk" -import { JoinerServiceConfig } from "@medusajs/types" -export const joinerConfig: JoinerServiceConfig = { +export const joinerConfig: ModuleJoinerConfig = { serviceName: Modules.STOCK_LOCATION, primaryKeys: ["id"], + linkableKeys: ["stock_location_id"], alias: [ { name: "stock_location", diff --git a/packages/stock-location/src/services/stock-location.ts b/packages/stock-location/src/services/stock-location.ts index 124aaed2c6..3ad855dfe3 100644 --- a/packages/stock-location/src/services/stock-location.ts +++ b/packages/stock-location/src/services/stock-location.ts @@ -4,8 +4,8 @@ import { FilterableStockLocationProps, FindConfig, IEventBusService, - JoinerServiceConfig, MODULE_RESOURCE_TYPE, + ModuleJoinerConfig, SharedContext, StockLocationAddressInput, UpdateStockLocationInput, @@ -50,7 +50,7 @@ export default class StockLocationService { this.eventBusService_ = eventBusService } - __joinerConfig(): JoinerServiceConfig { + __joinerConfig(): ModuleJoinerConfig { return joinerConfig } diff --git a/packages/types/package.json b/packages/types/package.json index 1bd10964d3..606e908646 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -23,7 +23,7 @@ "ioredis": "^5.2.5", "rimraf": "^5.0.1", "typeorm": "^0.3.16", - "typescript": "^4.4.4", + "typescript": "^5.1.6", "winston": "^3.8.2" }, "scripts": { diff --git a/packages/types/src/dal/repository-service.ts b/packages/types/src/dal/repository-service.ts index af5cc0afa5..f8d3e5f174 100644 --- a/packages/types/src/dal/repository-service.ts +++ b/packages/types/src/dal/repository-service.ts @@ -1,6 +1,6 @@ -import { FindOptions } from "./index" import { RepositoryTransformOptions } from "../common" import { Context } from "../shared-context" +import { FindOptions } from "./index" /** * Data access layer (DAL) interface to implements for any repository service. @@ -54,7 +54,10 @@ export interface RepositoryService extends BaseRepositoryService { context?: Context ): Promise<[T[], Record]> - restore(ids: string[], context?: Context): Promise + restore( + ids: string[], + context?: Context + ): Promise<[T[], Record]> } export interface TreeRepositoryService @@ -75,3 +78,11 @@ export interface TreeRepositoryService delete(id: string, context?: Context): Promise } + +export type SoftDeleteReturn = { + returnLinkableKeys?: TReturnableLinkableKeys[] +} + +export type RestoreReturn = { + returnLinkableKeys?: TReturnableLinkableKeys[] +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6b0e3d558d..bd22231b23 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -9,6 +9,7 @@ export * from "./feature-flag" export * from "./file-service" export * from "./inventory" export * from "./joiner" +export * from "./link-modules" export * from "./logger" export * from "./modules-sdk" export * from "./pricing" diff --git a/packages/types/src/inventory/service.ts b/packages/types/src/inventory/service.ts index d0c5ebe348..b110b2801e 100644 --- a/packages/types/src/inventory/service.ts +++ b/packages/types/src/inventory/service.ts @@ -13,11 +13,11 @@ import { } from "./common" import { FindConfig } from "../common" -import { JoinerServiceConfig } from "../joiner" -import { SharedContext } from ".." +import { ModuleJoinerConfig } from "../modules-sdk" +import { SharedContext } from "../shared-context" export interface IInventoryService { - __joinerConfig(): JoinerServiceConfig + __joinerConfig(): ModuleJoinerConfig listInventoryItems( selector: FilterableInventoryItemProps, config?: FindConfig, diff --git a/packages/types/src/joiner/index.ts b/packages/types/src/joiner/index.ts index 0edd29f3d7..ce505d1f89 100644 --- a/packages/types/src/joiner/index.ts +++ b/packages/types/src/joiner/index.ts @@ -28,7 +28,11 @@ export interface JoinerServiceConfig { export interface JoinerArgument { name: string value?: any - field?: string +} + +export interface JoinerDirective { + name: string + value?: any } export interface RemoteJoinerQuery { @@ -38,10 +42,11 @@ export interface RemoteJoinerQuery { property: string fields: string[] args?: JoinerArgument[] - relationships?: JoinerRelationship[] + directives?: { [field: string]: JoinerDirective[] } }> fields: string[] args?: JoinerArgument[] + directives?: { [field: string]: JoinerDirective[] } } export interface RemoteNestedExpands { @@ -54,6 +59,7 @@ export interface RemoteNestedExpands { export interface RemoteExpandProperty { property: string + parent: string serviceConfig: JoinerServiceConfig fields: string[] args?: JoinerArgument[] diff --git a/packages/types/src/link-modules/index.ts b/packages/types/src/link-modules/index.ts new file mode 100644 index 0000000000..60537f6740 --- /dev/null +++ b/packages/types/src/link-modules/index.ts @@ -0,0 +1,49 @@ +import { FindConfig } from "../common" +import { RestoreReturn, SoftDeleteReturn } from "../dal" +import { ModuleJoinerConfig } from "../modules-sdk" +import { Context } from "../shared-context" + +export interface ILinkModule { + __joinerConfig(): ModuleJoinerConfig + + list( + filters?: Record, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCount( + filters?: Record, + config?: FindConfig, + sharedContext?: Context + ): Promise<[unknown[], number]> + + create( + primaryKeyOrBulkData: + | string + | string[] + | [string | string[], string, Record?][], + foreignKeyData?: string, + sharedContext?: Context + ): Promise + + dismiss( + primaryKeyOrBulkData: string | string[] | [string | string[], string][], + foreignKeyData?: string, + sharedContext?: Context + ): Promise + + delete(data: unknown | unknown[], sharedContext?: Context): Promise + + softDelete( + data: unknown | unknown[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + restore( + data: unknown | unknown[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> +} diff --git a/packages/types/src/modules-sdk/index.ts b/packages/types/src/modules-sdk/index.ts index 422b7141a6..b75cf0e6b4 100644 --- a/packages/types/src/modules-sdk/index.ts +++ b/packages/types/src/modules-sdk/index.ts @@ -1,7 +1,8 @@ -import { JoinerServiceConfig } from "../joiner" -import { Logger } from "../logger" +import { JoinerRelationship, JoinerServiceConfig } from "../joiner" + import { MedusaContainer } from "../common" import { RepositoryService } from "../dal" +import { Logger } from "../logger" export type Constructor = new (...args: any[]) => T export * from "../common/medusa-container" @@ -30,6 +31,9 @@ export type InternalModuleDeclaration = { scope: MODULE_SCOPE.INTERNAL resources: MODULE_RESOURCE_TYPE dependencies?: string[] + /** + * @deprecated The property should not be used. + */ resolve?: string options?: Record alias?: string // If multiple modules are registered with the same key, the alias can be used to differentiate them @@ -43,6 +47,7 @@ export type ExternalModuleDeclaration = { url: string keepAlive: boolean } + options?: Record alias?: string // If multiple modules are registered with the same key, the alias can be used to differentiate them main?: boolean // If the module is the main module for the key when multiple ones are registered } @@ -61,17 +66,38 @@ export type ModuleDefinition = { registrationName: string defaultPackage: string | false label: string + /** + * @deprecated property will be removed in future versions + */ canOverride?: boolean + /** + * @deprecated property will be removed in future versions + */ isRequired?: boolean - isQueryable?: boolean // If the modules should be queryable via Remote Joiner + isQueryable?: boolean // If the module is queryable via Remote Joiner dependencies?: string[] defaultModuleDeclaration: | InternalModuleDeclaration | ExternalModuleDeclaration } +export type LinkModuleDefinition = { + key: string + registrationName: string + label: string + dependencies?: string[] + defaultModuleDeclaration: InternalModuleDeclaration +} + +type ModuleDeclaration = ExternalModuleDeclaration | InternalModuleDeclaration +export type ModuleConfig = ModuleDeclaration & { + module: string + path: string + definition: ModuleDefinition +} + export type LoadedModule = unknown & { - __joinerConfig: JoinerServiceConfig + __joinerConfig: ModuleJoinerConfig __definition: ModuleDefinition } @@ -91,6 +117,60 @@ export type ModulesResponse = { resolution: string | false }[] +export type ModuleJoinerConfig = Omit< + JoinerServiceConfig, + "serviceName" | "primaryKeys" | "relationships" | "extends" +> & { + relationships?: ModuleJoinerRelationship[] + extends?: { + serviceName: string + relationship: ModuleJoinerRelationship + }[] + serviceName?: string + primaryKeys?: string[] + isLink?: boolean // If the module is a link module + linkableKeys?: string[] // Keys that can be used to link to other modules + isReadOnlyLink?: boolean // If true it expands a RemoteQuery property but doesn't create a pivot table + databaseConfig?: { + tableName?: string // Name of the pivot table. If not provided it is auto generated + idPrefix?: string // Prefix for the id column. If not provided it is "link" + extraFields?: Record< + string, + { + type: + | "date" + | "time" + | "datetime" + | "bigint" + | "blob" + | "uint8array" + | "array" + | "enumArray" + | "enum" + | "json" + | "integer" + | "smallint" + | "tinyint" + | "mediumint" + | "float" + | "double" + | "boolean" + | "decimal" + | "string" + | "uuid" + | "text" + defaultValue?: string + nullable?: boolean + options?: Record // Mikro-orm options for the column + } + > + } +} + +export declare type ModuleJoinerRelationship = JoinerRelationship & { + deleteCascade?: boolean // If true, the link joiner will cascade deleting the relationship +} + export type ModuleExports = { service: Constructor loaders?: ModuleLoaderFunction[] @@ -114,6 +194,11 @@ export interface ModuleServiceInitializeOptions { connection?: any clientUrl?: string schema?: string + host?: string + port?: number + user?: string + password?: string + database?: string driverOptions?: Record debug?: boolean } diff --git a/packages/types/src/product/service.ts b/packages/types/src/product/service.ts index df92a281ed..d0d51b448e 100644 --- a/packages/types/src/product/service.ts +++ b/packages/types/src/product/service.ts @@ -27,12 +27,13 @@ import { UpdateProductTypeDTO, } from "./common" -import { Context } from "../shared-context" import { FindConfig } from "../common" -import { JoinerServiceConfig } from "../joiner" +import { RestoreReturn, SoftDeleteReturn } from "../dal" +import { ModuleJoinerConfig } from "../modules-sdk" +import { Context } from "../shared-context" export interface IProductModuleService { - __joinerConfig(): JoinerServiceConfig + __joinerConfig(): ModuleJoinerConfig retrieve( productId: string, @@ -241,9 +242,13 @@ export interface IProductModuleService { softDelete( productIds: string[], - config?: { returnLinkableKeys?: TReturnableLinkableKeys[] }, + config?: SoftDeleteReturn, sharedContext?: Context ): Promise | void> - restore(productIds: string[], sharedContext?: Context): Promise + restore( + productIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> } diff --git a/packages/types/src/stock-location/service.ts b/packages/types/src/stock-location/service.ts index d327e6e0ce..68db9f6227 100644 --- a/packages/types/src/stock-location/service.ts +++ b/packages/types/src/stock-location/service.ts @@ -1,6 +1,3 @@ -import { FindConfig } from "../common/common" -import { JoinerServiceConfig } from "../joiner" -import { SharedContext } from "../shared-context" import { CreateStockLocationInput, FilterableStockLocationProps, @@ -8,8 +5,12 @@ import { UpdateStockLocationInput, } from "./common" +import { FindConfig } from "../common/common" +import { ModuleJoinerConfig } from "../modules-sdk" +import { SharedContext } from "../shared-context" + export interface IStockLocationService { - __joinerConfig(): JoinerServiceConfig + __joinerConfig(): ModuleJoinerConfig list( selector: FilterableStockLocationProps, config?: FindConfig, diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js index 7de5bf104a..2fd636dce6 100644 --- a/packages/utils/jest.config.js +++ b/packages/utils/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/utils/package.json b/packages/utils/package.json index addbdfd686..c93336a68a 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -22,10 +22,10 @@ "@types/express": "^4.17.17", "cross-env": "^5.2.1", "express": "^4.18.2", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "dependencies": { "awilix": "^8.0.1", diff --git a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts index 1ab617ceaf..02fb9306c1 100644 --- a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts +++ b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts @@ -1,6 +1,12 @@ -import { Context, DAL, RepositoryTransformOptions } from "@medusajs/types" +import { + Context, + DAL, + FilterQuery, + RepositoryTransformOptions, +} from "@medusajs/types" +import { isString } from "../../common" import { MedusaContext } from "../../decorators" -import { buildQuery, InjectTransactionManager } from "../../modules-sdk" +import { InjectTransactionManager, buildQuery } from "../../modules-sdk" import { getSoftDeletedCascadedEntitiesIdsMappedBy, transactionWrapper, @@ -68,11 +74,21 @@ export abstract class MikroOrmAbstractBaseRepository @InjectTransactionManager() async softDelete( - ids: string[], + idsOrFilter: string[] | FilterQuery, @MedusaContext() { transactionManager: manager }: Context = {} ): Promise<[T[], Record]> { - const entities = await this.find({ where: { id: { $in: ids } } as any }) + const isArray = Array.isArray(idsOrFilter) + const filter = + isArray || isString(idsOrFilter) + ? { + id: { + $in: isArray ? idsOrFilter : [idsOrFilter], + }, + } + : idsOrFilter + + const entities = await this.find({ where: filter as any }) const date = new Date() await mikroOrmUpdateDeletedAtRecursively(manager, entities, date) @@ -86,22 +102,34 @@ export abstract class MikroOrmAbstractBaseRepository @InjectTransactionManager() async restore( - ids: string[], + idsOrFilter: string[] | FilterQuery, @MedusaContext() { transactionManager: manager }: Context = {} - ): Promise { - const query = buildQuery( - { id: { $in: ids } }, - { - withDeleted: true, - } - ) + ): Promise<[T[], Record]> { + const isArray = Array.isArray(idsOrFilter) + const filter = + isArray || isString(idsOrFilter) + ? { + id: { + $in: isArray ? idsOrFilter : [idsOrFilter], + }, + } + : idsOrFilter + + const query = buildQuery(filter, { + withDeleted: true, + }) const entities = await this.find(query) await mikroOrmUpdateDeletedAtRecursively(manager, entities, null) - return entities + const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({ + entities, + restored: true, + }) + + return [entities, softDeletedEntitiesMap] } } diff --git a/packages/utils/src/dal/repository.ts b/packages/utils/src/dal/repository.ts index 3fa990f7d3..062b0dcc77 100644 --- a/packages/utils/src/dal/repository.ts +++ b/packages/utils/src/dal/repository.ts @@ -54,7 +54,10 @@ export abstract class AbstractBaseRepository context?: Context ): Promise<[T[], Record]> - abstract restore(ids: string[], context?: Context): Promise + abstract restore( + ids: string[], + context?: Context + ): Promise<[T[], Record]> abstract getFreshManager(): TManager diff --git a/packages/utils/src/dal/utils.ts b/packages/utils/src/dal/utils.ts index bf1dd9eefb..457a7ab0eb 100644 --- a/packages/utils/src/dal/utils.ts +++ b/packages/utils/src/dal/utils.ts @@ -47,10 +47,12 @@ export function getSoftDeletedCascadedEntitiesIdsMappedBy({ entities, deletedEntitiesMap, getEntityName, + restored, }: { entities: any[] deletedEntitiesMap?: Map getEntityName?: (entity: any) => string + restored?: boolean }): Record { deletedEntitiesMap ??= new Map() getEntityName ??= (entity) => entity.constructor.name @@ -61,7 +63,7 @@ export function getSoftDeletedCascadedEntitiesIdsMappedBy({ .get(entityName) ?.some((e) => e.id === entity.id) - if (!entity.deleted_at || shouldSkip) { + if ((restored ? !!entity.deleted_at : !entity.deleted_at) || shouldSkip) { continue } diff --git a/packages/utils/src/modules-sdk/__tests__/load-database-config.spec.ts b/packages/utils/src/modules-sdk/__tests__/load-database-config.spec.ts index 3ae3584426..ae59dff6dc 100644 --- a/packages/utils/src/modules-sdk/__tests__/load-database-config.spec.ts +++ b/packages/utils/src/modules-sdk/__tests__/load-database-config.spec.ts @@ -3,15 +3,19 @@ import { loadDatabaseConfig } from "../load-module-database-config" describe("loadDatabaseConfig", function () { afterEach(() => { delete process.env.POSTGRES_URL + delete process.env.MEDUSA_POSTGRES_URL delete process.env.PRODUCT_POSTGRES_URL }) - it("should return the local configuration using the environment variable", function () { - process.env.POSTGRES_URL = "postgres://localhost:5432/medusa" + it("should return the local configuration using the environment variable respecting their precedence", function () { + process.env.MEDUSA_POSTGRES_URL = "postgres://localhost:5432/medusa" + process.env.PRODUCT_POSTGRES_URL = "postgres://localhost:5432/product" + process.env.POSTGRES_URL = "postgres://localhost:5432/share_db" + let config = loadDatabaseConfig("product") expect(config).toEqual({ - clientUrl: process.env.POSTGRES_URL, + clientUrl: process.env.PRODUCT_POSTGRES_URL, driverOptions: { connection: { ssl: false, @@ -21,12 +25,25 @@ describe("loadDatabaseConfig", function () { schema: "", }) - delete process.env.POSTGRES_URL - process.env.PRODUCT_POSTGRES_URL = "postgres://localhost:5432/medusa" + delete process.env.PRODUCT_POSTGRES_URL config = loadDatabaseConfig("product") expect(config).toEqual({ - clientUrl: process.env.PRODUCT_POSTGRES_URL, + clientUrl: process.env.MEDUSA_POSTGRES_URL, + driverOptions: { + connection: { + ssl: false, + }, + }, + debug: false, + schema: "", + }) + + delete process.env.MEDUSA_POSTGRES_URL + config = loadDatabaseConfig("product") + + expect(config).toEqual({ + clientUrl: process.env.POSTGRES_URL, driverOptions: { connection: { ssl: false, @@ -127,7 +144,7 @@ describe("loadDatabaseConfig", function () { } expect(error.message).toEqual( - "No database clientUrl provided. Please provide the clientUrl through the PRODUCT_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function." + "No database clientUrl provided. Please provide the clientUrl through the [MODULE]_POSTGRES_URL, MEDUSA_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function." ) }) }) diff --git a/packages/utils/src/modules-sdk/load-module-database-config.ts b/packages/utils/src/modules-sdk/load-module-database-config.ts index ee86f3dccd..baed3cd977 100644 --- a/packages/utils/src/modules-sdk/load-module-database-config.ts +++ b/packages/utils/src/modules-sdk/load-module-database-config.ts @@ -1,9 +1,11 @@ -import { MedusaError } from "../common" import { ModulesSdkTypes } from "@medusajs/types" +import { MedusaError } from "../common" function getEnv(key: string, moduleName: string): string { const value = - process.env[`${moduleName.toUpperCase()}_${key}`] ?? process.env[`${key}`] + process.env[`${moduleName.toUpperCase()}_${key}`] ?? + process.env[`MEDUSA_${key}`] ?? + process.env[`${key}`] return value ?? "" } @@ -39,6 +41,16 @@ function getDefaultDriverOptions(clientUrl: string) { : {} } +function getDatabaseUrl( + config: ModulesSdkTypes.ModuleServiceInitializeOptions +): string { + const { clientUrl, host, port, user, password, database } = config.database! + if (clientUrl) { + return clientUrl + } + return `postgres://${user}:${password}@${host}:${port}/${database}` +} + /** * Load the config for the database connection. The options can be retrieved * e.g through PRODUCT_* (e.g PRODUCT_POSTGRES_URL) or * (e.g POSTGRES_URL) environment variables or the options object. @@ -49,11 +61,14 @@ export function loadDatabaseConfig( moduleName: string, options?: ModulesSdkTypes.ModuleServiceInitializeOptions, silent: boolean = false -): ModulesSdkTypes.ModuleServiceInitializeOptions["database"] { +): Pick< + ModulesSdkTypes.ModuleServiceInitializeOptions["database"], + "clientUrl" | "schema" | "driverOptions" | "debug" +> { const clientUrl = getEnv("POSTGRES_URL", moduleName) const database = { - clientUrl: getEnv("POSTGRES_URL", moduleName), + clientUrl, schema: getEnv("POSTGRES_SCHEMA", moduleName) ?? "public", driverOptions: JSON.parse( getEnv("POSTGRES_DRIVER_OPTIONS", moduleName) || @@ -63,7 +78,7 @@ export function loadDatabaseConfig( } if (isModuleServiceInitializeOptions(options)) { - database.clientUrl = options.database!.clientUrl ?? database.clientUrl + database.clientUrl = getDatabaseUrl(options) database.schema = options.database!.schema ?? database.schema database.driverOptions = options.database!.driverOptions ?? @@ -74,7 +89,7 @@ export function loadDatabaseConfig( if (!database.clientUrl && !silent) { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, - "No database clientUrl provided. Please provide the clientUrl through the PRODUCT_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function." + "No database clientUrl provided. Please provide the clientUrl through the [MODULE]_POSTGRES_URL, MEDUSA_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function." ) } diff --git a/packages/utils/src/modules-sdk/loaders/mikro-orm-connection-loader.ts b/packages/utils/src/modules-sdk/loaders/mikro-orm-connection-loader.ts index 734c5e3c90..b11db4dca5 100644 --- a/packages/utils/src/modules-sdk/loaders/mikro-orm-connection-loader.ts +++ b/packages/utils/src/modules-sdk/loaders/mikro-orm-connection-loader.ts @@ -51,7 +51,6 @@ export async function mikroOrmConnectionLoader({ const shouldSwallowError = !!( options as ModulesSdkTypes.ModuleServiceInitializeOptions )?.database?.connection - dbConfig = { ...loadDatabaseConfig( "product", @@ -97,7 +96,7 @@ async function loadShared({ container, entities }) { ) if (!sharedConnection) { throw new Error( - "The module is setup to use a shared resources but no shared connection is present. A new connection will be created" + "The module is setup to use a shared resources but no shared connection is present." ) } diff --git a/packages/workflows/jest.config.js b/packages/workflows/jest.config.js index 7de5bf104a..2fd636dce6 100644 --- a/packages/workflows/jest.config.js +++ b/packages/workflows/jest.config.js @@ -1,12 +1,12 @@ module.exports = { - globals: { - "ts-jest": { - tsConfig: "tsconfig.json", - isolatedModules: false, - }, - }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.json", + isolatedModules: true, + }, + ], }, testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 7950a51f0a..16d8510564 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -20,10 +20,10 @@ "devDependencies": { "@medusajs/types": "^1.10.2", "cross-env": "^5.2.1", - "jest": "^25.5.4", + "jest": "^29.6.3", "rimraf": "^5.0.1", - "ts-jest": "^25.5.1", - "typescript": "^4.4.4" + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" }, "dependencies": { "@medusajs/modules-sdk": "^1.9.2", diff --git a/yarn.lock b/yarn.lock index 5d31fbe098..1b3c0fda7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6196,10 +6196,10 @@ __metadata: "@medusajs/modules-sdk": ^1.8.8 "@medusajs/types": ^1.8.8 cross-env: ^5.2.1 - jest: ^25.5.4 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 languageName: unknown linkType: soft @@ -6212,10 +6212,10 @@ __metadata: awilix: ^8.0.0 cross-env: ^5.2.1 ioredis: ^5.3.1 - jest: ^25.5.4 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 languageName: unknown linkType: soft @@ -6241,10 +6241,10 @@ __metadata: "@medusajs/types": ^1.8.10 "@medusajs/utils": ^1.9.2 cross-env: ^5.2.1 - jest: ^25.5.2 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 ulid: ^2.3.0 languageName: unknown linkType: soft @@ -6260,11 +6260,11 @@ __metadata: bullmq: ^3.5.6 cross-env: ^5.2.1 ioredis: ^5.2.5 - jest: ^25.5.2 + jest: ^29.6.3 medusa-test-utils: ^1.1.40 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 languageName: unknown linkType: soft @@ -6310,11 +6310,32 @@ __metadata: "@medusajs/utils": ^1.9.1 awilix: ^8.0.0 cross-env: ^5.2.1 - jest: ^25.5.4 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 + ts-jest: ^29.1.1 typeorm: ^0.3.16 - typescript: ^4.4.4 + typescript: ^5.1.6 + languageName: unknown + linkType: soft + +"@medusajs/link-modules@workspace:packages/link-modules": + version: 0.0.0-use.local + resolution: "@medusajs/link-modules@workspace:packages/link-modules" + dependencies: + "@medusajs/modules-sdk": ^1.8.8 + "@medusajs/types": ^1.8.11 + "@medusajs/utils": ^1.9.2 + "@mikro-orm/core": 5.7.12 + "@mikro-orm/postgresql": 5.7.12 + awilix: ^8.0.0 + cross-env: ^5.2.1 + jest: ^29.6.3 + pg-god: ^1.0.12 + rimraf: ^5.0.1 + ts-jest: ^29.1.1 + ts-node: ^10.9.1 + tsc-alias: ^1.8.6 + typescript: ^5.1.6 languageName: unknown linkType: soft @@ -6496,11 +6517,11 @@ __metadata: "@medusajs/utils": ^1.9.6 awilix: ^8.0.0 cross-env: ^5.2.1 - jest: ^25.5.4 + jest: ^29.6.3 resolve-cwd: ^3.0.0 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 languageName: unknown linkType: soft @@ -6558,10 +6579,10 @@ __metadata: "@medusajs/utils": ^1.9.6 cross-env: ^5.2.1 graphql: ^16.6.0 - jest: ^25.5.4 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 languageName: unknown linkType: soft @@ -6609,16 +6630,16 @@ __metadata: cross-env: ^5.2.1 dotenv: ^16.1.4 faker: ^6.6.6 - jest: ^25.5.4 + jest: ^29.6.3 knex: 2.4.2 lodash: ^4.17.21 medusa-test-utils: ^1.1.40 pg-god: ^1.0.12 rimraf: ^3.0.2 - ts-jest: ^25.5.1 + ts-jest: ^29.1.1 ts-node: ^10.9.1 tsc-alias: ^1.8.6 - typescript: ^4.4.4 + typescript: ^5.1.6 bin: medusa-product-migrations-down: dist/scripts/bin/run-migration-down.js medusa-product-migrations-up: dist/scripts/bin/run-migration-up.js @@ -6635,11 +6656,11 @@ __metadata: "@medusajs/utils": ^1.9.1 awilix: ^8.0.0 cross-env: ^5.2.1 - jest: ^25.5.4 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 + ts-jest: ^29.1.1 typeorm: ^0.3.16 - typescript: ^4.4.4 + typescript: ^5.1.6 languageName: unknown linkType: soft @@ -6652,7 +6673,7 @@ __metadata: ioredis: ^5.2.5 rimraf: ^5.0.1 typeorm: ^0.3.16 - typescript: ^4.4.4 + typescript: ^5.1.6 winston: ^3.8.2 languageName: unknown linkType: soft @@ -6763,10 +6784,10 @@ __metadata: awilix: ^8.0.1 cross-env: ^5.2.1 express: ^4.18.2 - jest: ^25.5.4 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 ulid: ^2.3.0 languageName: unknown linkType: soft @@ -6781,10 +6802,10 @@ __metadata: "@medusajs/utils": ^1.9.6 awilix: ^8.0.1 cross-env: ^5.2.1 - jest: ^25.5.4 + jest: ^29.6.3 rimraf: ^5.0.1 - ts-jest: ^25.5.1 - typescript: ^4.4.4 + ts-jest: ^29.1.1 + typescript: ^5.1.6 ulid: ^2.3.0 languageName: unknown linkType: soft @@ -29262,7 +29283,7 @@ __metadata: languageName: node linkType: hard -"jest@npm:25.5.4, jest@npm:^25.5.2, jest@npm:^25.5.4": +"jest@npm:25.5.4, jest@npm:^25.5.4": version: 25.5.4 resolution: "jest@npm:25.5.4" dependencies: