diff --git a/src/__tests__/latest-per-type-read-guard.test.ts b/src/__tests__/latest-per-type-read-guard.test.ts index a0e7eb796..4ebc51836 100644 --- a/src/__tests__/latest-per-type-read-guard.test.ts +++ b/src/__tests__/latest-per-type-read-guard.test.ts @@ -14,6 +14,12 @@ * the one cell; `computeSummariesSlice` is its builder and is called nowhere * else in production code. * + * The Prisma spelling of the same question is `measurement.groupBy` by type + * with nothing but `_max` (or nothing at all, a presence probe) and no time + * window: it groups every live row the account has to answer a question one + * index probe per type answers. Grouped reads that count, sum or average, or + * that are bounded in time, ask something else and pass. + * * Comments are stripped before matching, and the planted cases prove each * matcher finds what it claims to. */ @@ -37,6 +43,36 @@ const LATEST_PER_TYPE = /DISTINCT\s+ON\s*\(\s*(?:\w+\s*\.\s*)?"?type"?\s*\)/i; const SLICE_BUILDER_CALL = /\bcomputeSummariesSlice\s*\(/; +/** Grouped presence reads that ask something the per-type probe cannot. */ +const GROUPBY_ALLOWED: Record = { + "lib/measurements/import-apple-health-export.ts": + "Which types hold a MANUAL reading: a source-filtered presence the per-type probe does not take.", + "lib/jobs/insight-pregenerate.ts": + "Which types exist at all, with no type list to drive a per-type probe; a nightly job, not a request path.", +}; + +/** Each `measurement.groupBy(...)` argument that asks only latest or presence. */ +function latestOnlyGroupBys(code: string): string[] { + const out: string[] = []; + for (const m of code.matchAll(/\.measurement\s*\.\s*groupBy\s*\(/g)) { + let i = m.index + m[0].length; + let depth = 1; + while (depth > 0 && i < code.length) { + if (code[i] === "(") depth += 1; + else if (code[i] === ")") depth -= 1; + i += 1; + } + const arg = code.slice(m.index + m[0].length, i - 1); + const byType = /by:\s*\[\s*"type"\s*\]/.test(arg); + const asksMore = /\b_(?:count|sum|avg|min)\b/.test(arg); + const windowed = /\bmeasuredAt\s*:/.test( + arg.replace(/_max:\s*\{[^}]*\}/, ""), + ); + if (byType && !asksMore && !windowed) out.push(arg.replace(/\s+/g, " ")); + } + return out; +} + function productionFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }).filter( (file) => @@ -71,6 +107,49 @@ describe("latest-per-type reads", () => { expect(offenders).toEqual([]); }); + it("the groupBy matcher finds a latest-only or presence-only read", () => { + expect( + latestOnlyGroupBys( + `prisma.measurement.groupBy({ by: ["type"], where: { userId }, _max: { measuredAt: true } })`, + ), + ).toHaveLength(1); + expect( + latestOnlyGroupBys( + `prisma.measurement\n .groupBy({ by: ["type"], where: { userId, deletedAt: null } })`, + ), + ).toHaveLength(1); + expect( + latestOnlyGroupBys( + `prisma.measurement.groupBy({ by: ["type"], where: { userId }, _count: { _all: true }, _max: { measuredAt: true } })`, + ), + ).toEqual([]); + expect( + latestOnlyGroupBys( + `prisma.measurement.groupBy({ by: ["type"], where: { userId, measuredAt: { gte: since } }, _max: { measuredAt: true } })`, + ), + ).toEqual([]); + }); + + it("no production read groups every row for the latest per type", () => { + const files = productionFiles(); + const grouped = files.filter((file) => + /\.measurement\s*\.\s*groupBy\s*\(/.test( + readFileSync(join(SRC, file), "utf8"), + ), + ); + // The tree groups measurements in a dozen places; finding none means + // the walk read the wrong tree. + expect(grouped.length).toBeGreaterThanOrEqual(8); + const offenders = files + .filter((file) => !(file in GROUPBY_ALLOWED)) + .flatMap((file) => + latestOnlyGroupBys( + stripComments(readFileSync(join(SRC, file), "utf8")), + ).map((arg) => `${file}: ${arg.slice(0, 100)}`), + ); + expect(offenders).toEqual([]); + }); + it("the summaries slice is built only behind its cache cell", () => { const callers = productionFiles().filter((file) => SLICE_BUILDER_CALL.test( diff --git a/src/lib/daily/briefing-today-read.ts b/src/lib/daily/briefing-today-read.ts index f9d74bb20..49ace8d14 100644 --- a/src/lib/daily/briefing-today-read.ts +++ b/src/lib/daily/briefing-today-read.ts @@ -11,6 +11,7 @@ */ import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; import { prisma } from "@/lib/db"; +import { readLatestEver } from "@/lib/dashboard/summary-reads"; import type { Locale } from "@/lib/i18n/config"; import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { userDayKey } from "@/lib/tz/format"; @@ -41,21 +42,17 @@ export async function readSignalLastSeen( userId: string, ): Promise<(type: string) => string | null> { try { - const rows = await prisma.measurement.groupBy({ - by: ["type"], - where: { - userId, - deletedAt: null, - type: { - in: Object.values(SIGNAL_READING_TYPE) as MeasurementType[], - }, - }, - _max: { measuredAt: true }, - }); + // One index probe per type for its latest live reading + // (`readLatestEver`), rather than a grouped max over every row. + const rows = await readLatestEver( + prisma, + userId, + Object.values(SIGNAL_READING_TYPE) as MeasurementType[], + ); const byType = new Map( rows.map((row) => [ String(row.type), - row._max.measuredAt?.toISOString() ?? null, + new Date(row.measured_at).toISOString(), ]), ); return (type) => byType.get(type) ?? null; diff --git a/src/lib/mcp/__tests__/record-search.test.ts b/src/lib/mcp/__tests__/record-search.test.ts index 4d05457da..7a7f5e9e1 100644 --- a/src/lib/mcp/__tests__/record-search.test.ts +++ b/src/lib/mcp/__tests__/record-search.test.ts @@ -121,6 +121,13 @@ vi.mock("@/lib/db", () => ({ vaccinationRecord: table("vaccinationRecord"), }, })); + +// The search presence probe is one index lookup per type in Postgres; the +// suite states which types hold a live reading through this stub. +vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({ + ...(await importOriginal()), + readLatestEver: vi.fn(async () => []), +})); vi.mock("@/lib/links", () => ({ listTargets: vi.fn( async ( diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts index d6de27350..63f09c7c3 100644 --- a/src/lib/mcp/__tests__/search-fetch.test.ts +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -42,6 +42,13 @@ vi.mock("@/lib/db", () => ({ }, })); +// The search presence probe is one index lookup per type in Postgres; the +// suite states which types hold a live reading through this stub. +vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({ + ...(await importOriginal()), + readLatestEver: vi.fn(async () => []), +})); + import { MCP_TOOLS } from "../tools"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index f316ccfaa..f736e0d92 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -60,6 +60,13 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findMany: vi.fn(async () => []) }, }, })); + +// The search presence probe is one index lookup per type in Postgres; the +// suite states which types hold a live reading through this stub. +vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({ + ...(await importOriginal()), + readLatestEver: vi.fn(async () => []), +})); // v1.24 — the operational reads delegate to existing server-authoritative // engines; stub them so the registry-wide loops never reach a real engine. vi.mock("@/lib/medications/scheduling/next-due", () => ({ @@ -157,6 +164,7 @@ import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; import { listTargetsBySource } from "@/lib/links"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import type { McpAuthContext } from "../auth"; +import { readLatestEver } from "@/lib/dashboard/summary-reads"; const CTX: McpAuthContext = { userId: "user-1", @@ -1229,8 +1237,8 @@ describe("v1.25 clinical signals on the MCP surface", () => { }); it("search surfaces a present clinical signal as metric:", async () => { - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ - { type: "GRIP_STRENGTH" }, + vi.mocked(readLatestEver).mockResolvedValue([ + { type: "GRIP_STRENGTH", value: 1, measured_at: new Date() }, ] as never); const result = (await tool("search").run(CTX, { query: "grip" })) as { results: Array<{ id: string; title: string; url: string }>; @@ -1242,7 +1250,7 @@ describe("v1.25 clinical signals on the MCP surface", () => { }); it("search omits a clinical signal with no recorded data", async () => { - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(readLatestEver).mockResolvedValue([] as never); const result = (await tool("search").run(CTX, { query: "grip" })) as { results: Array<{ id: string }>; }; @@ -1250,7 +1258,7 @@ describe("v1.25 clinical signals on the MCP surface", () => { }); it("fetch hydrates a clinical signal via the baseline read (not the Coach path)", async () => { - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(readLatestEver).mockResolvedValue([] as never); vi.mocked(getMetricBaseline).mockResolvedValue({ present: true, metric: "Grip strength", @@ -1292,8 +1300,8 @@ describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5 }); it("search surfaces a present metric-status-only id (undiscoverable before this wave)", async () => { - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ - { type: "WRIST_TEMPERATURE" }, + vi.mocked(readLatestEver).mockResolvedValue([ + { type: "WRIST_TEMPERATURE", value: 1, measured_at: new Date() }, ] as never); const result = (await tool("search").run(CTX, { query: "wrist" })) as { results: Array<{ id: string; title: string }>; @@ -1303,7 +1311,7 @@ describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5 }); it("fetch hydrates a metric-status-only id via the baseline read", async () => { - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(readLatestEver).mockResolvedValue([] as never); vi.mocked(getMetricBaseline).mockResolvedValue({ present: true, metric: "Wrist skin temperature", @@ -1387,7 +1395,7 @@ describe("nutrients on search / fetch (v1.30 coverage review G1)", () => { } as never); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(readLatestEver).mockResolvedValue([] as never); }); it("search resolves 'water' to nutrient:water when the user has logged it", async () => { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index d5b046a11..ee6e575f0 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -20,6 +20,7 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; +import { readLatestEver } from "@/lib/dashboard/summary-reads"; import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; import { annotate } from "@/lib/logging/context"; import { getDayArgsSchema } from "@/lib/ai/coach/tools/definitions"; @@ -584,14 +585,13 @@ function searchAndFetchTools(): McpToolDefinition[] { // v1.41.2 — live rows only: a deleted reading is not a signal the // record holds, and the filter lets the probe use the live-rows // partial index instead of walking the tombstones. - const discoverablePresent = await prisma.measurement.groupBy({ - by: ["type"], - where: { - userId: ctx.userId, - type: { in: discoverableSignals.map((s) => s.measurementType) }, - deletedAt: null, - }, - }); + // A presence probe is one index lookup per type (`readLatestEver` + // drops a type with no live reading), not a grouping of every row. + const discoverablePresent = await readLatestEver( + prisma, + ctx.userId, + discoverableSignals.map((s) => s.measurementType), + ); const presentTypes = new Set(discoverablePresent.map((r) => r.type)); for (const sig of discoverableSignals) { if (!presentTypes.has(sig.measurementType)) continue;