feat(index): Index module foundation (#9095)

**What**
Index module foundation

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2024-09-18 21:04:04 +02:00
committed by GitHub
parent 3cfcd075ae
commit 58167b5dfa
53 changed files with 4796 additions and 1201 deletions

View File

@@ -32,6 +32,7 @@ packages/*
!packages/workflow-engine-inmemory
!packages/fulfillment
!packages/fulfillment-manual
!packages/index
!packages/framework

View File

@@ -111,6 +111,7 @@ module.exports = {
"./packages/modules/auth/tsconfig.spec.json",
"./packages/modules/cart/tsconfig.spec.json",
"./packages/modules/currency/tsconfig.spec.json",
"./packages/modules/index/tsconfig.spec.json",
"./packages/modules/customer/tsconfig.spec.json",
"./packages/modules/file/tsconfig.spec.json",
"./packages/modules/inventory-next/tsconfig.spec.json",

View File

@@ -0,0 +1,211 @@
import { IndexTypes } from "@medusajs/types"
import { defaultCurrencies, Modules } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import { setTimeout } from "timers/promises"
import {
adminHeaders,
createAdminUser,
} from "../../../helpers/create-admin-user"
jest.setTimeout(120000)
process.env.ENABLE_INDEX_MODULE = "true"
medusaIntegrationTestRunner({
testSuite: ({ getContainer, dbConnection, api, dbConfig }) => {
let indexEngine: IndexTypes.IIndexService
let appContainer
beforeAll(() => {
appContainer = getContainer()
indexEngine = appContainer.resolve(Modules.INDEX)
})
afterAll(() => {
process.env.ENABLE_INDEX_MODULE = "false"
})
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, appContainer)
})
describe("Index engine", () => {
it("should search through the indexed data and return the correct results ordered and filtered [1]", async () => {
const payload = {
title: "Test Giftcard",
is_giftcard: true,
description: "test-giftcard-description",
options: [{ title: "Denominations", values: ["100"] }],
variants: new Array(10).fill(0).map((_, i) => ({
title: `Test variant ${i}`,
sku: `test-variant-${i}`,
prices: new Array(10).fill(0).map((_, j) => ({
currency_code: Object.values(defaultCurrencies)[j].code,
amount: 10 * j,
})),
options: {
Denominations: "100",
},
})),
}
await api
.post("/admin/products", payload, adminHeaders)
.catch((err) => {
console.log(err)
})
// Timeout to allow indexing to finish
await setTimeout(2000)
const [results, count] = await indexEngine.queryAndCount(
{
select: {
product: {
variants: {
prices: true,
},
},
},
where: {
"product.variants.prices.amount": { $gt: 50 },
},
},
{
orderBy: [{ "product.variants.prices.amount": "DESC" }],
}
)
expect(count).toBe(1)
const variants = results[0].variants
expect(variants.length).toBe(10)
for (const variant of variants) {
expect(variant.prices.length).toBe(4)
for (const price of variant.prices) {
expect(price.amount).toBeGreaterThan(50)
}
}
})
it("should search through the indexed data and return the correct results ordered and filtered [2]", async () => {
const payload = {
title: "Test Giftcard",
is_giftcard: true,
description: "test-giftcard-description",
options: [{ title: "Denominations", values: ["100"] }],
variants: new Array(10).fill(0).map((_, i) => ({
title: `Test variant ${i}`,
sku: `test-variant-${i}`,
prices: new Array(10).fill(0).map((_, j) => ({
currency_code: Object.values(defaultCurrencies)[j].code,
amount: 10 * j,
})),
options: {
Denominations: "100",
},
})),
}
await api
.post("/admin/products", payload, adminHeaders)
.catch((err) => {
console.log(err)
})
// Timeout to allow indexing to finish
await setTimeout(2000)
const [results, count] = await indexEngine.queryAndCount(
{
select: {
product: {
variants: {
prices: true,
},
},
},
where: {
"product.variants.prices.amount": { $gt: 50 },
"product.variants.prices.currency_code": { $eq: "AUD" },
},
},
{
orderBy: [{ "product.variants.prices.amount": "DESC" }],
}
)
expect(count).toBe(1)
const variants = results[0].variants
expect(variants.length).toBe(10)
for (const variant of variants) {
expect(variant.prices.length).toBe(1)
expect(variant.prices[0].amount).toBeGreaterThan(50)
expect(variant.prices[0].currency_code).toBe("AUD")
}
})
it.skip("should search through the indexed data and return the correct results ordered and filtered [3]", async () => {
const payloads = new Array(50).fill(0).map((_, a) => ({
title: "Test Giftcard-" + a,
is_giftcard: true,
description: "test-giftcard-description" + a,
options: [{ title: "Denominations", values: ["100"] }],
variants: new Array(10).fill(0).map((_, i) => ({
title: `Test variant ${i}`,
sku: `test-variant-${i}${a}`,
prices: new Array(10).fill(0).map((_, j) => ({
currency_code: Object.values(defaultCurrencies)[j].code,
amount: 10 * j,
})),
options: {
Denominations: "100",
},
})),
}))
let i = 0
for (const payload of payloads) {
++i
await api.post("/admin/products", payload, adminHeaders).then(() => {
console.log(`Created ${i} products in ${payloads.length} payloads`)
})
}
await setTimeout(5000)
const queryArgs = [
{
select: {
product: {
variants: {
prices: true,
},
},
},
where: {
"product.variants.prices.amount": { $gt: 50 },
"product.variants.prices.currency_code": { $eq: "AUD" },
},
},
{
orderBy: [{ "product.variants.prices.amount": "DESC" }],
},
]
await indexEngine.queryAndCount(...queryArgs)
const [results, count, perf] = await indexEngine.queryAndCount(
...queryArgs
)
console.log(perf)
})
})
},
})

View File

@@ -112,5 +112,10 @@ module.exports = {
],
},
},
[Modules.INDEX]: process.env.ENABLE_INDEX_MODULE
? {
resolve: "@medusajs/index",
}
: false,
},
}

View File

@@ -1,24 +1,25 @@
import { asValue } from "awilix"
import { MedusaAppOutput } from "@medusajs/modules-sdk"
import { ContainerLike, MedusaContainer } from "@medusajs/types"
import {
ContainerRegistrationKeys,
createMedusaContainer,
} from "@medusajs/utils"
import { asValue } from "awilix"
import { createDatabase, dropDatabase } from "pg-god"
import { getDatabaseURL } from "./database"
import { startApp } from "./medusa-test-runner-utils/bootstrap-app"
import { clearInstances } from "./medusa-test-runner-utils/clear-instances"
import { configLoaderOverride } from "./medusa-test-runner-utils/config"
import {
initDb,
migrateDatabase,
syncLinks,
} from "./medusa-test-runner-utils/use-db"
import { configLoaderOverride } from "./medusa-test-runner-utils/config"
import { applyEnvVarsToProcess } from "./medusa-test-runner-utils/utils"
import { clearInstances } from "./medusa-test-runner-utils/clear-instances"
const DB_HOST = process.env.DB_HOST
const DB_USERNAME = process.env.DB_USERNAME
const DB_PASSWORD = process.env.DB_PASSWORD
const DB_USERNAME = process.env.DB_USERNAME ?? ''
const DB_PASSWORD = process.env.DB_PASSWORD ?? ''
const pgGodCredentials = {
user: DB_USERNAME,
@@ -26,11 +27,14 @@ const pgGodCredentials = {
host: DB_HOST,
}
const dbTestUtilFactory = (): any => ({
export const dbTestUtilFactory = (): any => ({
pgConnection_: null,
create: async function (dbName: string) {
await createDatabase({ databaseName: dbName }, pgGodCredentials)
await createDatabase(
{ databaseName: dbName, errorIfExist: false },
pgGodCredentials
)
},
teardown: async function ({ schema }: { schema?: string } = {}) {
@@ -80,11 +84,14 @@ export interface MedusaSuiteOptions<TService = unknown> {
schema: string
clientUrl: string
}
getMedusaApp: () => MedusaAppOutput
}
export function medusaIntegrationTestRunner({
moduleName,
dbName,
medusaConfigFile,
loadApplication,
schema = "public",
env = {},
debug = false,
@@ -94,6 +101,8 @@ export function medusaIntegrationTestRunner({
moduleName?: string
env?: Record<string, any>
dbName?: string
medusaConfigFile?: string
loadApplication?: boolean
schema?: string
debug?: boolean
inApp?: boolean
@@ -110,12 +119,13 @@ export function medusaIntegrationTestRunner({
debug,
}
const cwd = process.cwd()
const cwd = medusaConfigFile ?? process.cwd()
let shutdown = async () => void 0
const dbUtils = dbTestUtilFactory()
let globalContainer: ContainerLike
let apiUtils: any
let loadedApplication: any
let options = {
api: new Proxy(
@@ -134,6 +144,7 @@ export function medusaIntegrationTestRunner({
},
}
),
getMedusaApp: () => loadedApplication,
getContainer: () => globalContainer,
dbConfig: {
dbName,
@@ -175,6 +186,10 @@ export function medusaIntegrationTestRunner({
let serverShutdownRes: () => any
let portRes: number
if (loadApplication) {
loadedApplication = await appLoader.load()
}
try {
const {
shutdown = () => void 0,

View File

@@ -0,0 +1,94 @@
import { mergeTypeDefs } from "@graphql-tools/merge"
import { makeExecutableSchema } from "@graphql-tools/schema"
import { gqlGetFieldsAndRelations } from "../../utils"
const userModule = `
type User {
id: ID!
name: String!
blabla: WHATEVER
}
type Post {
author: User!
}
`
const postModule = `
type Post {
id: ID!
title: String!
date: String
}
type User {
posts: [Post!]!
}
type WHATEVER {
random_field: String
post: Post
}
`
const mergedSchema = mergeTypeDefs([userModule, postModule])
const schema = makeExecutableSchema({
typeDefs: mergedSchema,
})
const types = schema.getTypeMap()
describe("gqlGetFieldsAndRelations", function () {
it("Should get all fields of a given entity", async function () {
const fields = gqlGetFieldsAndRelations(types, "User")
expect(fields).toEqual(expect.arrayContaining(["id", "name"]))
})
it("Should get all fields of a given entity and a relation", async function () {
const fields = gqlGetFieldsAndRelations(types, "User", ["posts"])
expect(fields).toEqual(
expect.arrayContaining([
"id",
"name",
"posts.id",
"posts.title",
"posts.date",
])
)
})
it("Should get all fields of a given entity and many relations", async function () {
const fields = gqlGetFieldsAndRelations(types, "User", [
"posts",
"blabla",
"blabla.post",
])
expect(fields).toEqual(
expect.arrayContaining([
"id",
"name",
"posts.id",
"posts.title",
"posts.date",
"blabla.random_field",
"blabla.post.id",
"blabla.post.title",
"blabla.post.date",
])
)
})
it("Should get all fields of a given entity and many relations limited to the relations given", async function () {
const fields = gqlGetFieldsAndRelations(types, "User", ["posts", "blabla"])
expect(fields).toEqual(
expect.arrayContaining([
"id",
"name",
"posts.id",
"posts.title",
"posts.date",
"blabla.random_field",
])
)
})
})

View File

@@ -1,5 +1,9 @@
import { ModuleDefinition } from "@medusajs/types"
import { Modules, upperCaseFirst } from "@medusajs/utils"
import {
ContainerRegistrationKeys,
Modules,
upperCaseFirst,
} from "@medusajs/utils"
import { MODULE_RESOURCE_TYPE, MODULE_SCOPE } from "./types"
export const MODULE_PACKAGE_NAMES = {
@@ -27,6 +31,7 @@ export const MODULE_PACKAGE_NAMES = {
[Modules.CURRENCY]: "@medusajs/currency",
[Modules.FILE]: "@medusajs/file",
[Modules.NOTIFICATION]: "@medusajs/notification",
[Modules.INDEX]: "@medusajs/index",
}
export const ModulesDefinition: {
@@ -308,6 +313,22 @@ export const ModulesDefinition: {
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.INDEX]: {
key: Modules.INDEX,
defaultPackage: false,
label: upperCaseFirst(Modules.INDEX),
isRequired: false,
isQueryable: false,
dependencies: [
Modules.EVENT_BUS,
"logger",
ContainerRegistrationKeys.REMOTE_QUERY,
],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
}
export const MODULE_DEFINITIONS: ModuleDefinition[] =

View File

@@ -5,4 +5,4 @@ export * from "./medusa-module"
export * from "./remote-link"
export * from "./remote-query"
export * from "./types"
export { gqlSchemaToTypes } from "./utils"
export * from "./utils"

View File

@@ -102,6 +102,11 @@ export async function loadModules(
let declaration: any = {}
let definition: Partial<ModuleDefinition> | undefined = undefined
// Skip disabled modules
if (mod === false) {
return
}
if (isObject(mod)) {
const mod_ = mod as unknown as InternalModuleDeclaration
path = mod_.resolve ?? MODULE_PACKAGE_NAMES[moduleName]

View File

@@ -0,0 +1,43 @@
import { GraphQLNamedType, GraphQLObjectType, isObjectType } from "graphql"
/**
* Generate a list of fields and fields relations for a given type with the requested relations
* @param schemaTypeMap
* @param typeName
* @param relations
*/
export function gqlGetFieldsAndRelations(
schemaTypeMap: { [key: string]: GraphQLNamedType },
typeName: string,
relations: string[] = []
) {
const result: string[] = []
function traverseFields(typeName, prefix = "") {
const type = schemaTypeMap[typeName]
if (!(type instanceof GraphQLObjectType)) {
return
}
const fields = type.getFields()
for (const fieldName in fields) {
const field = fields[fieldName]
let fieldType = field.type as any
while (fieldType.ofType) {
fieldType = fieldType.ofType
}
if (!isObjectType(fieldType)) {
result.push(`${prefix}${fieldName}`)
} else if (relations.includes(prefix + fieldName)) {
traverseFields(fieldType.name, `${prefix}${fieldName}.`)
}
}
}
traverseFields(typeName)
return result
}

View File

@@ -1,3 +1,4 @@
export * from "./clean-graphql-schema"
export * from "./graphql-schema-to-fields"
export * from "./gql-schema-to-types"
export * from "./gql-get-fields-and-relations"

View File

@@ -31,3 +31,4 @@ export * as UserTypes from "./user"
export * as WorkflowTypes from "./workflow"
export * as WorkflowsSdkTypes from "./workflows-sdk"
export * as DmlTypes from "./dml"
export * as IndexTypes from "./index/index"

View File

@@ -41,3 +41,4 @@ export * from "./workflow"
export * from "./workflows"
export * from "./workflows-sdk"
export * from "./dml"
export * from "./index"

View File

@@ -0,0 +1 @@
export * from "./service"

View File

@@ -0,0 +1,6 @@
import { IModuleService } from "../modules-sdk"
export interface IIndexService extends IModuleService {
query(...args): Promise<any>
queryAndCount(...args): Promise<any>
}

View File

@@ -29,7 +29,6 @@ export const MedusaErrorCodes = {
* @extends Error
*/
export class MedusaError extends Error {
// Symbol is not serializable
__isMedusaError = true
public type: string

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,7 @@ export enum Modules {
CURRENCY = "Currency",
FILE = "File",
NOTIFICATION = "Notification",
INDEX = "Index",
}
export const ModuleRegistrationName = Modules
@@ -78,5 +79,6 @@ declare module "@medusajs/types" {
[Modules.CURRENCY]: ICurrencyModuleService
[Modules.FILE]: IFileModuleService
[Modules.NOTIFICATION]: INotificationModuleService
[Modules.INDEX]: any // TODO: define index module interface
}
}

6
packages/modules/index/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1 @@
# @medusajs/index

View File

@@ -0,0 +1 @@
# Index Module

View File

@@ -0,0 +1,47 @@
import {
EventBusTypes,
IEventBusModuleService,
Message,
Subscriber,
} from "@medusajs/types"
export class EventBusServiceMock implements IEventBusModuleService {
protected readonly subscribers_: Map<string | symbol, Set<Subscriber>> =
new Map()
async emit<T>(
messages: Message<T> | Message<T>[],
options?: Record<string, unknown>
): Promise<void> {
const messages_ = Array.isArray(messages) ? messages : [messages]
for (const message of messages_) {
const subscribers = this.subscribers_.get(message.name)
for (const subscriber of subscribers ?? []) {
const { options, ...payload } = message
await subscriber(payload)
}
}
}
subscribe(event: string | symbol, subscriber: Subscriber): this {
this.subscribers_.set(event, new Set([subscriber]))
return this
}
unsubscribe(
event: string | symbol,
subscriber: Subscriber,
context?: EventBusTypes.SubscriberContext
): this {
return this
}
releaseGroupedEvents(eventGroupId: string): Promise<void> {
throw new Error("Method not implemented.")
}
clearGroupedEvents(eventGroupId: string): Promise<void> {
throw new Error("Method not implemented.")
}
}

View File

@@ -0,0 +1,2 @@
export * from "./event-bus"
export * from "./schema"

View File

@@ -0,0 +1,38 @@
const { defineConfig, Modules } = require("@medusajs/utils")
const { schema } = require("./schema")
export const dbName = "medusa-index-integration-2024"
const DB_HOST = process.env.DB_HOST ?? "localhost:5432"
const DB_USERNAME = process.env.DB_USERNAME ?? ""
const DB_PASSWORD = process.env.DB_PASSWORD ?? ""
const DB_URL = `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${dbName}`
const config = defineConfig({
admin: {
disable: true,
},
projectConfig: {
databaseUrl: DB_URL,
},
})
Object.keys(config.modules).forEach((key) => {
if ([Modules.EVENT_BUS].includes(key)) {
return
}
config.modules[key] = false
})
config.modules[Modules.INDEX] = {
resolve: "@medusajs/index",
dependencies: [Modules.EVENT_BUS],
options: {
schema,
},
}
config.modules[Modules.PRODUCT] = true
config.modules[Modules.PRICING] = true
export default config

View File

@@ -0,0 +1,28 @@
export const schema = `
type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) {
id: String
title: String
deep: InternalNested
variants: [ProductVariant]
}
type InternalNested {
a: Int
obj: InternalObject
}
type InternalObject {
b: Int
}
type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) {
id: String
product_id: String
sku: String
prices: [Price]
}
type Price @Listeners(values: ["price.created", "price.updated", "price.deleted"]) {
amount: Int
}
`

View File

@@ -0,0 +1,747 @@
import {
MedusaAppLoader,
configLoader,
container,
logger,
} from "@medusajs/framework"
import { MedusaAppOutput, MedusaModule } from "@medusajs/modules-sdk"
import { EventBusTypes } from "@medusajs/types"
import {
ContainerRegistrationKeys,
ModuleRegistrationName,
Modules,
} from "@medusajs/utils"
import { EntityManager } from "@mikro-orm/postgresql"
import { IndexData, IndexRelation } from "@models"
import { asValue } from "awilix"
import { dbTestUtilFactory } from "medusa-test-utils"
import { initDb } from "medusa-test-utils/dist/medusa-test-runner-utils/use-db"
import * as path from "path"
import { EventBusServiceMock } from "../__fixtures__"
import { dbName } from "../__fixtures__/medusa-config"
const eventBusMock = new EventBusServiceMock()
const remoteQueryMock = jest.fn()
const dbUtils = dbTestUtilFactory()
jest.setTimeout(300000)
const productId = "prod_1"
const variantId = "var_1"
const priceSetId = "price_set_1"
const priceId = "money_amount_1"
const linkId = "link_id_1"
const sendEvents = async (eventDataToEmit) => {
let a = 0
remoteQueryMock.mockImplementation((query) => {
query = query.__value
if (query.product) {
return {
id: a++ > 0 ? "aaaa" : productId,
}
} else if (query.product_variant) {
return {
id: variantId,
sku: "aaa test aaa",
product: {
id: productId,
},
}
} else if (query.price_set) {
return {
id: priceSetId,
}
} else if (query.price) {
return {
id: priceId,
amount: 100,
price_set: [
{
id: priceSetId,
},
],
}
} else if (query.product_variant_price_set) {
return {
id: linkId,
variant_id: variantId,
price_set_id: priceSetId,
variant: [
{
id: variantId,
},
],
}
}
return {}
})
await eventBusMock.emit(eventDataToEmit)
}
let isFirstTime = true
let medusaAppLoader!: MedusaAppLoader
const beforeAll_ = async () => {
try {
configLoader(path.join(__dirname, "./../__fixtures__"), "medusa-config.js")
console.log(`Creating database ${dbName}`)
await dbUtils.create(dbName)
dbUtils.pgConnection_ = await initDb()
container.register({
[ContainerRegistrationKeys.LOGGER]: asValue(logger),
[ContainerRegistrationKeys.REMOTE_QUERY]: asValue(null),
[ContainerRegistrationKeys.PG_CONNECTION]: asValue(dbUtils.pgConnection_),
})
medusaAppLoader = new MedusaAppLoader(container as any)
// Migrations
await medusaAppLoader.runModulesMigrations()
const linkPlanner = await medusaAppLoader.getLinksExecutionPlanner()
const plan = await linkPlanner.createPlan()
await linkPlanner.executePlan(plan)
// Clear partially loaded instances
MedusaModule.clearInstances()
// Bootstrap modules
const globalApp = await medusaAppLoader.load()
const index = container.resolve(Modules.INDEX)
// Mock event bus the index module
;(index as any).eventBusModuleService_ = eventBusMock
await globalApp.onApplicationStart()
jest
.spyOn((index as any).storageProvider_, "remoteQuery_")
.mockImplementation(remoteQueryMock)
return globalApp
} catch (error) {
console.error("Error initializing", error?.message)
throw error
}
}
const beforeEach_ = async (eventDataToEmit) => {
jest.clearAllMocks()
if (isFirstTime) {
isFirstTime = false
await sendEvents(eventDataToEmit)
return
}
try {
await medusaAppLoader.runModulesLoader()
await sendEvents(eventDataToEmit)
} catch (error) {
console.error("Error runner modules loaders", error?.message)
throw error
}
}
const afterEach_ = async () => {
try {
await dbUtils.teardown({ schema: "public" })
} catch (error) {
console.error("Error tearing down database:", error?.message)
throw error
}
}
describe("IndexModuleService", function () {
let medusaApp: MedusaAppOutput
let onApplicationPrepareShutdown!: () => Promise<void>
let onApplicationShutdown!: () => Promise<void>
beforeAll(async () => {
medusaApp = await beforeAll_()
onApplicationPrepareShutdown = medusaApp.onApplicationPrepareShutdown
onApplicationShutdown = medusaApp.onApplicationShutdown
})
afterAll(async () => {
await onApplicationPrepareShutdown()
await onApplicationShutdown()
await dbUtils.shutdown(dbName)
})
describe("on created or attached events", function () {
let manager
const eventDataToEmit: EventBusTypes.Event[] = [
{
name: "product.created",
data: {
id: productId,
},
},
{
name: "product.created",
data: {
id: "PRODUCTASDASDAS",
},
},
{
name: "variant.created",
data: {
id: variantId,
product: {
id: productId,
},
},
},
{
name: "PriceSet.created",
data: {
id: priceSetId,
},
},
{
name: "price.created",
data: {
id: priceId,
price_set: {
id: priceSetId,
},
},
},
{
name: "LinkProductVariantPriceSet.attached",
data: {
id: linkId,
variant_id: variantId,
price_set_id: priceSetId,
},
},
]
beforeEach(async () => {
await beforeEach_(eventDataToEmit)
manager = (
medusaApp.sharedContainer!.resolve(ModuleRegistrationName.INDEX) as any
).container_.manager as EntityManager
})
afterEach(afterEach_)
it("should create the corresponding index entries and index relation entries", async function () {
expect(remoteQueryMock).toHaveBeenCalledTimes(6)
/**
* Validate all index entries and index relation entries
*/
const indexEntries: IndexData[] = await manager.find(IndexData, {})
const productIndexEntries = indexEntries.filter((entry) => {
return entry.name === "Product"
})
expect(productIndexEntries).toHaveLength(2)
const variantIndexEntries = indexEntries.filter((entry) => {
return entry.name === "ProductVariant"
})
expect(variantIndexEntries).toHaveLength(1)
const priceSetIndexEntries = indexEntries.filter((entry) => {
return entry.name === "PriceSet"
})
expect(priceSetIndexEntries).toHaveLength(1)
const priceIndexEntries = indexEntries.filter((entry) => {
return entry.name === "Price"
})
expect(priceIndexEntries).toHaveLength(1)
const linkIndexEntries = indexEntries.filter((entry) => {
return entry.name === "LinkProductVariantPriceSet"
})
expect(linkIndexEntries).toHaveLength(1)
const indexRelationEntries: IndexRelation[] = await manager.find(
IndexRelation,
{}
)
expect(indexRelationEntries).toHaveLength(4)
const productVariantIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === productId &&
entry.parent_name === "Product" &&
entry.child_id === variantId &&
entry.child_name === "ProductVariant"
)
}
)
expect(productVariantIndexRelationEntries).toHaveLength(1)
const variantLinkIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === variantId &&
entry.parent_name === "ProductVariant" &&
entry.child_id === linkId &&
entry.child_name === "LinkProductVariantPriceSet"
)
}
)
expect(variantLinkIndexRelationEntries).toHaveLength(1)
const linkPriceSetIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === linkId &&
entry.parent_name === "LinkProductVariantPriceSet" &&
entry.child_id === priceSetId &&
entry.child_name === "PriceSet"
)
}
)
expect(linkPriceSetIndexRelationEntries).toHaveLength(1)
const priceSetPriceIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === priceSetId &&
entry.parent_name === "PriceSet" &&
entry.child_id === priceId &&
entry.child_name === "Price"
)
}
)
expect(priceSetPriceIndexRelationEntries).toHaveLength(1)
})
})
describe("on unordered created or attached events", function () {
let manager
const eventDataToEmit: EventBusTypes.Event[] = [
{
name: "variant.created",
data: {
id: variantId,
product: {
id: productId,
},
},
},
{
name: "product.created",
data: {
id: productId,
},
},
{
name: "product.created",
data: {
id: "PRODUCTASDASDAS",
},
},
{
name: "PriceSet.created",
data: {
id: priceSetId,
},
},
{
name: "price.created",
data: {
id: priceId,
price_set: {
id: priceSetId,
},
},
},
{
name: "LinkProductVariantPriceSet.attached",
data: {
id: linkId,
variant_id: variantId,
price_set_id: priceSetId,
},
},
]
beforeEach(async () => {
await beforeEach_(eventDataToEmit)
manager = (
medusaApp.sharedContainer!.resolve(ModuleRegistrationName.INDEX) as any
).container_.manager as EntityManager
})
afterEach(afterEach_)
it("should create the corresponding index entries and index relation entries", async function () {
expect(remoteQueryMock).toHaveBeenCalledTimes(6)
/**
* Validate all index entries and index relation entries
*/
const indexEntries: IndexData[] = await manager.find(IndexData, {})
const productIndexEntries = indexEntries.filter((entry) => {
return entry.name === "Product"
})
expect(productIndexEntries).toHaveLength(2)
expect(productIndexEntries[0].id).toEqual(productId)
const variantIndexEntries = indexEntries.filter((entry) => {
return entry.name === "ProductVariant"
})
expect(variantIndexEntries).toHaveLength(1)
expect(variantIndexEntries[0].id).toEqual(variantId)
const priceSetIndexEntries = indexEntries.filter((entry) => {
return entry.name === "PriceSet"
})
expect(priceSetIndexEntries).toHaveLength(1)
expect(priceSetIndexEntries[0].id).toEqual(priceSetId)
const priceIndexEntries = indexEntries.filter((entry) => {
return entry.name === "Price"
})
expect(priceIndexEntries).toHaveLength(1)
expect(priceIndexEntries[0].id).toEqual(priceId)
const linkIndexEntries = indexEntries.filter((entry) => {
return entry.name === "LinkProductVariantPriceSet"
})
expect(linkIndexEntries).toHaveLength(1)
expect(linkIndexEntries[0].id).toEqual(linkId)
const indexRelationEntries: IndexRelation[] = await manager.find(
IndexRelation,
{}
)
expect(indexRelationEntries).toHaveLength(4)
const productVariantIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === productId &&
entry.parent_name === "Product" &&
entry.child_id === variantId &&
entry.child_name === "ProductVariant"
)
}
)
expect(productVariantIndexRelationEntries).toHaveLength(1)
const variantLinkIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === variantId &&
entry.parent_name === "ProductVariant" &&
entry.child_id === linkId &&
entry.child_name === "LinkProductVariantPriceSet"
)
}
)
expect(variantLinkIndexRelationEntries).toHaveLength(1)
const linkPriceSetIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === linkId &&
entry.parent_name === "LinkProductVariantPriceSet" &&
entry.child_id === priceSetId &&
entry.child_name === "PriceSet"
)
}
)
expect(linkPriceSetIndexRelationEntries).toHaveLength(1)
const priceSetPriceIndexRelationEntries = indexRelationEntries.filter(
(entry) => {
return (
entry.parent_id === priceSetId &&
entry.parent_name === "PriceSet" &&
entry.child_id === priceId &&
entry.child_name === "Price"
)
}
)
expect(priceSetPriceIndexRelationEntries).toHaveLength(1)
})
})
describe("on updated events", function () {
let manager
const updateData = async (manager) => {
const indexRepository = manager.getRepository(IndexData)
await indexRepository.upsertMany([
{
id: productId,
name: "Product",
data: {
id: productId,
},
},
{
id: variantId,
name: "ProductVariant",
data: {
id: variantId,
sku: "aaa test aaa",
product: {
id: productId,
},
},
},
])
}
const eventDataToEmit: EventBusTypes.Event[] = [
{
name: "product.updated",
data: {
id: productId,
},
},
{
name: "variant.updated",
data: {
id: variantId,
product: {
id: productId,
},
},
},
]
beforeEach(async () => {
await beforeEach_(eventDataToEmit)
manager = (
medusaApp.sharedContainer!.resolve(ModuleRegistrationName.INDEX) as any
).container_.manager as EntityManager
await updateData(manager)
remoteQueryMock.mockImplementation((query) => {
query = query.__value
if (query.product) {
return {
id: productId,
title: "updated Title",
}
} else if (query.product_variant) {
return {
id: variantId,
sku: "updated sku",
product: [
{
id: productId,
},
],
}
}
return {}
})
await eventBusMock.emit(eventDataToEmit)
})
afterEach(afterEach_)
it("should update the corresponding index entries", async () => {
expect(remoteQueryMock).toHaveBeenCalledTimes(4)
const updatedIndexEntries = await manager.find(IndexData, {})
expect(updatedIndexEntries).toHaveLength(2)
const productEntry = updatedIndexEntries.find((entry) => {
return entry.name === "Product" && entry.id === productId
})
expect(productEntry?.data?.title).toEqual("updated Title")
const variantEntry = updatedIndexEntries.find((entry) => {
return entry.name === "ProductVariant" && entry.id === variantId
})
expect(variantEntry?.data?.sku).toEqual("updated sku")
})
})
describe("on deleted events", function () {
let manager
const eventDataToEmit: EventBusTypes.Event[] = [
{
name: "product.created",
data: {
id: productId,
},
},
{
name: "variant.created",
data: {
id: variantId,
product: {
id: productId,
},
},
},
{
name: "PriceSet.created",
data: {
id: priceSetId,
},
},
{
name: "price.created",
data: {
id: priceId,
price_set: {
id: priceSetId,
},
},
},
{
name: "LinkProductVariantPriceSet.attached",
data: {
id: linkId,
variant_id: variantId,
price_set_id: priceSetId,
},
},
]
const deleteEventDataToEmit: EventBusTypes.Event[] = [
{
name: "product.deleted",
data: {
id: productId,
},
},
{
name: "variant.deleted",
data: {
id: variantId,
},
},
]
beforeEach(async () => {
await beforeEach_(eventDataToEmit)
manager = (
medusaApp.sharedContainer!.resolve(ModuleRegistrationName.INDEX) as any
).container_.manager as EntityManager
remoteQueryMock.mockImplementation((query) => {
query = query.__value
if (query.product) {
return {
id: productId,
}
} else if (query.product_variant) {
return {
id: variantId,
product: [
{
id: productId,
},
],
}
}
return {}
})
await eventBusMock.emit(deleteEventDataToEmit)
})
afterEach(afterEach_)
it("should consume all deleted events and delete the index entries", async () => {
expect(remoteQueryMock).toHaveBeenCalledTimes(7)
const indexEntries = await manager.find(IndexData, {})
const indexRelationEntries = await manager.find(IndexRelation, {})
expect(indexEntries).toHaveLength(3)
expect(indexRelationEntries).toHaveLength(2)
const linkIndexEntry = indexEntries.find((entry) => {
return (
entry.name === "LinkProductVariantPriceSet" && entry.id === linkId
)
})!
const priceSetIndexEntry = indexEntries.find((entry) => {
return entry.name === "PriceSet" && entry.id === priceSetId
})!
const priceIndexEntry = indexEntries.find((entry) => {
return entry.name === "Price" && entry.id === priceId
})!
const linkPriceSetIndexRelationEntry = indexRelationEntries.find(
(entry) => {
return (
entry.parent_id === linkId &&
entry.parent_name === "LinkProductVariantPriceSet" &&
entry.child_id === priceSetId &&
entry.child_name === "PriceSet"
)
}
)!
expect(linkPriceSetIndexRelationEntry.parent).toEqual(linkIndexEntry)
expect(linkPriceSetIndexRelationEntry.child).toEqual(priceSetIndexEntry)
const priceSetPriceIndexRelationEntry = indexRelationEntries.find(
(entry) => {
return (
entry.parent_id === priceSetId &&
entry.parent_name === "PriceSet" &&
entry.child_id === priceId &&
entry.child_name === "Price"
)
}
)!
expect(priceSetPriceIndexRelationEntry.parent).toEqual(priceSetIndexEntry)
expect(priceSetPriceIndexRelationEntry.child).toEqual(priceIndexEntry)
})
})
})

