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:
committed by
GitHub
parent
5b135a41fe
commit
b9d6f73320
487
packages/modules/caching/src/utils/__tests__/parser.test.ts
Normal file
487
packages/modules/caching/src/utils/__tests__/parser.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { GraphQLSchema, buildSchema } from "graphql"
|
||||
import { CacheInvalidationParser, EntityReference } from "../parser"
|
||||
|
||||
describe("CacheInvalidationParser", () => {
|
||||
let parser: CacheInvalidationParser
|
||||
let schema: GraphQLSchema
|
||||
|
||||
beforeEach(() => {
|
||||
const schemaDefinition = `
|
||||
type Product {
|
||||
id: ID!
|
||||
title: String
|
||||
description: String
|
||||
collection: ProductCollection
|
||||
categories: [ProductCategory!]
|
||||
variants: [ProductVariant!]
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
|
||||
type ProductCollection {
|
||||
id: ID!
|
||||
title: String
|
||||
products: [Product!]
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
|
||||
type ProductCategory {
|
||||
id: ID!
|
||||
name: String
|
||||
products: [Product!]
|
||||
parent: ProductCategory
|
||||
children: [ProductCategory!]
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
|
||||
type ProductVariant {
|
||||
id: ID!
|
||||
title: String
|
||||
sku: String
|
||||
product: Product!
|
||||
prices: [Price!]
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
|
||||
type Price {
|
||||
id: ID!
|
||||
amount: Int
|
||||
currency_code: String
|
||||
variant: ProductVariant!
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
|
||||
type Order {
|
||||
id: ID!
|
||||
status: String
|
||||
items: [OrderItem!]
|
||||
customer: Customer
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
|
||||
type OrderItem {
|
||||
id: ID!
|
||||
quantity: Int
|
||||
order: Order!
|
||||
variant: ProductVariant!
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
|
||||
type Customer {
|
||||
id: ID!
|
||||
first_name: String
|
||||
last_name: String
|
||||
email: String
|
||||
orders: [Order!]
|
||||
created_at: String
|
||||
updated_at: String
|
||||
}
|
||||
`
|
||||
|
||||
schema = buildSchema(schemaDefinition)
|
||||
parser = new CacheInvalidationParser(schema, [
|
||||
// Partially populate this record ro force the test to match from both id prefix or type
|
||||
// detection
|
||||
{
|
||||
idPrefixToEntityName: {
|
||||
prod: "Product",
|
||||
col: "ProductCollection",
|
||||
cat: "ProductCategory",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe("parseObjectForEntities", () => {
|
||||
it("should identify a simple product entity", () => {
|
||||
const product = {
|
||||
id: "prod_123",
|
||||
title: "Test Product",
|
||||
description: "A test product",
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(product)
|
||||
|
||||
expect(entities).toHaveLength(1)
|
||||
expect(entities[0]).toEqual({
|
||||
type: "Product",
|
||||
id: "prod_123",
|
||||
isInArray: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should identify nested entities in a product with collection", () => {
|
||||
const product = {
|
||||
id: "prod_123",
|
||||
title: "Test Product",
|
||||
collection: {
|
||||
id: "col_456",
|
||||
title: "Test Collection",
|
||||
},
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(product)
|
||||
|
||||
expect(entities).toHaveLength(2)
|
||||
expect(entities).toContainEqual({
|
||||
type: "Product",
|
||||
id: "prod_123",
|
||||
isInArray: false,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "ProductCollection",
|
||||
id: "col_456",
|
||||
isInArray: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should identify entities in arrays", () => {
|
||||
const product = {
|
||||
id: "prod_123",
|
||||
title: "Test Product",
|
||||
variants: [
|
||||
{
|
||||
id: "var_789",
|
||||
title: "Variant 1",
|
||||
sku: "SKU-001",
|
||||
},
|
||||
{
|
||||
id: "var_790",
|
||||
title: "Variant 2",
|
||||
sku: "SKU-002",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(product)
|
||||
|
||||
expect(entities).toHaveLength(3)
|
||||
expect(entities).toContainEqual({
|
||||
type: "Product",
|
||||
id: "prod_123",
|
||||
isInArray: false,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "ProductVariant",
|
||||
id: "var_789",
|
||||
isInArray: true,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "ProductVariant",
|
||||
id: "var_790",
|
||||
isInArray: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle deeply nested entities", () => {
|
||||
const order = {
|
||||
id: "order_123",
|
||||
status: "completed",
|
||||
items: [
|
||||
{
|
||||
id: "item_456",
|
||||
quantity: 2,
|
||||
variant: {
|
||||
id: "var_789",
|
||||
title: "Variant 1",
|
||||
product: {
|
||||
id: "prod_123",
|
||||
title: "Test Product",
|
||||
collection: {
|
||||
id: "col_456",
|
||||
title: "Test Collection",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
customer: {
|
||||
id: "cus_789",
|
||||
email: "test@example.com",
|
||||
first_name: "John",
|
||||
},
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(order)
|
||||
|
||||
expect(entities).toHaveLength(6)
|
||||
expect(entities).toContainEqual({
|
||||
type: "Order",
|
||||
id: "order_123",
|
||||
isInArray: false,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "OrderItem",
|
||||
id: "item_456",
|
||||
isInArray: true,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "ProductVariant",
|
||||
id: "var_789",
|
||||
isInArray: false,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "Product",
|
||||
id: "prod_123",
|
||||
isInArray: false,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "ProductCollection",
|
||||
id: "col_456",
|
||||
isInArray: false,
|
||||
})
|
||||
expect(entities).toContainEqual({
|
||||
type: "Customer",
|
||||
id: "cus_789",
|
||||
isInArray: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return empty array for null or primitive values", () => {
|
||||
expect(parser.parseObjectForEntities(null)).toEqual([])
|
||||
expect(parser.parseObjectForEntities(undefined)).toEqual([])
|
||||
expect(parser.parseObjectForEntities("string")).toEqual([])
|
||||
expect(parser.parseObjectForEntities(123)).toEqual([])
|
||||
expect(parser.parseObjectForEntities(true)).toEqual([])
|
||||
})
|
||||
|
||||
it("should ignore objects without id field", () => {
|
||||
const invalidObject = {
|
||||
title: "No ID Object",
|
||||
description: "This object has no ID",
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(invalidObject)
|
||||
expect(entities).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle objects with partial field matches", () => {
|
||||
const partialProduct = {
|
||||
id: "prod_123",
|
||||
title: "Test Product",
|
||||
unknown_field: "Should still work",
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(partialProduct)
|
||||
|
||||
expect(entities).toHaveLength(1)
|
||||
expect(entities[0]).toEqual({
|
||||
type: "Product",
|
||||
id: "prod_123",
|
||||
isInArray: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildInvalidationEvents", () => {
|
||||
it("should build invalidation events for a single entity", () => {
|
||||
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
|
||||
|
||||
const events = parser.buildInvalidationEvents(entities)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
entityType: "Product",
|
||||
entityId: "prod_123",
|
||||
relatedEntities: [],
|
||||
})
|
||||
|
||||
expect(events[0].cacheKeys).toEqual(["Product:prod_123"])
|
||||
})
|
||||
|
||||
it("should build invalidation events with related entities", () => {
|
||||
const entities: EntityReference[] = [
|
||||
{ type: "Product", id: "prod_123" },
|
||||
{ type: "ProductCollection", id: "col_456" },
|
||||
{ type: "ProductVariant", id: "var_789" },
|
||||
]
|
||||
|
||||
const events = parser.buildInvalidationEvents(entities)
|
||||
|
||||
expect(events).toHaveLength(3)
|
||||
|
||||
const productEvent = events.find((e) => e.entityType === "Product")
|
||||
expect(productEvent).toBeDefined()
|
||||
expect(productEvent!.relatedEntities).toHaveLength(2)
|
||||
expect(productEvent!.cacheKeys).toEqual(["Product:prod_123"])
|
||||
})
|
||||
|
||||
it("should avoid duplicate entities in events", () => {
|
||||
const entities: EntityReference[] = [
|
||||
{ type: "Product", id: "prod_123" },
|
||||
{ type: "Product", id: "prod_123" }, // Duplicate
|
||||
{ type: "ProductCollection", id: "col_456" },
|
||||
]
|
||||
|
||||
const events = parser.buildInvalidationEvents(entities)
|
||||
|
||||
expect(events).toHaveLength(2) // Should only have Product and ProductCollection events
|
||||
expect(events.map((e) => e.entityType).sort()).toEqual([
|
||||
"Product",
|
||||
"ProductCollection",
|
||||
])
|
||||
})
|
||||
|
||||
it("should generate comprehensive cache keys", () => {
|
||||
const entities: EntityReference[] = [
|
||||
{ type: "Product", id: "prod_123" },
|
||||
{ type: "ProductCollection", id: "col_456" },
|
||||
]
|
||||
|
||||
const events = parser.buildInvalidationEvents(entities)
|
||||
const productEvent = events.find((e) => e.entityType === "Product")!
|
||||
|
||||
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("integration scenarios", () => {
|
||||
it("should handle a complete product updated scenario", () => {
|
||||
const productData = {
|
||||
id: "prod_123",
|
||||
title: "Updated Product Title",
|
||||
collection: {
|
||||
id: "col_456",
|
||||
title: "Fashion Collection",
|
||||
},
|
||||
categories: [
|
||||
{ id: "cat_789", name: "Shirts" },
|
||||
{ id: "cat_790", name: "Casual" },
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
id: "var_111",
|
||||
title: "Size S",
|
||||
prices: [{ id: "price_222", amount: 2999, currency_code: "USD" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(productData)
|
||||
const events = parser.buildInvalidationEvents(entities)
|
||||
|
||||
// Should identify all nested entities
|
||||
expect(entities).toHaveLength(6) // Product, Collection, 2 Categories, Variant, Price
|
||||
|
||||
// Should created events for each entity type
|
||||
expect(events).toHaveLength(6)
|
||||
|
||||
// Validate cache keys for each entity type
|
||||
const productEvent = events.find((e) => e.entityType === "Product")!
|
||||
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
|
||||
|
||||
const collectionEvent = events.find(
|
||||
(e) => e.entityType === "ProductCollection"
|
||||
)!
|
||||
expect(collectionEvent.cacheKeys).toEqual(["ProductCollection:col_456"])
|
||||
|
||||
const categoryEvents = events.filter(
|
||||
(e) => e.entityType === "ProductCategory"
|
||||
)
|
||||
expect(categoryEvents).toHaveLength(2)
|
||||
expect(categoryEvents[0].cacheKeys).toEqual([
|
||||
"ProductCategory:cat_789",
|
||||
"ProductCategory:list:*",
|
||||
])
|
||||
expect(categoryEvents[1].cacheKeys).toEqual([
|
||||
"ProductCategory:cat_790",
|
||||
"ProductCategory:list:*",
|
||||
])
|
||||
|
||||
const variantEvent = events.find(
|
||||
(e) => e.entityType === "ProductVariant"
|
||||
)!
|
||||
expect(variantEvent.cacheKeys).toEqual([
|
||||
"ProductVariant:var_111",
|
||||
"ProductVariant:list:*",
|
||||
])
|
||||
|
||||
const priceEvent = events.find((e) => e.entityType === "Price")!
|
||||
expect(priceEvent.cacheKeys).toEqual(["Price:price_222", "Price:list:*"])
|
||||
})
|
||||
|
||||
it("should handle order with customer and items scenario", () => {
|
||||
const orderData = {
|
||||
id: "order_123",
|
||||
status: "completed",
|
||||
customer: {
|
||||
id: "cus_456",
|
||||
email: "customer@example.com",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: "item_789",
|
||||
quantity: 2,
|
||||
variant: {
|
||||
id: "var_111",
|
||||
sku: "SHIRT-S-BLUE",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const entities = parser.parseObjectForEntities(orderData)
|
||||
const events = parser.buildInvalidationEvents(entities)
|
||||
|
||||
expect(entities).toHaveLength(4) // Order, Customer, OrderItem, ProductVariant
|
||||
expect(events).toHaveLength(4)
|
||||
|
||||
// Validate cache keys for each entity type
|
||||
const orderEvent = events.find((e) => e.entityType === "Order")!
|
||||
expect(orderEvent.cacheKeys).toEqual(["Order:order_123"])
|
||||
|
||||
const customerEvent = events.find((e) => e.entityType === "Customer")!
|
||||
expect(customerEvent.cacheKeys).toEqual(["Customer:cus_456"])
|
||||
|
||||
const itemEvent = events.find((e) => e.entityType === "OrderItem")!
|
||||
expect(itemEvent.cacheKeys).toEqual([
|
||||
"OrderItem:item_789",
|
||||
"OrderItem:list:*",
|
||||
])
|
||||
|
||||
const variantEvent = events.find(
|
||||
(e) => e.entityType === "ProductVariant"
|
||||
)!
|
||||
expect(variantEvent.cacheKeys).toEqual(["ProductVariant:var_111"])
|
||||
})
|
||||
|
||||
it("should include simplified cache keys for created operation", () => {
|
||||
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
|
||||
|
||||
const events = parser.buildInvalidationEvents(entities, "created")
|
||||
|
||||
const productEvent = events[0]
|
||||
expect(productEvent.cacheKeys).toEqual([
|
||||
"Product:prod_123",
|
||||
"Product:list:*",
|
||||
])
|
||||
})
|
||||
|
||||
it("should include simplified cache keys for deleted operation", () => {
|
||||
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
|
||||
|
||||
const events = parser.buildInvalidationEvents(entities, "deleted")
|
||||
|
||||
const productEvent = events[0]
|
||||
expect(productEvent.cacheKeys).toEqual([
|
||||
"Product:prod_123",
|
||||
"Product:list:*",
|
||||
])
|
||||
})
|
||||
|
||||
it("should include simplified cache keys for updated operation", () => {
|
||||
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
|
||||
|
||||
const events = parser.buildInvalidationEvents(entities, "updated")
|
||||
|
||||
const productEvent = events[0]
|
||||
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
|
||||
})
|
||||
})
|
||||
})
|
||||
242
packages/modules/caching/src/utils/parser.ts
Normal file
242
packages/modules/caching/src/utils/parser.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { ModuleJoinerConfig } from "@medusajs/framework/types"
|
||||
import { isObject } from "@medusajs/framework/utils"
|
||||
import {
|
||||
GraphQLObjectType,
|
||||
GraphQLSchema,
|
||||
isListType,
|
||||
isNonNullType,
|
||||
isObjectType,
|
||||
} from "graphql"
|
||||
|
||||
export interface EntityReference {
|
||||
type: string
|
||||
id: string | number
|
||||
field?: string
|
||||
isInArray?: boolean
|
||||
}
|
||||
|
||||
export interface InvalidationEvent {
|
||||
entityType: string
|
||||
entityId: string | number
|
||||
relatedEntities: EntityReference[]
|
||||
cacheKeys: string[]
|
||||
}
|
||||
|
||||
export class CacheInvalidationParser {
|
||||
private typeMap: Map<string, GraphQLObjectType>
|
||||
private idPrefixToEntityName: Record<string, string>
|
||||
|
||||
constructor(schema: GraphQLSchema, joinerConfigs: ModuleJoinerConfig[]) {
|
||||
this.typeMap = new Map()
|
||||
|
||||
// Build type map for quick lookups
|
||||
const schemaTypeMap = schema.getTypeMap()
|
||||
Object.keys(schemaTypeMap).forEach((typeName) => {
|
||||
const type = schemaTypeMap[typeName]
|
||||
if (isObjectType(type) && !typeName.startsWith("__")) {
|
||||
this.typeMap.set(typeName, type)
|
||||
}
|
||||
})
|
||||
|
||||
this.idPrefixToEntityName = joinerConfigs.reduce((acc, joinerConfig) => {
|
||||
if (joinerConfig.idPrefixToEntityName) {
|
||||
Object.entries(joinerConfig.idPrefixToEntityName).forEach(
|
||||
([idPrefix, entityName]) => {
|
||||
acc[idPrefix] = entityName
|
||||
}
|
||||
)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an object to identify entities and their relationships
|
||||
*/
|
||||
parseObjectForEntities(
|
||||
obj: any,
|
||||
parentType?: string,
|
||||
isInArray: boolean = false
|
||||
): EntityReference[] {
|
||||
const entities: EntityReference[] = []
|
||||
|
||||
if (!obj || typeof obj !== "object") {
|
||||
return entities
|
||||
}
|
||||
|
||||
// Check if this object matches any known GraphQL types
|
||||
const detectedType = this.detectEntityType(obj, parentType)
|
||||
if (detectedType && obj.id) {
|
||||
entities.push({
|
||||
type: detectedType,
|
||||
id: obj.id,
|
||||
isInArray,
|
||||
})
|
||||
}
|
||||
|
||||
// Recursively parse nested objects and arrays
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const value = obj[key]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
entities.push(
|
||||
...this.parseObjectForEntities(
|
||||
item,
|
||||
this.getRelationshipType(detectedType, key),
|
||||
true
|
||||
)
|
||||
)
|
||||
})
|
||||
} else if (isObject(value)) {
|
||||
entities.push(
|
||||
...this.parseObjectForEntities(
|
||||
value,
|
||||
this.getRelationshipType(detectedType, key),
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return entities
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect entity type based on object structure and GraphQL type map
|
||||
*/
|
||||
private detectEntityType(obj: any, suggestedType?: string): string | null {
|
||||
if (obj.id) {
|
||||
const idParts = obj.id.split("_")
|
||||
if (idParts.length > 1 && this.idPrefixToEntityName[idParts[0]]) {
|
||||
return this.idPrefixToEntityName[idParts[0]]
|
||||
}
|
||||
}
|
||||
|
||||
if (suggestedType && this.typeMap.has(suggestedType)) {
|
||||
const type = this.typeMap.get(suggestedType)!
|
||||
if (this.objectMatchesType(obj, type)) {
|
||||
return suggestedType
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match against all known types
|
||||
for (const [typeName, type] of this.typeMap) {
|
||||
if (this.objectMatchesType(obj, type)) {
|
||||
return typeName
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if object structure matches GraphQL type fields
|
||||
*/
|
||||
private objectMatchesType(obj: any, type: GraphQLObjectType): boolean {
|
||||
const fields = type.getFields()
|
||||
const objKeys = Object.keys(obj)
|
||||
|
||||
// Must have id field for entities
|
||||
if (!obj.id || !fields.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if at least 50% of non-null object fields match type fields
|
||||
const matchingFields = objKeys.filter((key) => fields[key]).length
|
||||
return matchingFields >= Math.max(1, objKeys.length * 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected type for a relationship field
|
||||
*/
|
||||
private getRelationshipType(
|
||||
parentType: string | null,
|
||||
fieldName: string
|
||||
): string | undefined {
|
||||
if (!parentType || !this.typeMap.has(parentType)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const type = this.typeMap.get(parentType)!
|
||||
const field = type.getFields()[fieldName]
|
||||
|
||||
if (!field) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let fieldType = field.type
|
||||
|
||||
// Unwrap NonNull and List wrappers
|
||||
if (isNonNullType(fieldType)) {
|
||||
fieldType = fieldType.ofType
|
||||
}
|
||||
if (isListType(fieldType)) {
|
||||
fieldType = fieldType.ofType
|
||||
}
|
||||
if (isNonNullType(fieldType)) {
|
||||
fieldType = fieldType.ofType
|
||||
}
|
||||
|
||||
if (isObjectType(fieldType)) {
|
||||
return fieldType.name
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build invalidation events based on parsed entities
|
||||
*/
|
||||
buildInvalidationEvents(
|
||||
entities: EntityReference[],
|
||||
operation: "created" | "updated" | "deleted" = "updated"
|
||||
): InvalidationEvent[] {
|
||||
const events: InvalidationEvent[] = []
|
||||
const processedEntities = new Set<string>()
|
||||
|
||||
entities.forEach((entity) => {
|
||||
const entityKey = `${entity.type}:${entity.id}`
|
||||
|
||||
if (processedEntities.has(entityKey)) {
|
||||
return
|
||||
}
|
||||
processedEntities.add(entityKey)
|
||||
|
||||
const relatedEntities = entities.filter(
|
||||
(e) => e.type !== entity.type || e.id !== entity.id
|
||||
)
|
||||
|
||||
const affectedKeys = this.buildAffectedCacheKeys(entity, operation)
|
||||
|
||||
events.push({
|
||||
entityType: entity.type,
|
||||
entityId: entity.id,
|
||||
relatedEntities,
|
||||
cacheKeys: affectedKeys,
|
||||
})
|
||||
})
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of cache keys that should be invalidated
|
||||
*/
|
||||
private buildAffectedCacheKeys(
|
||||
entity: EntityReference,
|
||||
operation: "created" | "updated" | "deleted" = "updated"
|
||||
): string[] {
|
||||
const keys = new Set<string>()
|
||||
|
||||
keys.add(`${entity.type}:${entity.id}`)
|
||||
|
||||
// Add list key only if entity was found in an array context or if an event of type created or
|
||||
// deleted is triggered
|
||||
if (entity.isInArray || ["created", "deleted"].includes(operation)) {
|
||||
keys.add(`${entity.type}:list:*`)
|
||||
}
|
||||
|
||||
return Array.from(keys)
|
||||
}
|
||||
}
|
||||
133
packages/modules/caching/src/utils/strategy.ts
Normal file
133
packages/modules/caching/src/utils/strategy.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
Event,
|
||||
ICachingModuleService,
|
||||
ICachingStrategy,
|
||||
ModuleJoinerConfig,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
type GraphQLSchema,
|
||||
Modules,
|
||||
toCamelCase,
|
||||
upperCaseFirst,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { type CachingModuleService } from "@services"
|
||||
import type { InjectedDependencies } from "@types"
|
||||
import stringify from "fast-json-stable-stringify"
|
||||
import { CacheInvalidationParser, EntityReference } from "./parser"
|
||||
|
||||
export class DefaultCacheStrategy implements ICachingStrategy {
|
||||
#cacheInvalidationParser: CacheInvalidationParser
|
||||
#cacheModule: ICachingModuleService
|
||||
#container: InjectedDependencies
|
||||
#hasher: (data: string) => string
|
||||
|
||||
constructor(
|
||||
container: InjectedDependencies,
|
||||
cacheModule: CachingModuleService
|
||||
) {
|
||||
this.#cacheModule = cacheModule
|
||||
this.#container = container
|
||||
this.#hasher = container.hasher
|
||||
}
|
||||
|
||||
objectHash(input: any): string {
|
||||
const str = stringify(input)
|
||||
return this.#hasher(str)
|
||||
}
|
||||
|
||||
async onApplicationStart(
|
||||
schema: GraphQLSchema,
|
||||
joinerConfigs: ModuleJoinerConfig[]
|
||||
) {
|
||||
this.#cacheInvalidationParser = new CacheInvalidationParser(
|
||||
schema,
|
||||
joinerConfigs
|
||||
)
|
||||
|
||||
const eventBus = this.#container[Modules.EVENT_BUS]
|
||||
|
||||
const handleEvent = async (data: Event) => {
|
||||
try {
|
||||
// We dont have to await anything here and the rest can be done in the background
|
||||
return
|
||||
} finally {
|
||||
const eventName = data.name
|
||||
const operation = eventName.split(".").pop() as
|
||||
| "created"
|
||||
| "updated"
|
||||
| "deleted"
|
||||
const entityType = eventName.split(".").slice(-2).shift()!
|
||||
|
||||
const eventData = data.data as
|
||||
| { id: string | string[] }
|
||||
| { id: string | string[] }[]
|
||||
|
||||
const normalizedEventData = Array.isArray(eventData)
|
||||
? eventData
|
||||
: [eventData]
|
||||
|
||||
const tags: string[] = []
|
||||
for (const item of normalizedEventData) {
|
||||
const ids = Array.isArray(item.id) ? item.id : [item.id]
|
||||
|
||||
for (const id of ids) {
|
||||
const entityReference: EntityReference = {
|
||||
type: upperCaseFirst(toCamelCase(entityType)),
|
||||
id,
|
||||
}
|
||||
|
||||
const tags_ = await this.computeTags(item, {
|
||||
entities: [entityReference],
|
||||
operation,
|
||||
})
|
||||
tags.push(...tags_)
|
||||
}
|
||||
}
|
||||
|
||||
void this.#cacheModule.clear({
|
||||
tags,
|
||||
options: { autoInvalidate: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.subscribe("*", handleEvent)
|
||||
eventBus.addInterceptor?.(handleEvent)
|
||||
}
|
||||
|
||||
async computeKey(input: object) {
|
||||
return this.objectHash(input)
|
||||
}
|
||||
|
||||
async computeTags(
|
||||
input: object,
|
||||
options?: {
|
||||
entities?: EntityReference[]
|
||||
operation?: "created" | "updated" | "deleted"
|
||||
}
|
||||
): Promise<string[]> {
|
||||
// Parse the input object to identify entities
|
||||
const entities_ =
|
||||
options?.entities ||
|
||||
this.#cacheInvalidationParser.parseObjectForEntities(input)
|
||||
|
||||
if (entities_.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Build invalidation events to get comprehensive cache keys
|
||||
const events = this.#cacheInvalidationParser.buildInvalidationEvents(
|
||||
entities_,
|
||||
options?.operation
|
||||
)
|
||||
|
||||
// Collect all unique cache keys from all events as tags
|
||||
const tags = new Set<string>()
|
||||
|
||||
events.forEach((event) => {
|
||||
event.cacheKeys.forEach((key) => tags.add(key))
|
||||
})
|
||||
|
||||
return Array.from(tags)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user