diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts new file mode 100644 index 000000000..fcd8cffa3 --- /dev/null +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -0,0 +1,207 @@ +/** + * No client list or picker with a fixed row cap and nothing to reach past it. + * + * A client read that asks for "the newest N" and renders the answer as the + * whole list loses everything beyond N without a word: the Labs list asked + * for 500 raw rows and grouped them in the browser, so on a long history + * every marker measured only long ago had no row at all. The visit-form lab + * picker was the same class before it, and the GLP-1 curve, the side-effect + * log and the symptom timeline each had a quieter copy. + * + * The rule: every literal `limit=` a client fetch sends either sits + * beside a pager (an `offset` / `cursor` / `pageParam` within a few lines, or + * a `useInfiniteQuery` in the file), or is listed below with the reason the + * cap cannot cut anything off. + * + * What it matches: `limit=` inside a URL string, and the two + * `URLSearchParams` spellings, `.set("limit", "")` and + * `limit: ""`. What it does not: a limit routed through a named + * constant (`limit=${PAGE_SIZE}`), which is a deliberate page size in every + * case today. It walks client code only (`components/`, `hooks/`, and `app/` + * outside `app/api/`); a server-side `take` is not a fetch. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +interface Allowed { + file: string; + limit: number; + reason: string; +} + +const ALLOWLIST: readonly Allowed[] = [ + { + file: "app/labs/page.tsx", + limit: 1, + reason: + "Deep-link resolution: the newest reading under one analyte name, used only to find its marker page.", + }, + { + file: "components/onboarding/first-result-screen.tsx", + limit: 1, + reason: "Asks only whether a type has a reading, and which is newest.", + }, + { + file: "components/insights/glp1-substance-curve-summary.tsx", + limit: 1, + reason: + "Reads only `medications`, which lists every GLP-1 medication whatever the entry cap; the entries are not used.", + }, + { + file: "components/charts/chart-series-request.ts", + limit: 5000, + reason: + "Daily rollup buckets: one row per day, and the widest window (All, 3,650 days, plus a 365-day comparison) is 4,015 days.", + }, + { + file: "components/insights/trend-annotation.tsx", + limit: 5000, + reason: "Daily rollup buckets over a 30-day window: at most 31 rows.", + }, + { + file: "components/insights/coach-panel/attachment-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "components/settings/share-document-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "components/documents/document-link-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "hooks/use-mood-day.ts", + limit: 50, + reason: + "One calendar day of mood entries; fifty check-ins in a day is beyond any cadence the app offers.", + }, +]; + +const LIMIT_PATTERNS: readonly RegExp[] = [ + /[?&]limit=(\d+)/g, + /\.set\(\s*["']limit["']\s*,\s*["'](\d+)["']\s*\)/g, + /\blimit:\s*["'](\d+)["']/g, +]; + +const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/; +const PAGER_LINES = 3; + +function clientFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter( + (p) => + p.startsWith("components/") || + p.startsWith("hooks/") || + (p.startsWith("app/") && !p.startsWith("app/api/")), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !/\.test\.tsx?$/.test(p)) + .sort(); +} + +interface Hit { + file: string; + line: number; + limit: number; + paged: boolean; +} + +/** Every literal limit in the given source, with whether a pager is beside it. */ +function findLimitHits(file: string, source: string): Hit[] { + const lines = source.split("\n"); + const infinite = /\buseInfiniteQuery\b/.test(source); + const hits: Hit[] = []; + lines.forEach((text, index) => { + // Comments describe old shapes; they send nothing. + const trimmed = text.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*")) return; + for (const pattern of LIMIT_PATTERNS) { + for (const match of text.matchAll(pattern)) { + const near = lines + .slice(Math.max(0, index - PAGER_LINES), index + PAGER_LINES + 1) + .join("\n"); + hits.push({ + file, + line: index + 1, + limit: Number(match[1]), + paged: infinite || PAGER_NEARBY.test(near), + }); + } + } + }); + return hits; +} + +function allHits(): Hit[] { + return clientFiles().flatMap((file) => + findLimitHits(file, readFileSync(join(SRC, file), "utf8")), + ); +} + +function isAllowed(hit: Hit): boolean { + return ALLOWLIST.some((a) => a.file === hit.file && a.limit === hit.limit); +} + +describe("client fetch limits", () => { + it("finds the literal limits it is meant to police", () => { + // An empty sweep would agree with any allowlist. Every allowlisted cap is + // a live hit, so the hit set can never be smaller than the list. + expect(allHits().length).toBeGreaterThanOrEqual(ALLOWLIST.length); + }); + + it("puts every fixed limit beside a pager or on the allowlist", () => { + const offenders = allHits() + .filter((hit) => !hit.paged && !isAllowed(hit)) + .map((hit) => `${hit.file}:${hit.line} limit=${hit.limit}`); + expect( + offenders, + "A client fetch with a fixed limit loses everything past it. Page it (offset / cursor / useInfiniteQuery), move the grouping server-side, or add an allowlist entry that says why the cap cannot cut anything off.", + ).toEqual([]); + }); + + it("keeps no allowlist entry that no longer matches anything", () => { + const hits = allHits(); + const stale = ALLOWLIST.filter( + (a) => !hits.some((h) => h.file === a.file && h.limit === a.limit), + ).map((a) => `${a.file} limit=${a.limit}`); + expect(stale).toEqual([]); + }); + + it("gives every allowlist entry a reason", () => { + for (const entry of ALLOWLIST) { + expect(entry.reason.length, entry.file).toBeGreaterThan(20); + } + }); + + it("flags the shape that dropped markers from the Labs list", () => { + const hits = findLimitHits( + "components/labs/lab-list.tsx", + 'apiGet("/api/labs?limit=500&sortDir=desc")', + ); + expect(hits).toHaveLength(1); + expect(hits[0].paged).toBe(false); + expect(isAllowed(hits[0])).toBe(false); + }); + + it("accepts a limit that travels with an offset", () => { + const hits = findLimitHits( + "components/example.tsx", + "fetch(`/api/x?status=taken&limit=100&offset=${offset}`)", + ); + expect(hits).toHaveLength(1); + expect(hits[0].paged).toBe(true); + }); +}); diff --git a/src/components/medications/__tests__/drug-level-chart.test.tsx b/src/components/medications/__tests__/drug-level-chart.test.tsx index a509dbcd3..9e9b0b673 100644 --- a/src/components/medications/__tests__/drug-level-chart.test.tsx +++ b/src/components/medications/__tests__/drug-level-chart.test.tsx @@ -81,6 +81,7 @@ vi.mock("@/lib/medications/glp1-pk", async () => { import { DrugLevelChart, + loadTakenIntakesSince, parseDoseMg, resolveDoseMg, } from "../drug-level-chart"; @@ -362,3 +363,54 @@ describe("parseDoseMg / resolveDoseMg helpers", () => { expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42); }); }); + +describe("loadTakenIntakesSince", () => { + const DAY = 24 * 60 * 60 * 1000; + // 400 daily taken doses, newest first, the newest one a day ago. + const now = Date.parse("2026-06-01T08:00:00.000Z"); + const all = Array.from({ length: 400 }, (_, i) => ({ + id: `i-${i}`, + takenAt: new Date(now - (i + 1) * DAY).toISOString(), + skipped: false, + scheduledFor: new Date(now - (i + 1) * DAY).toISOString(), + })); + + function pager() { + const urls: string[] = []; + const fetchPage = async (url: string) => { + urls.push(url); + const params = new URL(url, "http://localhost").searchParams; + const limit = Number(params.get("limit")); + const offset = Number(params.get("offset")); + return { + events: all.slice(offset, offset + limit), + meta: { total: all.length }, + }; + }; + return { urls, fetchPage }; + } + + it("walks back past a single page to the horizon, and no further", async () => { + const { urls, fetchPage } = pager(); + // 21-day window + 5 half-lives of 7 days = 56 days, then 150 to cross a page. + const since = new Date(now - 150.5 * DAY); + const { events } = await loadTakenIntakesSince("med-1", since, fetchPage); + expect(events).toHaveLength(150); + expect(urls).toHaveLength(2); + for (const url of urls) { + expect(url).toContain("status=taken"); + expect(url).toContain("sortBy=takenAt"); + } + }); + + it("stops at the end of the history", async () => { + const { urls, fetchPage } = pager(); + const { events } = await loadTakenIntakesSince( + "med-1", + new Date(now - 10_000 * DAY), + fetchPage, + ); + expect(events).toHaveLength(400); + expect(urls).toHaveLength(4); + }); +}); diff --git a/src/components/medications/drug-level-chart.tsx b/src/components/medications/drug-level-chart.tsx index 481b65748..86e2fdcd7 100644 --- a/src/components/medications/drug-level-chart.tsx +++ b/src/components/medications/drug-level-chart.tsx @@ -56,6 +56,7 @@ import { } from "@/lib/medications/glp1-pk"; import { findDrugIdByBrand, + GLP1_DRUGS, type Glp1DrugId, } from "@/lib/medications/glp1-knowledge"; import { parseDoseMg } from "@/lib/medications/dose-string"; @@ -74,6 +75,53 @@ const HOURS_PER_DAY = 24; const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY; const WINDOW_HOURS_AFTER = 0; +/** Taken intakes per request while walking back to the model's horizon. */ +const INTAKE_PAGE_SIZE = 100; +/** + * Half-lives before the chart's left edge whose doses still count. After five + * half-lives a dose holds about 3% of its peak, below what the chart can show. + */ +const HORIZON_HALF_LIVES = 5; + +/** + * Every taken intake from `since` on, newest first, walked page by page. The + * curve is a sum over every dose still in the body, so a fixed count of + * recent intakes was wrong in both directions: twenty daily doses of a + * long-acting drug stop well short of steady state, and planned or skipped + * rows used up the count without being doses at all. + */ +export async function loadTakenIntakesSince( + medicationId: string, + since: Date, + fetchPage: (url: string) => Promise<{ + events: IntakeEvent[]; + meta: { total: number }; + }> = (url) => apiGet(url), +): Promise<{ events: IntakeEvent[] }> { + const events: IntakeEvent[] = []; + for (let offset = 0; ; offset += INTAKE_PAGE_SIZE) { + const page = await fetchPage( + `/api/medications/${medicationId}/intake?status=taken&sortBy=takenAt&sortDir=desc&limit=${INTAKE_PAGE_SIZE}&offset=${offset}`, + ); + events.push(...page.events); + const oldest = page.events[page.events.length - 1]; + const reachedHorizon = + !oldest?.takenAt || Date.parse(oldest.takenAt) < since.getTime(); + if ( + reachedHorizon || + page.events.length < INTAKE_PAGE_SIZE || + offset + INTAKE_PAGE_SIZE >= page.meta.total + ) { + break; + } + } + return { + events: events.filter( + (ev) => ev.takenAt && Date.parse(ev.takenAt) >= since.getTime(), + ), + }; +} + interface IntakeEvent { id: string; takenAt: string | null; @@ -147,9 +195,16 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id), queryFn: async () => { try { - return await apiGet<{ events: IntakeEvent[] }>( - `/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`, + const halfLifeHours = + GLP1_DRUGS[drugId!].pharmacology.halfLifeDays * HOURS_PER_DAY; + const since = new Date( + Date.now() - + (WINDOW_HOURS_BEFORE + HORIZON_HALF_LIVES * halfLifeHours) * + 60 * + 60 * + 1000, ); + return await loadTakenIntakesSince(medication.id, since); } catch { return null; } diff --git a/src/components/symptoms/__tests__/load-symptom-events.test.ts b/src/components/symptoms/__tests__/load-symptom-events.test.ts new file mode 100644 index 000000000..4a9098f35 --- /dev/null +++ b/src/components/symptoms/__tests__/load-symptom-events.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { SymptomEventDTO } from "@/lib/symptoms/shared"; +import { loadSymptomEvents, type SymptomEventList } from "../use-symptoms"; + +/** + * A fake of `GET /api/symptoms/events`: newest first, `to` inclusive, at most + * `limit` rows, `hasMore` when the page is full. + */ +function fakeRoute(all: SymptomEventDTO[]) { + const urls: string[] = []; + const fetchPage = async (url: string): Promise => { + urls.push(url); + const params = new URL(url, "http://localhost").searchParams; + const to = params.get("to")!; + const limit = Number(params.get("limit")); + const rows = all + .filter((e) => e.occurredAt <= to) + .sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1)) + .slice(0, limit); + return { events: rows, limit, hasMore: rows.length === limit }; + }; + return { urls, fetchPage }; +} + +function event(i: number, occurredAt: string): SymptomEventDTO { + return { id: `e-${i}`, occurredAt } as SymptomEventDTO; +} + +describe("loadSymptomEvents", () => { + it("reads past the first page until the window is exhausted", async () => { + const base = Date.parse("2026-06-01T00:00:00.000Z"); + // 1,200 occurrences an hour apart, two of them sharing a page boundary. + const all = Array.from({ length: 1_200 }, (_, i) => + event(i, new Date(base - i * 3_600_000).toISOString()), + ); + all.push(event(9_999, all[499].occurredAt)); + const { urls, fetchPage } = fakeRoute(all); + + const out = await loadSymptomEvents( + "1900-01-01T00:00:00.000Z", + "2026-06-02T00:00:00.000Z", + "ep-1", + fetchPage, + ); + + expect(out.events).toHaveLength(1_201); + expect(new Set(out.events.map((e) => e.id)).size).toBe(1_201); + expect(out.hasMore).toBe(false); + expect(urls.length).toBeGreaterThan(2); + expect(urls.every((u) => u.includes("episodeId=ep-1"))).toBe(true); + }); + + it("stops after one request when the first page is not full", async () => { + const { urls, fetchPage } = fakeRoute([ + event(1, "2026-05-01T00:00:00.000Z"), + ]); + const out = await loadSymptomEvents( + "2026-04-01T00:00:00.000Z", + "2026-06-01T00:00:00.000Z", + null, + fetchPage, + ); + expect(out.events).toHaveLength(1); + expect(urls).toHaveLength(1); + }); +}); diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index a95ebaed2..9c900f10c 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -55,9 +55,55 @@ export function useSymptomDefinitions(includeHidden = false, enabled = true) { }); } +/** Occurrences per request while walking a window. */ +const SYMPTOM_EVENTS_PAGE_SIZE = 500; + +/** + * Every occurrence inside `[from, to]` (ISO instants), newest first, read page + * by page. The route answers at most one page and says `hasMore`; each + * further page asks up to the oldest instant already read (`to` is + * inclusive, so the boundary instant comes back and is dropped by id). A + * chronic episode can hold more than one page, and a single capped read + * showed only its newest part. + */ +export async function loadSymptomEvents( + from: string, + to: string, + episodeId: string | null, + fetchPage: (url: string) => Promise = (url) => + apiGet(url), +): Promise { + const seen = new Set(); + const events: SymptomEventDTO[] = []; + let upTo = to; + for (;;) { + const params = new URLSearchParams({ + from, + to: upTo, + limit: String(SYMPTOM_EVENTS_PAGE_SIZE), + }); + if (episodeId) params.set("episodeId", episodeId); + const page = await fetchPage(`/api/symptoms/events?${params}`); + let added = 0; + for (const event of page.events) { + if (seen.has(event.id)) continue; + seen.add(event.id); + events.push(event); + added += 1; + } + const oldest = page.events[page.events.length - 1]; + // A page that adds nothing (a full page of one instant) cannot move the + // bound, so it ends the walk rather than repeating forever. + if (!page.hasMore || !oldest || added === 0) { + return { events, limit: SYMPTOM_EVENTS_PAGE_SIZE, hasMore: false }; + } + upTo = oldest.occurredAt; + } +} + /** * Occurrences inside `[from, to]` (ISO instants), optionally only those filed - * against one episode. At most 500, newest first. + * against one episode. Every one of them, newest first. */ export function useSymptomEvents( from: string, @@ -68,11 +114,7 @@ export function useSymptomEvents( return useQuery({ queryKey: queryKeys.symptomEvents(from, to, episodeId), enabled: options.enabled ?? true, - queryFn: () => { - const params = new URLSearchParams({ from, to, limit: "500" }); - if (episodeId) params.set("episodeId", episodeId); - return apiGet(`/api/symptoms/events?${params}`); - }, + queryFn: () => loadSymptomEvents(from, to, episodeId), }); }