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:
co-authored by
Carlos R. L. Rodrigues
parent
5b135a41fe
commit
b9d6f73320
@@ -0,0 +1,6 @@
|
||||
/dist
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
.env
|
||||
*.sql
|
||||
@@ -0,0 +1 @@
|
||||
# @medusajs/caching
|
||||
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
const starSubscribers = this.subscribers_.get("*")
|
||||
|
||||
for (const subscriber of [
|
||||
...(subscribers ?? []),
|
||||
...(starSubscribers ?? []),
|
||||
]) {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { ICachingModuleService } from "@medusajs/framework/types"
|
||||
import { MedusaModule } from "@medusajs/framework/modules-sdk"
|
||||
|
||||
jest.setTimeout(10000)
|
||||
|
||||
jest.spyOn(MedusaModule, "getAllJoinerConfigs").mockReturnValue([
|
||||
{
|
||||
schema: `
|
||||
type Product {
|
||||
id: ID
|
||||
title: String
|
||||
handle: String
|
||||
status: String
|
||||
type_id: String
|
||||
collection_id: String
|
||||
is_giftcard: Boolean
|
||||
external_id: String
|
||||
created_at: DateTime
|
||||
updated_at: DateTime
|
||||
|
||||
variants: [ProductVariant]
|
||||
sales_channels: [SalesChannel]
|
||||
}
|
||||
|
||||
type ProductVariant {
|
||||
id: ID
|
||||
product_id: String
|
||||
sku: String
|
||||
|
||||
prices: [Price]
|
||||
}
|
||||
|
||||
type Price {
|
||||
id: ID
|
||||
amount: Float
|
||||
currency_code: String
|
||||
}
|
||||
|
||||
type SalesChannel {
|
||||
id: ID
|
||||
is_disabled: Boolean
|
||||
}
|
||||
`,
|
||||
},
|
||||
])
|
||||
|
||||
moduleIntegrationTestRunner<ICachingModuleService>({
|
||||
moduleName: Modules.CACHING,
|
||||
testSuite: ({ service }) => {
|
||||
describe("Caching Module Service", () => {
|
||||
beforeEach(async () => {
|
||||
await service.clear({ tags: ["*"] }).catch(() => {})
|
||||
})
|
||||
|
||||
describe("Basic Cache Operations", () => {
|
||||
it("should set and get cache data with default memory provider", async () => {
|
||||
const testData = { id: "test-id", name: "Test Item" }
|
||||
|
||||
await service.set({
|
||||
key: "test-key",
|
||||
data: testData,
|
||||
ttl: 3600,
|
||||
})
|
||||
|
||||
const result = await service.get({ key: "test-key" })
|
||||
expect(result).toEqual(testData)
|
||||
})
|
||||
|
||||
it("should return null for non-existent keys", async () => {
|
||||
const result = await service.get({ key: "non-existent" })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle tags-based storage and retrieval", async () => {
|
||||
const testData1 = { id: "1", name: "Item 1" }
|
||||
const testData2 = { id: "2", name: "Item 2" }
|
||||
|
||||
await service.set({
|
||||
key: "item-1",
|
||||
data: testData1,
|
||||
tags: ["product", "active"],
|
||||
})
|
||||
|
||||
await service.set({
|
||||
key: "item-2",
|
||||
data: testData2,
|
||||
tags: ["product", "inactive"],
|
||||
})
|
||||
|
||||
const productResults = await service.get({ tags: ["product"] })
|
||||
expect(productResults).toHaveLength(2)
|
||||
expect(productResults).toContainEqual(testData1)
|
||||
expect(productResults).toContainEqual(testData2)
|
||||
|
||||
const activeResults = await service.get<any[]>({ tags: ["active"] })
|
||||
expect(activeResults).toHaveLength(1)
|
||||
expect(activeResults?.[0]).toEqual(testData1)
|
||||
})
|
||||
|
||||
it("should clear cache by key", async () => {
|
||||
await service.set({
|
||||
key: "test-key",
|
||||
data: { value: "test" },
|
||||
})
|
||||
|
||||
await service.clear({ key: "test-key" })
|
||||
|
||||
const result = await service.get({ key: "test-key" })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should clear cache by tags", async () => {
|
||||
await service.set({
|
||||
key: "item-1",
|
||||
data: { id: "1" },
|
||||
tags: ["category-a"],
|
||||
})
|
||||
|
||||
await service.set({
|
||||
key: "item-2",
|
||||
data: { id: "2" },
|
||||
tags: ["category-b"],
|
||||
})
|
||||
|
||||
await service.clear({ tags: ["category-a"] })
|
||||
|
||||
const result1 = await service.get({ key: "item-1" })
|
||||
const result2 = await service.get({ key: "item-2" })
|
||||
|
||||
expect(result1).toBeNull()
|
||||
expect(result2).toEqual({ id: "2" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("Provider Priority", () => {
|
||||
it("should check providers in order of priority when specified", async () => {
|
||||
const testData = { id: "priority-test", name: "Priority Test" }
|
||||
|
||||
await service.set({
|
||||
key: "priority-key",
|
||||
data: testData,
|
||||
providers: ["cache-memory"],
|
||||
})
|
||||
|
||||
const result = await service.get({
|
||||
key: "priority-key",
|
||||
providers: ["cache-memory"],
|
||||
})
|
||||
|
||||
expect(result).toEqual(testData)
|
||||
})
|
||||
|
||||
it("should return null when providers array is empty or invalid", async () => {
|
||||
const result = await service.get({
|
||||
key: "test-key",
|
||||
providers: [],
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Promise Deduplication", () => {
|
||||
it("should deduplicate concurrent get requests with same parameters", async () => {
|
||||
const testData = { id: "concurrent-test", name: "Concurrent Test" }
|
||||
|
||||
await service.set({
|
||||
key: "concurrent-key",
|
||||
data: testData,
|
||||
})
|
||||
|
||||
const promises = Array.from({ length: 5 }, () =>
|
||||
service.get<any>({ key: "concurrent-key" })
|
||||
)
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
results.forEach((result) => {
|
||||
expect(result).toEqual(testData)
|
||||
})
|
||||
})
|
||||
|
||||
it("should deduplicate concurrent get requests with same tags", async () => {
|
||||
const testData = { id: "tag-test", name: "Tag Test" }
|
||||
|
||||
await service.set({
|
||||
key: "tag-key",
|
||||
data: testData,
|
||||
tags: ["concurrent-tag"],
|
||||
})
|
||||
|
||||
const promises = Array.from({ length: 5 }, () =>
|
||||
service.get<any[]>({ tags: ["concurrent-tag"] })
|
||||
)
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
results.forEach((result) => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result?.[0]).toEqual(testData)
|
||||
})
|
||||
})
|
||||
|
||||
it("should deduplicate concurrent clear requests", async () => {
|
||||
await service.set({
|
||||
key: "clear-test-1",
|
||||
data: { id: "1" },
|
||||
tags: ["clear-tag"],
|
||||
})
|
||||
|
||||
await service.set({
|
||||
key: "clear-test-2",
|
||||
data: { id: "2" },
|
||||
tags: ["clear-tag"],
|
||||
})
|
||||
|
||||
const promises = Array.from({ length: 3 }, () =>
|
||||
service.clear({ tags: ["clear-tag"] })
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
const result1 = await service.get({ key: "clear-test-1" })
|
||||
const result2 = await service.get({ key: "clear-test-2" })
|
||||
|
||||
expect(result1).toBeNull()
|
||||
expect(result2).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle concurrent requests with different parameters separately", async () => {
|
||||
const testData1 = { id: "1", name: "Item 1" }
|
||||
const testData2 = { id: "2", name: "Item 2" }
|
||||
|
||||
await service.set({ key: "key-1", data: testData1 })
|
||||
await service.set({ key: "key-2", data: testData2 })
|
||||
|
||||
const promises = [
|
||||
service.get({ key: "key-1" }),
|
||||
service.get({ key: "key-1" }),
|
||||
service.get({ key: "key-2" }),
|
||||
service.get({ key: "key-2" }),
|
||||
]
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
expect(results[0]).toEqual(testData1)
|
||||
expect(results[1]).toEqual(testData1)
|
||||
expect(results[2]).toEqual(testData2)
|
||||
expect(results[3]).toEqual(testData2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Memory Cache Provider Integration", () => {
|
||||
it("should respect TTL settings", async () => {
|
||||
const testData = { id: "ttl-test", name: "TTL Test" }
|
||||
|
||||
await service.set({
|
||||
key: "ttl-key",
|
||||
data: testData,
|
||||
ttl: 1,
|
||||
})
|
||||
|
||||
let result = await service.get({ key: "ttl-key" })
|
||||
expect(result).toEqual(testData)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100))
|
||||
|
||||
result = await service.get({ key: "ttl-key" })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle autoInvalidate option", async () => {
|
||||
const testData = { id: "no-auto-test", name: "No Auto Test" }
|
||||
|
||||
await service.set({
|
||||
key: "no-auto-key",
|
||||
data: testData,
|
||||
tags: ["no-auto-tag"],
|
||||
options: { autoInvalidate: false },
|
||||
})
|
||||
|
||||
await service.clear({
|
||||
tags: ["no-auto-tag"],
|
||||
options: { autoInvalidate: true },
|
||||
})
|
||||
|
||||
const result = await service.get({ key: "no-auto-key" })
|
||||
expect(result).toEqual(testData)
|
||||
|
||||
await service.clear({
|
||||
tags: ["no-auto-tag"],
|
||||
})
|
||||
|
||||
const result2 = await service.get({ key: "no-auto-key" })
|
||||
expect(result2).toBeNull()
|
||||
})
|
||||
|
||||
it("should generate consistent cache keys", async () => {
|
||||
const testInput = { userId: "123", action: "view" }
|
||||
|
||||
const key1 = await service.computeKey(testInput)
|
||||
const key2 = await service.computeKey(testInput)
|
||||
|
||||
expect(key1).toBe(key2)
|
||||
expect(typeof key1).toBe("string")
|
||||
expect(key1.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should generate cache tags", async () => {
|
||||
const testInput = { id: "prod_1", title: "123", description: "456" }
|
||||
|
||||
const tags = await service.computeTags(testInput)
|
||||
|
||||
expect(Array.isArray(tags)).toBe(true)
|
||||
expect(tags.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should throw error when neither key nor tags provided to get", async () => {
|
||||
await expect(service.get({})).rejects.toThrow(
|
||||
"Either key or tags must be provided"
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when neither key nor tags provided to clear", async () => {
|
||||
await expect(service.clear({})).rejects.toThrow(
|
||||
"Either key or tags must be provided"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,430 @@
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { ICachingModuleService } from "@medusajs/framework/types"
|
||||
import { MedusaModule } from "@medusajs/framework/modules-sdk"
|
||||
import { EventBusServiceMock } from "../__fixtures__/event-bus-mock"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
jest.spyOn(MedusaModule, "getAllJoinerConfigs").mockReturnValue([
|
||||
{
|
||||
schema: `
|
||||
type Product {
|
||||
id: ID
|
||||
title: String
|
||||
handle: String
|
||||
status: String
|
||||
type_id: String
|
||||
collection_id: String
|
||||
is_giftcard: Boolean
|
||||
external_id: String
|
||||
created_at: DateTime
|
||||
updated_at: DateTime
|
||||
|
||||
variants: [ProductVariant]
|
||||
sales_channels: [SalesChannel]
|
||||
}
|
||||
|
||||
type ProductVariant {
|
||||
id: ID
|
||||
product_id: String
|
||||
sku: String
|
||||
|
||||
prices: [Price]
|
||||
}
|
||||
|
||||
type Price {
|
||||
id: ID
|
||||
amount: Float
|
||||
currency_code: String
|
||||
variant_id: String
|
||||
}
|
||||
|
||||
type SalesChannel {
|
||||
id: ID
|
||||
is_disabled: Boolean
|
||||
}
|
||||
|
||||
type ProductCollection {
|
||||
id: ID
|
||||
title: String
|
||||
handle: String
|
||||
}
|
||||
`,
|
||||
},
|
||||
])
|
||||
|
||||
const mockEventBus = new EventBusServiceMock()
|
||||
|
||||
moduleIntegrationTestRunner<ICachingModuleService>({
|
||||
moduleName: Modules.CACHING,
|
||||
injectedDependencies: {
|
||||
[Modules.EVENT_BUS]: mockEventBus,
|
||||
},
|
||||
testSuite: ({ service }) => {
|
||||
describe("Cache Invalidation with Entity Relationships", () => {
|
||||
afterEach(async () => {
|
||||
await service.clear({ tags: ["*"] }).catch(() => {})
|
||||
})
|
||||
|
||||
describe("Single Entity Caching", () => {
|
||||
it("should cache and retrieve a single product entity using computed keys", async () => {
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Test Product",
|
||||
handle: "test-product",
|
||||
status: "published",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
})
|
||||
|
||||
const cachedProduct = await service.get({ key: productKey })
|
||||
expect(cachedProduct).toEqual(product)
|
||||
})
|
||||
|
||||
it("should auto-invalidate single entity when strategy clears computed tags", async () => {
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Test Product",
|
||||
handle: "test-product",
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
})
|
||||
|
||||
await mockEventBus.emit(
|
||||
[{ name: "product.updated", data: { id: product.id } }],
|
||||
{}
|
||||
)
|
||||
|
||||
const result = await service.get({ key: productKey })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should not auto-invalidate single entity with autoInvalidate=false", async () => {
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Test Product",
|
||||
handle: "test-product",
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
options: { autoInvalidate: false },
|
||||
})
|
||||
|
||||
await mockEventBus.emit(
|
||||
[{ name: "product.updated", data: { id: product.id } }],
|
||||
{}
|
||||
)
|
||||
|
||||
const result = await service.get({ key: productKey })
|
||||
expect(result).toEqual(product)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Entity List Caching", () => {
|
||||
it("should cache and retrieve lists of entities using computed keys", async () => {
|
||||
const publishedProductsQuery = {
|
||||
entity: "product",
|
||||
filters: { status: "published" },
|
||||
fields: ["id", "title", "status"],
|
||||
}
|
||||
|
||||
const allProductsQuery = {
|
||||
entity: "product",
|
||||
filters: {},
|
||||
fields: ["id", "title", "status"],
|
||||
}
|
||||
|
||||
const publishedProducts = [
|
||||
{ id: "prod_1", title: "Product 1", status: "published" },
|
||||
{ id: "prod_2", title: "Product 2", status: "published" },
|
||||
]
|
||||
|
||||
const allProducts = [
|
||||
...publishedProducts,
|
||||
{ id: "prod_3", title: "Product 3", status: "draft" },
|
||||
]
|
||||
|
||||
const publishedProductsKey = await service.computeKey(
|
||||
publishedProductsQuery
|
||||
)
|
||||
const allProductsKey = await service.computeKey(allProductsQuery)
|
||||
|
||||
await service.set({
|
||||
key: publishedProductsKey,
|
||||
data: publishedProducts,
|
||||
})
|
||||
|
||||
await service.set({
|
||||
key: allProductsKey,
|
||||
data: allProducts,
|
||||
})
|
||||
|
||||
const cachedPublished = await service.get({
|
||||
key: publishedProductsKey,
|
||||
})
|
||||
const cachedAll = await service.get({ key: allProductsKey })
|
||||
|
||||
expect(cachedPublished).toEqual(publishedProducts)
|
||||
expect(cachedAll).toEqual(allProducts)
|
||||
})
|
||||
|
||||
it("should invalidate related lists when individual product is updated", async () => {
|
||||
const listQuery = {
|
||||
entity: "product",
|
||||
filters: { status: "published" },
|
||||
includes: ["id", "title"],
|
||||
}
|
||||
|
||||
const products = [
|
||||
{ id: "prod_1", title: "Product 1", status: "published" },
|
||||
{ id: "prod_2", title: "Product 2", status: "published" },
|
||||
]
|
||||
|
||||
const listKey = await service.computeKey(listQuery)
|
||||
|
||||
await service.set({
|
||||
key: listKey,
|
||||
data: products,
|
||||
})
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "product.updated",
|
||||
data: { id: "prod_1", title: "Updated Product 1" },
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
const cachedList = await service.get({ key: listKey })
|
||||
|
||||
expect(cachedList).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Nested Entity Caching", () => {
|
||||
it("should cache products with nested variants and prices using computed keys", async () => {
|
||||
const productWithVariants = {
|
||||
id: "prod_1",
|
||||
title: "Complex Product",
|
||||
variants: [
|
||||
{
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-001",
|
||||
prices: [
|
||||
{
|
||||
id: "price_1",
|
||||
variant_id: "var_1",
|
||||
amount: 1000,
|
||||
currency_code: "USD",
|
||||
},
|
||||
{
|
||||
id: "price_2",
|
||||
variant_id: "var_1",
|
||||
amount: 900,
|
||||
currency_code: "EUR",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "var_2",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-002",
|
||||
prices: [
|
||||
{
|
||||
id: "price_3",
|
||||
variant_id: "var_2",
|
||||
amount: 1200,
|
||||
currency_code: "USD",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(productWithVariants)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: productWithVariants,
|
||||
})
|
||||
|
||||
const cached = await service.get<typeof productWithVariants>({
|
||||
key: productKey,
|
||||
})
|
||||
expect(cached).toEqual(productWithVariants)
|
||||
expect(cached!.variants).toHaveLength(2)
|
||||
expect(cached!.variants[0].prices).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should invalidate nested product when related variant is updated", async () => {
|
||||
const productWithVariants = {
|
||||
id: "prod_1",
|
||||
title: "Complex Product",
|
||||
variants: [{ id: "var_1", product_id: "prod_1", sku: "SKU-001" }],
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(productWithVariants)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: productWithVariants,
|
||||
})
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "product_variant.updated",
|
||||
data: {
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-001-UPDATED",
|
||||
},
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
const result = await service.get({ key: productKey })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle price updates affecting variant and product caches", async () => {
|
||||
const price = {
|
||||
id: "price_1",
|
||||
variant_id: "var_1",
|
||||
amount: 1000,
|
||||
currency_code: "USD",
|
||||
}
|
||||
|
||||
const variant = {
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-001",
|
||||
prices: [price],
|
||||
}
|
||||
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Product",
|
||||
variants: [variant],
|
||||
}
|
||||
|
||||
const priceKey = await service.computeKey(price)
|
||||
const variantKey = await service.computeKey(variant)
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: priceKey,
|
||||
data: price,
|
||||
})
|
||||
await service.set({
|
||||
key: variantKey,
|
||||
data: variant,
|
||||
})
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
})
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "price.updated",
|
||||
data: { id: "price_1", variant_id: "var_1", amount: 1100 },
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
const cachedPrice = await service.get({ key: priceKey })
|
||||
const cachedVariant = await service.get({ key: variantKey })
|
||||
const cachedProduct = await service.get({ key: productKey })
|
||||
|
||||
expect(cachedPrice).toBeNull()
|
||||
expect(cachedVariant).toBeNull()
|
||||
expect(cachedProduct).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Complex Query Caching", () => {
|
||||
it("should cache complex queries and invalidate based on entity relationships", async () => {
|
||||
const complexQuery = {
|
||||
entity: "product",
|
||||
filters: { status: "published", collection_id: "col_1" },
|
||||
includes: {
|
||||
variants: {
|
||||
include: {
|
||||
prices: true,
|
||||
},
|
||||
},
|
||||
collection: true,
|
||||
},
|
||||
pagination: { limit: 10, offset: 0 },
|
||||
}
|
||||
|
||||
const queryResult = {
|
||||
data: [
|
||||
{
|
||||
id: "prod_1",
|
||||
title: "Product 1",
|
||||
collection_id: "col_1",
|
||||
variants: [
|
||||
{
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
prices: [{ id: "price_1", amount: 1000 }],
|
||||
},
|
||||
],
|
||||
collection: { id: "col_1", title: "Collection 1" },
|
||||
},
|
||||
],
|
||||
pagination: { total: 1, limit: 10, offset: 0 },
|
||||
}
|
||||
|
||||
const queryKey = await service.computeKey(complexQuery)
|
||||
|
||||
await service.set({
|
||||
key: queryKey,
|
||||
data: queryResult,
|
||||
})
|
||||
|
||||
const cached = await service.get({ key: queryKey })
|
||||
expect(cached).toEqual(queryResult)
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "price.updated",
|
||||
data: { id: "price_1", amount: 1100 },
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
const cachedAfterUpdate = await service.get({ key: queryKey })
|
||||
expect(cachedAfterUpdate).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,540 @@
|
||||
import { MedusaModule } from "@medusajs/framework/modules-sdk"
|
||||
import { ICachingModuleService } from "@medusajs/framework/types"
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { setTimeout } from "timers/promises"
|
||||
import { EventBusServiceMock } from "../../__fixtures__/event-bus-mock"
|
||||
|
||||
jest.setTimeout(300000)
|
||||
|
||||
jest.spyOn(MedusaModule, "getAllJoinerConfigs").mockReturnValue([
|
||||
{
|
||||
schema: `
|
||||
type Product {
|
||||
id: ID
|
||||
title: String
|
||||
handle: String
|
||||
status: String
|
||||
type_id: String
|
||||
collection_id: String
|
||||
is_giftcard: Boolean
|
||||
external_id: String
|
||||
created_at: DateTime
|
||||
updated_at: DateTime
|
||||
|
||||
variants: [ProductVariant]
|
||||
sales_channels: [SalesChannel]
|
||||
}
|
||||
|
||||
type ProductVariant {
|
||||
id: ID
|
||||
product_id: String
|
||||
sku: String
|
||||
|
||||
prices: [Price]
|
||||
}
|
||||
|
||||
type Price {
|
||||
id: ID
|
||||
amount: Float
|
||||
currency_code: String
|
||||
variant_id: String
|
||||
}
|
||||
|
||||
type SalesChannel {
|
||||
id: ID
|
||||
is_disabled: Boolean
|
||||
}
|
||||
|
||||
type ProductCollection {
|
||||
id: ID
|
||||
title: String
|
||||
handle: String
|
||||
}
|
||||
`,
|
||||
},
|
||||
])
|
||||
|
||||
const DEFAULT_WAIT_INTERVAL = 50
|
||||
const mockEventBus = new EventBusServiceMock()
|
||||
|
||||
moduleIntegrationTestRunner<ICachingModuleService>({
|
||||
moduleName: Modules.CACHING,
|
||||
injectedDependencies: {
|
||||
[Modules.EVENT_BUS]: mockEventBus,
|
||||
},
|
||||
moduleOptions: {
|
||||
providers: [
|
||||
{
|
||||
id: "cache-redis",
|
||||
resolve: require.resolve("../../../../providers/caching-redis/src"),
|
||||
is_default: true,
|
||||
options: {
|
||||
redisUrl: "localhost:6379",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
testSuite: ({ service }) => {
|
||||
describe("Cache Invalidation with Entity Relationships", () => {
|
||||
afterEach(async () => {
|
||||
await service.clear({ tags: ["*"] }).catch(() => {})
|
||||
})
|
||||
|
||||
describe("Single Entity Caching", () => {
|
||||
it("should cache and retrieve a single product entity using computed keys", async () => {
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Test Product",
|
||||
handle: "test-product",
|
||||
status: "published",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cachedProduct = await service.get({ key: productKey })
|
||||
expect(cachedProduct).toEqual(product)
|
||||
})
|
||||
|
||||
it("should auto-invalidate single entity when strategy clears computed tags", async () => {
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Test Product",
|
||||
handle: "test-product",
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
await mockEventBus.emit(
|
||||
[{ name: "product.updated", data: { id: product.id } }],
|
||||
{}
|
||||
)
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const result = await service.get({ key: productKey })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should not auto-invalidate single entity with autoInvalidate=false", async () => {
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Test Product",
|
||||
handle: "test-product",
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
options: { autoInvalidate: false },
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
await mockEventBus.emit(
|
||||
[{ name: "product.updated", data: { id: product.id } }],
|
||||
{}
|
||||
)
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const result = await service.get({ key: productKey })
|
||||
expect(result).toEqual(product)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Entity List Caching", () => {
|
||||
it("should cache and retrieve lists of entities using computed keys", async () => {
|
||||
const publishedProductsQuery = {
|
||||
entity: "product",
|
||||
filters: { status: "published" },
|
||||
fields: ["id", "title", "status"],
|
||||
}
|
||||
|
||||
const allProductsQuery = {
|
||||
entity: "product",
|
||||
filters: {},
|
||||
fields: ["id", "title", "status"],
|
||||
}
|
||||
|
||||
const publishedProducts = [
|
||||
{ id: "prod_1", title: "Product 1", status: "published" },
|
||||
{ id: "prod_2", title: "Product 2", status: "published" },
|
||||
]
|
||||
|
||||
const allProducts = [
|
||||
...publishedProducts,
|
||||
{ id: "prod_3", title: "Product 3", status: "draft" },
|
||||
]
|
||||
|
||||
const publishedProductsKey = await service.computeKey(
|
||||
publishedProductsQuery
|
||||
)
|
||||
const allProductsKey = await service.computeKey(allProductsQuery)
|
||||
|
||||
await service.set({
|
||||
key: publishedProductsKey,
|
||||
data: publishedProducts,
|
||||
})
|
||||
|
||||
await service.set({
|
||||
key: allProductsKey,
|
||||
data: allProducts,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cachedPublished = await service.get({
|
||||
key: publishedProductsKey,
|
||||
})
|
||||
const cachedAll = await service.get({ key: allProductsKey })
|
||||
|
||||
expect(cachedPublished).toEqual(publishedProducts)
|
||||
expect(cachedAll).toEqual(allProducts)
|
||||
})
|
||||
|
||||
it("should invalidate related lists when individual product is updated", async () => {
|
||||
const listQuery = {
|
||||
entity: "product",
|
||||
filters: { status: "published" },
|
||||
includes: ["id", "title"],
|
||||
}
|
||||
|
||||
const products = [
|
||||
{ id: "prod_1", title: "Product 1", status: "published" },
|
||||
{ id: "prod_2", title: "Product 2", status: "published" },
|
||||
]
|
||||
|
||||
const listKey = await service.computeKey(listQuery)
|
||||
|
||||
await service.set({
|
||||
key: listKey,
|
||||
data: products,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "product.updated",
|
||||
data: { id: "prod_1", title: "Updated Product 1" },
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cachedList = await service.get({ key: listKey })
|
||||
|
||||
expect(cachedList).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Nested Entity Caching", () => {
|
||||
it("should cache products with nested variants and prices using computed keys", async () => {
|
||||
const productWithVariants = {
|
||||
id: "prod_1",
|
||||
title: "Complex Product",
|
||||
variants: [
|
||||
{
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-001",
|
||||
prices: [
|
||||
{
|
||||
id: "price_1",
|
||||
variant_id: "var_1",
|
||||
amount: 1000,
|
||||
currency_code: "USD",
|
||||
},
|
||||
{
|
||||
id: "price_2",
|
||||
variant_id: "var_1",
|
||||
amount: 900,
|
||||
currency_code: "EUR",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "var_2",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-002",
|
||||
prices: [
|
||||
{
|
||||
id: "price_3",
|
||||
variant_id: "var_2",
|
||||
amount: 1500,
|
||||
currency_code: "USD",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(productWithVariants)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: productWithVariants,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cached = await service.get<typeof productWithVariants>({
|
||||
key: productKey,
|
||||
})
|
||||
expect(cached).toEqual(productWithVariants)
|
||||
expect(cached!.variants).toHaveLength(2)
|
||||
expect(cached!.variants[0].prices).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should invalidate nested product when related variant is updated", async () => {
|
||||
const productWithVariants = {
|
||||
id: "prod_1",
|
||||
title: "Complex Product",
|
||||
variants: [{ id: "var_1", product_id: "prod_1", sku: "SKU-001" }],
|
||||
}
|
||||
|
||||
const productKey = await service.computeKey(productWithVariants)
|
||||
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: productWithVariants,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "product_variant.updated",
|
||||
data: {
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-001-UPDATED",
|
||||
},
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const result = await service.get({ key: productKey })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle price updates affecting variant and product caches", async () => {
|
||||
const price = {
|
||||
id: "price_1",
|
||||
variant_id: "var_1",
|
||||
amount: 1000,
|
||||
currency_code: "USD",
|
||||
}
|
||||
|
||||
const variant = {
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
sku: "SKU-001",
|
||||
prices: [price],
|
||||
}
|
||||
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
title: "Product",
|
||||
variants: [variant],
|
||||
}
|
||||
|
||||
const priceKey = await service.computeKey(price)
|
||||
const variantKey = await service.computeKey(variant)
|
||||
const productKey = await service.computeKey(product)
|
||||
|
||||
await service.set({
|
||||
key: priceKey,
|
||||
data: price,
|
||||
})
|
||||
await service.set({
|
||||
key: variantKey,
|
||||
data: variant,
|
||||
})
|
||||
await service.set({
|
||||
key: productKey,
|
||||
data: product,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "price.updated",
|
||||
data: { id: "price_1", variant_id: "var_1", amount: 1100 },
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cachedPrice = await service.get({ key: priceKey })
|
||||
const cachedVariant = await service.get({ key: variantKey })
|
||||
const cachedProduct = await service.get({ key: productKey })
|
||||
|
||||
expect(cachedPrice).toBeNull()
|
||||
expect(cachedVariant).toBeNull()
|
||||
expect(cachedProduct).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Complex Query Caching", () => {
|
||||
it("should cache complex queries and invalidate based on entity relationships", async () => {
|
||||
const complexQuery = {
|
||||
entity: "product",
|
||||
filters: { status: "published", collection_id: "col_1" },
|
||||
includes: {
|
||||
variants: {
|
||||
include: {
|
||||
prices: true,
|
||||
},
|
||||
},
|
||||
collection: true,
|
||||
},
|
||||
pagination: { limit: 10, offset: 0 },
|
||||
}
|
||||
|
||||
const queryResult = {
|
||||
data: [
|
||||
{
|
||||
id: "prod_1",
|
||||
title: "Product 1",
|
||||
collection_id: "col_1",
|
||||
variants: [
|
||||
{
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
prices: [{ id: "price_1", amount: 1000 }],
|
||||
},
|
||||
],
|
||||
collection: { id: "col_1", title: "Collection 1" },
|
||||
},
|
||||
],
|
||||
pagination: { total: 1, limit: 10, offset: 0 },
|
||||
}
|
||||
|
||||
const queryKey = await service.computeKey(complexQuery)
|
||||
|
||||
await service.set({
|
||||
key: queryKey,
|
||||
data: queryResult,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cached = await service.get({ key: queryKey })
|
||||
expect(cached).toEqual(queryResult)
|
||||
|
||||
await mockEventBus.emit(
|
||||
[
|
||||
{
|
||||
name: "price.updated",
|
||||
data: { id: "price_1", amount: 1100 },
|
||||
},
|
||||
],
|
||||
{}
|
||||
)
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cachedAfterUpdate = await service.get({ key: queryKey })
|
||||
expect(cachedAfterUpdate).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it("should cache complex queries and return the correct cached data", async () => {
|
||||
const complexQuery = {
|
||||
entity: "product",
|
||||
filters: { status: "published", collection_id: "col_1" },
|
||||
includes: {
|
||||
variants: {
|
||||
include: {
|
||||
prices: true,
|
||||
},
|
||||
},
|
||||
collection: true,
|
||||
},
|
||||
pagination: { limit: 10, offset: 0 },
|
||||
}
|
||||
|
||||
const queryResult = {
|
||||
data: [
|
||||
{
|
||||
id: "prod_1",
|
||||
title: "Product 1",
|
||||
collection_id: "col_1",
|
||||
variants: [
|
||||
{
|
||||
id: "var_1",
|
||||
product_id: "prod_1",
|
||||
prices: [{ id: "price_1", amount: 1000 }],
|
||||
},
|
||||
],
|
||||
collection: { id: "col_1", title: "Collection 1" },
|
||||
},
|
||||
],
|
||||
pagination: { total: 1, limit: 10, offset: 0 },
|
||||
}
|
||||
|
||||
const queryKey = await service.computeKey(complexQuery)
|
||||
|
||||
await service.set({
|
||||
key: queryKey,
|
||||
data: queryResult,
|
||||
})
|
||||
|
||||
await setTimeout(DEFAULT_WAIT_INTERVAL)
|
||||
|
||||
const cached = await service.get({ key: queryKey })
|
||||
expect(cached).toEqual(queryResult)
|
||||
|
||||
const complexQueryOffset = {
|
||||
entity: "product",
|
||||
filters: { status: "published", collection_id: "col_1" },
|
||||
includes: {
|
||||
variants: {
|
||||
include: {
|
||||
prices: true,
|
||||
},
|
||||
},
|
||||
collection: true,
|
||||
},
|
||||
pagination: { limit: 10, offset: 10 },
|
||||
}
|
||||
|
||||
const queryKeyOffset = await service.computeKey(complexQueryOffset)
|
||||
|
||||
const cachedOffset = await service.get({ key: queryKeyOffset })
|
||||
expect(cachedOffset).toEqual(null)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
const defineJestConfig = require("../../../define_jest_config")
|
||||
module.exports = defineJestConfig({
|
||||
moduleNameMapper: {
|
||||
"^@services": "<rootDir>/src/services",
|
||||
"^@types": "<rootDir>/src/types",
|
||||
"^@utils": "<rootDir>/src/utils",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@medusajs/caching",
|
||||
"version": "2.10.3",
|
||||
"description": "Caching Module for Medusa",
|
||||
"main": "dist/index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/modules/caching"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/__mocks__",
|
||||
"!dist/**/__fixtures__"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"author": "Medusa",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"watch": "tsc --build --watch",
|
||||
"watch:test": "tsc --build tsconfig.spec.json --watch",
|
||||
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
||||
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
||||
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src/",
|
||||
"test:integration": "jest --runInBand --forceExit -- integration-tests/__tests__/**/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/framework": "2.10.3",
|
||||
"@medusajs/test-utils": "2.10.3",
|
||||
"@swc/core": "^1.7.28",
|
||||
"@swc/jest": "^0.2.36",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"tsc-alias": "^1.8.6",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@medusajs/framework": "2.10.3",
|
||||
"awilix": "^8.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"fast-json-stable-stringify": "^2.1.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"xxhash-wasm": "^1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module, Modules } from "@medusajs/framework/utils"
|
||||
import { default as loadHash } from "./loaders/hash"
|
||||
import { default as loadProviders } from "./loaders/providers"
|
||||
import CachingModuleService from "./services/cache-module"
|
||||
|
||||
export default Module(Modules.CACHING, {
|
||||
service: CachingModuleService,
|
||||
loaders: [loadHash, loadProviders],
|
||||
})
|
||||
|
||||
// Module options types
|
||||
export { CachingModuleOptions } from "./types"
|
||||
@@ -0,0 +1,8 @@
|
||||
import { asValue } from "awilix"
|
||||
|
||||
export default async ({ container }) => {
|
||||
const xxhashhWasm = await import("xxhash-wasm")
|
||||
const { h32ToString } = await xxhashhWasm.default()
|
||||
|
||||
container.register("hasher", asValue(h32ToString))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { moduleProviderLoader } from "@medusajs/framework/modules-sdk"
|
||||
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/framework/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
getProviderRegistrationKey,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { CachingProviderService } from "@services"
|
||||
import {
|
||||
CachingDefaultProvider,
|
||||
CachingIdentifiersRegistrationName,
|
||||
CachingModuleOptions,
|
||||
CachingProviderRegistrationPrefix,
|
||||
} from "@types"
|
||||
import { aliasTo, asFunction, asValue, Lifetime } from "awilix"
|
||||
import { MemoryCachingProvider } from "../providers/memory-cache"
|
||||
import { DefaultCacheStrategy } from "../utils/strategy"
|
||||
|
||||
const registrationFn = async (klass, container, { id }) => {
|
||||
const key = CachingProviderService.getRegistrationIdentifier(klass)
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`No "id" provided for provider ${key}`)
|
||||
}
|
||||
|
||||
const regKey = getProviderRegistrationKey({
|
||||
providerId: id,
|
||||
providerIdentifier: key,
|
||||
})
|
||||
|
||||
container.register({
|
||||
[CachingProviderRegistrationPrefix + id]: aliasTo(regKey),
|
||||
})
|
||||
|
||||
container.registerAdd(CachingIdentifiersRegistrationName, asValue(key))
|
||||
}
|
||||
|
||||
export default async ({
|
||||
container,
|
||||
options,
|
||||
}: LoaderOptions<
|
||||
(
|
||||
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||
) &
|
||||
CachingModuleOptions
|
||||
>): Promise<void> => {
|
||||
container.registerAdd(CachingIdentifiersRegistrationName, asValue(undefined))
|
||||
|
||||
const strategy = DefaultCacheStrategy // Re enable custom strategy another time
|
||||
container.register("strategy", asValue(strategy))
|
||||
|
||||
// MemoryCachingProvider - default provider
|
||||
container.register({
|
||||
[CachingProviderRegistrationPrefix + MemoryCachingProvider.identifier]:
|
||||
asFunction(() => new MemoryCachingProvider(), {
|
||||
lifetime: Lifetime.SINGLETON,
|
||||
}),
|
||||
})
|
||||
container.registerAdd(
|
||||
CachingIdentifiersRegistrationName,
|
||||
asValue(MemoryCachingProvider.identifier)
|
||||
)
|
||||
container.register(
|
||||
CachingDefaultProvider,
|
||||
asValue(MemoryCachingProvider.identifier)
|
||||
)
|
||||
|
||||
// Load other providers
|
||||
await moduleProviderLoader({
|
||||
container,
|
||||
providers: options?.providers || [],
|
||||
registerServiceFn: registrationFn,
|
||||
})
|
||||
|
||||
const isSingleProvider = options?.providers?.length === 1
|
||||
let hasDefaultProvider = false
|
||||
for (const provider of options?.providers || []) {
|
||||
if (provider.is_default || isSingleProvider) {
|
||||
if (provider.is_default) {
|
||||
hasDefaultProvider = true
|
||||
}
|
||||
container.register(CachingDefaultProvider, asValue(provider.id))
|
||||
}
|
||||
}
|
||||
|
||||
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||
if (!hasDefaultProvider) {
|
||||
logger.warn(
|
||||
`[caching-module]: No default caching provider defined. Using "${container.resolve(
|
||||
CachingDefaultProvider
|
||||
)}" as default.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import NodeCache from "node-cache"
|
||||
import type { ICachingProviderService } from "@medusajs/framework/types"
|
||||
|
||||
export interface MemoryCacheModuleOptions {
|
||||
/**
|
||||
* TTL in seconds
|
||||
*/
|
||||
ttl?: number
|
||||
/**
|
||||
* Maximum number of keys to store (see node-cache documentation)
|
||||
*/
|
||||
maxKeys?: number
|
||||
/**
|
||||
* Check period for expired keys in seconds (see node-cache documentation)
|
||||
*/
|
||||
checkPeriod?: number
|
||||
/**
|
||||
* Use clones for cached data (see node-cache documentation)
|
||||
*/
|
||||
useClones?: boolean
|
||||
}
|
||||
|
||||
export class MemoryCachingProvider implements ICachingProviderService {
|
||||
static identifier = "cache-memory"
|
||||
|
||||
protected cacheClient: NodeCache
|
||||
protected tagIndex: Map<string, Set<string>> = new Map() // tag -> keys
|
||||
protected keyTags: Map<string, Set<string>> = new Map() // key -> tags
|
||||
protected entryOptions: Map<string, { autoInvalidate?: boolean }> = new Map() // key -> options
|
||||
protected options: MemoryCacheModuleOptions
|
||||
|
||||
constructor() {
|
||||
this.options = {
|
||||
ttl: 3600,
|
||||
maxKeys: 25000,
|
||||
checkPeriod: 60, // 10 minutes
|
||||
useClones: false, // Default to false for speed, true would be slower but safer. we can discuss
|
||||
}
|
||||
|
||||
const cacheClient = new NodeCache({
|
||||
stdTTL: this.options.ttl,
|
||||
maxKeys: this.options.maxKeys,
|
||||
checkperiod: this.options.checkPeriod,
|
||||
useClones: this.options.useClones,
|
||||
})
|
||||
|
||||
this.cacheClient = cacheClient
|
||||
|
||||
// Clean up tag indices when keys expire
|
||||
this.cacheClient.on("expired", (key: string, value: any) => {
|
||||
this.cleanupTagReferences(key)
|
||||
})
|
||||
|
||||
this.cacheClient.on("del", (key: string, value: any) => {
|
||||
this.cleanupTagReferences(key)
|
||||
})
|
||||
}
|
||||
|
||||
private cleanupTagReferences(key: string): void {
|
||||
const tags = this.keyTags.get(key)
|
||||
if (tags) {
|
||||
tags.forEach((tag) => {
|
||||
const keysForTag = this.tagIndex.get(tag)
|
||||
if (keysForTag) {
|
||||
keysForTag.delete(key)
|
||||
if (keysForTag.size === 0) {
|
||||
this.tagIndex.delete(tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.keyTags.delete(key)
|
||||
}
|
||||
// Also clean up entry options
|
||||
this.entryOptions.delete(key)
|
||||
}
|
||||
|
||||
async get({ key, tags }: { key?: string; tags?: string[] }): Promise<any> {
|
||||
if (key) {
|
||||
return this.cacheClient.get(key) ?? null
|
||||
}
|
||||
|
||||
if (tags && tags.length) {
|
||||
const allKeys = new Set<string>()
|
||||
|
||||
tags.forEach((tag) => {
|
||||
const keysForTag = this.tagIndex.get(tag)
|
||||
if (keysForTag) {
|
||||
keysForTag.forEach((key) => allKeys.add(key))
|
||||
}
|
||||
})
|
||||
|
||||
if (allKeys.size === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: any[] = []
|
||||
allKeys.forEach((key) => {
|
||||
const value = this.cacheClient.get(key)
|
||||
if (value !== undefined) {
|
||||
results.push(value)
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async set({
|
||||
key,
|
||||
data,
|
||||
ttl,
|
||||
tags,
|
||||
options,
|
||||
}: {
|
||||
key: string
|
||||
data: object
|
||||
ttl?: number
|
||||
tags?: string[]
|
||||
options?: {
|
||||
autoInvalidate?: boolean
|
||||
}
|
||||
}): Promise<void> {
|
||||
// Set the cache entry
|
||||
const effectiveTTL = ttl ?? this.options.ttl ?? 3600
|
||||
this.cacheClient.set(key, data, effectiveTTL)
|
||||
|
||||
// Handle tags if provided
|
||||
if (tags && tags.length) {
|
||||
// Clean up any existing tag references for this key
|
||||
this.cleanupTagReferences(key)
|
||||
|
||||
const tagSet = new Set(tags)
|
||||
this.keyTags.set(key, tagSet)
|
||||
|
||||
// Add this key to each tag's index
|
||||
tags.forEach((tag) => {
|
||||
if (!this.tagIndex.has(tag)) {
|
||||
this.tagIndex.set(tag, new Set())
|
||||
}
|
||||
this.tagIndex.get(tag)!.add(key)
|
||||
})
|
||||
}
|
||||
|
||||
// Store entry options if provided
|
||||
if (
|
||||
Object.keys(options ?? {}).length &&
|
||||
!Object.values(options ?? {}).every((value) => value === undefined)
|
||||
) {
|
||||
this.entryOptions.set(key, options!)
|
||||
}
|
||||
}
|
||||
|
||||
async clear({
|
||||
key,
|
||||
tags,
|
||||
options,
|
||||
}: {
|
||||
key?: string
|
||||
tags?: string[]
|
||||
options?: {
|
||||
autoInvalidate?: boolean
|
||||
}
|
||||
}): Promise<void> {
|
||||
if (key) {
|
||||
this.cacheClient.del(key)
|
||||
return
|
||||
}
|
||||
|
||||
if (tags && tags.length) {
|
||||
// Handle wildcard tag to clear all cache data
|
||||
if (tags.includes("*")) {
|
||||
this.cacheClient.flushAll()
|
||||
this.tagIndex.clear()
|
||||
this.keyTags.clear()
|
||||
this.entryOptions.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const allKeys = new Set<string>()
|
||||
|
||||
tags.forEach((tag) => {
|
||||
const keysForTag = this.tagIndex.get(tag)
|
||||
if (keysForTag) {
|
||||
keysForTag.forEach((key) => allKeys.add(key))
|
||||
}
|
||||
})
|
||||
|
||||
if (allKeys.size) {
|
||||
// If no options provided (user explicit call), clear everything
|
||||
if (!options) {
|
||||
const keysToDelete = Array.from(allKeys)
|
||||
this.cacheClient.del(keysToDelete)
|
||||
|
||||
// Clean up ALL tag references for deleted keys
|
||||
keysToDelete.forEach((key) => {
|
||||
this.cleanupTagReferences(key)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If autoInvalidate is true (strategy call), only clear entries with autoInvalidate=true (default)
|
||||
if (options.autoInvalidate === true) {
|
||||
const keysToDelete: string[] = []
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
const entryOptions = this.entryOptions.get(key)
|
||||
// Delete if entry has autoInvalidate=true or no setting (default true)
|
||||
const shouldAutoInvalidate = entryOptions?.autoInvalidate ?? true
|
||||
if (shouldAutoInvalidate) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
if (keysToDelete.length) {
|
||||
this.cacheClient.del(keysToDelete)
|
||||
|
||||
// Clean up ALL tag references for deleted keys
|
||||
keysToDelete.forEach((key) => {
|
||||
this.cleanupTagReferences(key)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { MedusaModule } from "@medusajs/framework/modules-sdk"
|
||||
import type {
|
||||
ICachingModuleService,
|
||||
ICachingStrategy,
|
||||
Logger,
|
||||
} from "@medusajs/framework/types"
|
||||
import { GraphQLUtils, MedusaError } from "@medusajs/framework/utils"
|
||||
import { CachingDefaultProvider, InjectedDependencies } from "@types"
|
||||
import CacheProviderService from "./cache-provider"
|
||||
|
||||
const ONE_HOUR_IN_SECOND = 60 * 60
|
||||
|
||||
export default class CachingModuleService implements ICachingModuleService {
|
||||
protected container: InjectedDependencies
|
||||
protected providerService: CacheProviderService
|
||||
protected strategyCtr: new (...args: any[]) => ICachingStrategy
|
||||
protected strategy: ICachingStrategy
|
||||
protected defaultProviderId: string
|
||||
|
||||
protected logger: Logger
|
||||
protected ongoingRequests: Map<string, Promise<any>> = new Map()
|
||||
|
||||
protected ttl: number
|
||||
|
||||
static traceGet?: (
|
||||
cacheGetFn: () => Promise<any>,
|
||||
key: string,
|
||||
tags: string[]
|
||||
) => Promise<any>
|
||||
|
||||
static traceSet?: (
|
||||
cacheSetFn: () => Promise<any>,
|
||||
key: string,
|
||||
tags: string[],
|
||||
options: { autoInvalidate?: boolean }
|
||||
) => Promise<any>
|
||||
|
||||
static traceClear?: (
|
||||
cacheClearFn: () => Promise<any>,
|
||||
key: string,
|
||||
tags: string[],
|
||||
options: { autoInvalidate?: boolean }
|
||||
) => Promise<any>
|
||||
|
||||
constructor(
|
||||
container: InjectedDependencies,
|
||||
protected readonly moduleDeclaration:
|
||||
| { options: { ttl?: number } }
|
||||
| { ttl?: number }
|
||||
) {
|
||||
this.container = container
|
||||
this.providerService = container.cacheProviderService
|
||||
this.defaultProviderId = container[CachingDefaultProvider]
|
||||
this.strategyCtr = container.strategy as new (
|
||||
...args: any[]
|
||||
) => ICachingStrategy
|
||||
this.strategy = new this.strategyCtr(this.container, this)
|
||||
|
||||
const moduleOptions =
|
||||
"options" in moduleDeclaration
|
||||
? moduleDeclaration.options
|
||||
: moduleDeclaration
|
||||
|
||||
this.ttl = moduleOptions.ttl ?? ONE_HOUR_IN_SECOND
|
||||
|
||||
this.logger = container.logger ?? (console as unknown as Logger)
|
||||
}
|
||||
|
||||
__hooks = {
|
||||
onApplicationStart: async () => {
|
||||
this.onApplicationStart()
|
||||
},
|
||||
onApplicationShutdown: async () => {
|
||||
this.onApplicationShutdown()
|
||||
},
|
||||
onApplicationPrepareShutdown: async () => {
|
||||
this.onApplicationPrepareShutdown()
|
||||
},
|
||||
}
|
||||
|
||||
protected onApplicationStart() {
|
||||
const loadedSchema = MedusaModule.getAllJoinerConfigs()
|
||||
.map((joinerConfig) => joinerConfig?.schema ?? "")
|
||||
.join("\n")
|
||||
|
||||
const defaultMedusaSchema = `
|
||||
scalar DateTime
|
||||
scalar JSON
|
||||
directive @enumValue(value: String) on ENUM_VALUE
|
||||
`
|
||||
|
||||
const { schema: cleanedSchema } = GraphQLUtils.cleanGraphQLSchema(
|
||||
defaultMedusaSchema + loadedSchema
|
||||
)
|
||||
const mergedSchema = GraphQLUtils.mergeTypeDefs(cleanedSchema)
|
||||
const schema = GraphQLUtils.makeExecutableSchema({
|
||||
typeDefs: mergedSchema,
|
||||
})
|
||||
|
||||
this.strategy.onApplicationStart?.(
|
||||
schema,
|
||||
MedusaModule.getAllJoinerConfigs()
|
||||
)
|
||||
}
|
||||
|
||||
protected onApplicationShutdown() {
|
||||
this.strategy.onApplicationShutdown?.()
|
||||
}
|
||||
|
||||
protected onApplicationPrepareShutdown() {
|
||||
this.strategy.onApplicationPrepareShutdown?.()
|
||||
}
|
||||
|
||||
protected static normalizeProviders(
|
||||
providers: string[] | { id: string; ttl?: number }[]
|
||||
): { id: string; ttl?: number }[] {
|
||||
const providers_ = Array.isArray(providers) ? providers : [providers]
|
||||
return providers_.map((provider) => {
|
||||
return typeof provider === "string" ? { id: provider } : provider
|
||||
})
|
||||
}
|
||||
|
||||
protected getRequestKey(
|
||||
key?: string,
|
||||
tags?: string[],
|
||||
providers?: string[]
|
||||
): string {
|
||||
const keyPart = key || ""
|
||||
const tagsPart = tags?.sort().join(",") || ""
|
||||
const providersPart = providers?.join(",") || this.defaultProviderId
|
||||
return `${keyPart}|${tagsPart}|${providersPart}`
|
||||
}
|
||||
|
||||
protected getClearRequestKey(
|
||||
key?: string,
|
||||
tags?: string[],
|
||||
providers?: string[]
|
||||
): string {
|
||||
const keyPart = key || ""
|
||||
const tagsPart = tags?.sort().join(",") || ""
|
||||
const providersPart = providers?.join(",") || this.defaultProviderId
|
||||
return `clear:${keyPart}|${tagsPart}|${providersPart}`
|
||||
}
|
||||
|
||||
async get(options: { key?: string; tags?: string[]; providers?: string[] }) {
|
||||
if (CachingModuleService.traceGet) {
|
||||
return await CachingModuleService.traceGet(
|
||||
() => this.get_(options),
|
||||
options.key ?? "",
|
||||
options.tags ?? []
|
||||
)
|
||||
}
|
||||
|
||||
return await this.get_(options)
|
||||
}
|
||||
|
||||
private async get_({
|
||||
key,
|
||||
tags,
|
||||
providers,
|
||||
}: {
|
||||
key?: string
|
||||
tags?: string[]
|
||||
providers?: string[]
|
||||
}) {
|
||||
if (!key && !tags) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Either key or tags must be provided"
|
||||
)
|
||||
}
|
||||
|
||||
const requestKey = this.getRequestKey(key, tags, providers)
|
||||
|
||||
const existingRequest = this.ongoingRequests.get(requestKey)
|
||||
if (existingRequest) {
|
||||
return await existingRequest
|
||||
}
|
||||
|
||||
const requestPromise = this.performCacheGet(key, tags, providers)
|
||||
this.ongoingRequests.set(requestKey, requestPromise)
|
||||
|
||||
try {
|
||||
const result = await requestPromise
|
||||
return result
|
||||
} finally {
|
||||
// Clean up the completed request
|
||||
this.ongoingRequests.delete(requestKey)
|
||||
}
|
||||
}
|
||||
|
||||
protected async performCacheGet(
|
||||
key?: string,
|
||||
tags?: string[],
|
||||
providers?: string[]
|
||||
): Promise<any> {
|
||||
const providersToCheck = providers ?? [this.defaultProviderId]
|
||||
|
||||
for (const providerId of providersToCheck) {
|
||||
try {
|
||||
const provider_ = this.providerService.retrieveProvider(providerId)
|
||||
const result = await provider_.get({ key, tags })
|
||||
|
||||
if (result != null) {
|
||||
return result
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Cache provider ${providerId} failed: ${error.message}\n${error.stack}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async set(options: {
|
||||
key: string
|
||||
data: object
|
||||
ttl?: number
|
||||
tags?: string[]
|
||||
providers?: string[]
|
||||
options?: { autoInvalidate?: boolean }
|
||||
}) {
|
||||
if (CachingModuleService.traceSet) {
|
||||
return await CachingModuleService.traceSet(
|
||||
() => this.set_(options),
|
||||
options.key,
|
||||
options.tags ?? [],
|
||||
options.options ?? {}
|
||||
)
|
||||
}
|
||||
|
||||
return await this.set_(options)
|
||||
}
|
||||
|
||||
private async set_({
|
||||
key,
|
||||
data,
|
||||
ttl,
|
||||
tags,
|
||||
providers,
|
||||
options,
|
||||
}: {
|
||||
key: string
|
||||
data: object
|
||||
tags?: string[]
|
||||
ttl?: number
|
||||
providers?: string[] | { id: string; ttl?: number }[]
|
||||
options?: {
|
||||
autoInvalidate?: boolean
|
||||
}
|
||||
}) {
|
||||
if (!key) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"[CachingModuleService] Key must be provided"
|
||||
)
|
||||
}
|
||||
|
||||
const key_ = key
|
||||
const tags_ = tags ?? (await this.strategy.computeTags(data))
|
||||
|
||||
let providers_: string[] | { id: string; ttl?: number }[] = [
|
||||
this.defaultProviderId,
|
||||
]
|
||||
providers_ = CachingModuleService.normalizeProviders(
|
||||
providers ?? providers_
|
||||
)
|
||||
|
||||
const providerIds = providers_.map((p) => p.id)
|
||||
const requestKey = this.getRequestKey(key_, tags_, providerIds)
|
||||
|
||||
const existingRequest = this.ongoingRequests.get(requestKey)
|
||||
if (existingRequest) {
|
||||
return await existingRequest
|
||||
}
|
||||
|
||||
const requestPromise = this.performCacheSet(
|
||||
key_,
|
||||
tags_,
|
||||
data,
|
||||
ttl,
|
||||
providers_,
|
||||
options
|
||||
)
|
||||
this.ongoingRequests.set(requestKey, requestPromise)
|
||||
|
||||
try {
|
||||
await requestPromise
|
||||
} finally {
|
||||
// Clean up the completed request
|
||||
this.ongoingRequests.delete(requestKey)
|
||||
}
|
||||
}
|
||||
|
||||
protected async performCacheSet(
|
||||
key: string,
|
||||
tags: string[],
|
||||
data: object,
|
||||
ttl?: number,
|
||||
providers?: { id: string; ttl?: number }[],
|
||||
options?: {
|
||||
autoInvalidate?: boolean
|
||||
}
|
||||
): Promise<void> {
|
||||
for (const providerOptions of providers || []) {
|
||||
const ttl_ = providerOptions.ttl ?? ttl ?? this.ttl
|
||||
const provider = this.providerService.retrieveProvider(providerOptions.id)
|
||||
void provider.set({
|
||||
key,
|
||||
tags,
|
||||
data,
|
||||
ttl: ttl_,
|
||||
options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async clear(options: {
|
||||
key?: string
|
||||
tags?: string[]
|
||||
options?: { autoInvalidate?: boolean }
|
||||
providers?: string[]
|
||||
}) {
|
||||
if (CachingModuleService.traceClear) {
|
||||
return await CachingModuleService.traceClear(
|
||||
() => this.clear_(options),
|
||||
options.key ?? "",
|
||||
options.tags ?? [],
|
||||
options.options ?? {}
|
||||
)
|
||||
}
|
||||
|
||||
return await this.clear_(options)
|
||||
}
|
||||
|
||||
private async clear_({
|
||||
key,
|
||||
tags,
|
||||
options,
|
||||
providers,
|
||||
}: {
|
||||
key?: string
|
||||
tags?: string[]
|
||||
options?: {
|
||||
autoInvalidate?: boolean
|
||||
}
|
||||
providers?: string[]
|
||||
}) {
|
||||
if (!key && !tags) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Either key or tags must be provided"
|
||||
)
|
||||
}
|
||||
|
||||
const requestKey = this.getClearRequestKey(key, tags, providers)
|
||||
|
||||
const existingRequest = this.ongoingRequests.get(requestKey)
|
||||
if (existingRequest) {
|
||||
return await existingRequest
|
||||
}
|
||||
|
||||
const requestPromise = this.performCacheClear(key, tags, options, providers)
|
||||
this.ongoingRequests.set(requestKey, requestPromise)
|
||||
|
||||
try {
|
||||
await requestPromise
|
||||
} finally {
|
||||
// Clean up the completed request
|
||||
this.ongoingRequests.delete(requestKey)
|
||||
}
|
||||
}
|
||||
|
||||
protected async performCacheClear(
|
||||
key?: string,
|
||||
tags?: string[],
|
||||
options?: {
|
||||
autoInvalidate?: boolean
|
||||
},
|
||||
providers?: string[]
|
||||
): Promise<void> {
|
||||
let providerIds_: string[] = [this.defaultProviderId]
|
||||
if (providers) {
|
||||
providerIds_ = Array.isArray(providers) ? providers : [providers]
|
||||
}
|
||||
|
||||
for (const providerId of providerIds_) {
|
||||
const provider = this.providerService.retrieveProvider(providerId)
|
||||
void provider.clear({ key, tags, options })
|
||||
}
|
||||
}
|
||||
|
||||
async computeKey(input: object): Promise<string> {
|
||||
return await this.strategy.computeKey(input)
|
||||
}
|
||||
|
||||
async computeTags(
|
||||
input: object,
|
||||
options?: Record<string, any>
|
||||
): Promise<string[]> {
|
||||
return await this.strategy.computeTags(input, options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Constructor,
|
||||
ICachingProviderService,
|
||||
Logger,
|
||||
} from "@medusajs/framework/types"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
import { CachingProviderRegistrationPrefix } from "../types"
|
||||
|
||||
type InjectedDependencies = {
|
||||
[key: `cp_${string}`]: ICachingProviderService
|
||||
logger?: Logger
|
||||
}
|
||||
|
||||
export default class CacheProviderService {
|
||||
#container: InjectedDependencies
|
||||
#logger: Logger
|
||||
|
||||
constructor(container: InjectedDependencies) {
|
||||
this.#container = container
|
||||
this.#logger = container["logger"]
|
||||
? container.logger
|
||||
: (console as unknown as Logger)
|
||||
}
|
||||
|
||||
static getRegistrationIdentifier(
|
||||
providerClass: Constructor<ICachingProviderService>
|
||||
) {
|
||||
if (!(providerClass as any).identifier) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
`Trying to register a caching provider without an identifier.`
|
||||
)
|
||||
}
|
||||
return `${(providerClass as any).identifier}`
|
||||
}
|
||||
|
||||
public retrieveProvider(providerId: string): ICachingProviderService {
|
||||
try {
|
||||
return this.#container[
|
||||
`${CachingProviderRegistrationPrefix}${providerId}`
|
||||
]
|
||||
} catch (err) {
|
||||
if (err.name === "AwilixResolutionError") {
|
||||
const errMessage = `
|
||||
Unable to retrieve the caching provider with id: ${providerId}
|
||||
Please make sure that the provider is registered in the container and it is configured correctly in your project configuration file.`
|
||||
|
||||
// Log full error for debugging
|
||||
this.#logger.error(`AwilixResolutionError: ${err.message}`, err)
|
||||
|
||||
throw new Error(errMessage)
|
||||
}
|
||||
|
||||
const errMessage = `Unable to retrieve the caching provider with id: ${providerId}, the following error occurred: ${err.message}`
|
||||
this.#logger.error(errMessage)
|
||||
|
||||
throw new Error(errMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CachingModuleService } from "./cache-module"
|
||||
export { default as CachingProviderService } from "./cache-provider"
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
Constructor,
|
||||
ICachingStrategy,
|
||||
IEventBusModuleService,
|
||||
Logger,
|
||||
ModuleProviderExports,
|
||||
ModuleServiceInitializeOptions,
|
||||
} from "@medusajs/framework/types"
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { default as CacheProviderService } from "../services/cache-provider"
|
||||
|
||||
export const CachingDefaultProvider = "default_provider"
|
||||
export const CachingIdentifiersRegistrationName = "caching_providers_identifier"
|
||||
|
||||
export const CachingProviderRegistrationPrefix = "lp_"
|
||||
|
||||
export type InjectedDependencies = {
|
||||
cacheProviderService: CacheProviderService
|
||||
hasher: (data: string) => string
|
||||
logger?: Logger
|
||||
strategy: Constructor<ICachingStrategy>
|
||||
[CachingDefaultProvider]: string
|
||||
[Modules.EVENT_BUS]: IEventBusModuleService
|
||||
}
|
||||
|
||||
export type CachingModuleOptions = Partial<ModuleServiceInitializeOptions> & {
|
||||
/**
|
||||
* The strategy to be used. Default to the inbuilt default strategy.
|
||||
*/
|
||||
// strategy?: ICachingStrategy
|
||||
/**
|
||||
* Time to keep data in cache (in seconds)
|
||||
*/
|
||||
ttl?: number
|
||||
/**
|
||||
* Providers to be registered
|
||||
*/
|
||||
providers?: {
|
||||
/**
|
||||
* The module provider to be registered
|
||||
*/
|
||||
resolve: string | ModuleProviderExports
|
||||
/**
|
||||
* If the provider is the default
|
||||
*/
|
||||
is_default?: boolean
|
||||
/**
|
||||
* The id of the provider
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* key value pair of the configuration to be passed to the provider constructor
|
||||
*/
|
||||
options?: Record<string, unknown>
|
||||
}[]
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../_tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@services": ["./src/services"],
|
||||
"@types": ["./src/types"],
|
||||
"@utils": ["./src/utils"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user