@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,443 @@
1
+ import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'
2
+ import { CacheDependencyUnavailableError } from '../errors'
3
+
4
+ type RedisPipeline = {
5
+ set(key: string, value: string): RedisPipeline
6
+ setex(key: string, ttlSeconds: number, value: string): RedisPipeline
7
+ sadd(key: string, value: string): RedisPipeline
8
+ srem(key: string, member: string): RedisPipeline
9
+ del(key: string): RedisPipeline
10
+ exec(): Promise<unknown>
11
+ }
12
+
13
+ type RedisClient = {
14
+ get(key: string): Promise<string | null>
15
+ set(key: string, value: string): Promise<unknown>
16
+ setex(key: string, ttlSeconds: number, value: string): Promise<unknown>
17
+ del(key: string): Promise<unknown>
18
+ exists(key: string): Promise<number>
19
+ keys(pattern: string): Promise<string[]>
20
+ smembers(key: string): Promise<string[]>
21
+ pipeline(): RedisPipeline
22
+ quit(): Promise<void>
23
+ once?(event: 'end', listener: () => void): void
24
+ }
25
+
26
+ type RedisConstructor = new (url?: string) => RedisClient
27
+ type PossibleRedisModule = unknown
28
+
29
+ type RequireFn = (id: string) => unknown
30
+
31
+ /**
32
+ * Redis cache strategy with tag support
33
+ * Persistent across process restarts, can be shared across multiple instances
34
+ *
35
+ * Uses Redis data structures:
36
+ * - Hash for storing cache entries: cache:{key} -> {value, tags, expiresAt, createdAt}
37
+ * - Sets for tag index: tag:{tag} -> Set of keys
38
+ */
39
+ let redisModulePromise: Promise<PossibleRedisModule> | null = null
40
+ type RedisRegistryEntry = { client?: RedisClient; creating?: Promise<RedisClient>; refs: number }
41
+ const redisRegistry = new Map<string, RedisRegistryEntry>()
42
+
43
+ function resolveRequire(): RequireFn | null {
44
+ const nonWebpack = (globalThis as { __non_webpack_require__?: unknown }).__non_webpack_require__
45
+ if (typeof nonWebpack === 'function') return nonWebpack as RequireFn
46
+ if (typeof require === 'function') return require as RequireFn
47
+ if (typeof module !== 'undefined' && typeof module.require === 'function') {
48
+ return module.require.bind(module)
49
+ }
50
+ try {
51
+ const maybeRequire = Function('return typeof require !== "undefined" ? require : undefined')()
52
+ if (typeof maybeRequire === 'function') return maybeRequire as RequireFn
53
+ } catch {
54
+ // ignore
55
+ }
56
+ return null
57
+ }
58
+
59
+ function loadRedisModuleViaRequire(): PossibleRedisModule | null {
60
+ const resolver = resolveRequire()
61
+ if (!resolver) return null
62
+ try {
63
+ return resolver('ioredis') as PossibleRedisModule
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ function pickRedisConstructor(mod: PossibleRedisModule): RedisConstructor | null {
70
+ const queue: unknown[] = [mod]
71
+ const seen = new Set<unknown>()
72
+ while (queue.length) {
73
+ const current = queue.shift()
74
+ if (!current || seen.has(current)) continue
75
+ seen.add(current)
76
+ if (typeof current === 'function') return current as RedisConstructor
77
+ if (typeof current === 'object') {
78
+ queue.push((current as { default?: unknown }).default)
79
+ queue.push((current as { Redis?: unknown }).Redis)
80
+ queue.push((current as { module?: { exports?: unknown } }).module?.exports)
81
+ queue.push((current as { exports?: unknown }).exports)
82
+ }
83
+ }
84
+ return null
85
+ }
86
+
87
+ async function loadRedisModule(): Promise<PossibleRedisModule> {
88
+ if (!redisModulePromise) {
89
+ redisModulePromise = (async () => {
90
+ const required = loadRedisModuleViaRequire() ?? (await import('ioredis'))
91
+ return required as PossibleRedisModule
92
+ })().catch((error) => {
93
+ redisModulePromise = null
94
+ throw new CacheDependencyUnavailableError('redis', 'ioredis', error)
95
+ })
96
+ }
97
+ return redisModulePromise
98
+ }
99
+
100
+ function retainRedisEntry(key: string): RedisRegistryEntry {
101
+ let entry = redisRegistry.get(key)
102
+ if (!entry) {
103
+ entry = { refs: 0 }
104
+ redisRegistry.set(key, entry)
105
+ }
106
+ entry.refs += 1
107
+ return entry
108
+ }
109
+
110
+ async function acquireRedisClient(key: string, entry: RedisRegistryEntry): Promise<RedisClient> {
111
+ if (entry.client) return entry.client
112
+ if (entry.creating) return entry.creating
113
+ entry.creating = loadRedisModule()
114
+ .then((mod) => {
115
+ const ctor = pickRedisConstructor(mod)
116
+ if (!ctor) {
117
+ throw new CacheDependencyUnavailableError('redis', 'ioredis', new Error('No usable Redis constructor'))
118
+ }
119
+ const client = new ctor(key)
120
+ entry.client = client
121
+ entry.creating = undefined
122
+ client.once?.('end', () => {
123
+ if (redisRegistry.get(key) === entry && entry.refs === 0) {
124
+ redisRegistry.delete(key)
125
+ } else if (redisRegistry.get(key) === entry) {
126
+ entry.client = undefined
127
+ }
128
+ })
129
+ return client
130
+ })
131
+ .catch((error) => {
132
+ entry.creating = undefined
133
+ throw error
134
+ })
135
+ return entry.creating
136
+ }
137
+
138
+ async function releaseRedisEntry(key: string, entry: RedisRegistryEntry): Promise<void> {
139
+ entry.refs = Math.max(0, entry.refs - 1)
140
+ if (entry.refs > 0) return
141
+ redisRegistry.delete(key)
142
+ if (entry.client) {
143
+ try {
144
+ await entry.client.quit()
145
+ } catch {
146
+ // ignore shutdown errors
147
+ } finally {
148
+ entry.client = undefined
149
+ }
150
+ }
151
+ }
152
+
153
+ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number }): CacheStrategy {
154
+ const defaultTtl = options?.defaultTtl
155
+ const keyPrefix = 'cache:'
156
+ const tagPrefix = 'tag:'
157
+ const connectionUrl =
158
+ redisUrl || process.env.REDIS_URL || process.env.CACHE_REDIS_URL || 'redis://localhost:6379'
159
+ const registryEntry = retainRedisEntry(connectionUrl)
160
+ let redis: RedisClient | null = registryEntry.client ?? null
161
+
162
+ async function getRedisClient(): Promise<RedisClient> {
163
+ if (redis) return redis
164
+
165
+ redis = await acquireRedisClient(connectionUrl, registryEntry)
166
+ return redis
167
+ }
168
+
169
+ function getCacheKey(key: string): string {
170
+ return `${keyPrefix}${key}`
171
+ }
172
+
173
+ function getTagKey(tag: string): string {
174
+ return `${tagPrefix}${tag}`
175
+ }
176
+
177
+ function isExpired(entry: CacheEntry): boolean {
178
+ if (entry.expiresAt === null) return false
179
+ return Date.now() > entry.expiresAt
180
+ }
181
+
182
+ function matchPattern(key: string, pattern: string): boolean {
183
+ const regexPattern = pattern
184
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
185
+ .replace(/\*/g, '.*')
186
+ .replace(/\?/g, '.')
187
+ const regex = new RegExp(`^${regexPattern}$`)
188
+ return regex.test(key)
189
+ }
190
+
191
+ const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {
192
+ const client = await getRedisClient()
193
+ const cacheKey = getCacheKey(key)
194
+ const data = await client.get(cacheKey)
195
+
196
+ if (!data) return null
197
+
198
+ try {
199
+ const entry: CacheEntry = JSON.parse(data)
200
+
201
+ if (isExpired(entry)) {
202
+ if (options?.returnExpired) {
203
+ return entry.value
204
+ }
205
+ // Clean up expired entry
206
+ await deleteKey(key)
207
+ return null
208
+ }
209
+
210
+ return entry.value
211
+ } catch {
212
+ // Invalid JSON, remove it
213
+ await client.del(cacheKey)
214
+ return null
215
+ }
216
+ }
217
+
218
+ const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {
219
+ const client = await getRedisClient()
220
+ const cacheKey = getCacheKey(key)
221
+
222
+ // Remove old entry from tag index if it exists
223
+ const oldData = await client.get(cacheKey)
224
+ if (oldData) {
225
+ try {
226
+ const oldEntry: CacheEntry = JSON.parse(oldData)
227
+ // Remove from old tags
228
+ const pipeline = client.pipeline()
229
+ for (const tag of oldEntry.tags) {
230
+ pipeline.srem(getTagKey(tag), key)
231
+ }
232
+ await pipeline.exec()
233
+ } catch {
234
+ // Ignore parse errors
235
+ }
236
+ }
237
+
238
+ const ttl = options?.ttl ?? defaultTtl
239
+ const tags = options?.tags || []
240
+ const expiresAt = ttl ? Date.now() + ttl : null
241
+
242
+ const entry: CacheEntry = {
243
+ key,
244
+ value,
245
+ tags,
246
+ expiresAt,
247
+ createdAt: Date.now(),
248
+ }
249
+
250
+ const pipeline = client.pipeline()
251
+
252
+ // Store the entry
253
+ const serialized = JSON.stringify(entry)
254
+ if (ttl) {
255
+ pipeline.setex(cacheKey, Math.ceil(ttl / 1000), serialized)
256
+ } else {
257
+ pipeline.set(cacheKey, serialized)
258
+ }
259
+
260
+ // Add to tag index
261
+ for (const tag of tags) {
262
+ pipeline.sadd(getTagKey(tag), key)
263
+ }
264
+
265
+ await pipeline.exec()
266
+ }
267
+
268
+ const has = async (key: string): Promise<boolean> => {
269
+ const client = await getRedisClient()
270
+ const cacheKey = getCacheKey(key)
271
+ const exists = await client.exists(cacheKey)
272
+
273
+ if (!exists) return false
274
+
275
+ // Check if expired
276
+ const data = await client.get(cacheKey)
277
+ if (!data) return false
278
+
279
+ try {
280
+ const entry: CacheEntry = JSON.parse(data)
281
+ if (isExpired(entry)) {
282
+ await deleteKey(key)
283
+ return false
284
+ }
285
+ return true
286
+ } catch {
287
+ return false
288
+ }
289
+ }
290
+
291
+ const deleteKey = async (key: string): Promise<boolean> => {
292
+ const client = await getRedisClient()
293
+ const cacheKey = getCacheKey(key)
294
+
295
+ // Get entry to remove from tag index
296
+ const data = await client.get(cacheKey)
297
+ if (!data) return false
298
+
299
+ try {
300
+ const entry: CacheEntry = JSON.parse(data)
301
+ const pipeline = client.pipeline()
302
+
303
+ // Remove from tag index
304
+ for (const tag of entry.tags) {
305
+ pipeline.srem(getTagKey(tag), key)
306
+ }
307
+
308
+ // Delete the cache entry
309
+ pipeline.del(cacheKey)
310
+
311
+ await pipeline.exec()
312
+ return true
313
+ } catch {
314
+ // Just delete the key if we can't parse it
315
+ await client.del(cacheKey)
316
+ return true
317
+ }
318
+ }
319
+
320
+ const deleteByTags = async (tags: string[]): Promise<number> => {
321
+ const client = await getRedisClient()
322
+ const keysToDelete = new Set<string>()
323
+
324
+ // Collect all keys that have any of the specified tags
325
+ for (const tag of tags) {
326
+ const tagKey = getTagKey(tag)
327
+ const keys = await client.smembers(tagKey)
328
+ for (const key of keys) {
329
+ keysToDelete.add(key)
330
+ }
331
+ }
332
+
333
+ // Delete all collected keys
334
+ let deleted = 0
335
+ for (const key of keysToDelete) {
336
+ const success = await deleteKey(key)
337
+ if (success) deleted++
338
+ }
339
+
340
+ return deleted
341
+ }
342
+
343
+ const clear = async (): Promise<number> => {
344
+ const client = await getRedisClient()
345
+
346
+ // Get all cache keys
347
+ const cacheKeys = await client.keys(`${keyPrefix}*`)
348
+ const tagKeys = await client.keys(`${tagPrefix}*`)
349
+
350
+ if (cacheKeys.length === 0 && tagKeys.length === 0) return 0
351
+
352
+ const pipeline = client.pipeline()
353
+ for (const key of [...cacheKeys, ...tagKeys]) {
354
+ pipeline.del(key)
355
+ }
356
+
357
+ await pipeline.exec()
358
+ return cacheKeys.length
359
+ }
360
+
361
+ const keys = async (pattern?: string): Promise<string[]> => {
362
+ const client = await getRedisClient()
363
+ const searchPattern = pattern
364
+ ? `${keyPrefix}${pattern}`
365
+ : `${keyPrefix}*`
366
+
367
+ const cacheKeys = await client.keys(searchPattern)
368
+
369
+ // Remove prefix from keys
370
+ const result = cacheKeys.map((key: string) => key.substring(keyPrefix.length))
371
+
372
+ if (!pattern) return result
373
+
374
+ // Apply pattern matching (Redis KEYS command uses glob pattern, but we want our pattern)
375
+ return result.filter((key: string) => matchPattern(key, pattern))
376
+ }
377
+
378
+ const stats = async (): Promise<{ size: number; expired: number }> => {
379
+ const client = await getRedisClient()
380
+ const cacheKeys = await client.keys(`${keyPrefix}*`)
381
+
382
+ let expired = 0
383
+ for (const cacheKey of cacheKeys) {
384
+ const data = await client.get(cacheKey)
385
+ if (data) {
386
+ try {
387
+ const entry: CacheEntry = JSON.parse(data)
388
+ if (isExpired(entry)) {
389
+ expired++
390
+ }
391
+ } catch {
392
+ // Ignore parse errors
393
+ }
394
+ }
395
+ }
396
+
397
+ return { size: cacheKeys.length, expired }
398
+ }
399
+
400
+ const cleanup = async (): Promise<number> => {
401
+ const client = await getRedisClient()
402
+ const cacheKeys = await client.keys(`${keyPrefix}*`)
403
+
404
+ let removed = 0
405
+ for (const cacheKey of cacheKeys) {
406
+ const data = await client.get(cacheKey)
407
+ if (data) {
408
+ try {
409
+ const entry: CacheEntry = JSON.parse(data)
410
+ if (isExpired(entry)) {
411
+ const key = cacheKey.substring(keyPrefix.length)
412
+ await deleteKey(key)
413
+ removed++
414
+ }
415
+ } catch {
416
+ // Remove invalid entries
417
+ await client.del(cacheKey)
418
+ removed++
419
+ }
420
+ }
421
+ }
422
+
423
+ return removed
424
+ }
425
+
426
+ const close = async (): Promise<void> => {
427
+ await releaseRedisEntry(connectionUrl, registryEntry)
428
+ redis = null
429
+ }
430
+
431
+ return {
432
+ get,
433
+ set,
434
+ has,
435
+ delete: deleteKey,
436
+ deleteByTags,
437
+ clear,
438
+ keys,
439
+ stats,
440
+ cleanup,
441
+ close,
442
+ }
443
+ }