150 lines
4.1 KiB
TypeScript
150 lines
4.1 KiB
TypeScript
|
|
export type CacheKey = readonly string[];
|
||
|
|
|
||
|
|
interface CacheEntry<T> {
|
||
|
|
data?: T;
|
||
|
|
error?: unknown;
|
||
|
|
updatedAt: number;
|
||
|
|
expiresAt: number;
|
||
|
|
promise?: Promise<T>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface QueryOptions<T> {
|
||
|
|
key: CacheKey;
|
||
|
|
ttlMs: number;
|
||
|
|
fetcher: () => Promise<T>;
|
||
|
|
staleWhileRevalidate?: boolean;
|
||
|
|
force?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
type Listener = () => void;
|
||
|
|
|
||
|
|
const cache = new Map<string, CacheEntry<unknown>>();
|
||
|
|
const listeners = new Map<string, Set<Listener>>();
|
||
|
|
|
||
|
|
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<T>(serializedKey: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
|
||
|
|
const now = Date.now();
|
||
|
|
const existing = cache.get(serializedKey) as CacheEntry<T> | 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<T>({
|
||
|
|
key,
|
||
|
|
ttlMs,
|
||
|
|
fetcher,
|
||
|
|
staleWhileRevalidate = true,
|
||
|
|
force = false,
|
||
|
|
}: QueryOptions<T>): Promise<T> {
|
||
|
|
const serializedKey = keyToString(key);
|
||
|
|
const entry = cache.get(serializedKey) as CacheEntry<T> | 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<T>(options: QueryOptions<T>): void {
|
||
|
|
void query(options).catch((error) => {
|
||
|
|
console.debug('[data-client] Prefetch failed', error);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export function peek<T>(key: CacheKey): T | undefined {
|
||
|
|
return (cache.get(keyToString(key)) as CacheEntry<T> | undefined)?.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getEntry<T>(key: CacheKey): CacheEntry<T> | undefined {
|
||
|
|
return cache.get(keyToString(key)) as CacheEntry<T> | undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function set<T>(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<Listener>();
|
||
|
|
keyListeners.add(listener);
|
||
|
|
listeners.set(serializedKey, keyListeners);
|
||
|
|
return () => {
|
||
|
|
keyListeners.delete(listener);
|
||
|
|
if (keyListeners.size === 0) listeners.delete(serializedKey);
|
||
|
|
};
|
||
|
|
}
|