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>
This commit is contained in:
Carlos R. L. Rodrigues
2023-08-30 11:31:32 -03:00
committed by GitHub
parent bc4c9e0d32
commit 4d16acf5f0
97 changed files with 3540 additions and 424 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/link-modules": patch
"@medusajs/modules-sdk": patch
"@medusajs/types": patch
---
Add extra fields to link modules

View File

@@ -0,0 +1,9 @@
---
"@medusajs/orchestration": minor
"@medusajs/link-modules": minor
"@medusajs/modules-sdk": minor
"@medusajs/types": minor
"@medusajs/utils": minor
---
Medusa App Loader

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
packages/link-modules/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql

View File

@@ -0,0 +1,13 @@
module.exports = {
transform: {
"^.+\\.[jt]s?$": [
"ts-jest",
{
tsConfig: "tsconfig.json",
isolatedModules: true,
},
],
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
}

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./inventory-level-stock-location"
export * from "./product-variant-inventory-item"

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export * from "./initialize"
export * from "./types"
export * from "./loaders"
export * from "./services"

View File

@@ -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<LoaderOptions<ModuleServiceInitializeOptions>, "container">,
modulesDefinition?: ModuleJoinerConfig[]
) {
const modulesLoadedKeys = MedusaModule.getLoadedModules().map(
(mod) => Object.keys(mod)[0]
)
const allLinksToLoad = Object.values(linkDefinitions).concat(
modulesDefinition ?? []
)
const allLinks = new Set<string>()
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 })
}
}

View File

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

View File

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

View File

@@ -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<void> => {
await ModulesSdkUtils.mikroOrmConnectionLoader({
entities: [entity],
container,
options,
moduleDeclaration,
logger,
})
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
export { getLinkRepository } from "./link"

View File

@@ -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<object> {
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<unknown> = { where: {} },
context: Context = {}
): Promise<unknown[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.find(
this.model_,
findOptions_.where as MikroFilterQuery<unknown>,
findOptions_.options as MikroOptions<any>
)
}
async findAndCount(
findOptions: FindOptions<object> = { where: {} },
context: Context = {}
): Promise<[object[], number]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.findAndCount(
this.model_,
findOptions_.where as MikroFilterQuery<unknown>,
findOptions_.options as MikroOptions<any>
)
}
async delete(data: any, context: Context = {}): Promise<void> {
const filter = {}
for (const key in data) {
filter[key] = {
$in: Array.isArray(data[key]) ? data[key] : [data[key]],
}
}
const manager = this.getActiveManager<SqlEntityManager>(context)
await manager.nativeDelete(this.model_, data, {})
}
async create(data: object[], context: Context = {}): Promise<object[]> {
const manager = this.getActiveManager<SqlEntityManager>(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
}
}
}

View File

@@ -0,0 +1,22 @@
import { Constructor, ILinkModule, ModuleJoinerConfig } from "@medusajs/types"
import { LinkModuleService } from "@services"
export function getModuleService(
joinerConfig: ModuleJoinerConfig
): Constructor<ILinkModule> {
const joinerConfig_ = JSON.parse(JSON.stringify(joinerConfig))
delete joinerConfig_.databaseConfig
return class LinkService extends LinkModuleService<unknown> {
override __joinerConfig(): ModuleJoinerConfig {
return joinerConfig_ as ModuleJoinerConfig
}
}
}
export function getReadOnlyModuleService(joinerConfig: ModuleJoinerConfig) {
return class ReadOnlyLinkService {
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig as ModuleJoinerConfig
}
}
}

View File

@@ -0,0 +1,3 @@
export * from "./dynamic-service-class"
export { default as LinkService } from "./link"
export { default as LinkModuleService } from "./link-module-service"

View File

