@open-mercato/cache 0.9.1-develop.7575.1.8480826a9b → 0.10.1-develop.7577.1.477f2486e5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/strategies/sqlite.ts"],
4
- "sourcesContent": ["import type { CacheStrategy, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport fs from 'node:fs'\nimport { createRequire } from 'node:module'\nimport path from 'node:path'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { DEFAULT_SQLITE_CACHE_PATH } from '../defaults'\nimport { matchCacheKeyPattern } from '../patterns'\n\ntype SqliteStatement<TResult = unknown> = {\n get(...args: unknown[]): TResult | undefined\n all(...args: unknown[]): TResult[]\n run(...args: unknown[]): { changes: number }\n}\n\ntype SqliteTransaction<TResult = unknown> = () => TResult\n\ntype SqliteDatabase = {\n prepare<TResult = unknown>(sql: string): SqliteStatement<TResult>\n exec(sql: string): unknown\n transaction<TResult>(fn: () => TResult): SqliteTransaction<TResult>\n close(): void\n}\n\ntype SqliteConstructor = new (file: string) => SqliteDatabase\ntype SqliteModule = SqliteConstructor | { default: SqliteConstructor }\ntype SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor }\n\nconst sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))\n\n/**\n * SQLite cache strategy with tag support\n * Persistent across process restarts, stored in a SQLite database file\n * \n * Uses two tables:\n * - cache_entries: stores cache data\n * - cache_tags: stores tag associations (many-to-many)\n */\nexport function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOptions): CacheStrategy {\n let db: SqliteDatabase | null = null\n const defaultTtl = options?.defaultTtl\n const filePath = dbPath || process.env.CACHE_SQLITE_PATH || DEFAULT_SQLITE_CACHE_PATH\n\n async function getDb(): Promise<SqliteDatabase> {\n if (db) return db\n\n try {\n const imported = (options?.databaseConstructor ?? sqliteRequire('better-sqlite3')) as SqliteModule\n const Database = typeof imported === 'function' ? imported : imported.default\n \n // Ensure directory exists\n const dir = path.dirname(filePath)\n if (!fs.existsSync(dir)) {\n fs.mkdirSync(dir, { recursive: true })\n }\n\n db = new Database(filePath)\n\n db.exec(`\n PRAGMA journal_mode = WAL;\n PRAGMA synchronous = NORMAL;\n PRAGMA busy_timeout = 5000;\n PRAGMA foreign_keys = ON;\n `)\n\n // Create tables\n db.exec(`\n CREATE TABLE IF NOT EXISTS cache_entries (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n expires_at INTEGER,\n created_at INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS cache_tags (\n key TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (key, tag),\n FOREIGN KEY (key) REFERENCES cache_entries(key) ON DELETE CASCADE\n );\n\n CREATE INDEX IF NOT EXISTS idx_cache_tags_tag ON cache_tags(tag);\n CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at ON cache_entries(expires_at);\n `)\n\n return db\n } catch (error) {\n throw new CacheDependencyUnavailableError('sqlite', 'better-sqlite3', error)\n }\n }\n\n function isExpired(expiresAt: number | null): boolean {\n if (expiresAt === null) return false\n return Date.now() > expiresAt\n }\n\n type EntryRow = { value: string; expires_at: number | null }\n type ExpiresRow = { expires_at: number | null }\n type CountRow = { count: number }\n type KeyRow = { key: string }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT value, expires_at FROM cache_entries WHERE key = ?')\n const row = stmt.get(key) as EntryRow | undefined\n\n if (!row) return null\n\n try {\n const value = JSON.parse(row.value) as CacheValue\n const expiresAt = row.expires_at\n\n if (isExpired(expiresAt)) {\n if (options?.returnExpired) {\n return value\n }\n // Clean up expired entry\n await deleteKey(key)\n return null\n }\n\n return value\n } catch {\n // Invalid JSON, remove it\n await deleteKey(key)\n return null\n }\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n const database = await getDb()\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n const createdAt = Date.now()\n\n const serialized = JSON.stringify(value)\n\n database.transaction(() => {\n // Delete old tags\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)\n\n // Insert or replace cache entry\n database.prepare(`\n INSERT OR REPLACE INTO cache_entries (key, value, expires_at, created_at)\n VALUES (?, ?, ?, ?)\n `).run(key, serialized, expiresAt, createdAt)\n\n // Insert new tags\n if (tags.length > 0) {\n const insertTag = database.prepare('INSERT INTO cache_tags (key, tag) VALUES (?, ?)')\n for (const tag of tags) {\n insertTag.run(key, tag)\n }\n }\n })()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT expires_at FROM cache_entries WHERE key = ?')\n const row = stmt.get(key) as ExpiresRow | undefined\n\n if (!row) return false\n\n if (isExpired(row.expires_at)) {\n await deleteKey(key)\n return false\n }\n\n return true\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const database = await getDb()\n \n const result = database.transaction(() => {\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)\n const info = database.prepare('DELETE FROM cache_entries WHERE key = ?').run(key)\n return info.changes > 0\n })()\n\n return result\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const database = await getDb()\n \n // Get all unique keys that have any of the specified tags\n const placeholders = tags.map(() => '?').join(',')\n const stmt = database.prepare(`\n SELECT DISTINCT key FROM cache_tags WHERE tag IN (${placeholders})\n `)\n const rows = stmt.all(...tags) as KeyRow[]\n\n let deleted = 0\n for (const row of rows) {\n const success = await deleteKey(row.key)\n if (success) deleted++\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const database = await getDb()\n \n const result = database.transaction(() => {\n const countStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')\n const count = (countStmt.get() as CountRow).count\n\n database.prepare('DELETE FROM cache_tags').run()\n database.prepare('DELETE FROM cache_entries').run()\n\n return count\n })()\n\n return result\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT key FROM cache_entries')\n const rows = stmt.all() as KeyRow[]\n \n const allKeys = rows.map((row) => row.key)\n \n if (!pattern) return allKeys\n \n return allKeys.filter((key: string) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n const database = await getDb()\n \n const sizeStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')\n const size = (sizeStmt.get() as CountRow).count\n\n const now = Date.now()\n const expiredStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')\n const expired = (expiredStmt.get(now) as CountRow).count\n\n return { size, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n const database = await getDb()\n const now = Date.now()\n \n const result = database.transaction(() => {\n // Get keys to delete\n const stmt = database.prepare('SELECT key FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')\n const rows = stmt.all(now) as KeyRow[]\n\n // Delete tags for expired keys\n for (const row of rows) {\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(row.key)\n }\n\n // Delete expired entries\n const info = database.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now)\n return info.changes\n })()\n\n return result\n }\n\n const close = async (): Promise<void> => {\n if (db) {\n db.close()\n db = null\n }\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n cleanup,\n close,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { CacheStrategy, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport fs from 'node:fs'\nimport { createRequire } from 'node:module'\nimport path from 'node:path'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { DEFAULT_SQLITE_CACHE_PATH } from '../defaults'\nimport { matchCacheKeyPattern } from '../patterns'\nimport { decodeCacheText, encodeCacheText, resolveCacheCodecConfig } from '../codec'\nimport type { CacheCodecOptions } from '../codec'\n\ntype SqliteStatement<TResult = unknown> = {\n get(...args: unknown[]): TResult | undefined\n all(...args: unknown[]): TResult[]\n run(...args: unknown[]): { changes: number }\n}\n\ntype SqliteTransaction<TResult = unknown> = () => TResult\n\ntype SqliteDatabase = {\n prepare<TResult = unknown>(sql: string): SqliteStatement<TResult>\n exec(sql: string): unknown\n transaction<TResult>(fn: () => TResult): SqliteTransaction<TResult>\n close(): void\n}\n\ntype SqliteConstructor = new (file: string) => SqliteDatabase\ntype SqliteModule = SqliteConstructor | { default: SqliteConstructor }\ntype SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor } & CacheCodecOptions\n\nconst sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))\n\n/**\n * SQLite cache strategy with tag support\n * Persistent across process restarts, stored in a SQLite database file\n * \n * Uses two tables:\n * - cache_entries: stores cache data\n * - cache_tags: stores tag associations (many-to-many)\n */\nexport function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOptions): CacheStrategy {\n let db: SqliteDatabase | null = null\n const defaultTtl = options?.defaultTtl\n const codec = resolveCacheCodecConfig(options)\n const filePath = dbPath || process.env.CACHE_SQLITE_PATH || DEFAULT_SQLITE_CACHE_PATH\n\n async function getDb(): Promise<SqliteDatabase> {\n if (db) return db\n\n try {\n const imported = (options?.databaseConstructor ?? sqliteRequire('better-sqlite3')) as SqliteModule\n const Database = typeof imported === 'function' ? imported : imported.default\n \n // Ensure directory exists\n const dir = path.dirname(filePath)\n if (!fs.existsSync(dir)) {\n fs.mkdirSync(dir, { recursive: true })\n }\n\n db = new Database(filePath)\n\n db.exec(`\n PRAGMA journal_mode = WAL;\n PRAGMA synchronous = NORMAL;\n PRAGMA busy_timeout = 5000;\n PRAGMA foreign_keys = ON;\n `)\n\n // Create tables\n db.exec(`\n CREATE TABLE IF NOT EXISTS cache_entries (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n expires_at INTEGER,\n created_at INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS cache_tags (\n key TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (key, tag),\n FOREIGN KEY (key) REFERENCES cache_entries(key) ON DELETE CASCADE\n );\n\n CREATE INDEX IF NOT EXISTS idx_cache_tags_tag ON cache_tags(tag);\n CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at ON cache_entries(expires_at);\n `)\n\n return db\n } catch (error) {\n throw new CacheDependencyUnavailableError('sqlite', 'better-sqlite3', error)\n }\n }\n\n function isExpired(expiresAt: number | null): boolean {\n if (expiresAt === null) return false\n return Date.now() > expiresAt\n }\n\n type EntryRow = { value: string | Buffer; expires_at: number | null }\n type ExpiresRow = { expires_at: number | null }\n type CountRow = { count: number }\n type KeyRow = { key: string }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT value, expires_at FROM cache_entries WHERE key = ?')\n const row = stmt.get(key) as EntryRow | undefined\n\n if (!row) return null\n\n try {\n const value = JSON.parse(decodeCacheText(row.value)) as CacheValue\n const expiresAt = row.expires_at\n\n if (isExpired(expiresAt)) {\n if (options?.returnExpired) {\n return value\n }\n // Clean up expired entry\n await deleteKey(key)\n return null\n }\n\n return value\n } catch {\n // Invalid JSON, remove it\n await deleteKey(key)\n return null\n }\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n const database = await getDb()\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n const createdAt = Date.now()\n\n const serialized = encodeCacheText(JSON.stringify(value), codec)\n\n database.transaction(() => {\n // Delete old tags\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)\n\n // Insert or replace cache entry\n database.prepare(`\n INSERT OR REPLACE INTO cache_entries (key, value, expires_at, created_at)\n VALUES (?, ?, ?, ?)\n `).run(key, serialized, expiresAt, createdAt)\n\n // Insert new tags\n if (tags.length > 0) {\n const insertTag = database.prepare('INSERT INTO cache_tags (key, tag) VALUES (?, ?)')\n for (const tag of tags) {\n insertTag.run(key, tag)\n }\n }\n })()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT expires_at FROM cache_entries WHERE key = ?')\n const row = stmt.get(key) as ExpiresRow | undefined\n\n if (!row) return false\n\n if (isExpired(row.expires_at)) {\n await deleteKey(key)\n return false\n }\n\n return true\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const database = await getDb()\n \n const result = database.transaction(() => {\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)\n const info = database.prepare('DELETE FROM cache_entries WHERE key = ?').run(key)\n return info.changes > 0\n })()\n\n return result\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const database = await getDb()\n \n // Get all unique keys that have any of the specified tags\n const placeholders = tags.map(() => '?').join(',')\n const stmt = database.prepare(`\n SELECT DISTINCT key FROM cache_tags WHERE tag IN (${placeholders})\n `)\n const rows = stmt.all(...tags) as KeyRow[]\n\n let deleted = 0\n for (const row of rows) {\n const success = await deleteKey(row.key)\n if (success) deleted++\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const database = await getDb()\n \n const result = database.transaction(() => {\n const countStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')\n const count = (countStmt.get() as CountRow).count\n\n database.prepare('DELETE FROM cache_tags').run()\n database.prepare('DELETE FROM cache_entries').run()\n\n return count\n })()\n\n return result\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT key FROM cache_entries')\n const rows = stmt.all() as KeyRow[]\n \n const allKeys = rows.map((row) => row.key)\n \n if (!pattern) return allKeys\n \n return allKeys.filter((key: string) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n const database = await getDb()\n \n const sizeStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')\n const size = (sizeStmt.get() as CountRow).count\n\n const now = Date.now()\n const expiredStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')\n const expired = (expiredStmt.get(now) as CountRow).count\n\n return { size, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n const database = await getDb()\n const now = Date.now()\n \n const result = database.transaction(() => {\n // Get keys to delete\n const stmt = database.prepare('SELECT key FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')\n const rows = stmt.all(now) as KeyRow[]\n\n // Delete tags for expired keys\n for (const row of rows) {\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(row.key)\n }\n\n // Delete expired entries\n const info = database.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now)\n return info.changes\n })()\n\n return result\n }\n\n const close = async (): Promise<void> => {\n if (db) {\n db.close()\n db = null\n }\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n cleanup,\n close,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["options"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cache",
3
- "version": "0.9.1-develop.7575.1.8480826a9b",
3
+ "version": "0.10.1-develop.7577.1.477f2486e5",
4
4
  "license": "MIT",
5
5
  "description": "Multi-strategy cache service with tag-based invalidation support",
6
6
  "type": "module",
@@ -72,5 +72,5 @@
72
72
  "url": "https://github.com/open-mercato/open-mercato",
73
73
  "directory": "packages/cache"
74
74
  },
