perf(analytics): read the latest value per type with one probe per type

The latest canonical reading per type ran as `DISTINCT ON (type)` over
every live row of the account in five places (summaries slice warm and
cold path, comprehensive aggregator warm and cold path, targets). With no
skip scan in Postgres 16 that sorted the whole account to keep one row
per type. `readLatestCanonical` drives the read from the type list
instead: one index probe for each type's latest instant, then a closed
one-day window ranked by the source ladder. The closed window also keeps
the plan under `jit_above_cost`. The targets read reuses `readLatestEver`
with a floor.

The summaries slice was built inside three cache cells (the tile-strip
route, the thick analytics body, the dashboard snapshot). It now has one
cell, read through `readSummariesSlice`; composing callers read it
fresh-only so a refresh after a write cannot cache the pre-write slice
again, and the snapshot folds the water tile into a copy instead of the
cached object.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 03:35:03 +02:00
1 parent 8eb4ef3d56
commit 9c51dd0d30
14 files changed
+436 -185

No files matched your search

@@ -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<typeof vi.fn>).mockResolvedValue(
@@ -196,7 +196,7 @@ beforeEach(async () => {
(
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue({ hypotheses: [] });
(computeSummariesSlice as ReturnType<typeof vi.fn>).mockResolvedValue({
(readSummariesSlice as ReturnType<typeof vi.fn>).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<typeof vi.fn>;
@@ -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"]);
});
});
+4 -11
View File
@@ -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<ReturnType<typeof computeSummariesSlice>>
>,
`${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
@@ -170,9 +170,9 @@ beforeEach(() => {
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).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<typeof vi.fn>).mockResolvedValue([]);
// The latest-ever-per-type read (`readLatestEver`).
(prisma.$queryRaw as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock
.calls;
const distinctCall = rawCalls.find(
(c) => typeof c[0] === "string" && c[0].includes("DISTINCT ON"),
const rawCalls = (prisma.$queryRaw as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([
(prisma.$queryRaw as ReturnType<typeof vi.fn>).mockResolvedValue([
{ type: "WEIGHT", value: 70 },
{ type: "BLOOD_PRESSURE_SYS", value: 125 },
{ type: "BLOOD_PRESSURE_DIA", value: 75 },
@@ -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<typeof vi.fn>;
const UNSAFE = prisma.$queryRawUnsafe as unknown as ReturnType<typeof vi.fn>;
@@ -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);
});
});
@@ -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,
+35 -47
View File
@@ -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 `<TrendCard staleDays>`).
*/
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<SummariesSlice> {
const cache = caches.analytics as ServerCache<SummariesSlice>;
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<SummariesSlice> {
// 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<SummariesSlice> {
`,
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<LatestRow[]>(
`
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<AllTimeAggregateRow[]>(
@@ -1129,19 +1129,7 @@ async function computeFromLiveAggregate(
`,
userId,
),
prisma.$queryRawUnsafe<LatestRow[]>(
`
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<string, number>();
+31 -7
View File
@@ -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 },
+21 -20
View File
@@ -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[] }
+8 -2
View File
@@ -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<LatestEverRow[]> {
const floor = since
? Prisma.sql`AND m."measured_at" >= ${since}`
: Prisma.empty;
return db.$queryRaw<LatestEverRow[]>`
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
+7 -37
View File
@@ -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<LatestRow[]>(
`
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<LatestRow[]>(
`
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
+66
View File
@@ -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<LatestCanonicalRow[]> {
const rank = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"');
return prisma.$queryRawUnsafe<LatestCanonicalRow[]>(
`
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] : [])],
);
}
+2 -25
View File
@@ -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: {
@@ -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);
});
});