mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge work/cache-instance-proof
This commit is contained in:
commit
ee0375142c
4 files changed
+438
-236
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+272
-230
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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