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,