View File

@@ -0,0 +1,562 @@
import {
MedusaAppLoader,
configLoader,
container,
logger,
} from "@medusajs/framework"
import { MedusaAppOutput, MedusaModule } from "@medusajs/modules-sdk"
import { IndexTypes } from "@medusajs/types"
import {
ContainerRegistrationKeys,
ModuleRegistrationName,
Modules,
} from "@medusajs/utils"
import { EntityManager } from "@mikro-orm/postgresql"
import { IndexData, IndexRelation } from "@models"
import { asValue } from "awilix"
import { dbTestUtilFactory } from "medusa-test-utils"
import { initDb } from "medusa-test-utils/dist/medusa-test-runner-utils/use-db"
import path from "path"
import { EventBusServiceMock } from "../__fixtures__"
import { dbName } from "../__fixtures__/medusa-config"
const eventBusMock = new EventBusServiceMock()
const remoteQueryMock = jest.fn()
const dbUtils = dbTestUtilFactory()
jest.setTimeout(300000)
let isFirstTime = true
let medusaAppLoader!: MedusaAppLoader
const beforeAll_ = async () => {
try {
configLoader(path.join(__dirname, "./../__fixtures__"), "medusa-config.js")
console.log(`Creating database ${dbName}`)
await dbUtils.create(dbName)
dbUtils.pgConnection_ = await initDb()
container.register({
[ContainerRegistrationKeys.LOGGER]: asValue(logger),
[ContainerRegistrationKeys.REMOTE_QUERY]: asValue(null),
[ContainerRegistrationKeys.PG_CONNECTION]: asValue(dbUtils.pgConnection_),
})
medusaAppLoader = new MedusaAppLoader(container as any)
// Migrations
await medusaAppLoader.runModulesMigrations()
const linkPlanner = await medusaAppLoader.getLinksExecutionPlanner()
const plan = await linkPlanner.createPlan()
await linkPlanner.executePlan(plan)
// Clear partially loaded instances
MedusaModule.clearInstances()
// Bootstrap modules
const globalApp = await medusaAppLoader.load()
const index = container.resolve(Modules.INDEX)
// Mock event bus the index module
;(index as any).eventBusModuleService_ = eventBusMock
await globalApp.onApplicationStart()
jest
.spyOn((index as any).storageProvider_, "remoteQuery_")
.mockImplementation(remoteQueryMock)
return globalApp
} catch (error) {
console.error("Error initializing", error?.message)
throw error
}
}
const beforeEach_ = async () => {
jest.clearAllMocks()
if (isFirstTime) {
isFirstTime = false
return
}
try {
await medusaAppLoader.runModulesLoader()
} catch (error) {
console.error("Error runner modules loaders", error?.message)
throw error
}
}
const afterEach_ = async () => {
try {
await dbUtils.teardown({ schema: "public" })
} catch (error) {
console.error("Error tearing down database:", error?.message)
throw error
}
}
describe("IndexModuleService query", function () {
let medusaApp: MedusaAppOutput
let module: IndexTypes.IIndexService
let onApplicationPrepareShutdown!: () => Promise<void>
let onApplicationShutdown!: () => Promise<void>
beforeAll(async () => {
medusaApp = await beforeAll_()
onApplicationPrepareShutdown = medusaApp.onApplicationPrepareShutdown
onApplicationShutdown = medusaApp.onApplicationShutdown
})
afterAll(async () => {
await onApplicationPrepareShutdown()
await onApplicationShutdown()
await dbUtils.shutdown(dbName)
})
beforeEach(async () => {
await beforeEach_()
module = medusaApp.sharedContainer!.resolve(ModuleRegistrationName.INDEX)
const manager = (
(medusaApp.sharedContainer!.resolve(ModuleRegistrationName.INDEX) as any)
.container_.manager as EntityManager
).fork()
const indexRepository = manager.getRepository(IndexData)
await manager.persistAndFlush(
[
{
id: "prod_1",
name: "Product",
data: {
id: "prod_1",
},
},
{
id: "prod_2",
name: "Product",
data: {
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
},
},
{
id: "var_1",
name: "ProductVariant",
data: {
id: "var_1",
sku: "aaa test aaa",
},
},
{
id: "var_2",
name: "ProductVariant",
data: {
id: "var_2",
sku: "sku 123",
},
},
{
id: "link_id_1",
name: "LinkProductVariantPriceSet",
data: {
id: "link_id_1",
variant_id: "var_1",
price_set_id: "price_set_1",
},
},
{
id: "link_id_2",
name: "LinkProductVariantPriceSet",
data: {
id: "link_id_2",
variant_id: "var_2",
price_set_id: "price_set_2",
},
},
{
id: "price_set_1",
name: "PriceSet",
data: {
id: "price_set_1",
},
},
{
id: "price_set_2",
name: "PriceSet",
data: {
id: "price_set_2",
},
},
{
id: "money_amount_1",
name: "Price",
data: {
id: "money_amount_1",
amount: 100,
},
},
{
id: "money_amount_2",
name: "Price",
data: {
id: "money_amount_2",
amount: 10,
},
},
].map((data) => indexRepository.create(data))
)
const indexRelationRepository = manager.getRepository(IndexRelation)
await manager.persistAndFlush(
[
{
parent_id: "prod_1",
parent_name: "Product",
child_id: "var_1",
child_name: "ProductVariant",
pivot: "Product-ProductVariant",
},
{
parent_id: "prod_1",
parent_name: "Product",
child_id: "var_2",
child_name: "ProductVariant",
pivot: "Product-ProductVariant",
},
{
parent_id: "var_1",
parent_name: "ProductVariant",
child_id: "link_id_1",
child_name: "LinkProductVariantPriceSet",
pivot: "ProductVariant-LinkProductVariantPriceSet",
},
{
parent_id: "var_2",
parent_name: "ProductVariant",
child_id: "link_id_2",
child_name: "LinkProductVariantPriceSet",
pivot: "ProductVariant-LinkProductVariantPriceSet",
},
{
parent_id: "link_id_1",
parent_name: "LinkProductVariantPriceSet",
child_id: "price_set_1",
child_name: "PriceSet",
pivot: "LinkProductVariantPriceSet-PriceSet",
},
{
parent_id: "link_id_2",
parent_name: "LinkProductVariantPriceSet",
child_id: "price_set_2",
child_name: "PriceSet",
pivot: "LinkProductVariantPriceSet-PriceSet",
},
{
parent_id: "price_set_1",
parent_name: "PriceSet",
child_id: "money_amount_1",
child_name: "Price",
pivot: "PriceSet-Price",
},
{
parent_id: "price_set_2",
parent_name: "PriceSet",
child_id: "money_amount_2",
child_name: "Price",
pivot: "PriceSet-Price",
},
].map((data) => indexRelationRepository.create(data))
)
})
afterEach(afterEach_)
it("should query all products ordered by sku DESC", async () => {
const [result, count] = await module.queryAndCount(
{
select: {
product: {
variants: {
prices: true,
},
},
},
},
{
orderBy: [{ "product.variants.sku": "DESC" }],
}
)
expect(count).toEqual(2)
expect(result).toEqual([
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
variants: [],
},
{
id: "prod_1",
variants: [
{
id: "var_2",
sku: "sku 123",
prices: [
{
id: "money_amount_2",
amount: 10,
},
],
},
{
id: "var_1",
sku: "aaa test aaa",
prices: [
{
id: "money_amount_1",
amount: 100,
},
],
},
],
},
])
})
it("should query products filtering by variant sku", async () => {
const result = await module.query({
select: {
product: {
variants: {
prices: true,
},
},
},
where: {
"product.variants.sku": { $like: "aaa%" },
},
})
expect(result).toEqual([
{
id: "prod_1",
variants: [
{
id: "var_1",
sku: "aaa test aaa",
prices: [
{
id: "money_amount_1",
amount: 100,
},
],
},
],
},
])
})
it("should query products filtering by price and returning the complete entity", async () => {
const [result] = await module.queryAndCount(
{
select: {
product: {
variants: {
prices: true,
},
},
},
where: {
"product.variants.prices.amount": { $gt: "50" },
},
},
{
keepFilteredEntities: true,
}
)
expect(result).toEqual([
{
id: "prod_1",
variants: [
{
id: "var_1",
sku: "aaa test aaa",
prices: [
{
id: "money_amount_1",
amount: 100,
},
],
},
{
id: "var_2",
sku: "sku 123",
prices: [
{
id: "money_amount_2",
amount: 10,
},
],
},
],
},
])
})
it("should query all products", async () => {
const [result, count] = await module.queryAndCount({
select: {
product: {
variants: {
prices: true,
},
},
},
})
expect(count).toEqual(2)
expect(result).toEqual([
{
id: "prod_1",
variants: [
{
id: "var_1",
sku: "aaa test aaa",
prices: [
{
id: "money_amount_1",
amount: 100,
},
],
},
{
id: "var_2",
sku: "sku 123",
prices: [
{
id: "money_amount_2",
amount: 10,
},
],
},
],
},
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
variants: [],
},
])
})
it("should paginate products", async () => {
const result = await module.query(
{
select: {
product: {
variants: {
prices: true,
},
},
},
},
{
take: 1,
skip: 1,
}
)
expect(result).toEqual([
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
variants: [],
},
])
})
it("should handle null values on where clause", async () => {
const result = await module.query({
select: {
product: {
variants: {
prices: true,
},
},
},
where: {
"product.variants.sku": null,
},
})
expect(result).toEqual([
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
variants: [],
},
])
})
it("should query products filtering by deep nested levels", async () => {
const [result] = await module.queryAndCount({
select: {
product: true,
},
where: {
"product.deep.obj.b": 15,
},
})
expect(result).toEqual([
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
},
])
})
})

View File

@@ -0,0 +1,22 @@
module.exports = {
moduleNameMapper: {
"^@models": "<rootDir>/src/models",
"^@services": "<rootDir>/src/services",
"^@repositories": "<rootDir>/src/repositories",
"^@types": "<rootDir>/src/types",
},
transform: {
"^.+\\.[jt]s$": [
"@swc/jest",
{
jsc: {
parser: { syntax: "typescript", decorators: true },
transform: { decoratorMetadata: true },
},
},
],
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
modulePathIgnorePatterns: ["dist/"],
}

View File

@@ -0,0 +1,6 @@
import { defineMikroOrmCliConfig, Modules } from "@medusajs/utils"
import * as entities from "./src/models"
export default defineMikroOrmCliConfig(Modules.INDEX, {
entities: Object.values(entities),
})

View File

@@ -0,0 +1,55 @@
{
"name": "@medusajs/index",
"version": "0.0.1",
"description": "Medusa Index module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"engines": {
"node": ">=20"
},
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/index"
},
"publishConfig": {
"access": "public"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"watch": "tsc --build --watch",
"watch:test": "tsc --build tsconfig.spec.json --watch",
"build": "rimraf dist && tsc --noEmit && tsc -p ./tsconfig.build.json && tsc-alias -p ./tsconfig.build.json",
"test": "jest --passWithNoTests ./src",
"test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.ts",
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial",
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
"migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up",
"orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear"
},
"devDependencies": {
"@medusajs/types": "^1.11.16",
"@mikro-orm/cli": "5.9.7",
"cross-env": "^5.2.1",
"jest": "^29.7.0",
"medusa-test-utils": "^1.1.44",
"rimraf": "^3.0.2",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.6",
"typescript": "^5.1.6"
},
"dependencies": {
"@medusajs/utils": "^1.11.9"
},
"peerDependencies": {
"@mikro-orm/core": "5.9.7",
"@mikro-orm/migrations": "5.9.7",
"@mikro-orm/postgresql": "5.9.7",
"awilix": "^8.0.1"
}
}