75
- "stableVersion": "0.9.0"
75
+ "stableVersion": "0.10.0"
76
76
  }
@@ -0,0 +1,162 @@
1
+ /** @jest-environment node */
2
+ import zlib from 'node:zlib'
3
+ import {
4
+ DEFAULT_COMPRESSION_MIN_BYTES,
5
+ MAX_DECOMPRESSED_BYTES,
6
+ decodeCacheText,
7
+ encodeCacheText,
8
+ parseCacheCompression,
9
+ parseCacheCompressionMinBytes,
10
+ resolveCacheCodecConfig,
11
+ } from '../codec'
12
+ import type { CacheCodecConfig } from '../codec'
13
+
14
+ const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
15
+ const GZIP_MAGIC = [0x1f, 0x8b]
16
+
17
+ function listPayload(rows: number): string {
18
+ return JSON.stringify({
19
+ items: Array.from({ length: rows }, (_, index) => ({
20
+ id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
21
+ displayName: `Contact ${index}`,
22
+ primaryEmail: `contact.${index}@example.com`,
23
+ status: index % 2 ? 'active' : 'lead',
24
+ })),
25
+ total: rows,
26
+ })
27
+ }
28
+
29
+ function startsWith(raw: string | Buffer, magic: number[]): boolean {
30
+ return Buffer.isBuffer(raw) && magic.every((byte, index) => raw[index] === byte)
31
+ }
32
+
33
+ describe.each([
34
+ ['zstd', ZSTD_MAGIC],
35
+ ['gzip', GZIP_MAGIC],
36
+ ] as const)('cache codec (%s)', (compression, magic) => {
37
+ const config: CacheCodecConfig = { compression, minBytes: DEFAULT_COMPRESSION_MIN_BYTES }
38
+
39
+ it('round-trips a large entry through a compressed frame', () => {
40
+ const json = listPayload(100)
41
+ const encoded = encodeCacheText(json, config)
42
+
43
+ expect(startsWith(encoded, magic)).toBe(true)
44
+ expect((encoded as Buffer).length).toBeLessThan(Buffer.byteLength(json) / 4)
45
+ expect(decodeCacheText(encoded)).toBe(json)
46
+ })
47
+
48
+ it('keeps entries below the threshold as plain JSON', () => {
49
+ const json = listPayload(2)
50
+ expect(Buffer.byteLength(json)).toBeLessThan(DEFAULT_COMPRESSION_MIN_BYTES)
51
+
52
+ expect(encodeCacheText(json, config)).toBe(json)
53
+ })
54
+
55
+ it('stores the JSON when compression would not shrink it', () => {
56
+ const json = JSON.stringify({ id: 'a1' })
57
+
58
+ expect(encodeCacheText(json, { compression, minBytes: 1 })).toBe(json)
59
+ })
60
+
61
+ it('reads entries written by the other codec and by the off setting', () => {
62
+ const json = listPayload(100)
63
+ const other: CacheCodecConfig = { compression: compression === 'zstd' ? 'gzip' : 'zstd', minBytes: 1024 }
64
+
65
+ expect(decodeCacheText(encodeCacheText(json, other))).toBe(json)
66
+ expect(decodeCacheText(encodeCacheText(json, { compression: 'off', minBytes: 1024 }))).toBe(json)
67
+ })
68
+
69
+ it('rejects a corrupt frame', () => {
70
+ const encoded = encodeCacheText(listPayload(100), config) as Buffer
71
+ const corrupt = Buffer.concat([encoded.subarray(0, magic.length), Buffer.alloc(32, 0xff)])
72
+
73
+ expect(() => decodeCacheText(corrupt)).toThrow()
74
+ })
75
+ })
76
+
77
+ describe('cache codec format detection', () => {
78
+ it('reads legacy JSON stored as a string or as UTF-8 bytes', () => {
79
+ const json = listPayload(100)
80
+
81
+ expect(decodeCacheText(json)).toBe(json)
82
+ expect(decodeCacheText(Buffer.from(json, 'utf8'))).toBe(json)
83
+ })
84
+
85
+ it('never compresses with compression off', () => {
86
+ const json = listPayload(100)
87
+ expect(encodeCacheText(json, { compression: 'off', minBytes: 1 })).toBe(json)
88
+ })
89
+
90
+ it('skips compression above the decode cap so a legitimate entry can always be read back', () => {
91
+ const encodeSpy = jest.spyOn(zlib, 'gzipSync')
92
+ const json = 'x'.repeat(MAX_DECOMPRESSED_BYTES + 1)
93
+
94
+ expect(encodeCacheText(json, { compression: 'gzip', minBytes: 1 })).toBe(json)
95
+ expect(encodeSpy).not.toHaveBeenCalled()
96
+ encodeSpy.mockRestore()
97
+ })
98
+
99
+ it('caps the decompressed size of a hostile frame', () => {
100
+ const bomb = zlib.zstdCompressSync(Buffer.alloc(MAX_DECOMPRESSED_BYTES + 1024, 0x20))
101
+
102
+ expect(() => decodeCacheText(bomb)).toThrow()
103
+ })
104
+ })
105
+
106
+ describe('cache codec configuration', () => {
107
+ afterEach(() => {
108
+ jest.restoreAllMocks()
109
+ })
110
+
111
+ it('parses known compression values case-insensitively', () => {
112
+ expect(parseCacheCompression('zstd')).toBe('zstd')
113
+ expect(parseCacheCompression(' GZIP ')).toBe('gzip')
114
+ expect(parseCacheCompression('off')).toBe('off')
115
+ expect(parseCacheCompression(undefined)).toBe('off')
116
+ expect(parseCacheCompression('')).toBe('off')
117
+ })
118
+
119
+ it('treats an unknown value as off and warns once', () => {
120
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
121
+
122
+ expect(parseCacheCompression('brotli-codec-test')).toBe('off')
123
+ expect(parseCacheCompression('brotli-codec-test')).toBe('off')
124
+
125
+ expect(warn).toHaveBeenCalledTimes(1)
126
+ })
127
+
128
+ it('accepts only a positive min-bytes value', () => {
129
+ expect(parseCacheCompressionMinBytes('2048')).toBe(2048)
130
+ expect(parseCacheCompressionMinBytes('0')).toBeUndefined()
131
+ expect(parseCacheCompressionMinBytes('-5')).toBeUndefined()
132
+ expect(parseCacheCompressionMinBytes('abc')).toBeUndefined()
133
+ expect(parseCacheCompressionMinBytes(undefined)).toBeUndefined()
134
+ })
135
+
136
+ it('defaults to off with a 1 KB threshold', () => {
137
+ expect(resolveCacheCodecConfig()).toEqual({ compression: 'off', minBytes: DEFAULT_COMPRESSION_MIN_BYTES })
138
+ expect(resolveCacheCodecConfig({ compression: 'zstd', compressionMinBytes: 0 })).toEqual({
139
+ compression: 'zstd',
140
+ minBytes: DEFAULT_COMPRESSION_MIN_BYTES,
141
+ })
142
+ })
143
+
144
+ it('falls back to gzip when the runtime has no zstd', () => {
145
+ jest.isolateModules(() => {
146
+ jest.doMock('node:zlib', () => {
147
+ const actual = jest.requireActual<typeof import('node:zlib')>('node:zlib')
148
+ return { ...actual, zstdCompressSync: undefined, zstdDecompressSync: undefined }
149
+ })
150
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
151
+ const isolated = require('../codec') as typeof import('../codec')
152
+
153
+ const config = isolated.resolveCacheCodecConfig({ compression: 'zstd' })
154
+ const encoded = isolated.encodeCacheText(listPayload(100), config)
155
+
156
+ expect(config.compression).toBe('gzip')
157
+ expect(startsWith(encoded, GZIP_MAGIC)).toBe(true)
158
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('zstd is not available'))
159
+ })
160
+ jest.dontMock('node:zlib')
161
+ })
162
+ })
@@ -0,0 +1,41 @@
1
+ /** @jest-environment node */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { createRedisStrategy } from '../strategies/redis'
4
+
5
+ const redisUrl = process.env.CACHE_REDIS_URL
6
+ const describeWithRedis = redisUrl ? describe : describe.skip
7
+
8
+ describeWithRedis('redis strategy value compression against a live Redis', () => {
9
+ const largeValue = {
10
+ items: Array.from({ length: 100 }, (_, index) => ({ id: `row-${index}`, name: `Contact ${index}`, status: 'active' })),
11
+ }
12
+ const runId = randomUUID()
13
+
14
+ it.each(['zstd', 'gzip'] as const)('round-trips %s frames and legacy JSON over the binary-safe path', async (compression) => {
15
+ const legacy = createRedisStrategy(redisUrl, { compression: 'off' })
16
+ const compressed = createRedisStrategy(redisUrl, { compression })
17
+ const legacyKey = `compression-live:${runId}:${compression}:legacy`
18
+ const compressedKey = `compression-live:${runId}:${compression}:compressed`
19
+ const tag = `compression-live:${runId}:${compression}`
20
+
21
+ try {
22
+ await legacy.set(legacyKey, largeValue, { ttl: 60_000, tags: [tag] })
23
+ await compressed.set(compressedKey, largeValue, { ttl: 60_000, tags: [tag] })
24
+
25
+ expect(await compressed.get(legacyKey)).toEqual(largeValue)
26
+ expect(await compressed.get(compressedKey)).toEqual(largeValue)
27
+ expect(await legacy.get(compressedKey)).toEqual(largeValue)
28
+ expect(await compressed.has(compressedKey)).toBe(true)
29
+
30
+ await compressed.set(compressedKey, largeValue, { ttl: 60_000, tags: [`${tag}:next`] })
31
+ expect(await compressed.deleteByTags([tag])).toBe(1)
32
+ expect(await compressed.deleteByTags([`${tag}:next`])).toBe(1)
33
+ expect(await compressed.get(compressedKey)).toBeNull()
34
+ } finally {
35
+ await legacy.delete(legacyKey)
36
+ await compressed.delete(compressedKey)
37
+ await legacy.close?.()
38
+ await compressed.close?.()
39
+ }
40
+ })
41
+ })
@@ -5,7 +5,7 @@ import { createRedisStrategy } from '../strategies/redis'
5
5
  type PipelineOp = () => void
