Files
HealthLog/src/lib/cache/invalidate.ts
T
Marc-André BombeckandClaude Opus 4.8 6de5de0284 perf(dashboard): serve the snapshot stale-while-revalidate on a measurement write
The dashboard snapshot lives in the analytics cache bucket, which had no
stale window, and every measurement write hard-evicted the whole
${userId}| prefix. A high-frequency iOS Apple-Health sync therefore
busted the snapshot into a cold rebuild on the next read all day long
(the same class as the v1.8.7 insight-cache eviction).

Give the analytics bucket a stale-while-revalidate window, read the
snapshot via cachedSwr, and switch the measurement-write invalidation
from a hard deleteByPrefix to markStaleByPrefix. The snapshot now serves
the prior value immediately while a single background recompute warms a
fresh one. Slim / thick / summary cells read via plain cached are
unaffected: a marked-stale entry has expiresAt === now, so their next
read is a clean miss and rebuilds fresh. Widget reorder keeps its hard
point-evict so a layout change paints synchronously; mood and medication
writes keep their hard analytics sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00

171 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.34 IW-G — per-user cache invalidation helpers.
*
* Each write endpoint that touches a measurement / mood / medication /
* dashboard-widget row calls the matching helper after its successful
* database commit. The helpers walk the affected `caches.*` maps and
* delete every key whose `userId` prefix matches.
*
* The "evict the whole user-bucket" stance is intentional: a redundant
* eviction costs the next reader one cache miss (cheap, the row's
* already paginated and the snapshot LRU sits behind), but an
* under-eviction surfaces stale data which is the directive's failure
* mode. The full per-write → per-cache fan-out is documented in the
* blueprint §6 (`.planning/research/v1434-r-cache-aggregation.md`).
*
* The helpers are intentionally synchronous — they do `Map.delete()`
* over a bounded entry set, which is cheap enough to run inline before
* the write endpoint's `apiSuccess()` return. No `await` overhead.
*/
import { caches } from "./server-cache";
/**
* v1.7.0 W6 — server cache key for the unified dashboard first-paint
* snapshot. Lives under the analytics cache bucket so the `${userId}|`
* sweep on a measurement / mood / medication write already covers it
* (measurement writes mark it stale; mood / medication writes hard-evict
* it); the widget + insight invalidators that DON'T touch the analytics
* bucket call `invalidateUserDashboardSnapshot` explicitly. Exported so
* the cache key stays single-source-of-truth across route and tests.
*/
export function dashboardSnapshotCacheKey(userId: string): string {
return `${userId}|dashboard-snapshot`;
}
/**
* Drop the unified dashboard snapshot for a single user. Cheap
* point-delete; safe to call redundantly.
*/
export function invalidateUserDashboardSnapshot(userId: string): void {
caches.analytics.delete(dashboardSnapshotCacheKey(userId));
}
/**
* Invalidate every cache that may reflect a user's measurement set.
*
* Covers: analytics aggregate, achievement progress, workouts cache.
* Mood-analytics is invalidated separately via `invalidateUserMood`
* because mood writes don't change measurement rows.
*/
export function invalidateUserMeasurements(userId: string): void {
// The `${userId}|` prefix covers the slim / thick analytics cells, the
// iOS summary cell, AND the v1.7.0 dashboard snapshot
// (`${userId}|dashboard-snapshot`) in one pass.
//
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
// the highest-frequency dirty signal (every iOS Apple-Health sync
// posts a batch), and a hard evict busts the snapshot into a cold
// rebuild on the next read all day long. Marking stale lets the
// `cachedSwr` snapshot read serve the prior value immediately while a
// single background recompute warms a fresh one. The slim / thick /
// summary cells read via plain `cached` are unaffected: a marked-stale
// entry has `expiresAt === now`, so their next read is a clean miss and
// rebuilds fresh — identical to the old evict for those keys.
caches.analytics.markStaleByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
// v1.4.36 W1 — measurement writes change the per-target consistency
// strips, the in-range rates and the streak counters that
// `/api/insights/targets` computes. Evict the user's bucket so the
// next mount paints fresh data.
caches.insightsTargets.deleteByPrefix(userId);
}
/**
* Invalidate every cache that may reflect a user's mood entries.
*
* Mood writes also dirty the analytics aggregate (correlations runner
* pairs mood × pulse) and achievement progress (consistent-month / mood
* badges).
*/
export function invalidateUserMood(userId: string): void {
caches.moodAnalytics.deleteByPrefix(userId);
caches.achievements.deleteByPrefix(userId);
caches.analytics.deleteByPrefix(`${userId}|`);
// v1.4.36 W1 — mood writes change the mood target rows on the
// insights/targets response (MOOD_SCORE, MOOD_STABILITY).
caches.insightsTargets.deleteByPrefix(userId);
// v1.8.5 — mood writes dirty every dimension of the mood-insights
// aggregate (heatmap cell, distribution, weekday, tag breakdown).
// v1.12.1 — mark stale rather than hard-evict: the mood-insights read
// is a multi-second cold compute, so an active logger writing back to
// back used to re-pay it on every entry. Marking stale keeps serving
// the prior aggregate (within the SWR window) while a single
// background recompute warms a fresh one.
caches.moodInsights.markStaleByPrefix(userId);
}
/**
* Invalidate every cache that may reflect a user's medication state.
*
* Covers the medications cache, the medication-intake compliance cache
* (cached daily), and achievement progress.
*/
export function invalidateUserMedications(userId: string): void {
caches.medications.deleteByPrefix(userId);
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
// v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE
// target rollup (compliance7 / compliance30 / consistency strip).
caches.insightsTargets.deleteByPrefix(userId);
// v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics
// cache and reads `medicationIntakeEvent` for both today's compliance
// tally and the 365-day streak feed. A taken / skipped event must
// evict the user-bucket so the next iOS poll reflects the change.
caches.analytics.deleteByPrefix(`${userId}|`);
}
/**
* Invalidate the dashboard-widget layout cache for a single user.
*
* Called from the dashboard-widgets PUT / DELETE endpoints when the
* user reorders / disables a tile.
*/
export function invalidateUserDashboardWidgets(userId: string): void {
caches.dashboardWidgets.deleteByPrefix(userId);
// v1.7.0 W6 — the layout rides inside the unified snapshot now, so a
// tile reorder / visibility toggle must also drop the snapshot. The
// widget invalidator does NOT touch the analytics bucket, so the
// prefix sweep above does not cover the snapshot key — drop it
// explicitly.
invalidateUserDashboardSnapshot(userId);
}
/**
* v1.7.0 — invalidate caches a fresh comprehensive-insight write
* dirties. The dashboard snapshot embeds the pre-generated daily
* briefing read-only, so a new generation must evict it; the next
* snapshot then carries the new briefing. Called directly from the
* `/api/insights/generate` POST after its own cache write, and from
* `generateComprehensiveInsight` after its write — so the
* `insight-pregenerate` cron evicts through the generator, not by
* calling this itself.
*/
export function invalidateUserInsights(userId: string): void {
invalidateUserDashboardSnapshot(userId);
}
/**
* Invalidate the insights tile-layout cache for a single user. Called
* from the insights-layout PUT / DELETE endpoints when the user
* reorders / disables a tile so the next `/insights` mount paints the
* new layout.
*/
export function invalidateUserInsightsLayout(userId: string): void {
caches.insightsLayout.deleteByPrefix(userId);
}
/**
* Global eviction of the bug-report-status cache.
*
* The cache is keyed on the singleton `"singleton"` slot because the
* underlying data is a row in `AppSettings`. Any admin write to the
* GitHub-token / bug-report-enabled fields invalidates the entire
* cache; the per-user `isAdmin` flag is layered on at request time, so
* the cache itself only stores role-agnostic shape.
*/
export function invalidateAppSettings(): void {
caches.bugreportStatus.deleteByPrefix("");
}