Files
HealthLog/src/lib/ai/coach/snapshot-cache.ts
T
Marc-André Bombeck 5433fc8fbd chore(release): v1.28.26 — internal restructuring for maintainability
No user-facing change. Eight of the largest source files split along their
natural seams into focused modules, every move verbatim with stable import
paths:

- mood analytics calculators (mood-tag-influence / mood-crosstab /
  mood-better-days / mood-patterns; hub 2229 → 770 lines)
- insight status-invalidation machinery (status-invalidation.ts)
- feature extraction (feature-blocks / signals-of-day)
- doctor report types + pure helpers (doctor-report-types /
  doctor-report-helpers; collector 1756 → 1002)
- Google Health mapping layer (mappers.ts; client 2153 → 1022, acyclic
  client → mappers with type-only back-imports)
- coach chat bubbles + read-aloud controls (chat-bubble / read-aloud;
  message-thread 1713 → 464)
- Telegram webhook handlers to lib (route 1307 → 92 — handler thin, logic in
  lib; rate-limit-before-secret ordering byte-identical)
- coach snapshot cache, series helpers and the four largest per-metric blocks
  (snapshot-cache / snapshot-series / snapshot-blocks; 2691 → 1896)

The glucose-panel-window structural test follows the moved alias; two unused
imports left by the feature split are removed.
2026-07-11 08:43:41 +02:00

89 lines
3.2 KiB
TypeScript

/**
* In-memory result cache for the Coach snapshot builder.
*
* v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The
* snapshot reads only persisted data; a single chat conversation sends
* 2-4 turns within a minute and the snapshot would otherwise rebuild
* from the same rows each turn. Caching the result for 60s shaves the
* ~10 measurement reads + the GLP-1 / mood / intake side-fetches off
* every turn after the first, which `.planning/round-v1433-audit-perf.md`
* §3.3 estimates at 200-800 ms of server-side tail.
*
* Scope is part of the cache key so a switch from `last30days` to
* `last7days` (or a different `sources` set) computes fresh. The map
* is bounded at 64 entries — a multi-tenant deployment with a few
* active power users sits well inside that ceiling even if each cycles
* through several scopes per minute.
*
* Split out of `snapshot.ts` so the builder file carries only the
* assembly logic; the public surface (`buildCoachSnapshot`,
* `__resetCoachSnapshotCacheForTests`) stays importable from
* `./snapshot` via re-export.
*/
import type { CoachSnapshotResult } from "./snapshot";
import type { CoachScope, CoachScopeWindow } from "./types";
/** Default window when the caller doesn't pass a scope. */
export const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
const SNAPSHOT_TTL_MS = 60_000;
const SNAPSHOT_LRU_MAX = 64;
const snapshotCache = new Map<
string,
{ expiresAt: number; result: CoachSnapshotResult }
>();
export function snapshotCacheKey(
userId: string,
scope: CoachScope | undefined,
): string {
// v1.7.0 — when the request pins an explicit source list, key on it.
// Otherwise the source set is derived from the user's saved
// `dataClusters`, which we don't read here (the cache must stay
// I/O-free on a hit) — key on a stable `clusters` marker instead.
// A cluster change is reflected on the next cache miss (≤60 s), the
// same staleness window every other pref change already tolerates.
const window = scope?.window ?? DEFAULT_WINDOW;
const sourceList =
scope?.sources && scope.sources.length > 0
? Array.from(scope.sources).sort().join(",")
: "clusters";
return `${userId}|${window}|${sourceList}`;
}
export function readSnapshotCache(key: string): CoachSnapshotResult | null {
const entry = snapshotCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
snapshotCache.delete(key);
return null;
}
// Touch for LRU — re-insert moves to the end of the Map's iteration order.
snapshotCache.delete(key);
snapshotCache.set(key, entry);
return entry.result;
}
export function writeSnapshotCache(
key: string,
result: CoachSnapshotResult,
): void {
if (snapshotCache.size >= SNAPSHOT_LRU_MAX) {
// Evict the oldest entry — JS Map iteration order is insertion order,
// so the first key is the least-recently inserted/touched.
const oldest = snapshotCache.keys().next().value;
if (oldest !== undefined) {
snapshotCache.delete(oldest);
}
}
snapshotCache.set(key, {
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
result,
});
}
/** Clear the snapshot cache. Test-only escape hatch. */
export function __resetCoachSnapshotCacheForTests(): void {
snapshotCache.clear();
}