@open-mercato/cache 0.10.1-develop.7576.1.21d5b50435 → 0.10.1-develop.7579.1.3db6f78f16

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/src/codec.ts ADDED
@@ -0,0 +1,94 @@
1
+ import zlib from 'node:zlib'
2
+
3
+ export type CacheCompression = 'off' | 'zstd' | 'gzip'
4
+
5
+ export type CacheCodecConfig = {
6
+ compression: CacheCompression
7
+ minBytes: number
8
+ }
9
+
10
+ export type CacheCodecOptions = {
11
+ compression?: CacheCompression
12
+ compressionMinBytes?: number
13
+ }
14
+
15
+ export const DEFAULT_COMPRESSION_MIN_BYTES = 1024
16
+
17
+ /** Upper bound for one decompressed entry; larger JSON is never compressed, so no legitimate entry can hit it. */
18
+ export const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024
19
+
20
+ const ZSTD_LEVEL = 1
21
+ const COMPRESSIONS: CacheCompression[] = ['off', 'zstd', 'gzip']
22
+
23
+ const warnedMessages = new Set<string>()
24
+
25
+ function warnOnce(message: string): void {
26
+ if (warnedMessages.has(message)) return
27
+ warnedMessages.add(message)
28
+ console.warn(message)
29
+ }
30
+
31
+ export function isZstdAvailable(): boolean {
32
+ return typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function'
33
+ }
34
+
35
+ export function parseCacheCompression(raw: string | undefined): CacheCompression {
36
+ const value = raw?.trim().toLowerCase()
37
+ if (!value) return 'off'
38
+ if (COMPRESSIONS.includes(value as CacheCompression)) return value as CacheCompression
39
+ warnOnce(`[cache] Unknown CACHE_COMPRESSION value "${raw}". Compression stays off.`)
40
+ return 'off'
41
+ }
42
+
43
+ export function parseCacheCompressionMinBytes(raw: string | undefined): number | undefined {
44
+ if (!raw) return undefined
45
+ const parsed = Number.parseInt(raw, 10)
46
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
47
+ }
48
+
49
+ export function resolveCacheCodecConfig(options?: CacheCodecOptions): CacheCodecConfig {
50
+ const minBytes = options?.compressionMinBytes && options.compressionMinBytes > 0
51
+ ? options.compressionMinBytes
52
+ : DEFAULT_COMPRESSION_MIN_BYTES
53
+ let compression = options?.compression ?? 'off'
54
+ if (compression === 'zstd' && !isZstdAvailable()) {
55
+ warnOnce('[cache] zstd is not available in this Node.js runtime. Falling back to gzip cache compression.')
56
+ compression = 'gzip'
57
+ }
58
+ return { compression, minBytes }
59
+ }
60
+
61
+ function compress(input: Buffer, compression: Exclude<CacheCompression, 'off'>): Buffer {
62
+ if (compression === 'zstd') {
63
+ return zlib.zstdCompressSync(input, { params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } })
64
+ }
65
+ return zlib.gzipSync(input)
66
+ }
67
+
68
+ export function encodeCacheText(json: string, config: CacheCodecConfig): string | Buffer {
69
+ if (config.compression === 'off') return json
70
+ const size = Buffer.byteLength(json)
71
+ if (size < config.minBytes || size > MAX_DECOMPRESSED_BYTES) return json
72
+ const compressed = compress(Buffer.from(json, 'utf8'), config.compression)
73
+ return compressed.length < size ? compressed : json
74
+ }
75
+
76
+ function isZstdFrame(raw: Buffer): boolean {
77
+ return raw.length >= 4 && raw[0] === 0x28 && raw[1] === 0xb5 && raw[2] === 0x2f && raw[3] === 0xfd
78
+ }
79
+
80
+ function isGzipStream(raw: Buffer): boolean {
81
+ return raw.length >= 2 && raw[0] === 0x1f && raw[1] === 0x8b
82
+ }
83
+
84
+ /** Detects the format from the stored bytes, so entries stay readable whatever CACHE_COMPRESSION is set to now. */
85
+ export function decodeCacheText(raw: string | Buffer): string {
86
+ if (typeof raw === 'string') return raw
87
+ if (isZstdFrame(raw)) {
88
+ return zlib.zstdDecompressSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')
89
+ }
90
+ if (isGzipStream(raw)) {
91
+ return zlib.gunzipSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')
92
+ }
93
+ return raw.toString('utf8')
94
+ }
package/src/service.ts CHANGED
@@ -7,6 +7,8 @@ import { getCurrentCacheTenant } from './tenantContext'
7
7
  import { createHash } from 'node:crypto'
