@open-mercato/cache 0.9.1-develop.7574.1.837288eec1 → 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
|
@@ -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>()
|
|
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,23 +15,15 @@ class FakeRedis {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
async get(key: string): Promise<string | null> {
|
|
18
|
-
|
|
19
|
-
if (value === undefined) return null
|
|
20
|
-
return Buffer.isBuffer(value) ? value.toString('utf8') : value
|
|
18
|
+
return this.values.get(key) ?? null
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
async
|
|
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> {
|
|
21
|
+
async set(key: string, value: string): Promise<unknown> {
|
|
30
22
|
this.values.set(key, value)
|
|
31
23
|
return 'OK'
|
|
32
24
|
}
|
|
33
25
|
|
|
34
|
-
async setex(key: string, _ttlSeconds: number, value: string
|
|
26
|
+
async setex(key: string, _ttlSeconds: number, value: string): Promise<unknown> {
|
|
35
27
|
this.values.set(key, value)
|
|
36
28
|
return 'OK'
|
|
37
29
|
}
|
|
@@ -83,11 +75,11 @@ class FakeRedis {
|
|
|
83
75
|
pipeline() {
|
|
84
76
|
const ops: PipelineOp[] = []
|
|
85
77
|
const chain = {
|
|
86
|
-
set: (key: string, value: string
|
|
78
|
+
set: (key: string, value: string) => {
|
|
87
79
|
ops.push(() => void this.values.set(key, value))
|
|
88
80
|
return chain
|
|
89
81
|
},
|
|
90
|
-
setex: (key: string, _ttlSeconds: number, value: string
|
|
82
|
+
setex: (key: string, _ttlSeconds: number, value: string) => {
|
|
91
83
|
ops.push(() => void this.values.set(key, value))
|
|
92
84
|
return chain
|
|
93
85
|
},
|
|
@@ -210,9 +202,9 @@ describe('redis strategy tag index', () => {
|
|
|
210
202
|
// deleteByTags walks members one round trip at a time, so an entry it read
|
|
211
203
|
// as missing can be rebuilt before the reap runs. Land that write in the
|
|
212
204
|
// window by hooking the GET that reports the key absent.
|
|
213
|
-
const readValue = currentRedis.
|
|
205
|
+
const readValue = currentRedis.get.bind(currentRedis)
|
|
214
206
|
let rebuilt = false
|
|
215
|
-
currentRedis.
|
|
207
|
+
currentRedis.get = async (key: string) => {
|
|
216
208
|
const result = await readValue(key)
|
|
217
209
|
if (!rebuilt && key === 'cache:nav:user-1' && result === null) {
|
|
218
210
|
rebuilt = true
|
|
@@ -222,7 +214,7 @@ describe('redis strategy tag index', () => {
|
|
|
222
214
|
}
|
|
223
215
|
|
|
224
216
|
await strategy.deleteByTags(['rbac:tenant:t1'])
|
|
225
|
-
currentRedis.
|
|
217
|
+
currentRedis.get = readValue
|
|
226
218
|
|
|
227
219
|
// The rebuilt entry must stay in the index, or it silently outlives every
|
|
228
220
|
// future invalidation of this tag and serves stale data until its TTL.
|
|
@@ -284,95 +276,3 @@ describe('redis strategy tag index', () => {
|
|
|
284
276
|
expect(await currentRedis.smembers('tag:tag-b')).toEqual(['key-1'])
|
|
285
277
|
})
|
|
286
278
|
})
|
|
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,57 +392,3 @@ 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
|
|
6
|
+
type CacheEntry = { value: string; 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:
|
|
93
|
+
value: String(value),
|
|
94
94
|
expires_at: expiresAt === null ? null : Number(expiresAt),
|
|
95
95
|
created_at: Number(createdAt),
|
|
96
96
|
})
|
|
@@ -161,54 +161,3 @@ 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
|
-
})
|
package/src/service.ts
CHANGED
|
@@ -7,8 +7,6 @@ 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'
|
|
12
10
|
|
|
13
11
|
function normalizeTenantKey(raw: string | null | undefined): string {
|
|
14
12
|
const value = typeof raw === 'string' ? raw.trim() : ''
|
|
@@ -262,12 +260,7 @@ function createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): Ten
|
|
|
262
260
|
const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined
|
|
263
261
|
const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)
|
|
264
262
|
|
|
265
|
-
const
|
|
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)
|
|
263
|
+
const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries)
|
|
271
264
|
const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries)
|
|
272
265
|
const reportsBoundedCounters = strategyType === 'memory'
|
|
273
266
|
|
|
@@ -283,8 +276,6 @@ function createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): Ten
|
|
|
283
276
|
* - CACHE_REDIS_URL: Redis connection URL (for redis strategy)
|
|
284
277
|
* - CACHE_SQLITE_PATH: SQLite database file path (for sqlite strategy)
|
|
285
278
|
* - 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)
|
|
288
279
|
*
|
|
289
280
|
* @example
|
|
290
281
|
* const cache = createCacheService({ strategy: 'memory', defaultTtl: 60000 })
|
|
@@ -382,12 +373,12 @@ export class CacheService implements CacheStrategy {
|
|
|
382
373
|
}
|
|
383
374
|
}
|
|
384
375
|
|
|
385
|
-
function createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number, maxEntries?: number
|
|
376
|
+
function createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number, maxEntries?: number): CacheStrategy {
|
|
386
377
|
switch (strategyType) {
|
|
387
378
|
case 'redis':
|
|
388
|
-
return createRedisStrategy(options?.redisUrl, { defaultTtl
|
|
379
|
+
return createRedisStrategy(options?.redisUrl, { defaultTtl })
|
|
389
380
|
case 'sqlite':
|
|
390
|
-
return createSqliteStrategy(options?.sqlitePath, { defaultTtl
|
|
381
|
+
return createSqliteStrategy(options?.sqlitePath, { defaultTtl })
|
|
391
382
|
case 'jsonfile':
|
|
392
383
|
return createJsonFileStrategy(options?.jsonFilePath, { defaultTtl })
|
|
393
384
|
case 'memory':
|
package/src/strategies/redis.ts
CHANGED
|
@@ -3,12 +3,10 @@ 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'
|
|
8
6
|
|
|
9
7
|
type RedisPipeline = {
|
|
10
|
-
set(key: string, value: string
|
|
11
|
-
setex(key: string, ttlSeconds: number, value: string
|
|
8
|
+
set(key: string, value: string): RedisPipeline
|
|
9
|
+
setex(key: string, ttlSeconds: number, value: string): RedisPipeline
|
|
12
10
|
sadd(key: string, value: string): RedisPipeline
|
|
13
11
|
srem(key: string, member: string): RedisPipeline
|
|
14
12
|
del(key: string): RedisPipeline
|
|
@@ -19,9 +17,8 @@ type RedisPipeline = {
|
|
|
19
17
|
type RedisClient = {
|
|
20
18
|
ping(): Promise<string>
|
|
21
19
|
get(key: string): Promise<string | null>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
setex(key: string, ttlSeconds: number, value: string | Buffer): Promise<unknown>
|
|
20
|
+
set(key: string, value: string): Promise<unknown>
|
|
21
|
+
setex(key: string, ttlSeconds: number, value: string): Promise<unknown>
|
|
25
22
|
del(key: string): Promise<unknown>
|
|
26
23
|
exists(key: string): Promise<number>
|
|
27
24
|
keys(pattern: string): Promise<string[]>
|
|
@@ -189,9 +186,8 @@ async function releaseRedisEntry(key: string, entry: RedisRegistryEntry): Promis
|
|
|
189
186
|
}
|
|
190
187
|
}
|
|
191
188
|
|
|
192
|
-
export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number }
|
|
189
|
+
export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number }): CacheStrategy {
|
|
193
190
|
const defaultTtl = options?.defaultTtl
|
|
194
|
-
const codec = resolveCacheCodecConfig(options)
|
|
195
191
|
const keyPrefix = 'cache:'
|
|
196
192
|
const tagPrefix = 'tag:'
|
|
197
193
|
const connectionUrl = redisUrl || getRedisUrlOrThrow('CACHE')
|
|
@@ -221,12 +217,12 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
221
217
|
const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {
|
|
222
218
|
const client = await getRedisClient()
|
|
223
219
|
const cacheKey = getCacheKey(key)
|
|
224
|
-
const data = await client.
|
|
220
|
+
const data = await client.get(cacheKey)
|
|
225
221
|
|
|
226
222
|
if (!data) return null
|
|
227
223
|
|
|
228
224
|
try {
|
|
229
|
-
const entry: CacheEntry = JSON.parse(
|
|
225
|
+
const entry: CacheEntry = JSON.parse(data)
|
|
230
226
|
|
|
231
227
|
if (isExpired(entry)) {
|
|
232
228
|
if (options?.returnExpired) {
|
|
@@ -250,10 +246,10 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
250
246
|
const cacheKey = getCacheKey(key)
|
|
251
247
|
|
|
252
248
|
// Remove old entry from tag index if it exists
|
|
253
|
-
const oldData = await client.
|
|
249
|
+
const oldData = await client.get(cacheKey)
|
|
254
250
|
if (oldData) {
|
|
255
251
|
try {
|
|
256
|
-
const oldEntry: CacheEntry = JSON.parse(
|
|
252
|
+
const oldEntry: CacheEntry = JSON.parse(oldData)
|
|
257
253
|
// Remove from old tags
|
|
258
254
|
const pipeline = client.pipeline()
|
|
259
255
|
for (const tag of oldEntry.tags) {
|
|
@@ -280,7 +276,7 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
280
276
|
const pipeline = client.pipeline()
|
|
281
277
|
|
|
282
278
|
// Store the entry
|
|
283
|
-
const serialized =
|
|
279
|
+
const serialized = JSON.stringify(entry)
|
|
284
280
|
if (ttl) {
|
|
285
281
|
pipeline.setex(cacheKey, Math.ceil(ttl / 1000), serialized)
|
|
286
282
|
} else {
|
|
@@ -303,11 +299,11 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
303
299
|
if (!exists) return false
|
|
304
300
|
|
|
305
301
|
// Check if expired
|
|
306
|
-
const data = await client.
|
|
302
|
+
const data = await client.get(cacheKey)
|
|
307
303
|
if (!data) return false
|
|
308
304
|
|
|
309
305
|
try {
|
|
310
|
-
const entry: CacheEntry = JSON.parse(
|
|
306
|
+
const entry: CacheEntry = JSON.parse(data)
|
|
311
307
|
if (isExpired(entry)) {
|
|
312
308
|
await deleteKey(key)
|
|
313
309
|
return false
|
|
@@ -323,11 +319,11 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
323
319
|
const cacheKey = getCacheKey(key)
|
|
324
320
|
|
|
325
321
|
// Get entry to remove from tag index
|
|
326
|
-
const data = await client.
|
|
322
|
+
const data = await client.get(cacheKey)
|
|
327
323
|
if (!data) return false
|
|
328
324
|
|
|
329
325
|
try {
|
|
330
|
-
const entry: CacheEntry = JSON.parse(
|
|
326
|
+
const entry: CacheEntry = JSON.parse(data)
|
|
331
327
|
const pipeline = client.pipeline()
|
|
332
328
|
|
|
333
329
|
// Remove from tag index
|
|
@@ -451,10 +447,10 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
451
447
|
|
|
452
448
|
let expired = 0
|
|
453
449
|
for (const cacheKey of cacheKeys) {
|
|
454
|
-
const data = await client.
|
|
450
|
+
const data = await client.get(cacheKey)
|
|
455
451
|
if (data) {
|
|
456
452
|
try {
|
|
457
|
-
const entry: CacheEntry = JSON.parse(
|
|
453
|
+
const entry: CacheEntry = JSON.parse(data)
|
|
458
454
|
if (isExpired(entry)) {
|
|
459
455
|
expired++
|
|
460
456
|
}
|
|
@@ -473,10 +469,10 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
473
469
|
|
|
474
470
|
let removed = 0
|
|
475
471
|
for (const cacheKey of cacheKeys) {
|
|
476
|
-
const data = await client.
|
|
472
|
+
const data = await client.get(cacheKey)
|
|
477
473
|
if (data) {
|
|
478
474
|
try {
|
|
479
|
-
const entry: CacheEntry = JSON.parse(
|
|
475
|
+
const entry: CacheEntry = JSON.parse(data)
|
|
480
476
|
if (isExpired(entry)) {
|
|
481
477
|
const key = cacheKey.substring(keyPrefix.length)
|
|
482
478
|
await deleteKey(key)
|
package/src/strategies/sqlite.ts
CHANGED
|
@@ -5,8 +5,6 @@ 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'
|
|
10
8
|
|
|
11
9
|
type SqliteStatement<TResult = unknown> = {
|
|
12
10
|
get(...args: unknown[]): TResult | undefined
|
|
@@ -25,7 +23,7 @@ type SqliteDatabase = {
|
|
|
25
23
|
|
|
26
24
|
type SqliteConstructor = new (file: string) => SqliteDatabase
|
|
27
25
|
type SqliteModule = SqliteConstructor | { default: SqliteConstructor }
|
|
28
|
-
type SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor }
|
|
26
|
+
type SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor }
|
|
29
27
|
|
|
30
28
|
const sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))
|
|
31
29
|
|
|
@@ -40,7 +38,6 @@ const sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))
|
|
|
40
38
|
export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOptions): CacheStrategy {
|
|
41
39
|
let db: SqliteDatabase | null = null
|
|
42
40
|
const defaultTtl = options?.defaultTtl
|
|
43
|
-
const codec = resolveCacheCodecConfig(options)
|
|
44
41
|
const filePath = dbPath || process.env.CACHE_SQLITE_PATH || DEFAULT_SQLITE_CACHE_PATH
|
|
45
42
|
|
|
46
43
|
async function getDb(): Promise<SqliteDatabase> {
|
|
@@ -96,7 +93,7 @@ export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOp
|
|
|
96
93
|
return Date.now() > expiresAt
|
|
97
94
|
}
|
|
98
95
|
|
|
99
|
-
type EntryRow = { value: string
|
|
96
|
+
type EntryRow = { value: string; expires_at: number | null }
|
|
100
97
|
type ExpiresRow = { expires_at: number | null }
|
|
101
98
|
type CountRow = { count: number }
|
|
102
99
|
type KeyRow = { key: string }
|
|
@@ -110,7 +107,7 @@ export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOp
|
|
|
110
107
|
if (!row) return null
|
|
111
108
|
|
|
112
109
|
try {
|
|
113
|
-
const value = JSON.parse(
|
|
110
|
+
const value = JSON.parse(row.value) as CacheValue
|
|
114
111
|
const expiresAt = row.expires_at
|
|
115
112
|
|
|
116
113
|
if (isExpired(expiresAt)) {
|
|
@@ -137,7 +134,7 @@ export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOp
|
|
|
137
134
|
const expiresAt = ttl ? Date.now() + ttl : null
|
|
138
135
|
const createdAt = Date.now()
|
|
139
136
|
|
|
140
|
-
const serialized =
|
|
137
|
+
const serialized = JSON.stringify(value)
|
|
141
138
|
|
|
142
139
|
database.transaction(() => {
|
|
143
140
|
// Delete old tags
|
package/src/types.ts
CHANGED
|
@@ -120,15 +120,4 @@ 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
|
|
134
123
|
}
|
package/dist/codec.js
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import zlib from "node:zlib";
|
|
2
|
-
const DEFAULT_COMPRESSION_MIN_BYTES = 1024;
|
|
3
|
-
const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
4
|
-
const ZSTD_LEVEL = 1;
|
|
5
|
-
const COMPRESSIONS = ["off", "zstd", "gzip"];
|
|
6
|
-
const warnedMessages = /* @__PURE__ */ new Set();
|
|
7
|
-
function warnOnce(message) {
|
|
8
|
-
if (warnedMessages.has(message)) return;
|
|
9
|
-
warnedMessages.add(message);
|
|
10
|
-
console.warn(message);
|
|
11
|
-
}
|
|
12
|
-
function isZstdAvailable() {
|
|
13
|
-
return typeof zlib.zstdCompressSync === "function" && typeof zlib.zstdDecompressSync === "function";
|
|
14
|
-
}
|
|
15
|
-
function parseCacheCompression(raw) {
|
|
16
|
-
const value = raw?.trim().toLowerCase();
|
|
17
|
-
if (!value) return "off";
|
|
18
|
-
if (COMPRESSIONS.includes(value)) return value;
|
|
19
|
-
warnOnce(`[cache] Unknown CACHE_COMPRESSION value "${raw}". Compression stays off.`);
|
|
20
|
-
return "off";
|
|
21
|
-
}
|
|
22
|
-
function parseCacheCompressionMinBytes(raw) {
|
|
23
|
-
if (!raw) return void 0;
|
|
24
|
-
const parsed = Number.parseInt(raw, 10);
|
|
25
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
26
|
-
}
|
|
27
|
-
function resolveCacheCodecConfig(options) {
|
|
28
|
-
const minBytes = options?.compressionMinBytes && options.compressionMinBytes > 0 ? options.compressionMinBytes : DEFAULT_COMPRESSION_MIN_BYTES;
|
|
29
|
-
let compression = options?.compression ?? "off";
|
|
30
|
-
if (compression === "zstd" && !isZstdAvailable()) {
|
|
31
|
-
warnOnce("[cache] zstd is not available in this Node.js runtime. Falling back to gzip cache compression.");
|
|
32
|
-
compression = "gzip";
|
|
33
|
-
}
|
|
34
|
-
return { compression, minBytes };
|
|
35
|
-
}
|
|
36
|
-
function compress(input, compression) {
|
|
37
|
-
if (compression === "zstd") {
|
|
38
|
-
return zlib.zstdCompressSync(input, { params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } });
|
|
39
|
-
}
|
|
40
|
-
return zlib.gzipSync(input);
|
|
41
|
-
}
|
|
42
|
-
function encodeCacheText(json, config) {
|
|
43
|
-
if (config.compression === "off") return json;
|
|
44
|
-
const size = Buffer.byteLength(json);
|
|
45
|
-
if (size < config.minBytes || size > MAX_DECOMPRESSED_BYTES) return json;
|
|
46
|
-
const compressed = compress(Buffer.from(json, "utf8"), config.compression);
|
|
47
|
-
return compressed.length < size ? compressed : json;
|
|
48
|
-
}
|
|
49
|
-
function isZstdFrame(raw) {
|
|
50
|
-
return raw.length >= 4 && raw[0] === 40 && raw[1] === 181 && raw[2] === 47 && raw[3] === 253;
|
|
51
|
-
}
|
|
52
|
-
function isGzipStream(raw) {
|
|
53
|
-
return raw.length >= 2 && raw[0] === 31 && raw[1] === 139;
|
|
54
|
-
}
|
|
55
|
-
function decodeCacheText(raw) {
|
|
56
|
-
if (typeof raw === "string") return raw;
|
|
57
|
-
if (isZstdFrame(raw)) {
|
|
58
|
-
return zlib.zstdDecompressSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString("utf8");
|
|
59
|
-
}
|
|
60
|
-
if (isGzipStream(raw)) {
|
|
61
|
-
return zlib.gunzipSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString("utf8");
|
|
62
|
-
}
|
|
63
|
-
return raw.toString("utf8");
|
|
64
|
-
}
|
|
65
|
-
export {
|
|
66
|
-
DEFAULT_COMPRESSION_MIN_BYTES,
|
|
67
|
-
MAX_DECOMPRESSED_BYTES,
|
|
68
|
-
decodeCacheText,
|
|
69
|
-
encodeCacheText,
|
|
70
|
-
isZstdAvailable,
|
|
71
|
-
parseCacheCompression,
|
|
72
|
-
parseCacheCompressionMinBytes,
|
|
73
|
-
resolveCacheCodecConfig
|
|
74
|
-
};
|
|
75
|
-
//# sourceMappingURL=codec.js.map
|
package/dist/codec.js.map
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../src/codec.ts"],
|
|
4
|
-
"sourcesContent": ["import zlib from 'node:zlib'\n\nexport type CacheCompression = 'off' | 'zstd' | 'gzip'\n\nexport type CacheCodecConfig = {\n compression: CacheCompression\n minBytes: number\n}\n\nexport type CacheCodecOptions = {\n compression?: CacheCompression\n compressionMinBytes?: number\n}\n\nexport const DEFAULT_COMPRESSION_MIN_BYTES = 1024\n\n/** Upper bound for one decompressed entry; larger JSON is never compressed, so no legitimate entry can hit it. */\nexport const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024\n\nconst ZSTD_LEVEL = 1\nconst COMPRESSIONS: CacheCompression[] = ['off', 'zstd', 'gzip']\n\nconst warnedMessages = new Set<string>()\n\nfunction warnOnce(message: string): void {\n if (warnedMessages.has(message)) return\n warnedMessages.add(message)\n console.warn(message)\n}\n\nexport function isZstdAvailable(): boolean {\n return typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function'\n}\n\nexport function parseCacheCompression(raw: string | undefined): CacheCompression {\n const value = raw?.trim().toLowerCase()\n if (!value) return 'off'\n if (COMPRESSIONS.includes(value as CacheCompression)) return value as CacheCompression\n warnOnce(`[cache] Unknown CACHE_COMPRESSION value \"${raw}\". Compression stays off.`)\n return 'off'\n}\n\nexport function parseCacheCompressionMinBytes(raw: string | undefined): number | undefined {\n if (!raw) return undefined\n const parsed = Number.parseInt(raw, 10)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\nexport function resolveCacheCodecConfig(options?: CacheCodecOptions): CacheCodecConfig {\n const minBytes = options?.compressionMinBytes && options.compressionMinBytes > 0\n ? options.compressionMinBytes\n : DEFAULT_COMPRESSION_MIN_BYTES\n let compression = options?.compression ?? 'off'\n if (compression === 'zstd' && !isZstdAvailable()) {\n warnOnce('[cache] zstd is not available in this Node.js runtime. Falling back to gzip cache compression.')\n compression = 'gzip'\n }\n return { compression, minBytes }\n}\n\nfunction compress(input: Buffer, compression: Exclude<CacheCompression, 'off'>): Buffer {\n if (compression === 'zstd') {\n return zlib.zstdCompressSync(input, { params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } })\n }\n return zlib.gzipSync(input)\n}\n\nexport function encodeCacheText(json: string, config: CacheCodecConfig): string | Buffer {\n if (config.compression === 'off') return json\n const size = Buffer.byteLength(json)\n if (size < config.minBytes || size > MAX_DECOMPRESSED_BYTES) return json\n const compressed = compress(Buffer.from(json, 'utf8'), config.compression)\n return compressed.length < size ? compressed : json\n}\n\nfunction isZstdFrame(raw: Buffer): boolean {\n return raw.length >= 4 && raw[0] === 0x28 && raw[1] === 0xb5 && raw[2] === 0x2f && raw[3] === 0xfd\n}\n\nfunction isGzipStream(raw: Buffer): boolean {\n return raw.length >= 2 && raw[0] === 0x1f && raw[1] === 0x8b\n}\n\n/** Detects the format from the stored bytes, so entries stay readable whatever CACHE_COMPRESSION is set to now. */\nexport function decodeCacheText(raw: string | Buffer): string {\n if (typeof raw === 'string') return raw\n if (isZstdFrame(raw)) {\n return zlib.zstdDecompressSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')\n }\n if (isGzipStream(raw)) {\n return zlib.gunzipSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')\n }\n return raw.toString('utf8')\n}\n"],
|
|
5
|
-
"mappings": "AAAA,OAAO,UAAU;AAcV,MAAM,gCAAgC;AAGtC,MAAM,yBAAyB,KAAK,OAAO;AAElD,MAAM,aAAa;AACnB,MAAM,eAAmC,CAAC,OAAO,QAAQ,MAAM;AAE/D,MAAM,iBAAiB,oBAAI,IAAY;AAEvC,SAAS,SAAS,SAAuB;AACvC,MAAI,eAAe,IAAI,OAAO,EAAG;AACjC,iBAAe,IAAI,OAAO;AAC1B,UAAQ,KAAK,OAAO;AACtB;AAEO,SAAS,kBAA2B;AACzC,SAAO,OAAO,KAAK,qBAAqB,cAAc,OAAO,KAAK,uBAAuB;AAC3F;AAEO,SAAS,sBAAsB,KAA2C;AAC/E,QAAM,QAAQ,KAAK,KAAK,EAAE,YAAY;AACtC,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,aAAa,SAAS,KAAyB,EAAG,QAAO;AAC7D,WAAS,4CAA4C,GAAG,2BAA2B;AACnF,SAAO;AACT;AAEO,SAAS,8BAA8B,KAA6C;AACzF,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,SAAS,OAAO,SAAS,KAAK,EAAE;AACtC,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEO,SAAS,wBAAwB,SAA+C;AACrF,QAAM,WAAW,SAAS,uBAAuB,QAAQ,sBAAsB,IAC3E,QAAQ,sBACR;AACJ,MAAI,cAAc,SAAS,eAAe;AAC1C,MAAI,gBAAgB,UAAU,CAAC,gBAAgB,GAAG;AAChD,aAAS,gGAAgG;AACzG,kBAAc;AAAA,EAChB;AACA,SAAO,EAAE,aAAa,SAAS;AACjC;AAEA,SAAS,SAAS,OAAe,aAAuD;AACtF,MAAI,gBAAgB,QAAQ;AAC1B,WAAO,KAAK,iBAAiB,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,UAAU,uBAAuB,GAAG,WAAW,EAAE,CAAC;AAAA,EAC1G;AACA,SAAO,KAAK,SAAS,KAAK;AAC5B;AAEO,SAAS,gBAAgB,MAAc,QAA2C;AACvF,MAAI,OAAO,gBAAgB,MAAO,QAAO;AACzC,QAAM,OAAO,OAAO,WAAW,IAAI;AACnC,MAAI,OAAO,OAAO,YAAY,OAAO,uBAAwB,QAAO;AACpE,QAAM,aAAa,SAAS,OAAO,KAAK,MAAM,MAAM,GAAG,OAAO,WAAW;AACzE,SAAO,WAAW,SAAS,OAAO,aAAa;AACjD;AAEA,SAAS,YAAY,KAAsB;AACzC,SAAO,IAAI,UAAU,KAAK,IAAI,CAAC,MAAM,MAAQ,IAAI,CAAC,MAAM,OAAQ,IAAI,CAAC,MAAM,MAAQ,IAAI,CAAC,MAAM;AAChG;AAEA,SAAS,aAAa,KAAsB;AAC1C,SAAO,IAAI,UAAU,KAAK,IAAI,CAAC,MAAM,MAAQ,IAAI,CAAC,MAAM;AAC1D;AAGO,SAAS,gBAAgB,KAA8B;AAC5D,MAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,MAAI,YAAY,GAAG,GAAG;AACpB,WAAO,KAAK,mBAAmB,KAAK,EAAE,iBAAiB,uBAAuB,CAAC,EAAE,SAAS,MAAM;AAAA,EAClG;AACA,MAAI,aAAa,GAAG,GAAG;AACrB,WAAO,KAAK,WAAW,KAAK,EAAE,iBAAiB,uBAAuB,CAAC,EAAE,SAAS,MAAM;AAAA,EAC1F;AACA,SAAO,IAAI,SAAS,MAAM;AAC5B;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|