From e3fb326f0c88d3602a6f0d2e6f77573373bdb7fc Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Tue, 14 Jul 2026 23:55:09 -0400 Subject: [PATCH] refactor: pf-4387 enhance memo, clear, keys, getKeys --- src/__tests__/patternFly.helpers.test.ts | 2 +- src/__tests__/server.caching.test.ts | 135 ++++++++++++++ src/__tests__/server.getResources.test.ts | 14 +- src/server.caching.ts | 207 +++++++++++++++++++--- 4 files changed, 325 insertions(+), 33 deletions(-) diff --git a/src/__tests__/patternFly.helpers.test.ts b/src/__tests__/patternFly.helpers.test.ts index 2ddc3168..0905c421 100644 --- a/src/__tests__/patternFly.helpers.test.ts +++ b/src/__tests__/patternFly.helpers.test.ts @@ -14,7 +14,7 @@ jest.mock('../server.getResources', () => ({ } })); -const mockReadLocalFile = readLocalFileFunction.memo as jest.Mock; +const mockReadLocalFile = readLocalFileFunction.memo as any as jest.Mock; describe('findClosestPatternFlyVersion', () => { it('should provide a temporary closest version that always returns the latest version', async () => { diff --git a/src/__tests__/server.caching.test.ts b/src/__tests__/server.caching.test.ts index 22292c7b..a43f9445 100644 --- a/src/__tests__/server.caching.test.ts +++ b/src/__tests__/server.caching.test.ts @@ -362,4 +362,139 @@ describe('memo', () => { await expect(updateLog(log)).resolves.toMatchSnapshot('sync'); await expect(updateLog(logAsync)).resolves.toMatchSnapshot('async'); }); + + it.each([ + { + description: 'clear all entries', + setup: (mem: any) => { + mem(1); + mem(2); + mem(3); + }, + action: (mem: any) => mem.clear(), + expectedResult: true, + expectedRemainingCount: 0 + }, + { + description: 'clear specific key', + setup: (mem: any) => { + mem(1); + mem(2); + }, + action: (mem: any) => { + const key = mem.getKey(1); + + return mem.clear(key); + }, + expectedResult: true, + expectedRemainingCount: 1 + }, + { + description: 'clear non-existent key', + setup: (mem: any) => { mem(1); }, + action: (mem: any) => mem.clear('non-existent'), + expectedResult: false, + expectedRemainingCount: 1 + } + ])('should handle clear() without callback, $description', ({ setup, action, expectedResult, expectedRemainingCount }) => { + const memoized = memo((val: number) => val + 1, { cacheLimit: 10 }); + + setup(memoized); + + const result = action(memoized); + + expect(result).toBe(expectedResult); + expect(memoized.keys()).toHaveLength(expectedRemainingCount); + }); + + it.each([ + { + description: 'clear all entries', + setup: (mem: any) => { + mem(7); + mem(2); + mem(9); + }, + action: (mem: any) => mem.clear(), + expectedResult: true, + expectedRemainingCount: 0, + expectedRemoved: [8, 3, 10] + }, + { + description: 'clear specific key', + setup: (mem: any) => { + mem(10); + mem(2); + }, + action: (mem: any) => { + const key = mem.getKey(10); + + return mem.clear(key); + }, + expectedResult: true, + expectedRemainingCount: 1, + expectedRemoved: [11] + } + ])('should fire onCacheClear callback, $description', ({ setup, action, expectedResult, expectedRemainingCount, expectedRemoved }) => { + const spy = jest.fn(); + const memoized = memo((val: number) => val + 1, { cacheLimit: 10, onCacheClear: spy }); + + setup(memoized); + const result = action(memoized); + + expect(result).toBe(expectedResult); + expect(memoized.keys()).toHaveLength(expectedRemainingCount); + + expect(spy).toHaveBeenCalledTimes(1); + const payload = spy.mock.calls[0][0]; + + expect(payload.removed).toEqual(expect.arrayContaining(expectedRemoved)); + expect(payload.removed).toHaveLength(expectedRemoved.length); + expect(payload.remaining).toHaveLength(expectedRemainingCount); + }); + + it.each([ + { + description: 'non-existent key', + setup: (mem: any) => { mem(1); }, + action: (mem: any) => mem.clear('non-existent'), + expectedResult: false, + expectedRemainingCount: 1 + } + ])('should NOT fire onCacheClear callback, $description', ({ setup, action, expectedResult, expectedRemainingCount }) => { + const spy = jest.fn(); + const memoized = memo((val: number) => val + 1, { cacheLimit: 10, onCacheClear: spy }); + + setup(memoized); + const result = action(memoized); + + expect(result).toBe(expectedResult); + expect(memoized.keys()).toHaveLength(expectedRemainingCount); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should return consistent structure from keys()', () => { + const memoized = memo((val: number) => val + 1, { cacheLimit: 10 }); + + memoized(1); + const keys = memoized.keys(); + + expect(keys).toHaveLength(1); + expect(keys[0]).toEqual({ + key: expect.any(String), + value: 2, + index: 0 + }); + }); + + it('should return key from getKey() when found', () => { + const memoized = memo((val: number) => val + 1); + + memoized(1); + const keys = memoized.keys(); + const key = keys[0]?.key; + + expect(memoized.getKey(1)).toBe(key); + expect(memoized.getKey(2)).toBeUndefined(); + }); }); diff --git a/src/__tests__/server.getResources.test.ts b/src/__tests__/server.getResources.test.ts index 645d46ad..add2d456 100644 --- a/src/__tests__/server.getResources.test.ts +++ b/src/__tests__/server.getResources.test.ts @@ -308,8 +308,8 @@ describe('loadFileFetch', () => { expectedIsFetch: false } ])('should attempt to load a file or fetch, $description', async ({ pathUrl, expectedIsFetch }) => { - const mockFetchCall = jest.fn().mockResolvedValue('content'); - const mockReadCall = jest.fn().mockResolvedValue('content'); + const mockFetchCall = jest.fn().mockResolvedValue('content') as any; + const mockReadCall = jest.fn().mockResolvedValue('content') as any; readLocalFileFunction.memo = mockReadCall; fetchUrlFunction.memo = mockFetchCall; @@ -332,8 +332,8 @@ describe('promiseQueue', () => { }); it('should execute promises in order', async () => { - readLocalFileFunction.memo = jest.fn().mockImplementation(path => Promise.resolve(path)); - fetchUrlFunction.memo = jest.fn().mockImplementation(url => Promise.reject(url)); + readLocalFileFunction.memo = jest.fn().mockImplementation(path => Promise.resolve(path)) as any; + fetchUrlFunction.memo = jest.fn().mockImplementation(url => Promise.reject(url)) as any; const pathUrlQueue = ['dolor-sit.md', 'https://example.com/remote.md', 'lorem-ipsum.md']; @@ -346,8 +346,8 @@ describe('processDocsFunction', () => { jest.clearAllMocks(); // Mock the memo functions - readLocalFileFunction.memo = jest.fn().mockResolvedValue('local file content'); - fetchUrlFunction.memo = jest.fn().mockResolvedValue('fetched content'); + readLocalFileFunction.memo = jest.fn().mockResolvedValue('local file content') as any; + fetchUrlFunction.memo = jest.fn().mockResolvedValue('fetched content') as any; }); it.each([ @@ -438,7 +438,7 @@ describe('processDocsFunction', () => { // Mock one success and one failure readLocalFileFunction.memo = jest.fn() .mockResolvedValueOnce('success content') - .mockRejectedValueOnce(new Error('File not found')); + .mockRejectedValueOnce(new Error('File not found')) as any; const inputs = [ 'good-file.md', diff --git a/src/server.caching.ts b/src/server.caching.ts index 267bf42d..c837f10c 100644 --- a/src/server.caching.ts +++ b/src/server.caching.ts @@ -57,6 +57,7 @@ type MemoDebugHandler = (info: { type: string; value: unknown * @property {MemoDebugHandler} [debug] Debug callback function * @property [expire] Expandable milliseconds until cache expires * @property [keyHash] Function to generate a predictable hash key from the provided arguments. Defaults to internal `generateHash`. + * @property {OnMemoCacheHandler} [onCacheClear] Callback when cache entries are cleared. * @property {OnMemoCacheHandler} [onCacheExpire] Callback when cache expires. Only fires when the `expire` option is set. * @property {OnMemoCacheHandler} [onCacheRollout] Callback when cache entries are rolled off due to cache limit. */ @@ -66,10 +67,24 @@ interface MemoOptions { debug?: MemoDebugHandler; expire?: number; keyHash?: (args: Readonly, ..._forbidRest: never[]) => unknown; + onCacheClear?: OnMemoCacheHandler; onCacheExpire?: OnMemoCacheHandler; onCacheRollout?: OnMemoCacheHandler; } +/** + * Return type of `memoize`. + * + * @property clear Clear the cache or clear a specific key. + * @property getKey Generate a cache key from the provided arguments. Useful for passing to `clear`. + * @property keys Return all active cache entries. Useful for debugging and passing to `clear`. + */ +type MemoReturn = ((...args: TArgs) => TReturn) & { + clear: (key?: string | undefined) => boolean; + getKey: (...args: TArgs) => string | undefined; + keys: () => { key: string; value: TReturn; index: number }[]; +}; + /** * Simple argument-based memoize with adjustable cache limit, and extendable cache expire. * apidoc-mock: https://github.com/cdcabrera/apidoc-mock.git @@ -96,12 +111,15 @@ const memo = ( debug = () => {}, expire, keyHash = generateHash, + onCacheClear, onCacheExpire, onCacheRollout }: MemoOptions = {} -): (...args: TArgs) => TReturn => { +): MemoReturn => { const isCacheErrors = Boolean(cacheErrors); const isFuncPromise = isPromise(func); + const isOnCacheClearPromise = isPromise(onCacheClear); + const isOnCacheClear = typeof onCacheClear === 'function' || isOnCacheClearPromise; const isOnCacheExpirePromise = isPromise(onCacheExpire); const isOnCacheExpire = typeof onCacheExpire === 'function' || isOnCacheExpirePromise; const isOnCacheRolloutPromise = isPromise(onCacheRollout); @@ -111,11 +129,45 @@ const memo = ( return keyHash.call(null, value); }; + /** + * Notify callback handlers. + * + * @template TReturn - See {@link MemoCache} + * @param params - Passed parameters. + * @param {MemoCache} params.all - The current state of the entire memoized cache. + * @param {MemoCache} params.remaining - A subset of items that have not been removed from the cache. + * @param {MemoCache} params.removed - A subset of items that have been removed from the cache. + * @param {OnMemoCacheHandler|undefined} params.handler - See {@link OnMemoCacheHandler} + */ + const notifyCacheChange = ({ + all, remaining, removed, handler + }: { all: MemoCache; remaining: MemoCache; removed: MemoCache; handler: OnMemoCacheHandler | undefined; }) => { + if (!handler) { + return; + } + + const payload: MemoCacheHandlerResponse = { + all, + remaining, + removed + }; + + if (isPromise(handler)) { + Promise.resolve(handler(payload)).catch(error => log.error('Memoized handler error', error)); + } else { + try { + handler(payload); + } catch (error) { + log.error('Memoized function error', error); + } + } + }; + const ized = function () { const cache: MemoCache = []; let timeout: NodeJS.Timeout | undefined; - return (...args: TArgs): TReturn => { + const memoized = (...args: TArgs): TReturn => { const isMemo = cacheLimit > 0; if (typeof updatedExpire === 'number') { @@ -131,17 +183,12 @@ const memo = ( } }); - const cacheEntries = { remaining: [], removed: allCacheEntries, all: allCacheEntries }; - - if (isOnCacheExpirePromise) { - Promise.resolve(onCacheExpire?.(cacheEntries)).catch(error => log.error('onCacheExpire handler error', error)); - } else { - try { - onCacheExpire?.(cacheEntries); - } catch (error) { - log.error('Memoized function error (uncached)', error); - } - } + notifyCacheChange({ + all: allCacheEntries, + remaining: [], + removed: allCacheEntries, + handler: onCacheExpire + }); } cache.length = 0; @@ -214,17 +261,13 @@ const memo = ( if (removedCacheEntries.length > 0) { const remainingCacheEntries = allCacheEntries.slice(0, cacheLimit); - const cacheEntries = { remaining: remainingCacheEntries, removed: removedCacheEntries, all: allCacheEntries }; - - if (isOnCacheRolloutPromise) { - Promise.resolve(onCacheRollout?.(cacheEntries)).catch(error => log.error('onCacheRollout handler error', error)); - } else { - try { - onCacheRollout?.(cacheEntries); - } catch (error) { - log.error('Memoized function error (rolled out)', error); - } - } + + notifyCacheChange({ + all: allCacheEntries, + remaining: remainingCacheEntries, + removed: removedCacheEntries, + handler: onCacheRollout + }); } } @@ -258,9 +301,123 @@ const memo = ( return cachedValue; }; + + /** + * Clear the memoized cache or specific keys + * + * @param key + * @returns A `boolean` indicating if the cache, or cache item, was cleared. + */ + memoized.clear = (key?: string | undefined) => { + let keyIndex: number | undefined; + + if (typeof key === 'string') { + keyIndex = cache.indexOf(key); + + if (keyIndex < 0) { + return false; + } + } + + const allBefore = [...cache]; + + if (keyIndex !== undefined) { + cache.splice(keyIndex, 2); + } else { + cache.length = 0; + } + + if (isOnCacheClear) { + const all: MemoCache = []; + const remaining: MemoCache = []; + const removed: MemoCache = []; + + allBefore.forEach((entry, index) => { + if (index % 2 !== 0 || entry === undefined) { + return; + } + + const value = allBefore[index + 1]; + + all.push(value); + + if (keyIndex === undefined) { + removed.push(value); + } else if (index === keyIndex) { + removed.push(value); + } else { + remaining.push(value); + } + }); + + notifyCacheChange({ all, remaining, removed, handler: onCacheClear }); + } + + return true; + }; + + /** + * Returns the key used to memoize the function. Pass in the same parameters + * used to initialize the memoized function and receive the key. + * + * @note The behavior + * - returns `undefined` if the key is not found instead of attempting to + * just return an assumed key. + * - passing in an empty, `undefined` or `null` value will attempt to parse. + * + * @param args + * @returns The key used to memoize the function with specific arguments or `undefined`. + */ + memoized.getKey = (...args: TArgs) => { + const key = setKey(args) as string; + const keyIndex = cache.indexOf(key); + + if (keyIndex > -1) { + return key; + } + + return undefined; + }; + + /** + * Returns cache keys. Used to clear cache items. + * + * @note In the future we should consider expanding this to return an object + * with the key, value, index, and possibly a timestamp. + * + * @returns An array of cache entries. + * - `key`: Cache key. + * - `value`: Cache value. + * - `index`: Cache index. + */ + memoized.keys = () => { + const result: { key: string; value: TReturn; index: number }[] = []; + + cache.forEach((entry, index) => { + if (index % 2 === 0) { + result.push({ + key: entry, + value: cache[index + 1], + index: index / 2 + }); + } + }); + + return result; + }; + + return memoized; }; return ized(); }; -export { memo, type MemoCacheHandlerResponse, type MemoDebugHandler, type MemoCache, type OnMemoCacheHandler, type MemoOptions }; +export { + memo, + type MemoCacheHandlerResponse, + type MemoDebugHandler, + type MemoCache, + type OnMemoCacheHandler, + type MemoOptions, + type MemoReturn +};