8
8
  import { CacheDependencyUnavailableError } from './errors'
9
9
  import { matchCacheKeyPattern } from './patterns'
10
+ import { parseCacheCompression, parseCacheCompressionMinBytes } from './codec'
11
+ import type { CacheCodecOptions } from './codec'
10
12
 
11
13
  function normalizeTenantKey(raw: string | null | undefined): string {
12
14
  const value = typeof raw === 'string' ? raw.trim() : ''
@@ -260,7 +262,12 @@ function createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): Ten
260
262
  const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined
261
263
  const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)
262
264
 
263
- const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries)
265
+ const codecOptions: CacheCodecOptions = {
266
+ compression: options?.compression ?? parseCacheCompression(process.env.CACHE_COMPRESSION),
267
+ compressionMinBytes: options?.compressionMinBytes ?? parseCacheCompressionMinBytes(process.env.CACHE_COMPRESSION_MIN_BYTES),
268
+ }
269
+
270
+ const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries, codecOptions)
264
271
  const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries)
265
272
  const reportsBoundedCounters = strategyType === 'memory'
266
273
 
@@ -276,6 +283,8 @@ function createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): Ten
276
283
  * - CACHE_REDIS_URL: Redis connection URL (for redis strategy)
277
284
  * - CACHE_SQLITE_PATH: SQLite database file path (for sqlite strategy)
278
285
  * - CACHE_JSON_FILE_PATH: JSON file path (for jsonfile strategy)
286
+ * - CACHE_COMPRESSION: 'off' | 'zstd' | 'gzip' value compression for redis/sqlite (default: 'off')
287
+ * - CACHE_COMPRESSION_MIN_BYTES: smallest serialized entry that gets compressed (default: 1024)
279
288
  *
280
289
  * @example
281
290
  * const cache = createCacheService({ strategy: 'memory', defaultTtl: 60000 })
@@ -373,12 +382,12 @@ export class CacheService implements CacheStrategy {
373
382
  }
374
383
  }
375
384
 
