Feat(): distributed caching (#13435)

RESOLVES CORE-1153

**What**
- This pr mainly lay the foundation the caching layer. It comes with a modules (built in memory cache) and a redis provider.
- Apply caching to few touch point to test

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de PerettiandCarlos R. L. Rodrigues authored and GitHub committed 2025-09-30 16:19:06 +00:00
1 parent 5b135a41fe
commit b9d6f73320
117 files changed
+5741 -530

No files matched your search

@@ -1,8 +1,9 @@
import {
IRegionModuleService,
IStoreModuleService,
MedusaContainer,
} from "@medusajs/framework/types"
import { MedusaError, Modules } from "@medusajs/framework/utils"
import { MedusaError, Modules, useCache } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
@@ -15,6 +16,48 @@ export type FindOneOrAnyRegionStepInput = {
regionId?: string
}
async function fetchRegionById(regionId: string, container: MedusaContainer) {
const service = container.resolve<IRegionModuleService>(Modules.REGION)
const args = [
regionId,
{
relations: ["countries"],
},
] as Parameters<IRegionModuleService["retrieveRegion"]>
return await useCache(async () => service.retrieveRegion(...args), {
container,
key: args,
})
}
async function fetchDefaultStore(container: MedusaContainer) {
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
return await useCache(async () => storeModule.listStores(), {
container,
key: "find-one-or-any-region-default-store",
})
}
async function fetchDefaultRegion(
defaultRegionId: string,
container: MedusaContainer
) {
const service = container.resolve<IRegionModuleService>(Modules.REGION)
const args = [
{ id: defaultRegionId },
{ relations: ["countries"] },
] as Parameters<IRegionModuleService["listRegions"]>
return await useCache(async () => service.listRegions(...args), {
container,
key: args,
})
}
export const findOneOrAnyRegionStepId = "find-one-or-any-region"
/**
* This step retrieves a region either by the provided ID or the first region in the first store.
@@ -22,32 +65,24 @@ export const findOneOrAnyRegionStepId = "find-one-or-any-region"
export const findOneOrAnyRegionStep = createStep(
findOneOrAnyRegionStepId,
async (data: FindOneOrAnyRegionStepInput, { container }) => {
const service = container.resolve<IRegionModuleService>(Modules.REGION)
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
if (data.regionId) {
try {
const region = await service.retrieveRegion(data.regionId, {
relations: ["countries"],
})
const region = await fetchRegionById(data.regionId, container)
return new StepResponse(region)
} catch (error) {
return new StepResponse(null)
}
}
const [store] = await storeModule.listStores()
const [store] = await fetchDefaultStore(container)
if (!store) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Store not found")
}
const [region] = await service.listRegions(
{
id: store.default_region_id,
},
{ relations: ["countries"] }
const [region] = await fetchDefaultRegion(
store.default_region_id!,
container
)
if (!region) {
@@ -1,8 +1,14 @@
import type {
CustomerDTO,
ICustomerModuleService,
MedusaContainer,
} from "@medusajs/framework/types"
import { isDefined, Modules, validateEmail } from "@medusajs/framework/utils"
import {
isDefined,
Modules,
useCache,
validateEmail,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
/**
@@ -39,6 +45,40 @@ interface StepCompensateInput {
customerWasCreated: boolean
}
async function fetchCustomerById(
customerId: string,
container: MedusaContainer
): Promise<CustomerDTO> {
const service = container.resolve<ICustomerModuleService>(Modules.CUSTOMER)
return await useCache<CustomerDTO>(
async () => service.retrieveCustomer(customerId),
{
container,
key: ["find-or-create-customer-by-id", customerId],
}
)
}
async function fetchCustomersByEmail(
email: string,
container: MedusaContainer,
hasAccount?: boolean
): Promise<CustomerDTO[]> {
const service = container.resolve<ICustomerModuleService>(Modules.CUSTOMER)
const filters =
hasAccount !== undefined ? { email, has_account: hasAccount } : { email }
return await useCache<CustomerDTO[]>(
async () => service.listCustomers(filters),
{
container,
key: ["find-or-create-customer-by-email", filters],
}
)
}
export const findOrCreateCustomerStepId = "find-or-create-customer"
/**
* This step finds or creates a customer based on the provided ID or email. It prioritizes finding the customer by ID, then by email.
@@ -75,7 +115,7 @@ export const findOrCreateCustomerStep = createStep(
let customerWasCreated = false
if (data.customerId) {
originalCustomer = await service.retrieveCustomer(data.customerId)
originalCustomer = await fetchCustomerById(data.customerId, container)
customerData.customer = originalCustomer
customerData.email = originalCustomer.email
}
@@ -85,9 +125,7 @@ export const findOrCreateCustomerStep = createStep(
let [customer] = originalCustomer
? [originalCustomer]
: await service.listCustomers({
email: validatedEmail,
})
: await fetchCustomersByEmail(validatedEmail, container)
// if NOT a guest customer, return it
if (customer?.has_account) {
@@ -100,10 +138,11 @@ export const findOrCreateCustomerStep = createStep(
}
if (customer && customer.email !== validatedEmail) {
;[customer] = await service.listCustomers({
email: validatedEmail,
has_account: false,
})
;[customer] = await fetchCustomersByEmail(
validatedEmail,
container,
false
)
}
if (!customer) {
@@ -1,9 +1,15 @@
import {
ISalesChannelModuleService,
IStoreModuleService,
MedusaContainer,
SalesChannelDTO,
} from "@medusajs/framework/types"
import { MedusaError, Modules, isDefined } from "@medusajs/framework/utils"
import {
MedusaError,
Modules,
isDefined,
useCache,
} from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
@@ -16,6 +22,34 @@ export interface FindSalesChannelStepInput {
salesChannelId?: string | null
}
async function fetchSalesChannel(
salesChannelId: string,
container: MedusaContainer
) {
const salesChannelService = container.resolve<ISalesChannelModuleService>(
Modules.SALES_CHANNEL
)
return await useCache<
Awaited<ReturnType<typeof salesChannelService.retrieveSalesChannel>>
>(async () => salesChannelService.retrieveSalesChannel(salesChannelId), {
container,
key: ["find-sales-channel", salesChannelId],
})
}
async function fetchStore(container: MedusaContainer) {
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
return await useCache<Awaited<ReturnType<typeof storeModule.listStores>>>(
async () =>
storeModule.listStores(
{},
{ select: ["id", "default_sales_channel_id"] }
),
{ key: "find-sales-channel-default-store", container }
)
}
export const findSalesChannelStepId = "find-sales-channel"
/**
* This step either retrieves a sales channel either using the ID provided as an input, or, if no ID
@@ -24,26 +58,17 @@ export const findSalesChannelStepId = "find-sales-channel"
export const findSalesChannelStep = createStep(
findSalesChannelStepId,
async (data: FindSalesChannelStepInput, { container }) => {
const salesChannelService = container.resolve<ISalesChannelModuleService>(
Modules.SALES_CHANNEL
)
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
let salesChannel: SalesChannelDTO | undefined
if (data.salesChannelId) {
salesChannel = await salesChannelService.retrieveSalesChannel(
data.salesChannelId
)
salesChannel = await fetchSalesChannel(data.salesChannelId, container)
} else if (!isDefined(data.salesChannelId)) {
const [store] = await storeModule.listStores(
{},
{ select: ["default_sales_channel_id"] }
)
const [store] = await fetchStore(container)
if (store?.default_sales_channel_id) {
salesChannel = await salesChannelService.retrieveSalesChannel(
store.default_sales_channel_id
salesChannel = await fetchSalesChannel(
store.default_sales_channel_id,
container
)
}
}
@@ -1,7 +1,6 @@
import type { IPromotionModuleService } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
MedusaError,
Modules,
PromotionActions,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
@@ -72,9 +71,6 @@ export const getPromotionCodesToApply = createStep(
async (data: GetPromotionCodesToApplyStepInput, { container }) => {
const { promo_codes = [], cart, action = PromotionActions.ADD } = data
const { items = [], shipping_methods = [] } = cart
const promotionService = container.resolve<IPromotionModuleService>(
Modules.PROMOTION
)
const adjustmentCodes: string[] = []
items.concat(shipping_methods).forEach((object) => {
@@ -99,14 +95,23 @@ export const getPromotionCodesToApply = createStep(
action === PromotionActions.ADD ||
action === PromotionActions.REPLACE
) {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const validPromoCodes: Set<string> = new Set(
promo_codes.length
? (
await promotionService.listPromotions(
{ code: promo_codes },
{ select: ["code"] }
await query.graph(
{
entity: "promotion",
fields: ["id", "code"],
filters: { code: promo_codes },
},
{
cache: {
enable: true,
},
}
)
).map((p) => p.code!)
).data.map((p) => p.code!)
: []
)
@@ -1,4 +1,4 @@
import { Query } from "@medusajs/framework"
import { MedusaContainer, Query } from "@medusajs/framework"
import {
CalculatedPriceSet,
IPricingModuleService,
@@ -75,11 +75,18 @@ async function fetchVariantPriceSets(
variantIds: string[]
): Promise<VariantPriceSetData[]> {
return (
await query.graph({
entity: "variant",
fields: ["id", "price_set.id"],
filters: { id: variantIds },
})
await query.graph(
{
entity: "variant",
fields: ["id", "price_set.id"],
filters: { id: variantIds },
},
{
cache: {
enable: true,
},
}
)
).data
}
@@ -108,7 +115,8 @@ function validateVariantPriceSets(
*/
async function processVariantPriceSets(
pricingService: IPricingModuleService,
items: PriceCalculationItem[]
items: PriceCalculationItem[],
container: MedusaContainer
): Promise<GetVariantPriceSetsStepOutput> {
const result: GetVariantPriceSetsStepOutput = {}
@@ -298,7 +306,8 @@ export const getVariantPriceSetsStep = createStep(
// Use unified processing logic for both input types
const result = await processVariantPriceSets(
pricingModuleService,
calculationItems
calculationItems,
container
)
return new StepResponse(result)
@@ -1,4 +1,3 @@
import type { IPromotionModuleService } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
Modules,
@@ -40,9 +39,6 @@ export const updateCartPromotionsStep = createStep(
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const promotionService = container.resolve<IPromotionModuleService>(
Modules.PROMOTION
)
const existingCartPromotionLinks = await remoteQuery({
entryPoint: "cart_promotion",
@@ -60,9 +56,18 @@ export const updateCartPromotionsStep = createStep(
const linksToDismiss: any[] = []
if (promo_codes?.length) {
const promotions = await promotionService.listPromotions(
{ code: promo_codes },
{ select: ["id"] }
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: promotions } = await query.graph(
{
entity: "promotion",
fields: ["id", "code"],
filters: { code: promo_codes },
},
{
cache: {
enable: true,
},
}
)
for (const promotion of promotions) {
@@ -205,6 +205,11 @@ export const addToCartWorkflow = createWorkflow(
filters: {
id: variantIds,
},
options: {
cache: {
enable: true,
},
},
}).config({ name: "fetch-variants" })
})
@@ -142,6 +142,11 @@ export const getVariantsAndItemsWithPrices = createWorkflow(
filters: {
id: variantIds,
},
options: {
cache: {
enable: true,
},
},
}).config({ name: "fetch-variants" })
const calculatedPriceSets = getVariantPriceSetsStep({
@@ -75,26 +75,26 @@ export const listShippingOptionsForCartWithPricingWorkflowId =
* @summary
*
* List a cart's shipping options with prices.
*
*
* @property hooks.setShippingOptionsContext - This hook is executed after the cart is retrieved and before the shipping options are queried. You can consume this hook to return any custom context useful for the shipping options retrieval.
*
* For example, you can consume the hook to add the customer Id to the context:
*
*
* ```ts
* import { listShippingOptionsForCartWithPricingWorkflow } from "@medusajs/medusa/core-flows"
* import { StepResponse } from "@medusajs/workflows-sdk"
*
*
* listShippingOptionsForCartWithPricingWorkflow.hooks.setShippingOptionsContext(
* async ({ cart }, { container }) => {
*
*
* if (cart.customer_id) {
* return new StepResponse({
* customer_id: cart.customer_id,
* })
* }
*
*
* const query = container.resolve("query")
*
*
* const { data: carts } = await query.graph({
* entity: "cart",
* filters: {
@@ -102,16 +102,16 @@ export const listShippingOptionsForCartWithPricingWorkflowId =
* },
* fields: ["customer_id"],
* })
*
*
* return new StepResponse({
* customer_id: carts[0].customer_id,
* })
* }
* )
* ```
*
*
* The `customer_id` property will be added to the context along with other properties such as `is_return` and `enabled_in_store`.
*
*
* :::note
*
* You should also consume the `setShippingOptionsContext` hook in the {@link listShippingOptionsForCartWorkflow} workflow to ensure that the context is consistent when listing shipping options across workflows.
@@ -120,7 +120,11 @@ export const listShippingOptionsForCartWithPricingWorkflowId =
*/
export const listShippingOptionsForCartWithPricingWorkflow = createWorkflow(
listShippingOptionsForCartWithPricingWorkflowId,
(input: WorkflowData<ListShippingOptionsForCartWithPricingWorkflowInput & AdditionalData>) => {
(
input: WorkflowData<
ListShippingOptionsForCartWithPricingWorkflowInput & AdditionalData
>
) => {
const optionIds = transform({ input }, ({ input }) =>
(input.options ?? []).map(({ id }) => id)
)
@@ -155,6 +159,11 @@ export const listShippingOptionsForCartWithPricingWorkflow = createWorkflow(
"stock_locations.address.*",
"stock_locations.fulfillment_sets.id",
],
options: {
cache: {
enable: true,
},
},
}).config({ name: "sales_channels-fulfillment-query" })
const scFulfillmentSets = transform(
@@ -193,13 +202,21 @@ export const listShippingOptionsForCartWithPricingWorkflow = createWorkflow(
resultValidator: shippingOptionsContextResult,
}
)
const setShippingOptionsContextResult = setShippingOptionsContext.getResult()
const setShippingOptionsContextResult =
setShippingOptionsContext.getResult()
const commonOptions = transform(
{ input, cart, fulfillmentSetIds, setShippingOptionsContextResult },
({ input, cart, fulfillmentSetIds, setShippingOptionsContextResult }) => ({
({
input,
cart,
fulfillmentSetIds,
setShippingOptionsContextResult,
}) => ({
context: {
...(setShippingOptionsContextResult ? setShippingOptionsContextResult : {}),
...(setShippingOptionsContextResult
? setShippingOptionsContextResult
: {}),
is_return: input.is_return ? "true" : "false",
enabled_in_store: !isDefined(input.enabled_in_store)
? "true"
@@ -168,6 +168,11 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
"stock_locations.name",
"stock_locations.address.*",
],
options: {
cache: {
enable: true,
},
},
}).config({ name: "sales_channels-fulfillment-query" })
const scFulfillmentSets = transform(
@@ -148,6 +148,9 @@ export const updateCartWorkflow = createWorkflow(
options: {
throwIfKeyNotFound: true,
isList: false,
cache: {
enable: true,
},
},
}).config({ name: "get-region" })
@@ -247,6 +247,11 @@ export const createOrderWorkflow = createWorkflow(
filters: {
id: variantIdsWithoutCalculatedPrice,
},
options: {
cache: {
enable: true,
},
},
}).config({ name: "query-variants-without-calculated-price" })
/**
+1
View File
@@ -16,6 +16,7 @@
"exports": {
".": "./dist/index.js",
"./config": "./dist/config/index.js",
"./caching": "./dist/caching/index.js",
"./logger": "./dist/logger/index.js",
"./database": "./dist/database/index.js",
"./subscribers": "./dist/subscribers/index.js",
@@ -30,20 +30,38 @@ export async function ensurePublishableApiKeyMiddleware(
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
try {
const { data } = await query.graph({
entity: "api_key",
fields: ["id", "token", "sales_channels_link.sales_channel_id"],
filters: {
token: publishableApiKey,
type: ApiKeyType.PUBLISHABLE,
$or: [
{ revoked_at: { $eq: null } },
{ revoked_at: { $gt: new Date() } },
// Cache API key data and check revocation in memory
const { data } = await query.graph(
{
entity: "api_key",
fields: [
"id",
"token",
"revoked_at",
"sales_channels_link.sales_channel_id",
],
filters: {
token: publishableApiKey,
type: ApiKeyType.PUBLISHABLE,
},
},
})
{
cache: {
enable: true,
},
}
)
apiKey = data[0]
if (data.length) {
const now = new Date()
const cachedApiKey = data[0]
const isRevoked =
!!cachedApiKey.revoked_at && new Date(cachedApiKey.revoked_at) <= now
if (!isRevoked) {
apiKey = cachedApiKey
}
}
} catch (e) {
return next(e)
}
@@ -1,49 +1,88 @@
import { MedusaContainer } from "@medusajs/types"
import {
ContainerRegistrationKeys,
isString,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { MedusaRequest } from "../types"
import type {
GraphResultSet,
MedusaContainer,
RemoteJoinerOptions,
RemoteQueryEntryPoints,
RemoteQueryFunctionReturnPagination,
} from "../../types"
import { ContainerRegistrationKeys, isString } from "../../utils"
import type { MedusaRequest } from "../types"
export const refetchEntities = async (
entryPoint: string,
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[],
pagination?: MedusaRequest["queryConfig"]["pagination"],
export const refetchEntities = async <TEntry extends string>({
entity,
idOrFilter,
scope,
fields,
pagination,
withDeleted,
options,
}: {
entity: TEntry
idOrFilter?: string | object
scope: MedusaContainer
fields?: string[]
pagination?: MedusaRequest["queryConfig"]["pagination"]
withDeleted?: boolean
) => {
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter
let context: object = {}
options?: RemoteJoinerOptions
}): Promise<
Omit<GraphResultSet<TEntry>, "metadata"> & {
metadata: RemoteQueryFunctionReturnPagination
}
> => {
const query = scope.resolve(ContainerRegistrationKeys.QUERY)
let filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter
let context!: Record<string, unknown>
if ("context" in filters) {
if (filters.context) {
context = filters.context!
if (filters && "context" in filters) {
const { context: context_, ...rest } = filters
if (context_) {
context = context_! as Record<string, unknown>
}
delete filters.context
filters = rest
}
const variables = { filters, ...context, ...pagination, withDeleted }
const graphOptions: Parameters<typeof query.graph>[0] = {
entity,
fields: fields ?? [],
filters,
pagination,
withDeleted,
context: context,
}
const queryObject = remoteQueryObjectFromString({
entryPoint,
variables,
const result = await query.graph(graphOptions, options)
return {
data: result.data as TEntry extends keyof RemoteQueryEntryPoints
? RemoteQueryEntryPoints[TEntry][]
: any[],
metadata: result.metadata ?? ({} as RemoteQueryFunctionReturnPagination),
}
}
export const refetchEntity = async <TEntry extends string>({
entity,
idOrFilter,
scope,
fields,
options,
}: {
entity: TEntry & string
idOrFilter: string | object
scope: MedusaContainer
fields: string[]
options?: RemoteJoinerOptions
}): Promise<
TEntry extends keyof RemoteQueryEntryPoints
? RemoteQueryEntryPoints[TEntry]
: any
> => {
const { data } = await refetchEntities<TEntry>({
entity,
idOrFilter,
scope,
fields,
options,
})
return await remoteQuery(queryObject)
}
export const refetchEntity = async (
entryPoint: string,
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[]
) => {
const [entity] = await refetchEntities(entryPoint, idOrFilter, scope, fields)
return entity
return Array.isArray(data) ? data[0] : data
}
@@ -5,6 +5,7 @@ import {
IApiKeyModuleService,
IAuthModuleService,
ICacheService,
ICachingModuleService,
ICartModuleService,
ICurrencyModuleService,
ICustomerModuleService,
@@ -76,6 +77,7 @@ declare module "@medusajs/types" {
[Modules.NOTIFICATION]: INotificationModuleService
[Modules.LOCKING]: ILockingModule
[Modules.SETTINGS]: ISettingsModuleService
[Modules.CACHING]: ICachingModuleService
}
}
@@ -244,6 +244,7 @@ describe("load internal", () => {
expect(generatedJoinerConfig).toEqual({
serviceName: "module-without-joiner-config",
primaryKeys: ["id"],
idPrefixToEntityName: {},
linkableKeys: {
entity2_id: "Entity2",
entity_model_id: "EntityModel",
@@ -322,6 +323,7 @@ describe("load internal", () => {
expect(generatedJoinerConfig).toEqual({
serviceName: "module-service",
primaryKeys: ["id"],
idPrefixToEntityName: {},
linkableKeys: {},
schema: "",
alias: [
@@ -231,7 +231,8 @@ export async function loadInternalModule(args: {
ContainerRegistrationKeys.CONFIG_MODULE,
ContainerRegistrationKeys.LOGGER,
ContainerRegistrationKeys.PG_CONNECTION,
Modules.EVENT_BUS
Modules.EVENT_BUS,
Modules.CACHING
)
for (const dependency of dependencies) {
@@ -609,6 +609,7 @@ async function MedusaApp_({
query: createQuery({
remoteQuery,
indexModule,
container: sharedContainer_,
}) as any, // TODO: rm any once we remove the old RemoteQueryFunction and rely on the Query object instead,
entitiesMap,
gqlSchema: schema,
@@ -1,6 +1,7 @@
import {
GraphResultSet,
IIndexService,
MedusaContainer,
RemoteJoinerOptions,
RemoteJoinerQuery,
RemoteQueryFilters,
@@ -11,6 +12,7 @@ import {
RemoteQueryObjectFromStringResult,
} from "@medusajs/types"
import {
Cached,
MedusaError,
isObject,
remoteQueryObjectFromString,
@@ -19,12 +21,62 @@ import {
import { RemoteQuery } from "./remote-query"
import { toRemoteQuery } from "./to-remote-query"
function extractCacheOptions(option: string) {
return function extractKey(args: any[]) {
return args[1]?.cache?.[option]
}
}
function isCacheEnabled(args: any[]) {
const isEnabled = extractCacheOptions("enable")(args)
if (isEnabled === false) {
return false
}
return (
isEnabled === true ||
extractCacheOptions("key")(args) ||
extractCacheOptions("ttl")(args) ||
extractCacheOptions("tags")(args) ||
extractCacheOptions("autoInvalidate")(args) ||
extractCacheOptions("providers")(args)
)
}
const cacheDecoratorOptions = {
enable: isCacheEnabled,
key: async (args, cachingModule) => {
const key = extractCacheOptions("key")(args)
if (key) {
return key
}
const queryOptions = args[0]
const remoteJoinerOptions = args[1] ?? {}
const { initialData, cache, ...restOptions } = remoteJoinerOptions
const keyInput = {
queryOptions,
options: restOptions,
}
return await cachingModule.computeKey(keyInput)
},
ttl: extractCacheOptions("ttl"),
tags: extractCacheOptions("tags"),
autoInvalidate: extractCacheOptions("autoInvalidate"),
providers: extractCacheOptions("providers"),
container: function (this: Query) {
return this.container
},
}
/**
* API wrapper around the remoteQuery
*/
export class Query {
#remoteQuery: RemoteQuery
#indexModule: IIndexService
protected container: MedusaContainer
/**
* Method to wrap execution of the graph query for instrumentation
@@ -61,12 +113,15 @@ export class Query {
constructor({
remoteQuery,
indexModule,
container,
}: {
remoteQuery: RemoteQuery
indexModule: IIndexService
container: MedusaContainer
}) {
this.#remoteQuery = remoteQuery
this.#indexModule = indexModule
this.container = container
}
#unwrapQueryConfig(
@@ -151,6 +206,7 @@ export class Query {
* Graph function uses the remoteQuery under the hood and
* returns a result set
*/
@Cached(cacheDecoratorOptions)
async graph<const TEntry extends string>(
queryOptions: RemoteQueryInput<TEntry>,
options?: RemoteJoinerOptions
@@ -189,6 +245,7 @@ export class Query {
* Index function uses the Index module to query and hydrates the data with query.graph
* returns a result set
*/
@Cached(cacheDecoratorOptions)
async index<const TEntry extends string>(
queryOptions: RemoteQueryInput<TEntry> & {
joinFilters?: RemoteQueryFilters<TEntry>
@@ -266,13 +323,16 @@ export class Query {
export function createQuery({
remoteQuery,
indexModule,
container,
}: {
remoteQuery: RemoteQuery
indexModule: IIndexService
container: MedusaContainer
}) {
const query = new Query({
remoteQuery,
indexModule,
container,
})
function backwardCompatibleQuery(...args: any[]) {
@@ -69,8 +69,7 @@ export function toRemoteQuery<const TEntity extends string>(
}
if (QueryContext.isQueryContext(src)) {
const normalizedFilters = { ...src } as any
delete normalizedFilters.__type
const { __type, ...normalizedFilters } = src as any
const prop = "context"
@@ -100,7 +99,7 @@ export function toRemoteQuery<const TEntity extends string>(
}
// Process filters and context recursively
processNestedObjects(joinerQuery[entity], context)
processNestedObjects(joinerQuery[entity], context, true)
for (const field of fields) {
const fieldAsString = field as string
+1
View File
@@ -3,6 +3,7 @@ export * as AnalyticsTypes from "./analytics"
export * as ApiKeyTypes from "./api-key"
export * as AuthTypes from "./auth"
export * as CacheTypes from "./cache"
export * as CachingTypes from "./caching"
export * as CartTypes from "./cart"
export * as CommonTypes from "./common"
export * as CurrencyTypes from "./currency"
+161
View File
@@ -0,0 +1,161 @@
import { IModuleService, ModuleJoinerConfig } from "../modules-sdk"
type Providers = string[] | { id: string; ttl?: number }[]
export interface ICachingModuleService extends IModuleService {
// Static trace methods
// traceGet: (
// cacheGetFn: () => Promise<any>,
// key: string,
// tags: string[]
// ) => Promise<any>
// traceSet?: (
// cacheSetFn: () => Promise<any>,
// key: string,
// tags: string[],
// options: { autoInvalidate?: boolean }
// ) => Promise<any>
// traceClear?: (
// cacheClearFn: () => Promise<any>,
// key: string,
// tags: string[],
// options: { autoInvalidate?: boolean }
// ) => Promise<any>
/**
* This method retrieves data from the cache.
*
* @param key - The key of the item to retrieve.
* @param tags - The tags of the items to retrieve.
* @param providers - Array of providers to check in order of priority. If not provided,
* only the default provider will be used.
*
* @returns The item(s) that was stored in the cache. If the item(s) was not found, null will
* be returned.
*
*/
get<T>({
key,
tags,
providers,
}: {
key?: string
tags?: string[]
providers?: string[]
}): Promise<T | null>
/**
* This method stores data in the cache.
*
* @param key - The key of the item to store.
* @param data - The data to store in the cache.
* @param ttl - The time-to-live (TTL in seconds) value in seconds. If not provided, the default TTL value
* is used. The default value is based on the used Cache Module.
* @param tags - The tags of the items to store. can be used for cross invalidation.
* @param options - if specified, will be stored with the item(s).
* @param providers - The providers from which to store the item(s).
*
*/
set({
key,
data,
ttl,
tags,
options,
providers,
}: {
key: string
data: object
ttl?: number
tags?: string[]
options?: {
autoInvalidate?: boolean
}
providers?: Providers
}): Promise<void>
/**
* This method clears data from the cache.
*
* @param key - The key of the item to clear.
* @param tags - The tags of the items to clear.
* @param options - if specified, invalidate the item(s) that has the value of the given
* options stored. e.g you can invalidate the tags X if their options.autoInvalidate is false or not present.
* @param providers - The providers from which to clear the item(s).
*
*/
clear({
key,
tags,
options,
providers,
}: {
key?: string
tags?: string[]
options?: {
autoInvalidate?: boolean
}
providers?: string[]
}): Promise<void>
computeKey(input: object): Promise<string>
computeTags(input: object, options?: Record<string, any>): Promise<string[]>
}
export interface ICachingProviderService {
get({ key, tags }: { key?: string; tags?: string[] }): Promise<any>
set({
key,
data,
ttl,
tags,
options,
}: {
key: string
data: object
ttl?: number
tags?: string[]
options?: { autoInvalidate?: boolean }
}): Promise<void>
clear({
key,
tags,
options,
}: {
key?: string
tags?: string[]
options?: { autoInvalidate?: boolean }
}): Promise<void>
}
export interface EntityReference {
type: string
id: string | number
field?: string
}
export interface ICachingStrategy {
/**
* This method is called when the application starts. It can be useful to set up some auto
* invalidation logic that reacts to something.
*
* @param container MedusaContainer
* @param schema GraphQLSchema
* @param cacheModule ICachingModuleService
*/
onApplicationStart?(
schema: any,
joinerConfigs: ModuleJoinerConfig[]
): Promise<void>
onApplicationPrepareShutdown?(): Promise<void>
onApplicationShutdown?(): Promise<void>
computeKey(input: object): Promise<string>
computeTags(input: object, options?: Record<string, any>): Promise<string[]>
}
@@ -58,3 +58,8 @@ export type RawMessageFormat<TData = any> = {
context?: Pick<Context, "eventGroupId">
options?: Record<string, any>
}
export type InterceptorSubscriber<T = unknown> = (
message: Message<T>,
context?: { isGrouped?: boolean; eventGroupId?: string }
) => Promise<void> | void
@@ -1,4 +1,9 @@
import { Message, Subscriber, SubscriberContext } from "./common"
import {
InterceptorSubscriber,
Message,
Subscriber,
SubscriberContext,
} from "./common"
export interface IEventBusModuleService {
/**
@@ -86,4 +91,31 @@ export interface IEventBusModuleService {
eventNames?: string[]
}
): Promise<void>
/**
* This method adds an interceptor to the event bus. This means that the interceptor will be
* called before the event is emitted.
*
* @param interceptor - The interceptor to add.
* @returns The instance of the Event Module
*
* @example
* eventModuleService.addInterceptor((message, context) => {
* console.log("Interceptor", message, context)
* })
*/
addInterceptor?(interceptor: InterceptorSubscriber): this
/**
* This method removes an interceptor from the event bus.
*
* @param interceptor - The interceptor to remove.
* @returns The instance of the Event Module
*
* @example
* eventModuleService.removeInterceptor((message, context) => {
* console.log("Interceptor", message, context)
* })
*/
removeInterceptor?(interceptor: InterceptorSubscriber): this
}
+1
View File
@@ -5,6 +5,7 @@ export * from "./api-key"
export * from "./auth"
export * from "./bundles"
export * from "./cache"
export * from "./caching"
export * from "./cart"
export * from "./common"
export * from "./currency"
+36
View File
@@ -1,3 +1,5 @@
import { ICachingModuleService } from "../caching"
export type JoinerRelationship = {
alias: string
foreignKey: string
@@ -92,6 +94,40 @@ export interface RemoteJoinerOptions {
throwIfRelationNotFound?: boolean | string[]
initialData?: object | object[]
initialDataOnly?: boolean
cache?: {
/**
* Whether to enable the cache. This is only useful if you want to enable without providing any
* other options or if you want to enable/disable the cache based on the arguments.
*/
enable?: boolean | ((args: any[]) => boolean | undefined)
/**
* The key to use for the cache.
* If a function is provided, it will be called with the arguments as the first argument and the
* container as the second argument.
*/
key?:
| string
| ((
args: any[],
cachingModule: ICachingModuleService
) => string | Promise<string>)
/**
* The tags to use for the cache.
*/
tags?: string[] | ((args: any[]) => string[] | undefined)
/**
* The time-to-live (TTL) value in seconds.
*/
ttl?: number | ((args: any[]) => number | undefined)
/**
* Whether to auto invalidate the cache whenever it is possible.
*/
autoInvalidate?: boolean | ((args: any[]) => boolean | undefined)
/**
* The providers to use for the cache.
*/
providers?: string[] | ((args: any[]) => string[] | undefined)
}
}
export interface RemoteNestedExpands {
@@ -196,6 +196,7 @@ export type ModuleJoinerConfig = Omit<
* GraphQL schema for the all module's available entities and fields
*/
schema?: string
idPrefixToEntityName?: Record<string, string>
relationships?: ModuleJoinerRelationship[]
extends?: {
serviceName: string
@@ -48,9 +48,10 @@ export type QueryGraphFunction = {
* a normalized/consistent output.
*/
export type QueryIndexFunction = {
<const TEntry extends string>(queryOptions: IndexQueryInput<TEntry>): Promise<
Prettify<QueryResultSet<TEntry>>
>
<const TEntry extends string>(
queryOptions: IndexQueryInput<TEntry>,
options?: RemoteJoinerOptions
): Promise<Prettify<QueryResultSet<TEntry>>>
}
/*export type RemoteQueryReturnedData<TEntry extends string> =
@@ -132,7 +132,6 @@ export interface FilterablePriceRuleProps
* The IDs to filter the price rule's associated price set.
*/
price_set_id?: string | string[] | OperatorMap<string | string[]>
/**
* The IDs to filter the price rule's associated price.
*/
+1
View File
@@ -18,3 +18,4 @@ export * as PromotionUtils from "./promotion"
export * as SearchUtils from "./search"
export * as ShippingProfileUtils from "./shipping"
export * as UserUtils from "./user"
export * as CachingUtils from "./caching"
+249
View File
@@ -0,0 +1,249 @@
import { ICachingModuleService, Logger, MedusaContainer } from "@medusajs/types"
import { MedusaContextType, Modules } from "../modules-sdk"
import { FeatureFlag } from "../feature-flags"
import { ContainerRegistrationKeys, isObject } from "../common"
/**
* This function is used to cache the result of a function call.
*
* @param cb - The callback to execute.
* @param options - The options for the cache.
* @returns The result of the callback.
*/
export async function useCache<T>(
cb: (...args: any[]) => Promise<T>,
options: {
enable?: boolean
key: string | any[]
tags?: string[]
ttl?: number
/**
* Whethere the default strategy should auto invalidate the cache whenever it is possible.
*/
autoInvalidate?: boolean
providers?: string[]
container: MedusaContainer
}
): Promise<T> {
const cachingModule = options.container.resolve<ICachingModuleService>(
Modules.CACHING,
{
allowUnregistered: true,
}
)
if (
!options.enable ||
!FeatureFlag.isFeatureEnabled("caching") ||
!cachingModule
) {
return await cb()
}
let key: string
if (typeof options.key === "string") {
key = options.key
} else {
key = await cachingModule.computeKey(options.key)
}
const data = await cachingModule.get({
key,
tags: options.tags,
providers: options.providers,
})
if (data) {
return data as T
}
const result = await cb()
void cachingModule
.set({
key,
tags: options.tags,
ttl: options.ttl,
data: result as object,
options: { autoInvalidate: options.autoInvalidate },
providers: options.providers,
})
.catch((e) => {
const logger =
options.container.resolve<Logger>(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? (console as unknown as Logger)
logger.error(
`An error occured while setting cache for key: ${key}\n${e.message}\n${e.stack}`
)
})
return result
}
type TargetMethodArgs<Target, PropertyKey> = Target[PropertyKey &
keyof Target] extends (...args: any[]) => any
? Parameters<Target[PropertyKey & keyof Target]>
: never
/**
* This function is used to cache the result of a method call.
*
* @param options - The options for the cache.
* @returns The original method with the cache applied.
*/
export function Cached<
const Target extends object,
const PropertyKey extends keyof Target
>(options: {
/**
* The key to use for the cache.
* If a function is provided, it will be called with the arguments as the first argument and the
* container as the second argument.
*/
key?:
| string
| ((
args: TargetMethodArgs<Target, PropertyKey>,
cachingModule: ICachingModuleService
) => string | Promise<string> | Promise<any[]> | any[])
/**
* Whether to enable the cache. This is only useful if you want to enable without providing any
* other options.
*/
enable?:
| boolean
| ((args: TargetMethodArgs<Target, PropertyKey>) => boolean | undefined)
/**
* The tags to use for the cache.
*/
tags?:
| string[]
| ((args: TargetMethodArgs<Target, PropertyKey>) => string[] | undefined)
/**
* The time-to-live (TTL) value in seconds.
*/
ttl?:
| number
| ((args: TargetMethodArgs<Target, PropertyKey>) => number | undefined)
/**
* Whether to auto invalidate the cache whenever it is possible.
*/
autoInvalidate?:
| boolean
| ((args: TargetMethodArgs<Target, PropertyKey>) => boolean | undefined)
/**
* The providers to use for the cache.
*/
providers?:
| string[]
| ((args: TargetMethodArgs<Target, PropertyKey>) => string[] | undefined)
container: MedusaContainer | ((this: Target) => MedusaContainer)
}) {
return function (
target: Target,
propertyKey: PropertyKey,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value
if (typeof originalMethod !== "function") {
throw new Error("@cached can only be applied to methods")
}
descriptor.value = async function (
...args: Target[PropertyKey & keyof Target] extends (
...args: any[]
) => any
? Parameters<Target[PropertyKey & keyof Target]>
: never
) {
const container: MedusaContainer =
typeof options.container === "function"
? options.container.call(this)
: options.container
const cachingModule = container.resolve<ICachingModuleService>(
Modules.CACHING,
{
allowUnregistered: true,
}
)
if (!FeatureFlag.isFeatureEnabled("caching") || !cachingModule) {
return await originalMethod.apply(this, args)
}
if (!options.key) {
options.key = await cachingModule.computeKey(
args
.map((arg) => {
if (isObject(arg)) {
// Prevent any container, manager, transactionManager, etc from being included in the key
const {
container,
manager,
transactionManager,
__type,
...rest
} = arg as any
if (__type === MedusaContextType) {
return
}
return rest
}
return arg
})
.filter(Boolean)
)
}
const resolvableKeys = [
"enable",
"key",
"tags",
"ttl",
"autoInvalidate",
"providers",
]
const cacheOptions = {} as Parameters<typeof useCache>[1]
const promises: Promise<any>[] = []
for (const key of resolvableKeys) {
if (typeof options[key] === "function") {
const res = options[key](args, cachingModule)
if (res instanceof Promise) {
promises.push(
res.then((value) => {
cacheOptions[key] = value
})
)
} else {
cacheOptions[key] = res
}
} else {
cacheOptions[key] = options[key]
}
}
await Promise.all(promises)
if (!cacheOptions.enable) {
return await originalMethod.apply(this, args)
}
Object.assign(cacheOptions, {
container,
})
return await useCache(
() => originalMethod.apply(this, args),
cacheOptions as Parameters<typeof useCache>[1]
)
}
return descriptor
}
}
@@ -2,7 +2,22 @@ import { ContainerLike } from "@medusajs/types"
export function createContainerLike(obj): ContainerLike {
return {
resolve(key: string) {
resolve(
key: string,
{
allowUnregistered = false,
}: {
allowUnregistered?: boolean
} = {}
) {
if (allowUnregistered) {
try {
return obj[key]
} catch (error) {
return undefined
}
}
return obj[key]
},
}
+1 -1
View File
@@ -14,7 +14,7 @@ export class IdProperty extends BaseProperty<string> {
return !!value?.[IsIdProperty] || value?.dataType?.name === "id"
}
protected dataType: {
dataType: {
name: "id"
options: {
prefix?: string
@@ -24,6 +24,10 @@ export class PrimaryKeyModifier<T, Schema extends PropertyType<T>>
*/
#schema: Schema
get schema() {
return this.#schema
}
constructor(schema: Schema) {
this.#schema = schema
}
+50 -1
View File
@@ -1,4 +1,8 @@
import { EventBusTypes, InternalModuleDeclaration } from "@medusajs/types"
import {
EventBusTypes,
InterceptorSubscriber,
InternalModuleDeclaration,
} from "@medusajs/types"
import { ulid } from "ulid"
export abstract class AbstractEventBusModuleService
@@ -11,6 +15,8 @@ export abstract class AbstractEventBusModuleService
EventBusTypes.SubscriberDescriptor[]
> = new Map()
protected interceptorSubscribers_: Set<InterceptorSubscriber> = new Set()
public get eventToSubscribersMap(): Map<
string | symbol,
EventBusTypes.SubscriberDescriptor[]
@@ -134,6 +140,49 @@ export abstract class AbstractEventBusModuleService
return this
}
/**
* Add an interceptor subscriber that receives all messages before they are emitted
*
* @param interceptor - Function that receives messages before emission
* @returns this for chaining
*/
public addInterceptor(interceptor: InterceptorSubscriber): this {
this.interceptorSubscribers_.add(interceptor)
return this
}
/**
* Remove an interceptor subscriber
*
* @param interceptor - Function to remove from interceptors
* @returns this for chaining
*/
public removeInterceptor(interceptor: InterceptorSubscriber): this {
this.interceptorSubscribers_.delete(interceptor)
return this
}
/**
* Call all interceptor subscribers with the message before emission
* This should be called by implementations before emitting events
*
* @param message - The message to be intercepted
* @param context - Optional context about the emission
*/
protected async callInterceptors<T = unknown>(
message: EventBusTypes.Message<T>,
context?: { isGrouped?: boolean; eventGroupId?: string }
): Promise<void> {
Array.from(this.interceptorSubscribers_).map(async (interceptor) => {
try {
await interceptor(message, context)
} catch (error) {
// Log error but don't stop other interceptors or the emission
console.error("Error in event bus interceptor:", error)
}
})
}
}
export * from "./build-event-messages"
+1
View File
@@ -29,6 +29,7 @@ export * from "./shipping"
export * from "./totals"
export * from "./totals/big-number"
export * from "./user"
export * from "./caching"
export const MedusaModuleType = Symbol.for("MedusaModule")
export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider")
@@ -48,6 +48,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -136,6 +137,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {},
alias: [
{
@@ -176,6 +178,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -269,6 +272,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {},
alias: [
{
@@ -300,6 +304,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
},
@@ -335,6 +340,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: expect.any(String),
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -27,6 +27,7 @@ export const Modules = {
INDEX: "index",
LOCKING: "locking",
SETTINGS: "settings",
CACHING: "caching",
} as const
export const MODULE_PACKAGE_NAMES = {
@@ -58,6 +59,7 @@ export const MODULE_PACKAGE_NAMES = {
[Modules.INDEX]: "@medusajs/medusa/index-module",
[Modules.LOCKING]: "@medusajs/medusa/locking",
[Modules.SETTINGS]: "@medusajs/medusa/settings",
[Modules.CACHING]: "@medusajs/caching",
}
export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries(
@@ -46,8 +46,10 @@ export function defineJoinerConfig(
models,
linkableKeys,
primaryKeys,
idPrefixToEntityName,
}: {
alias?: JoinerServiceConfigAlias[]
idPrefixToEntityName?: Record<string, string>
schema?: string
models?: DmlEntity<any, any>[] | { name: string }[]
linkableKeys?: ModuleJoinerConfig["linkableKeys"]
@@ -150,6 +152,12 @@ export function defineJoinerConfig(
schema = toGraphQLSchema([...modelDefinitions.values()])
}
if (!idPrefixToEntityName) {
idPrefixToEntityName = buildIdPrefixToEntityNameFromDmlObjects([
...modelDefinitions.values(),
])
}
const linkableKeysFromDml = buildLinkableKeysFromDmlObjects([
...modelDefinitions.values(),
])
@@ -199,6 +207,7 @@ export function defineJoinerConfig(
serviceName,
primaryKeys,
schema,
idPrefixToEntityName,
linkableKeys: linkableKeys,
alias: [
...[...(alias ?? ([] as any))].map((alias) => ({
@@ -230,6 +239,31 @@ export function defineJoinerConfig(
}
}
/**
* Build the id prefix to entity name map from the DML objects
* @param models
*/
export function buildIdPrefixToEntityNameFromDmlObjects(
models: DmlEntity<any, any>[]
): Record<string, string> {
return models.reduce((acc, model) => {
const id = model.parse().schema.id as
| IdProperty
| PrimaryKeyModifier<any, any>
if (
PrimaryKeyModifier.isPrimaryKeyModifier(id) &&
id.schema.dataType.options.prefix
) {
acc[id.schema.dataType.options.prefix] = model.name
} else if (IdProperty.isIdProperty(id) && id.dataType.options.prefix) {
acc[id.dataType.options.prefix] = model.name
}
return acc
}, {})
}
/**
* From a set of DML objects, build the linkable keys
*
@@ -1,6 +1,7 @@
import { Constructor, IDmlEntity, ModuleExports } from "@medusajs/types"
import { DmlEntity } from "../dml"
import {
buildIdPrefixToEntityNameFromDmlObjects,
buildLinkConfigFromLinkableKeys,
buildLinkConfigFromModelObjects,
defineJoinerConfig,
@@ -53,6 +54,10 @@ export function Module<
// TODO: Add support for non linkable modifier DML object to be skipped from the linkable generation
const linkableKeys = service.prototype.__joinerConfig().linkableKeys
service.prototype.__joinerConfig().idPrefixToEntityName =
buildIdPrefixToEntityNameFromDmlObjects(
dmlObjects.map(([, model]) => model) as DmlEntity<any, any>[]
)
if (dmlObjects.length) {
linkable = buildLinkConfigFromModelObjects<ServiceName, ModelObjects>(
@@ -155,18 +155,25 @@ const getDataForComputation = async (
query: Omit<RemoteQueryFunction, symbol>,
data: { variant_ids: string[]; sales_channel_id?: string }
) => {
const { data: variantInventoryItems } = await query.graph({
entity: "product_variant_inventory_items",
fields: [
"variant_id",
"required_quantity",
"variant.manage_inventory",
"variant.allow_backorder",
"inventory.*",
"inventory.location_levels.*",
],
filters: { variant_id: data.variant_ids },
})
const { data: variantInventoryItems } = await query.graph(
{
entity: "product_variant_inventory_items",
fields: [
"variant_id",
"required_quantity",
"variant.manage_inventory",
"variant.allow_backorder",
"inventory.*",
"inventory.location_levels.*",
],
filters: { variant_id: data.variant_ids },
},
{
cache: {
enable: true,
},
}
)
const variantInventoriesMap = new Map()
variantInventoryItems.forEach((link) => {
@@ -177,11 +184,21 @@ const getDataForComputation = async (
const locationIds = new Set<string>()
if (data.sales_channel_id) {
const { data: channelLocations } = await query.graph({
entity: "sales_channel_locations",
fields: ["stock_location_id"],
filters: { sales_channel_id: data.sales_channel_id },
})
const { data: channelLocations } = await query.graph(
{
entity: "sales_channel_locations",
fields: ["stock_location_id"],
filters: { sales_channel_id: data.sales_channel_id },
},
{
cache: {
tags: [
`SalesChannel:${data.sales_channel_id}`,
"StockLocation:list:*",
],
},
}
)
channelLocations.forEach((loc) => locationIds.add(loc.stock_location_id))
}