feat(medusa): Cache modules (#3187)
This commit is contained in:
6
packages/cache-inmemory/.gitignore
vendored
Normal file
6
packages/cache-inmemory/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/dist
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
.env
|
||||
*.sql
|
||||
23
packages/cache-inmemory/README.md
Normal file
23
packages/cache-inmemory/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Medusa Cache In-memory
|
||||
|
||||
Medusa in-memory cache module. Use plain JS Map as a cache store.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
yarn add @medusajs/cache-inmemory
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
{
|
||||
ttl?: number // Time to keep data in cache (in seconds)
|
||||
}
|
||||
```
|
||||
|
||||
### Note
|
||||
Recommended for testing and development. For production, use Redis cache module.
|
||||
|
||||
### Other caching modules
|
||||
- [Medusa Cache Redis](../cache-redis/README.md)
|
||||
13
packages/cache-inmemory/jest.config.js
Normal file
13
packages/cache-inmemory/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
globals: {
|
||||
"ts-jest": {
|
||||
tsConfig: "tsconfig.json",
|
||||
isolatedModules: false,
|
||||
},
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.[jt]s?$": "ts-jest",
|
||||
},
|
||||
testEnvironment: `node`,
|
||||
moduleFileExtensions: [`js`, `ts`],
|
||||
}
|
||||
36
packages/cache-inmemory/package.json
Normal file
36
packages/cache-inmemory/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@medusajs/cache-inmemory",
|
||||
"version": "1.0.0",
|
||||
"description": "In-memory Cache Module for Medusa",
|
||||
"main": "dist/index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/cache-inmemory"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"author": "Medusa",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@medusajs/medusa": "*",
|
||||
"cross-env": "^5.2.1",
|
||||
"jest": "^25.5.4",
|
||||
"ts-jest": "^25.5.1",
|
||||
"typescript": "^4.4.4"
|
||||
},
|
||||
"scripts": {
|
||||
"watch": "tsc --build --watch",
|
||||
"prepare": "cross-env NODE_ENV=production yarn run build",
|
||||
"build": "tsc --build",
|
||||
"test": "jest --passWithNoTests",
|
||||
"test:unit": "jest --passWithNoTests"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@medusajs/medusa": "^1.7.11"
|
||||
}
|
||||
}
|
||||
13
packages/cache-inmemory/src/index.ts
Normal file
13
packages/cache-inmemory/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ModuleExports } from "@medusajs/modules-sdk"
|
||||
|
||||
import InMemoryCacheService from "./services/inmemory-cache"
|
||||
|
||||
const loaders = []
|
||||
const service = InMemoryCacheService
|
||||
|
||||
const moduleDefinition: ModuleExports = {
|
||||
service,
|
||||
loaders,
|
||||
}
|
||||
|
||||
export default moduleDefinition
|
||||
@@ -0,0 +1,96 @@
|
||||
import { InMemoryCacheService } from "../index"
|
||||
|
||||
jest.setTimeout(40000)
|
||||
|
||||
describe("InMemoryCacheService", () => {
|
||||
let inMemoryCache
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("Stores and retrieves data", async () => {
|
||||
inMemoryCache = new InMemoryCacheService({}, {})
|
||||
|
||||
await inMemoryCache.set("cache-key", { data: "value" })
|
||||
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
|
||||
})
|
||||
|
||||
it("Invalidates single record", async () => {
|
||||
inMemoryCache = new InMemoryCacheService({}, {})
|
||||
|
||||
await inMemoryCache.set("cache-key", { data: "value" })
|
||||
|
||||
await inMemoryCache.invalidate("cache-key")
|
||||
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual(null)
|
||||
})
|
||||
|
||||
it("Invalidates multiple keys with wildcard (end matching)", async () => {
|
||||
inMemoryCache = new InMemoryCacheService({}, {})
|
||||
|
||||
await inMemoryCache.set("cache-key:id_1:x:y", { data: "value" })
|
||||
await inMemoryCache.set("cache-key:id_2:x:y", { data: "value" })
|
||||
await inMemoryCache.set("cache-key:id_3:x:y", { data: "value" })
|
||||
await inMemoryCache.set("cache-key-old", { data: "value" })
|
||||
|
||||
await inMemoryCache.invalidate("cache-key:*")
|
||||
|
||||
expect(await inMemoryCache.get("cache-key:id1:x:y")).toEqual(null)
|
||||
expect(await inMemoryCache.get("cache-key:id2:x:y")).toEqual(null)
|
||||
expect(await inMemoryCache.get("cache-key:id3:x:y")).toEqual(null)
|
||||
expect(await inMemoryCache.get("cache-key-old")).toEqual({ data: "value" })
|
||||
})
|
||||
|
||||
it("Invalidates multiple keys with wildcard (middle matching)", async () => {
|
||||
inMemoryCache = new InMemoryCacheService({}, {})
|
||||
|
||||
await inMemoryCache.set("cache-key:1:new", { data: "value" })
|
||||
await inMemoryCache.set("cache-key:2:new", { data: "value" })
|
||||
await inMemoryCache.set("cache-key:3:new", { data: "value" })
|
||||
await inMemoryCache.set("cache-key:4:old", { data: "value" })
|
||||
|
||||
await inMemoryCache.invalidate("cache-key:*:new")
|
||||
|
||||
expect(await inMemoryCache.get("cache-key:1:new")).toEqual(null)
|
||||
expect(await inMemoryCache.get("cache-key:2:new")).toEqual(null)
|
||||
expect(await inMemoryCache.get("cache-key:3:new")).toEqual(null)
|
||||
expect(await inMemoryCache.get("cache-key:4:old")).toEqual({
|
||||
data: "value",
|
||||
})
|
||||
})
|
||||
|
||||
it("Removes data after TTL", async () => {
|
||||
inMemoryCache = new InMemoryCacheService({}, {})
|
||||
|
||||
await inMemoryCache.set("cache-key", { data: "value" }, 2)
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
|
||||
|
||||
await new Promise((res) => setTimeout(res, 3000))
|
||||
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual(null)
|
||||
})
|
||||
|
||||
it("Removes data after default TTL if TTL params isn't passed", async () => {
|
||||
inMemoryCache = new InMemoryCacheService({})
|
||||
|
||||
await inMemoryCache.set("cache-key", { data: "value" })
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
|
||||
|
||||
await new Promise((res) => setTimeout(res, 33000))
|
||||
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual(null)
|
||||
})
|
||||
|
||||
it("Removes data after TTL from the config if TTL params isn't passed", async () => {
|
||||
inMemoryCache = new InMemoryCacheService({}, { ttl: 1 })
|
||||
|
||||
await inMemoryCache.set("cache-key", { data: "value" })
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
|
||||
|
||||
await new Promise((res) => setTimeout(res, 2000))
|
||||
|
||||
expect(await inMemoryCache.get("cache-key")).toEqual(null)
|
||||
})
|
||||
})
|
||||
1
packages/cache-inmemory/src/services/index.ts
Normal file
1
packages/cache-inmemory/src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as InMemoryCacheService } from "./inmemory-cache"
|
||||
101
packages/cache-inmemory/src/services/inmemory-cache.ts
Normal file
101
packages/cache-inmemory/src/services/inmemory-cache.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ICacheService } from "@medusajs/medusa"
|
||||
|
||||
import { CacheRecord, InMemoryCacheModuleOptions } from "../types"
|
||||
|
||||
const DEFAULT_TTL = 30 // seconds
|
||||
|
||||
type InjectedDependencies = {}
|
||||
|
||||
/**
|
||||
* Class represents basic, in-memory, cache store.
|
||||
*/
|
||||
class InMemoryCacheService implements ICacheService {
|
||||
protected readonly TTL: number
|
||||
|
||||
protected readonly store = new Map<string, CacheRecord<any>>()
|
||||
protected readonly timoutRefs = new Map<string, NodeJS.Timeout>()
|
||||
|
||||
constructor(
|
||||
deps: InjectedDependencies,
|
||||
options: InMemoryCacheModuleOptions = {}
|
||||
) {
|
||||
this.TTL = options.ttl ?? DEFAULT_TTL
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve data from the cache.
|
||||
* @param key - cache key
|
||||
*/
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const now = Date.now()
|
||||
const record: CacheRecord<T> | undefined = this.store.get(key)
|
||||
|
||||
const recordExpire = record?.expire ?? Infinity
|
||||
|
||||
if (!record || recordExpire < now) {
|
||||
return null
|
||||
}
|
||||
|
||||
return record.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data to the cache.
|
||||
* @param key - cache key under which the data is stored
|
||||
* @param data - data to be stored in the cache
|
||||
* @param ttl - expiration time in seconds
|
||||
*/
|
||||
async set<T>(key: string, data: T, ttl: number = this.TTL): Promise<void> {
|
||||
const record: CacheRecord<T> = { data, expire: ttl * 1000 + Date.now() }
|
||||
|
||||
const oldRecord = this.store.get(key)
|
||||
|
||||
if (oldRecord) {
|
||||
clearTimeout(this.timoutRefs.get(key))
|
||||
this.timoutRefs.delete(key)
|
||||
}
|
||||
|
||||
const ref = setTimeout(() => {
|
||||
this.invalidate(key)
|
||||
}, ttl * 1000)
|
||||
|
||||
this.timoutRefs.set(key, ref)
|
||||
this.store.set(key, record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete data from the cache.
|
||||
* Could use wildcard (*) matcher e.g. `invalidate("ps:*")` to delete all keys that start with "ps:"
|
||||
*
|
||||
* @param key - cache key
|
||||
*/
|
||||
async invalidate(key: string): Promise<void> {
|
||||
let keys = [key]
|
||||
|
||||
if (key.includes("*")) {
|
||||
const regExp = new RegExp(key.replace("*", ".*"))
|
||||
keys = Array.from(this.store.keys()).filter((k) => k.match(regExp))
|
||||
}
|
||||
|
||||
keys.forEach((key) => {
|
||||
const timeoutRef = this.timoutRefs.get(key)
|
||||
if (timeoutRef) {
|
||||
clearTimeout(timeoutRef)
|
||||
this.timoutRefs.delete(key)
|
||||
}
|
||||
this.store.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entire cache.
|
||||
*/
|
||||
async clear() {
|
||||
this.timoutRefs.forEach((ref) => clearTimeout(ref))
|
||||
this.timoutRefs.clear()
|
||||
|
||||
this.store.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export default InMemoryCacheService
|
||||
17
packages/cache-inmemory/src/types/index.ts
Normal file
17
packages/cache-inmemory/src/types/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Shape of a record saved in `in-memory` cache
|
||||
*/
|
||||
export type CacheRecord<T> = {
|
||||
data: T
|
||||
/**
|
||||
* Timestamp in milliseconds
|
||||
*/
|
||||
expire: number
|
||||
}
|
||||
|
||||
export type InMemoryCacheModuleOptions = {
|
||||
/**
|
||||
* Time to keep data in cache (in seconds)
|
||||
*/
|
||||
ttl?: number
|
||||
}
|
||||
33
packages/cache-inmemory/tsconfig.json
Normal file
33
packages/cache-inmemory/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es5",
|
||||
"es6",
|
||||
"es2019"
|
||||
],
|
||||
"target": "es5",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitReturns": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitThis": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"downlevelIteration": true // to use ES5 specific tooling
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"./src/**/__tests__",
|
||||
"./src/**/__mocks__",
|
||||
"./src/**/__fixtures__",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user