diff --git a/.planning/v15-strategic-plan.md b/.planning/v15-strategic-plan.md index a474eb650..15785e2a3 100644 --- a/.planning/v15-strategic-plan.md +++ b/.planning/v15-strategic-plan.md @@ -386,6 +386,7 @@ navigates to a feature that needs it (R-F §2.5 option 2). | Categorisation shape | UI-side TypeScript overlay `src/lib/measurements/categories.ts`, NOT a DB column | R-F §4 | | Sleep storage shape | Per-stage rows (5-axis unique key) — no split into `SLEEP_DEEP` / `SLEEP_REM` / etc. enum entries | R-F open Q #6 | | Web freeze trigger | v1.4.34 tag on `main` (after the Apple Health XML import lands) | This plan §2, R2 | +| v1.4.34 ships web-freeze marker | tag on main | this plan §2 | | v1.5 release shape | Version-bump-only marker the day after iOS clears Apple review. All functional work lands incrementally in v1.4.29 → v1.4.34. | Maintainer directive 2026-05-16 | | Web patch sequence | v1.4.29 polish → v1.4.29.1 step hotfix → v1.4.30 iOS-coordinated foundation (Daily-Stats + SyncMode) → v1.4.30.1 categories endpoint + conflict-resolution lock → v1.4.31 toggles + insights blocking + Coolify auto-deploy → v1.4.32 Tier 1 wave A → v1.4.33 Tier 1 wave B → v1.4.34 freeze | This plan §2, maintainer directive 2026-05-16 + R1 + R2 | | Apple Health XML import slot | v1.4.34 (before freeze) | R2 + maintainer 2026-05-16 | diff --git a/CHANGELOG.md b/CHANGELOG.md index aff900daa..3fceed982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ imports on behalf of a target user (cookie-only `requireAdmin()` gate; Bearer tokens never elevate). +### Web freeze + +v1.4.34 marks the last functional release of the web codebase before +the iOS native client launches. Subsequent v1.4.x tags carry security +fixes, dependency bumps, and hotfix-only corrections. New feature work +is paused until the iOS app clears Apple review, at which point a +v1.5.0 version-bump-only release tags the milestone. + ## [1.4.33] — 2026-05-17 — Polish and reliability Quality-leap release between two HealthKit milestones. The headline is diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1f30aa215..af7930d28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,3 +1,6 @@ +// v1.4.34 web-freeze. Schema is locked additive-only until iOS launch +// + v1.5.0 marker. See .planning/v15-strategic-plan.md §2. + generator client { provider = "prisma-client" output = "../src/generated/prisma" diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts new file mode 100644 index 000000000..283bc1c8d --- /dev/null +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for the per-user invalidation helpers — every helper + * evicts the matching `caches.*` bucket and leaves unrelated entries + * intact. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + __resetAllCachesForTests, + cached, + caches, +} from "../server-cache"; +import { + invalidateAppSettings, + invalidateUserDashboardWidgets, + invalidateUserMeasurements, + invalidateUserMedications, + invalidateUserMood, +} from "../invalidate"; + +const USER_A = "user-a"; +const USER_B = "user-b"; + +beforeEach(() => { + __resetAllCachesForTests(); +}); + +afterEach(() => { + __resetAllCachesForTests(); +}); + +async function primeAllCaches(): Promise { + // Analytics keys carry a slice suffix; the prefix-match must include + // the pipe so user-1 doesn't match user-10 in production traffic. + await cached(caches.analytics, `${USER_A}|default`, async () => ({ a: 1 })); + await cached(caches.analytics, `${USER_A}|summaries`, async () => ({ a: 2 })); + await cached(caches.analytics, `${USER_B}|default`, async () => ({ b: 1 })); + + await cached(caches.medications, USER_A, async () => ({ m: 1 })); + await cached(caches.medications, USER_B, async () => ({ m: 2 })); + + await cached(caches.medicationsIntake, `${USER_A}|compliance|30`, async () => ({ + c: 1, + })); + await cached(caches.medicationsIntake, `${USER_B}|compliance|30`, async () => ({ + c: 2, + })); + + await cached(caches.achievements, USER_A, async () => ({ ach: 1 })); + await cached(caches.achievements, USER_B, async () => ({ ach: 2 })); + + await cached(caches.workouts, `${USER_A}|3|0||`, async () => ({ w: 1 })); + await cached(caches.workouts, `${USER_B}|3|0||`, async () => ({ w: 2 })); + + await cached(caches.moodAnalytics, USER_A, async () => ({ mood: 1 })); + await cached(caches.moodAnalytics, USER_B, async () => ({ mood: 2 })); + + await cached(caches.dashboardWidgets, USER_A, async () => ({ d: 1 })); + await cached(caches.dashboardWidgets, USER_B, async () => ({ d: 2 })); + + await cached(caches.bugreportStatus, "singleton", async () => ({ s: 1 })); +} + +describe("invalidateUserMeasurements", () => { + it("evicts analytics + achievements + workouts for the target user only", async () => { + await primeAllCaches(); + invalidateUserMeasurements(USER_A); + + expect(caches.analytics.get(`${USER_A}|default`)).toBeNull(); + expect(caches.analytics.get(`${USER_A}|summaries`)).toBeNull(); + expect(caches.achievements.get(USER_A)).toBeNull(); + expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull(); + + // User B's caches untouched. + expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull(); + expect(caches.achievements.get(USER_B)).not.toBeNull(); + expect(caches.workouts.get(`${USER_B}|3|0||`)).not.toBeNull(); + + // Mood-analytics not touched by a measurement write. + expect(caches.moodAnalytics.get(USER_A)).not.toBeNull(); + }); +}); + +describe("invalidateUserMood", () => { + it("evicts mood-analytics + achievements + analytics", async () => { + await primeAllCaches(); + invalidateUserMood(USER_A); + + expect(caches.moodAnalytics.get(USER_A)).toBeNull(); + expect(caches.achievements.get(USER_A)).toBeNull(); + expect(caches.analytics.get(`${USER_A}|default`)).toBeNull(); + expect(caches.moodAnalytics.get(USER_B)).not.toBeNull(); + expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull(); + }); +}); + +describe("invalidateUserMedications", () => { + it("evicts medications + medications-intake + achievements", async () => { + await primeAllCaches(); + invalidateUserMedications(USER_A); + + expect(caches.medications.get(USER_A)).toBeNull(); + expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull(); + expect(caches.achievements.get(USER_A)).toBeNull(); + expect(caches.medications.get(USER_B)).not.toBeNull(); + expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull(); + }); +}); + +describe("invalidateUserDashboardWidgets", () => { + it("evicts only the dashboardWidgets bucket for the target user", async () => { + await primeAllCaches(); + invalidateUserDashboardWidgets(USER_A); + expect(caches.dashboardWidgets.get(USER_A)).toBeNull(); + expect(caches.dashboardWidgets.get(USER_B)).not.toBeNull(); + // Sibling caches untouched. + expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull(); + }); +}); + +describe("invalidateAppSettings", () => { + it("evicts the entire bug-report status cache (singleton row)", async () => { + await primeAllCaches(); + invalidateAppSettings(); + expect(caches.bugreportStatus.get("singleton")).toBeNull(); + // Per-user caches untouched. + expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull(); + }); +}); diff --git a/src/lib/cache/__tests__/server-cache.test.ts b/src/lib/cache/__tests__/server-cache.test.ts new file mode 100644 index 000000000..50e0c2f51 --- /dev/null +++ b/src/lib/cache/__tests__/server-cache.test.ts @@ -0,0 +1,232 @@ +/** + * Unit tests for the `ServerCache` primitive — LRU ordering, TTL + * expiry, capacity-cap eviction, prefix delete, single-flight + * coalescing, builder-rejection cleanup, and the `cached()` wrapper's + * observability annotation. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + __resetAllCachesForTests, + cached, + caches, + hashCacheKey, + ServerCache, +} from "../server-cache"; + +afterEach(() => { + __resetAllCachesForTests(); + vi.useRealTimers(); +}); + +describe("ServerCache.get / set", () => { + it("returns the stored value within TTL", () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + }); + + it("returns null when the key is absent", () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + expect(cache.get("missing")).toBeNull(); + }); + + it("expires entries past the TTL", () => { + vi.useFakeTimers(); + const cache = new ServerCache({ maxEntries: 4, ttlMs: 1000 }); + cache.set("k", "v"); + vi.advanceTimersByTime(999); + expect(cache.get("k")).toBe("v"); + vi.advanceTimersByTime(2); + expect(cache.get("k")).toBeNull(); + }); +}); + +describe("ServerCache LRU eviction", () => { + it("evicts the oldest entry on capacity overflow", () => { + const cache = new ServerCache({ maxEntries: 3, ttlMs: 60_000 }); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + cache.set("d", 4); // evicts "a" + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBe(2); + expect(cache.get("c")).toBe(3); + expect(cache.get("d")).toBe(4); + expect(cache.stats().evictions).toBe(1); + }); + + it("touches on read so least-recently-used is evicted", () => { + const cache = new ServerCache({ maxEntries: 3, ttlMs: 60_000 }); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + // Touch "a" — now "b" is the oldest. + expect(cache.get("a")).toBe(1); + cache.set("d", 4); // evicts "b" + expect(cache.get("a")).toBe(1); + expect(cache.get("b")).toBeNull(); + expect(cache.get("c")).toBe(3); + expect(cache.get("d")).toBe(4); + }); + + it("re-setting an existing key does not count toward eviction", () => { + const cache = new ServerCache({ maxEntries: 2, ttlMs: 60_000 }); + cache.set("a", 1); + cache.set("b", 2); + cache.set("a", 99); // overwrite, no eviction + expect(cache.stats().evictions).toBe(0); + expect(cache.get("a")).toBe(99); + expect(cache.get("b")).toBe(2); + }); +}); + +describe("ServerCache.deleteByPrefix", () => { + it("removes every key with a matching prefix", () => { + const cache = new ServerCache({ maxEntries: 16, ttlMs: 60_000 }); + cache.set("user-1|analytics", 1); + cache.set("user-1|workouts", 2); + cache.set("user-2|analytics", 3); + const removed = cache.deleteByPrefix("user-1|"); + expect(removed).toBe(2); + expect(cache.get("user-1|analytics")).toBeNull(); + expect(cache.get("user-1|workouts")).toBeNull(); + expect(cache.get("user-2|analytics")).toBe(3); + }); + + it("returns 0 when no keys match", () => { + const cache = new ServerCache({ maxEntries: 16, ttlMs: 60_000 }); + cache.set("user-1|analytics", 1); + expect(cache.deleteByPrefix("user-99|")).toBe(0); + }); +}); + +describe("ServerCache.wrap single-flight", () => { + it("calls the builder once for two concurrent reads of the same key", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let callCount = 0; + let resolveBuilder: ((value: string) => void) | null = null; + const builder = vi.fn( + () => + new Promise((resolve) => { + callCount += 1; + resolveBuilder = resolve; + }), + ); + + const first = cache.wrap("k", builder); + const second = cache.wrap("k", builder); + + // Both reads have landed before the builder resolves. + expect(callCount).toBe(1); + resolveBuilder!("built"); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult.value).toBe("built"); + expect(firstResult.outcome).toBe("miss"); + expect(secondResult.value).toBe("built"); + expect(secondResult.outcome).toBe("stampede"); + expect(builder).toHaveBeenCalledTimes(1); + expect(cache.stats().stampedes).toBe(1); + }); + + it("returns the cached value as a hit on the third read", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + const builder = vi.fn(async () => "built"); + await cache.wrap("k", builder); + const third = await cache.wrap("k", builder); + expect(third.value).toBe("built"); + expect(third.outcome).toBe("hit"); + expect(builder).toHaveBeenCalledTimes(1); + expect(cache.stats().hits).toBe(1); + }); + + it("clears the pending slot on builder rejection so the next call retries", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let attempt = 0; + const builder = vi.fn(async () => { + attempt += 1; + if (attempt === 1) throw new Error("transient"); + return "ok"; + }); + + await expect(cache.wrap("k", builder)).rejects.toThrow("transient"); + const next = await cache.wrap("k", builder); + expect(next.value).toBe("ok"); + expect(next.outcome).toBe("miss"); + expect(builder).toHaveBeenCalledTimes(2); + }); +}); + +describe("cached() observability wrapper", () => { + it("annotates hit / miss outcome and a hashed key", async () => { + const cache = new ServerCache({ + name: "test", + maxEntries: 4, + ttlMs: 60_000, + }); + const annotate = vi.fn(); + const builder = vi.fn(async () => "v"); + + await cached(cache, "user-1|default", builder, annotate); + expect(annotate).toHaveBeenCalledWith({ + meta: { + "cache.test.outcome": "miss", + "cache.test.key_hash": hashCacheKey("user-1|default"), + }, + }); + + annotate.mockClear(); + await cached(cache, "user-1|default", builder, annotate); + expect(annotate).toHaveBeenCalledWith({ + meta: expect.objectContaining({ + "cache.test.outcome": "hit", + }), + }); + }); + + it("no-ops the annotation when no annotator is passed", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + const value = await cached(cache, "k", async () => "v"); + expect(value).toBe("v"); + }); +}); + +describe("global cache registry", () => { + it("exposes the eight blueprint caches as module-scope singletons", () => { + expect(caches.analytics).toBeDefined(); + expect(caches.medications).toBeDefined(); + expect(caches.achievements).toBeDefined(); + expect(caches.dashboardWidgets).toBeDefined(); + expect(caches.bugreportStatus).toBeDefined(); + expect(caches.workouts).toBeDefined(); + expect(caches.medicationsIntake).toBeDefined(); + expect(caches.moodAnalytics).toBeDefined(); + }); + + it("__resetAllCachesForTests clears every registry instance", async () => { + await cached(caches.analytics, "user-1|default", async () => ({ ok: 1 })); + expect(caches.analytics.stats().size).toBe(1); + __resetAllCachesForTests(); + expect(caches.analytics.stats().size).toBe(0); + expect(caches.analytics.stats().hits).toBe(0); + expect(caches.analytics.stats().misses).toBe(0); + }); +}); + +describe("hashCacheKey", () => { + it("is deterministic and unsigned 32-bit", () => { + const h1 = hashCacheKey("user-1|default"); + const h2 = hashCacheKey("user-1|default"); + expect(h1).toBe(h2); + expect(h1).toBeGreaterThanOrEqual(0); + expect(h1).toBeLessThanOrEqual(0xffffffff); + }); + + it("distinguishes near-identical keys", () => { + expect(hashCacheKey("user-1|default")).not.toBe( + hashCacheKey("user-2|default"), + ); + }); +}); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts new file mode 100644 index 000000000..4e8f57997 --- /dev/null +++ b/src/lib/cache/invalidate.ts @@ -0,0 +1,82 @@ +/** + * v1.4.34 IW-G — per-user cache invalidation helpers. + * + * Each write endpoint that touches a measurement / mood / medication / + * dashboard-widget row calls the matching helper after its successful + * database commit. The helpers walk the affected `caches.*` maps and + * delete every key whose `userId` prefix matches. + * + * The "evict the whole user-bucket" stance is intentional: a redundant + * eviction costs the next reader one cache miss (cheap, the row's + * already paginated and the snapshot LRU sits behind), but an + * under-eviction surfaces stale data which is the directive's failure + * mode. The full per-write → per-cache fan-out is documented in the + * blueprint §6 (`.planning/research/v1434-r-cache-aggregation.md`). + * + * The helpers are intentionally synchronous — they do `Map.delete()` + * over a bounded entry set, which is cheap enough to run inline before + * the write endpoint's `apiSuccess()` return. No `await` overhead. + */ + +import { caches } from "./server-cache"; + +/** + * Invalidate every cache that may reflect a user's measurement set. + * + * Covers: analytics aggregate, achievement progress, workouts cache. + * Mood-analytics is invalidated separately via `invalidateUserMood` + * because mood writes don't change measurement rows. + */ +export function invalidateUserMeasurements(userId: string): void { + caches.analytics.deleteByPrefix(`${userId}|`); + caches.achievements.deleteByPrefix(userId); + caches.workouts.deleteByPrefix(`${userId}|`); +} + +/** + * Invalidate every cache that may reflect a user's mood entries. + * + * Mood writes also dirty the analytics aggregate (correlations runner + * pairs mood × pulse) and achievement progress (consistent-month / mood + * badges). + */ +export function invalidateUserMood(userId: string): void { + caches.moodAnalytics.deleteByPrefix(userId); + caches.achievements.deleteByPrefix(userId); + caches.analytics.deleteByPrefix(`${userId}|`); +} + +/** + * Invalidate every cache that may reflect a user's medication state. + * + * Covers the medications cache, the medication-intake compliance cache + * (cached daily), and achievement progress. + */ +export function invalidateUserMedications(userId: string): void { + caches.medications.deleteByPrefix(userId); + caches.medicationsIntake.deleteByPrefix(`${userId}|`); + caches.achievements.deleteByPrefix(userId); +} + +/** + * Invalidate the dashboard-widget layout cache for a single user. + * + * Called from the dashboard-widgets PUT / DELETE endpoints when the + * user reorders / disables a tile. + */ +export function invalidateUserDashboardWidgets(userId: string): void { + caches.dashboardWidgets.deleteByPrefix(userId); +} + +/** + * Global eviction of the bug-report-status cache. + * + * The cache is keyed on the singleton `"singleton"` slot because the + * underlying data is a row in `AppSettings`. Any admin write to the + * GitHub-token / bug-report-enabled fields invalidates the entire + * cache; the per-user `isAdmin` flag is layered on at request time, so + * the cache itself only stores role-agnostic shape. + */ +export function invalidateAppSettings(): void { + caches.bugreportStatus.deleteByPrefix(""); +} diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts new file mode 100644 index 000000000..737ef9ba3 --- /dev/null +++ b/src/lib/cache/server-cache.ts @@ -0,0 +1,299 @@ +/** + * 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; + /** + * 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(); + 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. */ + set(key: string, value: T): 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; + } + } + this.map.set(key, { + expiresAt: Date.now() + this.opts.ttlMs, + value, + }); + } + + /** 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): 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); + return value; + }) + .finally(() => { + this.pending.delete(key); + }); + this.pending.set(key, promise); + const value = await promise; + return { value, outcome: "miss" }; + } + + /** 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 = { + analytics: new ServerCache({ + name: "analytics", + maxEntries: 1000, + ttlMs: 60_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, + }), + moodAnalytics: new ServerCache({ + name: "moodAnalytics", + maxEntries: 1000, + ttlMs: 60_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, +): Promise { + const { value, outcome } = await cache.wrap(key, builder); + 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(); + } +}