mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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>
171 lines
7.5 KiB
TypeScript
171 lines
7.5 KiB
TypeScript
/**
|
||
* 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("");
|
||
}
|