Created
November 14, 2025 12:08
-
-
Save phillipharding/03e18b013483f3578c04e18ed40b7f90 to your computer and use it in GitHub Desktop.
Caching Proxy Wrapper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-disable @typescript-eslint/no-explicit-any */ | |
| /** Configuration options for caching behavior in the cache proxy service. | |
| * | |
| * @property {number} [ttlMs] - Time-to-live in milliseconds for cached entries. If not specified, entries may be cached indefinitely. | |
| * @property {string[]} [cacheMethods] - Array of method names that should have their results cached. If not specified, no methods will be cached by default. | |
| * @property {string[]} [invalidateOn] - Array of method names that, when called, should invalidate the cache. Useful for clearing cache after data mutations. | |
| * @property {(method: string, args: any[]) => string} [keyFn] - Custom function to generate cache keys based on the method name and arguments. If not provided, a default key generation strategy will be used. | |
| * @property {(method: string, args: any[]) => string[] | undefined} [deriveIdsFromArgs] - Function to extract entity IDs from method arguments, used for selective cache invalidation. Returns an array of IDs or undefined if no IDs can be derived. | |
| * @property {'memory' | 'redis'} [storage] - Storage backend for the cache. Defaults to 'memory' for in-process caching, or 'redis' for distributed caching. | |
| * @property {string} [namespace] - Namespace prefix for cache keys to prevent collisions when multiple cache instances share the same storage backend. | |
| */ | |
| export interface CacheOptions { | |
| ttlMs?: number; | |
| cacheMethods?: string[]; | |
| invalidateOn?: string[]; | |
| keyFn?: (method: string, args: any[]) => string; | |
| deriveIdsFromArgs?: (method: string, args: any[]) => string[] | undefined; | |
| storage?: 'memory' | 'redis'; | |
| namespace?: string; | |
| } | |
| export interface ICacheService { | |
| /** CacheServiceProxy ~~ manually invalidate the entire cache */ | |
| invalidateCache: () => void; | |
| } | |
| export interface ICacheServiceProxy<T> { | |
| service: T; | |
| cache: ICacheService; | |
| } | |
| type CacheEntry = { value: any; expiresAt: number }; | |
| /** Creates a caching proxy wrapper around a repository type service to automatically cache method results and invalidate cache entries. | |
| * | |
| * @template T - The type of the service to be wrapped, must extend object | |
| * @param {T} target - The service instance to wrap with caching behavior | |
| * @param {CacheOptions} [options={}] - Configuration options for cache behavior | |
| * @param {number} [options.ttlMs=60000] - Time-to-live in milliseconds for cached entries (default: 60 seconds) | |
| * @param {string[]} [options.cacheMethods=[]] - Array of method names that should have their results cached | |
| * @param {string[]} [options.invalidateOn=[]] - Array of method names that should invalidate the cache when called | |
| * @param {function} [options.keyFn] - Function to generate cache keys from method name and arguments. Default: `${method}:${JSON.stringify(args)}` | |
| * @param {function} [options.deriveIdsFromArgs] - Optional function to extract entity IDs from method arguments for targeted cache invalidation | |
| * @param {string} [options.namespace='repo-cache'] - Namespace prefix for cache keys to avoid collisions | |
| * | |
| * @returns {T} A proxy object with the same interface as the target, enhanced with caching capabilities | |
| * | |
| * @remarks | |
| * - Cached methods will return cached values if available and not expired | |
| * - Duplicate concurrent requests to cached methods are deduplicated using a pending promises map | |
| * - Methods in `invalidateOn` will invalidate specific cache entries if IDs are provided, or clear the entire cache otherwise | |
| * - Cache entries are automatically expired after `ttlMs` milliseconds | |
| * - Entity ID tracking allows for targeted cache invalidation when entities are modified | |
| * | |
| * @example | |
| * ```typescript | |
| * const cachedRepo = withServiceCache(repository, { | |
| * ttlMs: 300000, // 5 minutes | |
| * cacheMethods: ['getById', 'findAll'], | |
| * invalidateOn: ['update', 'delete'], | |
| * deriveIdsFromArgs: (method, args) => method === 'getById' ? [args[0]] : undefined | |
| * }); | |
| * ``` | |
| */ | |
| export function withServiceCache<T extends object>(target: T, options: CacheOptions = {}): ICacheServiceProxy<T> { | |
| const { | |
| ttlMs = 60_000, | |
| cacheMethods = [], | |
| invalidateOn = [], | |
| keyFn = (m, args) => `${m}:${JSON.stringify(args)}`, | |
| deriveIdsFromArgs, | |
| /* storage = 'memory', */ | |
| namespace = 'repo-cache', | |
| } = options; | |
| const memCache = new Map<string, CacheEntry>(); | |
| const pending = new Map<string, Promise<any>>(); | |
| const idToKeys = new Map<string, Set<string>>(); // map entityId → cache keys | |
| /* helpers */ | |
| async function getEntry(key: string): Promise<CacheEntry> { | |
| const e = memCache.get(key); | |
| if (!e || e.expiresAt < Date.now()) { | |
| memCache.delete(key); | |
| return null; | |
| } | |
| return e.value; | |
| } | |
| async function setEntry(key: string, value: any, ids?: string[]): Promise<void> { | |
| const expiresAt = Date.now() + ttlMs; | |
| memCache.set(key, { value, expiresAt }); | |
| if (ids?.length) { | |
| for (const id of ids) { | |
| if (!idToKeys.has(id)) idToKeys.set(id, new Set()); | |
| idToKeys.get(id)!.add(key); | |
| } | |
| } | |
| } | |
| async function invalidateByIds(ids: string[]): Promise<void> { | |
| if (!ids?.length) return; | |
| for (const id of ids) { | |
| const keys = idToKeys.get(id); | |
| if (!keys) continue; | |
| for (const key of keys) memCache.delete(key); | |
| idToKeys.delete(id); | |
| } | |
| } | |
| /* the proxy handler */ | |
| const handler: ProxyHandler<T> = { | |
| get(targetObj, prop, receiver) { | |
| const orig = Reflect.get(targetObj, prop, receiver); | |
| if (typeof orig !== 'function') return orig; | |
| const method = prop as string; | |
| /* caching read methods */ | |
| if (cacheMethods.includes(method)) { | |
| return async (...args: any[]) => { | |
| const key = `${namespace}:${keyFn(method, args)}`; | |
| const cached = await getEntry(key); | |
| if (cached !== null && cached !== undefined) return cached; | |
| if (pending.has(key)) return pending.get(key)!; | |
| const p = (async () => { | |
| try { | |
| const result = await orig.apply(targetObj, args); | |
| const ids = deriveIdsFromArgs?.(method, args); | |
| await setEntry(key, result, ids); | |
| return result; | |
| } finally { | |
| pending.delete(key); | |
| } | |
| })(); | |
| pending.set(key, p); | |
| return p; | |
| }; | |
| } | |
| /* mutator methods that should invalidate the cache */ | |
| if (invalidateOn.includes(method)) { | |
| return async (...args: any[]) => { | |
| const result = await orig.apply(targetObj, args); | |
| const ids = deriveIdsFromArgs?.(method, args); | |
| if (ids?.length) { | |
| await invalidateByIds(ids); | |
| } else { | |
| /* full clear if IDs are not known */ | |
| memCache.clear(); | |
| idToKeys.clear(); | |
| } | |
| return result; | |
| }; | |
| } | |
| return orig.bind(targetObj); | |
| }, | |
| }; | |
| return { | |
| service: new Proxy<T>(target, handler), | |
| cache: { | |
| invalidateCache: () => { | |
| memCache.clear(); | |
| idToKeys.clear(); | |
| }, | |
| }, | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment