From 96a37430093f41a3bb58847b677416fb299353b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 08:03:21 +0200 Subject: [PATCH 1/3] Share one server cache between the home page and the API routes A production build instantiates a module imported from both a Server Component and a route handler once per bundler layer. The cache registry sat in plain module state, so the home page's server render held its own set of Maps and every API route held another. Recording a dose evicted the route handlers' copy; the home page then rendered from a copy nothing had touched and painted the dose as still due until the entry aged out on its own, two or three minutes later. Because that render is dehydrated into the client query cache, it also overwrote the fresh value the browser had already fetched. Measured on `next build && next start`, single process: the page render and the snapshot route reported different cache instances, and after an interactive write the route handlers' copy dropped to zero entries while the page's copy stayed full. The registry now lives on globalThis under a Symbol.for slot, which is the only scope both layers share. db.ts guards its own global assignment with a non-production check because it is only defending against dev reloads; this one has to hold in production, so it is unconditional. --- .../__tests__/server-cache-global-pin.test.ts | 60 +++ src/lib/cache/server-cache.ts | 496 ++++++++++-------- 2 files changed, 326 insertions(+), 230 deletions(-) create mode 100644 src/lib/cache/__tests__/server-cache-global-pin.test.ts 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..9119f8dbb 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -463,242 +463,278 @@ 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 (`next build && next start`, one 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. + * + * `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 From 0fbaf6b2d38e111e4d899edbdbcd65be26ff08d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 08:07:04 +0200 Subject: [PATCH 2/3] Build the dashboard snapshot cache key from the one function that owns it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dashboardSnapshotCacheKey` documents itself as the single source of truth for the key, and the invalidators sweep by it, but the read spelled the same string out by hand. The two matched, so nothing was broken today — edit one side and the other stops matching silently, and the only symptom is a dashboard that keeps painting the body from before the write. The read now calls the function. The guard warms the cell through the real read and then asserts the entry sits at the composed key exactly, so a suffix change on either side is caught rather than forgiven by the prefix sweep; two more cases cover a snapshot invalidation and a recorded dose. The header on the shared read claimed both entry points ride the same cell. That claim now holds, but only because the cache registry is pinned to the global, so the header says so. --- .../__tests__/snapshot-read-cache-key.test.ts | 87 +++++++++++++++++++ src/lib/dashboard/snapshot-read.ts | 25 ++++-- 2 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts 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, From 8a316f582082e6a0ef11a3d1b9227710a34b2670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 08:20:34 +0200 Subject: [PATCH 3/3] Record that the cache pin was checked against the entrypoint the image runs The reading behind the pin was taken under `next start`. The container runs `node .next/standalone/server.js`, and a fix that is green everywhere and inert in production is the failure this project has paid for before, so the same probe was re-run under the standalone entrypoint with static assets and public copied the way the Dockerfile copies them. No difference: three module instantiations either way, one shared registry with the pin, separate registries without it, and a measurement write made through a route handler reaches the page render's cache only when the pin is there. The note says which entrypoint the claim rests on so the next person does not have to wonder. --- src/lib/cache/server-cache.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 9119f8dbb..8e4cead77 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -712,13 +712,19 @@ type ServerCacheRegistry = ReturnType; * 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 (`next build && next start`, one 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. + * 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`