Files
HealthLog/src/lib/dashboard/snapshot-read.ts
T
Marc-André Bombeck 567a564913 perf(auth): resolve the caller to a narrow user projection
The session lookup and the Bearer resolver loaded the whole users row on
every authenticated request, including the profile picture bytes, the
cached briefing text and every stored integration credential. Both now
select SESSION_USER_SELECT, a named allowlist of the small columns the
request path reads, and so does the owner load for a switched record.

The few routes that need a credential or a large column read it
themselves at the moment of use: the factor verifiers load the password
hash and TOTP secret, the profile response loads the insurance number,
and the snapshot read loads the cached briefing text. A structural guard
holds the lookups to the projection and keeps every Bytes and
secret-shaped column out of it.
2026-10-20 20:07:25 +02:00

216 lines
8.8 KiB
TypeScript

/**
* Shared cached-snapshot read for the two dashboard entry points:
*
* - `GET /api/dashboard/snapshot` (the client cell's endpoint), and
* - the dashboard RSC wrapper (`src/app/page.tsx`), which server-
* 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, 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 { SessionUser } from "@/lib/auth/session-user";
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 {
applyBriefingCapability,
buildDashboardSnapshot,
type DashboardSnapshot,
type SnapshotUserInput,
} from "@/lib/dashboard/snapshot";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import type { Locale } from "@/lib/i18n/config";
import { briefingForToday } from "@/lib/daily/briefing-today";
import { requestTodayBriefingWarm } from "@/lib/daily/briefing-today-warm";
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
import { userDayKey } from "@/lib/tz/format";
import {
INSIGHTS_CACHE_SELECT,
openInsightsCachedText,
} from "@/lib/insights/cached-text-cipher";
/**
* Per-key TTL for the snapshot cache entry. Strictly greater than the
* client's 120 s refetch interval so a scheduled poll lands on a warm
* entry instead of a guaranteed miss that re-runs the full builder. The
* 60 s headroom absorbs interval jitter / a poll firing a touch late.
* The analytics bucket's 60 s default still governs the slim / thick /
* mood cells; only this key is lengthened. Eviction on writes is
* unchanged (the `${userId}|` prefix sweep / point-delete both ignore
* the TTL).
*/
export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
/**
* Resolve the snapshot's briefing for today, on every read.
*
* The same rule the digest applies, here so the dashboard, the RSC prefetch,
* the iOS snapshot read and the digest all serve one answer: text generated
* on an earlier calendar day (or at an unknown time) is not served, a
* reading-backed signal whose metric has no reading today is dropped, and
* every delta reads at its metric's precision. Applied after the cache like
* the capability, so a body cached at 23:58 does not serve yesterday's text
* at 00:01. A withheld briefing reads "preparing" while a warm is due, never
* "ready" with nothing in it.
*/
export function applyBriefingForToday(
body: DashboardSnapshot,
ctx: {
generatedAt: string | null;
timezone: string;
language: Locale;
now: Date;
},
): DashboardSnapshot {
if (!body.briefing) return body;
const lastSeen = body.tiles?.lastSeenByType ?? {};
const briefing = briefingForToday(body.briefing, {
generatedAt: ctx.generatedAt,
lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null,
timezone: ctx.timezone,
todayLocalDate: userDayKey(ctx.now, ctx.timezone),
language: ctx.language,
});
if (briefing) return { ...body, briefing };
return {
...body,
briefing: null,
briefingStale: false,
briefingState:
body.briefingState === "ready" ? "preparing" : body.briefingState,
};
}
export interface SnapshotReadResult {
body: DashboardSnapshot;
/** The locale the snapshot was resolved (and cache-keyed) under. */
locale: Locale;
}
export interface SnapshotReadOptions {
/**
* A locale already resolved by the caller. Background paths (the morning
* briefing push) have no request to resolve one from and pass the job-side
* resolution here so the digest and the push copy agree on a language.
*/
locale?: Locale;
/** The read's clock; the digest passes its own so both agree on today. */
now?: Date;
}
/**
* Resolve locale + read the snapshot through the SWR cache for an already
* authenticated user row. `time` lets the API route keep its per-sub-query
* timing surface; the RSC prefetch passes none.
*/
export async function readDashboardSnapshotCached(
user: SessionUser,
time?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
options: SnapshotReadOptions = {},
): Promise<SnapshotReadResult> {
// The cached briefing text is not on the per-request user
// (`SESSION_USER_SELECT`), so it is read here, beside the locale: every
// read needs it for the generated-at stamp below, not only a cache miss.
const [cacheRow, locale] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: INSIGHTS_CACHE_SELECT,
}),
// v1.21.2 — the briefing recall + forward-look is locale-specific
// prose, so the snapshot cache key carries the resolved locale: an EN and
// a DE session never share a snapshot cell (and never see each other's
// memory wording). The `${user.id}|` prefix still covers the key under
// the measurement-write stale-sweep.
resolveServerLocale({
userLocale: user.locale,
override: options.locale ?? null,
}),
]);
const cachedText = openInsightsCachedText(cacheRow);
const snapshotUser: SnapshotUserInput = {
id: user.id,
username: user.username,
displayName: user.displayName,
timezone: user.timezone,
heightCm: user.heightCm,
dateOfBirth: user.dateOfBirth,
gender: user.gender,
glucoseUnit: user.glucoseUnit,
onboardingTourCompleted: user.onboardingTourCompleted,
insightsCachedText: cachedText,
insightsCachedAt: user.insightsCachedAt,
insightsCachedLocale: user.insightsCachedLocale,
dashboardWidgetsJson: user.dashboardWidgetsJson,
sourcePriorityJson: user.sourcePriorityJson,
thresholdsJson: user.thresholdsJson,
healthScoreConfigJson: user.healthScoreConfigJson,
};
// Stale-while-revalidate: a measurement write marks the analytics
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
// sync serves the prior snapshot immediately (within the bucket's
// stale window) while a single background recompute warms a fresh one
// — the foreground request never pays the cold rebuild. Hard-evicting
// writes (widget reorder) still drop the key outright, forcing a clean
// miss + synchronous rebuild here.
const [cached, briefingAi] = await Promise.all([
cachedSwr(
caches.analytics as ServerCache<DashboardSnapshot>,
// 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,
),
aiCapabilityToServe(user.id, "briefing"),
]);
// The briefing is model text: shown only while the record's `briefing`
// capability is available, decided on every read rather than baked into
// the cached body. The record's own state decides, whoever is reading, so a
// delegate sees the owner's briefing exactly when the owner would.
const now = options.now ?? new Date();
const generatedAt = readBriefingGeneratedAt(cachedText);
const body = applyBriefingForToday(
applyBriefingCapability(cached, briefingAi),
{ generatedAt, timezone: user.timezone, language: locale, now },
);
// The day's first read with no briefing written today, or the first read
// after a signal metric's first reading of the day, asks for a briefing
// for today (`briefing-today-warm.ts`). Never awaited by the read beyond
// its claim, never thrown.
if (briefingAi.available) {
const lastSeen = cached.tiles?.lastSeenByType ?? {};
void requestTodayBriefingWarm({
userId: user.id,
locale,
timezone: user.timezone,
generatedAt,
lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null,
now,
}).catch(() => undefined);
}
return { body, locale };
}