Merge work/cache-instance-proof

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 08:21:18 +02:00
commit ee0375142c
4 files changed
+438 -236

No files matched your search

+60
View File
@@ -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();
});
});
+272 -230
View File
@@ -463,242 +463,284 @@ export class ServerCache<T> {
}
/**
* 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<unknown>({
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<unknown>({
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<unknown>({
name: "achievements",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
dashboardWidgets: new ServerCache<unknown>({
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<WorkoutsProjection>({
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<unknown>({
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<unknown>({
name: "medicationCompliance",
maxEntries: 2000,
ttlMs: 900_000,
staleTtlMs: 600_000,
}),
moodAnalytics: new ServerCache<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
name: "achievements",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
dashboardWidgets: new ServerCache<unknown>({
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<WorkoutsProjection>({
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<unknown>({
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<unknown>({
name: "medicationCompliance",
maxEntries: 2000,
ttlMs: 900_000,
staleTtlMs: 600_000,
}),
moodAnalytics: new ServerCache<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
name: "medicationListLayout",
maxEntries: 500,
ttlMs: 300_000,
}),
} as const;
}
type ServerCacheRegistry = ReturnType<typeof createServerCaches>;
/**
* 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
@@ -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);
});
});
+19 -6
View File
@@ -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,