diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts index 221f1342e..1b3cfbf16 100644 --- a/src/__tests__/api/analytics/since-cap.test.ts +++ b/src/__tests__/api/analytics/since-cap.test.ts @@ -118,7 +118,7 @@ vi.mock("@/lib/analytics/correlations-fast-path", () => ({ })); vi.mock("@/lib/analytics/summaries-slice", () => ({ - computeSummariesSlice: vi.fn().mockResolvedValue({ + readSummariesSlice: vi.fn().mockResolvedValue({ summaries: {}, bmi: null, bpInTargetPct: null, @@ -176,7 +176,7 @@ beforeEach(async () => { await import("@/lib/analytics/score/reader"); const { computeCorrelationHypothesesFastPath } = await import("@/lib/analytics/correlations-fast-path"); - const { computeSummariesSlice } = + const { readSummariesSlice } = await import("@/lib/analytics/summaries-slice"); (ensureUserRollupsFresh as ReturnType).mockResolvedValue( @@ -196,7 +196,7 @@ beforeEach(async () => { ( computeCorrelationHypothesesFastPath as ReturnType ).mockResolvedValue({ hypotheses: [] }); - (computeSummariesSlice as ReturnType).mockResolvedValue({ + (readSummariesSlice as ReturnType).mockResolvedValue({ summaries: {}, bmi: null, lastSeenByType: {}, @@ -206,7 +206,7 @@ beforeEach(async () => { describe("GET /api/analytics — per-type live walk retired (v1.4.49.1)", () => { // v1.4.49.1 — the W-SINCE 425-day `since` cap was a defense-in-depth // bound on the now-deleted 15-way per-type live walk. The default - // slice routes through `computeSummariesSlice` exclusively, which + // slice routes through `readSummariesSlice` exclusively, which // reads `measurement_rollups` DAY buckets + a 90-day narrow // `$queryRaw` directly — no chunked findMany against `measurements` // at all on the default critical path. The two assertions that pinned @@ -222,7 +222,7 @@ describe("GET /api/analytics — per-type live walk retired (v1.4.49.1)", () => ); expect(response.status).toBe(200); - // The slim slice resolves through `computeSummariesSlice` only; + // The slim slice resolves through `readSummariesSlice` only; // the per-type findMany loop is skipped entirely so the rollup- // path semantics are unaffected by the `since` cap. const findMany = prisma.measurement.findMany as ReturnType; diff --git a/src/__tests__/latest-per-type-read-guard.test.ts b/src/__tests__/latest-per-type-read-guard.test.ts new file mode 100644 index 000000000..a0e7eb796 --- /dev/null +++ b/src/__tests__/latest-per-type-read-guard.test.ts @@ -0,0 +1,82 @@ +/** + * Structural guard: the latest reading per measurement type has two readers, + * and the summaries slice one cache cell. + * + * Postgres 16 has no skip scan, so `DISTINCT ON (type)` over `measurements` + * sorts every row it can see to keep one per type. Five copies of that shape + * sat in the dashboard, analytics, insights and targets reads; each one now + * goes through `readLatestCanonical` (the ladder-canonical reading of the + * latest day) or `readLatestEver` (the latest raw reading), both driven from + * the type list with one index probe per type. + * + * The summaries slice was computed inside three cells (the tile-strip route, + * the thick analytics body, the dashboard snapshot). `readSummariesSlice` is + * the one cell; `computeSummariesSlice` is its builder and is called nowhere + * else in production code. + * + * Comments are stripped before matching, and the planted cases prove each + * matcher finds what it claims to. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +function stripComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, "") + .replace(/^\s*--.*$/gm, ""); +} + +/** `DISTINCT ON (type)` or `DISTINCT ON (m."type")`, the type alone. */ +const LATEST_PER_TYPE = /DISTINCT\s+ON\s*\(\s*(?:\w+\s*\.\s*)?"?type"?\s*\)/i; + +const SLICE_BUILDER_CALL = /\bcomputeSummariesSlice\s*\(/; + +function productionFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }).filter( + (file) => + !file.startsWith("generated/") && + !file.includes("__tests__/") && + !/\.test\.tsx?$/.test(file), + ); +} + +describe("latest-per-type reads", () => { + it("the matchers find their shapes", () => { + expect( + LATEST_PER_TYPE.test(`SELECT DISTINCT ON (m."type") m."value"`), + ).toBe(true); + expect(LATEST_PER_TYPE.test(`SELECT DISTINCT ON ("type") value`)).toBe( + true, + ); + expect( + LATEST_PER_TYPE.test(`SELECT DISTINCT ON ("type", "bucket_start")`), + ).toBe(false); + expect( + SLICE_BUILDER_CALL.test(`await computeSummariesSlice(user.id)`), + ).toBe(true); + }); + + it("no production read keeps one row per type with DISTINCT ON", () => { + const offenders = productionFiles().filter((file) => + LATEST_PER_TYPE.test( + stripComments(readFileSync(join(SRC, file), "utf8")), + ), + ); + expect(offenders).toEqual([]); + }); + + it("the summaries slice is built only behind its cache cell", () => { + const callers = productionFiles().filter((file) => + SLICE_BUILDER_CALL.test( + stripComments(readFileSync(join(SRC, file), "utf8")), + ), + ); + expect(callers).toEqual(["lib/analytics/summaries-slice.ts"]); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index de8e909bd..6c5f611de 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -6,7 +6,7 @@ import { analyticsQuerySchema } from "@/lib/validations/analytics"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; -import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; +import { readSummariesSlice } from "@/lib/analytics/summaries-slice"; import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -103,14 +103,7 @@ export const GET = apiHandler(async (request?: Request) => { // tile strip's next mount should repaint from the prior summaries // instantly while one background recompute refreshes them, instead // of paying the 2-SQL-pass rebuild inline. - const slim = await cachedSwr( - caches.analytics as ServerCache< - Awaited> - >, - `${user.id}|summaries`, - () => computeSummariesSlice(user.id), - annotate, - ); + const slim = await readSummariesSlice(user.id, { serveStale: true }); // Drop the types whose module the user turned off, from the SAME map // the snapshot builder gates on (`SUMMARY_TYPE_MODULE`, via // `gateSummariesByModules`). With `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` @@ -282,7 +275,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // release; previously only the live walk produced it) // - `avg30LastYear` populated for any type whose WMY tier carries // the year-ago window - // - `lastSeenByType` from the `DISTINCT ON (type)` latest read + // - `lastSeenByType` from the per-type latest read // // `anomalyCount` and `slope90` left this wire in v1.37.19: the slim // path could never compute the former (it always reported 0) and no @@ -292,7 +285,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // `avg30LastYear` on types with no WMY coverage, which matches the // pre-fix behaviour for tenants whose year-ago window happened to // fall outside the 425-day floor. - const slim = await computeSummariesSlice(user.id); + const slim = await readSummariesSlice(user.id); // Same module gate as the slim slice and the snapshot builder — the // default slice feeds the dashboard too (it is the thick half of the // legacy two-cell path), so it cannot be the one feed that leaks a diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index 9338f928d..d83596dc3 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -170,9 +170,9 @@ beforeEach(() => { ( prisma.moodEntryRollup.findMany as ReturnType ).mockResolvedValue([]); - // v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON` - // (Prisma's `distinct` dedups client-side after pulling every row). (prisma.$queryRawUnsafe as ReturnType).mockResolvedValue([]); + // The latest-ever-per-type read (`readLatestEver`). + (prisma.$queryRaw as ReturnType).mockResolvedValue([]); vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]); vi.mocked(readRestingPulseProxy).mockResolvedValue([]); }); @@ -321,35 +321,30 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { expect(moodTarget).toBeUndefined(); }); - it("reads latest-ever-per-type via raw DISTINCT ON with the one-year floor", async () => { - // Audit High finding 3 established the 365-day floor; v1.28.25 moves - // the read onto a real Postgres `DISTINCT ON` (Prisma's `distinct` - // dedups in the driver AFTER pulling every row in the window, which - // on a dense-type tenant shipped a year of raw rows to Node just to - // keep seven). Pin the raw query: DISTINCT ON, per-type descending - // walk, user id + floored date as bound parameters. + it("reads latest-ever-per-type one index probe per type, floored at one year", async () => { + // Audit High finding 3 established the 365-day floor. The read is the + // shared `readLatestEver`: driven from the type list, one LIMIT 1 probe + // per type, so a dense type never sorts a year of raw rows to keep one. await callGet(makeReq()); - const rawCalls = (prisma.$queryRawUnsafe as ReturnType).mock - .calls; - const distinctCall = rawCalls.find( - (c) => typeof c[0] === "string" && c[0].includes("DISTINCT ON"), + const rawCalls = (prisma.$queryRaw as ReturnType).mock.calls; + const latestCall = rawCalls.find( + (c) => + Array.isArray(c[0]) && + (c[0] as string[]).join("?").includes("CROSS JOIN LATERAL"), ); - expect(distinctCall).toBeDefined(); - const [sql, boundUserId, flooredAt] = distinctCall as [ - string, - string, - Date, - ]; - expect(sql).toContain(`DISTINCT ON (m."type")`); - expect(sql).toContain(`ORDER BY m."type" ASC, m."measured_at" DESC`); - // Parameter-bound, never spliced: user id + date ride $1/$2. - expect(sql).toContain("$1"); - expect(sql).toContain("$2"); - expect(boundUserId).toBe("user-targets-1"); + expect(latestCall).toBeDefined(); + const [strings, ...values] = latestCall as [string[], ...unknown[]]; + expect(strings.join("?")).not.toContain("DISTINCT ON"); + expect(values).toContain("user-targets-1"); + const floor = values.find( + (v): v is { values: unknown[] } => + typeof v === "object" && v !== null && !Array.isArray(v), + ); + const flooredAt = floor?.values[0]; expect(flooredAt).toBeInstanceOf(Date); const oneYearMs = 365 * 24 * 60 * 60 * 1000; - const ageMs = Date.now() - flooredAt.getTime(); + const ageMs = Date.now() - (flooredAt as Date).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. @@ -454,7 +449,7 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { timeZone, ), ); - (prisma.$queryRawUnsafe as ReturnType).mockResolvedValue([ + (prisma.$queryRaw as ReturnType).mockResolvedValue([ { type: "WEIGHT", value: 70 }, { type: "BLOOD_PRESSURE_SYS", value: 125 }, { type: "BLOOD_PRESSURE_DIA", value: 75 }, diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index a503f58e0..de194fba8 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -66,7 +66,8 @@ vi.mock("@/lib/analytics/source-rank-sql", () => { import { prisma } from "@/lib/db"; import { canonicalMeasurementsCte } from "@/lib/analytics/source-rank-sql"; import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; -import { computeSummariesSlice } from "../summaries-slice"; +import { caches, __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { computeSummariesSlice, readSummariesSlice } from "../summaries-slice"; const RAW = prisma.$queryRaw as unknown as ReturnType; const UNSAFE = prisma.$queryRawUnsafe as unknown as ReturnType; @@ -686,3 +687,37 @@ describe("computeSummariesSlice", () => { }); }); }); + +describe("readSummariesSlice — the one cache cell", () => { + beforeEach(() => { + __resetAllCachesForTests(); + UNSAFE.mockResolvedValue([]); + }); + + it("builds the slice once for every consumer inside the fresh TTL", async () => { + const first = await readSummariesSlice("user-cell", { serveStale: true }); + const builds = RAW.mock.calls.length; + expect(builds).toBeGreaterThan(0); + + const composed = await readSummariesSlice("user-cell"); + const snapshot = await readSummariesSlice("user-cell", { + coverage: new Map(), + }); + + expect(composed).toBe(first); + expect(snapshot).toBe(first); + expect(RAW.mock.calls.length).toBe(builds); + }); + + it("serves stale only to the edge reader; a composing reader rebuilds", async () => { + const before = await readSummariesSlice("user-stale"); + caches.analytics.markStaleByPrefix("user-stale|"); + + const edge = await readSummariesSlice("user-stale", { serveStale: true }); + expect(edge).toBe(before); + + caches.analytics.markStaleByPrefix("user-stale|"); + const composed = await readSummariesSlice("user-stale"); + expect(composed).not.toBe(before); + }); +}); diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 2d46fa5c1..8e1511cb5 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -189,7 +189,7 @@ const fakeDb = vi.hoisted(() => { vi.mock("@/lib/db", () => ({ prisma: fakeDb })); // ── Everything around the score, faked at its own seam ────────────── -const computeSummariesSlice = vi.fn(); +const readSummariesSlice = vi.fn(); const probeRollupCoverage = vi.fn(); const isFullyCovered = vi.fn(); const readMoodDayRollups = vi.fn(); @@ -205,7 +205,7 @@ const loadIntradayPulse = vi.fn(); const readDayMeanSeries = vi.fn(); vi.mock("@/lib/analytics/summaries-slice", () => ({ - computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), + readSummariesSlice: (...a: unknown[]) => readSummariesSlice(...a), })); vi.mock("@/lib/rollups/measurement-coverage", () => ({ probeRollupCoverage: (...a: unknown[]) => probeRollupCoverage(...a), @@ -381,7 +381,7 @@ beforeEach(() => { vi.clearAllMocks(); __resetAllCachesForTests(); dismissedKeys.clear(); - computeSummariesSlice.mockResolvedValue({ + readSummariesSlice.mockResolvedValue({ summaries: {}, lastSeenByType: {}, bmi: null, diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index b1a5519a2..4bc962cfc 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -30,7 +30,7 @@ * windowed columns (`avg7`, `avg30`, and the slope tuples). The * heavy COUNT/MIN/MAX/AVG columns are NOT projected. * - * 3. **`DISTINCT ON (type)` latest read** — most-recent `(type, + * 3. **Latest read** (`readLatestCanonical`) — most-recent `(type, * value, measured_at)` triplet per type for the `latest` field * and the `lastSeenByType` freshness map. * @@ -77,6 +77,7 @@ import { import type { DataSummary } from "@/lib/analytics/trends"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { annotate } from "@/lib/logging/context"; +import { caches, type ServerCache } from "@/lib/cache/server-cache"; import { ensureUserRollupsFresh, readRollupBuckets, @@ -95,6 +96,7 @@ import { buildSourceRankCase, canonicalMeasurementsCte, } from "@/lib/analytics/source-rank-sql"; +import { readLatestCanonical } from "@/lib/measurements/latest-canonical"; import { isFullyCovered, probeRollupCoverage, @@ -223,19 +225,6 @@ interface WindowedAggregateRow extends NarrowAggregateRow { r2_30: number | null; } -interface LatestRow { - type: string; - value: number; - /** - * v1.4.34 IW-B — per-type freshness timestamp. The DISTINCT ON pass - * already orders by `measured_at DESC` to pick the latest row's - * value; surfacing `measured_at` from the same row costs zero extra - * round-trips and feeds the dashboard tile-strip's stale-data - * caption (driven via ``). - */ - measured_at: Date; -} - /** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */ function buildSlope( slope: number | null, @@ -373,6 +362,35 @@ export async function computeSummariesSlice( ); } +/** + * The one cache cell of the summaries slice. The tile-strip route, the thick + * analytics body and the dashboard snapshot all read the slice; each used to + * compute it inside its own cell, so one dashboard mount built it up to three + * times over the same rows. + * + * `serveStale` is for the caller that answers the client with the slice + * itself. The composing callers are stale-while-revalidate cells of their + * own and read fresh-only: a background refresh after a write must not + * compose the pre-write slice and cache it again as fresh. + */ +export async function readSummariesSlice( + userId: string, + options: { coverage?: RollupCoverageMap; serveStale?: boolean } = {}, +): Promise { + const cache = caches.analytics as ServerCache; + const key = summariesSliceCacheKey(userId); + const build = () => computeSummariesSlice(userId, options.coverage); + const { value, outcome } = options.serveStale + ? await cache.wrapSwr(key, build) + : await cache.wrap(key, build); + annotate({ meta: { "cache.summaries_slice.outcome": outcome } }); + return value; +} + +export function summariesSliceCacheKey(userId: string): string { + return `${userId}|summaries`; +} + /** * `latest` of an hourly-mean type (pulse) is the value of its latest local * day, not its latest single reading. @@ -559,15 +577,13 @@ async function computeFromRollups(userId: string): Promise { // v1.11.1 — resolve the user's source-priority ladders once, then build the // rank CASE expressions used to collapse overlapping sources to the canonical // reading per (type, day) inside each query. `"type"`/`"source"` variants for - // the unqualified rollup + inner-subquery columns, `m."…"` for the latest - // read's outer alias. + // the unqualified rollup + inner-subquery columns. const priorityJson = await loadUserSourcePriority(userId); const rankUnqualified = buildSourceRankCase( priorityJson, '"type"', '"source"', ); - const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); const rankPerSource = buildSourceRankCase( priorityJson, 'p."type"', @@ -618,22 +634,7 @@ async function computeFromRollups(userId: string): Promise { `, userId, ), - // v1.11.1 — the latest tile reflects the canonical source for the latest - // day, matching the chart: order by latest day first, then the ladder rank - // (canonical source wins), then the latest reading of that source. - prisma.$queryRawUnsafe( - ` - SELECT DISTINCT ON (m."type") - m."type"::text AS type, - m."value"::double precision AS value, - m."measured_at" AS measured_at - FROM measurements m - WHERE m."user_id" = $1 - AND m."deleted_at" IS NULL - ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC - `, - userId, - ), + readLatestCanonical(userId, priorityJson), // v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY // rollup bucket for the user (`findMany` without a `bucketStart` // window) and then composed `count / min / max / mean` in JS. @@ -1055,7 +1056,6 @@ async function computeFromLiveAggregate( '"type"', '"source"', ); - const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); const [allTime, windowed, latests] = await Promise.all([ prisma.$queryRawUnsafe( @@ -1129,19 +1129,7 @@ async function computeFromLiveAggregate( `, userId, ), - prisma.$queryRawUnsafe( - ` - SELECT DISTINCT ON (m."type") - m."type"::text AS type, - m."value"::double precision AS value, - m."measured_at" AS measured_at - FROM measurements m - WHERE m."user_id" = $1 - AND m."deleted_at" IS NULL - ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC - `, - userId, - ), + readLatestCanonical(userId, priorityJson), ]); const latestByType = new Map(); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 03c7e4fab..53daaec70 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -13,7 +13,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -const computeSummariesSlice = vi.fn(); +const readSummariesSlice = vi.fn(); const probeRollupCoverage = vi.fn(); const isFullyCovered = vi.fn(); const readMoodDayRollups = vi.fn(); @@ -24,7 +24,7 @@ const buildMedsTodayBlock = vi.fn(); const computeUserHealthScoreMock = vi.fn(); vi.mock("@/lib/analytics/summaries-slice", () => ({ - computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), + readSummariesSlice: (...a: unknown[]) => readSummariesSlice(...a), })); vi.mock("@/lib/rollups/measurement-coverage", () => ({ probeRollupCoverage: (...a: unknown[]) => probeRollupCoverage(...a), @@ -210,7 +210,7 @@ function baseUser( beforeEach(() => { vi.clearAllMocks(); - computeSummariesSlice.mockResolvedValue({ + readSummariesSlice.mockResolvedValue({ summaries: { WEIGHT: { ...emptySummary, count: 3, latest: 80 } }, lastSeenByType: { WEIGHT: { lastSeenAt: new Date().toISOString() }, @@ -301,7 +301,31 @@ describe("buildDashboardSnapshot — envelope shape", () => { expect(probeRollupCoverage).toHaveBeenCalledTimes(1); // The already-probed map is passed as the slice's second argument so // it reuses it instead of probing again. - expect(computeSummariesSlice).toHaveBeenCalledWith("user-1", coverageMap); + expect(readSummariesSlice).toHaveBeenCalledWith("user-1", { + coverage: coverageMap, + }); + }); +}); + +describe("buildDashboardSnapshot — the shared summaries cell", () => { + it("folds the water tile into a copy, never into the cached slice", async () => { + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]])); + isFullyCovered.mockReturnValue(false); + const cached = { + summaries: { WEIGHT: { ...emptySummary, count: 3, latest: 80 } }, + lastSeenByType: { WEIGHT: { lastSeenAt: new Date().toISOString() } }, + bmi: null, + }; + readSummariesSlice.mockResolvedValue(cached); + fakePrisma.nutrientIntakeDay.findMany.mockResolvedValue([ + { day: new Date().toISOString().slice(0, 10), amount: 1500 }, + ]); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.tiles.summaries.NUTRIENT_WATER).toBeDefined(); + expect(Object.keys(cached.summaries)).toEqual(["WEIGHT"]); + expect(Object.keys(cached.lastSeenByType)).toEqual(["WEIGHT"]); }); }); @@ -891,7 +915,7 @@ describe("buildDashboardSnapshot — metricStates (iOS cold-launch seed)", () => it("keys each entry by the iOS MetricKind raw value (incl. the non-obvious raws)", async () => { const now = new Date().toISOString(); - computeSummariesSlice.mockResolvedValue({ + readSummariesSlice.mockResolvedValue({ summaries: { WEIGHT: { ...emptySummary, count: 1, latest: 80 }, OXYGEN_SATURATION: { ...emptySummary, count: 1, latest: 98 }, @@ -948,7 +972,7 @@ describe("buildDashboardSnapshot — metricStates (iOS cold-launch seed)", () => }); it("omits metrics with no latest value or no timestamp", async () => { - computeSummariesSlice.mockResolvedValue({ + readSummariesSlice.mockResolvedValue({ summaries: { WEIGHT: { ...emptySummary, count: 0, latest: null }, PULSE: { ...emptySummary, count: 1, latest: 60 }, @@ -1162,7 +1186,7 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { priorMonth: { pct: 60 }, priorYear: { pct: 50 }, }); - computeSummariesSlice.mockResolvedValue({ + readSummariesSlice.mockResolvedValue({ summaries: { WEIGHT: { ...emptySummary, count: 3, latest: 80 }, SLEEP_DURATION: { ...emptySummary, count: 5, latest: 420 }, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 4d404906e..bcd5054c5 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -35,7 +35,7 @@ * The builder NEVER POSTs `/api/insights/generate`. */ import type { PrismaClient, MeasurementType } from "@/generated/prisma/client"; -import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; +import { readSummariesSlice } from "@/lib/analytics/summaries-slice"; import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; import { getUnitForType } from "@/lib/validations/measurement"; import { @@ -1013,7 +1013,7 @@ async function liftBriefing( /** * v1.7.0 — derive the per-metric latest-reading block from the slim * summaries slice already fetched for `tiles.summaries`. ZERO extra DB - * round-trips: the slim slice's `DISTINCT ON (type)` read already + * round-trips: the slim slice's per-type latest read already * carries the latest `value` (`summaries[type].latest`) and the * matching timestamp (`lastSeenByType[type].lastSeenAt`); the unit is * the static canonical-unit lookup. This keeps the snapshot pool-safe @@ -1329,7 +1329,7 @@ export async function buildDashboardSnapshot( ] = await Promise.all([ // A5 — reuse the coverage map already probed above so the slice // doesn't re-run the identical `probeRollupCoverage` query. - time("summaries", () => computeSummariesSlice(user.id, coverage)), + time("summaries", () => readSummariesSlice(user.id, { coverage })), time("mood", () => buildMoodBlock(prisma, user.id)), // v1.34 — the thick slice awaits the shared `modulesPromise` (the same // pattern the score-ring task uses) so the hero ring's health score can @@ -1372,29 +1372,30 @@ export async function buildDashboardSnapshot( // `NUTRIENT_WATER` key BEFORE the module gate below strips it (like // every other summary type) when `nutrients` is off. Not a real // `MeasurementType` — see the widened `SUMMARY_TYPE_MODULE` note. - if (nutrientWater) { - const lastMs = new Date(nutrientWater.lastSeenAt).getTime(); - const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000)); - slimRaw.summaries = { - ...slimRaw.summaries, - NUTRIENT_WATER: nutrientWater.summary, - }; - slimRaw.lastSeenByType = { - ...slimRaw.lastSeenByType, - NUTRIENT_WATER: { lastSeenAt: nutrientWater.lastSeenAt, daysAgo }, - }; - } + // The slice is the shared cache cell's value: fold into copies, never into + // the cached object itself. + const slimSummaries = nutrientWater + ? { ...slimRaw.summaries, NUTRIENT_WATER: nutrientWater.summary } + : slimRaw.summaries; + const slimLastSeen = nutrientWater + ? { + ...slimRaw.lastSeenByType, + NUTRIENT_WATER: { + lastSeenAt: nutrientWater.lastSeenAt, + daysAgo: Math.floor( + (nowMs - new Date(nutrientWater.lastSeenAt).getTime()) / + (24 * 60 * 60 * 1000), + ), + }, + } + : slimRaw.lastSeenByType; // v1.18.0 — strip disabled-module data before it leaves the server. // Mood is blanked when the mood module is off; sleep / glucose summary // types are dropped from the slim slice; the glucose clinical panel + // per-context block are cleared when glucose is off; the layout + // catalogue hide every disabled-module widget. - const slim = gateSummariesByModules( - slimRaw.summaries, - slimRaw.lastSeenByType, - modules, - ); + const slim = gateSummariesByModules(slimSummaries, slimLastSeen, modules); const mood = modules.mood === false ? { summary: null, entries: [] as DashboardSnapshotMoodEntry[] } diff --git a/src/lib/dashboard/summary-reads.ts b/src/lib/dashboard/summary-reads.ts index 15fc47786..a5206307e 100644 --- a/src/lib/dashboard/summary-reads.ts +++ b/src/lib/dashboard/summary-reads.ts @@ -15,7 +15,7 @@ * an equality on the leading type column, which turns a full pass over * the account into one bounded lookup per type. */ -import type { PrismaClient } from "@/generated/prisma/client"; +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import type { MeasurementType } from "@/generated/prisma/client"; /** v1.4.38 W-F — the single most recent reading per measurement @@ -29,7 +29,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; * was still in the database. The all-time aggregate (`groupBy` on * `measurements`) already proves the row exists; the row count is * still bounded by `|measurementTypes|`, now because the query is - * driven FROM that list (v1.38.5) rather than grouped down to it. */ + * driven FROM that list (v1.38.5) rather than grouped down to it. + * `since` drops a type whose latest reading is older than it. */ export interface LatestEverRow { type: MeasurementType; value: number; @@ -90,7 +91,11 @@ export function readLatestEver( db: PrismaClient, userId: string, measurementTypes: readonly MeasurementType[], + since: Date | null = null, ): Promise { + const floor = since + ? Prisma.sql`AND m."measured_at" >= ${since}` + : Prisma.empty; return db.$queryRaw` SELECT mt."type" AS type, @@ -105,6 +110,7 @@ export function readLatestEver( WHERE m."user_id" = ${userId} AND m."type" = mt."type" AND m."deleted_at" IS NULL + ${floor} ORDER BY m."measured_at" DESC LIMIT 1 ) l diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 63f22ec82..d6b27a377 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -114,6 +114,7 @@ import { type AccumulatorBucketRow, } from "@/lib/rollups/measurement-read"; import { windowWeighting } from "@/lib/measurements/day-statistic"; +import { readLatestCanonical } from "@/lib/measurements/latest-canonical"; import { SESSION_DAY_FRAME, dayWeightedRows, @@ -198,11 +199,9 @@ interface NarrowAggregateRow { // they no longer scan the raw measurements partition on the warm path. } -/** Latest raw value per type (DISTINCT ON over the 90-day window). */ -interface LatestRow { - type: string; - value: number; - measured_at: Date; +/** The latest canonical reading per type is read only inside the 90-day window. */ +function latestFloor(): Date { + return new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); } /** @@ -426,8 +425,7 @@ async function buildFromRollups( // (`type: { in: [...] }`) plus per-sub-query wall-clock timings. // v1.11.1 — collapse overlapping sources to the ladder-canonical reading per // (type, day) before aggregating. `rankUnqualified` ranks the inner - // canonical-source subquery; `rankM` ranks the latest read's `m.`-aliased - // columns. The window_stats CTE + the main narrow both read from the + // canonical-source subquery. The window_stats CTE + the main narrow both read from the // canonical-source-filtered set so a dual-source vital isn't blended. const priorityJson = await loadUserSourcePriority(userId); const rankUnqualified = buildSourceRankCase( @@ -435,7 +433,6 @@ async function buildFromRollups( '"type"', '"source"', ); - const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); const [narrows, latests, dayBuckets, bpRawRows, firstRows] = await Promise.all([ @@ -484,20 +481,7 @@ async function buildFromRollups( ), ), timeSubquery(timings, "latests", () => - prisma.$queryRawUnsafe( - ` - SELECT DISTINCT ON (m."type") - m."type"::text AS type, - m."value"::double precision AS value, - m."measured_at" AS measured_at - FROM measurements m - WHERE m."user_id" = $1 - AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' - AND m."deleted_at" IS NULL - ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC - `, - userId, - ), + readLatestCanonical(userId, priorityJson, latestFloor()), ), // v1.4.35 — DAY rollup buckets for the trailing 90 days, every // type. Drives both the per-type `count / min / max / mean` @@ -676,7 +660,6 @@ async function buildFromLiveAggregate( '"type"', '"source"', ); - const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); const [aggregates, latests, dayBuckets, bpRawRows] = await Promise.all([ timeSubquery(timings, "heavy", () => @@ -760,20 +743,7 @@ async function buildFromLiveAggregate( ), ), timeSubquery(timings, "latests", () => - prisma.$queryRawUnsafe( - ` - SELECT DISTINCT ON (m."type") - m."type"::text AS type, - m."value"::double precision AS value, - m."measured_at" AS measured_at - FROM measurements m - WHERE m."user_id" = $1 - AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' - AND m."deleted_at" IS NULL - ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC - `, - userId, - ), + readLatestCanonical(userId, priorityJson, latestFloor()), ), // Buckets may exist for some types even when the COUNT probe came // back zero (race window between the probe and a sibling diff --git a/src/lib/measurements/latest-canonical.ts b/src/lib/measurements/latest-canonical.ts new file mode 100644 index 000000000..a8e7f3ec9 --- /dev/null +++ b/src/lib/measurements/latest-canonical.ts @@ -0,0 +1,66 @@ +import { prisma } from "@/lib/db"; +import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; + +export interface LatestCanonicalRow { + type: string; + value: number; + measured_at: Date; +} + +/** + * The latest canonical reading per measurement type: the latest UTC day + * first, then the source ladder (the canonical source wins), then the latest + * reading of that source, so a tile's headline matches its chart. `since` + * drops a type whose latest reading is older than it. + * + * Driven from the type list rather than a `DISTINCT ON (type)` over the + * account: Postgres 16 has no skip scan, so that shape sorted every live row + * of the account to keep one per type. Here each type costs one probe of + * `measurements_live_covering_idx` for its latest instant and one read of that + * single day. The closed day window matters to the planner as much as to the + * result: with only the lower bound it estimates a third of the type's rows + * per probe, and the summed estimate crosses `jit_above_cost`, whose + * compilation then costs more than the query itself. + */ +export function readLatestCanonical( + userId: string, + priorityJson: unknown, + since: Date | null = null, +): Promise { + const rank = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); + return prisma.$queryRawUnsafe( + ` + SELECT + mt."type"::text AS type, + l.value AS value, + l.measured_at AS measured_at + FROM unnest($2::"measurement_type"[]) AS mt("type") + CROSS JOIN LATERAL ( + SELECT date_trunc('day', d."measured_at") AS day + FROM measurements d + WHERE d."user_id" = $1 + AND d."type" = mt."type" + AND d."deleted_at" IS NULL + ${since ? `AND d."measured_at" >= $3` : ""} + ORDER BY d."measured_at" DESC + LIMIT 1 + ) last_day + CROSS JOIN LATERAL ( + SELECT + m."value"::double precision AS value, + m."measured_at" AS measured_at + FROM measurements m + WHERE m."user_id" = $1 + AND m."type" = mt."type" + AND m."deleted_at" IS NULL + AND m."measured_at" >= last_day.day + AND m."measured_at" < last_day.day + INTERVAL '1 day' + ORDER BY (${rank}), m."measured_at" DESC, m."id" DESC + LIMIT 1 + ) l + ORDER BY mt."type" + `, + ...[userId, measurementTypeEnum.options, ...(since ? [since] : [])], + ); +} diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 7b0ddc569..ac8ad62ce 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -32,6 +32,7 @@ import { } from "@/lib/rollups/mood-rollups"; import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { readSevenDayMeans } from "@/lib/analytics/summaries-slice"; +import { readLatestEver } from "@/lib/dashboard/summary-reads"; import { summarizeSleepNights, type SleepStageRow, @@ -145,31 +146,7 @@ export async function buildTargetsResponse(user: AuthedUser) { select: { type: true, value: true, measuredAt: true }, }), ), - limit(() => { - for (const type of types) { - if (!/^[A-Z0-9_]+$/.test(type)) { - throw new Error(`invalid measurement type: ${type}`); - } - } - const typeList = types - .map((type) => `'${type}'::"measurement_type"`) - .join(","); - return prisma.$queryRawUnsafe< - Array<{ type: MeasurementType; value: number }> - >( - `SELECT DISTINCT ON (m."type") - m."type"::text AS type, - m."value" AS value - FROM measurements m - WHERE m."user_id" = $1 - AND m."type" IN (${typeList}) - AND m."measured_at" >= $2 - AND m."deleted_at" IS NULL - ORDER BY m."type" ASC, m."measured_at" DESC`, - userId, - oneYearAgo, - ); - }), + limit(() => readLatestEver(prisma, userId, types, oneYearAgo)), limit(() => prisma.measurement.findMany({ where: { diff --git a/tests/integration/latest-canonical-read.test.ts b/tests/integration/latest-canonical-read.test.ts new file mode 100644 index 000000000..88ffbe801 --- /dev/null +++ b/tests/integration/latest-canonical-read.test.ts @@ -0,0 +1,114 @@ +/** + * The latest canonical reading per type, against a real Postgres. + * + * `readLatestCanonical` replaced five `DISTINCT ON (type)` reads that sorted + * every row of the account. The contract it keeps: the latest UTC day decides, + * inside it the source ladder decides (Withings outranks Apple Health for + * weight), inside the winning source the latest reading; a type with no + * reading drops out, and `since` drops a type whose latest reading is older. + * The day window is closed: an older day of the ladder winner never beats a + * newer day of a lower source. + */ +import { beforeAll, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { readLatestCanonical } from "@/lib/measurements/latest-canonical"; + +const prisma = getPrismaClient(); +const USER = "latest-canonical"; +const now = new Date(); +/** UTC midnight two days back. */ +const D = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() - 2, +); +const at = (day: number, hour: number) => + new Date(D + day * 86_400_000 + hour * 3_600_000); + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, email: `${USER}@example.test` }, + }); + const rows: Array<[string, string, number, Date, string]> = [ + // WEIGHT: the latest day holds Apple at 20:00 and Withings at 07:00; + // Withings ranks first, so 82 wins over the later 81. + ["WEIGHT", "WITHINGS", 80, at(-1, 8), "kg"], + ["WEIGHT", "WITHINGS", 82, at(0, 7), "kg"], + ["WEIGHT", "APPLE_HEALTH", 81, at(0, 20), "kg"], + // BODY_FAT: Withings is the ladder winner but only on an older day; the + // newer Apple day wins. + ["BODY_FAT", "WITHINGS", 20, at(-1, 8), "%"], + ["BODY_FAT", "APPLE_HEALTH", 21, at(0, 9), "%"], + // PULSE: one source, the latest reading of the latest day. + ["PULSE", "APPLE_HEALTH", 60, at(0, 6), "bpm"], + ["PULSE", "APPLE_HEALTH", 64, at(0, 22), "bpm"], + // BLOOD_GLUCOSE: only a reading far in the past. + ["BLOOD_GLUCOSE", "MANUAL", 95, new Date(D - 200 * 86_400_000), "mg/dL"], + ]; + await prisma.measurement.createMany({ + data: rows.map(([type, source, value, measuredAt, unit], i) => ({ + id: `${USER}-${i}`, + userId: USER, + type: type as "WEIGHT", + source: source as "WITHINGS", + value, + unit, + measuredAt, + })), + }); + // A deleted later reading never counts. + await prisma.measurement.create({ + data: { + id: `${USER}-deleted`, + userId: USER, + type: "PULSE", + source: "APPLE_HEALTH", + value: 99, + unit: "bpm", + measuredAt: at(0, 23), + deletedAt: new Date(), + }, + }); +}); + +describe("readLatestCanonical", () => { + it("picks the latest day, then the ladder, then the latest reading", async () => { + const rows = await readLatestCanonical(USER, null); + const byType = new Map(rows.map((r) => [r.type, r])); + expect([...byType.keys()].sort()).toEqual([ + "BLOOD_GLUCOSE", + "BODY_FAT", + "PULSE", + "WEIGHT", + ]); + expect(byType.get("WEIGHT")?.value).toBe(82); + expect(byType.get("WEIGHT")?.measured_at.toISOString()).toBe( + at(0, 7).toISOString(), + ); + expect(byType.get("BODY_FAT")?.value).toBe(21); + expect(byType.get("PULSE")?.value).toBe(64); + expect(byType.get("BLOOD_GLUCOSE")?.value).toBe(95); + }); + + it("drops a type whose latest reading is older than `since`", async () => { + const rows = await readLatestCanonical( + USER, + null, + new Date(Date.now() - 90 * 86_400_000), + ); + expect(rows.map((r) => r.type).sort()).toEqual([ + "BODY_FAT", + "PULSE", + "WEIGHT", + ]); + }); + + it("honours a stored source order", async () => { + const rows = await readLatestCanonical(USER, { + weight: ["APPLE_HEALTH", "WITHINGS"], + }); + expect(rows.find((r) => r.type === "WEIGHT")?.value).toBe(81); + }); +});