From 5169be5887b712d699024489fac991420600f1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:18 +0200 Subject: [PATCH] fix(insights): order the correlations read desc so a hit cap keeps recent rows The FDR discovery read capped a dense account's 180-day window at orderBy asc + take, so exceeding the cap silently dropped the NEWEST rows. The early-detection pass exists specifically to see the recent window, so a capped read gave it nothing to work with. Order desc for both the measurement and mood reads, re-sort ascending in JS afterward (every downstream day-bucketing pass groups by day key, so pre-sort order doesn't matter to them), and annotate when a read actually hit its cap. --- .../correlations/__tests__/route.test.ts | 21 +++++++++++ src/app/api/insights/correlations/route.ts | 35 ++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index a584897c9..3f4b1ed4e 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -196,6 +196,27 @@ describe("GET /api/insights/correlations", () => { expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); + // v1.30 (DATAINT M1) — `asc` + `take` truncated the RECENT end of the + // 180-day window on a dense account; the read must order `desc` so the + // cap keeps the newest rows (re-sorted `asc` in JS before series-building). + it("reads the measurement + mood window ordered desc so the cap keeps the newest rows", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq()); + + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { measuredAt: "desc" }, + take: 20000, + }), + ); + expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { moodLoggedAt: "desc" }, + take: 5000, + }), + ); + }); + // v1.18.0 (B2) — the route now also requires the `insights` module. it("returns 403 + module.disabled when the insights module is off", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index e741f1607..b4e1899fa 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -101,7 +101,17 @@ export const GET = apiHandler(async () => { // `buildMeasurementDailySeries`'s per-type grain resolution below (source // collapse for cumulative types, per-night reconstruction for sleep); the // rest of the channels never look at them. - const [measurements, moodEntries, priorityJson] = await Promise.all([ + // v1.30 (PERFAUDIT M1) — `orderBy asc` + `take` truncates from the START + // of the window. A dense account (unconsolidated step chunks alone can + // run ~200 rows/day → 36 000 rows over 180 days) can exceed the cap, and + // an ascending order then drops the NEWEST rows — exactly backwards for + // `discoverEmergingCorrelations`, whose entire job is the recent window. + // Order DESC so the cap keeps the most-recent rows, then re-sort ASC in + // JS before folding into per-type series (every downstream day-bucketing + // pass is order-insensitive, but callers document an ascending contract). + const MEASUREMENT_READ_CAP = 20000; + const MOOD_READ_CAP = 5000; + const [measurementsDesc, moodEntriesDesc, priorityJson] = await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id, @@ -109,8 +119,8 @@ export const GET = apiHandler(async () => { measuredAt: { gte: since }, type: { in: [...behaviourTypes, ...outcomeTypes] }, }, - orderBy: { measuredAt: "asc" }, - take: 20000, + orderBy: { measuredAt: "desc" }, + take: MEASUREMENT_READ_CAP, select: { type: true, value: true, @@ -122,12 +132,20 @@ export const GET = apiHandler(async () => { }), prisma.moodEntry.findMany({ where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } }, - orderBy: { moodLoggedAt: "asc" }, - take: 5000, + orderBy: { moodLoggedAt: "desc" }, + take: MOOD_READ_CAP, select: { score: true, moodLoggedAt: true }, }), loadUserSourcePriority(user.id), ]); + const measurementsCapped = measurementsDesc.length >= MEASUREMENT_READ_CAP; + const moodCapped = moodEntriesDesc.length >= MOOD_READ_CAP; + const measurements = [...measurementsDesc].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + const moodEntries = [...moodEntriesDesc].sort( + (a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(), + ); const measurementsByType = new Map(); for (const m of measurements) { @@ -221,6 +239,13 @@ export const GET = apiHandler(async () => { pairs_tested: result.pairsTested, discovered: result.discovered.length, fdr_q: result.fdrQ, + // PERFAUDIT M1 — surfaces when a dense account's window exceeded the + // read cap. The cap now falls on the OLDEST rows (desc + take), so a + // capped read still covers the recent window `discoverEmergingCorrelations` + // needs; this only tells a dashboard the retrospective scan's older + // half of the window may be thin. + measurements_capped: measurementsCapped, + mood_entries_capped: moodCapped, // FDREXTEND — per-channel day-counts so a dashboard can see whether the // two sparse new channels reached the n ≥ 20 floor or degraded to absent. compliance_days: complianceSeries.points.length,