Skip to content

Instantly share code, notes, and snippets.

@phillipharding
Created November 14, 2025 12:08
Show Gist options
  • Select an option

  • Save phillipharding/03e18b013483f3578c04e18ed40b7f90 to your computer and use it in GitHub Desktop.

Select an option

Save phillipharding/03e18b013483f3578c04e18ed40b7f90 to your computer and use it in GitHub Desktop.
Caching Proxy Wrapper
/* 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