import type { CacheStore, KeyResolver, } from "../cache/cache.js"; import { assertMethodDecorator, isDecoratorCall, } from "../common/decorators.js"; import type { AsyncMethod } from "../common/types.js"; import { isWeakMapKey, resolveCallable, } from "../common/utils.js"; export interface AsyncCacheStore { set: (key: string, value: Value) => Promise; get: (key: string) => Promise; delete: (key: string) => Promise; has: (key: string) => Promise; } export interface AsyncCacheConfig { store?: CacheStore | AsyncCacheStore; keyResolver?: KeyResolver | keyof This; ttlMs?: number; } type CacheAsyncDecorator = ( value: AsyncMethod, context: ClassMethodDecoratorContext>, ) => AsyncMethod; type AsyncCacheStoreLike = CacheStore | AsyncCacheStore; function resolveCacheKey( instance: This, keyResolver: KeyResolver | keyof This | undefined, args: Args, ): string { if (keyResolver === undefined) { return JSON.stringify(args); } return resolveCallable(instance, keyResolver)(...args); } function normalizeCacheAsyncInput( input?: AsyncCacheConfig | number, ): AsyncCacheConfig { if (typeof input === "number") { return { ttlMs: input, }; } return input ?? {}; } function scheduleAsyncExpiration(store: AsyncCacheStoreLike, key: string, ttlMs: number): void { setTimeout(() => { void Promise.resolve(store.delete(key)).catch(() => { return undefined; }); }, ttlMs); } async function storeHas(store: AsyncCacheStoreLike, key: string): Promise { return Promise.resolve(store.has(key)); } async function storeGet(store: AsyncCacheStoreLike, key: string): Promise { return Promise.resolve(store.get(key)); } async function storeSet(store: AsyncCacheStoreLike, key: string, value: Value): Promise { await Promise.resolve(store.set(key, value)); } export function createCachedAsyncMethod( originalMethod: AsyncMethod, input?: AsyncCacheConfig | number, ): AsyncMethod { const resolvedConfig = normalizeCacheAsyncInput(input); const storesByInstance = new WeakMap>(); const pendingByInstance = new WeakMap>>(); const fallbackStore: AsyncCacheStoreLike = resolvedConfig.store ?? new Map(); const fallbackPending = new Map>(); const getStore = (instance: This): AsyncCacheStoreLike => { if (resolvedConfig.store !== undefined) { return resolvedConfig.store; } if (!isWeakMapKey(instance)) { return fallbackStore; } const instanceKey = instance as object; const existingStore = storesByInstance.get(instanceKey); if (existingStore !== undefined) { return existingStore; } const store = new Map(); storesByInstance.set(instanceKey, store); return store; }; const getPending = (instance: This): Map> => { if (!isWeakMapKey(instance)) { return fallbackPending; } const instanceKey = instance as object; const existingPending = pendingByInstance.get(instanceKey); if (existingPending !== undefined) { return existingPending; } const pending = new Map>(); pendingByInstance.set(instanceKey, pending); return pending; }; return async function(this: This, ...args: Args): Promise { const store = getStore(this); const pending = getPending(this); const key = resolveCacheKey(this, resolvedConfig.keyResolver, args); const pendingPromise = pending.get(key); if (pendingPromise !== undefined) { return pendingPromise; } const promise = (async (): Promise => { const inStore = await storeHas(store, key); if (inStore) { return await storeGet(store, key) as Return; } const data = await originalMethod.apply(this, args); await storeSet(store, key, data); if (resolvedConfig.ttlMs !== undefined) { scheduleAsyncExpiration(store, key, resolvedConfig.ttlMs); } return data; })().finally(() => { pending.delete(key); }); pending.set(key, promise); return promise; }; } export function cacheAsync( value: AsyncMethod, context: ClassMethodDecoratorContext>, ): AsyncMethod; export function cacheAsync( input?: AsyncCacheConfig | number, ): CacheAsyncDecorator; export function cacheAsync(inputOrValue?: unknown, context?: unknown): unknown { const decorate = ( value: AsyncMethod, decoratorContext: ClassMethodDecoratorContext>, input?: AsyncCacheConfig | number, ): AsyncMethod => { assertMethodDecorator("cacheAsync", value, decoratorContext); return createCachedAsyncMethod(value, input as AsyncCacheConfig | number); }; if (isDecoratorCall(context)) { return decorate( inputOrValue as AsyncMethod, context as ClassMethodDecoratorContext>, ); } return ( value: AsyncMethod, decoratorContext: ClassMethodDecoratorContext>, ): AsyncMethod => { return decorate(value, decoratorContext, inputOrValue as AsyncCacheConfig | number | undefined); }; }