diff --git a/src/lib/cache/__tests__/server-cache-global-pin.test.ts b/src/lib/cache/__tests__/server-cache-global-pin.test.ts new file mode 100644 index 000000000..2145c07f8 --- /dev/null +++ b/src/lib/cache/__tests__/server-cache-global-pin.test.ts @@ -0,0 +1,60 @@ +/** + * The server-cache registry must be shared across module instances. + * + * Next's App Router bundles a module that is imported from both a Server + * Component and a route handler once PER LAYER. Both copies run in the same + * process, but each gets its own module scope — so a registry held in plain + * module state becomes two registries. Measured on a production build: the + * dashboard RSC read its own copy while an intake / measurement write, which + * runs in a route handler, evicted the other one. The home page then kept + * serving the pre-write snapshot for the rest of the TTL. + * + * `globalThis` is the only scope both layers share, so the registry is parked + * there under a `Symbol.for` slot. These tests pin that down from both sides: + * the exported object IS the one on the global, and a second evaluation of the + * module adopts the existing registry instead of building a fresh one. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { caches } from "../server-cache"; + +const CACHES_GLOBAL_SLOT = Symbol.for("healthlog.serverCaches"); + +type GlobalWithCaches = typeof globalThis & { + [CACHES_GLOBAL_SLOT]?: unknown; +}; + +describe("server cache registry is pinned to globalThis", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("exports the very object parked on the global slot", () => { + const parked = (globalThis as GlobalWithCaches)[CACHES_GLOBAL_SLOT]; + expect(parked).toBeDefined(); + expect(caches).toBe(parked); + }); + + it("hands a second module evaluation the same registry", async () => { + // `vi.resetModules()` makes the next import evaluate the module file + // again, which is what a second bundler layer does in production. + const reimported = await import("../server-cache"); + expect(reimported.caches).toBe(caches); + }); + + it("lets a write through one module instance reach the other", async () => { + const reimported = await import("../server-cache"); + caches.analytics.__resetForTests(); + + caches.analytics.set("pin-guard|probe", { seen: true }); + + expect(reimported.caches.analytics.get("pin-guard|probe")).toEqual({ + seen: true, + }); + + reimported.caches.analytics.deleteByPrefix("pin-guard|"); + + expect(caches.analytics.get("pin-guard|probe")).toBeNull(); + }); +}); diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 7b8edc7fc..8e4cead77 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -463,242 +463,284 @@ export class ServerCache { } /** - * Per-route cache instances. Module-scope so every request lands in the - * same `Map`. Sizes match the blueprint §5 table. + * Per-route cache instances. 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/workouts`, * `/api/mood/analytics`) without churning the helper module. + * + * Built once and parked on `globalThis` (see `caches` below) — module + * scope alone is NOT enough to make every request land in the same `Map`. */ -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 stale window bounds how old a served - * snapshot can be when no reader triggers a revalidation. - * - * v1.16.12 — stale window widened 10 min → 1 h. A single self-hoster - * visits the dashboard / insights every ~20-30 min; the old 10-minute - * window meant nearly every return landed PAST it (a `miss`, ~1.5 s of - * synchronous rebuild) instead of inside it (a `stale`, instant + one - * background refresh). Each stale-serve re-inserts, so an active session - * stays warm regardless of the exact window; the value only governs how - * long an ABSENCE still gets an instant response. 1 h covers a normal - * break while keeping the dashboard's time-derived `nextDueOverdue` / - * tally drift bounded — and that field already self-corrects within the - * snapshot client's 120 s poll and the immediate background refresh. - */ - analytics: new ServerCache({ - name: "analytics", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 3_600_000, - }), - /** - * v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh - * TTL meant every visit minutes apart paid the full cold list build - * (five parallel reads + the next-due engine per medication). The list - * GET reads via `cachedSwr` now: inside the 10-minute stale window the - * prior list serves immediately while one background recompute warms a - * fresh one. Interactive writes hard-evict the bucket through - * `invalidateUserMedications({ evict: true })`, so the window only - * bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never - * user-action staleness. - */ - medications: new ServerCache({ - name: "medications", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 600_000, - }), - /** - * v1.18.11 (W5 perf) — stale-while-revalidate window. The - * `AchievementUnlockNotifier` is mounted on every authenticated page and - * polls this endpoint every 2 minutes. Against a 60 s hard-TTL bucket - * that poll always landed on an expired entry, firing a cold ~400 ms - * rebuild on every session every 2 minutes. With a stale window the poll - * (and the inline `/achievements` page mount) serves the prior payload - * instantly while one coalesced background recompute refreshes it. - * - * SWR is safe here: the route persists `pendingUnlocks` OUTSIDE the - * cached factory (idempotent `createMany({ skipDuplicates: true })`), so - * serving a stale body never skips an unlock write. The 10-minute window - * mirrors the `medications` bucket; measurement / mood / medication / - * intake writes invalidate the `${userId}|` prefix so a user's own - * action still surfaces on the next read. - */ - achievements: new ServerCache({ - name: "achievements", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 600_000, - }), - dashboardWidgets: new ServerCache({ - name: "dashboardWidgets", - maxEntries: 500, - ttlMs: 300_000, - }), - // Retain at most 50k canonical workout rows across all filter projections. - // The selected row shape is fixed and narrow, so row count is a stable - // byte proxy without serializing or walking every field on each cache set. - // A projection larger than the entire budget is returned to its caller but - // immediately evicted rather than monopolizing process memory. - workouts: new ServerCache({ - name: "workouts", - maxEntries: 1000, - // A row-weight budget prevents a handful of full-history projections - // from looking as cheap as tiny lists under the entry-count cap. - maxWeight: 50_000, - ttlMs: 60_000, - weightOf: (projection) => Math.max(1, projection.canonical.length), - }), - 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|${userTz}` (the payload's - * day buckets derive from the user timezone, so a timezone change must - * miss rather than serve the previous zone's buckets); 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. - * - * v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id - * detail read and the batched card read) consume via `cachedSwr`: - * inside the 10-minute stale window an expired cell serves the prior - * payload immediately while one coalesced rebuild warms it. Interactive - * intake / medication writes — including the iOS bulk-intake endpoint, - * which carries the phone user's own taken/skipped doses — hard-evict - * (the user must see their own dose on the next read); the genuinely - * background writers (the auto-miss cron, slot dedup) mark stale so a - * high-frequency pass never busts every card into an inline cold - * rebuild. - */ - medicationCompliance: new ServerCache({ - name: "medicationCompliance", - maxEntries: 2000, - ttlMs: 900_000, - staleTtlMs: 600_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. - * - * v1.16.8 — stale-while-revalidate. The 60 s fresh TTL meant every - * Insights mount more than a minute after the last one re-paid the - * full multi-query build inline (>1 s cold). The route reads via - * `cachedSwr` now: inside the 10-minute stale window the prior body - * serves immediately while one coalesced background rebuild warms a - * fresh one. Writes keep their hard evict (`deleteByPrefix`) so a - * user's own measurement / mood / medication action is always - * reflected on the very next read — the window only bounds - * wall-clock drift, never user-action staleness. - */ - insightsTargets: new ServerCache({ - name: "insightsTargets", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 600_000, - }), - /** - * v1.16.8 — batched derived-wellness payload - * (`GET /api/insights/derived/batch`). The Insights overview reads - * ~16 metric computes in one request; each cold build walks the - * rollup tier per metric and lands at 1–2 s wall-clock even under the - * bounded `p-limit(4)` fan-out. Keyed - * `${userId}|batch|${sortedTokens}|${locale}` so the overview's one - * canonical token set always lands on one cell. Measurement writes - * cover it through `invalidateUserMeasurements` (evict on interactive - * writes, mark-stale on background syncs); mood writes mark it stale - * (READINESS folds the mood series in). Stale-while-revalidate keeps - * any repeat read inside the 10-minute window instant while one - * background recompute refreshes the cell. - */ - insightsDerived: new ServerCache({ - name: "insightsDerived", - maxEntries: 2000, - ttlMs: 60_000, - // v1.16.12 — 10 min → 1 h, same rationale as the analytics bucket: the - // derived-insight aggregates are trend data (no time-critical due - // state), so a returning visitor serves an instant stale payload + - // background refresh instead of a ~1.1 s cold rebuild. - staleTtlMs: 3_600_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, - }), - /** - * v1.16.10 — per-user medications list presentation (view choice + - * manual order). Mirrors `insightsLayout` (same 5-minute TTL, same - * per-user bucket size). Read on every /medications mount; changes - * only on a view toggle / order save, which invalidates via - * `invalidateUserMedicationListLayout()`. - */ - medicationListLayout: new ServerCache({ - name: "medicationListLayout", - maxEntries: 500, - ttlMs: 300_000, - }), -} as const; +function createServerCaches() { + return { + /** + * 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 stale window bounds how old a served + * snapshot can be when no reader triggers a revalidation. + * + * v1.16.12 — stale window widened 10 min → 1 h. A single self-hoster + * visits the dashboard / insights every ~20-30 min; the old 10-minute + * window meant nearly every return landed PAST it (a `miss`, ~1.5 s of + * synchronous rebuild) instead of inside it (a `stale`, instant + one + * background refresh). Each stale-serve re-inserts, so an active session + * stays warm regardless of the exact window; the value only governs how + * long an ABSENCE still gets an instant response. 1 h covers a normal + * break while keeping the dashboard's time-derived `nextDueOverdue` / + * tally drift bounded — and that field already self-corrects within the + * snapshot client's 120 s poll and the immediate background refresh. + */ + analytics: new ServerCache({ + name: "analytics", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 3_600_000, + }), + /** + * v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh + * TTL meant every visit minutes apart paid the full cold list build + * (five parallel reads + the next-due engine per medication). The list + * GET reads via `cachedSwr` now: inside the 10-minute stale window the + * prior list serves immediately while one background recompute warms a + * fresh one. Interactive writes hard-evict the bucket through + * `invalidateUserMedications({ evict: true })`, so the window only + * bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never + * user-action staleness. + */ + medications: new ServerCache({ + name: "medications", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 600_000, + }), + /** + * v1.18.11 (W5 perf) — stale-while-revalidate window. The + * `AchievementUnlockNotifier` is mounted on every authenticated page and + * polls this endpoint every 2 minutes. Against a 60 s hard-TTL bucket + * that poll always landed on an expired entry, firing a cold ~400 ms + * rebuild on every session every 2 minutes. With a stale window the poll + * (and the inline `/achievements` page mount) serves the prior payload + * instantly while one coalesced background recompute refreshes it. + * + * SWR is safe here: the route persists `pendingUnlocks` OUTSIDE the + * cached factory (idempotent `createMany({ skipDuplicates: true })`), so + * serving a stale body never skips an unlock write. The 10-minute window + * mirrors the `medications` bucket; measurement / mood / medication / + * intake writes invalidate the `${userId}|` prefix so a user's own + * action still surfaces on the next read. + */ + achievements: new ServerCache({ + name: "achievements", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 600_000, + }), + dashboardWidgets: new ServerCache({ + name: "dashboardWidgets", + maxEntries: 500, + ttlMs: 300_000, + }), + // Retain at most 50k canonical workout rows across all filter projections. + // The selected row shape is fixed and narrow, so row count is a stable + // byte proxy without serializing or walking every field on each cache set. + // A projection larger than the entire budget is returned to its caller but + // immediately evicted rather than monopolizing process memory. + workouts: new ServerCache({ + name: "workouts", + maxEntries: 1000, + // A row-weight budget prevents a handful of full-history projections + // from looking as cheap as tiny lists under the entry-count cap. + maxWeight: 50_000, + ttlMs: 60_000, + weightOf: (projection) => Math.max(1, projection.canonical.length), + }), + 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|${userTz}` (the payload's + * day buckets derive from the user timezone, so a timezone change must + * miss rather than serve the previous zone's buckets); 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. + * + * v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id + * detail read and the batched card read) consume via `cachedSwr`: + * inside the 10-minute stale window an expired cell serves the prior + * payload immediately while one coalesced rebuild warms it. Interactive + * intake / medication writes — including the iOS bulk-intake endpoint, + * which carries the phone user's own taken/skipped doses — hard-evict + * (the user must see their own dose on the next read); the genuinely + * background writers (the auto-miss cron, slot dedup) mark stale so a + * high-frequency pass never busts every card into an inline cold + * rebuild. + */ + medicationCompliance: new ServerCache({ + name: "medicationCompliance", + maxEntries: 2000, + ttlMs: 900_000, + staleTtlMs: 600_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. + * + * v1.16.8 — stale-while-revalidate. The 60 s fresh TTL meant every + * Insights mount more than a minute after the last one re-paid the + * full multi-query build inline (>1 s cold). The route reads via + * `cachedSwr` now: inside the 10-minute stale window the prior body + * serves immediately while one coalesced background rebuild warms a + * fresh one. Writes keep their hard evict (`deleteByPrefix`) so a + * user's own measurement / mood / medication action is always + * reflected on the very next read — the window only bounds + * wall-clock drift, never user-action staleness. + */ + insightsTargets: new ServerCache({ + name: "insightsTargets", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 600_000, + }), + /** + * v1.16.8 — batched derived-wellness payload + * (`GET /api/insights/derived/batch`). The Insights overview reads + * ~16 metric computes in one request; each cold build walks the + * rollup tier per metric and lands at 1–2 s wall-clock even under the + * bounded `p-limit(4)` fan-out. Keyed + * `${userId}|batch|${sortedTokens}|${locale}` so the overview's one + * canonical token set always lands on one cell. Measurement writes + * cover it through `invalidateUserMeasurements` (evict on interactive + * writes, mark-stale on background syncs); mood writes mark it stale + * (READINESS folds the mood series in). Stale-while-revalidate keeps + * any repeat read inside the 10-minute window instant while one + * background recompute refreshes the cell. + */ + insightsDerived: new ServerCache({ + name: "insightsDerived", + maxEntries: 2000, + ttlMs: 60_000, + // v1.16.12 — 10 min → 1 h, same rationale as the analytics bucket: the + // derived-insight aggregates are trend data (no time-critical due + // state), so a returning visitor serves an instant stale payload + + // background refresh instead of a ~1.1 s cold rebuild. + staleTtlMs: 3_600_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, + }), + /** + * v1.16.10 — per-user medications list presentation (view choice + + * manual order). Mirrors `insightsLayout` (same 5-minute TTL, same + * per-user bucket size). Read on every /medications mount; changes + * only on a view toggle / order save, which invalidates via + * `invalidateUserMedicationListLayout()`. + */ + medicationListLayout: new ServerCache({ + name: "medicationListLayout", + maxEntries: 500, + ttlMs: 300_000, + }), + } as const; +} + +type ServerCacheRegistry = ReturnType; + +/** + * The registry lives on `globalThis`, not on module scope. + * + * A module imported from BOTH a Server Component and a route handler is + * instantiated once per bundler layer, so plain module state gives the + * dashboard RSC its own private `Map` and the API routes another. Measured + * on a production build, single process: the RSC copy held two analytics + * entries while an interactive measurement write — which runs in a route + * handler and calls `deleteByPrefix` — emptied only the route-handler copy. + * The dashboard's server-rendered first paint kept serving the pre-write + * snapshot until the TTL lapsed minutes later, and because that payload is + * dehydrated into TanStack it overwrote the fresh value the client had + * already fetched. + * + * Measured under `node .next/standalone/server.js`, which is what the image + * runs, and not only under `next start`. Both behave the same here: three + * module instantiations either way, one shared registry once pinned. Worth + * keeping in mind for the next cache-shaped bug — a reading taken under + * `next start` alone would not have settled this. + * + * `Symbol.for` is the same cross-layer slot `event-builder.ts` uses for the + * event-loop-lag sample; `globalForCaches` mirrors `db.ts`'s `globalForPrisma` + * alias. The pin is unconditional: `db.ts` guards its assignment with + * `NODE_ENV !== "production"` because it only defends against dev HMR, while + * the layer split this defends against is a PRODUCTION build behaviour. + */ +const CACHES_GLOBAL_SLOT = Symbol.for("healthlog.serverCaches"); + +const globalForCaches = globalThis as unknown as { + [key: symbol]: ServerCacheRegistry | undefined; +}; + +export const caches: ServerCacheRegistry = + globalForCaches[CACHES_GLOBAL_SLOT] ?? + (globalForCaches[CACHES_GLOBAL_SLOT] = createServerCaches()); /** * Non-reversible 32-bit hash of the cache key. Used in wide-event meta diff --git a/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts new file mode 100644 index 000000000..5184a0b05 --- /dev/null +++ b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts @@ -0,0 +1,87 @@ +/** + * The snapshot read and the snapshot invalidator must agree on one key. + * + * `dashboardSnapshotCacheKey` is the declared source of truth, and the + * invalidators sweep by its prefix. The read used to spell the same string + * out by hand: identical today, silently divergent the moment either side is + * edited, and the failure is invisible — the dashboard just keeps painting + * the pre-write body until the entry ages out. + * + * So this asserts the agreement by effect rather than by text: warm the cell + * through the real read, invalidate through the real invalidator, and require + * that the next read rebuilds. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; + +const buildDashboardSnapshot = vi.fn(async () => ({ tiles: {} })); +const resolveServerLocale = vi.fn(async () => "de"); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/dashboard/snapshot", () => ({ + buildDashboardSnapshot: (...args: unknown[]) => + buildDashboardSnapshot(...(args as [])), +})); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: (...args: unknown[]) => + resolveServerLocale(...(args as [])), +})); + +const { readDashboardSnapshotCached } = await import("../snapshot-read"); +const { caches, __resetAllCachesForTests } = + await import("@/lib/cache/server-cache"); +const { + dashboardSnapshotCacheKey, + invalidateUserDashboardSnapshot, + invalidateUserMedications, +} = await import("@/lib/cache/invalidate"); + +const USER = { id: "user-key-guard", locale: "de" } as unknown as User; + +beforeEach(() => { + __resetAllCachesForTests(); + buildDashboardSnapshot.mockClear(); +}); + +afterEach(() => { + __resetAllCachesForTests(); +}); + +describe("dashboard snapshot cache key", () => { + it("writes the cell at the key the builder composes", async () => { + const { body } = await readDashboardSnapshotCached(USER); + + // Exact key, not a prefix: `deleteByPrefix` would forgive a suffix change + // on the read side and leave the drift undetected until a user saw it. + expect( + caches.analytics.get(`${dashboardSnapshotCacheKey(USER.id)}|de`), + ).toBe(body); + }); + + it("serves the second read from the cell the first read wrote", async () => { + await readDashboardSnapshotCached(USER); + await readDashboardSnapshotCached(USER); + + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1); + }); + + it("rebuilds after the snapshot invalidator runs", async () => { + await readDashboardSnapshotCached(USER); + invalidateUserDashboardSnapshot(USER.id); + await readDashboardSnapshotCached(USER); + + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2); + }); + + it("rebuilds after an interactive medication write", async () => { + // The path behind the reported symptom: a dose is recorded, the intake + // route evicts, and the very next dashboard read must not be the old body. + await readDashboardSnapshotCached(USER); + invalidateUserMedications(USER.id, { evict: true }); + await readDashboardSnapshotCached(USER); + + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 44038892a..da2423aaa 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -6,17 +6,27 @@ * prefetches the same payload into a dehydrated TanStack cache so the * first HTML paints real tiles instead of skeletons-until-JS. * - * Both ride the SAME `caches.analytics` SWR cell (key - * `${userId}|dashboard-snapshot|${locale}`), so an RSC prefetch warms the - * API path and vice versa — the builder never runs twice for one user - * within the TTL, and the write-invalidation semantics (stale-sweep on - * measurement writes, hard evict on widget reorder) cover both readers. + * Both ride the SAME `caches.analytics` SWR cell, keyed by + * `dashboardSnapshotCacheKey(userId)` plus the resolved locale, so an RSC + * prefetch warms the API path and vice versa — the builder never runs twice + * for one user within the TTL, and the write-invalidation semantics + * (stale-sweep on measurement writes, hard evict on widget reorder) cover + * both readers. + * + * "The SAME cell" rests on `caches` being pinned to `globalThis` in + * `src/lib/cache/server-cache.ts`. These two entry points are bundled into + * different layers, and while the registry sat in plain module state they + * held one Map each: a write evicted the route handlers' copy and the home + * page kept rendering the pre-write snapshot until it aged out. Anything + * that moves the registry back off the global breaks this paragraph, not + * just a performance assumption. */ import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { dashboardSnapshotCacheKey } from "@/lib/cache/invalidate"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { buildDashboardSnapshot, @@ -87,7 +97,10 @@ export async function readDashboardSnapshotCached( // miss + synchronous rebuild here. const body = await cachedSwr( caches.analytics as ServerCache, - `${user.id}|dashboard-snapshot|${locale}`, + // The invalidators sweep by `dashboardSnapshotCacheKey(userId)` prefix, so + // the read has to build its key from that same function. Spelling the + // string out here worked only for as long as nobody edited one side. + `${dashboardSnapshotCacheKey(user.id)}|${locale}`, () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), annotate, SNAPSHOT_CACHE_TTL_MS,