376
- function createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number, maxEntries?: number): CacheStrategy {
385
+ function createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number, maxEntries?: number, codecOptions?: CacheCodecOptions): CacheStrategy {
377
386
  switch (strategyType) {
378
387
  case 'redis':
379
- return createRedisStrategy(options?.redisUrl, { defaultTtl })
388
+ return createRedisStrategy(options?.redisUrl, { defaultTtl, ...codecOptions })
380
389
  case 'sqlite':
381
- return createSqliteStrategy(options?.sqlitePath, { defaultTtl })
390
+ return createSqliteStrategy(options?.sqlitePath, { defaultTtl, ...codecOptions })
382
391
  case 'jsonfile':
383
392
  return createJsonFileStrategy(options?.jsonFilePath, { defaultTtl })
384
393
  case 'memory':
@@ -3,10 +3,12 @@ import { CacheDependencyUnavailableError } from '../errors'
3
3
  import { getRedisUrlOrThrow, REDIS_WIRE_PROTOCOL } from '@open-mercato/shared/lib/redis/connection'
4
4
  import type { RedisProtocolVersion } from '@open-mercato/shared/lib/redis/connection'
5
5
  import { matchCacheKeyPattern } from '../patterns'
6
+ import { decodeCacheText, encodeCacheText, resolveCacheCodecConfig } from '../codec'
7
+ import type { CacheCodecOptions } from '../codec'
6
8
 
7
9
  type RedisPipeline = {
8
- set(key: string, value: string): RedisPipeline
9
- setex(key: string, ttlSeconds: number, value: string): RedisPipeline
10
+ set(key: string, value: string | Buffer): RedisPipeline
11
+ setex(key: string, ttlSeconds: number, value: string | Buffer): RedisPipeline
10
12
  sadd(key: string, value: string): RedisPipeline
11
13
  srem(key: string, member: string): RedisPipeline
12
14
  del(key: string): RedisPipeline
@@ -17,8 +19,9 @@ type RedisPipeline = {
17
19
  type RedisClient = {
18
20
  ping(): Promise<string>
19
21
  get(key: string): Promise<string | null>
20
- set(key: string, value: string): Promise<unknown>
21
- setex(key: string, ttlSeconds: number, value: string): Promise<unknown>
22
+ getBuffer(key: string): Promise<Buffer | null>
23
+ set(key: string, value: string | Buffer): Promise<unknown>
24
+ setex(key: string, ttlSeconds: number, value: string | Buffer): Promise<unknown>
22
25
  del(key: string): Promise<unknown>
23
26
  exists(key: string): Promise<number>
24
27
  keys(pattern: string): Promise<string[]>
@@ -186,8 +189,9 @@ async function releaseRedisEntry(key: string, entry: RedisRegistryEntry): Promis
186
189
  }
187
190
  }
188
191
 
189
- export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number }): CacheStrategy {
192
+ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number } & CacheCodecOptions): CacheStrategy {
190
193
  const defaultTtl = options?.defaultTtl
194
+ const codec = resolveCacheCodecConfig(options)
191
195
  const keyPrefix = 'cache:'
192
196
  const tagPrefix = 'tag:'
193
197
  const connectionUrl = redisUrl || getRedisUrlOrThrow('CACHE')
@@ -217,12 +221,12 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
217
221
  const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {
218
222
  const client = await getRedisClient()
219
223
  const cacheKey = getCacheKey(key)
220
- const data = await client.get(cacheKey)
224
+ const data = await client.getBuffer(cacheKey)
221
225
 
222
226
  if (!data) return null
223
227
 
224
228
  try {
225
- const entry: CacheEntry = JSON.parse(data)
229
+ const entry: CacheEntry = JSON.parse(decodeCacheText(data))
226
230
 
227
231
  if (isExpired(entry)) {
228
232
  if (options?.returnExpired) {
@@ -246,10 +250,10 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
246
250
  const cacheKey = getCacheKey(key)
247
251
 
248
252
  // Remove old entry from tag index if it exists
249
- const oldData = await client.get(cacheKey)
253
+ const oldData = await client.getBuffer(cacheKey)
250
254
  if (oldData) {
251
255
  try {
252
- const oldEntry: CacheEntry = JSON.parse(oldData)
256
+ const oldEntry: CacheEntry = JSON.parse(decodeCacheText(oldData))
253
257
  // Remove from old tags
254
258
  const pipeline = client.pipeline()
255
259
  for (const tag of oldEntry.tags) {
@@ -276,7 +280,7 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
276
280
  const pipeline = client.pipeline()
277
281
 
278
282
  // Store the entry
279
- const serialized = JSON.stringify(entry)
283
+ const serialized = encodeCacheText(JSON.stringify(entry), codec)
280
284
  if (ttl) {
281
285
  pipeline.setex(cacheKey, Math.ceil(ttl / 1000), serialized)
282
286
  } else {
@@ -299,11 +303,11 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
299
303
  if (!exists) return false
300
304
 
301
305
  // Check if expired
302
- const data = await client.get(cacheKey)
306
+ const data = await client.getBuffer(cacheKey)
303
307
  if (!data) return false
304
308
 
305
309
  try {
306
- const entry: CacheEntry = JSON.parse(data)
310
+ const entry: CacheEntry = JSON.parse(decodeCacheText(data))
307
311
  if (isExpired(entry)) {
308
312
  await deleteKey(key)
309
313
  return false
@@ -319,11 +323,11 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
319
323
  const cacheKey = getCacheKey(key)
320
324
 
321
325
  // Get entry to remove from tag index
322
- const data = await client.get(cacheKey)
326
+ const data = await client.getBuffer(cacheKey)
323
327
  if (!data) return false
324
328
 
325
329
  try {
326
- const entry: CacheEntry = JSON.parse(data)
330
+ const entry: CacheEntry = JSON.parse(decodeCacheText(data))
327
331
  const pipeline = client.pipeline()
328
332
 
329
333
  // Remove from tag index
@@ -447,10 +451,10 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
447
451
 
448
452
  let expired = 0
449
453
  for (const cacheKey of cacheKeys) {
450
- const data = await client.get(cacheKey)
454
+ const data = await client.getBuffer(cacheKey)
451
455
  if (data) {
452
456
  try {
453
- const entry: CacheEntry = JSON.parse(data)
457
+ const entry: CacheEntry = JSON.parse(decodeCacheText(data))
454
458
  if (isExpired(entry)) {
455
459
  expired++
456
460
  }
@@ -469,10 +473,10 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
469
473
 
470
474
  let removed = 0
471
475
  for (const cacheKey of cacheKeys) {
472
- const data = await client.get(cacheKey)
476
+ const data = await client.getBuffer(cacheKey)
473
477
  if (data) {
474
478
  try {
475
- const entry: CacheEntry = JSON.parse(data)
479
+ const entry: CacheEntry = JSON.parse(decodeCacheText(data))
476
480
  if (isExpired(entry)) {
477
481
  const key = cacheKey.substring(keyPrefix.length)
478
482
  await deleteKey(key)
@@ -5,6 +5,8 @@ import path from 'node:path'
5
5
  import { CacheDependencyUnavailableError } from '../errors'
6
6
  import { DEFAULT_SQLITE_CACHE_PATH } from '../defaults'
7
7
  import { matchCacheKeyPattern } from '../patterns'
8
+ import { decodeCacheText, encodeCacheText, resolveCacheCodecConfig } from '../codec'
9
+ import type { CacheCodecOptions } from '../codec'
8
10
 
9
11
  type SqliteStatement<TResult = unknown> = {
10
12
  get(...args: unknown[]): TResult | undefined
@@ -23,7 +25,7 @@ type SqliteDatabase = {
23
25
 
24
26
  type SqliteConstructor = new (file: string) => SqliteDatabase
25
27
  type SqliteModule = SqliteConstructor | { default: SqliteConstructor }
26
- type SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor }
28
+ type SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor } & CacheCodecOptions
27
29
 
28
30
  const sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))
29
31
 
@@ -38,6 +40,7 @@ const sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))
38
40
  export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOptions): CacheStrategy {
39
41
  let db: SqliteDatabase | null = null
40
42
  const defaultTtl = options?.defaultTtl
43
+ const codec = resolveCacheCodecConfig(options)
41
44
  const filePath = dbPath || process.env.CACHE_SQLITE_PATH || DEFAULT_SQLITE_CACHE_PATH
42
45
 
43
46
  async function getDb(): Promise<SqliteDatabase> {
@@ -93,7 +96,7 @@ export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOp
93
96
  return Date.now() > expiresAt
94
97
  }
95
98
 
96
- type EntryRow = { value: string; expires_at: number | null }
99
+ type EntryRow = { value: string | Buffer; expires_at: number | null }
97
100
  type ExpiresRow = { expires_at: number | null }
98
101
  type CountRow = { count: number }
99
102
  type KeyRow = { key: string }
@@ -107,7 +110,7 @@ export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOp
107
110
  if (!row) return null
108
111
 
109
112
  try {
110
- const value = JSON.parse(row.value) as CacheValue
113
+ const value = JSON.parse(decodeCacheText(row.value)) as CacheValue
111
114
  const expiresAt = row.expires_at
112
115
 
113
116
  if (isExpired(expiresAt)) {
@@ -134,7 +137,7 @@ export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOp
134
137
  const expiresAt = ttl ? Date.now() + ttl : null
135
138
  const createdAt = Date.now()
136
139
 
137
- const serialized = JSON.stringify(value)
140
+ const serialized = encodeCacheText(JSON.stringify(value), codec)
138
141
 
139
142
  database.transaction(() => {
140
143
  // Delete old tags
package/src/types.ts CHANGED
@@ -120,4 +120,15 @@ export type CacheServiceOptions = {
120
120
  * disables the cap.
121
121
  */
122
122
  maxEntries?: number
123
+ /**
124
+ * Value compression for the redis and sqlite strategies. Falls back to
125
+ * `CACHE_COMPRESSION`, then `off`. Reads detect the stored format, so
126
+ * entries written under any setting stay readable.
127
+ */
128
+ compression?: 'off' | 'zstd' | 'gzip'
129
+ /**
130
+ * Smallest serialized entry, in bytes, that gets compressed. Falls back to
131
+ * `CACHE_COMPRESSION_MIN_BYTES`, then 1024.
132
+ */
133
+ compressionMinBytes?: number
123
134
  }