perf(insights-targets): consume mood-rollup tier and floor distinct measurement scan

Audit Critical Finding #2 + High finding 3 (round-v1439-arch-qa-infra-db).
The mood block at the bottom of the route used to run an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` on every mount —
pulling every mood the user had ever logged into Node only to bucket-
average them in JS. The `findMany({distinct:['type']})` for 'absolute
latest measurement per type' carried no `measuredAt >=` floor either,
so on a 347 k-row tenant Postgres still had to sort the full set
before the driver applied DISTINCT ON (Prisma's `distinct` doesn't
compile to PG's `DISTINCT ON`).

This commit:
  - swaps the mood findMany for `readMoodDayRollups(userId, since)`
    plus a single `findFirst` for the latest score; coverage-fallback
    runs a bounded 30-day raw walk + fires `ensureUserMoodRollupsFresh`
    so the next request lands on the rollup tier (same posture as
    /api/mood/analytics post-v1.4.39)
  - adds a 365-day floor to the latest-ever-per-type read so the
    driver-side distinct walk stays bounded
  - pins all three behaviours in `__tests__/route.test.ts` (4 new
    tests: rollup fast path, coverage-fallback bounded walk, no-mood
    branch, distinct floor present)
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:52:09 +02:00
1 parent 691443e264
commit f8de4b05fd
2 files changed
+404 -34

No files matched your search

@@ -0,0 +1,280 @@
/**
* v1.4.40 W-INSIGHTS — `/api/insights/targets` mood-rollup tier swap.
*
* Audit Critical Finding #2 (`.planning/round-v1439-arch-qa-infra-db.md`)
* flagged the unbounded `prisma.moodEntry.findMany({ where: { userId } })`
* at the bottom of the route as one of the six unbounded mood walks
* that paint the Insights surface 12 s cold on a power-user account.
* This file pins:
*
* 1. **The mood-rollup DAY tier replaces the raw findMany when
* populated.** Mood targets (`MOOD_SCORE`, `MOOD_STABILITY`)
* compute their `current` / `average30` / `consistency7d` from
* the rollup row set; `prisma.moodEntry.findMany` is never
* called on the fast path. A single `findFirst` reads the
* latest score (one bounded row).
*
* 2. **The coverage-fallback runs the live walk once when the
* rollup tier is empty but raw entries exist** (legacy account
* before the boot-time backfill has caught up). Bounded by the
* trailing 30-day window so the fallback never repeats the
* legacy unbounded scan.
*
* 3. **The `findMany distinct(["type"])` "latest ever per type"
* query carries a `measuredAt: { gte: oneYearAgo }` floor**
* (audit High finding 3). Without the floor Postgres still has
* to sort the full 347 k-row tenant before applying DISTINCT ON
* because Prisma's `distinct` does not compile to PG's
* `DISTINCT ON`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
appSettings: { findUnique: vi.fn() },
$queryRaw: vi.fn(),
$queryRawUnsafe: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the warm-up so test runs don't fire the real
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget
// on the route so its return value is irrelevant for parity.
vi.mock("@/lib/mood/rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/mood/rollups")>(
"@/lib/mood/rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
};
});
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_USER = {
id: "user-targets-1",
username: "marc",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
locale: "en",
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: SESSION_USER as never,
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/targets");
}
interface TargetsBody {
data: {
targets: Array<{
type: string;
current: number | null;
average30: number | null;
daysInRange7d: number;
daysLogged7d: number;
insufficientData: boolean;
}>;
};
}
beforeEach(() => {
vi.resetAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK);
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
gender: "MALE",
glucoseUnit: "mg/dL",
thresholdsJson: null,
});
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
});
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
it("returns a mood target driven by the rollup DAY tier without touching the raw findMany", async () => {
// Five DAY-rollup rows over the trailing 30 days. The route reads
// them directly to drive `MOOD_SCORE.current/average30/consistency`
// — no raw `moodEntry.findMany` on the fast path.
const today = new Date();
const day = (offset: number) => {
const d = new Date(today);
d.setUTCHours(0, 0, 0, 0);
d.setUTCDate(d.getUTCDate() - offset);
return d;
};
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() },
]);
// Latest score read — one bounded row.
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
score: 4,
moodLoggedAt: day(0),
});
const res = await callGet(makeReq());
expect(res.status).toBe(200);
// The unbounded raw `moodEntry.findMany` must not fire on the
// rollup-tier fast path.
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
const body = (await res.json()) as TargetsBody;
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
expect(moodTarget).toBeDefined();
expect(moodTarget?.current).toBe(4);
// Average over 5 daily means: (4+4+5+4+4.5)/5 = 4.3
expect(moodTarget?.average30).toBeCloseTo(4.3, 1);
});
it("falls back to the bounded raw walk when the rollup tier is empty but raw mood entries exist", async () => {
// Rollup empty → coverage-fallback fires the legacy live walk
// once. The fallback is bounded by the 30-day window — not the
// legacy unbounded scan over every mood the user ever wrote.
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
const today = new Date();
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
score: 4,
moodLoggedAt: today,
});
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ score: 4, moodLoggedAt: new Date(today.getTime() - 2 * 86_400_000) },
{ score: 5, moodLoggedAt: new Date(today.getTime() - 1 * 86_400_000) },
{ score: 4, moodLoggedAt: today },
]);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
// Live fallback fired exactly once. The fallback `findMany` call
// must carry a 30-day `moodLoggedAt: { gte: ... }` floor — i.e. a
// `where` clause with a timestamp filter, not the legacy
// unbounded `where: { userId }` shape.
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
const fallbackCall = (prisma.moodEntry.findMany as ReturnType<typeof vi.fn>)
.mock.calls[0][0];
expect(fallbackCall.where).toHaveProperty("moodLoggedAt");
expect(fallbackCall.where.moodLoggedAt).toHaveProperty("gte");
const body = (await res.json()) as TargetsBody;
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
expect(moodTarget).toBeDefined();
expect(moodTarget?.current).toBe(4);
});
it("emits no mood target when neither rollup nor raw entries exist", async () => {
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as TargetsBody;
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
expect(moodTarget).toBeUndefined();
});
it("floors the `findMany distinct` latest-ever-per-type read at one year", async () => {
// Audit High finding 3: the `findMany({orderBy: measuredAt desc,
// distinct: ["type"]})` for "absolute latest measurement per type"
// used to run WITHOUT a `measuredAt >=` floor. On a 347 k-row
// tenant Postgres still has to sort the full set before applying
// DISTINCT ON because Prisma's `distinct` does not compile to PG's
// `DISTINCT ON` — it dedups in the driver. v1.4.40 adds a
// 365-day floor so the planner-side walk is bounded.
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
await callGet(makeReq());
// Find the `distinct: ["type"]` call.
const calls = (
prisma.measurement.findMany as ReturnType<typeof vi.fn>
).mock.calls.map((c) => c[0]);
const distinctCall = calls.find(
(c) =>
c &&
Array.isArray(c.distinct) &&
c.distinct[0] === "type" &&
c.orderBy?.measuredAt === "desc",
);
expect(distinctCall).toBeDefined();
// The `measuredAt >= oneYearAgo` floor must be present.
expect(distinctCall?.where?.measuredAt).toBeDefined();
expect(distinctCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
const flooredAt = distinctCall?.where?.measuredAt?.gte as Date;
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
const ageMs = Date.now() - flooredAt.getTime();
// Floor is somewhere around one year ago — give a ±1-hour window
// for clock drift between the route's `Date.now()` and the test
// assertion.
expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000);
expect(ageMs).toBeLessThan(oneYearMs + 3_600_000);
});
});
+124 -34
View File
@@ -33,6 +33,10 @@ import {
} from "@/lib/analytics/effective-range";
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/mood/rollups";
export const dynamic = "force-dynamic";
@@ -184,12 +188,26 @@ async function buildTargetsResponse(user: AuthedUser) {
select: { type: true, value: true, measuredAt: true },
});
// Also get the absolute latest measurement per type (even if older than 30 days).
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
// (Postgres) emulated via a windowed orderBy + take-1 per type would still be
// N round-trips, so we sort once on the wide query and pick per-type below.
// Absolute latest measurement per type within a one-year floor.
//
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
// driver dedups after pulling the rows, so an unbounded scan on a
// 347 k-row tenant pulls every measurement back to Node only to drop
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
// floor caps the planner-side walk to the existing
// `(user_id, type, measured_at)` index range; the trade-off is that a
// user who hasn't measured a metric in over a year sees the tile's
// `current` value as null instead of pulling a stale year-old reading
// forward — which is the correct behaviour for the consistency strip
// (the strip already gates on "fewer than 3 readings in 30 days" →
// insufficientData).
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const latestEverByType = await prisma.measurement.findMany({
where: { userId, type: { in: types } },
where: {
userId,
type: { in: types },
measuredAt: { gte: oneYearAgo },
},
orderBy: { measuredAt: "desc" },
distinct: ["type"],
select: { type: true, value: true },
@@ -980,41 +998,112 @@ async function buildTargetsResponse(user: AuthedUser) {
}
// 9. Mood targets (if mood data exists)
const moodEntries = await prisma.moodEntry.findMany({
//
// v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: {
// userId } })` for the persistent mood-rollup DAY tier (audit Critical
// Finding #2). The tile reads:
// - per-day mean over the trailing 30 days (avg30, trend, stability)
// - the consistency strip (in/near/out band per day)
// - the latest score for the "current" reading + classification
//
// Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per
// calendar day. Per-day mean replaces the per-entry score series the
// legacy path passed to `rollupConsistency` \u2014 the helper buckets by
// day-key anyway, so feeding one row per day with the pre-computed
// mean is mathematically identical to feeding N raw entries that
// collapse to the same mean.
//
// The "latest score" needs a single raw entry (the newest one), so we
// pull it with `take: 1` \u2014 bounded by the index, never an unbounded
// scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so
// the cold-start branch stays consistent with the legacy semantics
// (which counted raw entries, not raw days).
//
// Coverage-fallback: when the user has mood entries but zero rollup
// rows yet (legacy account before the boot-time backfill caught up)
// we run the legacy live walk once and fire the warm-up so the next
// request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Five-year window mirrors `/api/mood/analytics` so existing rollup
// coverage is consulted; the trailing 30-day filter happens in JS
// because the consistency strip already walks per-day buckets.
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
const moodSince = new Date(Date.now() - moodRollupWindowMs);
const moodRollups = await readMoodDayRollups(userId, moodSince);
const latestMoodEntry = await prisma.moodEntry.findFirst({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
select: { score: true, moodLoggedAt: true },
});
if (moodEntries.length >= 3) {
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recentMood = moodEntries.filter(
(entry) => entry.moodLoggedAt >= thirtyDaysAgoMood,
);
const latestMoodScore = moodEntries[0]?.score ?? null;
// Build a per-day event list for the consistency strip + 30-day stats.
// Use the rollup tier when populated; fall back to a bounded raw walk
// when the user has mood entries but zero rollup coverage yet.
let moodDailyEvents: Array<{ measuredAt: Date; value: number }>;
let moodEntryCount: number;
let moodSumScores: number;
let moodVarianceN: number;
let moodSumSquares: number;
if (moodRollups.length > 0) {
moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0);
moodDailyEvents = moodRollups
.filter((r) => r.bucketStart >= thirtyDaysAgoMood)
.map((r) => ({ measuredAt: r.bucketStart, value: r.mean }));
moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0);
moodVarianceN = moodDailyEvents.length;
moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0);
} else if (latestMoodEntry !== null) {
// Coverage-fallback: rollups empty but raw entries exist. Pull the
// trailing 30-day window only (instead of every entry ever) \u2014 that's
// the bound the legacy code was missing.
const recentRawMood = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: thirtyDaysAgoMood } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
});
moodEntryCount = recentRawMood.length;
moodDailyEvents = recentRawMood.map((e) => ({
measuredAt: e.moodLoggedAt,
value: e.score,
}));
moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0);
moodVarianceN = recentRawMood.length;
moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0);
} else {
moodEntryCount = 0;
moodDailyEvents = [];
moodSumScores = 0;
moodVarianceN = 0;
moodSumSquares = 0;
}
if (moodEntryCount >= 3) {
const latestMoodScore = latestMoodEntry?.score ?? null;
const moodAvg30 =
recentMood.length > 0
moodDailyEvents.length > 0
? Math.round(
(recentMood.reduce((sum, e) => sum + e.score, 0) /
recentMood.length) *
10,
(moodSumScores / moodDailyEvents.length) * 10,
) / 10
: null;
// Mood trend: compare first half to second half of last 30 days
// Mood trend: compare first half to second half of last 30 days.
// The rollup tier carries one DataPoint per day (the daily mean),
// matching the legacy comparison semantics on single-entry days.
let moodTrend: "up" | "down" | "stable" | null = null;
if (recentMood.length >= 4) {
const sorted = [...recentMood].sort(
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
if (moodDailyEvents.length >= 4) {
const sorted = [...moodDailyEvents].sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
);
const mid = Math.floor(sorted.length / 2);
const firstHalf = sorted.slice(0, mid);
const secondHalf = sorted.slice(mid);
const avgFirst =
firstHalf.reduce((s, e) => s + e.score, 0) / firstHalf.length;
firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length;
const avgSecond =
secondHalf.reduce((s, e) => s + e.score, 0) / secondHalf.length;
secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length;
const diff = avgSecond - avgFirst;
if (diff > 0.2) moodTrend = "up";
else if (diff < -0.2) moodTrend = "down";
@@ -1031,12 +1120,8 @@ async function buildTargetsResponse(user: AuthedUser) {
: null;
const moodRange = { min: 3.5, max: 5 };
const moodEventsForConsistency = recentMood.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodConsistency = rollupConsistency(
moodEventsForConsistency,
moodDailyEvents,
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
);
targets.push({
@@ -1052,13 +1137,18 @@ async function buildTargetsResponse(user: AuthedUser) {
...moodConsistency,
});
// Mood stability: standard deviation of recent scores (lower = more stable)
if (recentMood.length >= 5) {
const mean = moodAvg30 ?? latestMoodScore ?? 3;
const variance =
recentMood.reduce((sum, e) => sum + (e.score - mean) ** 2, 0) /
recentMood.length;
const stdDev = Math.round(Math.sqrt(variance) * 100) / 100;
// Mood stability: standard deviation of the recent series. The
// rollup tier surfaces one mean per day, which is the right
// resolution for "is the user's mood stable across days" \u2014 a power
// user logging multiple times per day with a wide intraday spread
// still gets a "stable" badge if the daily mean barely moves. The
// legacy path conflated intraday variance with day-to-day stability
// by passing per-entry scores; the rollup-tier semantic is closer
// to what the UI label suggests.
if (moodVarianceN >= 5) {
const seriesMean = moodSumScores / moodVarianceN;
const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean;
const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100;
const stabilityClassification =
stdDev <= 0.5