-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcache.hook.ts
More file actions
executable file
·298 lines (271 loc) · 9.21 KB
/
Copy pathcache.hook.ts
File metadata and controls
executable file
·298 lines (271 loc) · 9.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
import type { HookContext, NextFunction, Params } from '@feathersjs/feathers'
import { stringifyParams } from '../../utils/stringify-params/stringify-params.util.js'
import { copy } from 'fast-copy'
import type { Promisable } from '../../internal.utils.js'
type Cache = {
get: (key: string) => Promisable<any>
set: (key: string, value: any) => Promisable<any>
delete: (key: string) => Promisable<any>
clear: () => any
keys: () => IterableIterator<string>
}
export type CacheEvent =
| { type: 'hit'; method: string; key: string }
| { type: 'miss'; method: string; key: string }
| { type: 'set'; method: string; key: string }
| { type: 'invalidate'; method: string; key: string }
| { type: 'clear'; method: string }
export type CacheOptions = {
/**
* The cache implementation to use. It should implement the methods `get`, `set`, `delete`, `clear`, and `keys`.
* This can be a Map, Redis client, or any other cache implementation.
*
* Use 'lru-cache' for an LRU cache implementation.
*/
map: Cache
/**
* The id field to use for caching. Defaults to `service.options.id` and if not found, then 'id'.
*/
id?: string
/**
* params are stringified for the key-value cache.
* There are params properties you don't want to include in the cache key.
* You can use this function to transform the params before they are stringified.
*
* The {@link gateParams} util is built for exactly this: it declaratively
* selects/projects `params` keys (keeping `query` by default) so noise like
* `rateLimit` never ends up in the cache key.
*
* @example
* ```ts
* import { gateParams } from 'feathers-utils/utils'
*
* cache({
* map: new Map(),
* transformParams: (params) => gateParams(params, { rateLimit: false }),
* })
* ```
*/
transformParams: (params: Params) => Params
/**
* Custom serialization function for converting params into a cache key string.
* By default, uses {@link stringifyParams} which sorts object keys and normalizes
* query operator arrays (`$or`, `$and`, `$in`, etc.) for order-independent caching.
*
* The default is crash-safe: it never throws on values that leak through
* `transformParams`. Circular references become `[Circular]`,
* functions/`undefined`/`symbol` are dropped, `BigInt` is stringified, and
* objects with `toJSON` (e.g. `Date`, `ObjectId`) are serialized via it.
*
* Override this to use a custom serialization strategy, e.g. to hash long keys
* for an external store (the id prefix stays separate, so invalidation keeps working):
*
* @example
* ```ts
* import { createHash } from 'node:crypto'
* import { stringifyParams } from 'feathers-utils/utils'
*
* cache({
* map: redisCache,
* transformParams: (params) => ({ query: params.query }),
* serialize: (params) =>
* createHash('sha256').update(stringifyParams(params)).digest('base64url'),
* })
* ```
*/
serialize?: (params: Params) => string
/**
* Optional logger callback for cache events (hit, miss, set, invalidate, clear).
* Useful for debugging and monitoring cache behavior.
*
* @example
* ```ts
* cache({
* map: new Map(),
* transformParams: (params) => ({ query: params.query }),
* logger: (event) => console.log(`cache ${event.type}`, event),
* })
* ```
*/
logger?: (event: CacheEvent) => void
/**
* How to clone results on store and on hit so callers can't mutate the shared
* cached object. Defaults to a `fast-copy` deep clone.
*
* Set to `false` to skip cloning entirely (fastest, but the caller MUST treat
* results as immutable), or pass a custom clone function (e.g. `structuredClone`).
*
* @default true
*/
clone?: boolean | (<T>(value: T) => T)
}
/**
* Caches `get` and `find` results based on `params`. On mutating methods (`create`, `update`,
* `patch`, `remove`), affected cache entries are automatically invalidated.
* Works as a `before`, `after`, or `around` hook.
*
* @example
* ```ts
* import { cache } from 'feathers-utils/hooks'
*
* const myCache = new Map()
*
* app.service('users').hooks({
* around: {
* all: [cache({ map: myCache, transformParams: (params) => ({ query: params.query }) })]
* }
* })
* ```
*
* @see https://utils.feathersjs.com/hooks/cache.html
*/
export const cache = <H extends HookContext = HookContext>(
options: CacheOptions,
) => {
const cacheMap = new ContextCacheMap(options)
return async (context: H, next?: NextFunction): Promise<void> => {
if (context.type === 'before') {
return await cacheBefore(context, cacheMap)
}
if (context.type === 'after') {
return await cacheAfter(context, cacheMap)
}
if (context.type === 'around' && next) {
await cacheBefore(context, cacheMap)
await next()
await cacheAfter(context, cacheMap)
return
}
}
}
const cacheBefore = async (
context: HookContext,
cacheMap: ContextCacheMap,
): Promise<void> => {
if (context.method === 'get' || context.method === 'find') {
const value = await cacheMap.get(context)
if (value) {
context.result = value
}
}
}
const cacheAfter = async (
context: HookContext,
cacheMap: ContextCacheMap,
): Promise<void> => {
if (context.method === 'get' || context.method === 'find') {
await cacheMap.set(context)
} else {
await cacheMap.clear(context)
}
}
class ContextCacheMap {
map: Cache
private delimiter = ':'
private options: CacheOptions
private log: ((event: CacheEvent) => void) | undefined
private serialize: (params: Params) => string
private clone: <T>(value: T) => T
constructor(options: CacheOptions) {
this.map = options.map
this.options = options
this.log = options.logger
this.serialize = options.serialize ?? stringifyParams
this.clone =
options.clone === false
? (value) => value
: typeof options.clone === 'function'
? options.clone
: copy
}
private stringifyCacheKey(context: HookContext) {
if (context.method !== 'get' && context.method !== 'find') {
throw new Error(
`Cache can only be used with 'get' or 'find' methods, not '${context.method}'`,
)
}
const stringifiedParams = this.serialize(
this.options.transformParams(context.params ?? {}),
)
return `${context.id ?? 'null'}${this.delimiter}${stringifiedParams}`
}
private getCachedId(key: string) {
const index = key.indexOf(this.delimiter)
if (index === -1) {
throw new Error(
`Cache key '${key}' does not contain a delimiter '${this.delimiter}'`,
)
}
return key.substring(0, index)
}
private getId(item: Record<string, any>, context: HookContext) {
const idField = context.service.options?.id || this.options.id || 'id'
const id = item[idField]
return id && id.toString ? id.toString() : id
}
/**
* Called before get() and find()
*
* returns a cached result for the given context if it exists.
*/
async get(context: HookContext) {
const key = this.stringifyCacheKey(context)
const result = await this.map.get(key)
if (result) {
this.log?.({ type: 'hit', method: context.method, key })
return this.clone(result) // clone to avoid mutation of the cached result
}
this.log?.({ type: 'miss', method: context.method, key })
}
/**
* Called after get() and find()
*
* Caches the result for the given context.
*/
async set(context: HookContext) {
const key = this.stringifyCacheKey(context)
this.log?.({ type: 'set', method: context.method, key })
// clone to avoid later mutation of the cached result
return this.map.set(key, this.clone(context.result))
}
// Called after create(), update(), patch(), and remove()
async clear<H extends HookContext>(context: H): Promise<H> {
const results = Array.isArray(context.result)
? context.result
: [context.result]
const promises: Promise<any>[] = []
const itemIds = results
.map((item: any) => this.getId(item, context))
.filter(Boolean)
// If no itemIds are found, clear the entire cache to avoid stale data
if (!itemIds.length) {
this.log?.({ type: 'clear', method: context.method })
await this.map.clear()
return context
}
// O(1) membership instead of an O(itemIds) scan per cached key.
const idSet = new Set<string>(itemIds.map((id: any) => `${id}`))
for (const key of this.map.keys()) {
const cachedId = this.getCachedId(key)
if (cachedId === 'null') {
// This is a cached `find` request. Any create/patch/update/del
// could affect the results of this query so it should be deleted
this.log?.({ type: 'invalidate', method: context.method, key })
promises.push(this.map.delete(key))
continue
}
// This is a cached `get` request
if (context.method === 'create') {
// If this is a create, we don't need to delete any cached get requests
continue
}
if (idSet.has(cachedId)) {
// If the cached id matches a mutated item id, delete the cached get
this.log?.({ type: 'invalidate', method: context.method, key })
promises.push(this.map.delete(key))
}
}
await Promise.all(promises)
return context
}
}