perf(api): wire 6 remaining hot reads through the server cache

The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.

Reads wrapped (60 s TTL unless noted):
  - GET /api/mood/analytics                 → caches.moodAnalytics, key userId
  - GET /api/workouts                       → caches.workouts, key includes
                                              all params (limit/offset/since/
                                              until/sportType)
  - GET /api/bugreport/status               → caches.bugreportStatus, key
                                              "singleton" (10 min TTL);
                                              isAdmin layered per request
  - GET /api/medications                    → caches.medications, key userId
  - GET /api/dashboard/widgets              → caches.dashboardWidgets, key
                                              userId (5 min TTL)

Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.

The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.

The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 22:24:56 +02:00
1 parent 49e599b04e
commit 7bbcc872f8
7 files changed
+252 -125

No files matched your search

+44 -11
View File
@@ -12,17 +12,16 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
// client hammer Postgres in a loop.
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
if (!rl.allowed) {
return apiError("Rate limit exceeded", 429);
}
interface BugreportStatusCacheValue {
configured: boolean;
enabled: boolean;
hasToken: boolean;
hasRepo: boolean;
}
async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
@@ -42,10 +41,44 @@ export const GET = apiHandler(async () => {
// Default ON when the column has never been written.
const enabled = appSettings?.bugReportEnabled !== false;
return { configured, enabled, hasToken, hasRepo };
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
// client hammer Postgres in a loop.
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
if (!rl.allowed) {
return apiError("Rate limit exceeded", 429);
}
// Cache the global app-settings shape on a singleton key (per blueprint §3).
// `isAdmin` lives outside the cache because it varies per request. The
// `invalidateAppSettings()` helper called from
// `PATCH /api/admin/app-settings` evicts this entry when bug-report
// configuration changes.
const status = await cached(
caches.bugreportStatus as ServerCache<BugreportStatusCacheValue>,
"singleton",
() => buildBugreportStatusValue(),
annotate,
);
annotate({
action: { name: "bugreport.status" },
meta: { configured, enabled, has_token: hasToken, has_repo: hasRepo },
meta: {
configured: status.configured,
enabled: status.enabled,
has_token: status.hasToken,
has_repo: status.hasRepo,
},
});
return apiSuccess({ configured, enabled, isAdmin: user.role === "ADMIN" });
return apiSuccess({
configured: status.configured,
enabled: status.enabled,
isAdmin: user.role === "ADMIN",
});
});
@@ -25,6 +25,7 @@ import {
serializeDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import type { NextRequest } from "next/server";
@@ -86,6 +87,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
{ isolationLevel: "Serializable" },
);
// The partial-update path mutates the same `User.dashboardWidgetsJson`
// blob the `/api/dashboard/widgets` GET reads from. Bust the cache so
// the next dashboard mount surfaces the new chartOverlayPrefs slot.
invalidateUserDashboardWidgets(user.id);
annotate({
action: { name: "dashboard.chartOverlayPrefs.update" },
meta: {
+18 -6
View File
@@ -22,6 +22,7 @@ import {
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
@@ -94,15 +95,26 @@ const layoutSchema = z.object({
.optional(),
});
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { dashboardWidgetsJson: true },
});
return resolveDashboardLayout(row?.dashboardWidgetsJson);
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
// 5-minute TTL per blueprint §5; the layout changes only when the user
// hits the Settings → Dashboard save button, which invalidates this
// cache via `invalidateUserDashboardWidgets()`.
const layout = await cached(
caches.dashboardWidgets as ServerCache<DashboardLayout>,
user.id,
() => buildDashboardLayout(user.id),
annotate,
);
return apiSuccess(layout);
});
+35 -29
View File
@@ -16,40 +16,34 @@ import {
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { getUserTodayBounds } from "@/lib/timezone";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { NextRequest } from "next/server";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
type MedicationsListResult = Array<Record<string, unknown>>;
// Compute today's UTC range for event counting
const userTz = user.timezone || "Europe/Berlin";
async function buildMedicationsList(
userId: string,
userTz: string,
): Promise<MedicationsListResult> {
const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds(
new Date(),
userTz,
);
// Run all three queries in parallel
const [medications, latestIntakes, todayEvents] = await Promise.all([
prisma.medication.findMany({
where: { userId: user.id },
where: { userId },
include: { schedules: true },
orderBy: { createdAt: "desc" },
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: {
userId: user.id,
skipped: false,
takenAt: { not: null },
},
where: { userId, skipped: false, takenAt: { not: null } },
_max: { takenAt: true },
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: {
userId: user.id,
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
},
where: { userId, scheduledFor: { gte: todayStartUtc, lte: todayEndUtc } },
_count: { id: true },
}),
]);
@@ -76,24 +70,36 @@ export const GET = apiHandler(async () => {
getEvent()?.addWarning("Medication categories could not be loaded");
}
return medications.map((m) => ({
...m,
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
}));
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const userTz = user.timezone || "Europe/Berlin";
// Cache the list shape on userId. The 60s TTL bounds the cross-midnight
// staleness window for `todayEventCount` (the only time-of-day-derived
// field in the response); writes via POST/PUT/DELETE flush through
// `invalidateUserMedications` before the next read lands.
const result = await cached(
caches.medications as ServerCache<MedicationsListResult>,
user.id,
() => buildMedicationsList(user.id, userTz),
annotate,
);
annotate({
action: { name: "medication.list" },
meta: { count: medications.length },
meta: { count: result.length },
});
return apiSuccess(
medications.map((m) => ({
...m,
// v1.4.25 W4d — `category` is the existing clinical taxonomy
// (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the
// Prisma model has its own `treatmentClass` (GENERIC | GLP1) that
// spreads through `...m` and unlocks the GLP-1 surfaces. Two
// orthogonal concepts, both exposed.
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
})),
);
return apiSuccess(result);
});
export const POST = apiHandler(async (request: NextRequest) => {
+25 -6
View File
@@ -3,6 +3,7 @@ import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
export const dynamic = "force-dynamic";
@@ -26,11 +27,17 @@ function aggregateDailyAverages(
}));
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
interface MoodAnalyticsResult {
entries: Array<{ date: string; score: number; samples: number }>;
summary: ReturnType<typeof summarize>;
entryCount: number;
}
async function buildMoodAnalyticsResponse(
userId: string,
): Promise<MoodAnalyticsResult> {
const moodEntries = await prisma.moodEntry.findMany({
where: { userId: user.id },
where: { userId },
orderBy: { moodLoggedAt: "asc" },
select: { date: true, score: true, moodLoggedAt: true },
});
@@ -39,7 +46,6 @@ export const GET = apiHandler(async () => {
moodEntries.map((e) => ({ date: e.date, score: e.score })),
);
// Build DataPoint array for summarize() using moodLoggedAt as the date
const dataPoints: DataPoint[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
value: e.score,
@@ -47,10 +53,23 @@ export const GET = apiHandler(async () => {
const summary = summarize(dataPoints);
return { entries, summary, entryCount: moodEntries.length };
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const result = await cached(
caches.moodAnalytics as ServerCache<MoodAnalyticsResult>,
user.id,
() => buildMoodAnalyticsResponse(user.id),
annotate,
);
annotate({
action: { name: "mood.analytics" },
meta: { entryCount: moodEntries.length },
meta: { entryCount: result.entryCount },
});
return apiSuccess({ entries, summary });
return apiSuccess({ entries: result.entries, summary: result.summary });
});
@@ -16,6 +16,8 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
vi.mock("@/lib/db", () => ({
prisma: {
workout: {
@@ -100,6 +102,11 @@ function makeRow(
describe("GET /api/workouts — canonical dedup", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset every server cache between tests so a fixed userId doesn't
// carry cached state across cases (v1.4.34.2 — added when the
// `/api/workouts` GET handler started reading through
// `caches.workouts`).
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Default: no per-user source-priority override → fall back to the
// canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ladder.
+117 -73
View File
@@ -54,10 +54,119 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
interface WorkoutsParams {
readonly limit: number;
readonly offset: number;
readonly since: string | null;
readonly until: string | null;
readonly sportType: string | null;
}
interface WorkoutsResult {
workouts: Array<{
id: string;
sportType: string | null;
startedAt: Date | null;
endedAt: Date | null;
durationSec: number | null;
distanceM: number | null;
activeEnergyKcal: number | null;
avgHr: number | null;
maxHr: number | null;
source: string;
externalId: string | null;
}>;
meta: {
total: number;
limit: number;
offset: number;
droppedDuplicates: number;
};
}
async function buildWorkoutsResponse(
userId: string,
params: WorkoutsParams,
): Promise<WorkoutsResult> {
const { limit, offset, since, until, sportType } = params;
const where: Record<string, unknown> = { userId };
if (since || until) {
const range: Record<string, Date> = {};
if (since) {
const d = new Date(since);
if (!Number.isNaN(d.getTime())) range.gte = d;
}
if (until) {
const d = new Date(until);
if (!Number.isNaN(d.getTime())) range.lte = d;
}
if (Object.keys(range).length > 0) {
where.startedAt = range;
}
}
if (sportType) {
where.sportType = sportType;
}
const rows = await prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
},
});
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
});
const canonical = pickCanonicalWorkoutRows(
rows,
userRow?.sourcePriorityJson ?? null,
);
const page = canonical.slice(offset, offset + limit).map((row) => ({
id: row.id,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
}));
return {
workouts: page,
meta: {
total: canonical.length,
limit,
offset,
droppedDuplicates: rows.length - canonical.length,
},
};
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "workouts.list" } });
@@ -79,80 +188,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
const until = searchParams.get("until");
const sportType = searchParams.get("sportType");
const where: Record<string, unknown> = { userId: user.id };
if (since || until) {
const range: Record<string, Date> = {};
if (since) {
const d = new Date(since);
if (!Number.isNaN(d.getTime())) range.gte = d;
}
if (until) {
const d = new Date(until);
if (!Number.isNaN(d.getTime())) range.lte = d;
}
if (Object.keys(range).length > 0) {
where.startedAt = range;
}
}
if (sportType) {
where.sportType = sportType;
}
const params: WorkoutsParams = { limit, offset, since, until, sportType };
const cacheKey = `${user.id}|${limit}|${offset}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
// Read the full filtered set, descending start. The picker requires
// deterministic input order; the `(startedAt, id)` secondary sort
// pins the tie-break for rows that share a millisecond stamp.
const rows = await prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
},
});
// Pass the user's source-priority blob so the picker honours the
// per-user ladder. The picker preserves input order on the returned
// subset; descending-start input → descending-start output.
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
const canonical = pickCanonicalWorkoutRows(
rows,
userRow?.sourcePriorityJson ?? null,
const result = await cached(
caches.workouts as ServerCache<WorkoutsResult>,
cacheKey,
() => buildWorkoutsResponse(user.id, params),
annotate,
);
const page = canonical.slice(offset, offset + limit).map((row) => ({
id: row.id,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
}));
return apiSuccess({
workouts: page,
meta: {
total: canonical.length,
limit,
offset,
droppedDuplicates: rows.length - canonical.length,
},
});
return apiSuccess(result);
});