@open-mercato/cache 0.9.1-develop.7573.1.d0ccc51d5b → 0.9.1-develop.7575.1.8480826a9b
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +0 -10
- package/ENV.md +0 -26
- package/dist/service.js +4 -9
- package/dist/service.js.map +2 -2
- package/dist/strategies/redis.js +13 -15
- package/dist/strategies/redis.js.map +2 -2
- package/dist/strategies/sqlite.js +2 -4
- package/dist/strategies/sqlite.js.map +2 -2
- package/package.json +1 -1
- package/src/__tests__/redis.strategy.test.ts +9 -109
- package/src/__tests__/service.test.ts +0 -54
- package/src/__tests__/sqlite.strategy.test.ts +2 -53
- package/src/service.ts +4 -13
- package/src/strategies/redis.ts +18 -22
- package/src/strategies/sqlite.ts +4 -7
- package/src/types.ts +0 -11
- package/dist/codec.js +0 -75
- package/dist/codec.js.map +0 -7
- package/src/__tests__/codec.test.ts +0 -162
- package/src/__tests__/redis.compression.live.test.ts +0 -41
- package/src/codec.ts +0 -94
|
@@ -1,162 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
})
|
package/src/codec.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
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
|
-
}
|