export type CacheKey = readonly string[]; interface CacheEntry { data?: T; error?: unknown; updatedAt: number; expiresAt: number; promise?: Promise; } export interface QueryOptions { key: CacheKey; ttlMs: number; fetcher: () => Promise; staleWhileRevalidate?: boolean; force?: boolean; } type Listener = () => void; const cache = new Map>(); const listeners = new Map>(); function keyToString(key: CacheKey): string { return JSON.stringify(key); } function isPrefix(key: CacheKey, prefix: CacheKey): boolean { return prefix.every((part, index) => key[index] === part); } function notify(serializedKey: string): void { listeners.get(serializedKey)?.forEach((listener) => listener()); } function notifyPrefix(prefix: CacheKey): void { for (const serializedKey of listeners.keys()) { const parsedKey = JSON.parse(serializedKey) as string[]; if (isPrefix(parsedKey, prefix)) notify(serializedKey); } } function startFetch(serializedKey: string, ttlMs: number, fetcher: () => Promise): Promise { const now = Date.now(); const existing = cache.get(serializedKey) as CacheEntry | undefined; const promise = fetcher() .then((data) => { cache.set(serializedKey, { data, updatedAt: Date.now(), expiresAt: Date.now() + ttlMs, }); notify(serializedKey); return data; }) .catch((error) => { cache.set(serializedKey, { data: existing?.data, error, updatedAt: existing?.updatedAt ?? now, expiresAt: existing?.data === undefined ? now : existing.expiresAt, }); notify(serializedKey); throw error; }); cache.set(serializedKey, { ...existing, updatedAt: existing?.updatedAt ?? now, expiresAt: existing?.expiresAt ?? now, promise, }); notify(serializedKey); return promise; } export function query({ key, ttlMs, fetcher, staleWhileRevalidate = true, force = false, }: QueryOptions): Promise { const serializedKey = keyToString(key); const entry = cache.get(serializedKey) as CacheEntry | undefined; const now = Date.now(); if (!force && entry?.promise) return entry.promise; if (!force && entry?.data !== undefined && entry.expiresAt > now) return Promise.resolve(entry.data); if (!force && staleWhileRevalidate && entry?.data !== undefined) { void startFetch(serializedKey, ttlMs, fetcher).catch((error) => { console.debug('[data-client] Background refresh failed', error); }); return Promise.resolve(entry.data); } return startFetch(serializedKey, ttlMs, fetcher); } export function prefetch(options: QueryOptions): void { void query(options).catch((error) => { console.debug('[data-client] Prefetch failed', error); }); } export function peek(key: CacheKey): T | undefined { return (cache.get(keyToString(key)) as CacheEntry | undefined)?.data; } export function getEntry(key: CacheKey): CacheEntry | undefined { return cache.get(keyToString(key)) as CacheEntry | undefined; } export function set(key: CacheKey, data: T, ttlMs: number): void { cache.set(keyToString(key), { data, updatedAt: Date.now(), expiresAt: Date.now() + ttlMs, }); notify(keyToString(key)); } export function invalidate(prefix: CacheKey): void { for (const serializedKey of Array.from(cache.keys())) { const parsedKey = JSON.parse(serializedKey) as string[]; if (isPrefix(parsedKey, prefix)) { cache.delete(serializedKey); notify(serializedKey); } } notifyPrefix(prefix); } export function clearAll(): void { cache.clear(); for (const serializedKey of listeners.keys()) notify(serializedKey); } export function subscribe(key: CacheKey, listener: Listener): () => void { const serializedKey = keyToString(key); const keyListeners = listeners.get(serializedKey) ?? new Set(); keyListeners.add(listener); listeners.set(serializedKey, keyListeners); return () => { keyListeners.delete(listener); if (keyListeners.size === 0) listeners.delete(serializedKey); }; }