From 8d534d08e892e4a5338d48eeeeb084b12ccdbe56 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:55:05 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A7=B9=20refactor:=20Extract=20cache?= =?UTF-8?q?=20key=20string=20formatting=20to=20utility=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: suranig <24814104+suranig@users.noreply.github.com> --- src/backends/index.ts | 43 +++++---- src/backends/memory.ts | 18 ++-- src/backends/redis.ts | 35 ++++--- src/cache/createCacheHandler.ts | 85 +++++++++-------- src/cache/fetchWithCache.ts | 8 +- src/errors.ts | 2 +- src/index.ts | 2 +- src/instrumentation/index.ts | 17 ++-- src/types/index.ts | 4 +- src/utils/keys.ts | 22 +++++ test/backends/index.test.ts | 12 +-- test/backends/memory.test.ts | 34 +++---- test/backends/redis.test.ts | 28 +++--- test/cache/createCacheHandler.test.ts | 128 +++++++++++++++----------- test/cache/fetchWithCache.test.ts | 44 +++++---- test/errors.test.ts | 2 +- test/index.test.ts | 2 +- test/instrumentation/index.test.ts | 54 ++++++----- test/types.test.ts | 12 +-- 19 files changed, 311 insertions(+), 241 deletions(-) create mode 100644 src/utils/keys.ts diff --git a/src/backends/index.ts b/src/backends/index.ts index 20ba43b..d29f81b 100644 --- a/src/backends/index.ts +++ b/src/backends/index.ts @@ -8,18 +8,18 @@ let connectionPromise: Promise | null = null; /** * Create a default Redis backend, reusing a global client if available. - * + * * @param options - Options for creating the Redis backend * @returns A Redis cache backend instance */ export function createDefaultBackend( - options: { prefix?: string; redisClient?: Redis } = {} + options: { prefix?: string; redisClient?: Redis } = {}, ): CacheBackend { const { prefix = 'next-cachex', redisClient } = options; - + // Use provided client or create/reuse global client const client = redisClient || getGlobalRedisClient(); - + return new RedisCacheBackend(client, prefix); } @@ -57,21 +57,24 @@ function getGlobalRedisClient(): Redis { }); // Handle connection promise - connectionPromise = globalRedisClient.connect().then(() => globalRedisClient!).catch((error) => { - // Reset the promise on error so it can be retried - connectionPromise = null; - const connectionError = new CacheConnectionError( - `Failed to connect to Redis: ${error instanceof Error ? error.message : String(error)}` - ); - - // In test environment, don't throw unhandled rejections - if (process.env.NODE_ENV === 'test') { - console.warn('Redis connection failed in test environment:', connectionError.message); - return globalRedisClient!; - } - - throw connectionError; - }); + connectionPromise = globalRedisClient + .connect() + .then(() => globalRedisClient!) + .catch((error) => { + // Reset the promise on error so it can be retried + connectionPromise = null; + const connectionError = new CacheConnectionError( + `Failed to connect to Redis: ${error instanceof Error ? error.message : String(error)}`, + ); + + // In test environment, don't throw unhandled rejections + if (process.env.NODE_ENV === 'test') { + console.warn('Redis connection failed in test environment:', connectionError.message); + return globalRedisClient!; + } + + throw connectionError; + }); } return globalRedisClient; } @@ -96,4 +99,4 @@ export function closeGlobalRedisClient(): void { } export { RedisCacheBackend } from './redis'; -export { MemoryCacheBackend } from './memory'; \ No newline at end of file +export { MemoryCacheBackend } from './memory'; diff --git a/src/backends/memory.ts b/src/backends/memory.ts index 8d42f6c..7d0a88c 100644 --- a/src/backends/memory.ts +++ b/src/backends/memory.ts @@ -15,13 +15,13 @@ export class MemoryCacheBackend implements CacheBackend { async get(key: string): Promise { const item = this.store.get(key); if (!item) return undefined; - + // Check if item has expired if (item.expiresAt && Date.now() > item.expiresAt) { this.store.delete(key); return undefined; } - + return item.value; } @@ -32,7 +32,7 @@ export class MemoryCacheBackend implements CacheBackend { * @param options - Optional TTL in seconds */ async set(key: string, value: T, options?: { ttl?: number }): Promise { - const expiresAt = options?.ttl ? Date.now() + (options.ttl * 1000) : undefined; + const expiresAt = options?.ttl ? Date.now() + options.ttl * 1000 : undefined; this.store.set(key, { value, expiresAt }); } @@ -52,14 +52,14 @@ export class MemoryCacheBackend implements CacheBackend { */ async lock(key: string, ttl: number): Promise { const now = Date.now(); - const expiresAt = now + (ttl * 1000); - + const expiresAt = now + ttl * 1000; + // Check if lock exists and is still valid const existingLock = this.locks.get(key); if (existingLock && existingLock.expiresAt > now) { return false; } - + // Acquire the lock this.locks.set(key, { expiresAt }); return true; @@ -86,14 +86,14 @@ export class MemoryCacheBackend implements CacheBackend { */ cleanup(): void { const now = Date.now(); - + // Clean up expired cache entries for (const [key, item] of this.store.entries()) { if (item.expiresAt && item.expiresAt <= now) { this.store.delete(key); } } - + // Clean up expired locks for (const [key, lock] of this.locks.entries()) { if (lock.expiresAt <= now) { @@ -101,4 +101,4 @@ export class MemoryCacheBackend implements CacheBackend { } } } -} \ No newline at end of file +} diff --git a/src/backends/redis.ts b/src/backends/redis.ts index d86cd07..27e7402 100644 --- a/src/backends/redis.ts +++ b/src/backends/redis.ts @@ -1,4 +1,9 @@ -import { CacheBackend, CacheSerializationError, CacheBackendError, CacheConfigError } from '../types'; +import { + CacheBackend, + CacheSerializationError, + CacheBackendError, + CacheConfigError, +} from '../types'; import type Redis from 'ioredis'; /** @@ -23,24 +28,24 @@ export class RedisCacheBackend implements CacheBackend { try { const value = await this.client.get(fullKey); if (value === null) return undefined; - + // Fast path for simple values if (value === 'null') return null as T; if (value === 'undefined') return undefined; if (value === 'true') return true as T; if (value === 'false') return false as T; - + // Try to parse as number first (common case) const num = Number(value); if (!isNaN(num) && value.trim() === num.toString()) { return num as T; } - + try { return JSON.parse(value) as T; } catch (error) { throw new CacheSerializationError( - `Failed to parse cached value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}` + `Failed to parse cached value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`, ); } } catch (error) { @@ -49,7 +54,7 @@ export class RedisCacheBackend implements CacheBackend { } throw new CacheBackendError( `Redis get operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } } @@ -63,7 +68,7 @@ export class RedisCacheBackend implements CacheBackend { async set(key: string, value: T, options?: { ttl?: number }): Promise { const fullKey = this.prefix ? `${this.prefix}:${key}` : key; let str: string; - + // Fast path for simple values if (value === null) str = 'null'; else if (value === undefined) str = 'undefined'; @@ -75,11 +80,11 @@ export class RedisCacheBackend implements CacheBackend { str = JSON.stringify(value); } catch (error) { throw new CacheSerializationError( - `Failed to stringify value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}` + `Failed to stringify value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`, ); } } - + try { if (options?.ttl) { await this.client.set(fullKey, str, 'EX', options.ttl); @@ -89,7 +94,7 @@ export class RedisCacheBackend implements CacheBackend { } catch (error) { throw new CacheBackendError( `Redis set operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } } @@ -105,7 +110,7 @@ export class RedisCacheBackend implements CacheBackend { } catch (error) { throw new CacheBackendError( `Redis delete operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } } @@ -125,7 +130,7 @@ export class RedisCacheBackend implements CacheBackend { } catch (error) { throw new CacheBackendError( `Redis lock operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } } @@ -141,7 +146,7 @@ export class RedisCacheBackend implements CacheBackend { } catch (error) { throw new CacheBackendError( `Redis unlock operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } } @@ -154,7 +159,7 @@ export class RedisCacheBackend implements CacheBackend { if (!this.prefix) { throw new CacheConfigError('Refusing to clear all keys: prefix is required for safety.'); } - + const pattern = `${this.prefix}:*`; let cursor = '0'; try { @@ -168,7 +173,7 @@ export class RedisCacheBackend implements CacheBackend { } catch (error) { throw new CacheBackendError( `Redis clear operation failed for pattern "${pattern}": ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } } diff --git a/src/cache/createCacheHandler.ts b/src/cache/createCacheHandler.ts index 70f42de..2cfb916 100644 --- a/src/cache/createCacheHandler.ts +++ b/src/cache/createCacheHandler.ts @@ -6,6 +6,7 @@ import { CacheTimeoutError, CacheBackendError, } from '../types'; +import { getLockKey, getStaleKey } from '../utils/keys'; /** * Default cache logger that does nothing @@ -27,30 +28,28 @@ const DEFAULT_FETCH_OPTIONS = { /** * Create a new cache handler with the specified backend and options. - * + * * @param options - Cache handler configuration * @returns A configured cache handler - * + * * @example * ```ts * import { createCacheHandler } from 'next-cachex'; * import { RedisCacheBackend } from 'next-cachex/backends/redis'; * import Redis from 'ioredis'; - * + * * const redisClient = new Redis(); * const cacheHandler = createCacheHandler({ * backend: new RedisCacheBackend(redisClient), * prefix: 'myapp', * version: 'v1', * }); - * + * * // Use the handler * const data = await cacheHandler.fetch('posts:all', fetchPosts, { ttl: 300 }); * ``` */ -export function createCacheHandler( - options: CacheHandlerOptions, -): CacheHandler { +export function createCacheHandler(options: CacheHandlerOptions): CacheHandler { const { backend, prefix = '', @@ -81,7 +80,7 @@ export function createCacheHandler( ): Promise => { const fullKey = getFullKey(key); const fetchOptions = { ...DEFAULT_FETCH_OPTIONS, ...options }; - + // Try to get from L1 cache first const l1Item = l1Cache.get(fullKey); if (l1Item && l1Item.expiresAt > Date.now()) { @@ -91,7 +90,7 @@ export function createCacheHandler( // Try to get from backend cache try { - const cached = await backend.get(fullKey) as R | undefined; + const cached = (await backend.get(fullKey)) as R | undefined; if (cached !== undefined) { // Store in L1 cache for future fast access l1Cache.set(fullKey, { @@ -104,45 +103,45 @@ export function createCacheHandler( } catch (error) { throw new CacheBackendError( `Failed to get value from cache: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } - + logger.log({ type: 'MISS', key: fullKey }); - + // Try to acquire a lock - const lockKey = `lock:${fullKey}`; + const lockKey = getLockKey(fullKey); let lockAcquired = false; try { lockAcquired = await backend.lock(lockKey, Math.ceil(fetchOptions.lockTimeout / 1000)); } catch (error) { throw new CacheBackendError( `Failed to acquire lock: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error : undefined + error instanceof Error ? error : undefined, ); } - + if (lockAcquired) { try { logger.log({ type: 'LOCK', key: lockKey }); - + // Execute the fetcher const value = await fetcher(); - + // Cache the result - await backend.set(fullKey, value as unknown as T, { + await backend.set(fullKey, value as unknown as T, { ttl: fetchOptions.ttl, }); - + // Also store in L1 cache l1Cache.set(fullKey, { value, expiresAt: Date.now() + L1_CACHE_TTL, }); - + // If staleTtl is set, store a stale copy with longer TTL if (fallbackToStale && fetchOptions.staleTtl && fetchOptions.staleTtl > fetchOptions.ttl) { - const staleKey = `stale:${fullKey}`; + const staleKey = getStaleKey(fullKey); try { await backend.set(staleKey, value as unknown as T, { ttl: fetchOptions.staleTtl, @@ -156,22 +155,22 @@ export function createCacheHandler( }); } } - + return value; } catch (error) { - logger.log({ - type: 'ERROR', - key: fullKey, + logger.log({ + type: 'ERROR', + key: fullKey, error: error instanceof Error ? error : new Error(String(error)), }); - + // If fallback to stale is enabled, try to get stale value if (fallbackToStale && fetchOptions.staleTtl) { - const staleKey = `stale:${fullKey}`; + const staleKey = getStaleKey(fullKey); try { - const staleValue = await backend.get(staleKey) as R | undefined; + const staleValue = (await backend.get(staleKey)) as R | undefined; if (staleValue !== undefined) { - logger.log({ type: 'HIT', key: `stale:${fullKey}` }); + logger.log({ type: 'HIT', key: staleKey }); return staleValue; } } catch (staleError) { @@ -183,7 +182,7 @@ export function createCacheHandler( }); } } - + throw error; } finally { // Always release the lock @@ -191,12 +190,12 @@ export function createCacheHandler( await backend.unlock(lockKey); } catch (unlockError) { // Just log unlock errors, don't throw - logger.log({ - type: 'ERROR', - key: lockKey, + logger.log({ + type: 'ERROR', + key: lockKey, error: new CacheBackendError( `Failed to release lock: ${unlockError instanceof Error ? unlockError.message : String(unlockError)}`, - unlockError instanceof Error ? unlockError : undefined + unlockError instanceof Error ? unlockError : undefined, ), }); } @@ -204,19 +203,19 @@ export function createCacheHandler( } else { // Lock not acquired, wait for the value to be available logger.log({ type: 'WAIT', key: lockKey }); - + // Exponential backoff polling implementation const startTime = Date.now(); let pollInterval = 50; // Start with 50ms const maxPollInterval = 500; // Max 500ms between polls - + while (Date.now() - startTime < fetchOptions.lockTimeout) { // Sleep with exponential backoff await new Promise((resolve) => setTimeout(resolve, pollInterval)); - + // Check if the value is now available try { - const value = await backend.get(fullKey) as R | undefined; + const value = (await backend.get(fullKey)) as R | undefined; if (value !== undefined) { return value; } @@ -228,15 +227,13 @@ export function createCacheHandler( error: error instanceof Error ? error : new Error(String(error)), }); } - + // Exponential backoff: double the interval, but cap it pollInterval = Math.min(pollInterval * 1.5, maxPollInterval); } - + // Timeout waiting for the value - throw new CacheTimeoutError( - `Timeout waiting for ${key} (${fetchOptions.lockTimeout}ms)` - ); + throw new CacheTimeoutError(`Timeout waiting for ${key} (${fetchOptions.lockTimeout}ms)`); } }; @@ -260,4 +257,4 @@ export function createCacheHandler( backend, getFullKey, }; -} \ No newline at end of file +} diff --git a/src/cache/fetchWithCache.ts b/src/cache/fetchWithCache.ts index f9302a4..49283ae 100644 --- a/src/cache/fetchWithCache.ts +++ b/src/cache/fetchWithCache.ts @@ -1,16 +1,16 @@ /** * Fetch data from cache or execute the fetcher function. * This is a convenience wrapper around the default cache handler. - * + * * @param key - Cache key * @param fetcher - Function to execute on cache miss * @param options - Cache options (ttl, lockTimeout, etc.) * @returns The cached or fetched value - * + * * @example * ```ts * import { fetchWithCache } from 'next-cachex'; - * + * * const data = await fetchWithCache( * 'posts:all', * () => fetch('https://api.example.com/posts').then(r => r.json()), @@ -58,7 +58,7 @@ export async function fetchWithCache( }); return tempHandler.fetch(key, fetcher, options); } - + // Use the default handler return getDefaultHandler().fetch(key, fetcher, options); } diff --git a/src/errors.ts b/src/errors.ts index 6cab584..f0169bc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -73,4 +73,4 @@ export class CacheLockError extends CacheError { super(message); this.name = 'CacheLockError'; } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index a5fb02d..581b8c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ /** * next-cachex - A distributed, shared cache handler for Next.js - * + * * @packageDocumentation */ diff --git a/src/instrumentation/index.ts b/src/instrumentation/index.ts index 5659b84..e304496 100644 --- a/src/instrumentation/index.ts +++ b/src/instrumentation/index.ts @@ -5,6 +5,7 @@ */ import { CacheHandler } from '../types'; +import { getStaleKey } from '../utils/keys'; /** * Cache initialization data structure @@ -18,18 +19,18 @@ export interface CacheItem { /** * Register initial cache data for cache warming. * Use this in Next.js instrumentation.ts or during app startup. - * + * * @param handler - The cache handler to populate * @param items - Cache items to populate * @returns Promise resolving when all cache items are set - * + * * @example * ```ts * // In your Next.js instrumentation.ts file * export async function register() { * if (process.env.NEXT_RUNTIME === 'nodejs') { * const { cacheHandler, registerInitialCache } = await import('next-cachex'); - * + * * await registerInitialCache(cacheHandler, [ * { key: 'global:config', value: { theme: 'light' }, options: { ttl: 3600 } }, * { key: 'products:featured', value: await fetchFeaturedProducts(), options: { ttl: 300 } }, @@ -51,10 +52,10 @@ export async function registerInitialCache( items.map(async ({ key, value, options }) => { const fullKey = handler.getFullKey(key); await handler.backend.set(fullKey, value as T, options); - + // If staleTtl is provided, also set a stale cache version if (options?.staleTtl && options.staleTtl > (options.ttl || 0)) { - const staleKey = `stale:${fullKey}`; + const staleKey = getStaleKey(fullKey); await handler.backend.set(staleKey, value as T, { ttl: options.staleTtl }); } }), @@ -64,15 +65,15 @@ export async function registerInitialCache( /** * Clear the cache prefix during deployment or on-demand. * Useful for global cache invalidation during deployments. - * + * * @param handler - The cache handler to clear * @returns Promise resolving when cache is cleared - * + * * @example * ```ts * // In your deployment script or API route * import { cacheHandler, clearCache } from 'next-cachex'; - * + * * export default async function handler(req, res) { * if (req.method === 'POST' && req.headers['x-api-key'] === process.env.CACHE_CLEAR_KEY) { * await clearCache(cacheHandler); diff --git a/src/types/index.ts b/src/types/index.ts index 154bc7e..b7ca329 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -109,12 +109,12 @@ export interface CacheHandler { * @returns The cached or fetched value */ fetch(key: string, fetcher: () => Promise, options?: CacheFetchOptions): Promise; - + /** * The backend instance used by this handler */ backend: CacheBackend; - + /** * Get the fully qualified key with prefix and version * @param key - The base key to prefix diff --git a/src/utils/keys.ts b/src/utils/keys.ts new file mode 100644 index 0000000..68a8d02 --- /dev/null +++ b/src/utils/keys.ts @@ -0,0 +1,22 @@ +/** + * Utility functions for generating standardized cache keys + * @packageDocumentation + */ + +/** + * Generates a key for the stale version of a cache entry + * @param key The base cache key + * @returns The stale cache key + */ +export function getStaleKey(key: string): string { + return `stale:${key}`; +} + +/** + * Generates a key for the lock of a cache entry + * @param key The base cache key + * @returns The lock cache key + */ +export function getLockKey(key: string): string { + return `lock:${key}`; +} diff --git a/test/backends/index.test.ts b/test/backends/index.test.ts index 22faae3..ac01d7a 100644 --- a/test/backends/index.test.ts +++ b/test/backends/index.test.ts @@ -48,12 +48,12 @@ describe('backends/index', () => { del: vi.fn(), scan: vi.fn(), }; - - const backend = createDefaultBackend({ + + const backend = createDefaultBackend({ redisClient: mockClient as unknown as Redis, - prefix: 'test' + prefix: 'test', }); - + expect(backend).toBeInstanceOf(RedisCacheBackend); }); @@ -78,7 +78,7 @@ describe('backends/index', () => { it('should reuse global Redis client on subsequent calls', () => { const backend1 = createDefaultBackend(); const backend2 = createDefaultBackend(); - + expect(backend1).toBeInstanceOf(RedisCacheBackend); expect(backend2).toBeInstanceOf(RedisCacheBackend); }); @@ -122,4 +122,4 @@ describe('backends/index', () => { expect(typeof RedisCacheBackend).toBe('function'); }); }); -}); \ No newline at end of file +}); diff --git a/test/backends/memory.test.ts b/test/backends/memory.test.ts index 127e7f5..03dd7ca 100644 --- a/test/backends/memory.test.ts +++ b/test/backends/memory.test.ts @@ -30,10 +30,10 @@ describe('MemoryCacheBackend', () => { await backend.set('test-key', 42, { ttl: 0.1 }); // 100ms TTL const value1 = await backend.get('test-key'); expect(value1).toBe(42); - + // Wait for expiration - await new Promise(resolve => setTimeout(resolve, 150)); - + await new Promise((resolve) => setTimeout(resolve, 150)); + const value2 = await backend.get('test-key'); expect(value2).toBeUndefined(); }); @@ -41,26 +41,26 @@ describe('MemoryCacheBackend', () => { it('should acquire and release locks', async () => { const lockAcquired1 = await backend.lock('test-lock', 1); expect(lockAcquired1).toBe(true); - + const lockAcquired2 = await backend.lock('test-lock', 1); expect(lockAcquired2).toBe(false); - + await backend.unlock('test-lock'); - + const lockAcquired3 = await backend.lock('test-lock', 1); expect(lockAcquired3).toBe(true); }); it('should handle lock expiration', async () => { await backend.lock('test-lock', 0.1); // 100ms TTL - + // Try to acquire the same lock immediately const lockAcquired = await backend.lock('test-lock', 1); expect(lockAcquired).toBe(false); - + // Wait for expiration - await new Promise(resolve => setTimeout(resolve, 150)); - + await new Promise((resolve) => setTimeout(resolve, 150)); + // Should be able to acquire the lock now const lockAcquiredAfterExpiry = await backend.lock('test-lock', 1); expect(lockAcquiredAfterExpiry).toBe(true); @@ -70,9 +70,9 @@ describe('MemoryCacheBackend', () => { await backend.set('key1', 1); await backend.set('key2', 2); await backend.lock('lock1', 1); - + await backend.clear(); - + expect(await backend.get('key1')).toBeUndefined(); expect(await backend.get('key2')).toBeUndefined(); expect(await backend.lock('lock1', 1)).toBe(true); // Lock should be cleared @@ -81,15 +81,15 @@ describe('MemoryCacheBackend', () => { it('should cleanup expired entries', async () => { await backend.set('expired-key', 42, { ttl: 0.1 }); await backend.lock('expired-lock', 0.1); - + // Wait for expiration - await new Promise(resolve => setTimeout(resolve, 150)); - + await new Promise((resolve) => setTimeout(resolve, 150)); + // Manually trigger cleanup backend.cleanup(); - + // Should not be able to acquire the expired lock const lockAcquired = await backend.lock('expired-lock', 1); expect(lockAcquired).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/test/backends/redis.test.ts b/test/backends/redis.test.ts index d75e524..668dbe6 100644 --- a/test/backends/redis.test.ts +++ b/test/backends/redis.test.ts @@ -15,11 +15,11 @@ describe('RedisCacheBackend', () => { let backend: RedisCacheBackend; let backendWithPrefix: RedisCacheBackend; - beforeEach(() => { - vi.clearAllMocks(); - backend = new RedisCacheBackend(mockRedisClient as unknown as Redis); - backendWithPrefix = new RedisCacheBackend(mockRedisClient as unknown as Redis, 'test'); - }); + beforeEach(() => { + vi.clearAllMocks(); + backend = new RedisCacheBackend(mockRedisClient as unknown as Redis); + backendWithPrefix = new RedisCacheBackend(mockRedisClient as unknown as Redis, 'test'); + }); describe('constructor', () => { it('should create backend without prefix', () => { @@ -76,7 +76,7 @@ describe('RedisCacheBackend', () => { it('should throw CacheSerializationError when JSON.parse fails', async () => { mockRedisClient.get.mockResolvedValue('invalid-json'); - + // Mock JSON.parse to throw an error const originalParse = JSON.parse; JSON.parse = vi.fn().mockImplementation(() => { @@ -120,7 +120,7 @@ describe('RedisCacheBackend', () => { 'test:test-key', JSON.stringify(testValue), 'EX', - 300 + 300, ); }); @@ -129,7 +129,9 @@ describe('RedisCacheBackend', () => { (circularValue as Record).self = circularValue; await expect(backend.set('test-key', circularValue)).rejects.toThrow(CacheSerializationError); - await expect(backend.set('test-key', circularValue)).rejects.toThrow('Failed to stringify value'); + await expect(backend.set('test-key', circularValue)).rejects.toThrow( + 'Failed to stringify value', + ); }); it('should throw CacheBackendError for Redis errors', async () => { @@ -270,7 +272,9 @@ describe('RedisCacheBackend', () => { describe('clear', () => { it('should throw CacheConfigError when no prefix is set', async () => { await expect(backend.clear()).rejects.toThrow(CacheConfigError); - await expect(backend.clear()).rejects.toThrow('Refusing to clear all keys: prefix is required'); + await expect(backend.clear()).rejects.toThrow( + 'Refusing to clear all keys: prefix is required', + ); }); it('should clear all keys with prefix successfully', async () => { @@ -288,9 +292,7 @@ describe('RedisCacheBackend', () => { }); it('should handle empty scan results', async () => { - mockRedisClient.scan - .mockResolvedValueOnce(['10', []]) - .mockResolvedValueOnce(['0', []]); + mockRedisClient.scan.mockResolvedValueOnce(['10', []]).mockResolvedValueOnce(['0', []]); await backendWithPrefix.clear(); @@ -312,4 +314,4 @@ describe('RedisCacheBackend', () => { await expect(backendWithPrefix.clear()).rejects.toThrow('string error'); }); }); -}); \ No newline at end of file +}); diff --git a/test/cache/createCacheHandler.test.ts b/test/cache/createCacheHandler.test.ts index 7098c97..f8a8283 100644 --- a/test/cache/createCacheHandler.test.ts +++ b/test/cache/createCacheHandler.test.ts @@ -64,15 +64,15 @@ describe('createCacheHandler', () => { await backend.set('test:v1:foo', 42); const result = await handler.fetch('foo', async () => 99); expect(result).toBe(42); - expect(logEvents.some(e => e.type === 'HIT')).toBe(true); + expect(logEvents.some((e) => e.type === 'HIT')).toBe(true); }); it('should fetch, set, and return value on miss, and log MISS and LOCK', async () => { const result = await handler.fetch('bar', async () => 123); expect(result).toBe(123); expect(await backend.get('test:v1:bar')).toBe(123); - expect(logEvents.some(e => e.type === 'MISS')).toBe(true); - expect(logEvents.some(e => e.type === 'LOCK')).toBe(true); + expect(logEvents.some((e) => e.type === 'MISS')).toBe(true); + expect(logEvents.some((e) => e.type === 'LOCK')).toBe(true); }); it('should wait for lock and return value if set by another process', async () => { @@ -84,27 +84,31 @@ describe('createCacheHandler', () => { }, 100); const result = await handler.fetch('baz', async () => 999, { lockTimeout: 1000 }); expect(result).toBe(555); - expect(logEvents.some(e => e.type === 'WAIT')).toBe(true); + expect(logEvents.some((e) => e.type === 'WAIT')).toBe(true); }); it('should throw CacheTimeoutError if lock not released in time', async () => { const lockKey = 'lock:test:v1:locked'; backend.locks.add(lockKey); - await expect(handler.fetch('locked', async () => 1, { lockTimeout: 100 })) - .rejects.toThrow(CacheTimeoutError); - expect(logEvents.some(e => e.type === 'WAIT')).toBe(true); + await expect(handler.fetch('locked', async () => 1, { lockTimeout: 100 })).rejects.toThrow( + CacheTimeoutError, + ); + expect(logEvents.some((e) => e.type === 'WAIT')).toBe(true); }); it('should log ERROR and rethrow if fetcher throws', async () => { - await expect(handler.fetch('err', async () => { throw new Error('fail'); })) - .rejects.toThrow('fail'); - expect(logEvents.some(e => e.type === 'ERROR')).toBe(true); + await expect( + handler.fetch('err', async () => { + throw new Error('fail'); + }), + ).rejects.toThrow('fail'); + expect(logEvents.some((e) => e.type === 'ERROR')).toBe(true); }); // Add tests for stale cache fallback describe('stale cache fallback', () => { let handlerWithFallback: CacheHandler; - + beforeEach(() => { backend = new MemoryBackend(); logEvents = []; @@ -116,28 +120,28 @@ describe('createCacheHandler', () => { logger: { log: (event) => logEvents.push(event) }, }); }); - + it('should save stale copy when staleTtl is provided', async () => { const result = await handlerWithFallback.fetch('stale-test', async () => 'fresh-value', { ttl: 10, staleTtl: 60, }); - + expect(result).toBe('fresh-value'); expect(await backend.get('test:v1:stale-test')).toBe('fresh-value'); expect(await backend.get('stale:test:v1:stale-test')).toBe('fresh-value'); }); - + it('should fall back to stale value when fetcher fails', async () => { // First successful fetch to populate stale cache await handlerWithFallback.fetch('stale-fallback', async () => 'original-value', { ttl: 10, staleTtl: 60, }); - + // Delete the main value but keep the stale copy await backend.del('test:v1:stale-fallback'); - + // Clear L1 cache by creating a new handler const freshHandler = createCacheHandler({ backend, @@ -146,24 +150,28 @@ describe('createCacheHandler', () => { fallbackToStale: true, logger: { log: (event) => logEvents.push(event) }, }); - + // Now fetch again, but make the fetcher fail - const fetcherThatFails = async () => { throw new Error('Fetcher failed'); }; + const fetcherThatFails = async () => { + throw new Error('Fetcher failed'); + }; const result = await freshHandler.fetch('stale-fallback', fetcherThatFails, { staleTtl: 60, }); - + expect(result).toBe('original-value'); - expect(logEvents.some(e => e.type === 'ERROR')).toBe(true); - expect(logEvents.some(e => e.type === 'HIT' && e.key === 'stale:test:v1:stale-fallback')).toBe(true); + expect(logEvents.some((e) => e.type === 'ERROR')).toBe(true); + expect( + logEvents.some((e) => e.type === 'HIT' && e.key === 'stale:test:v1:stale-fallback'), + ).toBe(true); }); - + it('should not save stale copy when staleTtl is less than or equal to ttl', async () => { await handlerWithFallback.fetch('no-stale', async () => 'value', { ttl: 30, staleTtl: 30, // Same as ttl, so no stale copy }); - + expect(await backend.get('stale:test:v1:no-stale')).toBeUndefined(); }); }); @@ -231,30 +239,32 @@ describe('createCacheHandler', () => { it('should throw CacheBackendError when backend.get fails', async () => { errorBackend.shouldThrowOnGet = true; - - await expect(errorHandler.fetch('key', async () => 'value')) - .rejects.toThrow('Failed to get value from cache'); + + await expect(errorHandler.fetch('key', async () => 'value')).rejects.toThrow( + 'Failed to get value from cache', + ); }); it('should throw CacheBackendError when backend.lock fails', async () => { errorBackend.shouldThrowOnLock = true; - - await expect(errorHandler.fetch('key', async () => 'value')) - .rejects.toThrow('Failed to acquire lock'); + + await expect(errorHandler.fetch('key', async () => 'value')).rejects.toThrow( + 'Failed to acquire lock', + ); }); it('should log unlock errors but not throw', async () => { errorBackend.shouldThrowOnUnlock = true; - + const result = await errorHandler.fetch('key', async () => 'value'); expect(result).toBe('value'); - expect(errorLogEvents.some(e => e.type === 'ERROR' && e.key?.includes('lock:'))).toBe(true); + expect(errorLogEvents.some((e) => e.type === 'ERROR' && e.key?.includes('lock:'))).toBe(true); }); it('should continue polling even if get fails during wait', async () => { const lockKey = 'lock:test:v1:polling-key'; errorBackend.locks.add(lockKey); - + let getCallCount = 0; const originalGet = errorBackend.get.bind(errorBackend); errorBackend.get = async (key: string) => { @@ -268,14 +278,14 @@ describe('createCacheHandler', () => { } return originalGet(key); }; - + setTimeout(() => { errorBackend.locks.delete(lockKey); errorBackend.store.set('test:v1:polling-key', 42); }, 150); - - const result = await errorHandler.fetch('polling-key', async () => 'fetcher-value', { - lockTimeout: 1000 + + const result = await errorHandler.fetch('polling-key', async () => 'fetcher-value', { + lockTimeout: 1000, }); expect(result).toBe(42); }); @@ -336,16 +346,16 @@ describe('createCacheHandler', () => { it('should log stale set errors but not throw', async () => { staleErrorBackend.shouldThrowOnStaleSet = true; - + const result = await staleErrorHandler.fetch('stale-set-error', async () => 'value', { ttl: 10, staleTtl: 60, }); - + expect(result).toBe('value'); - expect(staleErrorLogEvents.some(e => - e.type === 'ERROR' && e.key?.includes('stale:') - )).toBe(true); + expect(staleErrorLogEvents.some((e) => e.type === 'ERROR' && e.key?.includes('stale:'))).toBe( + true, + ); }); it('should log stale get errors and continue with original error', async () => { @@ -354,13 +364,13 @@ describe('createCacheHandler', () => { ttl: 10, staleTtl: 60, }); - + // Delete main value await staleErrorBackend.del('test:v1:stale-get-error'); - + // Make stale get fail staleErrorBackend.shouldThrowOnStaleGet = true; - + // Create a fresh handler to avoid L1 cache interference const freshStaleErrorHandler = createCacheHandler({ backend: staleErrorBackend, @@ -369,21 +379,27 @@ describe('createCacheHandler', () => { fallbackToStale: true, logger: { log: (event) => staleErrorLogEvents.push(event) }, }); - + // Fetcher should fail and stale fallback should also fail - await expect(freshStaleErrorHandler.fetch('stale-get-error', async () => { - throw new Error('Fetcher failed'); - }, { staleTtl: 60 })).rejects.toThrow('Fetcher failed'); - - expect(staleErrorLogEvents.some(e => - e.type === 'ERROR' && e.key?.includes('stale:') - )).toBe(true); + await expect( + freshStaleErrorHandler.fetch( + 'stale-get-error', + async () => { + throw new Error('Fetcher failed'); + }, + { staleTtl: 60 }, + ), + ).rejects.toThrow('Fetcher failed'); + + expect(staleErrorLogEvents.some((e) => e.type === 'ERROR' && e.key?.includes('stale:'))).toBe( + true, + ); }); }); describe('edge cases', () => { it('should handle empty prefix and version', () => { - const plainHandler = createCacheHandler({ + const plainHandler = createCacheHandler({ backend, prefix: '', version: '', @@ -392,7 +408,7 @@ describe('createCacheHandler', () => { }); it('should handle only prefix without version', () => { - const prefixOnlyHandler = createCacheHandler({ + const prefixOnlyHandler = createCacheHandler({ backend, prefix: 'myapp', version: '', @@ -401,7 +417,7 @@ describe('createCacheHandler', () => { }); it('should handle only version without prefix', () => { - const versionOnlyHandler = createCacheHandler({ + const versionOnlyHandler = createCacheHandler({ backend, prefix: '', version: 'v2', @@ -425,4 +441,4 @@ describe('createCacheHandler', () => { expect(result).toBe('value'); }); }); -}); \ No newline at end of file +}); diff --git a/test/cache/fetchWithCache.test.ts b/test/cache/fetchWithCache.test.ts index ad6124f..83e1606 100644 --- a/test/cache/fetchWithCache.test.ts +++ b/test/cache/fetchWithCache.test.ts @@ -51,43 +51,55 @@ describe('fetchWithCache', () => { await backend.set('next-cachex:foo', 42); const result = await fetchWithCache('foo', async () => 99, { backend, logger }); expect(result).toBe(42); - expect(logEvents.some(e => e.type === 'HIT')).toBe(true); + expect(logEvents.some((e) => e.type === 'HIT')).toBe(true); }); it('fetches, sets, and returns value on MISS, logs MISS and LOCK', async () => { const result = await fetchWithCache('bar', async () => 123, { backend, logger }); expect(result).toBe(123); expect(await backend.get('next-cachex:bar')).toBe(123); - expect(logEvents.some(e => e.type === 'MISS')).toBe(true); - expect(logEvents.some(e => e.type === 'LOCK')).toBe(true); + expect(logEvents.some((e) => e.type === 'MISS')).toBe(true); + expect(logEvents.some((e) => e.type === 'LOCK')).toBe(true); }); it('waits for lock and returns value if set by another process', async () => { // Simulate lock already taken await backend.lock('lock:next-cachex:baz', 0.2); // Lock for 0.2 seconds - + // Setup another "process" to release the lock and set value setTimeout(() => { backend.unlock('lock:next-cachex:baz'); backend.set('next-cachex:baz', 555); }, 50); - - const result = await fetchWithCache('baz', async () => 999, { backend, logger, lockTimeout: 500 }); + + const result = await fetchWithCache('baz', async () => 999, { + backend, + logger, + lockTimeout: 500, + }); expect(result).toBe(555); - expect(logEvents.some(e => e.type === 'WAIT')).toBe(true); + expect(logEvents.some((e) => e.type === 'WAIT')).toBe(true); }); it('throws CacheTimeoutError if lock not released in time', async () => { await backend.lock('lock:next-cachex:locked', 0.5); // Lock for 0.5 second - await expect(fetchWithCache('locked', async () => 1, { backend, logger, lockTimeout: 100 })) - .rejects.toThrow(CacheTimeoutError); - expect(logEvents.some(e => e.type === 'WAIT')).toBe(true); + await expect( + fetchWithCache('locked', async () => 1, { backend, logger, lockTimeout: 100 }), + ).rejects.toThrow(CacheTimeoutError); + expect(logEvents.some((e) => e.type === 'WAIT')).toBe(true); }); it('logs ERROR and rethrows if fetcher throws', async () => { - await expect(fetchWithCache('err', async () => { throw new Error('fail'); }, { backend, logger })) - .rejects.toThrow('fail'); - expect(logEvents.some(e => e.type === 'ERROR')).toBe(true); + await expect( + fetchWithCache( + 'err', + async () => { + throw new Error('fail'); + }, + { backend, logger }, + ), + ).rejects.toThrow('fail'); + expect(logEvents.some((e) => e.type === 'ERROR')).toBe(true); }); it('uses default handler when no backend is provided in options', async () => { @@ -106,9 +118,9 @@ describe('fetchWithCache', () => { it('uses temporary handler when backend is provided in options', async () => { const customLogger = { log: () => {} }; - const result = await fetchWithCache('temp-handler', async () => 'temp-value', { - backend, - logger: customLogger + const result = await fetchWithCache('temp-handler', async () => 'temp-value', { + backend, + logger: customLogger, }); expect(result).toBe('temp-value'); }); diff --git a/test/errors.test.ts b/test/errors.test.ts index b4bc7f6..7586ebb 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -66,4 +66,4 @@ describe('Error Types', () => { expect(error instanceof CacheConnectionError).toBe(false); } }); -}); \ No newline at end of file +}); diff --git a/test/index.test.ts b/test/index.test.ts index 6ebaeaf..cf9c9b5 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -28,4 +28,4 @@ describe('main exports', () => { expect(registerInitialCache).toBeDefined(); expect(clearCache).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/test/instrumentation/index.test.ts b/test/instrumentation/index.test.ts index 93302c5..600f015 100644 --- a/test/instrumentation/index.test.ts +++ b/test/instrumentation/index.test.ts @@ -56,10 +56,10 @@ describe('Instrumentation', () => { it('should set stale cache copies when staleTtl is provided', async () => { await registerInitialCache(handler, [ - { - key: 'stale-item', - value: 'stale-value', - options: { ttl: 60, staleTtl: 3600 } + { + key: 'stale-item', + value: 'stale-value', + options: { ttl: 60, staleTtl: 3600 }, }, ]); @@ -69,10 +69,10 @@ describe('Instrumentation', () => { it('should not set stale cache when staleTtl <= ttl', async () => { await registerInitialCache(handler, [ - { - key: 'no-stale', - value: 'value', - options: { ttl: 100, staleTtl: 100 } + { + key: 'no-stale', + value: 'value', + options: { ttl: 100, staleTtl: 100 }, }, ]); @@ -82,21 +82,33 @@ describe('Instrumentation', () => { it('should do nothing with empty or invalid items', async () => { const setSpy = vi.spyOn(backend, 'set'); - + await registerInitialCache(handler, []); expect(setSpy).not.toHaveBeenCalled(); - - await registerInitialCache(handler, null as unknown as Array<{ key: string; value: unknown; options?: { ttl?: number; staleTtl?: number } }>); + + await registerInitialCache( + handler, + null as unknown as Array<{ + key: string; + value: unknown; + options?: { ttl?: number; staleTtl?: number }; + }>, + ); expect(setSpy).not.toHaveBeenCalled(); - - await registerInitialCache(handler, undefined as unknown as Array<{ key: string; value: unknown; options?: { ttl?: number; staleTtl?: number } }>); + + await registerInitialCache( + handler, + undefined as unknown as Array<{ + key: string; + value: unknown; + options?: { ttl?: number; staleTtl?: number }; + }>, + ); expect(setSpy).not.toHaveBeenCalled(); }); it('should handle items without options', async () => { - await registerInitialCache(handler, [ - { key: 'no-options', value: 'simple-value' }, - ]); + await registerInitialCache(handler, [{ key: 'no-options', value: 'simple-value' }]); expect(await backend.get('test:no-options')).toBe('simple-value'); }); @@ -124,9 +136,9 @@ describe('Instrumentation', () => { it('should clear the cache using backend.clear', async () => { backend.set('test:item1', 'value1'); backend.set('test:item2', 'value2'); - + await clearCache(handler); - + expect(backend.store.size).toBe(0); }); @@ -137,7 +149,7 @@ describe('Instrumentation', () => { set: backend.set.bind(backend), del: backend.del.bind(backend), lock: backend.lock.bind(backend), - unlock: backend.unlock.bind(backend) + unlock: backend.unlock.bind(backend), // clear method intentionally omitted } as CacheBackend, fetch: vi.fn(), @@ -145,8 +157,8 @@ describe('Instrumentation', () => { }; await expect(clearCache(handlerWithoutClear)).rejects.toThrow( - 'Cache backend does not support the clear operation' + 'Cache backend does not support the clear operation', ); }); }); -}); \ No newline at end of file +}); diff --git a/test/types.test.ts b/test/types.test.ts index f5005e5..37d1615 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -11,7 +11,7 @@ describe('Types', () => { it('should export all required types and interfaces', () => { // This test ensures all types are properly exported // TypeScript will catch any missing exports at compile time - + // Test that we can create type-safe objects const mockBackend: CacheBackend = { get: async () => undefined, @@ -58,10 +58,10 @@ describe('Types', () => { const missEvent: CacheLogEvent = { type: 'MISS', key: 'key2' }; const lockEvent: CacheLogEvent = { type: 'LOCK', key: 'key3' }; const waitEvent: CacheLogEvent = { type: 'WAIT', key: 'key4' }; - const errorEvent: CacheLogEvent = { - type: 'ERROR', - key: 'key5', - error: new Error('test error') + const errorEvent: CacheLogEvent = { + type: 'ERROR', + key: 'key5', + error: new Error('test error'), }; expect(hitEvent.type).toBe('HIT'); @@ -89,4 +89,4 @@ describe('Types', () => { expect(minimalOptions.backend).toBeDefined(); expect(minimalFetchOptions).toBeDefined(); }); -}); \ No newline at end of file +}); From f2c4fde3bb83f93e2c15e992be437024193bf953 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:03:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=B9=20fix:=20Resolve=20ESLint=20wa?= =?UTF-8?q?rnings=20and=20errors=20causing=20CI=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: suranig <24814104+suranig@users.noreply.github.com> --- src/backends/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/backends/index.ts b/src/backends/index.ts index d29f81b..752b3df 100644 --- a/src/backends/index.ts +++ b/src/backends/index.ts @@ -46,20 +46,25 @@ function getGlobalRedisClient(): Redis { globalRedisClient.on('error', (error) => { // Only log connection errors, don't throw unhandled rejections if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console console.warn('Redis connection error:', error.message); } }); globalRedisClient.on('connect', () => { if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console console.log('Redis connected successfully'); } }); + // Capture the client reference to avoid non-null assertion in the closures below + const currentClient = globalRedisClient; + // Handle connection promise - connectionPromise = globalRedisClient + connectionPromise = currentClient .connect() - .then(() => globalRedisClient!) + .then(() => currentClient) .catch((error) => { // Reset the promise on error so it can be retried connectionPromise = null; @@ -69,8 +74,9 @@ function getGlobalRedisClient(): Redis { // In test environment, don't throw unhandled rejections if (process.env.NODE_ENV === 'test') { + // eslint-disable-next-line no-console console.warn('Redis connection failed in test environment:', connectionError.message); - return globalRedisClient!; + return currentClient; } throw connectionError;