( f: (...args: Args) => Promise<Result>, cacheFn: (...args: Args) => string, maxCacheSize: number = 100, cacheLifetimeMs: number = 5 * 60 * 1000, )
| 278 | * - Rejected promises are NOT cached (next caller retries) |
| 279 | */ |
| 280 | export function memoizeAsyncWithLRU< |
| 281 | Args extends unknown[], |
| 282 | Result, |
| 283 | >( |
| 284 | f: (...args: Args) => Promise<Result>, |
| 285 | cacheFn: (...args: Args) => string, |
| 286 | maxCacheSize: number = 100, |
| 287 | cacheLifetimeMs: number = 5 * 60 * 1000, |
| 288 | ): { |
| 289 | (...args: Args): Promise<Result> |
| 290 | cache: { |
| 291 | clear: () => void |
| 292 | delete: (key: string) => boolean |
| 293 | size: () => number |
| 294 | has: (key: string) => boolean |
| 295 | } |
| 296 | } { |
| 297 | type Entry = { value: Result; timestamp: number } |
| 298 | const cache = new LRUCache<string, Entry>({ max: maxCacheSize }) |
| 299 | const inFlight = new Map<string, Promise<Result>>() |
| 300 | |
| 301 | const memoized = async (...args: Args): Promise<Result> => { |
| 302 | const key = cacheFn(...args) |
| 303 | const cached = cache.get(key) |
| 304 | const now = Date.now() |
| 305 | |
| 306 | if (cached && now - cached.timestamp <= cacheLifetimeMs) { |
| 307 | return cached.value |
| 308 | } |
| 309 | |
| 310 | if (cached) { |
| 311 | cache.delete(key) |
| 312 | } |
| 313 | |
| 314 | const pending = inFlight.get(key) |
| 315 | if (pending) return pending |
| 316 | |
| 317 | const promise = f(...args) |
| 318 | inFlight.set(key, promise) |
| 319 | |
| 320 | try { |
| 321 | const result = await promise |
| 322 | if (inFlight.get(key) === promise) { |
| 323 | cache.set(key, { value: result, timestamp: now }) |
| 324 | } |
| 325 | return result |
| 326 | } catch (e) { |
| 327 | // Don't cache failures |
| 328 | throw e |
| 329 | } finally { |
| 330 | if (inFlight.get(key) === promise) { |
| 331 | inFlight.delete(key) |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | memoized.cache = { |
| 337 | clear: () => { |
no test coverage detected