/** * v1.4.34 IW-G — in-process LRU + single-flight server cache. * * Generalises the v1.4.33 Coach snapshot cache shape * (`src/lib/ai/coach/snapshot.ts:292-336`, commit `af17db5d`) into a * reusable primitive. Same recipe: `Map` * gives us insertion-order iteration for LRU touch, a hard cap on * entries to bound memory, and a `pending` map so concurrent reads of * the same cold key fan into a single builder call. * * Design notes: * * - LRU touch is `delete + set` so re-insertion moves the key to the * end of the Map's iteration order. Eviction reads the first key * (least-recently-touched) via `map.keys().next().value`. * * - `wrap(key, builder)` is the only public read path. Misses register * the in-flight promise on `pending` before awaiting the builder so * a second caller for the same key inside the build window awaits * the same promise instead of starting a duplicate read. Rejected * promises remove themselves from `pending` so a transient failure * doesn't poison the key. * * - Metrics are per-instance: hits, misses, evictions, stampedes (a * caller that hit the single-flight join), and capacity (current * entry count). Read these via `stats()` for the wide-event * annotations the route handlers attach to each request. * * - The cache is process-local. A multi-instance Coolify deploy keeps * each container's cache isolated; the TTL bounds the staleness * window. The Redis migration path is sketched in the blueprint * (`.planning/research/v1434-r-cache-aggregation.md` §7). * * - `__resetForTests()` matches the snapshot pattern's escape hatch so * vitest's afterEach can clear every instance between tests without * reaching into the private `Map`. */ export interface ServerCacheOptions { /** Hard cap on entries. Oldest entry is evicted on overflow. */ readonly maxEntries: number; /** Time-to-live in milliseconds. Expired entries miss on read. */ readonly ttlMs: number; /** * v1.12.1 — stale-while-revalidate window in milliseconds. When set, * an entry whose `ttlMs` has lapsed but is still inside * `ttlMs + staleTtlMs` is served immediately (stale) by `wrapSwr()`, * which kicks off a single background recompute to refresh it. Past * the stale window the entry is a hard miss like any other. Leave * unset for the legacy hard-TTL behaviour. */ readonly staleTtlMs?: number; /** * Optional name for observability — surfaced in `stats().name` so the * wide-event meta keys can carry the cache identity without the * caller having to remember which `caches.*` instance it pulled. */ readonly name?: string; } export interface ServerCacheStats { readonly name: string; readonly size: number; readonly hits: number; readonly misses: number; readonly evictions: number; readonly stampedes: number; } export class ServerCache { private readonly map = new Map< string, { expiresAt: number; staleUntil: number; value: T } >(); private readonly pending = new Map>(); private hits = 0; private misses = 0; private evictions = 0; private stampedes = 0; constructor(private readonly opts: ServerCacheOptions) {} /** * Read a key. Returns null on miss, expired entry, or absent entry. * Side effect: an expired entry is removed; a live entry is * LRU-touched (re-inserted) so subsequent reads keep it warm. * * Does NOT increment `hits`/`misses` — those live on the * `wrap()` path because the bare `get` is also called from tests * and from the test escape hatch. */ get(key: string): T | null { const entry = this.map.get(key); if (!entry) return null; if (entry.expiresAt <= Date.now()) { this.map.delete(key); return null; } // LRU touch — re-insertion moves the key to the end of the Map's // iteration order (which is the most-recently-used end). this.map.delete(key); this.map.set(key, entry); return entry.value; } /** * Insert or overwrite. Evicts the oldest entry if the cap is hit. * * `ttlMsOverride` lets a single key carry a longer (or shorter) TTL * than the bucket default without splitting it into a separate cache * instance — used by the dashboard-snapshot key, which must outlive * the 60 s analytics default so the client's 120 s refetch interval * lands on a warm entry, while still sharing the bucket's `${userId}|` * prefix-sweep invalidation. */ set(key: string, value: T, ttlMsOverride?: number): void { // The key might already exist (eviction-by-set is a write through // the same slot — no cap eviction needed). Delete-then-set to keep // the LRU ordering predictable. if (this.map.has(key)) { this.map.delete(key); } else if (this.map.size >= this.opts.maxEntries) { const oldest = this.map.keys().next().value; if (oldest !== undefined) { this.map.delete(oldest); this.evictions += 1; } } const now = Date.now(); const expiresAt = now + (ttlMsOverride ?? this.opts.ttlMs); this.map.set(key, { expiresAt, // The stale window extends past the fresh TTL only when the // bucket opted into SWR; otherwise `staleUntil === expiresAt` // so `getAllowStale` behaves exactly like `get`. staleUntil: expiresAt + (this.opts.staleTtlMs ?? 0), value, }); } /** * v1.12.1 — read a key allowing a stale entry inside the SWR window. * Returns the value plus a `stale` flag (true when the fresh TTL has * lapsed but the entry is still inside `staleTtlMs`). Past the stale * window the entry is removed and `null` is returned. LRU-touches a * live or stale entry so a hot key never falls out under pressure. */ getAllowStale(key: string): { value: T; stale: boolean } | null { const entry = this.map.get(key); if (!entry) return null; const now = Date.now(); if (entry.staleUntil <= now) { this.map.delete(key); return null; } // LRU touch. this.map.delete(key); this.map.set(key, entry); return { value: entry.value, stale: entry.expiresAt <= now }; } /** * v1.12.1 — mark every entry under `prefix` stale without dropping it. * Collapses the fresh TTL to "now" so the next `wrapSwr` read serves * the prior value immediately (within the stale window) while a single * coalesced recompute warms a fresh value. This replaces the * hard-evict on a mood write so an active logger no longer re-pays the * cold compute on every entry. No-op for buckets without `staleTtlMs` * (the entry is already at/over its hard TTL after this, so the next * read is a clean miss — identical to the old evict behaviour). */ markStaleByPrefix(prefix: string): number { let marked = 0; const now = Date.now(); for (const [key, entry] of this.map) { if (key.startsWith(prefix)) { entry.expiresAt = now; marked += 1; } } return marked; } /** Single-key eviction. Returns true when the key was present. */ delete(key: string): boolean { return this.map.delete(key); } /** * Bulk eviction. Removes every entry whose key starts with `prefix`. * Returns the count of removed entries. * * The invalidation helpers in `./invalidate.ts` use this to flush an * entire user-bucket from a cache without having to enumerate every * possible cache-key variant the user might have warmed. */ deleteByPrefix(prefix: string): number { let removed = 0; for (const key of this.map.keys()) { if (key.startsWith(prefix)) { this.map.delete(key); removed += 1; } } return removed; } /** * Read-through + single-flight. On a live hit, returns the cached * value. On a miss, either joins an in-flight builder (single-flight) * or kicks off a new builder, stores the result, and returns it. * * Builder rejections do NOT poison the cache — the `pending` entry * is removed on reject so the next caller retries. */ async wrap( key: string, builder: () => Promise, ttlMsOverride?: number, ): Promise<{ value: T; outcome: "hit" | "miss" | "stampede"; }> { const live = this.get(key); if (live !== null) { this.hits += 1; return { value: live, outcome: "hit" }; } const inFlight = this.pending.get(key); if (inFlight) { this.stampedes += 1; const value = await inFlight; return { value, outcome: "stampede" }; } this.misses += 1; const promise = builder() .then((value) => { this.set(key, value, ttlMsOverride); return value; }) .finally(() => { this.pending.delete(key); }); this.pending.set(key, promise); const value = await promise; return { value, outcome: "miss" }; } /** * v1.12.1 — stale-while-revalidate read. On a fresh hit it returns the * cached value like `wrap`. On a stale hit (entry past its fresh TTL * but inside the SWR window) it returns the prior value IMMEDIATELY * and kicks off a single background recompute — the active caller * never pays the cold compute. On a hard miss / cold key it falls back * to `wrap`'s single-flight builder and awaits it. * * Background revalidation reuses the `pending` single-flight slot so a * burst of stale reads coalesces into one recompute. A rejected * background build is swallowed (the stale value already served) and * clears `pending` so the next read retries. */ async wrapSwr( key: string, builder: () => Promise, ttlMsOverride?: number, ): Promise<{ value: T; outcome: "hit" | "stale" | "miss" | "stampede"; }> { const cachedEntry = this.getAllowStale(key); if (cachedEntry && !cachedEntry.stale) { this.hits += 1; return { value: cachedEntry.value, outcome: "hit" }; } if (cachedEntry && cachedEntry.stale) { // Serve stale now; revalidate in the background under the // single-flight slot so concurrent stale reads share one rebuild. this.hits += 1; if (!this.pending.has(key)) { const refresh = builder() .then((value) => { this.set(key, value, ttlMsOverride); return value; }) .catch((err) => { // The stale value already went out; don't crash the worker. // Re-throw so the join path of any awaiting caller sees it. throw err; }) .finally(() => { this.pending.delete(key); }); this.pending.set(key, refresh); // Detach: the foreground caller does not await the rebuild. // Swallow the rejection on the detached handle so an // unhandled-rejection warning never fires. refresh.catch(() => {}); } return { value: cachedEntry.value, outcome: "stale" }; } // Hard miss / cold key — fall back to the awaited single-flight path. return this.wrap(key, builder, ttlMsOverride); } /** Snapshot of per-instance counters for the wide-event annotation. */ stats(): ServerCacheStats { return { name: this.opts.name ?? "unnamed", size: this.map.size, hits: this.hits, misses: this.misses, evictions: this.evictions, stampedes: this.stampedes, }; } /** Reset every counter + entry. Test-only escape hatch. */ __resetForTests(): void { this.map.clear(); this.pending.clear(); this.hits = 0; this.misses = 0; this.evictions = 0; this.stampedes = 0; } } /** * Per-route cache instances. Module-scope so every request lands in the * same `Map`. Sizes match the blueprint §5 table. * * The blueprint's full eight-cache roster is provisioned so future * rounds can wire the remaining routes (`/api/medications`, * `/api/dashboard/widgets`, `/api/bugreport/status`, `/api/workouts`, * `/api/mood/analytics`) without churning the helper module. */ export const caches = { /** * Slim / thick analytics cells, the iOS summary cell, the dashboard * summary, AND the unified dashboard first-paint snapshot (per-key TTL * override). 60 s fresh TTL keeps multiple sub-page mounts inside a * minute on a warm entry. * * v1.12.7 — stale-while-revalidate window. The snapshot read uses * `cachedSwr` and a measurement write marks the bucket stale (not a * hard evict) via `invalidateUserMeasurements`, so a high-frequency * iOS Apple-Health sync no longer busts the snapshot into a cold * rebuild on every batch (the v1.8.7 "sync evicting the cache all day" * class, on the dashboard snapshot). Slim / thick / summary cells read * via plain `cached` are unaffected: a marked-stale entry has * `expiresAt === now`, so their next `cached` read is a clean miss and * rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a * background recompute. The 10-minute stale window bounds how old a * served snapshot can be when no reader triggers a revalidation. */ analytics: new ServerCache({ name: "analytics", maxEntries: 1000, ttlMs: 60_000, staleTtlMs: 600_000, }), medications: new ServerCache({ name: "medications", maxEntries: 1000, ttlMs: 60_000, }), achievements: new ServerCache({ name: "achievements", maxEntries: 1000, ttlMs: 60_000, }), dashboardWidgets: new ServerCache({ name: "dashboardWidgets", maxEntries: 500, ttlMs: 300_000, }), bugreportStatus: new ServerCache({ name: "bugreportStatus", maxEntries: 10, ttlMs: 600_000, }), workouts: new ServerCache({ name: "workouts", maxEntries: 1000, ttlMs: 60_000, }), medicationsIntake: new ServerCache({ name: "medicationsIntake", maxEntries: 1000, ttlMs: 900_000, }), /** * v1.15.20 — per-medication compliance payload * (`GET /api/medications/[id]/compliance`). The medications list fans * the endpoint out once per card, and each cold build runs the full * band-expansion pass — so the warm path has to live server-side. * Keyed `${userId}|${medicationId}|compliance`; every intake / * medication write flushes the `${userId}|` prefix via * `invalidateUserMedications`, so the 15-minute TTL only bounds * wall-clock drift (a dose flipping overdue), never user-action * staleness. Sized for a few hundred users × a handful of meds. */ medicationCompliance: new ServerCache({ name: "medicationCompliance", maxEntries: 2000, ttlMs: 900_000, }), moodAnalytics: new ServerCache({ name: "moodAnalytics", maxEntries: 1000, ttlMs: 60_000, }), /** * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, * weekday, tags, cross-metric correlations) for the Mood Insights page. * Read on every `/insights/mood` mount; bounded 365-day live read behind * the cache. 60 s fresh TTL matches `moodAnalytics`. * * v1.12.1 — stale-while-revalidate. The read route uses `cachedSwr` and * a mood write marks the bucket stale (not a hard evict) via * `invalidateUserMood`. An active logger now serves the prior aggregate * immediately while a single background recompute warms a fresh one, * instead of re-paying the multi-second cold compute on every entry * (the v1.8.7 "sync evicting the cache all day" class, on mood). The * 10-minute stale window bounds how old a served aggregate can be when * no reader has triggered a revalidation. */ moodInsights: new ServerCache({ name: "moodInsights", maxEntries: 1000, ttlMs: 60_000, staleTtlMs: 600_000, }), /** * v1.4.36 W1 — `/api/insights/targets` is read on every Insights * mount and runs >1 s cold (it pulls 30-day measurements for every * type + medications + intakes + mood + glucose). 60 s TTL matches * the analytics cache so multiple sub-page mounts inside a minute * all hit a warm cache. Invalidated alongside the analytics bucket * on measurement / mood / medication writes. */ insightsTargets: new ServerCache({ name: "insightsTargets", maxEntries: 1000, ttlMs: 60_000, }), /** * v1.5.5 — per-user insights tile layout cache. Mirrors * `dashboardWidgets` (same 5-minute TTL, same per-user bucket size). * Read on every `/insights` mount; the layout only changes on a * Settings save, which invalidates this cache via * `invalidateUserInsightsLayout()`. */ insightsLayout: new ServerCache({ name: "insightsLayout", maxEntries: 500, ttlMs: 300_000, }), } as const; /** * Non-reversible 32-bit hash of the cache key. Used in wide-event meta * so we can correlate hit / miss patterns without leaking the raw * userId portion of the key into logs. * * djb2 — public domain, two lines, matches the blueprint §8 sketch. */ export function hashCacheKey(key: string): number { let hash = 5381; for (let i = 0; i < key.length; i += 1) { hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0; } return hash >>> 0; } /** * Read-through wrapper that also annotates the active wide-event with * the cache hit / miss / stampede outcome and a key hash. Matches the * observability contract in the blueprint §8. * * Falls back gracefully when no logging context is active (the * `annotate()` call is a no-op outside of `apiHandler`). */ export async function cached( cache: ServerCache, key: string, builder: () => Promise, annotateFn?: (fields: { meta: Record }) => void, ttlMsOverride?: number, ): Promise { const { value, outcome } = await cache.wrap(key, builder, ttlMsOverride); if (annotateFn) { const name = cache.stats().name; annotateFn({ meta: { [`cache.${name}.outcome`]: outcome, [`cache.${name}.key_hash`]: hashCacheKey(key), }, }); } return value; } /** * v1.12.1 — stale-while-revalidate read-through. Same observability * contract as `cached` (annotates the wide-event with the outcome + * key hash) but routes through `wrapSwr`: a stale entry inside the * bucket's `staleTtlMs` window is served immediately while a single * background recompute refreshes it, so an active caller never pays * the cold compute on an expired or freshly-marked-stale bucket. */ export async function cachedSwr( cache: ServerCache, key: string, builder: () => Promise, annotateFn?: (fields: { meta: Record }) => void, ttlMsOverride?: number, ): Promise { const { value, outcome } = await cache.wrapSwr(key, builder, ttlMsOverride); if (annotateFn) { const name = cache.stats().name; annotateFn({ meta: { [`cache.${name}.outcome`]: outcome, [`cache.${name}.key_hash`]: hashCacheKey(key), }, }); } return value; } /** Test helper — reset every cache in the registry. */ export function __resetAllCachesForTests(): void { for (const cache of Object.values(caches)) { cache.__resetForTests(); } }