Files
HealthLog/src/lib/request-cache.ts
T
Marc-André Bombeck 7bef199039 v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.

Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.

Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.

The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
2026-05-16 19:46:43 +02:00

74 lines
2.8 KiB
TypeScript

/**
* v1.4.33 — per-request memoisation carrier.
*
* Multiple gated routes share the same in-process request lifecycle
* (api-handler wraps every handler in `eventStorage.run(builder, …)`),
* yet the gate helpers and snapshot builders re-read the same row on
* every call. The Coach drawer opening fires five fetches in parallel,
* each of which lands a `requireAssistantSurface("coach")` call —
* five separate `SELECT` against `AppSettings.singleton` for the same
* value within the same wall-clock millisecond.
*
* This helper attaches a request-scoped `Map<string, Promise<unknown>>`
* to the active `WideEventBuilder` from `@/lib/logging/context`. Same
* key inside one request = one compute; different requests get
* independent caches; outside any request (e.g. tests with no event
* context) the factory runs every time so behaviour is unchanged.
*
* The cache is keyed by string. Callers compose composite keys (e.g.
* `"assistant-flags"` or `"coach-snapshot:${userId}:${windowKey}"`).
* The cache is intentionally weak: it lives on the event builder
* itself via a `WeakMap`, so it goes away as soon as the request
* finishes and the builder is collected.
*/
import { getEvent } from "@/lib/logging/context";
import type { WideEventBuilder } from "@/lib/logging/event-builder";
const REQUEST_CACHES = new WeakMap<WideEventBuilder, Map<string, unknown>>();
function getCache(builder: WideEventBuilder): Map<string, unknown> {
let cache = REQUEST_CACHES.get(builder);
if (!cache) {
cache = new Map<string, unknown>();
REQUEST_CACHES.set(builder, cache);
}
return cache;
}
/**
* Memoise `factory()` for the lifetime of the current request. Returns
* the factory's result directly when no event context is active so
* unit tests and background jobs that don't enter `eventStorage.run()`
* keep their existing semantics.
*
* The cached value is the Promise itself, so concurrent callers within
* the same request await one outstanding compute instead of starting
* their own — important for the gate-cascade pattern where five
* mounts fire `requireAssistantSurface()` in parallel.
*/
export function memoizePerRequest<T>(
key: string,
factory: () => Promise<T>,
): Promise<T> {
const event = getEvent();
if (!event) {
return factory();
}
const cache = getCache(event);
const existing = cache.get(key);
if (existing !== undefined) {
return existing as Promise<T>;
}
const promise = factory();
cache.set(key, promise);
// If the factory rejects, evict so a retry on the same key inside
// this request gets a fresh attempt. Cached failures inside a single
// request would mask real recovery (e.g. transient DB hiccup).
promise.catch(() => {
if (cache.get(key) === promise) {
cache.delete(key);
}
});
return promise;
}