@open-mercato/cache 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/ENV.md +173 -0
  2. package/README.md +177 -0
  3. package/dist/errors.d.ts +7 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +9 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +7 -0
  9. package/dist/service.d.ts +40 -0
  10. package/dist/service.d.ts.map +1 -0
  11. package/dist/service.js +321 -0
  12. package/dist/strategies/jsonfile.d.ts +10 -0
  13. package/dist/strategies/jsonfile.d.ts.map +1 -0
  14. package/dist/strategies/jsonfile.js +205 -0
  15. package/dist/strategies/memory.d.ts +9 -0
  16. package/dist/strategies/memory.d.ts.map +1 -0
  17. package/dist/strategies/memory.js +166 -0
  18. package/dist/strategies/redis.d.ts +5 -0
  19. package/dist/strategies/redis.d.ts.map +1 -0
  20. package/dist/strategies/redis.js +388 -0
  21. package/dist/strategies/sqlite.d.ts +13 -0
  22. package/dist/strategies/sqlite.d.ts.map +1 -0
  23. package/dist/strategies/sqlite.js +217 -0
  24. package/dist/tenantContext.d.ts +4 -0
  25. package/dist/tenantContext.d.ts.map +1 -0
  26. package/dist/tenantContext.js +9 -0
  27. package/dist/types.d.ts +86 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +1 -0
  30. package/jest.config.js +19 -0
  31. package/package.json +39 -0
  32. package/src/__tests__/memory.strategy.test.ts +245 -0
  33. package/src/__tests__/service.test.ts +189 -0
  34. package/src/errors.ts +14 -0
  35. package/src/index.ts +7 -0
  36. package/src/service.ts +367 -0
  37. package/src/strategies/jsonfile.ts +249 -0
  38. package/src/strategies/memory.ts +185 -0
  39. package/src/strategies/redis.ts +443 -0
  40. package/src/strategies/sqlite.ts +285 -0
  41. package/src/tenantContext.ts +13 -0
  42. package/src/types.ts +100 -0
  43. package/tsconfig.build.json +5 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,189 @@
