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
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user