@@ -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<any>
primaryKey: string | string[]
foreignKey: string
extraFields: string[]
}
export default class LinkModuleService<TLink> implements ILinkModule {
protected baseRepository_: DAL.RepositoryService
protected readonly linkService_: LinkService<TLink>
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<string, unknown> = {}
) {
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<unknown> {
const filter = this.buildData(primaryKeyData, foreignKeyData)
const queryOptions = ModulesSdkUtils.buildQuery<unknown>(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<string, unknown> = {},
config: FindConfig<unknown> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<unknown[]> {
const rows = await this.linkService_.list(filters, config, sharedContext)
return await this.baseRepository_.serialize<object[]>(rows)
}
@InjectManager("baseRepository_")
async listAndCount(
filters: Record<string, unknown> = {},
config: FindConfig<unknown> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[unknown[], number]> {
const [rows, count] = await this.linkService_.listAndCount(
filters,
config,
sharedContext
)
return [await this.baseRepository_.serialize<object[]>(rows), count]
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async create(
primaryKeyOrBulkData:
| string
| string[]
| [string | string[], string, Record<string, unknown>][],
foreignKeyData?: string,
extraFields?: Record<string, unknown>,
@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<string, unknown>
)
)
}
} else {
data.push(
this.buildData(
primaryKeyOrBulkData as string | string[],
foreignKeyData!,
extraFields
)
)
}
const links = await this.linkService_.create(data, sharedContext)
return await this.baseRepository_.serialize<object[]>(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<object[]>(links)
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async delete(
data: any,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
this.validateFields(data)
await this.linkService_.delete(data, sharedContext)
}
async softDelete(
data: any,
{ returnLinkableKeys }: SoftDeleteReturn = {},
sharedContext: Context = {}
): Promise<Record<string, unknown[]> | 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<Record<string, string[]>>(
cascadedEntitiesMap,
entityNameToLinkableKeysMap,
{
pick: returnLinkableKeys,
}
)
}
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
protected async softDelete_(
data: any,
@MedusaContext() sharedContext: Context = {}
): Promise<[string[], Record<string, string[]>]> {
return await this.linkService_.softDelete(data, sharedContext)
}
async restore(
data: any,
{ returnLinkableKeys }: RestoreReturn = {},
sharedContext: Context = {}
): Promise<Record<string, unknown[]> | 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<Record<string, string[]>>(
cascadedEntitiesMap,
entityNameToLinkableKeysMap,
{
pick: returnLinkableKeys,
}
)
}
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async restore_(
data: any,
@MedusaContext() sharedContext: Context = {}
): Promise<[string[], Record<string, string[]>]> {
return await this.linkService_.restore(data, sharedContext)
}
}

View File

@@ -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<TEntity> {
protected readonly linkRepository_: any
constructor({ linkRepository }: InjectedDependencies) {
this.linkRepository_ = linkRepository
}
@InjectManager("linkRepository_")
async list(
filters: unknown = {},
config: FindConfig<unknown> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const queryOptions = ModulesSdkUtils.buildQuery<unknown>(
filters as any,
config
)
return await this.linkRepository_.find(queryOptions, sharedContext)
}
@InjectManager("linkRepository_")
async listAndCount(
filters = {},
config: FindConfig<unknown> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
const queryOptions = ModulesSdkUtils.buildQuery<unknown>(filters, config)
return await this.linkRepository_.findAndCount(queryOptions, sharedContext)
}
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
async create(
data: unknown[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await this.linkRepository_.create(data, {
transactionManager: sharedContext.transactionManager,
})
}
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
async dismiss(
data: unknown[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
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<void> {
await this.linkRepository_.delete(data, {
transactionManager: sharedContext.transactionManager,
})
}
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
async softDelete(
data: any,
@MedusaContext() sharedContext: Context = {}
): Promise<[string[], Record<string, string[]>]> {
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<string, string[]>]> {
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,
})
}
}

View File

@@ -0,0 +1,5 @@
import { Logger } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
logger?: Logger
}

View File