1
+ import { createCacheService, CacheService } from '../service'
2
+
3
+ describe('Cache Service', () => {
4
+ describe('Strategy selection', () => {
5
+ it('should default to memory strategy', async () => {
6
+ const cache = createCacheService()
7
+ await cache.set('test', 'value')
8
+ const value = await cache.get('test')
9
+ expect(value).toBe('value')
10
+ })
11
+
12
+ it('should use memory strategy when specified', async () => {
13
+ const cache = createCacheService({ strategy: 'memory' })
14
+ await cache.set('test', 'value')
15
+ const value = await cache.get('test')
16
+ expect(value).toBe('value')
17
+ })
18
+
19
+ it('should use jsonfile strategy when specified', async () => {
20
+ const cache = createCacheService({
21
+ strategy: 'jsonfile',
22
+ jsonFilePath: '.test-cache.json'
23
+ })
24
+
25
+ await cache.set('test', 'value')
26
+ const value = await cache.get('test')
27
+ expect(value).toBe('value')
28
+
29
+ await cache.clear()
30
+ })
31
+
32
+ it('should respect defaultTtl option', async () => {
33
+ const cache = createCacheService({
34
+ strategy: 'memory',
35
+ defaultTtl: 100
36
+ })
37
+
38
+ await cache.set('test', 'value') // Should use default TTL
39
+ expect(await cache.get('test')).toBe('value')
40
+
41
+ await new Promise((resolve) => setTimeout(resolve, 150))
42
+ expect(await cache.get('test')).toBeNull()
43
+ })
44
+ })
45
+
46
+ describe('CacheService class', () => {
47
+ let cache: CacheService
48
+
49
+ beforeEach(() => {
50
+ cache = new CacheService({ strategy: 'memory' })
51
+ })
52
+
53
+ it('should set and get values', async () => {
54
+ await cache.set('key', 'value')
55
+ const value = await cache.get('key')
56
+ expect(value).toBe('value')
57
+ })
58
+
59
+ it('should support tags', async () => {
60
+ await cache.set('user:1', { name: 'John' }, { tags: ['users'] })
61
+ await cache.set('user:2', { name: 'Jane' }, { tags: ['users'] })
62
+
63
+ const deleted = await cache.deleteByTags(['users'])
64
+ expect(deleted).toBe(2)
65
+ })
66
+
67
+ it('should support all cache operations', async () => {
68
+ await cache.set('key1', 'value1')
69
+ await cache.set('key2', 'value2')
70
+
71
+ expect(await cache.has('key1')).toBe(true)
72
+ expect(await cache.has('nonexistent')).toBe(false)
73
+
74
+ const keys = await cache.keys()
75
+ expect(keys).toHaveLength(2)
76
+
77
+ const stats = await cache.stats()
78
+ expect(stats.size).toBe(2)
79
+
80
+ const deleted = await cache.delete('key1')
81
+ expect(deleted).toBe(true)
82
+
83
+ const cleared = await cache.clear()
84
+ expect(cleared).toBe(1)
85
+ })
86
+
87
+ it('should support cleanup', async () => {
88
+ await cache.set('key1', 'value1', { ttl: 50 })
89
+ await new Promise((resolve) => setTimeout(resolve, 100))
90
+
91
+ const removed = await cache.cleanup()
92
+ expect(removed).toBeGreaterThanOrEqual(0)
93
+ })
94
+
95
+ it('should support close', async () => {
96
+ await cache.close()
97
+ // Should not throw
98
+ })
99
+ })
100
+
101
+ describe('Complex scenarios', () => {
102
+ it('should handle concurrent operations', async () => {
103
+ const cache = createCacheService()
104
+
105
+ const promises = []
106
+ for (let i = 0; i < 100; i++) {
107
+ promises.push(cache.set(`key${i}`, `value${i}`))
108
+ }
109
+ await Promise.all(promises)
110
+
111
+ const stats = await cache.stats()
112
+ expect(stats.size).toBe(100)
113
+
114
+ const getPromises = []
115
+ for (let i = 0; i < 100; i++) {
116
+ getPromises.push(cache.get(`key${i}`))
117
+ }
118
+ const values = await Promise.all(getPromises)
119
+
120
+ values.forEach((value, i) => {
121
+ expect(value).toBe(`value${i}`)
122
+ })
123
+ })
124
+
125
+ it('should handle tag-based invalidation with complex tag structure', async () => {
126
+ const cache = createCacheService()
127
+
128
+ // User cache entries with multiple tags
129
+ await cache.set('user:1:profile', { name: 'John' }, {
130
+ tags: ['users', 'user:1', 'profiles', 'org:1']
131
+ })
132
+ await cache.set('user:1:settings', { theme: 'dark' }, {
133
+ tags: ['users', 'user:1', 'settings', 'org:1']
134
+ })
135
+ await cache.set('user:2:profile', { name: 'Jane' }, {
136
+ tags: ['users', 'user:2', 'profiles', 'org:1']
137
+ })
138
+ await cache.set('user:3:profile', { name: 'Bob' }, {
139
+ tags: ['users', 'user:3', 'profiles', 'org:2']
140
+ })
141
+
142
+ // Invalidate all user:1 cache
143
+ const deleted = await cache.deleteByTags(['user:1'])
144
+ expect(deleted).toBe(2)
145
+ expect(await cache.get('user:1:profile')).toBeNull()
146
+ expect(await cache.get('user:1:settings')).toBeNull()
147
+ expect(await cache.get('user:2:profile')).not.toBeNull()
148
+
149
+ // Invalidate all org:1 cache
150
+ const deleted2 = await cache.deleteByTags(['org:1'])
151
+ expect(deleted2).toBe(1) // Only user:2:profile remains
152
+ expect(await cache.get('user:2:profile')).toBeNull()
153
+ expect(await cache.get('user:3:profile')).not.toBeNull()
154
+ })
155
+
156
+ it('should handle rapid TTL expirations', async () => {
157
+ const cache = createCacheService()
158
+
159
+ // Create many entries with short TTL
160
+ for (let i = 0; i < 50; i++) {
161
+ await cache.set(`temp:${i}`, `value${i}`, { ttl: 50 })
162
+ }
163
+
164
+ // Create some permanent entries
165
+ for (let i = 0; i < 10; i++) {
166
+ await cache.set(`perm:${i}`, `value${i}`)
167
+ }
168
+
169
+ let stats = await cache.stats()
170
+ expect(stats.size).toBe(60)
171
+
172
+ await new Promise((resolve) => setTimeout(resolve, 100))
173
+
174
+ stats = await cache.stats()
175
+ expect(stats.expired).toBe(50)
176
+
177
+ // Cleanup should remove expired entries
178
+ if (!cache.cleanup) {
179
+ throw new Error('Expected cache strategy to support cleanup in tests')
180
+ }
181
+ const removed = await cache.cleanup()
182
+ expect(removed).toBe(50)
183
+
184
+ stats = await cache.stats()
185
+ expect(stats.size).toBe(10)
186
+ expect(stats.expired).toBe(0)
187
+ })
188
+ })
189
+ })
package/src/errors.ts ADDED
@@ -0,0 +1,14 @@
1
+ export class CacheDependencyUnavailableError extends Error {
2
+ public readonly strategy: string
3
+ public readonly dependency: string
4
+ public readonly originalError?: unknown
5
+
6
+ constructor(strategy: string, dependency: string, originalError?: unknown) {
7
+ super(`Cache strategy "${strategy}" requires dependency "${dependency}" which is not available`)
8
+ this.name = 'CacheDependencyUnavailableError'
9
+ this.strategy = strategy
10
+ this.dependency = dependency
11
+ this.originalError = originalError
12
+ }
13
+ }
14
+
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './types'
2
+ export * from './service'
3
+ export { createMemoryStrategy } from './strategies/memory'
4
+ export { createRedisStrategy } from './strategies/redis'
5
+ export { createSqliteStrategy } from './strategies/sqlite'
6
+ export { createJsonFileStrategy } from './strategies/jsonfile'
7
+ export { runWithCacheTenant, getCurrentCacheTenant } from './tenantContext'
package/src/service.ts ADDED
@@ -0,0 +1,367 @@
1
+ import type { CacheStrategy, CacheServiceOptions, CacheGetOptions, CacheSetOptions, CacheValue } from './types'
2
+ import { createMemoryStrategy } from './strategies/memory'
3
+ import { createRedisStrategy } from './strategies/redis'
4
+ import { createSqliteStrategy } from './strategies/sqlite'
5
+ import { createJsonFileStrategy } from './strategies/jsonfile'
6
+ import { getCurrentCacheTenant } from './tenantContext'
7
+ import { createHash } from 'node:crypto'
8
+ import { CacheDependencyUnavailableError } from './errors'
9
+
10
+ function normalizeTenantKey(raw: string | null | undefined): string {
11
+ const value = typeof raw === 'string' ? raw.trim() : ''
12
+ if (!value) return 'global'
13
+ return value.replace(/[^a-zA-Z0-9._-]/g, '_')
14
+ }
15
+
16
+ type TenantPrefixes = {
17
+ keyPrefix: string
18
+ tagPrefix: string
19
+ scopeTag: string
20
+ }
21
+
22
+ type CacheMetadata = {
23
+ key: string
24
+ expiresAt: number | null
25
+ }
26
+
27
+ function isCacheMetadata(value: CacheValue | null): value is CacheMetadata {
28
+ if (typeof value !== 'object' || value === null) {
29
+ return false
30
+ }
31
+ const record = value as Record<string, unknown>
32
+ const hasValidKey = typeof record.key === 'string'
33
+ const hasValidExpiresAt =
34
+ !('expiresAt' in record)
35
+ || record.expiresAt === null
36
+ || typeof record.expiresAt === 'number'
37
+
38
+ return hasValidKey && hasValidExpiresAt
39
+ }
40
+
41
+ type CacheStrategyName = NonNullable<CacheServiceOptions['strategy']>
42
+ const KNOWN_STRATEGIES: CacheStrategyName[] = ['memory', 'redis', 'sqlite', 'jsonfile']
43
+
44
+ function isCacheStrategyName(value: string | undefined): value is CacheStrategyName {
45
+ if (!value) return false
46
+ return KNOWN_STRATEGIES.includes(value as CacheStrategyName)
47
+ }
48
+
49
+ function resolveTenantPrefixes(): TenantPrefixes {
50
+ const tenant = normalizeTenantKey(getCurrentCacheTenant())
51
+ const base = `tenant:${tenant}:`
52
+ return {
53
+ keyPrefix: `${base}key:`,
54
+ tagPrefix: `${base}tag:`,
55
+ scopeTag: `${base}tag:__scope__`,
56
+ }
57
+ }
58
+
59
+ function hashIdentifier(input: string): string {
60
+ return createHash('sha1').update(input).digest('hex')
61
+ }
62
+
63
+ function storageKey(originalKey: string, prefixes: TenantPrefixes): string {
64
+ return `${prefixes.keyPrefix}k:${hashIdentifier(originalKey)}`
65
+ }
66
+
67
+ function metaKey(originalKey: string, prefixes: TenantPrefixes): string {
68
+ return `${prefixes.keyPrefix}meta:${hashIdentifier(originalKey)}`
69
+ }
70
+
71
+ function hashedTag(tag: string, prefixes: TenantPrefixes): string {
72
+ return `${prefixes.tagPrefix}t:${hashIdentifier(tag)}`
73
+ }
74
+
75
+ function buildTagSet(tags: string[] | undefined, prefixes: TenantPrefixes, includeScope: boolean): string[] {
76
+ const scoped = new Set<string>()
77
+ if (includeScope) scoped.add(prefixes.scopeTag)
78
+ if (Array.isArray(tags)) {
79
+ for (const tag of tags) {
80
+ if (typeof tag === 'string' && tag.length > 0) scoped.add(hashedTag(tag, prefixes))
81
+ }
82
+ }
83
+ return Array.from(scoped)
84
+ }
85
+
86
+ function matchPattern(value: string, pattern: string): boolean {
87
+ const regexPattern = pattern
88
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
89
+ .replace(/\*/g, '.*')
90
+ .replace(/\?/g, '.')
91
+ const regex = new RegExp(`^${regexPattern}$`)
92
+ return regex.test(value)
93
+ }
94
+
95
+ function createTenantAwareWrapper(base: CacheStrategy): CacheStrategy {
96
+ function normalizeDeletionCount(raw: number): number {
97
+ if (!raw) return raw
98
+ if (!Number.isFinite(raw)) return raw
99
+ return Math.ceil(raw / 2)
100
+ }
101
+
102
+ const get = async (key: string, options?: CacheGetOptions) => {
103
+ const prefixes = resolveTenantPrefixes()
104
+ return base.get(storageKey(key, prefixes), options)
105
+ }
106
+
107
+ const set = async (key: string, value: CacheValue, options?: CacheSetOptions) => {
108
+ const prefixes = resolveTenantPrefixes()
109
+ const hashedTags = buildTagSet(options?.tags, prefixes, true)
110
+ const ttl = options?.ttl ?? undefined
111
+ const nextOptions: CacheSetOptions | undefined = options
112
+ ? { ...options, tags: hashedTags }
113
+ : { tags: hashedTags }
114
+ await base.set(storageKey(key, prefixes), value, nextOptions)
115
+ const metaPayload: CacheMetadata = { key, expiresAt: ttl ? Date.now() + ttl : null }
116
+ await base.set(metaKey(key, prefixes), metaPayload, {
117
+ ttl,
118
+ tags: hashedTags,
119
+ })
120
+ }
121
+
122
+ const has = async (key: string) => {
123
+ const prefixes = resolveTenantPrefixes()
124
+ return base.has(storageKey(key, prefixes))
125
+ }
126
+
127
+ const del = async (key: string) => {
128
+ const prefixes = resolveTenantPrefixes()
129
+ const primary = await base.delete(storageKey(key, prefixes))
130
+ await base.delete(metaKey(key, prefixes))
131
+ return primary
132
+ }
133
+
134
+ const deleteByTags = async (tags: string[]) => {
135
+ const prefixes = resolveTenantPrefixes()
136
+ const scopedTags = buildTagSet(tags, prefixes, false)
137
+ if (!scopedTags.length) return 0
138
+ const removed = await base.deleteByTags(scopedTags)
139
+ return normalizeDeletionCount(removed)
140
+ }
141
+
142
+ const clear = async () => {
143
+ const prefixes = resolveTenantPrefixes()
144
+ const removed = await base.deleteByTags([prefixes.scopeTag])
145
+ return normalizeDeletionCount(removed)
146
+ }
147
+
148
+ const keys = async (pattern?: string) => {
149
+ const prefixes = resolveTenantPrefixes()
150
+ const metaPattern = `${prefixes.keyPrefix}meta:*`
151
+ const metaKeys = await base.keys(metaPattern)
152
+ const originals: string[] = []
153
+ for (const metaKey of metaKeys) {
154
+ const metaValue = await base.get(metaKey, { returnExpired: true })
155
+ if (!metaValue) continue
156
+ const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)
157
+ const original = typeof metaValue === 'string' ? metaValue : metadata?.key
158
+ if (!original) continue
159
+ if (pattern && !matchPattern(original, pattern)) continue
160
+ originals.push(original)
161
+ }
162
+ return originals
163
+ }
164
+
165
+ const stats = async () => {
166
+ const prefixes = resolveTenantPrefixes()
167
+ const metaKeys = await base.keys(`${prefixes.keyPrefix}meta:*`)
168
+ let size = 0
169
+ let expired = 0
170
+ const now = Date.now()
171
+ for (const metaKey of metaKeys) {
172
+ const metaValue = await base.get(metaKey, { returnExpired: true })
173
+ if (!metaValue) continue
174
+ const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)
175
+ const original = typeof metaValue === 'string' ? metaValue : metadata?.key
176
+ if (!original) continue
177
+ size++
178
+ const expiresAt = metadata?.expiresAt ?? null
179
+ if (expiresAt !== null && expiresAt <= now) expired++
180
+ }
181
+ return { size, expired }
182
+ }
183
+
184
+ const cleanup = base.cleanup
185
+ ? async () => normalizeDeletionCount(await base.cleanup!())
186
+ : undefined
187
+
188
+ const close = base.close
189
+ ? async () => base.close!()
190
+ : undefined
191
+
192
+ return {
193
+ get,
194
+ set,
195
+ has,
196
+ delete: del,
197
+ deleteByTags,
198
+ clear,
199
+ keys,
200
+ stats,
201
+ cleanup,
202
+ close,
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Cache service that provides a unified interface to different cache strategies
208
+ *
209
+ * Configuration via environment variables:
210
+ * - CACHE_STRATEGY: 'memory' | 'redis' | 'sqlite' | 'jsonfile' (default: 'memory')
211
+ * - CACHE_TTL: Default TTL in milliseconds (optional)
212
+ * - CACHE_REDIS_URL: Redis connection URL (for redis strategy)
213
+ * - CACHE_SQLITE_PATH: SQLite database file path (for sqlite strategy)
214
+ * - CACHE_JSON_FILE_PATH: JSON file path (for jsonfile strategy)
215
+ *
216
+ * @example
217
+ * const cache = createCacheService({ strategy: 'memory', defaultTtl: 60000 })
218
+ * await cache.set('user:123', { name: 'John' }, { tags: ['users', 'user:123'] })
219
+ * const user = await cache.get('user:123')
220
+ * await cache.deleteByTags(['users']) // Invalidate all user-related cache
221
+ */
222
+ export function createCacheService(options?: CacheServiceOptions): CacheStrategy {
223
+ const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY)
224
+ ? process.env.CACHE_STRATEGY
225
+ : undefined
226
+ const strategyType: CacheStrategyName = options?.strategy ?? envStrategy ?? 'memory'
227
+
228
+ const envTtl = process.env.CACHE_TTL
229
+ const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : undefined
230
+ const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === 'number' && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : undefined)
231
+
232
+ const baseStrategy = createStrategyForType(strategyType, options, defaultTtl)
233
+ const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl)
234
+
235
+ return createTenantAwareWrapper(resilientStrategy)
236
+ }
237
+
238
+ /**
239
+ * CacheService class wrapper for DI integration
240
+ * Provides the same interface as the functional API but as a class
241
+ */
242
+ export class CacheService implements CacheStrategy {
243
+ private strategy: CacheStrategy
244
+
245
+ constructor(options?: CacheServiceOptions) {
246
+ this.strategy = createCacheService(options)
247
+ }
248
+
249
+ async get(key: string, options?: CacheGetOptions): Promise<CacheValue | null> {
250
+ return this.strategy.get(key, options)
251
+ }
252
+
253
+ async set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> {
254
+ return this.strategy.set(key, value, options)
255
+ }
256
+
257
+ async has(key: string): Promise<boolean> {
258
+ return this.strategy.has(key)
259
+ }
260
+
261
+ async delete(key: string): Promise<boolean> {
262
+ return this.strategy.delete(key)
263
+ }
264
+
265
+ async deleteByTags(tags: string[]): Promise<number> {
266
+ return this.strategy.deleteByTags(tags)
267
+ }
268
+
269
+ async clear(): Promise<number> {
270
+ return this.strategy.clear()
271
+ }
272
+
273
+ async keys(pattern?: string): Promise<string[]> {
274
+ return this.strategy.keys(pattern)
275
+ }
276
+
277
+ async stats(): Promise<{ size: number; expired: number }> {
278
+ return this.strategy.stats()
279
+ }
280
+
281
+ async cleanup(): Promise<number> {
282
+ if (this.strategy.cleanup) {
283
+ return this.strategy.cleanup()
284
+ }
285
+ return 0
286
+ }
287
+
288
+ async close(): Promise<void> {
289
+ if (this.strategy.close) {
290
+ return this.strategy.close()
291
+ }
292
+ }
293
+ }
294
+
295
+ function createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number): CacheStrategy {
296
+ switch (strategyType) {
297
+ case 'redis':
298
+ return createRedisStrategy(options?.redisUrl, { defaultTtl })
299
+ case 'sqlite':
300
+ return createSqliteStrategy(options?.sqlitePath, { defaultTtl })
301
+ case 'jsonfile':
302
+ return createJsonFileStrategy(options?.jsonFilePath, { defaultTtl })
303
+ case 'memory':
304
+ default:
305
+ return createMemoryStrategy({ defaultTtl })
306
+ }
307
+ }
308
+
309
+ function withDependencyFallback(strategy: CacheStrategy, strategyType: CacheStrategyName, defaultTtl?: number): CacheStrategy {
310
+ if (strategyType === 'memory') return strategy
311
+
312
+ let activeStrategy = strategy
313
+ let fallbackStrategy: CacheStrategy | null = null
314
+ let warned = false
315
+
316
+ const ensureFallback = (error: CacheDependencyUnavailableError) => {
317
+ if (!fallbackStrategy) {
318
+ fallbackStrategy = createMemoryStrategy({ defaultTtl })
319
+ }
320
+ if (!warned) {
321
+ const dependencyMessage = error.dependency
322
+ ? ` (missing dependency: ${error.dependency})`
323
+ : ''
324
+ console.warn(`[cache] ${error.strategy} strategy unavailable${dependencyMessage}. Falling back to memory strategy.`)
325
+ warned = true
326
+ }
327
+ activeStrategy = fallbackStrategy
328
+ }
329
+
330
+ const wrapMethod = <K extends keyof CacheStrategy>(method: K): CacheStrategy[K] => {
331
+ const handler = async (...args: Parameters<NonNullable<CacheStrategy[K]>>) => {
332
+ const fn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined
333
+ if (!fn) {
334
+ return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>
335
+ }
336
+
337
+ try {
338
+ return await fn(...args)
339
+ } catch (error) {
340
+ if (error instanceof CacheDependencyUnavailableError) {
341
+ ensureFallback(error)
342
+ const fallbackFn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined
343
+ if (!fallbackFn) {
344
+ return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>
345
+ }
346
+ return fallbackFn(...args)
347
+ }
348
+ throw error
349
+ }
350
+ }
351
+
352
+ return handler as CacheStrategy[K]
353
+ }
354
+
355
+ return {
356
+ get: wrapMethod('get'),
357
+ set: wrapMethod('set'),
358
+ has: wrapMethod('has'),
359
+ delete: wrapMethod('delete'),
360
+ deleteByTags: wrapMethod('deleteByTags'),
361
+ clear: wrapMethod('clear'),
362
+ keys: wrapMethod('keys'),
363
+ stats: wrapMethod('stats'),
364
+ cleanup: typeof strategy.cleanup === 'function' ? wrapMethod('cleanup') : undefined,
365
+ close: typeof strategy.close === 'function' ? wrapMethod('close') : undefined,
366
+ }
367
+ }