Files
freno-dev/src/server/cache.ts
Michael Freno 898c891bd5 chore: remediate pygienium audit findings
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars,
cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and
12 unused dependencies removed
Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose
blocks tightened; dead commented-out config removed
Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10);
login formHandler split into 3 submitters (CCN 63->~5); account page render
split into 8 section components (CCN 40); updatePost SQL builder rebuilt;
assert*Owned consolidated behind generic assertOwnedBy
Defensive guards: 4 redundant rethrow/nullish guards removed
2026-08-11 13:36:18 -04:00

126 lines
3.1 KiB
TypeScript

/**
* In-memory cache with TTL
*
* Redis was replaced because on a low-traffic site the cache TTL almost always
* expires between visits, so every request paid Redis connection + round-trip
* overhead with no benefit. A module-level Map has zero network latency:
* cache hits are a single dictionary lookup, misses fall through immediately.
*/
import { CACHE_CONFIG } from "~/config";
interface CacheEntry<T> {
data: T;
/** Absolute timestamp (ms) after which this entry is considered stale */
expiresAt: number;
/** Absolute timestamp (ms) after which stale fallback is also discarded */
staleExpiresAt: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = new Map<string, CacheEntry<any>>();
export const cache = {
get<T>(key: string): T | null {
const entry = store.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (Date.now() > entry.expiresAt) return null;
return entry.data;
},
set<T>(key: string, data: T, ttlMs: number): void {
const existing = store.get(key);
store.set(key, {
data,
expiresAt: Date.now() + ttlMs,
// Preserve an existing stale expiry if it's longer, otherwise default
staleExpiresAt:
existing?.staleExpiresAt ?? Date.now() + CACHE_CONFIG.MAX_STALE_DATA_MS
});
},
delete(key: string): void {
store.delete(key);
},
deleteByPrefix(prefix: string): void {
for (const key of store.keys()) {
if (key.startsWith(prefix)) store.delete(key);
}
},
clear(): void {
store.clear();
},
has(key: string): boolean {
const entry = store.get(key);
if (!entry) return false;
return Date.now() <= entry.expiresAt;
}
};
/**
* Execute function with in-memory caching.
*/
export async function withCache<T>(
key: string,
ttlMs: number,
fn: () => Promise<T>
): Promise<T> {
const cached = cache.get<T>(key);
if (cached !== null) return cached;
const result = await fn();
cache.set(key, result, ttlMs);
return result;
}
/**
* Execute function with caching and stale-data fallback.
*
* Strategy:
* 1. Return data if fresh (within TTL).
* 2. Otherwise run fn().
* 3. If fn() throws, return stale data if still within maxStaleMs.
* 4. Store fresh result for future requests.
*/
export async function withCacheAndStale<T>(
key: string,
ttlMs: number,
fn: () => Promise<T>,
options: {
maxStaleMs?: number;
logErrors?: boolean;
} = {}
): Promise<T> {
const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } =
options;
const now = Date.now();
const entry = store.get(key) as CacheEntry<T> | undefined;
if (entry && entry.expiresAt > now) return entry.data;
try {
const result = await fn();
store.set(key, {
data: result,
expiresAt: now + ttlMs,
staleExpiresAt: now + maxStaleMs
});
return result;
} catch (error) {
if (logErrors) {
console.error(`Error fetching data for cache key "${key}":`, error);
}
if (entry && entry.staleExpiresAt > now) {
if (logErrors) console.log(`Serving stale data for cache key "${key}"`);
return entry.data;
}
throw error;
}
}