@@ -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("_")
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src", "integration-tests"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,4 +1,4 @@
import { stringifyNullProperties } from "../../src/utils"
const { stringifyNullProperties } = require("../../utils")
describe("stringifyNullProperties", () => {
test("returns empty object on no props", () => {

View File

@@ -1,4 +1,10 @@
{
"extends": "./tsconfig.json",
"include": ["test"],
"include": [
"./src/**/*",
"index.d.ts",
"./src/**/__tests__",
"./src/**/__mocks__"
],
"exclude": ["node_modules"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<InternalModuleDeclaration | ExternalModuleDeclaration>
| string
| false,
moduleExports?: ModuleExports,
definition?: ModuleDefinition
): Record<string, ModuleResolution> => {
const moduleResolutions = {} as Record<string, ModuleResolution>
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<InternalModuleDeclaration>,
moduleExports?: ModuleExports
): Record<string, ModuleResolution> => {
const moduleResolutions = {} as Record<string, ModuleResolution>
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,

View File

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

View File

@@ -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<ModuleConfig> | 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<string, LoadedModule | LoadedModule[]>
link: RemoteLink | undefined
query: (
query: string | RemoteJoinerQuery,
variables?: Record<string, unknown>
) => Promise<any>
}> {
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<string, LoadedModule | LoadedModule[]> = {}
await Promise.all(
modules.map(async (mod: Partial<ModuleConfig> | 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<string, unknown>
) => Promise<any>
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<string, unknown>
) => {
return await remoteQuery.query(query, variables)
}
return {
modules: allModules,
link,
query,
}
}

View File

@@ -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<string, any>
injectedDependencies?: Record<string, any>,
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<string, any>
): 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<InternalModuleDeclaration>)
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,
})
}

View File

@@ -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<string, unknown>
}
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<string, LoadedLinkModule> = new Map()
private relationsPairs: Map<string, LoadedLinkModule> = new Map()
private relations: Map<string, Map<string, RemoteRelationship[]>> = 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<string, Map<string, RemoteRelationship[]>> {
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<string, Set<string>> = {}
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<RemovedIds> => {
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<void>[] = []
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<string, string[]> = {}
try {
if (args?.methodSuffix) {
method += toPascalCase(args.methodSuffix)
}
const removed = await service[method](cascadeDelKeys, {
returnLinkableKeys: returnFields,
})
deletedEntities = removed as Record<string, string[]>
} 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<unknown[]> {
const allLinks = Array.isArray(link) ? link : [link]
const serviceLinks = new Map<
string,
[string | string[], string, Record<string, unknown>?][]
>()
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<unknown[]>[] = []
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<unknown[]> {
const allLinks = Array.isArray(link) ? link : [link]
const serviceLinks = new Map<string, [string | string[], string][]>()
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<unknown[]>[] = []
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")
}
}

View File

@@ -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<string, LoadedModule> = 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<string, unknown[]> = {}
): {
select: string[]
relations: string[]
args: Record<string, unknown[]>
} {
let fields: Set<string> = 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<any> {
return await this.remoteJoiner.query(
RemoteJoiner.parseQuery(query, variables)
)
public async query(
query: string | RemoteJoinerQuery,
variables?: Record<string, unknown>
): Promise<any> {
const finalQuery = isString(query)
? RemoteJoiner.parseQuery(query, variables)
: query
return await this.remoteJoiner.query(finalQuery)
}
}

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> = {}) {
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,

View File

@@ -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<string, JoinerServiceConfig> = new Map()
@@ -18,7 +31,7 @@ export class RemoteJoiner {
fields: string[],
expands?: RemoteNestedExpands
): Record<string, unknown> {
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<string, unknown>
): 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<string, JoinerRelationship[]> = 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, RemoteExpandProperty>,
string,
Set<string>
][] = [[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,

View File

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

View File

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

View File

@@ -4,14 +4,14 @@ module.exports = {
"^@services": "<rootDir>/src/services",
"^@repositories": "<rootDir>/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`],

View File

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

View File

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

View File

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

View File

@@ -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<TReturnableLinkableKeys> = {},
sharedContext: Context = {}
): Promise<Record<Lowercase<keyof typeof LinkableKeys>, 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<Lowercase<keyof typeof LinkableKeys>, 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<TReturnableLinkableKeys> = {},
sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.restore_(productIds, sharedContext)
): Promise<Record<Lowercase<keyof typeof LinkableKeys>, string[]> | void> {
const [_, cascadedEntitiesMap] = await this.restore_(
productIds,
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO[]>(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<Lowercase<keyof typeof LinkableKeys>, string[]>
>(cascadedEntitiesMap, entityNameToLinkableKeysMap, {
pick: returnLinkableKeys,
})
}
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async restore_(
productIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
): Promise<[TProduct[], Record<string, unknown[]>]> {
return await this.productService_.restore(productIds, sharedContext)
}
}

View File

@@ -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<TEntity extends Product = Product> {
async restore(
productIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
): Promise<[TEntity[], Record<string, unknown[]>]> {
return await this.productRepository_.restore(productIds, {
transactionManager: sharedContext.transactionManager,
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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<T = any> extends BaseRepositoryService<T> {
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
restore(ids: string[], context?: Context): Promise<T[]>
restore(
ids: string[],
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
}
export interface TreeRepositoryService<T = any>
@@ -75,3 +78,11 @@ export interface TreeRepositoryService<T = any>
delete(id: string, context?: Context): Promise<void>
}
export type SoftDeleteReturn<TReturnableLinkableKeys = string> = {
returnLinkableKeys?: TReturnableLinkableKeys[]
}
export type RestoreReturn<TReturnableLinkableKeys = string> = {
returnLinkableKeys?: TReturnableLinkableKeys[]
}

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>,
config?: FindConfig<unknown>,
sharedContext?: Context
): Promise<unknown[]>
listAndCount(
filters?: Record<string, unknown>,
config?: FindConfig<unknown>,
sharedContext?: Context
): Promise<[unknown[], number]>
create(
primaryKeyOrBulkData:
| string
| string[]
| [string | string[], string, Record<string, unknown>?][],
foreignKeyData?: string,
sharedContext?: Context
): Promise<unknown[]>
dismiss(
primaryKeyOrBulkData: string | string[] | [string | string[], string][],
foreignKeyData?: string,
sharedContext?: Context
): Promise<unknown[]>
delete(data: unknown | unknown[], sharedContext?: Context): Promise<void>
softDelete(
data: unknown | unknown[],
config?: SoftDeleteReturn,
sharedContext?: Context
): Promise<Record<string, unknown[]> | void>
restore(
data: unknown | unknown[],
config?: RestoreReturn,
sharedContext?: Context
): Promise<Record<string, unknown[]> | void>
}

View File

@@ -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<T> = 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<string, unknown>
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<string, unknown>
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<string, unknown> // 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<any>
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<string, unknown>
debug?: boolean
}

View File

@@ -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<TReturnableLinkableKeys extends string = string>(
productIds: string[],
config?: { returnLinkableKeys?: TReturnableLinkableKeys[] },
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
restore(productIds: string[], sharedContext?: Context): Promise<ProductDTO[]>
restore<TReturnableLinkableKeys extends string = string>(
productIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}

View File

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

View File

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

View File

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

View File

@@ -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<T = any>
@InjectTransactionManager()
async softDelete(
ids: string[],
idsOrFilter: string[] | FilterQuery,
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<[T[], Record<string, unknown[]>]> {
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<T = any>
@InjectTransactionManager()
async restore(
ids: string[],
idsOrFilter: string[] | FilterQuery,
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<T[]> {
const query = buildQuery(
{ id: { $in: ids } },
{
withDeleted: true,
}
)
): Promise<[T[], Record<string, unknown[]>]> {
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]
}
}

View File

@@ -54,7 +54,10 @@ export abstract class AbstractBaseRepository<T = any>
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
abstract restore(ids: string[], context?: Context): Promise<T[]>
abstract restore(
ids: string[],
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
abstract getFreshManager<TManager = unknown>(): TManager

View File

@@ -47,10 +47,12 @@ export function getSoftDeletedCascadedEntitiesIdsMappedBy({
entities,
deletedEntitiesMap,
getEntityName,
restored,
}: {
entities: any[]
deletedEntitiesMap?: Map<string, any[]>
getEntityName?: (entity: any) => string
restored?: boolean
}): Record<string, any[]> {
deletedEntitiesMap ??= new Map<string, any[]>()
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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