6
6
 
7
7
  class FakeRedis {
8
- values = new Map<string, string>()
8
+ values = new Map<string, string | Buffer>()
9
9
  sets = new Map<string, Set<string>>()
10
10
  /** Records the shape of each reap script call so tests can assert its cost. */
11
11
  evalCalls: { numKeys: number; memberCount: number }[] = []
@@ -15,15 +15,23 @@ class FakeRedis {
15
15
  }
16
16
 
17
17
  async get(key: string): Promise<string | null> {
18
- return this.values.get(key) ?? null
18
+ const value = this.values.get(key)
19
+ if (value === undefined) return null
20
+ return Buffer.isBuffer(value) ? value.toString('utf8') : value
19
21
  }
20
22
 
21
- async set(key: string, value: string): Promise<unknown> {
23
+ async getBuffer(key: string): Promise<Buffer | null> {
24
+ const value = this.values.get(key)
25
+ if (value === undefined) return null
26
+ return Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')
27
+ }
28
+
29
+ async set(key: string, value: string | Buffer): Promise<unknown> {
22
30
  this.values.set(key, value)
23
31
  return 'OK'
24
32
  }
25
33
 
26
- async setex(key: string, _ttlSeconds: number, value: string): Promise<unknown> {
34
+ async setex(key: string, _ttlSeconds: number, value: string | Buffer): Promise<unknown> {
27
35
  this.values.set(key, value)
28
36
  return 'OK'
29
37
  }
@@ -75,11 +83,11 @@ class FakeRedis {
75
83
  pipeline() {
76
84
  const ops: PipelineOp[] = []
77
85
  const chain = {
78
- set: (key: string, value: string) => {
86
+ set: (key: string, value: string | Buffer) => {
79
87
  ops.push(() => void this.values.set(key, value))
80
88
  return chain
81
89
  },
82
- setex: (key: string, _ttlSeconds: number, value: string) => {
90
+ setex: (key: string, _ttlSeconds: number, value: string | Buffer) => {
83
91
  ops.push(() => void this.values.set(key, value))
84
92
  return chain
85
93
  },
@@ -202,9 +210,9 @@ describe('redis strategy tag index', () => {
202
210
  // deleteByTags walks members one round trip at a time, so an entry it read
203
211
  // as missing can be rebuilt before the reap runs. Land that write in the
204
212
  // window by hooking the GET that reports the key absent.
205
- const readValue = currentRedis.get.bind(currentRedis)
213
+ const readValue = currentRedis.getBuffer.bind(currentRedis)
206
214
  let rebuilt = false
207
- currentRedis.get = async (key: string) => {
215
+ currentRedis.getBuffer = async (key: string) => {
208
216
  const result = await readValue(key)
209
217
  if (!rebuilt && key === 'cache:nav:user-1' && result === null) {
210
218
  rebuilt = true
@@ -214,7 +222,7 @@ describe('redis strategy tag index', () => {
214
222
  }
215
223
 
216
224
  await strategy.deleteByTags(['rbac:tenant:t1'])
217
- currentRedis.get = readValue
225
+ currentRedis.getBuffer = readValue
218
226
 
219
227
  // The rebuilt entry must stay in the index, or it silently outlives every
220
228
  // future invalidation of this tag and serves stale data until its TTL.
@@ -276,3 +284,95 @@ describe('redis strategy tag index', () => {
276
284
  expect(await currentRedis.smembers('tag:tag-b')).toEqual(['key-1'])
277
285
  })
278
286
  })
287
+
288
+ describe('redis strategy value compression', () => {
289
+ let urlCounter = 0
290
+ const largeValue = {
291
+ items: Array.from({ length: 100 }, (_, index) => ({ id: `row-${index}`, name: `Contact ${index}`, status: 'active' })),
292
+ }
293
+
294
+ function createStrategy(compression: 'off' | 'zstd' | 'gzip'): CacheStrategy {
295
+ urlCounter += 1
296
+ return createRedisStrategy(`redis://cache-compression-test-${urlCounter}`, { compression })
297
+ }
298
+
299
+ beforeEach(() => {
300
+ currentRedis = new FakeRedis()
301
+ })
302
+
303
+ it.each([
304
+ ['zstd', [0x28, 0xb5, 0x2f, 0xfd]],
305
+ ['gzip', [0x1f, 0x8b]],
306
+ ] as const)('stores large entries as %s frames and reads them back', async (compression, magic) => {
307
+ const strategy = createStrategy(compression)
308
+
309
+ await strategy.set('list:page:1', largeValue, { ttl: 60_000, tags: ['customers'] })
310
+
311
+ const stored = currentRedis.values.get('cache:list:page:1')
312
+ expect(Buffer.isBuffer(stored)).toBe(true)
313
+ expect(magic.every((byte, index) => (stored as Buffer)[index] === byte)).toBe(true)
314
+ expect(await strategy.get('list:page:1')).toEqual(largeValue)
315
+ expect(await strategy.has('list:page:1')).toBe(true)
316
+ await strategy.close?.()
317
+ })
318
+
319
+ it('keeps small entries as plain JSON strings', async () => {
320
+ const strategy = createStrategy('zstd')
321
+
322
+ await strategy.set('small', { id: 1 })
323
+
324
+ expect(typeof currentRedis.values.get('cache:small')).toBe('string')
325
+ expect(await strategy.get('small')).toEqual({ id: 1 })
326
+ await strategy.close?.()
327
+ })
328
+
329
+ it('reads legacy and compressed entries side by side in either direction', async () => {
330
+ const legacy = createStrategy('off')
331
+ await legacy.set('legacy', largeValue)
332
+ const compressed = createStrategy('zstd')
333
+ await compressed.set('compressed', largeValue)
334
+
335
+ expect(typeof currentRedis.values.get('cache:legacy')).toBe('string')
336
+ expect(await compressed.get('legacy')).toEqual(largeValue)
337
+ expect(await legacy.get('compressed')).toEqual(largeValue)
338
+ await legacy.close?.()
339
+ await compressed.close?.()
340
+ })
341
+
342
+ it('moves tag memberships when a compressed entry is overwritten', async () => {
343
+ const strategy = createStrategy('zstd')
344
+
345
+ await strategy.set('list', largeValue, { tags: ['old-tag'] })
346
+ await strategy.set('list', largeValue, { tags: ['new-tag'] })
347
+
348
+ expect(await currentRedis.smembers('tag:old-tag')).toEqual([])
349
+ expect(await currentRedis.smembers('tag:new-tag')).toEqual(['list'])
350
+ expect(await strategy.deleteByTags(['new-tag'])).toBe(1)
351
+ expect(currentRedis.values.has('cache:list')).toBe(false)
352
+ expect(await currentRedis.smembers('tag:new-tag')).toEqual([])
353
+ await strategy.close?.()
354
+ })
355
+
356
+ it('counts and cleans up expired compressed entries', async () => {
357
+ const strategy = createStrategy('gzip')
358
+ await strategy.set('fresh', largeValue, { ttl: 60_000, tags: ['t'] })
359
+ await strategy.set('stale', largeValue, { ttl: 1, tags: ['t'] })
360
+ await new Promise((resolve) => setTimeout(resolve, 5))
361
+
362
+ expect(await strategy.stats()).toEqual({ size: 2, expired: 1 })
363
+ expect(await strategy.cleanup?.()).toBe(1)
364
+ expect(currentRedis.values.has('cache:fresh')).toBe(true)
365
+ expect(currentRedis.values.has('cache:stale')).toBe(false)
366
+ expect(await currentRedis.smembers('tag:t')).toEqual(['fresh'])
367
+ await strategy.close?.()
368
+ })
369
+
370
+ it('drops a corrupt frame as a miss', async () => {
371
+ const strategy = createStrategy('zstd')
372
+ currentRedis.values.set('cache:broken', Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0xff, 0xff, 0xff]))
373
+
374
+ expect(await strategy.get('broken')).toBeNull()
375
+ expect(currentRedis.values.has('cache:broken')).toBe(false)
376
+ await strategy.close?.()
377
+ })
378
+ })
@@ -392,3 +392,57 @@ describe('Cache Service stats() counter gating', () => {
392
392
  expect(stats.lastSweepReclaimed).toBe(3)
393
393
  })
394
394
  })
395
+
396
+ describe('Cache Service compression resolution (CACHE_COMPRESSION)', () => {
397
+ afterEach(() => {
398
+ delete process.env.CACHE_COMPRESSION
399
+ delete process.env.CACHE_COMPRESSION_MIN_BYTES
400
+ jest.restoreAllMocks()
401
+ })
402
+
403
+ const captureSqliteOptions = () => {
404
+ const spy = jest.spyOn(sqliteStrategyModule, 'createSqliteStrategy')
405
+ spy.mockImplementation((_path, options) => {
406
+ return memoryStrategyModule.createMemoryStrategy({ defaultTtl: options?.defaultTtl })
407
+ })
408
+ return spy
409
+ }
410
+
411
+ it('passes the env compression settings to persistent strategies', () => {
412
+ process.env.CACHE_COMPRESSION = 'zstd'
413
+ process.env.CACHE_COMPRESSION_MIN_BYTES = '2048'
414
+ const spy = captureSqliteOptions()
415
+
416
+ createCacheService({ strategy: 'sqlite' })
417
+
418
+ expect(spy.mock.calls[0][1]).toMatchObject({ compression: 'zstd', compressionMinBytes: 2048 })
419
+ })
420
+
421
+ it('lets explicit options override the env', () => {
422
+ process.env.CACHE_COMPRESSION = 'zstd'
423
+ const spy = captureSqliteOptions()
424
+
425
+ createCacheService({ strategy: 'sqlite', compression: 'gzip', compressionMinBytes: 512 })
426
+
427
+ expect(spy.mock.calls[0][1]).toMatchObject({ compression: 'gzip', compressionMinBytes: 512 })
428
+ })
429
+
430
+ it('keeps compression off for an unknown env value and warns', () => {
431
+ process.env.CACHE_COMPRESSION = 'lz4-service-test'
432
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
433
+ const spy = captureSqliteOptions()
434
+
435
+ createCacheService({ strategy: 'sqlite' })
436
+
437
+ expect(spy.mock.calls[0][1]).toMatchObject({ compression: 'off' })
438
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('lz4-service-test'))
439
+ })
440
+
441
+ it('defaults to off with no env and no options', () => {
442
+ const spy = captureSqliteOptions()
443
+
444
+ createCacheService({ strategy: 'sqlite' })
445
+
446
+ expect(spy.mock.calls[0][1]).toMatchObject({ compression: 'off', compressionMinBytes: undefined })
447
+ })
448
+ })
@@ -3,7 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { createSqliteStrategy } from '../strategies/sqlite'
5
5
 
6
- type CacheEntry = { value: string; expires_at: number | null; created_at: number }
6
+ type CacheEntry = { value: string | Buffer; expires_at: number | null; created_at: number }
7
7
 
8
8
  class MockSqliteDatabase {
9
9
  static instances: MockSqliteDatabase[] = []
@@ -90,7 +90,7 @@ class MockSqliteDatabase {
90
90
  if (sql.startsWith('INSERT OR REPLACE INTO cache_entries')) {
91
91
  const [key, value, expiresAt, createdAt] = args
92
92
  this.entries.set(String(key), {
93
- value: String(value),
93
+ value: Buffer.isBuffer(value) ? value : String(value),
94
94
  expires_at: expiresAt === null ? null : Number(expiresAt),
95
95
  created_at: Number(createdAt),
96
96
  })
@@ -161,3 +161,54 @@ describe('SQLite Cache Strategy', () => {
161
161
  await cache.close?.()
162
162
  })
163
163
  })
164
+
165
+ describe('SQLite Cache Strategy value compression (better-sqlite3)', () => {
166
+ const largeValue = {
167
+ items: Array.from({ length: 100 }, (_, index) => ({ id: `row-${index}`, name: `Contact ${index}`, status: 'active' })),
168
+ }
169
+ let tempDir: string
170
+ let dbPath: string
171
+
172
+ beforeEach(() => {
173
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-mercato-cache-compression-'))
174
+ dbPath = path.join(tempDir, 'cache.db')
175
+ })
176
+
177
+ afterEach(() => {
178
+ fs.rmSync(tempDir, { recursive: true, force: true })
179
+ })
180
+
181
+ function storedTypes(): Record<string, string> {
182
+ const Database = require('better-sqlite3') as typeof import('better-sqlite3')
183
+ const db = new Database(dbPath, { readonly: true })
184
+ try {
185
+ const rows = db.prepare('SELECT key, typeof(value) AS type FROM cache_entries').all() as { key: string; type: string }[]
186
+ return Object.fromEntries(rows.map((row) => [row.key, row.type]))
187
+ } finally {
188
+ db.close()
189
+ }
190
+ }
191
+
192
+ it.each(['zstd', 'gzip'] as const)('stores large %s entries as BLOBs next to legacy TEXT rows', async (compression) => {
193
+ const legacy = createSqliteStrategy(dbPath, { compression: 'off' })
194
+ await legacy.set('legacy', largeValue, { tags: ['customers'] })
195
+ await legacy.close?.()
196
+
197
+ const cache = createSqliteStrategy(dbPath, { compression })
198
+ await cache.set('compressed', largeValue, { tags: ['customers'] })
199
+ await cache.set('small', { id: 1 }, { tags: ['customers'] })
200
+
201
+ expect(await cache.get('legacy')).toEqual(largeValue)
202
+ expect(await cache.get('compressed')).toEqual(largeValue)
203
+ expect(await cache.get('small')).toEqual({ id: 1 })
204
+ await cache.close?.()
205
+
206
+ expect(storedTypes()).toEqual({ legacy: 'text', compressed: 'blob', small: 'text' })
207
+
208
+ const off = createSqliteStrategy(dbPath, { compression: 'off' })
209
+ expect(await off.get('compressed')).toEqual(largeValue)
210
+ expect(await off.deleteByTags(['customers'])).toBe(3)
211
+ expect(await off.get('compressed')).toBeNull()
212
+ await off.close?.()
213
+ })
214
+ })