View File

@@ -0,0 +1,8 @@
import { IndexModuleService } from "@services"
import { Module, Modules } from "@medusajs/utils"
import containerLoader from "./loaders/index"
export default Module(Modules.INDEX, {
service: IndexModuleService,
loaders: [containerLoader],
})

View File

@@ -0,0 +1,29 @@
import { asClass, asValue } from "awilix"
import { PostgresProvider } from "../services/postgres-provider"
import { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
import { IndexModuleService } from "@services"
import { LoaderOptions } from "@medusajs/types"
export default async ({ container, options }: LoaderOptions): Promise<void> => {
container.register({
baseRepository: asClass(BaseRepository).singleton(),
searchModuleService: asClass(IndexModuleService).singleton(),
})
container.register("storageProviderCtrOptions", asValue(undefined))
container.register("storageProviderCtr", asValue(PostgresProvider))
/*if (!options?.customAdapter) {
container.register("storageProviderCtr", asValue(PostgresProvider))
} else {
container.register(
"storageProviderCtr",
asValue(options.customAdapter.constructor)
)
container.register(
"storageProviderCtrOptions",
asValue(options.customAdapter.options)
)
}*/
}

View File

@@ -0,0 +1,151 @@
{
"namespaces": ["public"],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"name": {
"name": "name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"data": {
"name": "data",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "'{}'",
"mappedType": "json"
}
},
"name": "index_data",
"schema": "public",
"indexes": [
{
"columnNames": ["id"],
"composite": false,
"keyName": "IDX_index_data_id",
"primary": false,
"unique": false
},
{
"columnNames": ["name"],
"composite": false,
"keyName": "IDX_index_data_name",
"primary": false,
"unique": false
},
{
"keyName": "IDX_index_data_gin",
"columnNames": ["data"],
"composite": false,
"primary": false,
"unique": false,
"type": "GIN"
},
{
"keyName": "index_data_pkey",
"columnNames": ["id", "name"],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": true,
"autoincrement": true,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"pivot": {
"name": "pivot",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"parent_id": {
"name": "parent_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"parent_name": {
"name": "parent_name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"child_id": {
"name": "child_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"child_name": {
"name": "child_name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "index_relation",
"schema": "public",
"indexes": [
{
"keyName": "IDX_index_relation_child_id",
"columnNames": ["child_id"],
"composite": false,
"primary": false,
"unique": false
},
{
"keyName": "index_relation_pkey",
"columnNames": ["id", "pivot"],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
}
]
}

View File

@@ -0,0 +1,21 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20231019174230 extends Migration {
async up(): Promise<void> {
this.addSql(
`create table "index_data" ("id" text not null, "name" text not null, "data" jsonb not null default '{}', constraint "index_data_pkey" primary key ("id", "name")) PARTITION BY LIST("name");`
)
this.addSql(`create index "IDX_index_data_id" on "index_data" ("id");`)
this.addSql(`create index "IDX_index_data_name" on "index_data" ("name");`)
this.addSql(
`create index "IDX_index_data_gin" on "index_data" using GIN ("data");`
)
this.addSql(
`create table "index_relation" ("id" bigserial, "pivot" text not null, "parent_id" text not null, "parent_name" text not null, "child_id" text not null, "child_name" text not null, constraint "index_relation_pkey" primary key ("id", "pivot")) PARTITION BY LIST("pivot");`
)
this.addSql(
`create index "IDX_index_relation_child_id" on "index_relation" ("child_id");`
)
}
}

View File

@@ -0,0 +1,45 @@
import {
Cascade,
Collection,
Entity,
Index,
ManyToMany,
OptionalProps,
PrimaryKey,
PrimaryKeyType,
Property,
} from "@mikro-orm/core"
import { IndexRelation } from "./index-relation"
type OptionalRelations = "parents"
@Entity({
tableName: "index_data",
})
export class IndexData {
[OptionalProps]: OptionalRelations
@PrimaryKey({ columnType: "text" })
@Index({ name: "IDX_index_data_id" })
id!: string
@PrimaryKey({ columnType: "text" })
@Index({ name: "IDX_index_data_name" })
name: string;
[PrimaryKeyType]?: [string, string]
@Index({ name: "IDX_index_data_gin", type: "GIN" })
@Property({ columnType: "jsonb", default: "{}" })
data: Record<string, unknown>
@ManyToMany({
owner: true,
entity: () => IndexData,
pivotEntity: () => IndexRelation,
cascade: [Cascade.REMOVE],
inverseJoinColumns: ["parent_id", "parent_name"],
joinColumns: ["child_id", "child_name"],
})
parents = new Collection<IndexData>(this)
}

View File

@@ -0,0 +1,64 @@
import {
Entity,
Index,
ManyToOne,
OptionalProps,
PrimaryKey,
Property,
Ref,
} from "@mikro-orm/core"
import { IndexData } from "./index-data"
type OptionalRelations =
| "parent"
| "child"
| "parent_id"
| "child_id"
| "parent_name"
| "child_name"
@Entity({
tableName: "index_relation",
})
@Index({
name: "IDX_index_relation_child_id",
properties: ["child_id"],
})
export class IndexRelation {
[OptionalProps]: OptionalRelations
@PrimaryKey({ columnType: "integer", autoincrement: true })
id!: string
// if added as PK, BeforeCreate value isn't set
@Property({
columnType: "text",
})
pivot: string
@Property({ columnType: "text" })
parent_id?: string
@Property({ columnType: "text" })
parent_name?: string
@Property({ columnType: "text" })
child_id?: string
@Property({ columnType: "text" })
child_name?: string
@ManyToOne({
entity: () => IndexData,
onDelete: "cascade",
persist: false,
})
parent?: Ref<IndexData>
@ManyToOne({
entity: () => IndexData,
onDelete: "cascade",
persist: false,
})
child?: Ref<IndexData>
}

View File

@@ -0,0 +1,2 @@
export { IndexData } from "./index-data"
export { IndexRelation } from "./index-relation"

View File

@@ -0,0 +1,144 @@
import {
IEventBusModuleService,
IndexTypes,
InternalModuleDeclaration,
RemoteQueryFunction,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
MikroOrmBaseRepository as BaseRepository,
Modules,
} from "@medusajs/utils"
import {
IndexModuleOptions,
SchemaObjectRepresentation,
schemaObjectRepresentationPropertiesToOmit,
StorageProvider,
} from "@types"
import { buildSchemaObjectRepresentation } from "../utils/build-config"
import { defaultSchema } from "../utils/default-schema"
type InjectedDependencies = {
[Modules.EVENT_BUS]: IEventBusModuleService
storageProviderCtr: StorageProvider
storageProviderCtrOptions: unknown
[ContainerRegistrationKeys.REMOTE_QUERY]: RemoteQueryFunction
baseRepository: BaseRepository
}
export default class IndexModuleService implements IndexTypes.IIndexService {
private readonly container_: InjectedDependencies
private readonly moduleOptions_: IndexModuleOptions
protected readonly eventBusModuleService_: IEventBusModuleService
protected schemaObjectRepresentation_: SchemaObjectRepresentation
protected schemaEntitiesMap_: Record<string, any>
protected readonly storageProviderCtr_: StorageProvider
protected readonly storageProviderCtrOptions_: unknown
protected storageProvider_: StorageProvider
constructor(
container: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
this.container_ = container
this.moduleOptions_ = (moduleDeclaration.options ??
moduleDeclaration) as unknown as IndexModuleOptions
const {
[Modules.EVENT_BUS]: eventBusModuleService,
storageProviderCtr,
storageProviderCtrOptions,
} = container
this.eventBusModuleService_ = eventBusModuleService
this.storageProviderCtr_ = storageProviderCtr
this.storageProviderCtrOptions_ = storageProviderCtrOptions
if (!this.eventBusModuleService_) {
throw new Error(
"EventBusModuleService is required for the IndexModule to work"
)
}
}
__joinerConfig() {
return {}
}
__hooks = {
onApplicationStart(this: IndexModuleService) {
return this.onApplicationStart_()
},
}
protected async onApplicationStart_() {
try {
this.buildSchemaObjectRepresentation_()
this.storageProvider_ = new this.storageProviderCtr_(
this.container_,
Object.assign(this.storageProviderCtrOptions_ ?? {}, {
schemaObjectRepresentation: this.schemaObjectRepresentation_,
entityMap: this.schemaEntitiesMap_,
}),
this.moduleOptions_
)
this.registerListeners()
if (this.storageProvider_.onApplicationStart) {
await this.storageProvider_.onApplicationStart()
}
} catch (e) {
console.log(e)
}
}
async query(...args) {
return await this.storageProvider_.query.apply(this.storageProvider_, args)
}
async queryAndCount(...args) {
return await this.storageProvider_.queryAndCount.apply(
this.storageProvider_,
args
)
}
protected registerListeners() {
const schemaObjectRepresentation = this.schemaObjectRepresentation_ ?? {}
for (const [entityName, schemaEntityObjectRepresentation] of Object.entries(
schemaObjectRepresentation
)) {
if (schemaObjectRepresentationPropertiesToOmit.includes(entityName)) {
continue
}
schemaEntityObjectRepresentation.listeners.forEach((listener) => {
this.eventBusModuleService_.subscribe(
listener,
this.storageProvider_.consumeEvent(schemaEntityObjectRepresentation)
)
})
}
}
private buildSchemaObjectRepresentation_() {
if (this.schemaObjectRepresentation_) {
return this.schemaObjectRepresentation_
}
const [objectRepresentation, entityMap] = buildSchemaObjectRepresentation(
this.moduleOptions_.schema ?? defaultSchema
)
this.schemaObjectRepresentation_ = objectRepresentation
this.schemaEntitiesMap_ = entityMap
return this.schemaObjectRepresentation_
}
}

View File

@@ -0,0 +1 @@
export { default as IndexModuleService } from "./index-module-service"

View File

@@ -0,0 +1,726 @@
import {
Context,
Event,
RemoteQueryFunction,
Subscriber,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
InjectManager,
InjectTransactionManager,
isDefined,
MedusaContext,
MikroOrmBaseRepository as BaseRepository,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { EntityManager, SqlEntityManager } from "@mikro-orm/postgresql"
import { IndexData, IndexRelation } from "@models"
import {
EntityNameModuleConfigMap,
IndexModuleOptions,
QueryFormat,
QueryOptions,
SchemaObjectEntityRepresentation,
SchemaObjectRepresentation,
} from "@types"
import { createPartitions, QueryBuilder } from "../utils"
type InjectedDependencies = {
manager: EntityManager
[ContainerRegistrationKeys.REMOTE_QUERY]: RemoteQueryFunction
baseRepository: BaseRepository
}
export class PostgresProvider {
#isReady_: Promise<boolean>
protected readonly eventActionToMethodMap_ = {
created: "onCreate",
updated: "onUpdate",
deleted: "onDelete",
attached: "onAttach",
detached: "onDetach",
}
protected container_: InjectedDependencies
protected readonly schemaObjectRepresentation_: SchemaObjectRepresentation
protected readonly schemaEntitiesMap_: Record<string, any>
protected readonly moduleOptions_: IndexModuleOptions
protected readonly manager_: SqlEntityManager
protected readonly remoteQuery_: RemoteQueryFunction
protected baseRepository_: BaseRepository
constructor(
{
manager,
[ContainerRegistrationKeys.REMOTE_QUERY]: remoteQuery,
baseRepository,
}: InjectedDependencies,
options: {
schemaObjectRepresentation: SchemaObjectRepresentation
entityMap: Record<string, any>
},
moduleOptions: IndexModuleOptions
) {
this.manager_ = manager
this.remoteQuery_ = remoteQuery
this.moduleOptions_ = moduleOptions
this.baseRepository_ = baseRepository
this.schemaObjectRepresentation_ = options.schemaObjectRepresentation
this.schemaEntitiesMap_ = options.entityMap
// Add a new column for each key that can be found in the jsonb data column to perform indexes and query on it.
// So far, the execution time is about the same
/*;(async () => {
const query = [
...new Set(
Object.keys(this.schemaObjectRepresentation_)
.filter(
(key) =>
![
"_serviceNameModuleConfigMap",
"_schemaPropertiesMap",
].includes(key)
)
.map((key) => {
return this.schemaObjectRepresentation_[key].fields.filter(
(field) => !field.includes(".")
)
})
.flat()
),
].map(
(field) =>
"ALTER TABLE index_data ADD IF NOT EXISTS " +
field +
" text GENERATED ALWAYS AS (NEW.data->>'" +
field +
"') STORED"
)
await this.manager_.execute(query.join(";"))
})()*/
}
async onApplicationStart() {
let initalizedOk: (value: any) => void = () => {}
let initalizedNok: (value: any) => void = () => {}
this.#isReady_ = new Promise((resolve, reject) => {
initalizedOk = resolve
initalizedNok = reject
})
await createPartitions(
this.schemaObjectRepresentation_,
this.manager_.fork()
)
.then(initalizedOk)
.catch(initalizedNok)
}
protected static parseData<
TData extends { id: string; [key: string]: unknown }
>(
data: TData | TData[],
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
) {
const data_ = Array.isArray(data) ? data : [data]
// Always keep the id in the entity properties
const entityProperties: string[] = ["id"]
const parentsProperties: { [entity: string]: string[] } = {}
/**
* Split fields into entity properties and parents properties
*/
schemaEntityObjectRepresentation.fields.forEach((field) => {
if (field.includes(".")) {
const parentAlias = field.split(".")[0]
const parentSchemaObjectRepresentation =
schemaEntityObjectRepresentation.parents.find(
(parent) => parent.ref.alias === parentAlias
)
if (!parentSchemaObjectRepresentation) {
throw new Error(
`IndexModule error, unable to parse data for ${schemaEntityObjectRepresentation.entity}. The parent schema object representation could not be found for the alias ${parentAlias} for the entity ${schemaEntityObjectRepresentation.entity}.`
)
}
parentsProperties[parentSchemaObjectRepresentation.ref.entity] ??= []
parentsProperties[parentSchemaObjectRepresentation.ref.entity].push(
field
)
} else {
entityProperties.push(field)
}
})
return {
data: data_,
entityProperties,
parentsProperties,
}
}
protected static parseMessageData<T>(message?: Event): {
action: string
data: { id: string }[]
ids: string[]
} | void {
const isExpectedFormat =
isDefined(message?.data) && isDefined(message?.metadata?.action)
if (!isExpectedFormat) {
return
}
const result: {
action: string
data: { id: string }[]
ids: string[]
} = {
action: "",
data: [],
ids: [],
}
result.action = message!.metadata!.action as string
result.data = message!.data as { id: string }[]
result.data = Array.isArray(result.data) ? result.data : [result.data]
result.ids = result.data.map((d) => d.id)
return result
}
@InjectManager("baseRepository_")
async query(
selection: QueryFormat,
options?: QueryOptions,
@MedusaContext() sharedContext: Context = {}
) {
await this.#isReady_
const { manager } = sharedContext as { manager: SqlEntityManager }
let hasPagination = false
if (
typeof options?.take === "number" ||
typeof options?.skip === "number"
) {
hasPagination = true
}
const connection = manager.getConnection()
const qb = new QueryBuilder({
schema: this.schemaObjectRepresentation_,
entityMap: this.schemaEntitiesMap_,
knex: connection.getKnex(),
selector: selection,
options,
})
const sql = qb.buildQuery(hasPagination, !!options?.keepFilteredEntities)
let resultset = await manager.execute(sql)
if (options?.keepFilteredEntities) {
const mainEntity = Object.keys(selection.select)[0]
const ids = resultset.map((r) => r[`${mainEntity}.id`])
if (ids.length) {
const selection_ = {
select: selection.select,
joinWhere: selection.joinWhere,
where: {
[`${mainEntity}.id`]: ids,
},
}
return await this.query(selection_, undefined, sharedContext)
}
}
return qb.buildObjectFromResultset(resultset)
}
@InjectManager("baseRepository_")
async queryAndCount(
selection: QueryFormat,
options?: QueryOptions,
@MedusaContext() sharedContext: Context = {}
): Promise<[Record<string, any>[], number, PerformanceEntry]> {
await this.#isReady_
const { manager } = sharedContext as { manager: SqlEntityManager }
const connection = manager.getConnection()
const qb = new QueryBuilder({
schema: this.schemaObjectRepresentation_,
entityMap: this.schemaEntitiesMap_,
knex: connection.getKnex(),
selector: selection,
options,
})
const sql = qb.buildQuery(true, !!options?.keepFilteredEntities)
performance.mark("index-query-start")
let resultset = await connection.execute(sql)
performance.mark("index-query-end")
const performanceMesurements = performance.measure(
"index-query-end",
"index-query-start"
)
const count = +(resultset[0]?.count ?? 0)
if (options?.keepFilteredEntities) {
const mainEntity = Object.keys(selection.select)[0]
const ids = resultset.map((r) => r[`${mainEntity}.id`])
if (ids.length) {
const selection_ = {
select: selection.select,
joinWhere: selection.joinWhere,
where: {
[`${mainEntity}.id`]: ids,
},
}
performance.mark("index-query-start")
resultset = await this.query(selection_, undefined, sharedContext)
performance.mark("index-query-end")
const performanceMesurements = performance.measure(
"index-query-end",
"index-query-start"
)
return [resultset, count, performanceMesurements]
}
}
return [
qb.buildObjectFromResultset(resultset),
count,
performanceMesurements,
]
}
consumeEvent(
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
): Subscriber<{ id: string }> {
return async (data: Event) => {
await this.#isReady_
const data_: { id: string }[] = Array.isArray(data.data)
? data.data
: [data.data]
let ids: string[] = data_.map((d) => d.id)
let action = data.name.split(".").pop() || ""
const parsedMessage = PostgresProvider.parseMessageData(data)
if (parsedMessage) {
action = parsedMessage.action
ids = parsedMessage.ids
}
const { fields, alias } = schemaEntityObjectRepresentation
const entityData = await this.remoteQuery_(
remoteQueryObjectFromString({
entryPoint: alias,
variables: {
filters: {
id: ids,
},
},
fields: [...new Set(["id", ...fields])],
})
)
const argument = {
entity: schemaEntityObjectRepresentation.entity,
data: entityData,
schemaEntityObjectRepresentation,
}
const targetMethod = this.eventActionToMethodMap_[action]
if (!targetMethod) {
return
}
await this[targetMethod](argument)
}
}
/**
* Create the index entry and the index relation entry when this event is emitted.
* @param entity
* @param data
* @param schemaEntityObjectRepresentation
* @param sharedContext
* @protected
*/
@InjectTransactionManager("baseRepository_")
protected async onCreate<
TData extends { id: string; [key: string]: unknown }
>(
{
entity,
data,
schemaEntityObjectRepresentation,
}: {
entity: string
data: TData | TData[]
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
},
@MedusaContext() sharedContext: Context = {}
) {
const { transactionManager: em } = sharedContext as {
transactionManager: SqlEntityManager
}
const indexRepository = em.getRepository(IndexData)
const indexRelationRepository = em.getRepository(IndexRelation)
const {
data: data_,
entityProperties,
parentsProperties,
} = PostgresProvider.parseData(data, schemaEntityObjectRepresentation)
/**
* Loop through the data and create index entries for each entity as well as the
* index relation entries if the entity has parents
*/
for (const entityData of data_) {
/**
* Clean the entity data to only keep the properties that are defined in the schema
*/
const cleanedEntityData = entityProperties.reduce((acc, property) => {
acc[property] = entityData[property]
return acc
}, {}) as TData
await indexRepository.upsert({
id: cleanedEntityData.id,
name: entity,
data: cleanedEntityData,
})
/**
* Retrieve the parents to attach it to the index entry.
*/
for (const [parentEntity, parentProperties] of Object.entries(
parentsProperties
)) {
const parentAlias = parentProperties[0].split(".")[0]
const parentData = entityData[parentAlias] as TData[]
if (!parentData) {
continue
}
const parentDataCollection = Array.isArray(parentData)
? parentData
: [parentData]
for (const parentData_ of parentDataCollection) {
await indexRepository.upsert({
id: (parentData_ as any).id,
name: parentEntity,
data: parentData_,
})
const parentIndexRelationEntry = indexRelationRepository.create({
parent_id: (parentData_ as any).id,
parent_name: parentEntity,
child_id: cleanedEntityData.id,
child_name: entity,
pivot: `${parentEntity}-${entity}`,
})
indexRelationRepository.persist(parentIndexRelationEntry)
}
}
}
}
/**
* Update the index entry when this event is emitted.
* @param entity
* @param data
* @param schemaEntityObjectRepresentation
* @param sharedContext
* @protected
*/
@InjectTransactionManager("baseRepository_")
protected async onUpdate<
TData extends { id: string; [key: string]: unknown }
>(
{
entity,
data,
schemaEntityObjectRepresentation,
}: {
entity: string
data: TData | TData[]
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
},
@MedusaContext() sharedContext: Context = {}
) {
const { transactionManager: em } = sharedContext as {
transactionManager: SqlEntityManager
}
const indexRepository = em.getRepository(IndexData)
const { data: data_, entityProperties } = PostgresProvider.parseData(
data,
schemaEntityObjectRepresentation
)
await indexRepository.upsertMany(
data_.map((entityData) => {
return {
id: entityData.id,
name: entity,
data: entityProperties.reduce((acc, property) => {
acc[property] = entityData[property]
return acc
}, {}),
}
})
)
}
/**
* Delete the index entry when this event is emitted.
* @param entity
* @param data
* @param schemaEntityObjectRepresentation
* @param sharedContext
* @protected
*/
@InjectTransactionManager("baseRepository_")
protected async onDelete<
TData extends { id: string; [key: string]: unknown }
>(
{
entity,
data,
schemaEntityObjectRepresentation,
}: {
entity: string
data: TData | TData[]
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
},
@MedusaContext() sharedContext: Context = {}
) {
const { transactionManager: em } = sharedContext as {
transactionManager: SqlEntityManager
}
const indexRepository = em.getRepository(IndexData)
const indexRelationRepository = em.getRepository(IndexRelation)
const { data: data_ } = PostgresProvider.parseData(
data,
schemaEntityObjectRepresentation
)
const ids = data_.map((entityData) => entityData.id)
await indexRepository.nativeDelete({
id: { $in: ids },
name: entity,
})
await indexRelationRepository.nativeDelete({
$or: [
{
parent_id: { $in: ids },
parent_name: entity,
},
{
child_id: { $in: ids },
child_name: entity,
},
],
})
}
/**
* event emitted from the link modules to attach a link entity to its parent and child entities from the linked modules.
* @param entity
* @param data
* @param schemaEntityObjectRepresentation
* @protected
*/
@InjectTransactionManager("baseRepository_")
protected async onAttach<
TData extends { id: string; [key: string]: unknown }
>(
{
entity,
data,
schemaEntityObjectRepresentation,
}: {
entity: string
data: TData | TData[]
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
},
@MedusaContext() sharedContext: Context = {}
) {
const { transactionManager: em } = sharedContext as {
transactionManager: SqlEntityManager
}
const indexRepository = em.getRepository(IndexData)
const indexRelationRepository = em.getRepository(IndexRelation)
const { data: data_, entityProperties } = PostgresProvider.parseData(
data,
schemaEntityObjectRepresentation
)
/**
* Retrieve the property that represent the foreign key related to the parent entity of the link entity.
* Then from the service name of the parent entity, retrieve the entity name using the linkable keys.
*/
const parentPropertyId =
schemaEntityObjectRepresentation.moduleConfig.relationships![0].foreignKey
const parentServiceName =
schemaEntityObjectRepresentation.moduleConfig.relationships![0]
.serviceName
const parentEntityName = (
this.schemaObjectRepresentation_._serviceNameModuleConfigMap[
parentServiceName
] as EntityNameModuleConfigMap[0]
).linkableKeys?.[parentPropertyId]
if (!parentEntityName) {
throw new Error(
`IndexModule error, unable to handle attach event for ${entity}. The parent entity name could not be found using the linkable keys from the module ${parentServiceName}.`
)
}
/**
* Retrieve the property that represent the foreign key related to the child entity of the link entity.
* Then from the service name of the child entity, retrieve the entity name using the linkable keys.
*/
const childPropertyId =
schemaEntityObjectRepresentation.moduleConfig.relationships![1].foreignKey
const childServiceName =
schemaEntityObjectRepresentation.moduleConfig.relationships![1]
.serviceName
const childEntityName = (
this.schemaObjectRepresentation_._serviceNameModuleConfigMap[
childServiceName
] as EntityNameModuleConfigMap[0]
).linkableKeys?.[childPropertyId]
if (!childEntityName) {
throw new Error(
`IndexModule error, unable to handle attach event for ${entity}. The child entity name could not be found using the linkable keys from the module ${childServiceName}.`
)
}
for (const entityData of data_) {
/**
* Clean the link entity data to only keep the properties that are defined in the schema
*/
const cleanedEntityData = entityProperties.reduce((acc, property) => {
acc[property] = entityData[property]
return acc
}, {}) as TData
await indexRepository.upsert({
id: cleanedEntityData.id,
name: entity,
data: cleanedEntityData,
})
/**
* Create the index relation entries for the parent entity and the child entity
*/
const parentIndexRelationEntry = indexRelationRepository.create({
parent_id: entityData[parentPropertyId] as string,
parent_name: parentEntityName,
child_id: cleanedEntityData.id,
child_name: entity,
pivot: `${parentEntityName}-${entity}`,
})
const childIndexRelationEntry = indexRelationRepository.create({
parent_id: cleanedEntityData.id,
parent_name: entity,
child_id: entityData[childPropertyId] as string,
child_name: childEntityName,
pivot: `${entity}-${childEntityName}`,
})
indexRelationRepository.persist([
parentIndexRelationEntry,
childIndexRelationEntry,
])
}
}
/**
* Event emitted from the link modules to detach a link entity from its parent and child entities from the linked modules.
* @param entity
* @param data
* @param schemaEntityObjectRepresentation
* @param sharedContext
* @protected
*/
@InjectTransactionManager("baseRepository_")
protected async onDetach<
TData extends { id: string; [key: string]: unknown }
>(
{
entity,
data,
schemaEntityObjectRepresentation,
}: {
entity: string
data: TData | TData[]
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
},
@MedusaContext() sharedContext: Context = {}
) {
const { transactionManager: em } = sharedContext as {
transactionManager: SqlEntityManager
}
const indexRepository = em.getRepository(IndexData)
const indexRelationRepository = em.getRepository(IndexRelation)
const { data: data_ } = PostgresProvider.parseData(
data,
schemaEntityObjectRepresentation
)
const ids = data_.map((entityData) => entityData.id)
await indexRepository.nativeDelete({
id: { $in: ids },
name: entity,
})
await indexRelationRepository.nativeDelete({
$or: [
{
parent_id: { $in: ids },
parent_name: entity,
},
{
child_id: { $in: ids },
child_name: entity,
},
],
})
}
}

View File

@@ -0,0 +1,169 @@
import {
ModuleJoinerConfig,
ModulesSdkTypes,
Subscriber,
} from "@medusajs/types"
/**
* Represents the module options that can be provided
*/
export interface IndexModuleOptions {
customAdapter?: {
constructor: new (...args: any[]) => any
options: any
}
defaultAdapterOptions?: ModulesSdkTypes.ModuleServiceInitializeOptions
schema: string
}
export type SchemaObjectEntityRepresentation = {
/**
* The name of the type/entity in the schema
*/
entity: string
/**
* All parents a type/entity refers to in the schema
* or through links
*/
parents: {
/**
* The reference to the schema object representation
* of the parent
*/
ref: SchemaObjectEntityRepresentation
/**
* When a link is inferred between two types/entities
* we are configuring the link tree, and therefore we are
* storing the reference to the parent type/entity within the
* schema which defer from the true parent from a pure entity
* point of view
*/
inSchemaRef?: SchemaObjectEntityRepresentation
/**
* The property the data should be assigned to in the parent
*/
targetProp: string
/**
* Are the data expected to be a list or not
*/
isList?: boolean
}[]
/**
* The default fields to query for the type/entity
*/
fields: string[]
/**
* @Listerners directive is required and all listeners found
* for the type will be stored here
*/
listeners: string[]
/**
* The alias for the type/entity retrieved in the corresponding
* module
*/
alias: string
/**
* The module joiner config corresponding to the module the type/entity
* refers to
*/
moduleConfig: ModuleJoinerConfig
}
export type SchemaPropertiesMap = {
[key: string]: {
shortCutOf?: string
ref: SchemaObjectEntityRepresentation
}
}
export type EntityNameModuleConfigMap = {
[key: string]: ModuleJoinerConfig
}
/**
* Represents the schema objects representation once the schema has been processed
*/
export type SchemaObjectRepresentation =
| {
[key: string]: SchemaObjectEntityRepresentation
}
| {
_schemaPropertiesMap: SchemaPropertiesMap
_serviceNameModuleConfigMap: EntityNameModuleConfigMap
}
export const schemaObjectRepresentationPropertiesToOmit = [
"_schemaPropertiesMap",
"_serviceNameModuleConfigMap",
]
/**
* Represents the storage provider interface,
*/
export interface StorageProvider {
new (
container: { [key: string]: any },
storageProviderOptions: unknown & {
schemaObjectRepresentation: SchemaObjectRepresentation
},
moduleOptions: IndexModuleOptions
): StorageProvider
onApplicationStart?(): Promise<void>
query(...args): unknown
queryAndCount(...args): unknown
consumeEvent(
schemaEntityObjectRepresentation: SchemaObjectEntityRepresentation
): Subscriber
}
export type Select = {
[key: string]: boolean | Select | Select[]
}
export type Where = {
[key: string]: any
}
export type OrderBy = {
[path: string]: OrderBy | "ASC" | "DESC" | 1 | -1 | true | false
}
export type QueryFormat = {
select: Select
where?: Where
joinWhere?: Where
}
export type QueryOptions = {
skip?: number
take?: number
orderBy?: OrderBy | OrderBy[]
keepFilteredEntities?: boolean
}
// Preventing infinite depth
type ResultSetLimit = [never | 0 | 1 | 2]
export type Resultset<Select, Prev extends number = 3> = Prev extends never
? never
: {
[key in keyof Select]: Select[key] extends boolean
? string
: Select[key] extends Select[]
? Resultset<Select[key][0], ResultSetLimit[Prev]>[]
: Select[key] extends {}
? Resultset<Select[key], ResultSetLimit[Prev]>
: unknown
}

View File

@@ -0,0 +1,734 @@
import { makeExecutableSchema } from "@graphql-tools/schema"
import {
cleanGraphQLSchema,
gqlGetFieldsAndRelations,
MedusaModule,
} from "@medusajs/modules-sdk"
import {
JoinerServiceConfigAlias,
ModuleJoinerConfig,
ModuleJoinerRelationship,
} from "@medusajs/types"
import { CommonEvents } from "@medusajs/utils"
import {
SchemaObjectEntityRepresentation,
SchemaObjectRepresentation,
schemaObjectRepresentationPropertiesToOmit,
} from "@types"
import { Kind, ObjectTypeDefinitionNode } from "graphql/index"
export const CustomDirectives = {
Listeners: {
configurationPropertyName: "listeners",
isRequired: true,
name: "Listeners",
directive: "@Listeners",
definition: "directive @Listeners (values: [String!]) on OBJECT",
},
}
function makeSchemaExecutable(inputSchema: string) {
const { schema: cleanedSchema } = cleanGraphQLSchema(inputSchema)
return makeExecutableSchema({ typeDefs: cleanedSchema })
}
function extractNameFromAlias(
alias: JoinerServiceConfigAlias | JoinerServiceConfigAlias[]
) {
const alias_ = Array.isArray(alias) ? alias[0] : alias
const names = Array.isArray(alias_?.name) ? alias_?.name : [alias_?.name]
return names[0]
}
function retrieveAliasForEntity(entityName: string, aliases) {
aliases = aliases ? (Array.isArray(aliases) ? aliases : [aliases]) : []
for (const alias of aliases) {
const names = Array.isArray(alias.name) ? alias.name : [alias.name]
if (alias.entity === entityName) {
return names[0]
}
for (const name of names) {
if (name.toLowerCase() === entityName.toLowerCase()) {
return name
}
}
}
}
function retrieveModuleAndAlias(entityName, moduleJoinerConfigs) {
let relatedModule
let alias
for (const moduleJoinerConfig of moduleJoinerConfigs) {
const moduleSchema = moduleJoinerConfig.schema
const moduleAliases = moduleJoinerConfig.alias
/**
* If the entity exist in the module schema, then the current module is the
* one we are looking for.
*
* If the module does not have any schema, then we need to base the search
* on the provided aliases. in any case, we try to get both
*/
if (moduleSchema) {
const executableSchema = makeSchemaExecutable(moduleSchema)
const entitiesMap = executableSchema.getTypeMap()
if (entitiesMap[entityName]) {
relatedModule = moduleJoinerConfig
}
}
if (relatedModule && moduleAliases) {
alias = retrieveAliasForEntity(entityName, moduleJoinerConfig.alias)
}
if (relatedModule) {
break
}
}
if (!relatedModule) {
return { relatedModule: null, alias: null }
}
if (!alias) {
throw new Error(
`Index Module error, the module ${relatedModule?.serviceName} has a schema but does not have any alias for the entity ${entityName}. Please add an alias to the module configuration and the entity it correspond to in the args under the entity property.`
)
}
return { relatedModule, alias }
}
// TODO: rename util
function retrieveLinkModuleAndAlias({
primaryEntity,
primaryModuleConfig,
foreignEntity,
foreignModuleConfig,
moduleJoinerConfigs,
}: {
primaryEntity: string
primaryModuleConfig: ModuleJoinerConfig
foreignEntity: string
foreignModuleConfig: ModuleJoinerConfig
moduleJoinerConfigs: ModuleJoinerConfig[]
}): {
entityName: string
alias: string
linkModuleConfig: ModuleJoinerConfig
intermediateEntityNames: string[]
}[] {
const linkModulesMetadata: {
entityName: string
alias: string
linkModuleConfig: ModuleJoinerConfig
intermediateEntityNames: string[]
}[] = []
for (const linkModuleJoinerConfig of moduleJoinerConfigs.filter(
(config) => config.isLink && !config.isReadOnlyLink
)) {
const linkPrimary =
linkModuleJoinerConfig.relationships![0] as ModuleJoinerRelationship
const linkForeign =
linkModuleJoinerConfig.relationships![1] as ModuleJoinerRelationship
if (
linkPrimary.serviceName === primaryModuleConfig.serviceName &&
linkForeign.serviceName === foreignModuleConfig.serviceName
) {
const primaryEntityLinkableKey = linkPrimary.foreignKey
const isTheForeignKeyEntityEqualPrimaryEntity =
primaryModuleConfig.linkableKeys?.[primaryEntityLinkableKey] ===
primaryEntity
const foreignEntityLinkableKey = linkForeign.foreignKey
const isTheForeignKeyEntityEqualForeignEntity =
foreignModuleConfig.linkableKeys?.[foreignEntityLinkableKey] ===
foreignEntity
const linkName = linkModuleJoinerConfig.extends?.find((extend) => {
return (
extend.serviceName === primaryModuleConfig.serviceName &&
extend.relationship.primaryKey === primaryEntityLinkableKey
)
})?.relationship.serviceName
if (!linkName) {
throw new Error(
`Index Module error, unable to retrieve the link module name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the extend relationship service name is set correctly`
)
}
if (!linkModuleJoinerConfig.alias?.[0]?.entity) {
throw new Error(
`Index Module error, unable to retrieve the link module entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the link module alias has an entity property in the args.`
)
}
if (
isTheForeignKeyEntityEqualPrimaryEntity &&
isTheForeignKeyEntityEqualForeignEntity
) {
/**
* The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module
*/
linkModulesMetadata.push({
entityName: linkModuleJoinerConfig.alias[0].entity,
alias: extractNameFromAlias(linkModuleJoinerConfig.alias),
linkModuleConfig: linkModuleJoinerConfig,
intermediateEntityNames: [],
})
} else {
const intermediateEntityName =
foreignModuleConfig.linkableKeys![foreignEntityLinkableKey]
if (!foreignModuleConfig.schema) {
throw new Error(
`Index Module error, unable to retrieve the intermediate entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the foreign module ${foreignModuleConfig.serviceName} has a schema.`
)
}
const executableSchema = makeSchemaExecutable(
foreignModuleConfig.schema
)
const entitiesMap = executableSchema.getTypeMap()
let intermediateEntities: string[] = []
let foundCount = 0
const isForeignEntityChildOfIntermediateEntity = (
entityName
): boolean => {
for (const entityType of Object.values(entitiesMap)) {
if (
entityType.astNode?.kind === "ObjectTypeDefinition" &&
entityType.astNode?.fields?.some((field) => {
return (field.type as any)?.type?.name?.value === entityName
})
) {
if (entityType.name === intermediateEntityName) {
++foundCount
return true
} else {
const test = isForeignEntityChildOfIntermediateEntity(
entityType.name
)
if (test) {
intermediateEntities.push(entityType.name)
}
}
}
}
return false
}
isForeignEntityChildOfIntermediateEntity(foreignEntity)
if (foundCount !== 1) {
throw new Error(
`Index Module error, unable to retrieve the intermediate entities for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName} between ${foreignEntity} and ${intermediateEntityName}. Multiple paths or no path found. Please check your schema in ${foreignModuleConfig.serviceName}`
)
}
intermediateEntities.push(intermediateEntityName!)
/**
* The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module
*/
linkModulesMetadata.push({
entityName: linkModuleJoinerConfig.alias[0].entity,
alias: extractNameFromAlias(linkModuleJoinerConfig.alias),
linkModuleConfig: linkModuleJoinerConfig,
intermediateEntityNames: intermediateEntities,
})
}
}
}
if (!linkModulesMetadata.length) {
// TODO: change to use the logger
console.warn(
`Index Module warning, unable to retrieve the link module that correspond to the entities ${primaryEntity} - ${foreignEntity}.`
)
}
return linkModulesMetadata
}
function getObjectRepresentationRef(
entityName,
{ objectRepresentationRef }
): SchemaObjectEntityRepresentation {
return (objectRepresentationRef[entityName] ??= {
entity: entityName,
parents: [],
alias: "",
listeners: [],
moduleConfig: null,
fields: [],
})
}
function setCustomDirectives(currentObjectRepresentationRef, directives) {
for (const customDirectiveConfiguration of Object.values(CustomDirectives)) {
const directive = directives.find(
(typeDirective) =>
typeDirective.name.value === customDirectiveConfiguration.name
)
if (!directive) {
return
}
// Only support array directive value for now
currentObjectRepresentationRef[
customDirectiveConfiguration.configurationPropertyName
] = ((directive.arguments[0].value as any)?.values ?? []).map(
(v) => v.value
)
}
}
function processEntity(
entityName: string,
{
entitiesMap,
moduleJoinerConfigs,
objectRepresentationRef,
}: {
entitiesMap: any
moduleJoinerConfigs: ModuleJoinerConfig[]
objectRepresentationRef: SchemaObjectRepresentation
}
) {
/**
* Get the reference to the object representation for the current entity.
*/
const currentObjectRepresentationRef = getObjectRepresentationRef(
entityName,
{
objectRepresentationRef,
}
)
/**
* Retrieve and set the custom directives for the current entity.
*/
setCustomDirectives(
currentObjectRepresentationRef,
entitiesMap[entityName].astNode?.directives ?? []
)
currentObjectRepresentationRef.fields =
gqlGetFieldsAndRelations(entitiesMap, entityName) ?? []
/**
* Retrieve the module and alias for the current entity.
*/
const { relatedModule: currentEntityModule, alias } = retrieveModuleAndAlias(
entityName,
moduleJoinerConfigs
)
if (
!currentEntityModule &&
currentObjectRepresentationRef.listeners.length > 0
) {
const example = JSON.stringify({
alias: [
{
name: "entity-alias",
entity: entityName,
},
],
})
throw new Error(
`Index Module error, unable to retrieve the module that corresponds to the entity ${entityName}.\nPlease add the entity to the module schema or add an alias to the joiner config like the example below:\n${example}`
)
}
if (currentEntityModule) {
objectRepresentationRef._serviceNameModuleConfigMap[
currentEntityModule.serviceName
] = currentEntityModule
currentObjectRepresentationRef.moduleConfig = currentEntityModule
currentObjectRepresentationRef.alias = alias
}
/**
* Retrieve the parent entities for the current entity.
*/
const schemaParentEntity = Object.values(entitiesMap).filter((value: any) => {
return (
value.astNode &&
(value.astNode as ObjectTypeDefinitionNode).fields?.some((field: any) => {
let currentType = field.type
while (currentType.type) {
currentType = currentType.type
}
return currentType.name?.value === entityName
})
)
})
if (!schemaParentEntity.length) {
return
}
/**
* If the current entity has parent entities, then we need to process them.
*/
const parentEntityNames = schemaParentEntity.map((parent: any) => {
return parent.name
})
for (const parent of parentEntityNames) {
/**
* Retrieve the parent entity field in the schema
*/
const entityFieldInParent = (
entitiesMap[parent].astNode as any
)?.fields?.find((field) => {
let currentType = field.type
while (currentType.type) {
currentType = currentType.type
}
return currentType.name?.value === entityName
})
const isEntityListInParent =
entityFieldInParent.type.kind === Kind.LIST_TYPE
const entityTargetPropertyNameInParent = entityFieldInParent.name.value
/**
* Retrieve the parent entity object representation reference.
*/
const parentObjectRepresentationRef = getObjectRepresentationRef(parent, {
objectRepresentationRef,
})
const parentModuleConfig = parentObjectRepresentationRef.moduleConfig
// If the entity is not part of any module, just set the parent and continue
if (!currentObjectRepresentationRef.moduleConfig) {
currentObjectRepresentationRef.parents.push({
ref: parentObjectRepresentationRef,
targetProp: entityTargetPropertyNameInParent,
isList: isEntityListInParent,
})
continue
}
/**
* If the parent entity and the current entity are part of the same servive then configure the parent and
* add the parent id as a field to the current entity.
*/
if (
currentObjectRepresentationRef.moduleConfig.serviceName ===
parentModuleConfig.serviceName ||
parentModuleConfig.isLink
) {
currentObjectRepresentationRef.parents.push({
ref: parentObjectRepresentationRef,
targetProp: entityTargetPropertyNameInParent,
isList: isEntityListInParent,
})
currentObjectRepresentationRef.fields.push(
parentObjectRepresentationRef.alias + ".id"
)
} else {
/**
* If the parent entity and the current entity are not part of the same service then we need to
* find the link module that join them.
*/
const linkModuleMetadatas = retrieveLinkModuleAndAlias({
primaryEntity: parentObjectRepresentationRef.entity,
primaryModuleConfig: parentModuleConfig,
foreignEntity: currentObjectRepresentationRef.entity,
foreignModuleConfig: currentEntityModule,
moduleJoinerConfigs,
})
for (const linkModuleMetadata of linkModuleMetadatas) {
const linkObjectRepresentationRef = getObjectRepresentationRef(
linkModuleMetadata.entityName,
{ objectRepresentationRef }
)
objectRepresentationRef._serviceNameModuleConfigMap[
linkModuleMetadata.linkModuleConfig.serviceName ||
linkModuleMetadata.entityName
] = currentEntityModule
/**
* Add the schema parent entity as a parent to the link module and configure it.
*/
linkObjectRepresentationRef.parents = [
{
ref: parentObjectRepresentationRef,
targetProp: linkModuleMetadata.alias,
},
]
linkObjectRepresentationRef.alias = linkModuleMetadata.alias
linkObjectRepresentationRef.listeners = [
`${linkModuleMetadata.entityName}.${CommonEvents.ATTACHED}`,
`${linkModuleMetadata.entityName}.${CommonEvents.DETACHED}`,
]
linkObjectRepresentationRef.moduleConfig =
linkModuleMetadata.linkModuleConfig
linkObjectRepresentationRef.fields = [
"id",
...linkModuleMetadata.linkModuleConfig
.relationships!.map(
(relationship) =>
[
parentModuleConfig.serviceName,
currentEntityModule.serviceName,
].includes(relationship.serviceName) && relationship.foreignKey
)
.filter((v): v is string => Boolean(v)),
]
/**
* If the current entity is not the entity that is used to join the link module and the parent entity
* then we need to add the new entity that join them and then add the link as its parent
* before setting the new entity as the true parent of the current entity.
*/
for (
let i = linkModuleMetadata.intermediateEntityNames.length - 1;
i >= 0;
--i
) {
const intermediateEntityName =
linkModuleMetadata.intermediateEntityNames[i]
const isLastIntermediateEntity =
i === linkModuleMetadata.intermediateEntityNames.length - 1
const parentIntermediateEntityRef = isLastIntermediateEntity
? linkObjectRepresentationRef
: objectRepresentationRef[
linkModuleMetadata.intermediateEntityNames[i + 1]
]
const {
relatedModule: intermediateEntityModule,
alias: intermediateEntityAlias,
} = retrieveModuleAndAlias(
intermediateEntityName,
moduleJoinerConfigs
)
const intermediateEntityObjectRepresentationRef =
getObjectRepresentationRef(intermediateEntityName, {
objectRepresentationRef,
})
objectRepresentationRef._serviceNameModuleConfigMap[
intermediateEntityModule.serviceName
] = intermediateEntityModule
intermediateEntityObjectRepresentationRef.parents.push({
ref: parentIntermediateEntityRef,
targetProp: intermediateEntityAlias,
isList: true, // TODO: check if it is a list in retrieveLinkModuleAndAlias and return the intermediate entity names + isList for each
})
intermediateEntityObjectRepresentationRef.alias =
intermediateEntityAlias
intermediateEntityObjectRepresentationRef.listeners = [
intermediateEntityName + "." + CommonEvents.CREATED,
intermediateEntityName + "." + CommonEvents.UPDATED,
]
intermediateEntityObjectRepresentationRef.moduleConfig =
intermediateEntityModule
intermediateEntityObjectRepresentationRef.fields = ["id"]
/**
* We push the parent id only between intermediate entities but not between intermediate and link
*/
if (!isLastIntermediateEntity) {
intermediateEntityObjectRepresentationRef.fields.push(
parentIntermediateEntityRef.alias + ".id"
)
}
}
/**
* If there is any intermediate entity then we need to set the last one as the parent field for the current entity.
* otherwise there is not need to set the link id field into the current entity.
*/
let currentParentIntermediateRef = linkObjectRepresentationRef
if (linkModuleMetadata.intermediateEntityNames.length) {
currentParentIntermediateRef =
objectRepresentationRef[
linkModuleMetadata.intermediateEntityNames[0]
]
currentObjectRepresentationRef.fields.push(
currentParentIntermediateRef.alias + ".id"
)
}
currentObjectRepresentationRef.parents.push({
ref: currentParentIntermediateRef,
inSchemaRef: parentObjectRepresentationRef,
targetProp: entityTargetPropertyNameInParent,
isList: isEntityListInParent,
})
}
}
}
}
/**
* Build a special object which will be used to retrieve the correct
* object representation using path tree
*
* @example
* {
* _schemaPropertiesMap: {
* "product": <ProductRef>
* "product.variants": <ProductVariantRef>
* }
* }
*/
function buildAliasMap(objectRepresentation: SchemaObjectRepresentation) {
const aliasMap: SchemaObjectRepresentation["_schemaPropertiesMap"] = {}
function recursivelyBuildAliasPath(
current,
alias = "",
aliases: { alias: string; shortCutOf?: string }[] = []
): { alias: string; shortCutOf?: string }[] {
if (current.parents?.length) {
for (const parentEntity of current.parents) {
/**
* Here we build the alias from child to parent to get it as parent to child
*/
const _aliases = recursivelyBuildAliasPath(
parentEntity.ref,
`${parentEntity.targetProp}${alias ? "." + alias : ""}`
).map((alias) => ({ alias: alias.alias }))
aliases.push(..._aliases)
/**
* Now if there is a inSchemaRef it means that we had inferred a link module
* and we want to get the alias path as it would be in the schema provided
* and it become the short cut path of the full path above
*/
if (parentEntity.inSchemaRef) {
const shortCutOf = _aliases.map((a) => a.alias)[0]
const _aliasesShortCut = recursivelyBuildAliasPath(
parentEntity.inSchemaRef,
`${parentEntity.targetProp}${alias ? "." + alias : ""}`
).map((alias_) => {
return {
alias: alias_.alias,
// It has to be the same entry point
shortCutOf:
shortCutOf.split(".")[0] === alias_.alias.split(".")[0]
? shortCutOf
: undefined,
}
})
aliases.push(..._aliasesShortCut)
}
}
}
aliases.push({ alias: current.alias + (alias ? "." + alias : "") })
return aliases
}
for (const objectRepresentationKey of Object.keys(
objectRepresentation
).filter(
(key) => !schemaObjectRepresentationPropertiesToOmit.includes(key)
)) {
const entityRepresentationRef =
objectRepresentation[objectRepresentationKey]
const aliases = recursivelyBuildAliasPath(entityRepresentationRef)
for (const alias of aliases) {
aliasMap[alias.alias] = {
ref: entityRepresentationRef,
}
if (alias.shortCutOf) {
aliasMap[alias.alias]["shortCutOf"] = alias.shortCutOf
}
}
}
return aliasMap
}
/**
* This util build an internal representation object from the provided schema.
* It will resolve all modules, fields, link module representation to build
* the appropriate representation for the index module.
*
* This representation will be used to re construct the expected output object from a search
* but can also be used for anything since the relation tree is available through ref.
*
* @param schema
*/
export function buildSchemaObjectRepresentation(
schema
): [SchemaObjectRepresentation, Record<string, any>] {
const moduleJoinerConfigs = MedusaModule.getAllJoinerConfigs()
const augmentedSchema = CustomDirectives.Listeners.definition + schema
const executableSchema = makeSchemaExecutable(augmentedSchema)
const entitiesMap = executableSchema.getTypeMap()
const objectRepresentation = {
_serviceNameModuleConfigMap: {},
} as SchemaObjectRepresentation
Object.entries(entitiesMap).forEach(([entityName, entityMapValue]) => {
if (!entityMapValue.astNode) {
return
}
processEntity(entityName, {
entitiesMap,
moduleJoinerConfigs,
objectRepresentationRef: objectRepresentation,
})
})
objectRepresentation._schemaPropertiesMap =
buildAliasMap(objectRepresentation)
return [objectRepresentation, entitiesMap]
}

View File

@@ -0,0 +1,46 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
SchemaObjectRepresentation,
schemaObjectRepresentationPropertiesToOmit,
} from "../types"
export async function createPartitions(
schemaObjectRepresentation: SchemaObjectRepresentation,
manager: SqlEntityManager
): Promise<void> {
const activeSchema = manager.config.get("schema")
? `"${manager.config.get("schema")}".`
: ""
const partitions = Object.keys(schemaObjectRepresentation)
.filter(
(key) =>
!schemaObjectRepresentationPropertiesToOmit.includes(key) &&
schemaObjectRepresentation[key].listeners.length > 0
)
.map((key) => {
const cName = key.toLowerCase()
const part: string[] = []
part.push(
`CREATE TABLE IF NOT EXISTS ${activeSchema}cat_${cName} PARTITION OF ${activeSchema}index_data FOR VALUES IN ('${key}')`
)
for (const parent of schemaObjectRepresentation[key].parents) {
const pKey = `${parent.ref.entity}-${key}`
const pName = `${parent.ref.entity}${key}`.toLowerCase()
part.push(
`CREATE TABLE IF NOT EXISTS ${activeSchema}cat_pivot_${pName} PARTITION OF ${activeSchema}index_relation FOR VALUES IN ('${pKey}')`
)
}
return part
})
.flat()
if (!partitions.length) {
return
}
partitions.push(`analyse ${activeSchema}index_data`)
partitions.push(`analyse ${activeSchema}index_relation`)
await manager.execute(partitions.join("; "))
}

View File

@@ -0,0 +1,19 @@
export const defaultSchema = `
type Product @Listeners(values: ["Product.product.created", "Product.product.updated", "Product.product.deleted"]) {
id: String
title: String
variants: [ProductVariant]
}
type ProductVariant @Listeners(values: ["Product.product-variant.created", "Product.product-variant.updated", "Product.product-variant.deleted"]) {
id: String
product_id: String
sku: String
prices: [Price]
}
type Price @Listeners(values: ["Pricing.price.created", "Pricing.price.updated", "Pricing.price.deleted"]) {
amount: Int
currency_code: String
}
`

View File

@@ -0,0 +1,3 @@
export * from "./query-builder"
export * from "./create-partitions"
export * from "./build-config"

View File

@@ -0,0 +1,628 @@
import { isObject, isString } from "@medusajs/utils"
import { GraphQLList } from "graphql"
import { Knex } from "knex"
import {
OrderBy,
QueryFormat,
QueryOptions,
SchemaObjectRepresentation,
SchemaPropertiesMap,
Select,
} from "../types"
export class QueryBuilder {
private readonly structure: Select
private readonly entityMap: Record<string, any>
private readonly knex: Knex
private readonly selector: QueryFormat
private readonly options?: QueryOptions
private readonly schema: SchemaObjectRepresentation
constructor(args: {
schema: SchemaObjectRepresentation
entityMap: Record<string, any>
knex: Knex
selector: QueryFormat
options?: QueryOptions
}) {
this.schema = args.schema
this.entityMap = args.entityMap
this.selector = args.selector
this.options = args.options
this.knex = args.knex
this.structure = this.selector.select
}
private getStructureKeys(structure) {
return Object.keys(structure ?? {}).filter((key) => key !== "entity")
}
private getEntity(path): SchemaPropertiesMap[0] {
if (!this.schema._schemaPropertiesMap[path]) {
throw new Error(`Could not find entity for path: ${path}`)
}
return this.schema._schemaPropertiesMap[path]
}
private getGraphQLType(path, field) {
const entity = this.getEntity(path)?.ref?.entity
const fieldRef = this.entityMap[entity]._fields[field]
if (!fieldRef) {
throw new Error(`Field ${field} not found in the entityMap.`)
}
let currentType = fieldRef.type
let isArray = false
while (currentType.ofType) {
if (currentType instanceof GraphQLList) {
isArray = true
}
currentType = currentType.ofType
}
return currentType.name + (isArray ? "[]" : "")
}
private transformValueToType(path, field, value) {
if (value === null) {
return null
}
const typeToFn = {
Int: (val) => parseInt(val, 10),
Float: (val) => parseFloat(val),
String: (val) => String(val),
Boolean: (val) => Boolean(val),
ID: (val) => String(val),
Date: (val) => new Date(val).toISOString(),
Time: (val) => new Date(`1970-01-01T${val}Z`).toISOString(),
}
const fullPath = [path, ...field]
const prop = fullPath.pop()
const fieldPath = fullPath.join(".")
const graphqlType = this.getGraphQLType(fieldPath, prop).replace("[]", "")
const fn = typeToFn[graphqlType]
if (Array.isArray(value)) {
return value.map((v) => (!fn ? v : fn(v)))
}
return !fn ? value : fn(value)
}
private getPostgresCastType(path, field) {
const graphqlToPostgresTypeMap = {
Int: "::int",
Float: "::double precision",
Boolean: "::boolean",
Date: "::timestamp",
Time: "::time",
"": "",
}
const fullPath = [path, ...field]
const prop = fullPath.pop()
const fieldPath = fullPath.join(".")
let graphqlType = this.getGraphQLType(fieldPath, prop)
const isList = graphqlType.endsWith("[]")
graphqlType = graphqlType.replace("[]", "")
return (
(graphqlToPostgresTypeMap[graphqlType] ?? "") + (isList ? "[]" : "") ?? ""
)
}
private parseWhere(
aliasMapping: { [path: string]: string },
obj: object,
builder: Knex.QueryBuilder
) {
const OPERATOR_MAP = {
$eq: "=",
$lt: "<",
$gt: ">",
$lte: "<=",
$gte: ">=",
$ne: "!=",
$in: "IN",
$is: "IS",
$like: "LIKE",
$ilike: "ILIKE",
}
const keys = Object.keys(obj)
const getPathAndField = (key: string) => {
const path = key.split(".")
const field = [path.pop()]
while (!aliasMapping[path.join(".")] && path.length > 0) {
field.unshift(path.pop())
}
const attr = path.join(".")
return { field, attr }
}
const getPathOperation = (
attr: string,
path: string[],
value: unknown
): string => {
const partialPath = path.length > 1 ? path.slice(0, -1) : path
const val = this.transformValueToType(attr, partialPath, value)
const result = path.reduceRight((acc, key) => {
return { [key]: acc }
}, val)
return JSON.stringify(result)
}
keys.forEach((key) => {
let value = obj[key]
if ((key === "$and" || key === "$or") && !Array.isArray(value)) {
value = [value]
}
if (key === "$and" && Array.isArray(value)) {
builder.where((qb) => {
value.forEach((cond) => {
qb.andWhere((subBuilder) =>
this.parseWhere(aliasMapping, cond, subBuilder)
)
})
})
} else if (key === "$or" && Array.isArray(value)) {
builder.where((qb) => {
value.forEach((cond) => {
qb.orWhere((subBuilder) =>
this.parseWhere(aliasMapping, cond, subBuilder)
)
})
})
} else if (isObject(value) && !Array.isArray(value)) {
const subKeys = Object.keys(value)
subKeys.forEach((subKey) => {
let operator = OPERATOR_MAP[subKey]
if (operator) {
const { field, attr } = getPathAndField(key)
const nested = new Array(field.length).join("->?")
const subValue = this.transformValueToType(
attr,
field,
value[subKey]
)
const castType = this.getPostgresCastType(attr, field)
const val = operator === "IN" ? subValue : [subValue]
if (operator === "=" && subValue === null) {
operator = "IS"
}
if (operator === "=") {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
subValue
)}'::jsonb`
)
} else {
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`,
[...field, ...val]
)
}
} else {
throw new Error(`Unsupported operator: ${subKey}`)
}
})
} else {
const { field, attr } = getPathAndField(key)
const nested = new Array(field.length).join("->?")
value = this.transformValueToType(attr, field, value)
if (Array.isArray(value)) {
const castType = this.getPostgresCastType(attr, field)
const inPlaceholders = value.map(() => "?").join(",")
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} IN (${inPlaceholders})`,
[...field, ...value]
)
} else {
const operator = value === null ? "IS" : "="
if (operator === "=") {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
value
)}'::jsonb`
)
} else {
const castType = this.getPostgresCastType(attr, field)
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`,
[...field, value]
)
}
}
}
})
return builder
}
private buildQueryParts(
structure: Select,
parentAlias: string,
parentEntity: string,
parentProperty: string,
aliasPath: string[] = [],
level = 0,
aliasMapping: { [path: string]: string } = {}
): string[] {
const currentAliasPath = [...aliasPath, parentProperty].join(".")
const entities = this.getEntity(currentAliasPath)
const mainEntity = entities.ref.entity
const mainAlias = mainEntity.toLowerCase() + level
const allEntities: any[] = []
if (!entities.shortCutOf) {
allEntities.push({
entity: mainEntity,
parEntity: parentEntity,
parAlias: parentAlias,
alias: mainAlias,
})
} else {
const intermediateAlias = entities.shortCutOf.split(".")
for (let i = intermediateAlias.length - 1, x = 0; i >= 0; i--, x++) {
const intermediateEntity = this.getEntity(intermediateAlias.join("."))
intermediateAlias.pop()
if (intermediateEntity.ref.entity === parentEntity) {
break
}
const parentIntermediateEntity = this.getEntity(
intermediateAlias.join(".")
)
const alias =
intermediateEntity.ref.entity.toLowerCase() + level + "_" + x
const parAlias =
parentIntermediateEntity.ref.entity === parentEntity
? parentAlias
: parentIntermediateEntity.ref.entity.toLowerCase() +
level +
"_" +
(x + 1)
if (x === 0) {
aliasMapping[currentAliasPath] = alias
}
allEntities.unshift({
entity: intermediateEntity.ref.entity,
parEntity: parentIntermediateEntity.ref.entity,
parAlias,
alias,
})
}
}
let queryParts: string[] = []
for (const join of allEntities) {
const { alias, entity, parEntity, parAlias } = join
aliasMapping[currentAliasPath] = alias
if (level > 0) {
const subQuery = this.knex.queryBuilder()
const knex = this.knex
subQuery
.select(`${alias}.id`, `${alias}.data`)
.from("index_data AS " + alias)
.join(`index_relation AS ${alias}_ref`, function () {
this.on(
`${alias}_ref.pivot`,
"=",
knex.raw("?", [`${parEntity}-${entity}`])
)
.andOn(`${alias}_ref.parent_id`, "=", `${parAlias}.id`)
.andOn(`${alias}.id`, "=", `${alias}_ref.child_id`)
})
.where(`${alias}.name`, "=", knex.raw("?", [entity]))
const joinWhere = this.selector.joinWhere ?? {}
const joinKey = Object.keys(joinWhere).find((key) => {
const k = key.split(".")
k.pop()
return k.join(".") === currentAliasPath
})
if (joinKey) {
this.parseWhere(
aliasMapping,
{ [joinKey]: joinWhere[joinKey] },
subQuery
)
}
queryParts.push(`LEFT JOIN LATERAL (
${subQuery.toQuery()}
) ${alias} ON TRUE`)
}
}
const children = this.getStructureKeys(structure)
for (const child of children) {
const childStructure = structure[child] as Select
queryParts = queryParts.concat(
this.buildQueryParts(
childStructure,
mainAlias,
mainEntity,
child,
aliasPath.concat(parentProperty),
level + 1,
aliasMapping
)
)
}
return queryParts
}
private buildSelectParts(
structure: Select,
parentProperty: string,
aliasMapping: { [path: string]: string },
aliasPath: string[] = [],
selectParts: object = {}
): object {
const currentAliasPath = [...aliasPath, parentProperty].join(".")
const alias = aliasMapping[currentAliasPath]
selectParts[currentAliasPath] = `${alias}.data`
selectParts[currentAliasPath + ".id"] = `${alias}.id`
const children = this.getStructureKeys(structure)
for (const child of children) {
const childStructure = structure[child] as Select
this.buildSelectParts(
childStructure,
child,
aliasMapping,
aliasPath.concat(parentProperty),
selectParts
)
}
return selectParts
}
private transformOrderBy(arr: (object | string)[]): OrderBy {
const result = {}
const map = new Map()
map.set(true, "ASC")
map.set(1, "ASC")
map.set("ASC", "ASC")
map.set(false, "DESC")
map.set(-1, "DESC")
map.set("DESC", "DESC")
function nested(obj, prefix = "") {
const keys = Object.keys(obj)
if (!keys.length) {
return
} else if (keys.length > 1) {
throw new Error("Order by only supports one key per object.")
}
const key = keys[0]
let value = obj[key]
if (isObject(value)) {
nested(value, prefix + key + ".")
} else {
if (isString(value)) {
value = value.toUpperCase()
}
result[prefix + key] = map.get(value) ?? "ASC"
}
}
arr.forEach((obj) => nested(obj))
return result
}
public buildQuery(countAllResults = true, returnIdOnly = false): string {
const queryBuilder = this.knex.queryBuilder()
const structure = this.structure
const filter = this.selector.where ?? {}
const { orderBy: order, skip, take } = this.options ?? {}
const orderBy = this.transformOrderBy(
(order && !Array.isArray(order) ? [order] : order) ?? []
)
const rootKey = this.getStructureKeys(structure)[0]
const rootStructure = structure[rootKey] as Select
const entity = this.getEntity(rootKey).ref.entity
const rootEntity = entity.toLowerCase()
const aliasMapping: { [path: string]: string } = {}
const joinParts = this.buildQueryParts(
rootStructure,
"",
entity,
rootKey,
[],
0,
aliasMapping
)
const rootAlias = aliasMapping[rootKey]
const selectParts = !returnIdOnly
? this.buildSelectParts(rootStructure, rootKey, aliasMapping)
: { [rootKey + ".id"]: `${rootAlias}.id` }
if (countAllResults) {
selectParts["offset_"] = this.knex.raw(
`DENSE_RANK() OVER (ORDER BY ${rootEntity}0.id)`
)
}
queryBuilder.select(selectParts)
queryBuilder.from(`index_data AS ${rootEntity}0`)
joinParts.forEach((joinPart) => {
queryBuilder.joinRaw(joinPart)
})
queryBuilder.where(`${aliasMapping[rootEntity]}.name`, "=", entity)
// WHERE clause
this.parseWhere(aliasMapping, filter, queryBuilder)
// ORDER BY clause
for (const aliasPath in orderBy) {
const path = aliasPath.split(".")
const field = path.pop()
const attr = path.join(".")
const alias = aliasMapping[attr]
const direction = orderBy[aliasPath]
queryBuilder.orderByRaw(`${alias}.data->>'${field}' ${direction}`)
}
let sql = `WITH data AS (${queryBuilder.toQuery()})
SELECT * ${
countAllResults ? ", (SELECT max(offset_) FROM data) AS count" : ""
}
FROM data`
let take_ = !isNaN(+take!) ? +take! : 15
let skip_ = !isNaN(+skip!) ? +skip! : 0
if (typeof take === "number" || typeof skip === "number") {
sql += `
WHERE offset_ > ${skip_}
AND offset_ <= ${skip_ + take_}
`
}
return sql
}
public buildObjectFromResultset(
resultSet: Record<string, any>[]
): Record<string, any>[] {
const structure = this.structure
const rootKey = this.getStructureKeys(structure)[0]
const maps: { [key: string]: { [id: string]: Record<string, any> } } = {}
const isListMap: { [path: string]: boolean } = {}
const referenceMap: { [key: string]: any } = {}
const pathDetails: {
[key: string]: { property: string; parents: string[]; parentPath: string }
} = {}
const initializeMaps = (structure: Select, path: string[]) => {
const currentPath = path.join(".")
maps[currentPath] = {}
if (path.length > 1) {
const property = path[path.length - 1]
const parents = path.slice(0, -1)
const parentPath = parents.join(".")
isListMap[currentPath] = !!this.getEntity(currentPath).ref.parents.find(
(p) => p.targetProp === property
)?.isList
pathDetails[currentPath] = { property, parents, parentPath }
}
const children = this.getStructureKeys(structure)
for (const key of children) {
initializeMaps(structure[key] as Select, [...path, key])
}
}
initializeMaps(structure[rootKey] as Select, [rootKey])
function buildReferenceKey(
path: string[],
id: string,
row: Record<string, any>
) {
let current = ""
let key = ""
for (const p of path) {
current += `${p}`
key += row[`${current}.id`] + "."
current += "."
}
return key + id
}
resultSet.forEach((row) => {
for (const path in maps) {
const id = row[`${path}.id`]
// root level
if (!pathDetails[path]) {
if (!maps[path][id]) {
maps[path][id] = row[path] || undefined
}
continue
}
const { property, parents, parentPath } = pathDetails[path]
const referenceKey = buildReferenceKey(parents, id, row)
if (referenceMap[referenceKey]) {
continue
}
maps[path][id] = row[path] || undefined
const parentObj = maps[parentPath][row[`${parentPath}.id`]]
if (!parentObj) {
continue
}
const isList = isListMap[parentPath + "." + property]
if (isList) {
parentObj[property] ??= []
}
if (maps[path][id] !== undefined) {
if (isList) {
parentObj[property].push(maps[path][id])
} else {
parentObj[property] = maps[path][id]
}
}
referenceMap[referenceKey] = true
}
})
return Object.values(maps[rootKey] ?? {})
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": [
"dist",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules"
],
}

View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"lib": ["es2021"],
"target": "es2021",
"outDir": "./dist",
"esModuleInterop": true,
"declarationMap": 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": {
"@models": ["./src/models"],
"@services": ["./src/services"],
"@repositories": ["./src/repositories"],
"@types": ["./src/types"]
}
},
"include": ["src", "integration-tests"],
"exclude": [
"dist",
"node_modules"
]
}

View File

@@ -1,7 +1,4 @@
export default `
scalar DateTime
scalar JSON
type InventoryItem {
id: ID!
created_at: DateTime!
@@ -21,7 +18,6 @@ type InventoryItem {
title: String
thumbnail: String
metadata: JSON
inventory_levels: [InventoryLevel]
}
@@ -31,6 +27,7 @@ type InventoryLevel {
updated_at: DateTime!
deleted_at: DateTime
inventory_item_id: String!
inventory_item: InventoryItem!
location_id: String!
stocked_quantity: Int!
reserved_quantity: Int!
@@ -45,6 +42,7 @@ type ReservationItem {
deleted_at: DateTime
line_item_id: String
inventory_item_id: String!
inventory_item: InventoryItem!
location_id: String!
quantity: Int!
external_id: String

View File

@@ -36,18 +36,6 @@ type OrderSummary {
raw_pending_difference: JSON
}
type OrderAdjustmentLine {
id: ID!
code: String
amount: Float
order_id: String!
description: String
promotion_id: String
provider_id: String
created_at: DateTime
updated_at: DateTime
}
type OrderShippingMethodAdjustment {
id: ID!
code: String
@@ -76,16 +64,6 @@ type OrderLineItemAdjustment {
item_id: String!
}
type OrderTaxLine {
id: ID!
description: String
tax_rate_id: String
code: String!
rate: Float
provider_id: String
created_at: DateTime
updated_at: DateTime
}
type OrderShippingMethodTaxLine {
id: ID!
@@ -367,6 +345,7 @@ type OrderClaimItem {
item_id: String!
quantity: Int!
reason: ClaimReason!
images: [OrderClaimItemImage]
raw_quantity: JSON
metadata: JSON
created_at: DateTime
@@ -376,6 +355,7 @@ type OrderClaimItem {
type OrderClaimItemImage {
id: ID!
claim_item_id: String!
item: OrderClaimItem!
url: String
metadata: JSON
created_at: DateTime
@@ -408,7 +388,7 @@ type OrderClaim {
type OrderExchange {
order_id: String!
return_items: [OrderReturnItem]!
additional_items: [OrderClaimItem]!
additional_items: [OrderExchangeItem]!
no_notification: Boolean
difference_due: Float
return: Return

View File

@@ -1,7 +1,4 @@
export default `
scalar DateTime
scalar JSON
enum TransactionState {
NOT_STARTED
INVOKING

View File

@@ -1,7 +1,4 @@
export default `
scalar DateTime
scalar JSON
enum TransactionState {
NOT_STARTED
INVOKING

View File

@@ -6027,6 +6027,28 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/index@workspace:packages/modules/index":
version: 0.0.0-use.local
resolution: "@medusajs/index@workspace:packages/modules/index"
dependencies:
"@medusajs/types": ^1.11.16
"@medusajs/utils": ^1.11.9
"@mikro-orm/cli": 5.9.7
cross-env: ^5.2.1
jest: ^29.7.0
medusa-test-utils: ^1.1.44
rimraf: ^3.0.2
ts-node: ^10.9.1
tsc-alias: ^1.8.6
typescript: ^5.1.6
peerDependencies:
"@mikro-orm/core": 5.9.7
"@mikro-orm/migrations": 5.9.7
"@mikro-orm/postgresql": 5.9.7
awilix: ^8.0.1
languageName: unknown
linkType: soft
"@medusajs/inventory-next@workspace:^, @medusajs/inventory-next@workspace:packages/modules/inventory-next":
version: 0.0.0-use.local
resolution: "@medusajs/inventory-next@workspace:packages/modules/inventory-next"