mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Build the dashboard snapshot cache key from the one function that owns it
`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.
This commit is contained in:
1 parent
96a3743009
commit
0fbaf6b2d3
2 files changed
+106
-6
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<DashboardSnapshot>,
|
||||
`${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,
|
||||
|
||||
Reference in new issue
Block a user