@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.
- package/ENV.md +173 -0
- package/README.md +177 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/service.d.ts +40 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +321 -0
- package/dist/strategies/jsonfile.d.ts +10 -0
- package/dist/strategies/jsonfile.d.ts.map +1 -0
- package/dist/strategies/jsonfile.js +205 -0
- package/dist/strategies/memory.d.ts +9 -0
- package/dist/strategies/memory.d.ts.map +1 -0
- package/dist/strategies/memory.js +166 -0
- package/dist/strategies/redis.d.ts +5 -0
- package/dist/strategies/redis.d.ts.map +1 -0
- package/dist/strategies/redis.js +388 -0
- package/dist/strategies/sqlite.d.ts +13 -0
- package/dist/strategies/sqlite.d.ts.map +1 -0
- package/dist/strategies/sqlite.js +217 -0
- package/dist/tenantContext.d.ts +4 -0
- package/dist/tenantContext.d.ts.map +1 -0
- package/dist/tenantContext.js +9 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/jest.config.js +19 -0
- package/package.json +39 -0
- package/src/__tests__/memory.strategy.test.ts +245 -0
- package/src/__tests__/service.test.ts +189 -0
- package/src/errors.ts +14 -0
- package/src/index.ts +7 -0
- package/src/service.ts +367 -0
- package/src/strategies/jsonfile.ts +249 -0
- package/src/strategies/memory.ts +185 -0
- package/src/strategies/redis.ts +443 -0
- package/src/strategies/sqlite.ts +285 -0
- package/src/tenantContext.ts +13 -0
- package/src/types.ts +100 -0
- package/tsconfig.build.json +5 -0
- 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
|
+
}
|