From 7f2349e56f944174394213ae8d676cc3352ec68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:47:18 +0200 Subject: [PATCH 01/16] fix(targets): fold the glucose year and the heart-rate month in SQL The targets page read a year of raw glucose readings to keep the newest one per meal context, and thirty days of raw heart rate to derive one resting figure per day. A sensor reporting every five minutes puts 105 000 glucose readings in that year and a watch recording once a minute puts 43 000 heart-rate readings in the month, and the page is read on every visit from the web and the app. Glucose now reads one row per context (DISTINCT ON) for the newest value and one row per context and local day for the thirty-day mean and the consistency strip; the strip takes day sums directly through rollupConsistencyFromDays. Heart rate reaches the resting-pulse card through readRestingPulseProxy, the SQL fold the insights page already uses, merged per day with the native resting rows exactly as before. The cards are unchanged: the integration test rebuilds them from the raw readings through the in-memory folds and compares. --- src/lib/analytics/resting-pulse.ts | 29 +- src/lib/targets/__tests__/builders.test.ts | 32 +- src/lib/targets/build-response.ts | 40 ++- src/lib/targets/consistency.ts | 62 ++-- src/lib/targets/glucose-builder.ts | 56 ++-- src/lib/targets/glucose-read.ts | 148 +++++++++ src/lib/targets/vitals-builder.ts | 23 +- tests/integration/dense-targets-reads.test.ts | 283 ++++++++++++++++++ 8 files changed, 600 insertions(+), 73 deletions(-) create mode 100644 src/lib/targets/glucose-read.ts create mode 100644 tests/integration/dense-targets-reads.test.ts diff --git a/src/lib/analytics/resting-pulse.ts b/src/lib/analytics/resting-pulse.ts index 8f8deaacc..ba26c914c 100644 --- a/src/lib/analytics/resting-pulse.ts +++ b/src/lib/analytics/resting-pulse.ts @@ -139,6 +139,26 @@ export function resolveRestingPulseSeries(input: { pulseSamples: ReadonlyArray; /** Day key for the proxy buckets: the user's own day. */ dayKeyOf: (d: Date) => string; +}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { + return mergeRestingWithProxy({ + restingSamples: input.restingSamples, + proxySamples: deriveRestingProxyFromPulse( + input.pulseSamples, + input.dayKeyOf, + ), + dayKeyOf: input.dayKeyOf, + }); +} + +/** + * {@link resolveRestingPulseSeries} for a caller that already holds the + * proxy, one point per day, as `readRestingPulseProxy` folds it in Postgres. + * The per-day merge and the label are the same. + */ +export function mergeRestingWithProxy(input: { + restingSamples: ReadonlyArray; + proxySamples: ReadonlyArray; + dayKeyOf: (d: Date) => string; }): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { const dayKeyOf = input.dayKeyOf; const byTime = (a: PulseSample, b: PulseSample) => @@ -148,14 +168,13 @@ export function resolveRestingPulseSeries(input: { // Days already covered by a real resting row — the proxy must not // second-guess them. const restingDays = new Set(resting.map((s) => dayKeyOf(s.measuredAt))); - const proxy = deriveRestingProxyFromPulse( - input.pulseSamples, - dayKeyOf, - ).filter((p) => !restingDays.has(dayKeyOf(p.measuredAt))); + const proxy = input.proxySamples.filter( + (p) => !restingDays.has(dayKeyOf(p.measuredAt)), + ); if (resting.length === 0) { return proxy.length > 0 - ? { series: proxy, which: "proxy" } + ? { series: [...proxy], which: "proxy" } : { series: [], which: "none" }; } if (proxy.length === 0) return { series: resting, which: "resting" }; diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index 8362cda40..9a765e531 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildGlucoseTargets } from "../glucose-builder"; +import { foldGlucoseTargetRows } from "../glucose-read"; import { buildMedicationTarget } from "../medication-builder"; import { buildMoodTargets } from "../mood-builder"; import { buildSleepTarget } from "../sleep-builder"; @@ -8,11 +9,13 @@ import { buildVitalTargets } from "../vitals-builder"; const NOW = new Date("2026-07-21T12:00:00.000Z"); const TZ = "UTC"; +const RECENT = new Date(NOW.getTime() - 30 * 86_400_000); describe("target section builders", () => { it("keeps the vital target order and profile-gated cards on an empty profile", () => { const result = buildVitalTargets({ recentMeasurements: [], + restingPulseProxy: [], latestByType: {}, average30ByType: {}, heightCm: null, @@ -108,7 +111,7 @@ describe("target section builders", () => { }, ]; const targets = buildGlucoseTargets({ - rows, + summaries: foldGlucoseTargetRows(rows, RECENT, TZ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -140,10 +143,14 @@ describe("target section builders", () => { // metadata, so every row arrives with a null context. Iterating the four // named contexts alone returned no glucose target at all. const targets = buildGlucoseTargets({ - rows: [ - { value: 112, measuredAt: NOW, glucoseContext: null }, - { value: 104, measuredAt: NOW, glucoseContext: null }, - ], + summaries: foldGlucoseTargetRows( + [ + { value: 112, measuredAt: NOW, glucoseContext: null }, + { value: 104, measuredAt: NOW, glucoseContext: null }, + ], + RECENT, + TZ, + ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -173,11 +180,15 @@ describe("target section builders", () => { it("keeps the tagged breakdown and puts the untagged bucket last (#943)", () => { const targets = buildGlucoseTargets({ - rows: [ - { value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const }, - { value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const }, - { value: 111, measuredAt: NOW, glucoseContext: null }, - ], + summaries: foldGlucoseTargetRows( + [ + { value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const }, + { value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const }, + { value: 111, measuredAt: NOW, glucoseContext: null }, + ], + RECENT, + TZ, + ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -235,6 +246,7 @@ describe("target section builders", () => { describe("buildVitalTargets — user weight target", () => { const base = { recentMeasurements: [], + restingPulseProxy: [], latestByType: {}, average30ByType: {}, heightCm: 178, diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 6addbc725..74903024b 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -26,7 +26,9 @@ import { ensureUserMoodRollupsFresh, readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { buildGlucoseTargets } from "./glucose-builder"; +import { readGlucoseTargetSummaries } from "./glucose-read"; import { buildMedicationTarget } from "./medication-builder"; import { buildMoodTargets } from "./mood-builder"; import { buildSleepTarget } from "./sleep-builder"; @@ -75,6 +77,10 @@ export async function buildTargetsResponse(user: AuthedUser) { "BODY_FAT", "ACTIVITY_STEPS", ]; + // Heart rate is the one vital a device streams. Its thirty days reach the + // resting-pulse card as one proxy point per day, folded in SQL below; every + // other vital is written a few times a day and is read as rows. + const recentTypes = types.filter((type) => type !== "PULSE"); const thirtyDaysAgo = new Date(queryNow.getTime() - 30 * 24 * 60 * 60 * 1000); const oneYearAgo = new Date(queryNow.getTime() - 365 * 24 * 60 * 60 * 1000); const moodSince = new Date( @@ -95,7 +101,8 @@ export async function buildTargetsResponse(user: AuthedUser) { medicationBundle, moodRollups, latestMoodEntry, - glucoseRows, + glucoseSummaries, + restingPulseProxy, ] = await Promise.all([ limit(() => prisma.user.findUnique({ @@ -115,7 +122,7 @@ export async function buildTargetsResponse(user: AuthedUser) { prisma.measurement.findMany({ where: { userId, - type: { in: types }, + type: { in: recentTypes }, measuredAt: { gte: thirtyDaysAgo }, deletedAt: null, }, @@ -211,16 +218,22 @@ export async function buildTargetsResponse(user: AuthedUser) { select: { score: true, moodLoggedAt: true }, }), ), + // A sensor reporting every five minutes writes 105 000 glucose readings + // a year. The cards need the newest per meal context and the last thirty + // days per local day, so both fold in SQL. limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_GLUCOSE", - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { value: true, measuredAt: true, glucoseContext: true }, + readGlucoseTargetSummaries({ + userId, + since: oneYearAgo, + recentSince: thirtyDaysAgo, + timeZone: timezone, + }), + ), + limit(() => + readRestingPulseProxy({ + userId, + since: thirtyDaysAgo, + timeZone: timezone, }), ), ]); @@ -251,6 +264,8 @@ export async function buildTargetsResponse(user: AuthedUser) { for (const type of types) { latestByType[type] = latestEverByType.find((row) => row.type === type)?.value ?? null; + } + for (const type of recentTypes) { const recent = recentMeasurements.filter( (measurement) => measurement.type === type, ); @@ -266,6 +281,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const vitalSection = buildVitalTargets({ recentMeasurements, + restingPulseProxy, latestByType, average30ByType, heightCm, @@ -314,7 +330,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null); targets.push( ...buildGlucoseTargets({ - rows: glucoseRows, + summaries: glucoseSummaries, profile: { heightCm, dateOfBirth: dbUser?.dateOfBirth ?? null, diff --git a/src/lib/targets/consistency.ts b/src/lib/targets/consistency.ts index 66de155d2..a7c994d41 100644 --- a/src/lib/targets/consistency.ts +++ b/src/lib/targets/consistency.ts @@ -43,12 +43,13 @@ function last7DayKeys(now: Date, timezone: string): string[] { return keys; } -function computeStrip( +/** A day's readings folded to their sum and count, keyed by local day. */ +export type DaySums = ReadonlyMap; + +function sumByDay( events: ReadonlyArray<{ measuredAt: Date; value: number }>, - classify: (mean: number) => DayBand | null, timezone: string, - now: Date, -) { +): Map { const byDay = new Map(); for (const event of events) { const key = userDayKey(event.measuredAt, timezone); @@ -57,7 +58,15 @@ function computeStrip( bucket.count += 1; byDay.set(key, bucket); } + return byDay; +} +function computeStrip( + byDay: DaySums, + classify: (mean: number) => DayBand | null, + timezone: string, + now: Date, +) { const consistency7d: Array = []; let daysInRange7d = 0; let daysLogged7d = 0; @@ -77,13 +86,40 @@ function computeStrip( export function rollupConsistency({ events, + ...rest +}: RollupConsistencyInput): TargetConsistency { + return rollupConsistencyFromDays({ + ...rest, + byDay: sumByDay(events, rest.timezone), + readingCount: events.length, + }); +} + +interface RollupDaySumsInput extends ConsistencyClock { + /** Each local day's readings as sum and count. */ + byDay: DaySums; + /** Readings behind `byDay`, for the thin-data threshold. */ + readingCount: number; + classify: (mean: number) => DayBand | null; + totalReadingsThreshold?: number; +} + +/** + * The same consistency as {@link rollupConsistency}, from readings already + * folded per local day. Every figure here is a function of each day's mean + * and of the reading count, so a reader that folds in SQL hands over one row + * per day instead of every reading and gets the same answer. + */ +export function rollupConsistencyFromDays({ + byDay, + readingCount, classify, timezone, now, totalReadingsThreshold = 3, -}: RollupConsistencyInput): TargetConsistency { - if (events.length < totalReadingsThreshold) { - const partial = computeStrip(events, classify, timezone, now); +}: RollupDaySumsInput): TargetConsistency { + if (readingCount < totalReadingsThreshold) { + const partial = computeStrip(byDay, classify, timezone, now); return { ...EMPTY_CONSISTENCY, ...partial, @@ -91,15 +127,7 @@ export function rollupConsistency({ }; } - const byDay = new Map(); - for (const event of events) { - const key = userDayKey(event.measuredAt, timezone); - const bucket = byDay.get(key) ?? { sum: 0, count: 0 }; - bucket.sum += event.value; - bucket.count += 1; - byDay.set(key, bucket); - } - const strip = computeStrip(events, classify, timezone, now); + const strip = computeStrip(byDay, classify, timezone, now); let daysInRange30d = 0; let daysLogged30d = 0; @@ -136,7 +164,7 @@ export function rollupConsistency({ lastMetGoalAt, streakDays, insufficientData: - events.length < totalReadingsThreshold || strip.daysLogged7d < 1, + readingCount < totalReadingsThreshold || strip.daysLogged7d < 1, consistency7d: strip.consistency7d, }; } diff --git a/src/lib/targets/glucose-builder.ts b/src/lib/targets/glucose-builder.ts index cea599c92..7d3dad20f 100644 --- a/src/lib/targets/glucose-builder.ts +++ b/src/lib/targets/glucose-builder.ts @@ -4,8 +4,9 @@ import { type GlucoseContextBucket, } from "@/lib/glucose"; import { resolveGlucoseTarget } from "./glucose-targets"; -import { makeRangeClassifier, rollupConsistency } from "./consistency"; -import type { TargetGlucoseRow, TargetItem, TargetProfile } from "./types"; +import { makeRangeClassifier, rollupConsistencyFromDays } from "./consistency"; +import type { TargetGlucoseContextSummary } from "./glucose-read"; +import type { TargetItem, TargetProfile } from "./types"; const LABEL_BY_CONTEXT: Record = { FASTING: "targets.glucoseFasting", @@ -16,38 +17,53 @@ const LABEL_BY_CONTEXT: Record = { }; interface GlucoseTargetsInput { - rows: TargetGlucoseRow[]; + /** + * One summary per stored meal context: the newest reading of the year and + * the last thirty days per local day (`readGlucoseTargetSummaries`). + */ + summaries: TargetGlucoseContextSummary[]; profile: TargetProfile; timezone: string; now: Date; } export function buildGlucoseTargets({ - rows, + summaries, profile, timezone, now, }: GlucoseTargetsInput): TargetItem[] { const targets: TargetItem[] = []; const unit = resolveGlucoseUnit(profile.glucoseUnit); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // #943 — the untagged bucket rides the same loop. Iterating the four named // contexts alone left an account whose source writes no meal-time tag with // no glucose target at all, the same gap the dashboard tile had. - for (const [context, contextRows] of groupByGlucoseContext( - rows, - (row) => row.glucoseContext, + for (const [context, contextSummaries] of groupByGlucoseContext( + summaries, + (summary) => summary.glucoseContext, )) { - const latest = contextRows[0].value; - const recent = contextRows.filter((row) => row.measuredAt >= thirtyDaysAgo); + // A bucket is one stored context today; folding several keeps the newest + // reading and adds the days up, should two ever share a bucket. + const newest = contextSummaries.reduce((a, b) => + b.latestAt > a.latestAt ? b : a, + ); + const latest = newest.latest; + const recentByDay = new Map(); + let recentCount = 0; + let recentSum = 0; + for (const summary of contextSummaries) { + recentCount += summary.recentCount; + for (const [day, bucket] of summary.recentByDay) { + const current = recentByDay.get(day) ?? { sum: 0, count: 0 }; + current.sum += bucket.sum; + current.count += bucket.count; + recentByDay.set(day, current); + recentSum += bucket.sum; + } + } const average30 = - recent.length > 0 - ? Math.round( - (recent.reduce((sum, row) => sum + row.value, 0) / recent.length) * - 10, - ) / 10 - : null; + recentCount > 0 ? Math.round((recentSum / recentCount) * 10) / 10 : null; const resolved = resolveGlucoseTarget({ context, hasDiabetes: profile.hasDiabetes, @@ -97,11 +113,9 @@ export function buildGlucoseTargets({ : resolved.source === "ADA goal (diabetes)" ? "ADA goal (diabetes)" : "ADA 2024 / DDG", - ...rollupConsistency({ - events: recent.map((row) => ({ - measuredAt: row.measuredAt, - value: row.value, - })), + ...rollupConsistencyFromDays({ + byDay: recentByDay, + readingCount: recentCount, classify: makeRangeClassifier( range, effectiveRange diff --git a/src/lib/targets/glucose-read.ts b/src/lib/targets/glucose-read.ts new file mode 100644 index 000000000..0496f8cef --- /dev/null +++ b/src/lib/targets/glucose-read.ts @@ -0,0 +1,148 @@ +/** + * The glucose rows the targets page needs, folded in Postgres. + * + * The glucose targets used to read every glucose reading of the last year and + * keep three things from them per meal context: the newest value, and for the + * last thirty days each local day's mean and the number of readings. A sensor + * that reports every five minutes puts 105 000 readings in that year, and the + * targets page is read on every visit, on the web and in the app. So the year + * is reduced to one row per context (`DISTINCT ON`) and the thirty days to one + * row per context and local day, and only those rows cross the wire. + * + * Output matches {@link foldGlucoseTargetRows} over the raw readings: the same + * newest value per context (ties broken by id, newest first), and the same + * per-day sums and counts, with days cut in the zone the caller passes by the + * expression every other day fold uses. + */ +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; +import type { GlucoseContext } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { isValidTimezone, userDayKey } from "@/lib/tz/format"; +import type { TargetGlucoseRow } from "./types"; + +/** One meal context's newest reading and its last thirty days, per day. */ +export interface TargetGlucoseContextSummary { + glucoseContext: GlucoseContext | null; + latest: number; + latestAt: Date; + /** Local day → the day's readings as sum and count, since `recentSince`. */ + recentByDay: Map; + /** Readings behind `recentByDay`. */ + recentCount: number; +} + +/** + * The in-memory fold over raw readings, newest first. Pure; the reference the + * SQL reader is tested against. + */ +export function foldGlucoseTargetRows( + rows: readonly TargetGlucoseRow[], + recentSince: Date, + timeZone: string, +): TargetGlucoseContextSummary[] { + const byContext = new Map(); + for (const row of rows) { + const key = row.glucoseContext ?? ""; + let summary = byContext.get(key); + if (!summary) { + summary = { + glucoseContext: row.glucoseContext, + latest: row.value, + latestAt: row.measuredAt, + recentByDay: new Map(), + recentCount: 0, + }; + byContext.set(key, summary); + } + if (row.measuredAt < recentSince) continue; + const day = userDayKey(row.measuredAt, timeZone); + const bucket = summary.recentByDay.get(day) ?? { sum: 0, count: 0 }; + bucket.sum += row.value; + bucket.count += 1; + summary.recentByDay.set(day, bucket); + summary.recentCount += 1; + } + return [...byContext.values()]; +} + +export async function readGlucoseTargetSummaries(opts: { + userId: string; + /** Inclusive lower bound for the newest reading per context. */ + since: Date; + /** Inclusive lower bound for the per-day fold. */ + recentSince: Date; + /** IANA zone the day boundary is cut in. */ + timeZone: string; + db?: Pick; +}): Promise { + const db = opts.db ?? prisma; + const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC"; + const [latestRows, dayRows] = await Promise.all([ + db.$queryRaw< + Array<{ + glucose_context: GlucoseContext | null; + value: number; + measured_at: Date; + }> + >(Prisma.sql` + SELECT DISTINCT ON (m."glucose_context") + m."glucose_context" AS glucose_context, + m."value" AS value, + m."measured_at" AS measured_at + FROM measurements m + WHERE m."user_id" = ${opts.userId} + AND m."type" = 'BLOOD_GLUCOSE'::measurement_type + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${opts.since} + ORDER BY m."glucose_context", m."measured_at" DESC, m."id" DESC + `), + db.$queryRaw< + Array<{ + glucose_context: GlucoseContext | null; + day: string; + n: number; + sum: number; + }> + >(Prisma.sql` + WITH src AS ( + SELECT + m."glucose_context" AS glucose_context, + to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day, + m."value" AS value + FROM measurements m + WHERE m."user_id" = ${opts.userId} + AND m."type" = 'BLOOD_GLUCOSE'::measurement_type + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${opts.recentSince} + ) + SELECT + glucose_context, + day, + COUNT(*)::int AS n, + SUM(value)::double precision AS sum + FROM src + GROUP BY glucose_context, day + `), + ]); + + const byContext = new Map(); + for (const row of latestRows) { + byContext.set(row.glucose_context ?? "", { + glucoseContext: row.glucose_context, + latest: Number(row.value), + latestAt: row.measured_at, + recentByDay: new Map(), + recentCount: 0, + }); + } + for (const row of dayRows) { + // Every recent reading is also inside the year, so its context has a + // newest row unless `recentSince` predates `since`, which no caller does. + const summary = byContext.get(row.glucose_context ?? ""); + if (!summary) continue; + const n = Number(row.n); + summary.recentByDay.set(row.day, { sum: Number(row.sum), count: n }); + summary.recentCount += n; + } + return [...byContext.values()]; +} diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 05c6eccaf..69b8b589d 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -13,7 +13,10 @@ import { classifyPulseByTarget, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; -import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { + mergeRestingWithProxy, + type PulseSample, +} from "@/lib/analytics/resting-pulse"; import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; import { userDayKey } from "@/lib/tz/resolver"; @@ -38,7 +41,15 @@ export interface BloodPressureTargetRange { } interface VitalTargetsInput { + /** The last thirty days of every vital type except `PULSE`. */ recentMeasurements: TargetMeasurement[]; + /** + * The resting-pulse proxy over the same thirty days, one point per local + * day, folded from raw `PULSE` in Postgres (`readRestingPulseProxy`). Raw + * heart rate is never read here: a watch that records it once a minute puts + * 43 000 readings in thirty days, and the card needs one figure per day. + */ + restingPulseProxy: PulseSample[]; latestByType: TargetValueByType; average30ByType: TargetValueByType; heightCm: number | null; @@ -140,6 +151,7 @@ function buildBpPairsByDay( export function buildVitalTargets({ recentMeasurements, + restingPulseProxy, latestByType, average30ByType, heightCm, @@ -273,19 +285,14 @@ export function buildVitalTargets({ } const pulseTarget = getPersonalizedPulseTarget(age, referenceSex); - const restingResolved = resolveRestingPulseSeries({ + const restingResolved = mergeRestingWithProxy({ restingSamples: recentMeasurements .filter((measurement) => measurement.type === "RESTING_HEART_RATE") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - pulseSamples: recentMeasurements - .filter((measurement) => measurement.type === "PULSE") - .map((measurement) => ({ - measuredAt: measurement.measuredAt, - value: measurement.value, - })), + proxySamples: restingPulseProxy, dayKeyOf: (date) => userDayKey(date, timezone), }); const restingEvents = restingResolved.series; diff --git a/tests/integration/dense-targets-reads.test.ts b/tests/integration/dense-targets-reads.test.ts new file mode 100644 index 000000000..e3a4a2aae --- /dev/null +++ b/tests/integration/dense-targets-reads.test.ts @@ -0,0 +1,283 @@ +/** + * The targets page on a dense account: a glucose sensor reporting every five + * minutes and a watch recording heart rate once a minute. + * + * `buildTargetsResponse` read a year of raw glucose readings to keep the + * newest one per meal context, and thirty days of raw heart rate to derive one + * resting figure per day. Both now fold in SQL. These cases pin that neither + * stream is read as rows any more, and that the cards come out exactly as the + * in-memory fold over the raw readings makes them, on a dense account and on + * a sparse one with readings next to the local midnight. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse"; +import { buildTargetsResponse } from "@/lib/targets/build-response"; +import { buildGlucoseTargets } from "@/lib/targets/glucose-builder"; +import { + foldGlucoseTargetRows, + readGlucoseTargetSummaries, +} from "@/lib/targets/glucose-read"; +import { buildVitalTargets } from "@/lib/targets/vitals-builder"; +import { userDayKey } from "@/lib/tz/format"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "America/New_York"; +const DENSE = "dense-targets-owner"; +const SPARSE = "sparse-targets-owner"; +const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000); +const DAY = 86_400_000; + +beforeAll(async () => { + await truncateAllTables(prisma); + for (const id of [DENSE, SPARSE]) { + await prisma.user.create({ + data: { + id, + username: id, + timezone: TZ, + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + gender: "FEMALE", + heightCm: 170, + }, + }); + invalidateUserTimezone(id); + } + // 200 days of a sensor every five minutes, untagged, with every 37th + // reading tagged fasting so two contexts are present. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, glucose_context, measured_at, + created_at, updated_at, sync_version) + SELECT 'dg' || lpad(g::text, 7, '0'), $1, 'BLOOD_GLUCOSE'::measurement_type, + 70 + (g % 110) + (g % 3) * 0.25, 'mg/dL', 'NIGHTSCOUT'::measurement_source, + CASE WHEN g % 37 = 0 THEN 'FASTING'::glucose_context ELSE NULL END, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '5 minutes'), + now(), now(), 1 + FROM generate_series(0, 200 * 288 - 1) g`, + DENSE, + NOW.toISOString(), + ); + // Ten days of heart rate once a minute. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, measured_at, + created_at, updated_at, sync_version) + SELECT 'dp' || lpad(g::text, 7, '0'), $1, 'PULSE'::measurement_type, + 55 + (g % 50), 'bpm', 'GOOGLE_HEALTH'::measurement_source, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'), + now(), now(), 1 + FROM generate_series(0, 10 * 24 * 60 - 1) g`, + DENSE, + NOW.toISOString(), + ); + + // Sparse: fingerstick readings in three contexts, a few a day, some older + // than thirty days, some within minutes of the New York midnight; a cuff a + // few times a day; a resting heart rate on alternate days. + const contexts = ["FASTING", "POSTPRANDIAL", null] as const; + const sparse: Array<{ + type: "BLOOD_GLUCOSE" | "PULSE" | "RESTING_HEART_RATE"; + value: number; + at: Date; + ctx?: (typeof contexts)[number]; + }> = []; + for (let day = 0; day < 60; day++) { + for (let i = 0; i < 1 + (day % 3); i++) { + sparse.push({ + type: "BLOOD_GLUCOSE", + value: 85 + ((day * 13 + i * 29) % 90) + (i % 2 ? 0.5 : 0), + at: new Date(NOW.getTime() - day * DAY - (i * 311 + 7) * 60_000), + ctx: contexts[(day + i) % 3], + }); + } + if (day < 25) { + for (let i = 0; i < 2 + (day % 4); i++) { + sparse.push({ + type: "PULSE", + value: 54 + ((day * 7 + i * 11) % 40), + at: new Date(NOW.getTime() - day * DAY - (i * 173 + 3) * 60_000), + }); + } + } + if (day % 2 === 0 && day < 30) { + sparse.push({ + type: "RESTING_HEART_RATE", + value: 58 + (day % 6), + at: new Date(NOW.getTime() - day * DAY - 4 * 3_600_000), + }); + } + } + await prisma.measurement.createMany({ + data: sparse.map((r, i) => ({ + id: `st${String(i).padStart(5, "0")}`, + userId: SPARSE, + type: r.type, + value: r.value, + unit: r.type === "BLOOD_GLUCOSE" ? "mg/dL" : "bpm", + source: "MANUAL" as const, + glucoseContext: r.ctx ?? null, + measuredAt: r.at, + })), + }); + await prisma.$executeRawUnsafe(`ANALYZE measurements`); +}, 180_000); + +function readsDenseRows(args: unknown): boolean { + const type = (args as { where?: { type?: unknown } } | undefined)?.where + ?.type; + if (type === "BLOOD_GLUCOSE" || type === "PULSE") return true; + const list = (type as { in?: unknown } | undefined)?.in; + return ( + Array.isArray(list) && + (list.includes("PULSE") || list.includes("BLOOD_GLUCOSE")) + ); +} + +describe("targets on a dense account", () => { + it("never reads the glucose or heart-rate stream as rows", async () => { + const spy = vi.spyOn(prisma.measurement, "findMany"); + try { + const started = performance.now(); + const res = await buildTargetsResponse({ id: DENSE, timezone: TZ }); + const elapsed = performance.now() - started; + process.stderr.write( + `[dense-targets] buildTargetsResponse ${elapsed.toFixed(0)} ms\n`, + ); + expect(res.targets.length).toBeGreaterThan(0); + const denseReads = spy.mock.calls.filter(([args]) => + readsDenseRows(args), + ); + expect(denseReads).toEqual([]); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("targets fold equals the raw-reading fold", () => { + for (const userId of [SPARSE, DENSE]) { + it(`glucose summaries match the in-memory fold (${userId})`, async () => { + const since = new Date(NOW.getTime() - 365 * DAY); + const recentSince = new Date(NOW.getTime() - 30 * DAY); + const raw = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + select: { value: true, measuredAt: true, glucoseContext: true }, + }); + const expected = foldGlucoseTargetRows(raw, recentSince, TZ); + const actual = await readGlucoseTargetSummaries({ + userId, + since, + recentSince, + timeZone: TZ, + }); + const byCtx = (xs: T[]) => + [...xs].sort((a, b) => + (a.glucoseContext ?? "").localeCompare(b.glucoseContext ?? ""), + ); + expect(actual.length).toBeGreaterThanOrEqual(2); + const exp = byCtx(expected); + const act = byCtx(actual); + expect(act.map((s) => s.glucoseContext)).toEqual( + exp.map((s) => s.glucoseContext), + ); + for (let i = 0; i < exp.length; i++) { + expect(act[i].latest).toBe(exp[i].latest); + expect(act[i].latestAt).toEqual(exp[i].latestAt); + expect(act[i].recentCount).toBe(exp[i].recentCount); + expect([...act[i].recentByDay.keys()].sort()).toEqual( + [...exp[i].recentByDay.keys()].sort(), + ); + for (const [day, bucket] of exp[i].recentByDay) { + const got = act[i].recentByDay.get(day)!; + expect(got.count, day).toBe(bucket.count); + expect(got.sum, day).toBeCloseTo(bucket.sum, 6); + } + } + }); + + it(`glucose and resting-pulse cards equal the raw path (${userId})`, async () => { + const res = await buildTargetsResponse({ id: userId, timezone: TZ }); + // The builder takes its own clock after the reads; rebuild the raw + // expectation against the same thirty-day cut. + const now = new Date(); + const recentSince = new Date(now.getTime() - 30 * DAY); + const raw = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: new Date(now.getTime() - 365 * DAY) }, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + select: { value: true, measuredAt: true, glucoseContext: true }, + }); + const expectedGlucose = buildGlucoseTargets({ + summaries: foldGlucoseTargetRows(raw, recentSince, TZ), + profile: { + heightCm: 170, + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + gender: "FEMALE", + glucoseUnit: null, + hasDiabetes: false, + thresholdsJson: null, + }, + timezone: TZ, + now, + }); + const glucose = res.targets.filter((t) => + t.type.startsWith("BLOOD_GLUCOSE_"), + ); + expect(glucose.length).toBe(expectedGlucose.length); + for (let i = 0; i < glucose.length; i++) { + const { average30, ...rest } = glucose[i]; + const { average30: expAvg, ...expRest } = expectedGlucose[i]; + expect(rest).toEqual(expRest); + if (expAvg === null) expect(average30).toBeNull(); + else expect(average30).toBeCloseTo(expAvg, 6); + } + + const recent = await prisma.measurement.findMany({ + where: { + userId, + type: { in: ["PULSE", "RESTING_HEART_RATE"] }, + deletedAt: null, + measuredAt: { gte: recentSince }, + }, + select: { type: true, value: true, measuredAt: true }, + }); + const expectedPulse = buildVitalTargets({ + recentMeasurements: recent.filter( + (r) => r.type === "RESTING_HEART_RATE", + ), + restingPulseProxy: deriveRestingProxyFromPulse( + recent.filter((r) => r.type === "PULSE"), + (d) => userDayKey(d, TZ), + ), + latestByType: {}, + average30ByType: {}, + heightCm: 170, + age: res.profile.age, + gender: "FEMALE", + timezone: TZ, + now, + weightTargetOverride: null, + }).targets.find((t) => t.type === "PULSE"); + const pulse = res.targets.find((t) => t.type === "PULSE"); + expect(pulse?.current).not.toBeNull(); + expect(pulse).toEqual(expectedPulse); + }); + } +}); From 8949606de9ef71e7b618e9c0067e5510a4afedb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:50:31 +0200 Subject: [PATCH 02/16] fix(cycle): read the insights crosstab as per-source day aggregates GET /api/cycle/insights read a year of raw readings for its outcome channels, glucose and heart-rate variability included. On a sensor account that is over 100 000 objects per request. The crosstab uses each local day's sum and count after picking one source and device per day, so the route now reads readSourceDayAggregates like the coach's cycle snapshot does, and each row carries its reading count into metricDayMap. The integration test runs the route over the fold and over every raw reading handed in as a one-reading group, and compares the answers. --- .../insights/__tests__/route-locale.test.ts | 3 + src/app/api/cycle/insights/route.ts | 40 ++- .../cycle-insights-dense-reads.test.ts | 264 ++++++++++++++++++ 3 files changed, 285 insertions(+), 22 deletions(-) create mode 100644 tests/integration/cycle-insights-dense-reads.test.ts diff --git a/src/app/api/cycle/insights/__tests__/route-locale.test.ts b/src/app/api/cycle/insights/__tests__/route-locale.test.ts index a55be82e6..0a516735c 100644 --- a/src/app/api/cycle/insights/__tests__/route-locale.test.ts +++ b/src/app/api/cycle/insights/__tests__/route-locale.test.ts @@ -37,6 +37,9 @@ vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({ discoverPhaseCorrelations(args), })); +vi.mock("@/lib/measurements/day-aggregates", () => ({ + readSourceDayAggregates: vi.fn().mockResolvedValue([]), +})); vi.mock("@/lib/db", () => ({ prisma: { auditLog: { create: vi.fn() }, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 4fc89c5b3..fb9d6c1c8 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -43,6 +43,7 @@ import { type SymptomDay, } from "@/lib/cycle/symptom-phase"; import { addDays } from "@/lib/cycle/day-math"; +import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates"; import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -130,27 +131,19 @@ export const GET = apiHandler(async () => { orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }), - // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. Skipped whole - // for a caller outside `measurements`. + // The outcome metrics the phase contrast compares, soft-delete-scoped. + // A year of steps, heart-rate variability and sensor glucose is far too + // many readings to hold as objects (#1023), and the crosstab uses only + // each day's sum and count after picking one source and device per day. + // So the fold runs in SQL, one row per type, local day, source and + // device, the read the coach's cycle snapshot already makes. Skipped + // whole for a caller outside `measurements`. seesMeasurements - ? prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, + ? readSourceDayAggregates({ + userId: user.id, + types: PHASE_CROSSTAB_METRIC_TYPES, + since: new Date(Date.parse(`${from}T00:00:00Z`)), + timeZone: tz, }) : [], prisma.user.findUnique({ @@ -200,10 +193,13 @@ export const GET = apiHandler(async () => { today, ); + // Each row stands for its day's readings: `value` is their sum and + // `count` their number, so `metricDayMap` gets the raw readings' day mean. const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ type: m.type, - value: m.value, - measuredAt: m.measuredAt, + value: m.sum, + count: m.n, + measuredAt: m.firstAt, source: m.source, deviceType: m.deviceType, })); diff --git a/tests/integration/cycle-insights-dense-reads.test.ts b/tests/integration/cycle-insights-dense-reads.test.ts new file mode 100644 index 000000000..545e1d24b --- /dev/null +++ b/tests/integration/cycle-insights-dense-reads.test.ts @@ -0,0 +1,264 @@ +/** + * `GET /api/cycle/insights` reads its outcome channels as per-source day + * aggregates, not as a year of readings. + * + * The route compared a year of resting heart rate, heart-rate variability, + * sleep, steps, weight, temperature and glucose across the cycle phases, and + * read every reading of it. A sensor reporting glucose every five minutes is + * over 100 000 readings in that year. The contrast only uses each local day's + * sum and count after one source and device is picked per day, which is what + * `readSourceDayAggregates` returns. + * + * Two properties on real Postgres: no outcome channel is read as rows, and the + * response is the one the raw readings give. The second runs the route twice, + * once over the SQL fold and once over every raw reading handed in as its own + * one-reading aggregate (exactly the old input), and compares the bodies. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import type { SourceDayAggregateRow } from "@/lib/measurements/day-aggregates"; +import { userDayKey } from "@/lib/tz/format"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** When set, the fold is replaced by every raw reading as a one-row group. */ +const mode = vi.hoisted(() => ({ raw: false })); + +vi.mock("@/lib/measurements/day-aggregates", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readSourceDayAggregates: async ( + opts: Parameters[0], + ): Promise => { + if (!mode.raw) return actual.readSourceDayAggregates(opts); + const { getPrismaClient } = await import("./setup"); + const rows = await getPrismaClient().measurement.findMany({ + where: { + userId: opts.userId, + deletedAt: null, + type: { in: [...opts.types] }, + measuredAt: { gte: opts.since }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }); + return rows.map((r) => ({ + type: r.type, + day: userDayKey(r.measuredAt, opts.timeZone), + source: r.source, + deviceType: r.deviceType, + n: 1, + sum: r.value, + firstAt: r.measuredAt, + })); + }, + }; +}); + +const prisma = getPrismaClient(); +const OWNER = "cycle-dense-owner"; +const TZ = "Europe/Berlin"; +const CYCLE_LENGTH = 28; +const OBSERVED_CYCLES = 5; +const DAY = 86_400_000; + +function isoDay(offsetDays: number): string { + return new Date(Date.now() + offsetDays * DAY).toISOString().slice(0, 10); +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: OWNER, username: OWNER, gender: "FEMALE", timezone: TZ }, + }); + invalidateUserTimezone(OWNER); + await prisma.cycleProfile.create({ + data: { + userId: OWNER, + cycleTrackingEnabled: true, + predictionEnabled: false, + lutealPhaseLength: 14, + }, + }); + for (let i = OBSERVED_CYCLES; i >= 1; i--) { + await prisma.menstrualCycle.create({ + data: { + userId: OWNER, + startDate: isoDay(-i * CYCLE_LENGTH), + endDate: i > 1 ? isoDay(-(i - 1) * CYCLE_LENGTH - 1) : null, + periodEndDate: isoDay(-i * CYCLE_LENGTH + 4), + tz: "UTC", + }, + }); + } + const start = `${isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH)} 00:00:00`; + // Sensor glucose every five minutes from two sources, higher in the second + // half of each cycle; heart-rate variability every twenty minutes from a + // watch, lower in the second half; steps every fifteen minutes from a watch + // and a phone; weight once a day next to the Berlin midnight. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version) + SELECT 'cg' || s || '-' || g, $1, 'BLOOD_GLUCOSE'::measurement_type, + (CASE WHEN (g / 288) % 28 >= 14 THEN 120 ELSE 95 END) + (g % 23) + s * 0.5, 'mg/dL', + (CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source, + $2::timestamp + (g * interval '5 minutes') + (s * interval '1 second'), + now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 288 - 1) g, generate_series(0, 1) s`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version) + SELECT 'hv' || g, $1, 'HEART_RATE_VARIABILITY'::measurement_type, + (CASE WHEN (g / 72) % 28 >= 14 THEN 38 ELSE 52 END) + (g % 11) * 0.7, 'ms', + 'APPLE_HEALTH'::measurement_source, + $2::timestamp + (g * interval '20 minutes'), now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 72 - 1) g`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at, sync_version) + SELECT 'st' || d || '-' || g, $1, 'ACTIVITY_STEPS'::measurement_type, 10 + (g % 7), 'count', + 'APPLE_HEALTH'::measurement_source, CASE WHEN d = 0 THEN 'watch' ELSE 'phone' END, + $2::timestamp + (g * interval '15 minutes') + (d * interval '2 seconds'), + now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 96 - 1) g, generate_series(0, 1) d + WHERE d = 0 OR g % 3 = 0`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version) + SELECT 'wt' || g, $1, 'WEIGHT'::measurement_type, + (CASE WHEN g % 28 >= 14 THEN 71.2 ELSE 70.1 END) + (g % 5) * 0.1, 'kg', + 'WITHINGS'::measurement_source, + $2::timestamp + (g * interval '1 day') + interval '22 hours 50 minutes', now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} - 1) g`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe(`ANALYZE measurements`); + const session = await prisma.session.create({ + data: { userId: OWNER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", session.id); +}, 180_000); + +async function readInsights(): Promise<{ status: number; body: unknown }> { + const { GET } = await import("@/app/api/cycle/insights/route"); + const handler = GET as unknown as (request: Request) => Promise; + const res = await handler(new Request("http://localhost/api/cycle/insights")); + return { status: res.status, body: await res.json() }; +} + +function readsOutcomeRows(args: unknown): boolean { + const type = (args as { where?: { type?: unknown } } | undefined)?.where + ?.type; + const list = (type as { in?: unknown } | undefined)?.in; + return ( + type === "BLOOD_GLUCOSE" || + type === "HEART_RATE_VARIABILITY" || + (Array.isArray(list) && + (list.includes("BLOOD_GLUCOSE") || + list.includes("HEART_RATE_VARIABILITY"))) + ); +} + +function expectSameUpToRounding( + actual: unknown, + expected: unknown, + path: string, +): void { + if (typeof expected === "number" && typeof actual === "number") { + const scale = Math.max(Math.abs(expected), Math.abs(actual)); + expect( + Math.abs(actual - expected) <= scale * 1e-9, + `${path}: ${actual} vs ${expected}`, + ).toBe(true); + return; + } + if (Array.isArray(expected)) { + expect(Array.isArray(actual), path).toBe(true); + expect((actual as unknown[]).length, path).toBe(expected.length); + expected.forEach((e, i) => + expectSameUpToRounding((actual as unknown[])[i], e, `${path}[${i}]`), + ); + return; + } + if (expected !== null && typeof expected === "object") { + expect(actual !== null && typeof actual === "object", path).toBe(true); + const a = actual as Record; + const e = expected as Record; + expect(Object.keys(a).sort(), path).toEqual(Object.keys(e).sort()); + for (const key of Object.keys(e)) { + expectSameUpToRounding(a[key], e[key], `${path}.${key}`); + } + return; + } + expect(actual, path).toEqual(expected); +} + +describe("cycle insights on a dense account", () => { + it("reads no outcome channel as rows", async () => { + mode.raw = false; + const spy = vi.spyOn(prisma.measurement, "findMany"); + try { + const started = performance.now(); + const { status } = await readInsights(); + process.stderr.write( + `[cycle-insights-dense] ${(performance.now() - started).toFixed(0)} ms\n`, + ); + expect(status).toBe(200); + expect(spy.mock.calls.filter(([a]) => readsOutcomeRows(a))).toEqual([]); + } finally { + spy.mockRestore(); + } + }, 60_000); + + it("answers exactly what the raw readings give", async () => { + mode.raw = false; + const folded = await readInsights(); + mode.raw = true; + const raw = await readInsights(); + mode.raw = false; + expect(folded.status).toBe(200); + const rows = (folded.body as { data: { rows: unknown[] } }).data.rows; + // The fixture is built so the contrast has something to say. + expect(rows.length).toBeGreaterThan(0); + // A day's sum is added in another order in SQL than in JavaScript, so a + // p-value can differ in its last bits; every figure is otherwise equal. + expectSameUpToRounding(folded.body, raw.body, "body"); + }, 120_000); +}); From b2c484e25e7b6ee2d0c826119838c3b00ff129de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:52:10 +0200 Subject: [PATCH 03/16] fix(series): bucket heart-rate variability and blood oxygen like pulse The dense rule in /api/measurements/series covered pulse and glucose only. Heart-rate variability and blood oxygen read every raw row for any window up to 3650 days, and a watch or ring that writes them every minute makes that the same unbounded read pulse had. Both kinds now fold per local day beyond 90 days and per local hour inside 90 days once the window holds more than 10 000 readings. A bucket keeps the point shape of its kind: only pulse carries valueMin / valueMax. Statistics stay computed over every reading, and a sparse series inside 90 days is unchanged. The OpenAPI description names the four kinds. --- docs/api/openapi.yaml | 19 +- src/app/api/measurements/series/route.ts | 32 +++- src/lib/openapi/routes/measurements.ts | 4 +- .../integration/dense-series-hrv-spo2.test.ts | 175 ++++++++++++++++++ 4 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 tests/integration/dense-series-hrv-spo2.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..ad690c61f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41066,9 +41066,9 @@ components: properties: id: type: string - description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse` / - `glucose` beyond 90 days); `hour:` for an hour bucket (`pulse` / `glucose` within 90 days - when the window holds more than 10 000 readings). + description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse`, + `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:` for an hour + bucket (the same kinds within 90 days when the window holds more than 10 000 readings). at: type: string format: date-time @@ -41141,12 +41141,13 @@ components: - stats additionalProperties: false description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an - explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and - `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window - holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff - or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range - as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` - is always computed over the underlying readings, never over the buckets." + explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, + `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within + 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or + ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries + the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its + range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. + `stats` is always computed over the underlying readings, never over the buckets." ListMeasurementsResponse: type: object properties: diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 06d46e07a..f42afb47c 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -49,11 +49,23 @@ const SLEEP_SERIES_MAX_DAYS = 365; * byte-identical. Sparse kinds (weight, BP, …) are untouched. */ const DENSE_SERIES_RAW_WINDOW_DAYS = 90; -const DENSE_SERIES_KINDS: ReadonlySet = new Set(["glucose", "pulse"]); +/** + * The kinds a device can write at sampling rate. Heart-rate variability and + * blood oxygen joined pulse and glucose once watches and rings began writing + * them every few minutes overnight: their 3650-day "Alle" range read every + * raw row, which is the read this rule exists to stop. + */ +const DENSE_SERIES_KINDS: ReadonlySet = new Set([ + "glucose", + "pulse", + "heartRateVariability", + "oxygenSaturation", +]); /** - * #1023 — inside the raw window, a pulse or glucose series with more rows - * than this is bucketed per local hour in SQL instead of sent raw. + * #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV, + * blood oxygen) with more rows than this is bucketed per local hour in SQL + * instead of sent raw. * * A watch that records heart rate once a minute puts 43 000 rows in the * default 30-day window and 130 000 in 90 days: a 15 MB response the client @@ -381,7 +393,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }, })) > DENSE_SERIES_RAW_ROW_CAP) ) { - // Day buckets past the raw window; hour buckets for a pulse or CGM + // Day buckets past the raw window; hour buckets for a dense-kind // stream too dense to send raw inside it. const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour"; // v1.28.25 — long-window read of a sample-dense kind (CGM glucose, @@ -489,17 +501,19 @@ export const GET = apiHandler(async (request: NextRequest) => { }; } } else { - // pulse — `value` is the bucket's average; valueMin/valueMax carry the - // bucket's low/high band (folding each hourly import bucket's own + // `value` is the bucket's average. For pulse, valueMin/valueMax carry + // the bucket's low/high band (folding each hourly import bucket's own // spread via the COALESCE above), same band semantics as the per-hour - // shape. + // shape. The other dense kinds keep the bare point shape their raw rows + // have, so a client reads a bucket the way it reads a reading. points = bucketRows.map((r) => ({ id: dayId(r.bucket_start), at: r.bucket_start.toISOString(), value: round2(r.mean), secondary: null, - valueMin: r.min_value, - valueMax: r.max_value, + ...(kind === "pulse" + ? { valueMin: r.min_value, valueMax: r.max_value } + : {}), })); if ( rawAgg !== undefined && diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 0795e88b1..864baeb4a 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -696,7 +696,7 @@ const seriesPointSchema = z.object({ id: z .string() .describe( - "Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:` for an hour bucket (`pulse` / `glucose` within 90 days when the window holds more than 10 000 readings).", + "Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:` for an hour bucket (the same kinds within 90 days when the window holds more than 10 000 readings).", ), at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."), value: z.number().describe("Primary value in the top-level `unit`."), @@ -751,7 +751,7 @@ const seriesResponse = z .meta({ id: "MeasurementsSeriesResponse", description: - "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.", + "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.", }); // ── Batched daily series (v1.18.6 dashboard fetch coalescing) ──────── diff --git a/tests/integration/dense-series-hrv-spo2.test.ts b/tests/integration/dense-series-hrv-spo2.test.ts new file mode 100644 index 000000000..e60c70647 --- /dev/null +++ b/tests/integration/dense-series-hrv-spo2.test.ts @@ -0,0 +1,175 @@ +/** + * The per-kind series for heart-rate variability and blood oxygen follows the + * same dense rule as pulse and glucose. + * + * Both used to read every raw row for any window up to 3650 days, and a ring + * or a watch that writes them every minute overnight, or all day, makes that + * the same unbounded read pulse had before #1023. Beyond 90 days they now fold + * per local day; within 90 days a window of more than 10 000 readings folds + * per local hour. A sparse account inside 90 days keeps its raw readings, + * byte for byte, and a bucket has the shape of a reading of its kind (no + * `valueMin` / `valueMax`, which only pulse carries). + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const DENSE = "dense-hrv-spo2-owner"; +const SPARSE = "sparse-hrv-spo2-owner"; +const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000); +/** Twelve days of one reading a minute: over the 10 000-row cap. */ +const DENSE_ROWS = 12 * 24 * 60; + +const KINDS = [ + { kind: "heartRateVariability", type: "HEART_RATE_VARIABILITY", unit: "ms" }, + { kind: "oxygenSaturation", type: "OXYGEN_SATURATION", unit: "%" }, +] as const; + +beforeAll(async () => { + await truncateAllTables(prisma); + for (const id of [DENSE, SPARSE]) { + await prisma.user.create({ data: { id, username: id, timezone: TZ } }); + invalidateUserTimezone(id); + } + for (const { type, unit } of KINDS) { + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, measured_at, + created_at, updated_at, sync_version) + SELECT $4 || lpad(g::text, 7, '0'), $1, $5::measurement_type, + CASE WHEN $5 = 'OXYGEN_SATURATION' THEN 92 + (g % 8) ELSE 30 + (g % 40) * 0.5 END, + $6, 'GOOGLE_HEALTH'::measurement_source, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'), + now(), now(), 1 + FROM generate_series(0, $3::int - 1) g`, + DENSE, + NOW.toISOString(), + DENSE_ROWS, + type === "OXYGEN_SATURATION" ? "ds" : "dh", + type, + unit, + ); + // A few readings a night for a month and a half. + await prisma.measurement.createMany({ + data: Array.from({ length: 45 * 3 }, (_, i) => ({ + id: `sp-${type}-${i}`, + userId: SPARSE, + type, + value: type === "OXYGEN_SATURATION" ? 94 + (i % 5) : 35 + (i % 17), + unit, + source: "MANUAL" as const, + measuredAt: new Date( + NOW.getTime() - Math.floor(i / 3) * 86_400_000 - (i % 3) * 7_200_000, + ), + })), + }); + } + await prisma.$executeRawUnsafe(`ANALYZE measurements`); +}, 120_000); + +async function series(userId: string, kind: string, days: number) { + const s = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { GET } = await import("@/app/api/measurements/series/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/measurements/series?kind=${kind}&days=${days}`, + ), + ); + expect(res.status).toBe(200); + return ( + (await res.json()) as { + data: { + unit: string; + points: Array & { id: string; value: number }>; + stats: { count: number; mean: number; min: number; max: number }; + }; + } + ).data; +} + +describe.each(KINDS)("$kind series", ({ kind, type, unit }) => { + it("sends a dense stream inside 90 days as hour buckets", async () => { + const data = await series(DENSE, kind, 30); + expect(data.unit).toBe(unit); + expect(data.points.length).toBeLessThanOrEqual(12 * 24 + 2); + expect(data.points.length).toBeGreaterThanOrEqual(12 * 24 - 2); + expect(data.points.every((p) => p.id.startsWith("hour:"))).toBe(true); + // A bucket has the shape of a reading of its kind. + expect( + data.points.every((p) => !("valueMin" in p) && !("valueMax" in p)), + ).toBe(true); + // Statistics stay over every reading. + const agg = await prisma.measurement.aggregate({ + where: { + userId: DENSE, + type, + measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) }, + }, + _count: true, + _min: { value: true }, + _max: { value: true }, + }); + expect(data.stats.count).toBe(agg._count); + expect(data.stats.min).toBe(agg._min.value); + expect(data.stats.max).toBe(agg._max.value); + }); + + it("folds the long ranges per local day", async () => { + const data = await series(SPARSE, kind, 3650); + expect(data.points.length).toBeGreaterThan(40); + expect(data.points.length).toBeLessThanOrEqual(46); + expect(data.points.every((p) => p.id.startsWith("day:"))).toBe(true); + expect(data.stats.count).toBe(45 * 3); + }); + + it("keeps a sparse series inside 90 days raw", async () => { + const data = await series(SPARSE, kind, 30); + const raw = await prisma.measurement.findMany({ + where: { + userId: SPARSE, + type, + measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) }, + }, + orderBy: { measuredAt: "asc" }, + select: { id: true, value: true, measuredAt: true }, + }); + expect(data.points).toEqual( + raw.map((r) => ({ + id: r.id, + at: r.measuredAt.toISOString(), + value: r.value, + secondary: null, + })), + ); + }); +}); From e7fdcc208ea233adbf43dbd5d01f258901f70470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:54:20 +0200 Subject: [PATCH 04/16] test(targets): pin the route over the folded glucose and heart-rate reads The route test mocked Prisma and asserted a raw glucose findMany. The glucose and resting-pulse inputs now come from their SQL readers, so the test mocks those, feeds the pinned response through the in-memory fold, and asserts that neither stream is read as rows. --- .../insights/targets/__tests__/route.test.ts | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index 19b0ca3e4..3fe45c806 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -30,6 +30,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// The glucose and heart-rate streams fold in SQL; this file pins the route's +// composition over Prisma mocks, so both readers are mocked at the module. +vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({ + ...(await importOriginal()), + readGlucoseTargetSummaries: vi.fn(), +})); +vi.mock("@/lib/analytics/resting-pulse-read", () => ({ + readRestingPulseProxy: vi.fn(), +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, @@ -84,6 +94,11 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { + foldGlucoseTargetRows, + readGlucoseTargetSummaries, +} from "@/lib/targets/glucose-read"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; const SESSION_USER = { id: "user-targets-1", @@ -153,6 +168,8 @@ beforeEach(() => { // 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([]); + vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]); + vi.mocked(readRestingPulseProxy).mockResolvedValue([]); }); describe("GET /api/insights/targets — mood-rollup tier swap", () => { @@ -338,16 +355,26 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => { // The glucose section used to scan the user's entire BLOOD_GLUCOSE // history on every cold build (no `measuredAt` filter at all) just - // to read the latest value per context. The read now carries the - // same 365-day floor as the latest-ever-per-type query. + // to read the latest value per context. It now folds in SQL, newest + // per context over a year and per day over thirty days, and no glucose + // or heart-rate reading is read as a row. await callGet(makeReq()); const calls = ( prisma.measurement.findMany as ReturnType ).mock.calls.map((c) => c[0]); - const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"); - expect(glucoseCall).toBeDefined(); - expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date); + expect( + calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"), + ).toBeUndefined(); + expect( + calls.filter((c) => c?.where?.type?.in?.includes?.("PULSE")), + ).toEqual([]); + const [args] = vi.mocked(readGlucoseTargetSummaries).mock.calls[0]; + const day = 86_400_000; + expect(Date.now() - args.since.getTime()).toBeGreaterThan(364 * day); + expect(Date.now() - args.since.getTime()).toBeLessThan(366 * day); + expect(Date.now() - args.recentSince.getTime()).toBeGreaterThan(29 * day); + expect(Date.now() - args.recentSince.getTime()).toBeLessThan(31 * day); }); it("pins the complete ordered public response for representative populated data", async () => { @@ -408,10 +435,20 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { ).mockImplementation( async (args: { where?: { type?: string | { in: string[] } } }) => { if (args.where?.type === "SLEEP_DURATION") return sleepRows; - if (args.where?.type === "BLOOD_GLUCOSE") return glucoseRows; return genericRows; }, ); + vi.mocked(readGlucoseTargetSummaries).mockImplementation( + async ({ recentSince, timeZone }) => + foldGlucoseTargetRows( + glucoseRows.map((row) => ({ + ...row, + glucoseContext: row.glucoseContext as "FASTING", + })), + recentSince, + timeZone, + ), + ); (prisma.$queryRawUnsafe as ReturnType).mockResolvedValue([ { type: "WEIGHT", value: 70 }, { type: "BLOOD_PRESSURE_SYS", value: 125 }, From 730e9e701580f2d2ca73675e5326218e80da2ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:56:19 +0200 Subject: [PATCH 05/16] fix(doctor-report): take the day path for dense heart-rate variability and blood oxygen The report's dense exception covered pulse and glucose. Heart-rate variability and blood oxygen were read raw for any window, and a watch or ring writing them every minute puts six figures of readings into a long report that draws them as one sparkline. Their report sections are a statistics row and a series, the same as pulse, so they take the same rule: beyond 90 days per local day, and inside 90 days per local day once the window holds more than 10 000 of their readings. One grouped count decides each type on its own. Count, minimum, maximum and the latest reading stay exact; the mean is the readings' mean. Glucose keeps its own rule because its clinical panel needs the per-reading counts. The bucket query now takes a list of types instead of two flags. --- .../health-record/__tests__/route.test.ts | 8 ++- .../__tests__/leaf-gating-sweep.test.ts | 1 + src/lib/doctor-report/collect.ts | 64 +++++++++++++----- src/lib/doctor-report/dense-buckets.ts | 31 +++++---- .../integration/dense-series-hrv-spo2.test.ts | 65 +++++++++++++++++++ 5 files changed, 133 insertions(+), 36 deletions(-) diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 29d5fe949..87cae0237 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -19,8 +19,12 @@ import { unzipSync } from "fflate"; vi.mock("@/lib/db", () => ({ prisma: { - // `count` sizes the report's pulse window (#1023): a sparse account. - measurement: { findMany: vi.fn(), count: vi.fn(async () => 0) }, + // `groupBy` sizes the report's dense windows (#1023): a sparse account. + measurement: { + findMany: vi.fn(), + count: vi.fn(async () => 0), + groupBy: vi.fn(async () => []), + }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index c279ed081..3d94b9e17 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -49,6 +49,7 @@ const { calls, rows, prismaMock } = vi.hoisted(() => { return Promise.resolve(rows[name] ?? []); }, count: () => Promise.resolve(0), + groupBy: () => Promise.resolve([]), findUnique: () => { calls.push(name); return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index fe2154b0a..d991edbac 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -77,8 +77,21 @@ import { const DENSE_REPORT_RAW_WINDOW_DAYS = 90; /** - * #1023 — inside the raw window, pulse takes the per-day path anyway once the - * window holds more readings than this. + * The types a device writes at sampling rate whose report sections are + * statistics and a series and nothing else: the figures the day path keeps + * exactly (count, mean, minimum, maximum, latest) plus one point per local + * day. Glucose is dense too but has its own rule below, because its clinical + * panel needs the per-reading counts the bucket query carries. + */ +const DENSE_STAT_TYPES = [ + "PULSE", + "HEART_RATE_VARIABILITY", + "OXYGEN_SATURATION", +] as const satisfies readonly MeasurementType[]; + +/** + * #1023 — inside the raw window, a dense statistics type takes the per-day + * path anyway once the window holds more of its readings than this. * * A watch that records heart rate once a minute puts 130 000 readings in a * 90-day report. Read raw, every one of them became an object, went through @@ -170,22 +183,38 @@ export async function collectDoctorReportData( const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`); const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS; - const densePulse = - !excluded.includes("PULSE") && - (aggregateDenseTypes || - (await prisma.measurement.count({ - where: { - userId, - type: "PULSE", - measuredAt: { gte: start, lte: end }, - deletedAt: null, - }, - })) > DENSE_REPORT_RAW_ROW_CAP); + const includedDenseStatTypes = DENSE_STAT_TYPES.filter( + (type) => !excluded.includes(type), + ); + // Inside the raw window, one grouped count decides each type on its own: + // a watch's minute-by-minute heart rate folds while a few blood-oxygen + // readings a night beside it stay raw. + const denseStatCounts = + aggregateDenseTypes || includedDenseStatTypes.length === 0 + ? null + : new Map( + ( + await prisma.measurement.groupBy({ + by: ["type"], + where: { + userId, + type: { in: [...includedDenseStatTypes] }, + measuredAt: { gte: start, lte: end }, + deletedAt: null, + }, + _count: { _all: true }, + }) + ).map((row) => [row.type, row._count._all]), + ); + const denseTypes: MeasurementType[] = includedDenseStatTypes.filter( + (type) => + denseStatCounts === null || + (denseStatCounts.get(type) ?? 0) > DENSE_REPORT_RAW_ROW_CAP, + ); const denseGlucose = aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE"); - const rawExcluded = [...excluded]; - if (densePulse) rawExcluded.push("PULSE"); - if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE"); + if (denseGlucose) denseTypes.push("BLOOD_GLUCOSE"); + const rawExcluded = [...excluded, ...denseTypes]; const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf)); const wantsIntakeEvents = @@ -318,8 +347,7 @@ export async function collectDoctorReportData( start, end, reportTz, - densePulse, - denseGlucose, + denseTypes, }), ]); diff --git a/src/lib/doctor-report/dense-buckets.ts b/src/lib/doctor-report/dense-buckets.ts index 597b55349..bd89d4dc1 100644 --- a/src/lib/doctor-report/dense-buckets.ts +++ b/src/lib/doctor-report/dense-buckets.ts @@ -1,16 +1,17 @@ /** - * The per-local-day pre-aggregation for the two sample-dense measurement - * types. + * The per-local-day pre-aggregation for the sample-dense measurement types. * * On a long window a CGM or a per-sample heart-rate account produces six - * figures of rows, so pulse and glucose collapse to one row per local - * day/source/device/context in SQL and the canonical-source picker then works - * over that bounded intermediate set. Every other type keeps the raw-row path. + * figures of rows, so the dense types (glucose, pulse, heart-rate variability, + * blood oxygen) collapse to one row per local day/source/device/context in + * SQL and the canonical-source picker then works over that bounded + * intermediate set. Every other type keeps the raw-row path. * * Extracted from the aggregator with the selection rework: the query is a job * of its own, it is the one raw-SQL surface on this path, and keeping it in a * thousand-line function is how a parameter stops being reviewed. */ +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import type { GlucoseContext, @@ -46,8 +47,8 @@ export interface DenseMeasurementBucket { } /** - * Load the dense buckets for whichever of the two types the caller wants. Both - * flags false is not a call: the caller skips it entirely. + * Load the dense buckets for the types the caller names. An empty list is not + * a call: nothing is read. * * Every value is bound as a tagged-template parameter — no splice, no * interpolation of anything that came off the wire. @@ -57,11 +58,13 @@ export async function loadDenseMeasurementBuckets(params: { start: Date; end: Date; reportTz: string; - densePulse: boolean; - denseGlucose: boolean; + denseTypes: readonly MeasurementType[]; }): Promise { - const { userId, start, end, reportTz, densePulse, denseGlucose } = params; - if (!densePulse && !denseGlucose) return []; + const { userId, start, end, reportTz, denseTypes } = params; + if (denseTypes.length === 0) return []; + const typeList = Prisma.join( + denseTypes.map((type) => Prisma.sql`${type}::"measurement_type"`), + ); return prisma.$queryRaw` WITH localized AS ( SELECT @@ -81,11 +84,7 @@ export async function loadDenseMeasurementBuckets(params: { AND m."measured_at" >= ${start} AND m."measured_at" <= ${end} AND m."deleted_at" IS NULL - AND ( - (${densePulse} AND m."type" = 'PULSE'::"measurement_type") - OR - (${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type") - ) + AND m."type" IN (${typeList}) ), scored AS ( SELECT diff --git a/tests/integration/dense-series-hrv-spo2.test.ts b/tests/integration/dense-series-hrv-spo2.test.ts index e60c70647..0145a4ae2 100644 --- a/tests/integration/dense-series-hrv-spo2.test.ts +++ b/tests/integration/dense-series-hrv-spo2.test.ts @@ -9,12 +9,19 @@ * per local hour. A sparse account inside 90 days keeps its raw readings, * byte for byte, and a bucket has the shape of a reading of its kind (no * `valueMin` / `valueMax`, which only pulse carries). + * + * The doctor report applies the same rule to both: their sections are a + * statistics row and a series, so a dense window takes the per-day path pulse + * already takes, with exact count, minimum, maximum and latest reading. */ import { NextRequest } from "next/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { latestReading } from "@/lib/fhir/resources/common"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; import { invalidateUserTimezone } from "@/lib/tz/resolver"; vi.mock("next/headers", async () => { @@ -173,3 +180,61 @@ describe.each(KINDS)("$kind series", ({ kind, type, unit }) => { ); }); }); + +describe.each(KINDS)("doctor report $type", ({ type }) => { + async function report(userId: string, days: number) { + const end = new Date(); + const start = new Date(end.getTime() - days * 86_400_000); + return collectDoctorReportData( + userId, + { start, end, days }, + selectionFromLeaves([type]), + ); + } + + it("folds a dense window to one point per day, statistics exact", async () => { + const data = await report(DENSE, 30); + const points = data.measurements[type] ?? []; + expect(points.length).toBeLessThanOrEqual(14); + expect(points.length).toBeGreaterThanOrEqual(12); + const agg = await prisma.measurement.aggregate({ + where: { userId: DENSE, type }, + _count: true, + _avg: { value: true }, + _min: { value: true }, + _max: { value: true }, + }); + expect(data.stats[type]?.count).toBe(agg._count); + expect(data.stats[type]?.min).toBe(agg._min.value); + expect(data.stats[type]?.max).toBe(agg._max.value); + expect(data.stats[type]?.avg).toBeCloseTo(agg._avg.value ?? 0, 9); + const newest = await prisma.measurement.findFirstOrThrow({ + where: { userId: DENSE, type }, + orderBy: { measuredAt: "desc" }, + select: { value: true, measuredAt: true }, + }); + expect(latestReading(data, type)).toEqual({ + value: newest.value, + measuredAt: newest.measuredAt.toISOString(), + }); + }); + + it("keeps a sparse window raw", async () => { + const data = await report(SPARSE, 30); + const count = await prisma.measurement.count({ + where: { + userId: SPARSE, + type, + measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) }, + }, + }); + expect(data.measurements[type]).toHaveLength(count); + expect(data.stats[type]?.count).toBe(count); + }); + + it("folds a window longer than 90 days per day", async () => { + const data = await report(SPARSE, 120); + expect(data.stats[type]?.count).toBe(45 * 3); + expect((data.measurements[type] ?? []).length).toBeLessThanOrEqual(46); + }); +}); From 5f1eb054457036c608b64cceef1fa967b2dbbfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:58:42 +0200 Subject: [PATCH 06/16] fix(measurements): bound the per-night sleep list to a year ending at to The sleep list capped its read to the last year only when no `from` was sent. An explicit early `from` walked every per-stage row back to that date, on a multi-source account several rows per night for years. The read window is now at most a year ending at `to` (or now). `from` narrows it but never widens it, older nights are reached by moving `to` back, and `meta.windowFrom` names the instant a cut window started, so a client can tell a cut from an empty history. --- docs/api/openapi.yaml | 6 ++ src/app/api/measurements/route.ts | 23 ++++++-- src/lib/openapi/routes/measurements.ts | 6 ++ .../measurements-sleep-list.test.ts | 59 ++++++++++++++++++- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ad690c61f..046d46b73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41340,6 +41340,12 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + windowFrom: + description: "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant + the read started from. The window is a year ending at `to` (or now); page further back by moving `to`." + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - total - limit diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ffcaafece..278ddb899 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -543,9 +543,18 @@ async function sleepListResponse( loadUserSourcePriority(user.id), ]); - // Bound the read window: honour an explicit from/to, otherwise cap to - // the last year so the per-stage scan stays small. - const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000); + // Bound the read window to a year ending at `to` (or now). An explicit + // `from` narrows it but never widens it: an early `from` used to walk every + // per-stage row back to that date, which on a multi-year, multi-source + // account is the unbounded read this cap exists to stop. Older nights are + // reached by moving `to` back, and `meta.windowFrom` says where a cut + // window started so a client can tell a cut from an empty history. + const windowEnd = to ?? new Date(); + const floor = new Date( + windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000, + ); + const since = from && from > floor ? from : floor; + const windowCut = from !== undefined && from < floor; const rows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -690,7 +699,13 @@ async function sleepListResponse( }); return apiSuccess({ measurements, - meta: { total: totalNights, limit, offset, groupBy: "night" }, + meta: { + total: totalNights, + limit, + offset, + groupBy: "night", + ...(windowCut ? { windowFrom: since.toISOString() } : {}), + }, }); } diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 864baeb4a..dea56ced2 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -389,6 +389,12 @@ const listMeasurementsMeta = z .describe( "Rows discarded by the cross-source canonical picker on the day-collapse mode.", ), + windowFrom: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant the read started from. The window is a year ending at `to` (or now); page further back by moving `to`.", + ), }) .meta({ id: "ListMeasurementsMeta" }); diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts index 9e952c4dc..9524bd854 100644 --- a/tests/integration/measurements-sleep-list.test.ts +++ b/tests/integration/measurements-sleep-list.test.ts @@ -56,7 +56,7 @@ interface ListEnvelope { napCount?: number; awakenings?: number; }>; - meta: { total: number; groupBy?: string }; + meta: { total: number; groupBy?: string; windowFrom?: string }; }; } @@ -160,4 +160,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { const stages = body.data.measurements.map((m) => m.sleepStage).sort(); expect(stages).toEqual(["CORE", "DEEP", "REM"]); }); + + it("reads at most a year ending at `to`, however early `from` is", async () => { + // An explicit early `from` used to walk every per-stage row back to it. + const prisma = getPrismaClient(); + const user = await seedSession("sleep-window-user"); + const day = 86_400_000; + const morning = new Date(); + morning.setUTCHours(4, 0, 0, 0); + // One night of a single CORE stage every day for two years. + await prisma.measurement.createMany({ + data: Array.from({ length: 730 }, (_, i) => ({ + userId: user.id, + type: "SLEEP_DURATION" as const, + value: 420, + unit: "minutes", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(morning.getTime() - i * day), + externalId: `uuid-night-${i}`, + sleepStage: "CORE" as const, + })), + }); + const spy = vi.spyOn(prisma.measurement, "findMany"); + const from = new Date(morning.getTime() - 800 * day).toISOString(); + const res = await callList( + `type=SLEEP_DURATION&from=${encodeURIComponent(from)}&limit=1000`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ListEnvelope; + expect(body.data.meta.total).toBeLessThanOrEqual(366); + expect(body.data.meta.total).toBeGreaterThanOrEqual(364); + expect(body.data.meta.windowFrom).toBeDefined(); + const windowFrom = Date.parse(body.data.meta.windowFrom!); + expect(Date.now() - windowFrom).toBeLessThan(366 * day); + expect(Date.now() - windowFrom).toBeGreaterThan(364 * day); + // The read itself was bounded, not just the answer. + const read = spy.mock.calls + .map(([args]) => args as { where?: { measuredAt?: { gte?: Date } } }) + .find((args) => args?.where?.measuredAt?.gte); + expect(read?.where?.measuredAt?.gte?.getTime()).toBe(windowFrom); + + // Older nights are reached by moving `to` back. + const to = new Date(morning.getTime() - 400 * day).toISOString(); + const older = await callList( + `type=SLEEP_DURATION&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=1000`, + ); + const olderBody = (await older.json()) as ListEnvelope; + expect(olderBody.data.meta.total).toBeGreaterThanOrEqual(329); + expect(olderBody.data.meta.total).toBeLessThanOrEqual(331); + + // A window inside the year is untouched and carries no marker. + const recent = await callList( + `type=SLEEP_DURATION&from=${encodeURIComponent(new Date(morning.getTime() - 10.5 * day).toISOString())}`, + ); + const recentBody = (await recent.json()) as ListEnvelope; + expect(recentBody.data.meta.total).toBe(11); + expect(recentBody.data.meta.windowFrom).toBeUndefined(); + }); }); From 0f31fd199b749165413ffa620ed0134c3366f0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:02:23 +0200 Subject: [PATCH 07/16] test(guard): police dense measurement reads in request handlers and raw SQL The dense-read guard walked the worker and insight directories only, and the same class kept turning up beside them: the targets page, the cycle insights, the per-kind series and the doctor report all lived in app/api or a helper next to it. The findMany check now also covers app/api, import, export, the targets and the doctor report. It reads the type from the where object rather than the first type key in the call, accepts a list of sparse literals, and counts a key-bounded where (an id, externalId or measuredAt list, or an exact instant) as bounded. Every remaining unbounded read is listed with its reason. A second check covers every raw statement in the tree that reads from measurements: it must GROUP BY, use DISTINCT or LIMIT, or select aggregates only, or be listed. Both checks carry floors and run their matchers over a planted offender. Restoring the old targets builder fails the findMany check on the year of glucose and the heart-rate month; dropping the GROUP BY from the resting-pulse fold fails the SQL check. --- .../dense-measurement-read-guard.test.ts | 356 +++++++++++++++--- 1 file changed, 299 insertions(+), 57 deletions(-) diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 8414051a4..35060ade2 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -1,6 +1,5 @@ /** - * Structural guard: background and insight readers must not materialise a - * dense measurement stream. + * Structural guard: no reader materialises a dense measurement stream. * * #1023: a single account syncing heart rate from a watch took the worker * down within half an hour. The status-card read was bounded in time @@ -10,17 +9,34 @@ * SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks * the number of days, not the sampling rate. * - * What this guard pins, in the directories that run on the worker or feed an - * insight: every `measurement.findMany` without a `take` reads a type that is - * named as a literal and is not one a device streams. A read whose type is a - * variable could be handed a dense type by the next caller, so it must be - * listed below with the reason it is safe. The list is frozen both ways: a new - * unlisted read fails, and an entry whose read is gone fails too. + * The same class kept turning up outside the directories this guard first + * walked: the targets page read a year of glucose and a month of heart rate, + * the cycle insights read a year of every outcome channel, the per-kind series + * read heart-rate variability and blood oxygen raw for ten years, all from + * `app/api` or a helper beside it. So the guard now covers the request + * handlers, import, export, the targets and the doctor report as well, and + * the raw SQL reads of `measurements` anywhere in the tree. * - * It is a tripwire, not a proof: the matcher reads source text, and a read - * written some other way (a raw SELECT of rows, a helper in another - * directory) passes it. The floor below fails the guard if the matcher stops - * finding reads at all. + * Two checks. + * + * 1. `measurement.findMany` without a `take`, in the scoped directories, + * must be bounded by its `where`: a type named as a literal (or a list of + * literals) none of which a device streams, or a key list (`id`, + * `externalId` or `measuredAt` `in: [...]`, or an exact `measuredAt`). + * Anything else is listed below with the reason it stays bounded. + * + * 2. A raw `$queryRaw` / `$queryRawUnsafe` whose statement reads from + * `measurements` must fold: `GROUP BY`, `DISTINCT`, `LIMIT`, or a select + * list of aggregates only. A statement that returns rows is listed below + * with its reason. A statement held in a variable is invisible to the + * matcher; that is the check's known blind spot. + * + * Both lists are frozen both ways: a new unlisted read fails, and an entry + * whose read is gone fails too. The floors below fail the guard if a matcher + * stops finding reads at all, and the "plants" cases run each matcher over a + * planted offender, which is the mutation proof kept in the suite: the + * planted findMany of `BLOOD_GLUCOSE` over a year and the planted raw SELECT of + * every pulse row are both caught. It is a tripwire, not a proof. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -42,6 +58,12 @@ const SCOPED_DIRS = [ "lib/analytics/", "lib/dashboard/", "lib/daily/", + // Widened in v1.39.7: the request handlers and the helpers they lean on. + "app/api/", + "lib/import", + "lib/export", + "lib/targets/", + "lib/doctor-report", ]; /** Types a device writes at sampling rate rather than a few times a day. */ @@ -55,8 +77,8 @@ const DENSE_TYPES: ReadonlySet = new Set([ ]); /** - * Untaken reads whose type is not a sparse literal, keyed - * `::`, with why each stays bounded. + * Untaken reads whose `where` neither names sparse literal types nor a key + * list, keyed `::`, with why each stays bounded. */ const ALLOWED: Record = { "lib/insights/comprehensive-generate.ts::(shorthand)": @@ -69,8 +91,6 @@ const ALLOWED: Record = { "A constant for six-minute-walk distance, written at most daily.", "lib/insights/derived/wellness-scores.ts::measurementType": "Daily computed / provider scores (recovery, strain, readiness), one row per day.", - "lib/jobs/reaction-line.ts::{ in: [...types]": - "Exact-instant match (`measuredAt: row.occurredAt`), not a window.", "lib/jobs/step-consolidation-repair.ts::STEP_TYPE": "Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.", 'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never': @@ -79,6 +99,34 @@ const ALLOWED: Record = { "Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.", "lib/analytics/score/reader.ts::{ in: types": "Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.", + 'app/api/analytics/route.ts::"BLOOD_GLUCOSE"': + "The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.", + "app/api/measurements/series/route.ts::(shorthand)": + "The raw branch of the per-kind series. Every dense kind reaches it only inside 90 days and under 10 000 rows (counted first); the sparse kinds are a few readings a day.", + "app/api/measurements/batch/route.ts::p.row.type as MeasurementType": + "Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.", + "app/api/measurements/route.ts::(shorthand)": + "Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.", + "lib/targets/build-response.ts::{ in: recentTypes": + "Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.", + "lib/doctor-report/collect.ts::{ notIn: rawExcluded": + "The report window's sparse types. Pulse, heart-rate variability and blood oxygen over 10 000 readings or any window over 90 days, and glucose over 90 days, are in `rawExcluded` and read as day buckets; glucose inside 90 days stays raw for the clinical panel (per-reading metrics), below 26 000 rows at a sensor's rate.", +}; + +/** + * Raw statements that read rows of `measurements` without folding them, keyed + * `::`, with why each + * stays bounded. + */ +const ALLOWED_SQL: Record = { + "lib/analytics/bp-in-target-fast-path.ts::1": + "Canonical-source blood pressure, one type over 365 days: a cuff, a few readings a day. The in-target pairing needs the individual readings.", + "lib/insights/comprehensive-aggregator.ts::1": + "Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.", + "lib/measurements/reconcile-external-measurement.ts::1": + "Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.", + "lib/rollups/measurement-coverage.ts::1": + "Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.", }; interface UntakenRead { @@ -87,63 +135,199 @@ interface UntakenRead { typeExpr: string; } -function scopedFiles(): string[] { +function lineOf(src: string, index: number): number { + return src.slice(0, index).split("\n").length; +} + +/** Index just past the bracket that closes the one at `open`. */ +function closeOf(src: string, open: number): number { + const pair: Record = { "(": ")", "{": "}", "[": "]" }; + const want = pair[src[open]]; + let depth = 1; + let i = open + 1; + while (depth > 0 && i < src.length) { + if (src[i] === src[open]) depth += 1; + else if (src[i] === want) depth -= 1; + i += 1; + } + return i; +} + +/** The `where: { … }` object of a call, or null when `where` is a variable. */ +function whereObject(call: string): string | null { + const m = /\bwhere\s*:\s*\{/.exec(call); + if (!m) return null; + const open = m.index + m[0].length - 1; + return call.slice(open, closeOf(call, open)); +} + +/** The type expression a `where` object constrains on. */ +function typeExprOf(call: string): string { + const where = whereObject(call); + if (where === null) { + return /\bwhere\s*[,}]/.test(call) ? "(where variable)" : "(none)"; + } + const typed = /\btype\s*:\s*([^,\n}]+)/.exec(where); + if (typed) return typed[1].trim(); + return /\btype\s*[,}\n]/.test(where) ? "(shorthand)" : "(none)"; +} + +/** A `where` that pins rows by key rather than by a window. */ +function keyBounded(call: string): boolean { + const where = whereObject(call) ?? ""; + return ( + /\b(?:id|externalId|measuredAt)\s*:\s*\{\s*in\s*:/.test(where) || + /\bmeasuredAt\s*:\s*(?![\s{])/.test(where) + ); +} + +function sparseLiteral(typeExpr: string): boolean { + const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr); + if (lit) return !DENSE_TYPES.has(lit[1]); + const list = /^\{\s*in\s*:\s*\[([^\]]*)/.exec(typeExpr); + if (!list) return false; + const items = list[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ( + items.length > 0 && + items.every((item) => { + const m = /^"([A-Z_0-9]+)"$/.exec(item); + return m !== null && !DENSE_TYPES.has(m[1]); + }) + ); +} + +/** Every untaken `measurement.findMany` in `src`, with its type expression. */ +function untakenFindManyReads( + file: string, + src: string, +): { all: number; untaken: UntakenRead[] } { + let all = 0; + const untaken: UntakenRead[] = []; + // Whitespace-tolerant: a call split across lines still matches. + const re = /\.measurement\s*\.\s*findMany\s*\(/g; + for (let m = re.exec(src); m; m = re.exec(src)) { + all += 1; + const call = src.slice(m.index, closeOf(src, m.index + m[0].length - 1)); + if (/\btake\b/.test(call)) continue; + if (keyBounded(call)) continue; + // A type expression can itself contain a full bracket (`{ in: [...] }`); + // the regex above stops at the first `,` / `}` / newline, which is the + // stable key the allowlist uses. + untaken.push({ + file, + line: lineOf(src, m.index), + typeExpr: typeExprOf(call), + }); + } + return { all, untaken }; +} + +/** The statement text of the raw call starting at `at`, or null if opaque. */ +function statementAt(src: string, at: number): string | null { + let i = at; + // Skip a type argument: `$queryRaw>`. + if (src[i] === "<") { + let depth = 1; + i += 1; + while (depth > 0 && i < src.length) { + if (src[i] === "<") depth += 1; + else if (src[i] === ">") depth -= 1; + i += 1; + } + } + while (/\s/.test(src[i] ?? "")) i += 1; + if (src[i] === "`") { + // Tagged template: up to the matching backtick, skipping `${ … }`. + let j = i + 1; + while (j < src.length && src[j] !== "`") { + if (src[j] === "$" && src[j + 1] === "{") j = closeOf(src, j + 1); + else j += 1; + } + return src.slice(i, j + 1); + } + if (src[i] === "(") return src.slice(i, closeOf(src, i)); + return null; +} + +const READS_MEASUREMENTS = + /\bFROM\s+"?measurements"?(?![_\w])|canonicalMeasurementsFrom\s*\(/i; +const FOLDS = + /GROUP\s+BY|\bDISTINCT\b|\bLIMIT\b|^[\s`(]*(?:Prisma\.sql`)?\s*SELECT\s+(?:COUNT|SUM|AVG|MIN|MAX|STDDEV_POP)\s*\(/i; + +interface RawRead { + file: string; + line: number; + folds: boolean; +} + +/** Every raw statement in `src` that reads from `measurements`. */ +function rawMeasurementReads(file: string, src: string): RawRead[] { + const out: RawRead[] = []; + const re = /\.\$queryRaw(?:Unsafe)?\b/g; + for (let m = re.exec(src); m; m = re.exec(src)) { + const statement = statementAt(src, m.index + m[0].length); + if (statement === null || !READS_MEASUREMENTS.test(statement)) continue; + out.push({ + file, + line: lineOf(src, m.index), + folds: FOLDS.test(statement), + }); + } + return out; +} + +function sourceFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }) - .filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d))) + .filter((p) => !p.startsWith("generated/")) .filter((p) => !p.includes("__tests__")) .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) .sort(); } -function untakenReads(): { all: number; untaken: UntakenRead[] } { +function scan() { let all = 0; const untaken: UntakenRead[] = []; - for (const file of scopedFiles()) { + const raw: RawRead[] = []; + for (const file of sourceFiles()) { const src = readFileSync(join(SRC, file), "utf8"); - // Whitespace-tolerant: a call split across lines still matches. - const re = /\.measurement\s*\.\s*findMany\s*\(/g; - for (let m = re.exec(src); m; m = re.exec(src)) { - all += 1; - let i = m.index + m[0].length; - let depth = 1; - while (depth > 0 && i < src.length) { - if (src[i] === "(") depth += 1; - else if (src[i] === ")") depth -= 1; - i += 1; - } - const call = src.slice(m.index, i); - if (/\btake\b/.test(call)) continue; - const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call); - const typeExpr = typed - ? typed[1].trim() - : /\btype\s*,/.test(call) - ? "(shorthand)" - : "(none)"; - untaken.push({ - file, - line: src.slice(0, m.index).split("\n").length, - typeExpr, - }); - } + raw.push(...rawMeasurementReads(file, src)); + if (!SCOPED_DIRS.some((d) => file.startsWith(d))) continue; + const reads = untakenFindManyReads(file, src); + all += reads.all; + untaken.push(...reads.untaken); } - return { all, untaken }; + return { all, untaken, raw }; } -function sparseLiteral(typeExpr: string): boolean { - const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr); - return lit !== null && !DENSE_TYPES.has(lit[1]); +/** `::` for the n-th unfolded statement of each file. */ +function unfoldedKeys(raw: readonly RawRead[]): Map { + const keys = new Map(); + const perFile = new Map(); + for (const read of raw) { + if (read.folds) continue; + const n = (perFile.get(read.file) ?? 0) + 1; + perFile.set(read.file, n); + keys.set(`${read.file}::${n}`, read); + } + return keys; } describe("dense measurement reads stay bounded (#1023)", () => { - const { all, untaken } = untakenReads(); + const { all, untaken, raw } = scan(); it("finds the reads it is meant to police", () => { // Guard against a matcher that silently matches nothing. - expect(all).toBeGreaterThanOrEqual(40); - expect(untaken.length).toBeGreaterThanOrEqual(15); + // Pinned below the counts on 2026-10-01 (82 / 45 / 37 / 33). + expect(all).toBeGreaterThanOrEqual(75); + expect(untaken.length).toBeGreaterThanOrEqual(40); + expect(raw.length).toBeGreaterThanOrEqual(33); + expect(raw.filter((r) => r.folds).length).toBeGreaterThanOrEqual(30); }); - it("every untaken read names a sparse literal type or is listed with its reason", () => { + it("every untaken read names sparse literal types, a key list, or is listed with its reason", () => { const offenders = untaken .filter((r) => !sparseLiteral(r.typeExpr)) .filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED)) @@ -156,20 +340,78 @@ describe("dense measurement reads stay bounded (#1023)", () => { expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]); }); + it("every raw read of measurements folds or is listed with its reason", () => { + const keys = unfoldedKeys(raw); + const offenders = [...keys] + .filter(([key]) => !(key in ALLOWED_SQL)) + .map(([key, read]) => `${key} (line ${read.line})`); + expect(offenders).toEqual([]); + expect(Object.keys(ALLOWED_SQL).filter((k) => !keys.has(k))).toEqual([]); + }); + it("the readers fixed for #1023 fold in SQL rather than reading rows", () => { for (const file of [ "lib/insights/graded-series.ts", "lib/rollups/tiered-context.ts", "lib/jobs/coach-plan-review.ts", "lib/measurements/daily-series-read.ts", + "lib/insights/derived/baseline.ts", + "lib/targets/glucose-read.ts", ]) { const src = readFileSync(join(SRC, file), "utf8"); expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); } - const baseline = readFileSync( - join(SRC, "lib/insights/derived/baseline.ts"), - "utf8", + }); + + it("plants: an unbounded findMany of a dense type is caught", () => { + const planted = ` + const rows = await prisma.measurement.findMany({ + where: { userId, type: "BLOOD_GLUCOSE", measuredAt: { gte: oneYearAgo } }, + select: { value: true, measuredAt: true }, + });`; + const { all: n, untaken: found } = untakenFindManyReads( + "app/api/planted/route.ts", + planted, ); - expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); + expect(n).toBe(1); + expect(found.map((r) => r.typeExpr)).toEqual(['"BLOOD_GLUCOSE"']); + expect(sparseLiteral(found[0].typeExpr)).toBe(false); + // The same read with a key list or a take is bounded. + expect( + untakenFindManyReads( + "x.ts", + planted.replace("measuredAt: { gte", "id: { in: ids }, x: { gte"), + ).untaken, + ).toEqual([]); + expect( + untakenFindManyReads( + "x.ts", + planted.replace("select:", "take: 10, select:"), + ).untaken, + ).toEqual([]); + // A list of sparse literals passes; one dense member does not. + expect(sparseLiteral('{ in: ["WEIGHT", "BODY_FAT"]')).toBe(true); + expect(sparseLiteral('{ in: ["WEIGHT", "PULSE"]')).toBe(false); + }); + + it("plants: a raw SELECT of rows from measurements is caught", () => { + const planted = + "const rows = await prisma.$queryRaw>`\n" + + ' SELECT m."value" FROM measurements m\n' + + ' WHERE m."user_id" = ${userId} AND m."type" = \'PULSE\'\n' + + "`;"; + const reads = rawMeasurementReads("lib/planted.ts", planted); + expect(reads).toHaveLength(1); + expect(reads[0].folds).toBe(false); + const folded = rawMeasurementReads( + "lib/planted.ts", + planted.replace("WHERE", "WHERE true GROUP BY 1 HAVING true AND"), + ); + expect(folded[0].folds).toBe(true); + const aggregate = rawMeasurementReads( + "lib/planted.ts", + planted.replace('SELECT m."value"', 'SELECT COUNT(*), AVG(m."value")'), + ); + expect(aggregate[0].folds).toBe(true); }); }); From ac3fdfccc3002afc2d4e4713af2cfdb501abae88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:23:45 +0200 Subject: [PATCH 08/16] fix(jobs): keep long passes inside their expiry and alone pg-boss gives a job fifteen minutes unless the send says otherwise, then fails it and retries it while the handler keeps running. Several passes can legitimately need longer and had no protection at all: the seven nightly status passes and the pre-generation (a provider call per account), the period-narrative warm, the record-detection cron (every account's history, every thirty minutes), the key rotation (every encrypted column), the content-index backfill (up to 200 transcriptions) and the on-host backup. Each of them now carries an explicit expiry on every send and schedule, stops between units once three quarters of it is spent (jobBudget), and runs under a session advisory lock keyed on queue and identity (lockedPass), so the retry pg-boss starts after the expiry does nothing beside a run that is still going. The lock is the one the full-history imports already held, moved into job-lock.ts. Where a pass stops early it says so (stopped_early) and the next run continues: the status passes and the narrative warm leave the rest to the next night, the pre-generation and the narratives walk the stalest accounts first, the on-host backup now backs up the oldest copies first, the key rotation reports a partial run in its audit row and skips rotated rows when run again, and the dose-history import fails the delivery so its retry resumes from the committed chunk. The off-host backup, which already stopped on its budget, gets a four-hour expiry and the lock. --- src/app/api/admin/encryption/rotate/route.ts | 2 + src/lib/crypto/encryption-corpus.ts | 96 +++++++---- .../__tests__/insight-pregenerate.test.ts | 27 ++- src/lib/jobs/__tests__/long-pass.test.ts | 74 ++++++++ .../__tests__/period-narrative-warm.test.ts | 2 +- .../__tests__/status-cron-candidates.test.ts | 4 +- src/lib/jobs/backup-restore.ts | 7 +- .../jobs/document-content-index-backfill.ts | 42 ++++- src/lib/jobs/encryption-key-rotate.ts | 23 ++- src/lib/jobs/insight-pregenerate-shared.ts | 11 ++ src/lib/jobs/insight-pregenerate.ts | 14 ++ src/lib/jobs/integration-backfill-lock.ts | 40 ++--- src/lib/jobs/job-lock.ts | 58 +++++++ src/lib/jobs/long-pass.ts | 58 +++++++ src/lib/jobs/medication-intake-import.ts | 43 ++++- src/lib/jobs/offhost-backup.ts | 9 + src/lib/jobs/period-narrative-shared.ts | 10 ++ src/lib/jobs/period-narrative-warm.ts | 14 ++ src/lib/jobs/pr-detection.ts | 11 ++ .../__tests__/backup-handlers.test.ts | 42 +++++ src/lib/jobs/reminder/backup-handlers.ts | 36 +++- src/lib/jobs/reminder/insights-handlers.ts | 88 ++++++++-- src/lib/jobs/reminder/ops-handlers.ts | 11 ++ src/lib/jobs/reminder/register-maintenance.ts | 144 ++++++++++------ src/lib/jobs/reminder/register-status.ts | 159 ++++++++++++------ src/lib/jobs/reminder/registrar-shared.ts | 16 ++ 26 files changed, 837 insertions(+), 204 deletions(-) create mode 100644 src/lib/jobs/__tests__/long-pass.test.ts create mode 100644 src/lib/jobs/job-lock.ts create mode 100644 src/lib/jobs/long-pass.ts diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts index 0beacfe7f..e3f8dc063 100644 --- a/src/app/api/admin/encryption/rotate/route.ts +++ b/src/app/api/admin/encryption/rotate/route.ts @@ -33,6 +33,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { ENCRYPTION_KEY_ROTATE_QUEUE, ENCRYPTION_KEY_ROTATE_SINGLETON, + ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, } from "@/lib/jobs/encryption-key-rotate"; import { getActiveKeyId } from "@/lib/crypto"; import { NextRequest } from "next/server"; @@ -57,6 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => { singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON, retryLimit: 2, retryDelay: 60, + expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, }, ); diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts index d470a163b..c55240731 100644 --- a/src/lib/crypto/encryption-corpus.ts +++ b/src/lib/crypto/encryption-corpus.ts @@ -390,19 +390,29 @@ export interface RotationResult { dropped: number; } -/** Re-encrypt one column's stale rows to the active key. */ +/** Thrown out of a column walk when the caller's time budget is spent. */ +class RotationStopped extends Error {} + +/** + * Re-encrypt one column's stale rows to the active key. `shouldStop` is the + * caller's time budget, checked between rows; a stopped column reports + * `stopped` and the rows it did not reach stay on their old key for the next + * run, which skips every row already rotated. + */ export async function rotateColumn( client: CorpusClient, col: EncryptedColumn, -): Promise { + shouldStop: () => boolean = () => false, +): Promise { const delegate = getDelegate(client, col.model); - const result: RotationResult = { + const result: RotationResult & { stopped: boolean } = { model: col.model, field: col.field, scanned: 0, rotated: 0, errors: 0, dropped: 0, + stopped: false, }; /** @@ -432,6 +442,10 @@ export async function rotateColumn( // (`convertSingleValueBackup`). A row converted, replaced or deleted since // it was listed is gone, not an error. for await (const id of singleValueIds(delegate)) { + if (shouldStop()) { + result.stopped = true; + break; + } result.scanned += 1; try { const outcome = await convertSingleValueBackup( @@ -452,31 +466,37 @@ export async function rotateColumn( // Idempotent — rows already on the active key are skipped, so an // interrupted run resumes cleanly on the next invocation. `scanned` counts // the rows that hold ciphertext, whatever the column. - await walkColumn(delegate, col, async ({ id, value, codec }) => { - result.scanned += 1; - if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return; - try { - // The plaintext is never inspected, only re-sealed, so every envelope - // a stored value can legitimately carry survives rotation untouched. - const next = - codec !== null - ? reencryptBlob(value as Uint8Array, codec, col.aad) - : fromCiphertext( - encrypt(decrypt(toCiphertext(value, col.kind)!)), - col.kind, - ); - await delegate.update({ - where: { [pkField(col)]: id }, - data: { [col.field]: next }, - }); - result.rotated += 1; - } catch (err) { - // Deleted between the read and the write (a backup replacing its - // pieces, an account going): nothing is left to rotate. - if (isRowGone(err)) return; - await onUnreadable(id); - } - }); + try { + await walkColumn(delegate, col, async ({ id, value, codec }) => { + if (shouldStop()) throw new RotationStopped(); + result.scanned += 1; + if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return; + try { + // The plaintext is never inspected, only re-sealed, so every envelope + // a stored value can legitimately carry survives rotation untouched. + const next = + codec !== null + ? reencryptBlob(value as Uint8Array, codec, col.aad) + : fromCiphertext( + encrypt(decrypt(toCiphertext(value, col.kind)!)), + col.kind, + ); + await delegate.update({ + where: { [pkField(col)]: id }, + data: { [col.field]: next }, + }); + result.rotated += 1; + } catch (err) { + // Deleted between the read and the write (a backup replacing its + // pieces, an account going): nothing is left to rotate. + if (isRowGone(err)) return; + await onUnreadable(id); + } + }); + } catch (err) { + if (!(err instanceof RotationStopped)) throw err; + result.stopped = true; + } return result; } @@ -488,16 +508,31 @@ export interface CorpusRotation { totalErrors: number; /** Unreadable rows deleted from `disposable` columns. */ totalDropped: number; + /** + * The time budget ran out before every column was walked. Running again + * finishes the job: rows already on the active key are skipped. + */ + stoppedEarly: boolean; } -/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */ +/** + * Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. + * `shouldStop` is the caller's time budget (`jobBudget` in the worker). + */ export async function rotateCorpus( client: CorpusClient, + shouldStop: () => boolean = () => false, ): Promise { const activeKeyId = getActiveKeyId(); const results: RotationResult[] = []; + let stoppedEarly = false; for (const col of ENCRYPTED_COLUMNS) { - results.push(await rotateColumn(client, col)); + const { stopped, ...result } = await rotateColumn(client, col, shouldStop); + results.push(result); + if (stopped) { + stoppedEarly = true; + break; + } } let totalScanned = 0; let totalRotated = 0; @@ -516,5 +551,6 @@ export async function rotateCorpus( totalRotated, totalErrors, totalDropped, + stoppedEarly, }; } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 0d0026a0a..163d0d209 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -207,6 +207,31 @@ describe("findPregenerateCandidates", () => { }); }); +describe("runInsightPregenerate — the job's time budget", () => { + it("stops between candidates once the budget is spent and says so", async () => { + capabilities = { + briefing: unavailable("user_disabled"), + statusText: unavailable("user_disabled"), + }; + const { prisma } = makePrisma([ + { id: "u1", locale: "de" }, + { id: "u2", locale: "de" }, + { id: "u3", locale: "de" }, + ]); + let checks = 0; + const result = await runInsightPregenerate(prisma as never, { + generate: vi.fn(), + statusGenerators: [], + warmGenericMetrics: vi.fn().mockResolvedValue(0), + // Spent after the first candidate. + shouldStop: () => checks++ >= 1, + }); + + expect(result).toMatchObject({ total: 3, skipped: 1, stoppedEarly: true }); + expect(aiCapabilityForJob).toHaveBeenCalledTimes(2); + }); +}); + describe("runInsightPregenerate — per-user capabilities", () => { it("skips a user with neither capability before any budget write or generation", async () => { capabilities = { @@ -1337,7 +1362,7 @@ describe("queue registration", () => { it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*insightRetryOptions\s*\]/, + /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*INSIGHT_PREGENERATE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/long-pass.test.ts b/src/lib/jobs/__tests__/long-pass.test.ts new file mode 100644 index 000000000..31a750fc3 --- /dev/null +++ b/src/lib/jobs/__tests__/long-pass.test.ts @@ -0,0 +1,74 @@ +/** + * `lockedPass`: each job runs under the lock `job::`, and a + * delivery that finds the lock held does no work and succeeds. + * + * The lock itself (a session advisory lock on a connection of its own) is + * exercised against Postgres in + * `tests/integration/integration-backfill-lane-expiry.test.ts`; this pins the + * wrapper around it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Job } from "pg-boss"; + +const held = vi.hoisted(() => new Set()); +const keys = vi.hoisted(() => [] as string[]); + +vi.mock("@/lib/jobs/job-lock", () => ({ + withJobLock: async (key: string, run: () => Promise) => { + keys.push(key); + if (held.has(key)) return { ran: false }; + held.add(key); + try { + return { ran: true, result: await run() }; + } finally { + held.delete(key); + } + }, +})); + +import { jobDone, jobFailed } from "@/lib/jobs/job-outcome"; +import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; + +type Payload = { userId?: string }; +const job = (data: Payload) => ({ id: "j", data }) as Job; + +beforeEach(() => { + held.clear(); + keys.length = 0; +}); + +describe("lockedPass", () => { + it("runs each job under its queue and identity", async () => { + const handler = vi.fn(async () => jobDone({ generated: 1 })); + const bound = lockedPass( + "q", + (j: Job) => + j.data.userId ? `user:${j.data.userId}` : WHOLE_PASS, + handler, + ); + const outcome = await bound([job({ userId: "a" })]); + expect(outcome).toEqual(jobDone({ generated: 1 })); + await bound([job({})]); + expect(keys).toEqual(["job:q:user:a", "job:q:pass"]); + }); + + it("does nothing beside a run that still holds the lock", async () => { + held.add("job:q:pass"); + const handler = vi.fn(async () => jobDone()); + const outcome = await lockedPass("q", () => WHOLE_PASS, handler)([job({})]); + expect(handler).not.toHaveBeenCalled(); + expect(outcome).toEqual(jobDone({ already_running: 1 })); + }); + + it("passes a failed outcome through", async () => { + const failed = jobFailed("stopped early", undefined, { + stopped_early: true, + }); + const outcome = await lockedPass( + "q", + () => WHOLE_PASS, + async () => failed, + )([job({})]); + expect(outcome).toBe(failed); + }); +}); diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 58e270c6d..a41071ad5 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -250,7 +250,7 @@ describe("queue registration", () => { it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*insightRetryOptions\s*\]/, + /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*PERIOD_NARRATIVE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index fcd97978a..075c531ab 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -190,9 +190,9 @@ describe("mood-status queue registration (dead-queue guard)", () => { expect(match![1]).toMatch(/\bMOOD_STATUS_QUEUE\b/); }); - it("schedules the 02:30 cron with the insight retry policy", () => { + it("schedules the 02:30 cron with the nightly pass options (retries and expiry)", () => { expect(workerSrc).toMatch( - /\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*insightRetryOptions\s*\]/, + /\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*nightlyInsightPassOptions\s*\]/, ); expect(workerSrc).toMatch(/MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/); }); diff --git a/src/lib/jobs/backup-restore.ts b/src/lib/jobs/backup-restore.ts index 0e76db75e..e2af9e0dd 100644 --- a/src/lib/jobs/backup-restore.ts +++ b/src/lib/jobs/backup-restore.ts @@ -811,7 +811,12 @@ export async function enqueueBackupRestoreSweepAtBoot( await boss.send( BACKUP_RESTORE_QUEUE, { sweep: true } satisfies BackupRestorePayload, - { startAfter: Math.ceil(BACKUP_RESTORE_STALE_AFTER_MS / 1000) + 15 }, + { + startAfter: Math.ceil(BACKUP_RESTORE_STALE_AFTER_MS / 1000) + 15, + // The sweep is short, but it rides a queue whose every send carries + // the restore's expiry, so no job on it is cut at the default. + expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS, + }, ); } catch { // The admission check fails an abandoned job the next time the account diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 2dc1bceb9..357619313 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -64,6 +64,14 @@ export const CONTENT_INDEX_BACKFILL_CONCURRENCY = 1; /** Discovery page size (id-only; blobs are loaded one at a time). */ const PAGE_SIZE = 50; +/** + * The job's expiry: three hours. One job transcribes up to 200 documents with + * a provider call each, which outlasted pg-boss's fifteen-minute default and + * was retried beside itself. The run stops at three quarters of this + * (`jobBudget`) and holds a lock per account (`lockedPass`). + */ +export const CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS = 3 * 60 * 60; + /** Max documents one job indexes before yielding (bounds spend + runtime). */ const MAX_DOCS_PER_RUN = 200; @@ -97,6 +105,11 @@ export interface ContentIndexBackfillSummary { failed: number; reason: "ok" | "no-provider" | "no-consent" | "budget-reached" | "unavailable"; + /** + * The job's time budget ran out before the walk did. The documents not + * reached stay un-indexed for the next run. + */ + stoppedEarly?: boolean; } /** @@ -158,6 +171,11 @@ async function retokeniseStaleRows( */ export async function runContentIndexBackfillForUser( userId: string, + /** + * The job's time budget (`jobBudget`), checked before each document: up to + * 200 transcriptions, one provider call each, can outlast the job. + */ + shouldStop: () => boolean = () => false, ): Promise { // Stale-tokeniser rows first: local work that owes nothing to the // provider, consent, or budget gates below, so a user without a usable @@ -222,6 +240,7 @@ export async function runContentIndexBackfillForUser( let failed = stale.failed; let reason: ContentIndexBackfillSummary["reason"] = "ok"; let cursor: string | null = null; + let stoppedEarly = false; outer: for (;;) { if (indexed >= MAX_DOCS_PER_RUN) break; @@ -244,6 +263,10 @@ export async function runContentIndexBackfillForUser( for (const { id } of rows) { if (indexed >= MAX_DOCS_PER_RUN) break outer; + if (shouldStop()) { + stoppedEarly = true; + break outer; + } const dateKey = buildDateKey(); const reservation = await reserveBudget( @@ -325,9 +348,23 @@ export async function runContentIndexBackfillForUser( annotate({ action: { name: "documents.contentIndex.backfill" }, - meta: { indexed, retokenised: stale.retokenised, skipped, failed, reason }, + meta: { + indexed, + retokenised: stale.retokenised, + skipped, + failed, + reason, + stopped_early: stoppedEarly, + }, }); - return { indexed, retokenised: stale.retokenised, skipped, failed, reason }; + return { + indexed, + retokenised: stale.retokenised, + skipped, + failed, + reason, + ...(stoppedEarly ? { stoppedEarly } : {}), + }; } /** @@ -344,6 +381,7 @@ export async function enqueueContentIndexBackfill( enqueuedAt: new Date().toISOString(), }; const jobId = await boss.send(CONTENT_INDEX_BACKFILL_QUEUE, payload, { + expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS, retryLimit: 3, retryDelay: 60, retryBackoff: true, diff --git a/src/lib/jobs/encryption-key-rotate.ts b/src/lib/jobs/encryption-key-rotate.ts index 86eb671d9..cd9c93eb8 100644 --- a/src/lib/jobs/encryption-key-rotate.ts +++ b/src/lib/jobs/encryption-key-rotate.ts @@ -25,6 +25,7 @@ import { type Job } from "pg-boss"; import { withBackgroundEvent } from "@/lib/logging/background"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; import { auditLog } from "@/lib/auth/audit"; import { rotateCorpus, @@ -37,27 +38,40 @@ export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1; /** Fixed key so duplicate admin triggers coalesce into one queued run. */ export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate"; +/** + * The job's expiry: twelve hours. The pass re-encrypts every encrypted column + * on the instance and outlasted pg-boss's fifteen-minute default, which + * retried it beside itself. The pass stops at three quarters of this + * (`jobBudget`) and holds a lock (`lockedPass`); the admin route's send + * carries it. + */ +export const ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS = 12 * 60 * 60; + export interface EncryptionKeyRotatePayload { /** The admin who triggered the run (for the audit trail). */ requestedByUserId?: string; enqueuedAt?: string; } -export async function runEncryptionKeyRotation(): Promise<{ +export async function runEncryptionKeyRotation( + shouldStop: () => boolean = () => false, +): Promise<{ activeKeyId: string; totalScanned: number; totalRotated: number; totalErrors: number; totalDropped: number; + stoppedEarly: boolean; }> { const prisma = getWorkerPrisma(); - const out = await rotateCorpus(prisma as unknown as CorpusClient); + const out = await rotateCorpus(prisma as unknown as CorpusClient, shouldStop); return { activeKeyId: out.activeKeyId, totalScanned: out.totalScanned, totalRotated: out.totalRotated, totalErrors: out.totalErrors, totalDropped: out.totalDropped, + stoppedEarly: out.stoppedEarly, }; } @@ -67,7 +81,10 @@ export async function handleEncryptionKeyRotate( return withBackgroundEvent("job.encryption_key_rotate", async (evt) => { const requestedBy = jobs[0]?.data?.requestedByUserId ?? null; try { - const result = await runEncryptionKeyRotation(); + // A whole-corpus pass: it stops between rows once the job's budget is + // spent, and says so in the audit row, so the operator runs it again + // to finish (rows already rotated are skipped). + const result = await runEncryptionKeyRotation(jobBudget(jobs)); evt.addMeta("rotate_active_key_id", result.activeKeyId); evt.addMeta("rotate_scanned", result.totalScanned); evt.addMeta("rotate_rotated", result.totalRotated); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 22aca36c8..e6353b37a 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -19,6 +19,15 @@ import { annotate } from "@/lib/logging/context"; export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate"; +/** + * The job's expiry: three hours. The scheduled pass walks up to 200 accounts + * with a comprehensive generation each, which can outlast pg-boss's fifteen + * minute default; past it pg-boss retried the job beside the pass still + * running. The pass now stops itself at three quarters of this (`jobBudget`) + * and holds a lock (`lockedPass`), and every send and the schedule carry it. + */ +export const INSIGHT_PREGENERATE_EXPIRE_SECONDS = 3 * 60 * 60; + export interface InsightPregeneratePayload { /** Set on the nightly scheduled tick (informational; the handler keys * its batch behaviour off the ABSENCE of `userId`/`force`). */ @@ -73,6 +82,7 @@ export async function enqueueForceWarm(payload: { } satisfies InsightPregeneratePayload, { singletonKey: `force:${payload.userId}`, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, // De-dupe within a 6-minute window. v1.16.8 — widened from 120 s: // the client's revalidation poll runs up to ~250 s after a stale // page-open, so a 2-minute singleton let the tail of one poll @@ -148,6 +158,7 @@ export async function enqueuePregenerateFailureRetry(payload: { // warm can never swallow the scheduled retry (and vice versa); // one retry per user per window. singletonKey: `retry:${payload.userId}`, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, singletonSeconds: 3600, // A worker crash mid-warm re-runs the job; a warm that completes // with a failed comprehensive does NOT throw, so this cannot loop. diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 37cac27f8..cdf911576 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -277,6 +277,8 @@ export interface PregenerateRunResult { skipped: number; failed: number; budgetBlocked: number; + /** The job's time budget ran out before every candidate was reached. */ + stoppedEarly: boolean; /** * Count of per-metric assessment caches written warm across the whole * run. The comprehensive generator evicts the seven `*-status` @@ -575,6 +577,12 @@ export async function runInsightPregenerate( userId: string; locale: SupportedLocale; }) => Promise; + /** + * The job's time budget (`jobBudget`). Checked between candidates: a pass + * of 200 provider calls can outlast its job, and the candidates it does + * not reach are the stalest again tomorrow, so they lead the next pass. + */ + shouldStop?: () => boolean; } = {}, ): Promise { const now = options.now ?? new Date(); @@ -606,6 +614,7 @@ export async function runInsightPregenerate( skipped: 0, failed: 0, budgetBlocked: 0, + stoppedEarly: false, assessmentsWarmed: 0, metricAssessmentsWarmed: 0, }; @@ -619,6 +628,10 @@ export async function runInsightPregenerate( result.total = candidates.length; for (const candidate of candidates) { + if (options.shouldStop?.()) { + result.stoppedEarly = true; + break; + } // v1.18.11 P3 — one feature-cache scope per candidate, so the // comprehensive briefing's bounded feature read is computed once and any // sibling consumer in this candidate's warm reuses it. Scoped per user so @@ -819,6 +832,7 @@ export async function runInsightPregenerate( skipped: result.skipped, failed: result.failed, budget_blocked: result.budgetBlocked, + stopped_early: result.stoppedEarly, assessments_warmed: result.assessmentsWarmed, metric_assessments_warmed: result.metricAssessmentsWarmed, }, diff --git a/src/lib/jobs/integration-backfill-lock.ts b/src/lib/jobs/integration-backfill-lock.ts index 1e6a90905..c42403720 100644 --- a/src/lib/jobs/integration-backfill-lock.ts +++ b/src/lib/jobs/integration-backfill-lock.ts @@ -11,8 +11,9 @@ * started beside it in the same worker, so the lane's "one at a time" did not * hold for the one case it exists for, an import too long for its expiry. * - * So each run holds a session-level advisory lock keyed on the job's identity - * (kind, provider where there is one, account) for as long as it runs. A + * So each run holds a session-level advisory lock (`withJobLock`) keyed on the + * job's identity (kind, provider where there is one, account) for as long as + * it runs. A * delivery that finds the lock taken does no work: the run that holds it is * still importing, and it either stamps its completion marker or leaves the * account for the next boot's discovery. The lock lives on a connection of its @@ -24,47 +25,26 @@ * One extra connection per running import; the lane admits one import per * process at a time, so that is one connection. */ -import { Client } from "pg"; +import { withJobLock, type GuardedRun } from "./job-lock"; /** The lock key for one admission job's identity. */ function integrationBackfillLockKey(identity: string): string { return `integration-backfill:${identity}`; } -/** What a guarded run came to. */ -type GuardedRun = { ran: true; result: T } | { ran: false }; - /** * Run `run` while holding the import lock for `identity`. Resolves * `{ ran: false }` at once, without calling `run`, when another run holds it. * Rejects exactly as `run` does otherwise. */ -export async function withIntegrationBackfillLock( +export function withIntegrationBackfillLock( identity: string, run: () => Promise, connectionString: string | undefined = process.env.DATABASE_URL, ): Promise> { - const client = new Client({ connectionString, keepAlive: true }); - // An idle client whose server goes away emits `error`; unhandled, that - // takes the whole worker down. The run itself notices the database is - // gone through its own queries. - client.on("error", () => {}); - await client.connect(); - try { - const key = integrationBackfillLockKey(identity); - const { rows } = await client.query<{ held: boolean }>( - "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS held", - [key], - ); - if (!rows[0]?.held) return { ran: false }; - try { - return { ran: true, result: await run() }; - } finally { - await client - .query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]) - .catch(() => {}); - } - } finally { - await client.end().catch(() => {}); - } + return withJobLock( + integrationBackfillLockKey(identity), + run, + connectionString, + ); } diff --git a/src/lib/jobs/job-lock.ts b/src/lib/jobs/job-lock.ts new file mode 100644 index 000000000..5db0d75ba --- /dev/null +++ b/src/lib/jobs/job-lock.ts @@ -0,0 +1,58 @@ +/** + * A session-level advisory lock held for the length of one run, across + * processes. + * + * pg-boss holds a job's "one at a time" only while the job is inside its + * expiry. Once the expiry passes it fails the job, fires its abort signal and + * retries it, and it does not stop the handler: measured against pg-boss 12.34 + * (`tests/integration/integration-backfill-lane-expiry.test.ts`), the retry of + * the same job started beside the run that was still going. No queue policy + * helps, because the policies constrain job rows and the expired row is no + * longer active. Only something the running handler holds can, so each long + * run holds an advisory lock keyed on its identity, and a delivery that finds + * the lock taken does no work. + * + * The lock lives on a connection of its own, outside the Prisma pool, because + * a session lock has to stay on one connection for the whole run and a pooled + * one is handed back between statements. Ending the connection releases the + * lock, and so does the process dying, so a crash never leaves a key locked. + * One extra connection per running locked job. + */ +import { Client } from "pg"; + +/** What a guarded run came to. */ +export type GuardedRun = { ran: true; result: T } | { ran: false }; + +/** + * Run `run` while holding the advisory lock for `key`. Resolves + * `{ ran: false }` at once, without calling `run`, when another run holds it. + * Rejects exactly as `run` does otherwise. + */ +export async function withJobLock( + key: string, + run: () => Promise, + connectionString: string | undefined = process.env.DATABASE_URL, +): Promise> { + const client = new Client({ connectionString, keepAlive: true }); + // An idle client whose server goes away emits `error`; unhandled, that + // takes the whole worker down. The run itself notices the database is + // gone through its own queries. + client.on("error", () => {}); + await client.connect(); + try { + const { rows } = await client.query<{ held: boolean }>( + "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS held", + [key], + ); + if (!rows[0]?.held) return { ran: false }; + try { + return { ran: true, result: await run() }; + } finally { + await client + .query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]) + .catch(() => {}); + } + } finally { + await client.end().catch(() => {}); + } +} diff --git a/src/lib/jobs/long-pass.ts b/src/lib/jobs/long-pass.ts new file mode 100644 index 000000000..0d4ca0da4 --- /dev/null +++ b/src/lib/jobs/long-pass.ts @@ -0,0 +1,58 @@ +/** + * The binding for a queue whose job can outlast pg-boss's default expiry. + * + * Three things together make a long job safe, and `queue-runtime.ts` names + * every queue that needs them: + * + * 1. An explicit `expireInSeconds` on every send and schedule, so pg-boss + * does not declare the job dead at fifteen minutes while it is working. + * 2. A time budget (`jobBudget` / `jobDeadline` in `job-budget.ts`) the + * handler checks between units of work, so the pass stops on its own + * before that expiry and the next run picks up what is left. + * 3. A lock the running handler holds (`withJobLock`), so the retry pg-boss + * starts once the expiry has passed does nothing beside a run that is + * still going. Queue policies cannot do this: they constrain job rows, + * and an expired job's row is no longer active. + * + * `lockedPass` is the third. It wraps a handler so each job runs under the + * lock `:`, where the identity is what two runs must not + * share: the account for a per-user job, a constant for a pass over everyone. + * A delivery that finds the lock held reports `already_running` and succeeds, + * because the run that holds it is doing the work. + */ +import type { Job } from "pg-boss"; + +import { withJobLock } from "@/lib/jobs/job-lock"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { annotate } from "@/lib/logging/context"; + +/** The identity of a pass that walks every account: one run at a time. */ +export const WHOLE_PASS = "pass"; + +export function lockedPass>( + queue: string, + identityOf: (job: J) => string, + handler: (jobs: J[]) => Promise, +): (jobs: J[]) => Promise { + return async (jobs) => { + let outcome: JobOutcome = jobDone({ jobs: 0 }); + for (const job of jobs) { + const run = await withJobLock(`job:${queue}:${identityOf(job)}`, () => + handler([job]), + ); + if (!run.ran) { + annotate({ + action: { name: "job.long_pass.already_running" }, + meta: { queue }, + }); + outcome = jobDone({ already_running: 1 }); + continue; + } + // The first failed job fails the batch; pg-boss hands one job per + // batch here, so this is that job's own outcome. + if (!run.result.ok) return run.result; + outcome = run.result; + } + return outcome; + }; +} diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index b7c92dfd0..a79a71ed4 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -4,7 +4,8 @@ import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; -import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { @@ -825,13 +826,25 @@ async function processNextChunk( ); } -export async function processMedicationIntakeImportJob( +/** + * Work the import chunk by chunk until it is finished. `shouldStop` is the + * job's time budget: between chunks, once it is spent, the run returns + * `"stopped"` and leaves the job row as it is. Every chunk commits its own + * progress under the row lock, so the retry that follows picks up at the next + * chunk, and a second delivery running beside this one waits on that lock and + * continues from the progress this one wrote. + */ +async function workMedicationIntakeImport( jobId: string, -): Promise { + shouldStop: () => boolean, +): Promise { const client = getWorkerPrisma(); for (;;) { const outcome = await processNextChunk(client, jobId); - if (!outcome.terminal) continue; + if (!outcome.terminal) { + if (shouldStop()) return "stopped"; + continue; + } if (outcome.finalized && outcome.userId && outcome.result) { if (outcome.result.imported > 0) { invalidateUserMedications(outcome.userId, { evict: true }); @@ -843,6 +856,14 @@ export async function processMedicationIntakeImportJob( } } +/** The whole import in one go, with no time budget. */ +export async function processMedicationIntakeImportJob( + jobId: string, +): Promise { + const result = await workMedicationIntakeImport(jobId, () => false); + return result === "stopped" ? null : result; +} + async function recordWorkerFailure( job: Job, error: unknown, @@ -879,7 +900,19 @@ export async function handleMedicationIntakeImport( job: Job, ): Promise { try { - const result = await processMedicationIntakeImportJob(job.data.jobId); + const result = await workMedicationIntakeImport( + job.data.jobId, + jobBudget([job]), + ); + // Out of time before the last chunk: fail the delivery so pg-boss + // retries it, and the retry resumes from the committed progress. + if (result === "stopped") { + return jobFailed( + "stopped before the last chunk; the retry resumes", + undefined, + { stopped_early: true }, + ); + } // A null result is a delivery that found the row already terminal or gone. // There was nothing left to import, which is an absence of work rather // than a failure to do it. diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index a2f2e34fa..e1c89d864 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -42,6 +42,15 @@ const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; const TAG_LENGTH = 16; +/** + * The off-host pass's expiry: four hours. It uploads every account and + * already stopped between accounts at three quarters of the default fifteen + * minutes, which on an instance of any size meant a retry per night; one + * large account past the default was retried beside itself. Four hours with + * the same budget and a lock (`lockedPass`) leaves room for the night's pass. + */ +export const OFFHOST_BACKUP_EXPIRE_SECONDS = 4 * 60 * 60; + export interface OffhostBackupConfig { endpoint: string; bucket: string; diff --git a/src/lib/jobs/period-narrative-shared.ts b/src/lib/jobs/period-narrative-shared.ts index 52dad1371..56742c1ea 100644 --- a/src/lib/jobs/period-narrative-shared.ts +++ b/src/lib/jobs/period-narrative-shared.ts @@ -23,6 +23,15 @@ import type { Locale } from "@/lib/i18n/config"; export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm"; +/** + * The job's expiry: three hours. On a boundary night the scheduled pass + * writes up to two narratives for each of 200 accounts, which can outlast + * pg-boss's fifteen-minute default. The pass stops itself at three quarters + * of this (`jobBudget`) and holds a lock (`lockedPass`); every send and the + * schedule carry it. + */ +export const PERIOD_NARRATIVE_EXPIRE_SECONDS = 3 * 60 * 60; + /** * Nightly at 05:05 Europe/Berlin — inside the existing maintenance window, * after the comprehensive insight pre-generation (04:30) and the computed @@ -79,6 +88,7 @@ export async function enqueueNarrativeWarm(payload: { { singletonKey: `warm:${payload.userId}:${payload.period}:${payload.locale}`, singletonSeconds: 120, + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, // Transient provider / pool failures retry with backoff instead // of leaving the narrative cold until the next boundary night. retryLimit: 3, diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 6ade00411..15eb409c9 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -63,6 +63,8 @@ export interface NarrativeWarmRunResult { insufficient: number; failed: number; budgetBlocked: number; + /** The job's time budget ran out before every candidate was reached. */ + stoppedEarly: boolean; } /** @@ -152,6 +154,12 @@ export async function runPeriodNarrativeWarm( cap?: number; /** Injected for the test — defaults to the real generator. */ generate?: typeof generatePeriodNarrative; + /** + * The job's time budget (`jobBudget`), checked between candidates. The + * candidates are ordered least recently narrated first, so the ones a + * stopped pass did not reach lead the next one. + */ + shouldStop?: () => boolean; } = {}, ): Promise { const now = options.now ?? new Date(); @@ -167,6 +175,7 @@ export async function runPeriodNarrativeWarm( insufficient: 0, failed: 0, budgetBlocked: 0, + stoppedEarly: false, }; const flags = await getAssistantFlags(); @@ -180,6 +189,10 @@ export async function runPeriodNarrativeWarm( result.total = candidates.length; for (const candidate of candidates) { + if (options.shouldStop?.()) { + result.stoppedEarly = true; + break; + } // The nightly pass exists for the model-written narrative. Unavailable, // it skips the user before the context build and the budget write; the // deterministic narrative is written on the next read instead (the @@ -233,6 +246,7 @@ export async function runPeriodNarrativeWarm( insufficient: result.insufficient, failed: result.failed, budget_blocked: result.budgetBlocked, + stopped_early: result.stoppedEarly, }, }); diff --git a/src/lib/jobs/pr-detection.ts b/src/lib/jobs/pr-detection.ts index 8062dc74e..94f933fd3 100644 --- a/src/lib/jobs/pr-detection.ts +++ b/src/lib/jobs/pr-detection.ts @@ -15,6 +15,16 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; export const PR_DETECTION_QUEUE = "pr-detection"; +/** + * The job's expiry: twenty-five minutes. The fallback cron walks every + * account's whole history every thirty minutes, which on a large instance + * outlasts pg-boss's fifteen-minute default; past it pg-boss retried the pass + * beside itself. The pass stops at three quarters of this (`jobBudget`) and + * holds a lock (`lockedPass`), so it ends before the next tick and a retry or + * a late tick never runs beside it. The ingest sends carry the same expiry. + */ +export const PR_DETECTION_EXPIRE_SECONDS = 25 * 60; + /** Concurrency budget for the worker process — five jobs in flight * is enough to drain a multi-user backfill spike without crowding * the reminder check or the daily insights workload that runs on the @@ -62,6 +72,7 @@ export async function enqueuePrDetection( triggeredAt: new Date().toISOString(), }; await boss.send(PR_DETECTION_QUEUE, payload, { + expireInSeconds: PR_DETECTION_EXPIRE_SECONDS, // Stagger retries gently — the detector is purely read-heavy on // the user's own data, but a stampede of failed jobs against a // briefly-flaky DB would compound the problem. diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts index fda4afc67..30b92dca3 100644 --- a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -70,6 +70,7 @@ function buildPrismaMock( ) { return { user: { findMany: vi.fn().mockResolvedValue(users) }, + dataBackup: { findMany: vi.fn().mockResolvedValue([]) }, }; } @@ -236,3 +237,44 @@ describe("handleDataBackup outcome", () => { expect(outcome).toMatchObject({ ok: true, did: { backed: 0, total: 0 } }); }); }); + +describe("handleDataBackup on a large instance", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.store.mockResolvedValue(undefined); + }); + + it("backs up the accounts with the oldest copies first and stops on the budget", async () => { + const prisma = buildPrismaMock([ + { id: "fresh", username: "fresh" }, + { id: "never", username: "never" }, + { id: "stale", username: "stale" }, + ]); + prisma.dataBackup.findMany.mockResolvedValue([ + { userId: "fresh", createdAt: new Date("2026-09-30T00:00:00Z") }, + { userId: "stale", createdAt: new Date("2026-09-01T00:00:00Z") }, + ]); + mocks.getWorkerPrisma.mockReturnValue(prisma); + const controller = new AbortController(); + // Two accounts in, pg-boss gives up on the job. + mocks.store.mockImplementation(async () => { + if (mocks.store.mock.calls.length === 2) controller.abort(); + }); + + const outcome = await handleDataBackup([ + { + expireInSeconds: 7200, + signal: controller.signal, + } as unknown as Parameters[0][number], + ]); + + const order = mocks.store.mock.calls.map( + ([input]) => (input as { userId: string }).userId, + ); + expect(order).toEqual(["never", "stale"]); + expect(outcome).toMatchObject({ + ok: true, + did: { backed: 2, stopped_early: true }, + }); + }); +}); diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 5d57c2e27..f02714de6 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -121,16 +121,39 @@ export async function handleOffhostBackup( }); } +/** + * The weekly on-host copy of every account. + * + * The pass serialises each account's whole record, so on a large instance it + * can outlast its job. It walks the accounts whose weekly copy is oldest + * first and stops between accounts once the job's budget is spent + * (`jobBudget`), reporting `stopped_early`. The accounts it did not reach have + * the oldest copies, so they lead the next pass (the weekly cron or the + * admin's "run now"), and every pass makes progress where it matters most. + * The binding holds a lock (`lockedPass`), so a retry never runs beside it. + */ export async function handleDataBackup( jobs: Job[], ): Promise { - void jobs; + const shouldStop = jobBudget(jobs); return withBackgroundEvent("job.data_backup", async (evt) => { const prisma = getWorkerPrisma(); try { - const users = await prisma.user.findMany({ - select: { id: true, username: true }, - }); + const [accounts, copies] = await Promise.all([ + prisma.user.findMany({ select: { id: true, username: true } }), + prisma.dataBackup.findMany({ + where: { type: "WEEKLY_AUTO" }, + select: { userId: true, createdAt: true }, + }), + ]); + const copiedAt = new Map( + copies.map((copy) => [copy.userId, copy.createdAt.getTime()]), + ); + // No copy yet sorts first, then the oldest copy. + const users = [...accounts].sort( + (a, b) => (copiedAt.get(a.id) ?? -1) - (copiedAt.get(b.id) ?? -1), + ); + let stoppedEarly = false; let backed = 0; let usersFailed = 0; @@ -142,6 +165,10 @@ export async function handleDataBackup( // six weeks in. let lastError: unknown; for (const user of users) { + if (shouldStop()) { + stoppedEarly = true; + break; + } reportJobProgress({ backup_user: user.id, backup_users_done: backed + usersFailed + skippedRestoring, @@ -207,6 +234,7 @@ export async function handleDataBackup( users_failed: usersFailed, records_oversized: oversized, users_skipped_restoring: skippedRestoring, + stopped_early: stoppedEarly, }; // A pass that wrote nothing for anybody protected nobody, and saying diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index 4fd293222..4f529f204 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -21,6 +21,7 @@ import { runInsightStatusGenerate, } from "@/lib/jobs/insight-status-generate"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; import { withBackgroundEvent } from "@/lib/logging/background"; import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; @@ -77,6 +78,12 @@ export interface MedicationComplianceStatusPayload { * The pass is what succeeds or fails. One user whose generation threw is that * user's cold card, reported as the `failed` count; failing the job would * re-run the whole cohort over it. + * + * The pass walks every candidate with a provider call each, so on a large + * instance it can outlast its job. `shouldStop` is the job's time budget: the + * pass stops between users once it is spent and reports `stopped_early`. The + * users it did not reach keep yesterday's note until the next night or their + * next visit; nothing is retried beside a pass that is still running. */ export async function runStatusCronGenerate( taskName: string, @@ -84,6 +91,7 @@ export async function runStatusCronGenerate( userId: string, options: { locale: string | null; force: boolean }, ) => Promise, + shouldStop: () => boolean = () => false, ): Promise { return withBackgroundEvent(taskName, async (evt) => { const prisma = getWorkerPrisma(); @@ -96,8 +104,13 @@ export async function runStatusCronGenerate( let generated = 0; let failed = 0; + let stoppedEarly = false; for (const user of users) { + if (shouldStop()) { + stoppedEarly = true; + break; + } // The capability before the generator builds a snapshot. The batch // entry below checks for itself. const capability = await aiCapabilityForJob(user.id, "statusText"); @@ -124,9 +137,14 @@ export async function runStatusCronGenerate( evt.setBackground({ task_name: taskName, - result: { generated, failed, total: users.length }, + result: { generated, failed, total: users.length, stoppedEarly }, + }); + return jobDone({ + total: users.length, + generated, + failed, + stopped_early: stoppedEarly, }); - return jobDone({ total: users.length, generated, failed }); } catch (err) { evt.setError(err); recordError(); @@ -153,7 +171,10 @@ export async function runStatusCronGenerate( * inside the batch itself). Net effect: the nightly ladder pays one call per * user instead of seven, with no queue/registry churn. */ -async function runStatusBatchCron(taskName: string): Promise { +async function runStatusBatchCron( + taskName: string, + shouldStop: () => boolean, +): Promise { return withBackgroundEvent(taskName, async (evt) => { const prisma = getWorkerPrisma(); try { @@ -166,7 +187,14 @@ async function runStatusBatchCron(taskName: string): Promise { let generated = 0; let served = 0; let failed = 0; + let stoppedEarly = false; for (const user of users) { + // Same budget as the per-metric passes: stop between users, and + // leave the rest to the later crons and the next night. + if (shouldStop()) { + stoppedEarly = true; + break; + } try { const result = await generateStatusBatchForUser(user.id, { // The stored locale, then the operator default — the prompt names @@ -186,16 +214,34 @@ async function runStatusBatchCron(taskName: string): Promise { annotate({ action: { name: "insights.status.batch.cron" }, - meta: { generated, served, failed, total: users.length }, + meta: { + generated, + served, + failed, + total: users.length, + stopped_early: stoppedEarly, + }, }); evt.setBackground({ task_name: taskName, - result: { generated, served, failed, total: users.length }, + result: { + generated, + served, + failed, + total: users.length, + stoppedEarly, + }, }); // A user whose batch call threw already fell back to the single-card // path inside the batch, and the six later per-metric crons cover what // is still cold. The batch pass ran, so it reports the count. - return jobDone({ total: users.length, generated, served, failed }); + return jobDone({ + total: users.length, + generated, + served, + failed, + stopped_early: stoppedEarly, + }); } catch (err) { evt.setError(err); recordError(); @@ -208,61 +254,66 @@ async function runStatusBatchCron(taskName: string): Promise { export function handleGeneralStatusGenerate( jobs: Job[], ): Promise { - void jobs; - return runStatusBatchCron("job.insights.batch"); + return runStatusBatchCron("job.insights.batch", jobBudget(jobs)); } export function handleBloodPressureStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.blood_pressure", generateBloodPressureStatusForUser, + jobBudget(jobs), ); } export function handleWeightStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.weight", generateWeightStatusForUser, + jobBudget(jobs), ); } export function handlePulseStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.pulse", generatePulseStatusForUser, + jobBudget(jobs), ); } export function handleBmiStatusGenerate( jobs: Job[], ): Promise { - void jobs; - return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser); + return runStatusCronGenerate( + "job.insights.bmi", + generateBmiStatusForUser, + jobBudget(jobs), + ); } export function handleMoodStatusGenerate( jobs: Job[], ): Promise { - void jobs; - return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser); + return runStatusCronGenerate( + "job.insights.mood", + generateMoodStatusForUser, + jobBudget(jobs), + ); } export function handleMedicationComplianceStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.medication_compliance", generateMedicationComplianceStatusForUser, + jobBudget(jobs), ); } @@ -310,7 +361,9 @@ export async function handleInsightPregenerateJob( return jobDone({ forced: forced.length, scheduled: 0 }); } try { - const summary = await runInsightPregenerate(getWorkerPrisma()); + const summary = await runInsightPregenerate(getWorkerPrisma(), { + shouldStop: jobBudget(jobs), + }); evt.setBackground({ task_name: "job.insight_pregenerate", result: { ...summary }, @@ -325,6 +378,7 @@ export async function handleInsightPregenerateJob( skipped: summary.skipped, failed: summary.failed, budget_blocked: summary.budgetBlocked, + stopped_early: summary.stoppedEarly, assessments_warmed: summary.assessmentsWarmed, metric_assessments_warmed: summary.metricAssessmentsWarmed, }); diff --git a/src/lib/jobs/reminder/ops-handlers.ts b/src/lib/jobs/reminder/ops-handlers.ts index 7560e70f5..7f944c1b8 100644 --- a/src/lib/jobs/reminder/ops-handlers.ts +++ b/src/lib/jobs/reminder/ops-handlers.ts @@ -4,6 +4,7 @@ * Extracted from reminder-worker.ts, which owns the queue names, cron * schedules, and boss.work registrations. */ +import { jobBudget } from "@/lib/jobs/job-budget"; import { type Job } from "pg-boss"; import { runGeoBackfill } from "@/lib/jobs/geo-backfill"; import { fetchGeoLite2Databases } from "@/lib/geo/geolite2-fetch"; @@ -223,6 +224,11 @@ export async function handlePrDetection( let insertedAcrossJobs = 0; let tiesAcrossJobs = 0; let usersFailed = 0; + let stoppedEarly = false; + // The cron pass walks every account's all-time history and ticks every + // thirty minutes. It stops between accounts once the job's budget is spent; + // the ingest jobs and the next tick carry the rest. + const shouldStop = jobBudget(jobs); for (const job of jobs) { await withBackgroundEvent("job.pr_detection", async (evt) => { const p = getWorkerPrisma(); @@ -241,6 +247,10 @@ export async function handlePrDetection( let insertedTotal = 0; let tiesTotal = 0; for (const userId of userIds) { + if (shouldStop()) { + stoppedEarly = true; + break; + } try { const result = await detectPersonalRecordsForUser(userId, { silent, @@ -274,5 +284,6 @@ export async function handlePrDetection( pr_detection_inserted: insertedAcrossJobs, pr_detection_ties: tiesAcrossJobs, pr_detection_users_failed: usersFailed, + stopped_early: stoppedEarly, }); } diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 460ac70ec..e9fdc896d 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -31,8 +31,12 @@ import { PR_DETECTION_QUEUE, PR_DETECTION_CONCURRENCY, PR_DETECTION_FALLBACK_CRON, + PR_DETECTION_EXPIRE_SECONDS, type PrDetectionPayload, } from "@/lib/jobs/pr-detection"; +import { jobBudget } from "@/lib/jobs/job-budget"; +import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; +import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup"; import { MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON, @@ -531,7 +535,11 @@ const schedules: ScheduleEntry[] = [ STEP_UP_ELEVATION_CLEANUP_CRON, cronIsTheRetry, ], - [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON], + [ + OFFHOST_BACKUP_QUEUE, + OFFHOST_BACKUP_CRON, + { expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS }, + ], [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON], // Nightly backstop for the purge the deletion kicks straight away: a // request the bucket refused, or one written while the queue was down. @@ -557,7 +565,11 @@ const schedules: ScheduleEntry[] = [ // cron payload deliberately omits a `userId` so the handler iterates // every user; per-user push-suppression cannot apply on the cron // path (the silent flag is set by the ingest hooks). - [PR_DETECTION_QUEUE, PR_DETECTION_FALLBACK_CRON], + [ + PR_DETECTION_QUEUE, + PR_DETECTION_FALLBACK_CRON, + { expireInSeconds: PR_DETECTION_EXPIRE_SECONDS }, + ], // v1.4.25 W19b — daily expire-stale pass for the per-pen inventory // entities. Flips IN_USE rows whose 30-day clock has blown to // EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx @@ -747,7 +759,7 @@ export async function registerMaintenanceQueues( boss, DATA_BACKUP_QUEUE, { localConcurrency: 1 }, - handleDataBackup, + lockedPass(DATA_BACKUP_QUEUE, () => WHOLE_PASS, handleDataBackup), ); await createAndWork( boss, @@ -778,7 +790,7 @@ export async function registerMaintenanceQueues( boss, OFFHOST_BACKUP_QUEUE, { localConcurrency: 1, includeMetadata: true }, - handleOffhostBackup, + lockedPass(OFFHOST_BACKUP_QUEUE, () => WHOLE_PASS, handleOffhostBackup), ); await createAndWork( boss, @@ -935,7 +947,16 @@ export async function registerMaintenanceQueues( boss, PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, - handlePrDetection, + // An ingest job is one account; the cron pass is every account, one run + // at a time. + lockedPass( + PR_DETECTION_QUEUE, + (job) => { + const userId = (job.data as PrDetectionPayload | undefined)?.userId; + return userId ? `user:${userId}` : WHOLE_PASS; + }, + handlePrDetection, + ), ); await createAndWork( boss, @@ -1159,7 +1180,11 @@ export async function registerMaintenanceQueues( boss, ENCRYPTION_KEY_ROTATE_QUEUE, { localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY }, - handleEncryptionKeyRotate, + lockedPass( + ENCRYPTION_KEY_ROTATE_QUEUE, + () => WHOLE_PASS, + handleEncryptionKeyRotate, + ), ); // v1.25 — medication-note encryption backfill worker. The boot enqueue @@ -1318,56 +1343,65 @@ export async function registerMaintenanceQueues( boss, CONTENT_INDEX_BACKFILL_QUEUE, { localConcurrency: CONTENT_INDEX_BACKFILL_CONCURRENCY }, - async (jobs) => { - let users = 0; - let skipped = 0; - let documentsIndexed = 0; - let documentsRetokenised = 0; - let documentsSkipped = 0; - let documentsFailed = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - skipped++; - continue; + lockedPass( + CONTENT_INDEX_BACKFILL_QUEUE, + (job) => `user:${job.data.userId ?? ""}`, + async (jobs) => { + let users = 0; + let stoppedEarly = false; + const shouldStop = jobBudget(jobs); + let skipped = 0; + let documentsIndexed = 0; + let documentsRetokenised = 0; + let documentsSkipped = 0; + let documentsFailed = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + skipped++; + continue; + } + try { + const { + indexed, + retokenised, + skipped: docsSkipped, + failed: docsFailed, + reason, + stoppedEarly: stopped, + } = await runContentIndexBackfillForUser(userId, shouldStop); + users++; + stoppedEarly ||= stopped === true; + documentsIndexed += indexed; + documentsRetokenised += retokenised; + documentsSkipped += docsSkipped; + documentsFailed += docsFailed; + workerLog( + "info", + `[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[document-content-index-backfill] user=${userId} failed`, + err, + ); + throw err; + } } - try { - const { - indexed, - retokenised, - skipped: docsSkipped, - failed: docsFailed, - reason, - } = await runContentIndexBackfillForUser(userId); - users++; - documentsIndexed += indexed; - documentsRetokenised += retokenised; - documentsSkipped += docsSkipped; - documentsFailed += docsFailed; - workerLog( - "info", - `[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[document-content-index-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - skipped, - documents_indexed: documentsIndexed, - documents_retokenised: documentsRetokenised, - documents_skipped: documentsSkipped, - documents_failed: documentsFailed, - }); - }, + return jobDone({ + jobs: jobs.length, + users, + skipped, + documents_indexed: documentsIndexed, + documents_retokenised: documentsRetokenised, + documents_skipped: documentsSkipped, + documents_failed: documentsFailed, + stopped_early: stoppedEarly, + }); + }, + ), ); // Document AI — automatic per-document content indexing. Enqueued on upload diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 70ece8c3d..bd8d3c09a 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -102,11 +102,16 @@ import { } from "@/lib/jobs/insight-status-generate"; import { withBackgroundEvent } from "@/lib/logging/background"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; +import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; +import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared"; +import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared"; import { getWorkerPrisma, workerLog } from "./shared"; import { createAndSchedule, createAndWork, insightRetryOptions, + nightlyInsightPassOptions, type QueuePolicyTable, type ScheduleEntry, } from "./registrar-shared"; @@ -232,25 +237,32 @@ const allQueues = [ ]; const schedules: ScheduleEntry[] = [ - [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, insightRetryOptions], + [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, nightlyInsightPassOptions], [ BLOOD_PRESSURE_STATUS_QUEUE, BLOOD_PRESSURE_STATUS_CRON, - insightRetryOptions, + nightlyInsightPassOptions, ], - [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, insightRetryOptions], - [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, insightRetryOptions], - [BMI_STATUS_QUEUE, BMI_STATUS_CRON, insightRetryOptions], + [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, nightlyInsightPassOptions], + [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, nightlyInsightPassOptions], + [BMI_STATUS_QUEUE, BMI_STATUS_CRON, nightlyInsightPassOptions], // v1.15.20 — mood status nightly, continuing the 02:xx ladder. - [MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, insightRetryOptions], + [MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, nightlyInsightPassOptions], [ MEDICATION_COMPLIANCE_STATUS_QUEUE, MEDICATION_COMPLIANCE_STATUS_CRON, - insightRetryOptions, + nightlyInsightPassOptions, ], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight // pre-generation. Budget-gated per user inside the handler. - [INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, insightRetryOptions], + [ + INSIGHT_PREGENERATE_QUEUE, + INSIGHT_PREGENERATE_CRON, + { + ...insightRetryOptions, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + }, + ], // v1.10.0 — computed scores (WX-C). Nightly 04:45 Europe/Berlin // Recovery-score compute + store, after the rollup-feeding consolidation // + drain so the signals it reads are already folded. @@ -266,7 +278,14 @@ const schedules: ScheduleEntry[] = [ // v1.11.0 — nightly 05:05 Europe/Berlin period-narrative warm. The // handler only fans out on a week (Mon) / month (1st) boundary; every // other night is a cheap no-op. Budget-gated per user inside the runner. - [PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, insightRetryOptions], + [ + PERIOD_NARRATIVE_QUEUE, + PERIOD_NARRATIVE_CRON, + { + ...insightRetryOptions, + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, + }, + ], // v1.15.20 — daily 05:15 Europe/Berlin proactive Coach nudge, after // the 04:45–04:55 score crons so the recovery-score trigger reads // settled rows. Deterministic triggers only — no AI call on this path. @@ -369,43 +388,59 @@ export async function registerStatusQueues( boss, GENERAL_STATUS_QUEUE, { localConcurrency: 1 }, - handleGeneralStatusGenerate, + lockedPass( + GENERAL_STATUS_QUEUE, + () => WHOLE_PASS, + handleGeneralStatusGenerate, + ), ); await createAndWork( boss, BLOOD_PRESSURE_STATUS_QUEUE, { localConcurrency: 1 }, - handleBloodPressureStatusGenerate, + lockedPass( + BLOOD_PRESSURE_STATUS_QUEUE, + () => WHOLE_PASS, + handleBloodPressureStatusGenerate, + ), ); await createAndWork( boss, WEIGHT_STATUS_QUEUE, { localConcurrency: 1 }, - handleWeightStatusGenerate, + lockedPass( + WEIGHT_STATUS_QUEUE, + () => WHOLE_PASS, + handleWeightStatusGenerate, + ), ); await createAndWork( boss, PULSE_STATUS_QUEUE, { localConcurrency: 1 }, - handlePulseStatusGenerate, + lockedPass(PULSE_STATUS_QUEUE, () => WHOLE_PASS, handlePulseStatusGenerate), ); await createAndWork( boss, BMI_STATUS_QUEUE, { localConcurrency: 1 }, - handleBmiStatusGenerate, + lockedPass(BMI_STATUS_QUEUE, () => WHOLE_PASS, handleBmiStatusGenerate), ); await createAndWork( boss, MOOD_STATUS_QUEUE, { localConcurrency: 1 }, - handleMoodStatusGenerate, + lockedPass(MOOD_STATUS_QUEUE, () => WHOLE_PASS, handleMoodStatusGenerate), ); await createAndWork( boss, MEDICATION_COMPLIANCE_STATUS_QUEUE, { localConcurrency: 1 }, - handleMedicationComplianceStatusGenerate, + lockedPass( + MEDICATION_COMPLIANCE_STATUS_QUEUE, + () => WHOLE_PASS, + handleMedicationComplianceStatusGenerate, + ), ); // v1.7.0 — nightly comprehensive-insight pre-generation. // @@ -422,7 +457,16 @@ export async function registerStatusQueues( boss, INSIGHT_PREGENERATE_QUEUE, { localConcurrency: 2 }, - handleInsightPregenerateJob, + // The cohort walk is one pass at a time; a forced warm is one per + // account, so it never waits behind the walk. + lockedPass( + INSIGHT_PREGENERATE_QUEUE, + (job) => + job.data?.force && job.data.userId + ? `user:${job.data.userId}` + : WHOLE_PASS, + handleInsightPregenerateJob, + ), ); // S4 — event-driven morning digest refresh. One forced comprehensive // regeneration per user per local morning, enqueued by the sleep-arrival @@ -749,44 +793,53 @@ export async function registerStatusQueues( boss, PERIOD_NARRATIVE_QUEUE, { localConcurrency: 1 }, - async (jobs): Promise => { - // Two modes on one queue, so the outcome counts them separately: a - // non-boundary night is a scheduled pass that generated nothing, which - // is a success with zero counts, not an idle queue. - let singleUser = 0; - let scheduled = 0; - let generated = 0; - let failed = 0; - for (const job of jobs) { - try { - if (job.data?.userId) { - await warmOneNarrative(job.data); - singleUser++; - } else { - const summary = await runPeriodNarrativeWarm(getWorkerPrisma()); - scheduled++; - generated += summary.generated; - failed += summary.failed; - workerLog( - "info", - `[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`, - ); + lockedPass( + PERIOD_NARRATIVE_QUEUE, + (job) => (job.data?.userId ? `user:${job.data.userId}` : WHOLE_PASS), + async (jobs): Promise => { + // Two modes on one queue, so the outcome counts them separately: a + // non-boundary night is a scheduled pass that generated nothing, which + // is a success with zero counts, not an idle queue. + let singleUser = 0; + let scheduled = 0; + let generated = 0; + let failed = 0; + let stoppedEarly = false; + for (const job of jobs) { + try { + if (job.data?.userId) { + await warmOneNarrative(job.data); + singleUser++; + } else { + const summary = await runPeriodNarrativeWarm(getWorkerPrisma(), { + shouldStop: jobBudget(jobs), + }); + scheduled++; + generated += summary.generated; + failed += summary.failed; + stoppedEarly ||= summary.stoppedEarly; + workerLog( + "info", + `[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`, + ); + } + } catch (err) { + recordError(); + await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { + mode: job.data?.userId ? "single-user" : "scheduled", + }); + throw err; } - } catch (err) { - recordError(); - await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { - mode: job.data?.userId ? "single-user" : "scheduled", - }); - throw err; } - } - return jobDone({ - single_user: singleUser, - scheduled, - generated, - failed, - }); - }, + return jobDone({ + single_user: singleUser, + scheduled, + generated, + failed, + stopped_early: stoppedEarly, + }); + }, + ), ); // v1.11.1 — combined Coach memory refresh: rolling conversation summary + // durable fact extraction for one long conversation. localConcurrency 1 so a diff --git a/src/lib/jobs/reminder/registrar-shared.ts b/src/lib/jobs/reminder/registrar-shared.ts index c46497fb8..6841b0111 100644 --- a/src/lib/jobs/reminder/registrar-shared.ts +++ b/src/lib/jobs/reminder/registrar-shared.ts @@ -168,6 +168,22 @@ export const insightRetryOptions = { retryBackoff: true, } as const; +/** + * Send options for a nightly AI pass over every account (the status crons). + * + * The retries of `insightRetryOptions`, plus a two-hour expiry. Each pass + * makes a provider call per account, and on a large instance that outlasted + * pg-boss's fifteen-minute default: the job was declared dead and retried + * beside the pass still running. The pass now stops itself at three quarters + * of this (`jobBudget`) and holds a lock (`lockedPass`). The options ride on + * the schedule because the cron is the only sender. + */ +export const NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS = 2 * 60 * 60; +export const nightlyInsightPassOptions = { + ...insightRetryOptions, + expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS, +} as const; + /** * Retry policy for a cron-driven pass whose failure mode is deterministic. * From 1934b97663915c1c0f9d82ac8b86b2584a72354c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:24:15 +0200 Subject: [PATCH 09/16] test(guard): declare every queue's runtime and hold long ones to it Every queue bound with createAndWork is now declared in src/lib/jobs/queue-runtime.ts as short (cannot come near the default fifteen minutes, with the reason), budgeted (stops itself at three quarters of the default and resumes) or long (an explicit expiry, a stop on the job's budget, and a lock or claim per identity; or a declared one-shot with no retries). The guard reads every binding, every boss.send and every schedule tuple in the tree: each names a queue in the table, every entry is bound, a long queue's sends and schedules all carry its expiry, its handler stops on jobBudget / jobDeadline where declared and holds lockedPass or the named marker, and a short or budgeted queue sets no expiry of its own. Floors pin the counts (112 bindings, 64 sends, 74 schedules), and planted long queues missing each protection in turn are caught. Run against the tree before the previous commit it reports 59 findings; dropping lockedPass and the cron expiry from pr-detection reports both. --- src/__tests__/job-queue-runtime-guard.test.ts | 371 ++++++++++++++++ src/lib/jobs/queue-runtime.ts | 398 ++++++++++++++++++ 2 files changed, 769 insertions(+) create mode 100644 src/__tests__/job-queue-runtime-guard.test.ts create mode 100644 src/lib/jobs/queue-runtime.ts diff --git a/src/__tests__/job-queue-runtime-guard.test.ts b/src/__tests__/job-queue-runtime-guard.test.ts new file mode 100644 index 000000000..5110a5c93 --- /dev/null +++ b/src/__tests__/job-queue-runtime-guard.test.ts @@ -0,0 +1,371 @@ +/** + * Structural guard: every pg-boss queue declares how long its job can run, and + * a long one is safe against pg-boss's expiry. + * + * pg-boss gives a job fifteen minutes unless the send says otherwise, then + * fails it, fires its abort signal and retries it while the handler keeps + * going. The full-history imports ran beside themselves that way (#1066), and + * so could every other job that legitimately needs longer: the nightly status + * passes, the pre-generation, the record scan, the key rotation, the backups. + * + * `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or + * `long`. This guard holds the tree to it: + * + * - every queue bound with `createAndWork` is in the table, and every table + * entry is bound; + * - every `boss.send` and every schedule tuple names a queue in the table; + * - a `long` queue has an expiry between the default and pg-boss's 24-hour + * ceiling, every send and schedule for it carries `expireInSeconds` (or an + * identifier the table names as its carrier), it stops on the job's budget + * where the table says (`jobBudget` / `jobDeadline`, or a declared + * one-shot), and it is exclusive per identity where the table says + * (`lockedPass` at the binding, or the named marker); + * - a `budgeted` queue stops on the budget and relies on the default expiry; + * - a `short` queue's sends set no expiry of their own, which would mean the + * job is longer than the table claims. + * + * Floors fail the guard if a matcher stops matching, and the "plants" cases + * run the checks over a planted long queue that lacks each protection in turn + * (the mutation proof kept in the suite). Removing `lockedPass` from the + * pr-detection binding, or the expiry from its cron tuple, fails the real + * checks below. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; +import { + DEFAULT_JOB_EXPIRE_SECONDS, + QUEUE_RUNTIME, + type QueueRuntime, + type SourceSite, +} from "@/lib/jobs/queue-runtime"; + +const SRC = join(process.cwd(), "src"); +const REGISTRARS = [ + "lib/jobs/reminder/register-integration-sync.ts", + "lib/jobs/reminder/register-maintenance.ts", + "lib/jobs/reminder/register-reminders.ts", + "lib/jobs/reminder/register-rollup.ts", + "lib/jobs/reminder/register-status.ts", +]; +/** pg-boss refuses an expiry of 24 hours or more. */ +const MAX_EXPIRE_SECONDS = 24 * 60 * 60; + +const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); +const source = new Map( + files.map((f) => [f, readFileSync(join(SRC, f), "utf8")]), +); + +/** Strip comments so documentation that quotes code never matches. */ +function code(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +/** Index just past the bracket that closes the one at `open`. */ +function closeOf(text: string, open: number): number { + const pair: Record = { "(": ")", "[": "]", "{": "}" }; + const want = pair[text[open]]; + let depth = 1; + let i = open + 1; + while (depth > 0 && i < text.length) { + if (text[i] === text[open]) depth += 1; + else if (text[i] === want) depth -= 1; + i += 1; + } + return i; +} + +type Definitions = Map>; + +/** `const NAME = "queue-name"` definitions, per file. */ +function definitionsOf(texts: ReadonlyMap): Definitions { + const definitions: Definitions = new Map(); + for (const [file, text] of texts) { + for (const m of text.matchAll( + /\bconst\s+([A-Z][A-Z0-9_]*)\s*(?::\s*string\s*)?=\s*"([a-z0-9-]+)"/g, + )) { + const list = definitions.get(m[1]) ?? []; + list.push({ file, name: m[2] }); + definitions.set(m[1], list); + } + } + return definitions; +} + +/** Resolve a queue identifier as seen from `file`, preferring its own. */ +function resolve( + definitions: Definitions, + file: string, + ident: string, +): string | null { + const defs = definitions.get(ident) ?? []; + const own = defs.find((d) => d.file === file); + if (own) return own.name; + const names = new Set(defs.map((d) => d.name)); + return names.size === 1 ? [...names][0] : null; +} + +interface Site { + file: string; + queue: string; + text: string; +} + +/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */ +function bindings( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const file of REGISTRARS) { + const text = code(texts.get(file) ?? ""); + for (const m of text.matchAll( + /createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g, + )) { + const open = text.indexOf("(", m.index); + const queue = resolve(definitions, file, m[1]); + if (queue) { + out.push({ file, queue, text: text.slice(open, closeOf(text, open)) }); + } + } + } + return out; +} + +/** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */ +function sends( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const [file, raw] of texts) { + const text = code(raw); + for (const m of text.matchAll( + /\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*([A-Z][A-Z0-9_]*)/g, + )) { + const open = text.indexOf("(", text.indexOf("send", m.index)); + const queue = resolve(definitions, file, m[1]); + if (queue) { + out.push({ file, queue, text: text.slice(open, closeOf(text, open)) }); + } + } + } + return out; +} + +/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */ +function schedules( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const file of REGISTRARS) { + const text = code(texts.get(file) ?? ""); + const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text); + if (!m) continue; + const open = m.index + m[0].length - 1; + const body = text.slice(open + 1, closeOf(text, open) - 1); + for (let i = 0; i < body.length; i += 1) { + if (body[i] !== "[") continue; + const end = closeOf(body, i); + const tuple = body.slice(i, end); + const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1]; + const queue = ident ? resolve(definitions, file, ident) : null; + if (queue) out.push({ file, queue, text: tuple }); + i = end - 1; + } + } + return out; +} + +/** The text of `fn` (a function or a const) in `file`. */ +function bodyOf( + texts: ReadonlyMap, + site: { file: string; fn: string }, +): string { + const text = code(texts.get(site.file) ?? ""); + const m = new RegExp( + `(?:function\\s+${site.fn}\\b|const\\s+${site.fn}\\b)`, + ).exec(text); + if (!m) return ""; + const end = text.indexOf("\n}", m.index); + return text.slice(m.index, end < 0 ? undefined : end + 2); +} + +function siteText( + texts: ReadonlyMap, + site: SourceSite, + binding: Site | undefined, +): string { + return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site); +} + +const STOPS = /\bjob(?:Budget|Deadline)\s*\(/; + +/** Every finding for one queue's declaration against the tree. */ +function check( + queue: string, + entry: QueueRuntime, + texts: ReadonlyMap, + all: { bindings: Site[]; sends: Site[]; schedules: Site[] }, +): string[] { + const problems: string[] = []; + const binding = all.bindings.find((b) => b.queue === queue); + const carriers = [...all.sends, ...all.schedules].filter( + (s) => s.queue === queue, + ); + if (entry.runtime === "long") { + if ( + !(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) || + !(entry.expireInSeconds < MAX_EXPIRE_SECONDS) + ) { + problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`); + } + for (const site of carriers) { + const carried = + /\bexpireInSeconds\b/.test(site.text) || + entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text)); + if (!carried) { + problems.push( + `${queue}: ${site.file} sends or schedules it without an expiry`, + ); + } + } + if (typeof entry.stop === "object" && "oneShot" in entry.stop) { + if (entry.stop.oneShot.length < 20) { + problems.push(`${queue}: a one-shot needs its reason`); + } + } else if (!STOPS.test(siteText(texts, entry.stop, binding))) { + problems.push(`${queue}: does not stop on the job's budget`); + } + if (entry.exclusive === "lockedPass") { + if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) { + problems.push(`${queue}: binding holds no lockedPass`); + } + } else if ( + !siteText(texts, entry.exclusive.at, binding).includes( + entry.exclusive.marker, + ) + ) { + problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`); + } + } else { + if (entry.runtime === "budgeted") { + if (!STOPS.test(siteText(texts, entry.budget, binding))) { + problems.push(`${queue}: does not stop on the job's budget`); + } + } + for (const site of carriers) { + if (/\bexpireInSeconds\b/.test(site.text)) { + problems.push( + `${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`, + ); + } + } + } + return problems; +} + +function scan(texts: ReadonlyMap) { + const definitions = definitionsOf(texts); + return { + bindings: bindings(texts, definitions), + sends: sends(texts, definitions), + schedules: schedules(texts, definitions), + }; +} + +describe("every queue declares its runtime", () => { + const all = scan(source); + const bound = new Set(all.bindings.map((b) => b.queue)); + + it("finds the bindings, sends and schedules it is meant to police", () => { + // Pinned below the counts on 2026-10-01 (112 / 64 / 74). + expect(all.bindings.length).toBeGreaterThanOrEqual(105); + expect(all.sends.length).toBeGreaterThanOrEqual(50); + expect(all.schedules.length).toBeGreaterThanOrEqual(70); + expect( + Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length, + ).toBeGreaterThanOrEqual(15); + }); + + it("every bound queue is in the table, and every entry is bound", () => { + expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]); + expect( + Object.keys(QUEUE_RUNTIME) + .filter((q) => !bound.has(q)) + .sort(), + ).toEqual([]); + }); + + it("every send and schedule names a queue in the table", () => { + const unknown = [...all.sends, ...all.schedules] + .filter((s) => !(s.queue in QUEUE_RUNTIME)) + .map((s) => `${s.file} → ${s.queue}`); + expect(unknown).toEqual([]); + }); + + it("every queue meets its declaration", () => { + const problems = Object.entries(QUEUE_RUNTIME).flatMap(([queue, entry]) => + check(queue, entry, source, all), + ); + expect(problems).toEqual([]); + }); + + it("plants: a long queue missing any protection is caught", () => { + const registrar = REGISTRARS[1]; + const planted = (binding: string, schedule: string) => + new Map([ + ...source, + [ + registrar, + `const PLANT_QUEUE = "planted-long";\n` + + `const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` + + `async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`, + ], + ]); + const entry: QueueRuntime = { + runtime: "long", + expireInSeconds: 3600, + expiryVia: [], + stop: "binding", + exclusive: "lockedPass", + why: "planted", + }; + const findings = (binding: string, schedule: string) => { + const texts = planted(binding, schedule); + return check("planted-long", entry, texts, scan(texts)); + }; + const safe = [ + "lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))", + "[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]", + ] as const; + expect(findings(...safe)).toEqual([]); + expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([ + `planted-long: ${registrar} sends or schedules it without an expiry`, + ]); + expect( + findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]), + ).toEqual(["planted-long: does not stop on the job's budget"]); + expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([ + "planted-long: binding holds no lockedPass", + ]); + expect( + check( + "planted-long", + { ...entry, expireInSeconds: 900 }, + ...((): [ReadonlyMap, ReturnType] => { + const texts = planted(...safe); + return [texts, scan(texts)]; + })(), + ), + ).toEqual(["planted-long: expiry 900s out of range"]); + }); +}); diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts new file mode 100644 index 000000000..caf376c3d --- /dev/null +++ b/src/lib/jobs/queue-runtime.ts @@ -0,0 +1,398 @@ +/** + * How long one job of each queue can run, and what keeps a long one safe. + * + * pg-boss gives every job an expiry, fifteen minutes unless the send says + * otherwise. Past it pg-boss fails the job, fires its abort signal and retries + * it, and it does not stop the handler, so a job that legitimately needs longer + * ends up running twice beside itself. No queue policy prevents that: the + * policies constrain job rows, and an expired row is no longer active. Every + * queue bound with `createAndWork` is therefore declared here as one of: + * + * - `short`: a job cannot come near fifteen minutes (one account's bounded + * window, one document, a forwarder, a bounded delete). It relies on the + * default expiry, and the reason says why that is enough. + * - `budgeted`: a pass over a long history that stops itself at three + * quarters of the default expiry (`jobBudget`) and hands the rest to its + * next run, so it ends before fifteen minutes by construction. + * - `long`: a job that may run for hours. It carries an explicit + * `expireInSeconds` on every send and schedule, stops itself before that + * expiry (`jobBudget` / `jobDeadline`), and holds something per identity + * that keeps a second run from working beside it: the lock `lockedPass` + * takes, a lock of its own, or a row it claims. The one exception is a + * one-shot job with `retryLimit: 0`, which pg-boss never retries and which + * cannot stop half-way without losing its input. + * + * `src/__tests__/job-queue-runtime-guard.test.ts` holds every binding, send + * and schedule in the tree to this table. + */ +import { INTEGRATION_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/integration-backfill-admission"; +import { NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS } from "@/lib/jobs/reminder/registrar-shared"; +import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared"; +import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared"; +import { PR_DETECTION_EXPIRE_SECONDS } from "@/lib/jobs/pr-detection"; +import { ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS } from "@/lib/jobs/encryption-key-rotate"; +import { CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/document-content-index-backfill"; +import { DATA_BACKUP_SEND_OPTIONS } from "@/lib/jobs/data-backup-policy"; +import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup"; +import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore"; +import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker"; +import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import"; + +/** pg-boss's default job expiry, in seconds. */ +export const DEFAULT_JOB_EXPIRE_SECONDS = 15 * 60; + +/** Where a check lives: `binding` is the `createAndWork` call itself. */ +export type SourceSite = "binding" | { file: string; fn: string }; + +export type QueueRuntime = + | { runtime: "short"; why: string } + | { runtime: "budgeted"; budget: SourceSite; why: string } + | { + runtime: "long"; + expireInSeconds: number; + /** + * Identifiers that carry the expiry into a send or a schedule. A site + * that names none of them must set `expireInSeconds` itself. + */ + expiryVia: readonly string[]; + /** Where the handler stops on the job's budget, or `oneShot`. */ + stop: SourceSite | { oneShot: string }; + /** + * What keeps a second run from working beside this one: `lockedPass` + * at the binding, or a marker in the named function. + */ + exclusive: "lockedPass" | { marker: string; at: SourceSite }; + why: string; + }; + +const short = (why: string): QueueRuntime => ({ runtime: "short", why }); + +const nightlyStatusPass = (fn: string): QueueRuntime => ({ + runtime: "long", + expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS, + expiryVia: ["nightlyInsightPassOptions"], + stop: { file: "lib/jobs/reminder/insights-handlers.ts", fn }, + exclusive: "lockedPass", + why: "Nightly status pass: one provider call per candidate account, serially, over the whole instance.", +}); + +const cohortSync = (provider: string): QueueRuntime => + short( + `${provider} sync: a recent window per connection (incremental since the last sync); a webhook job is one connection.`, + ); + +const oauthStateCleanup = short("One DELETE of expired OAuth state rows."); + +const forwarder = short( + "Forwards each job to integration-backfill-admission; the import itself runs there.", +); + +export const QUEUE_RUNTIME: Readonly> = { + // ── Integration sync ─────────────────────────────────────────────── + "integration-backfill-admission": { + runtime: "long", + expireInSeconds: INTEGRATION_BACKFILL_EXPIRE_SECONDS, + expiryVia: ["integrationBackfillAdmissionSendOptions"], + stop: { + file: "lib/jobs/integration-backfill-drain.ts", + fn: "drainIntegrationBackfillAdmission", + }, + exclusive: { + marker: "withIntegrationBackfillLock", + at: { + file: "lib/jobs/integration-backfill-drain.ts", + fn: "drainIntegrationBackfillAdmission", + }, + }, + why: "Full-history provider imports: thousands of pages on a dense account.", + }, + "withings-fallback-sync": cohortSync("Withings"), + "withings-activity-sync": cohortSync("Withings activity"), + "withings-sleep-sync": cohortSync("Withings sleep"), + "withings-ecg-sync": cohortSync("Withings ECG"), + "withings-oauth-state-cleanup": oauthStateCleanup, + "whoop-recovery-sync": cohortSync("WHOOP recovery"), + "whoop-sleep-sync": cohortSync("WHOOP sleep"), + "whoop-workout-sync": cohortSync("WHOOP workout"), + "whoop-cycle-sync": cohortSync("WHOOP cycle"), + "whoop-backfill": forwarder, + "whoop-oauth-state-cleanup": oauthStateCleanup, + "oidc-native-handoff-cleanup": short("One DELETE of expired handoff rows."), + "fitbit-sync": cohortSync("Fitbit"), + "fitbit-backfill": forwarder, + "fitbit-sleep-repair": forwarder, + "fitbit-oauth-state-cleanup": oauthStateCleanup, + "google-health-sync": cohortSync("Google Health"), + "google-health-backfill": forwarder, + "google-health-sleep-repair": forwarder, + "google-health-oauth-state-cleanup": oauthStateCleanup, + "sleep-timeline-backfill": forwarder, + "lab-biomarker-backfill": forwarder, + "nightscout-sync": cohortSync("Nightscout"), + "polar-sync": cohortSync("Polar"), + "oura-sync": cohortSync("Oura"), + "strava-sync": cohortSync("Strava"), + "strava-backfill": forwarder, + + // ── Status and insights ──────────────────────────────────────────── + "insights-general-status": nightlyStatusPass("handleGeneralStatusGenerate"), + "insights-blood-pressure-status": nightlyStatusPass( + "handleBloodPressureStatusGenerate", + ), + "insights-weight-status": nightlyStatusPass("handleWeightStatusGenerate"), + "insights-pulse-status": nightlyStatusPass("handlePulseStatusGenerate"), + "insights-bmi-status": nightlyStatusPass("handleBmiStatusGenerate"), + "insights-mood-status": nightlyStatusPass("handleMoodStatusGenerate"), + "insights-medication-compliance-status": nightlyStatusPass( + "handleMedicationComplianceStatusGenerate", + ), + "insight-pregenerate": { + runtime: "long", + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + expiryVia: ["INSIGHT_PREGENERATE_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/reminder/insights-handlers.ts", + fn: "handleInsightPregenerateJob", + }, + exclusive: "lockedPass", + why: "Up to 200 accounts, a comprehensive generation each (a provider call bounded by the account's timeout, up to ten minutes) plus the status warm.", + }, + "morning-digest-refresh": short( + "One account, one comprehensive generation, bounded by the provider timeout.", + ), + "insight-status-generate": short( + "One account, one metric, one provider call.", + ), + "data-arrival": short("One arrival's reactions."), + "reaction-line-generate": short("One provider call for one reaction line."), + "workout-insight-generate": short("One workout's insight."), + "recovery-score-compute": short( + "Arithmetic over a recent window, capped at 500 accounts; no provider call.", + ), + "stress-score-compute": short( + "Arithmetic over a recent window, capped at 500 accounts; no provider call.", + ), + "strain-score-compute": short( + "Arithmetic over a recent window, capped at 500 accounts; no provider call.", + ), + "period-narrative-warm": { + runtime: "long", + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, + expiryVia: ["PERIOD_NARRATIVE_EXPIRE_SECONDS"], + stop: "binding", + exclusive: "lockedPass", + why: "On a boundary night up to 200 accounts with a provider call per period each; a single-account warm is short.", + }, + "coach-memory-refresh": short("One conversation's summary and facts."), + "coach-nudge": short( + "Light deterministic checks per account; the provider part is capped at 25 calls and 90 seconds a tick.", + ), + "coach-reminder-sweep": short("Batches of 200 to 500 rows, no provider."), + "coach-plan-review": short("A batch of 100 plans, folded in SQL."), + "medication-low-stock": short("A light inventory check per account."), + "daily-briefing": short("A light cohort scan every fifteen minutes."), + + // ── Rollups ──────────────────────────────────────────────────────── + "rollup-recompute": short("One bucket."), + "rollup-full-backfill": short( + "One account's rollup tiers: four aggregate statements, each held to the 60-second statement timeout.", + ), + "mood-rollup-recompute": short("One bucket."), + "mood-rollup-full-backfill": short( + "One account's mood entries, a few a day.", + ), + "medication-compliance-full-backfill": short( + "One account's 90-day compliance window.", + ), + "step-consolidation": short( + "One account's legacy step rows, one transaction per day.", + ), + "step-consolidation-repair": short( + "One account's tombstoned step rows inside the retention window.", + ), + "cumulative-pr-rederive": short( + "One account's record detection; measured at 20 seconds for 1.25 million rows.", + ), + "mean-consolidation": { + runtime: "budgeted", + budget: "binding", + why: "One account's whole history, a day at a time; stops at three quarters of the default expiry and resumes.", + }, + "dense-intraday-retention": { + runtime: "budgeted", + budget: "binding", + why: "A dense account's history, a day at a time; stops at three quarters of the default expiry and resumes.", + }, + "dense-intraday-hourly-rebuild": { + runtime: "budgeted", + budget: "binding", + why: "A dense account's hourly rows, a day at a time; stops at three quarters of the default expiry and resumes.", + }, + "drain-per-sample-cumulative": { + runtime: "budgeted", + budget: "binding", + why: "Every account's per-sample drains; stops at three quarters of the default expiry and hands the rest to a continuation job.", + }, + + // ── Reminders ────────────────────────────────────────────────────── + "medication-reminder-check": short("The due slots of the current minute."), + "mood-reminder-check": short("Accounts whose local reminder hour is now."), + "cycle-reminder-check": short("Accounts whose local reminder hour is now."), + "measurement-reminder-check": short( + "Accounts whose local reminder hour is now.", + ), + "reminder-satisfy": short("One account's open reminders."), + + // ── Maintenance ──────────────────────────────────────────────────── + "data-backup": { + runtime: "long", + expireInSeconds: DATA_BACKUP_SEND_OPTIONS.expireInSeconds, + expiryVia: ["DATA_BACKUP_SEND_OPTIONS"], + stop: { + file: "lib/jobs/reminder/backup-handlers.ts", + fn: "handleDataBackup", + }, + exclusive: "lockedPass", + why: "Serialises every account's whole record.", + }, + "data-backup-offhost": { + runtime: "long", + expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS, + expiryVia: ["OFFHOST_BACKUP_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/reminder/backup-handlers.ts", + fn: "handleOffhostBackup", + }, + exclusive: "lockedPass", + why: "Uploads every account's record off-host.", + }, + "data-restore-drill": short("Fetches and stream-parses one off-host object."), + "offhost-backup-purge": short("At most 200 delete requests per run."), + "backup-restore": { + runtime: "long", + expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS, + expiryVia: ["BACKUP_RESTORE_SEND_OPTIONS", "BACKUP_RESTORE_EXPIRE_SECONDS"], + stop: { file: "lib/jobs/backup-restore.ts", fn: "handleBackupRestore" }, + exclusive: { + marker: "restore_claimed", + at: { file: "lib/jobs/backup-restore.ts", fn: "runBackupRestoreJob" }, + }, + why: "Restores a whole account in one transaction.", + }, + "host-metric-sample": short("One host sample."), + "feedback-aggregator": short("One aggregate over the feedback table."), + "geo-backfill": short("At most 500 audit rows per pass."), + "geolite2-fetch": short("One database download."), + "tls-pin-monitor": short("One TLS handshake."), + "mood-reminder-cleanup": short("One retention DELETE."), + "push-attempt-cleanup": short("One retention DELETE."), + "arrival-reaction-cleanup": short("One retention DELETE."), + "rate-limit-cleanup": short("One retention DELETE."), + "idempotency-cleanup": short("One retention DELETE."), + "step-up-elevation-cleanup": short("One retention DELETE."), + "coach-message-cleanup": short("One retention DELETE."), + "mcp-token-cleanup": short("One retention DELETE."), + "audit-log-cleanup": short("At most 40 batches of 5 000 rows per leg."), + "measurement-tombstone-cleanup": short( + "At most 40 batches of 5 000 rows per leg.", + ), + "document-tombstone-purge": short("One deleteMany."), + "document-summary-reaper": short("One updateMany."), + "cycle-prediction-refresh": short( + "Arithmetic per eligible account, no provider call.", + ), + "mood-prognosis-refresh": short( + "Arithmetic per eligible account, no provider call.", + ), + "achievement-unlock-sweep": short( + "A bounded achievement read per account, no provider call.", + ), + "pr-detection": { + runtime: "long", + expireInSeconds: PR_DETECTION_EXPIRE_SECONDS, + expiryVia: ["PR_DETECTION_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/reminder/ops-handlers.ts", + fn: "handlePrDetection", + }, + exclusive: "lockedPass", + why: "The fallback cron scans every account's all-time history every thirty minutes; an ingest job is one account.", + }, + "medication-inventory-expire": short("One updateMany."), + "intake-auto-skip": short("One updateMany."), + "apple-health-import-v2": { + runtime: "long", + expireInSeconds: APPLE_HEALTH_IMPORT_SEND_OPTIONS.expireInSeconds, + expiryVia: ["APPLE_HEALTH_IMPORT_SEND_OPTIONS"], + stop: { + oneShot: + "The run consumes and unlinks its staged upload, so it can neither stop half-way nor be retried; the send sets retryLimit 0.", + }, + exclusive: { + marker: "retryLimit: 0", + at: { + file: "lib/jobs/apple-health-import-worker.ts", + fn: "APPLE_HEALTH_IMPORT_SEND_OPTIONS", + }, + }, + why: "Parses an export that can be several gigabytes.", + }, + "apple-health-import": short( + "The legacy queue: moves each job onto apple-health-import-v2.", + ), + "apple-health-import-reconcile": short("One updateMany."), + "medication-intake-import": { + runtime: "long", + expireInSeconds: MEDICATION_INTAKE_IMPORT_SEND_OPTIONS.expireInSeconds, + expiryVia: ["MEDICATION_INTAKE_IMPORT_SEND_OPTIONS"], + stop: { + file: "lib/jobs/medication-intake-import.ts", + fn: "handleMedicationIntakeImport", + }, + exclusive: { + marker: "FOR UPDATE", + at: { + file: "lib/jobs/medication-intake-import.ts", + fn: "processNextChunk", + }, + }, + why: "Imports a dose-history file chunk by chunk; its length follows the file.", + }, + "intake-slot-dedup": short("One account's duplicate dose slots."), + "note-encryption-backfill": short( + "Pages of 200 rows of one account's plaintext notes, light per row.", + ), + "med-notes-encryption-backfill": short( + "Pages of 200 rows of one account's plaintext notes, light per row.", + ), + "free-text-encryption-backfill": short( + "Pages of 200 rows of one account's plaintext fields, light per row.", + ), + "encryption-key-rotate": { + runtime: "long", + expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, + expiryVia: ["ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/encryption-key-rotate.ts", + fn: "handleEncryptionKeyRotate", + }, + exclusive: "lockedPass", + why: "Re-encrypts every encrypted column on the instance.", + }, + "document-content-index-backfill": { + runtime: "long", + expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS, + expiryVia: ["CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS"], + stop: "binding", + exclusive: "lockedPass", + why: "Up to 200 documents of one account, a provider transcription each.", + }, + "document-index": short("One document."), + "document-thumbnail": short("One document."), + "document-summary": short("One document."), + "document-summary-catchup": short("Enqueues at most 200 documents."), + "document-thumbnail-backfill": short("Enqueues at most 1 000 documents."), + "environment-fetch": short( + "A fan-out of sends, or one account's bounded archive range.", + ), +}; From 4a13004917b38828ef8ff59a961b3e7140f652ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:29 +0200 Subject: [PATCH 10/16] fix(labs): fit the lab list, values list and add sheet to a phone The compact list was a five-column table at every width. Below the fixed columns (badge, 12rem range bar, 72px trend, chevron) the name column collapsed to nothing, the badge sat over the value and the row panned sideways off the card. Below lg the same cells now fold into two lines per row; the desktop subgrid is unchanged. The values table needed ~510px, so on a phone its edit and delete buttons sat in a sideways-scrolling strip and the range bar labels were clipped. Below lg each reading is a stacked row with its actions beside it. The add sheet's three footer buttons did not fit a phone row in German and pushed Cancel off the left edge; the secondary save takes a row of its own there, and the footer wraps instead of overflowing on desktop. The scan review's 'links to existing marker' chip truncates instead of running past its row. --- src/app/labs/page.tsx | 5 +- src/components/labs/lab-form.tsx | 4 + src/components/labs/lab-history-list.tsx | 233 ++++++++++++++--------- src/components/labs/lab-list.tsx | 72 +++++-- src/components/labs/ocr-row-editor.tsx | 12 +- 5 files changed, 214 insertions(+), 112 deletions(-) diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 1899669fd..69ca8f41e 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -192,7 +192,10 @@ export default function LabsPage() { title={t("labs.addResult")} description={t("labs.addDescription")} footer={ -
+
} > {pendingAction === "saveAndAddAnother" ? ( diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 55b397519..3dce4fa67 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }); }, [readings, sortBy, sortDir]); + // One action pair per reading, shared by the phone list and the table. + const renderActions = (r: LabResultDto) => ( +
+ {canManageLabs && ( + + )} + deleteMutation.mutate(r.id)} + title={t("labs.deleteConfirmTitle")} + description={t("labs.deleteConfirmDescription")} + confirmLabel={t("labs.deleteReading")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
+ ); + return ( <> - - - - - - - - - {t("labs.editReading")} - - - - - {ordered.map((r) => ( - - -
- {formatLabReading(r)} - -
-
- - {formatDateShort(r.takenAt, true)} - - -
- - -
-
- - {r.hasNote ? t("labs.hasNote") : ""} - - -
- {canManageLabs && ( - - )} - deleteMutation.mutate(r.id)} - title={t("labs.deleteConfirmTitle")} - description={t("labs.deleteConfirmDescription")} - confirmLabel={t("labs.deleteReading")} - className="size-11 sm:size-9" - iconClassName="h-4 w-4" - /> -
-
+ {/* Phones get one stacked row per reading. The table needs ~510 px for + its five columns, so on a phone it panned sideways inside its + container and parked the edit / delete buttons off-screen, while the + range bar's end labels were clipped by the same container. */} +
    + {ordered.map((r) => ( +
  • +
    +
    + {formatLabReading(r)} +
    + +
    + + {formatDateShort(r.takenAt, true)} + + + {r.hasNote ? ( + {t("labs.hasNote")} + ) : null} +
    + +
    + {renderActions(r)} +
  • + ))} +
+
+
+ + + + + + + + {t("labs.editReading")} + - ))} - -
+ + + {ordered.map((r) => ( + + +
+ {formatLabReading(r)} + +
+
+ + {formatDateShort(r.takenAt, true)} + + +
+ + +
+
+ + {r.hasNote ? t("labs.hasNote") : ""} + + + {renderActions(r)} + +
+ ))} +
+ +
+
void } = {}) { const { t } = useTranslations(); const { canWriteDomain } = useRecordCapabilities(); @@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {

) : null} - + {groups.map((group) => { const inner = ( -
+
- + {group.analyte}
@@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {inner} -
+
reading.value)} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} />
- + ) : ( // Un-linked group (not backfilled to a catalog biomarker): // render inert but say so, so it doesn't read as a broken // link next to its clickable neighbours. -
+
{inner} -
+
reading.value)} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} />
- + {t("labs.notLinkedYet")}
diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index ea1a06281..e63388a55 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -129,9 +129,17 @@ export function OcrRowEditor({ {fieldError("analyte", "labs.ocr.errorAnalyte")}
{row.biomarkerMatch === "existing" ? ( - + // The chip repeats the analyte, so a long panel name ran past + // the row on a phone; it truncates inside the row instead. + - {t("labs.ocr.linksExisting", { name: row.analyte })} + + {t("labs.ocr.linksExisting", { name: row.analyte })} + ) : ( From 24e6972f95cbc031ffcfc66028a64ea30ce3e100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:30 +0200 Subject: [PATCH 11/16] fix(labs): keep the biomarker chart axis at zero for a range starting at zero A reference window of 0-130 padded the axis to -13, a tick that reads like a value for a concentration that cannot be negative. The pad only goes below zero when the data already does. --- src/components/labs/lab-biomarker-chart.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index db6612d54..999d90e15 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -163,7 +163,10 @@ export function LabBiomarkerChart({ const min = Math.min(...candidates); const max = Math.max(...candidates); const pad = (max - min || Math.abs(max) || 1) * 0.1; - return [min - pad, max + pad]; + // A reference window that starts at 0 (LDL "0–130") must not drag the + // axis below zero: a concentration cannot be negative, and a "-13" tick + // reads as a value. The pad only stays below zero when the data already is. + return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad]; }, [points, lowerBound, upperBound, sourceBand]); const animate = !prefersReducedMotion(); From dc385405731daa2c3a2f615115cef96bb0c3b680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:43 +0200 Subject: [PATCH 12/16] fix(ui): give the page-header description the full row on a phone With three or four icon actions beside the title, the description was left a ~130px column that wrapped five or six times. Below sm it now spans the row under the title and actions; from sm up the actions span both rows exactly as before. A one-word German title wider than the column beside the actions ran underneath them; the heading hyphenates by the page language and breaks as a last resort. Notifications, whose two actions are text links, moves them under the description on a phone (stackActionsOnPhone). --- src/app/notifications/page.tsx | 2 + src/components/ui/page-header.tsx | 68 ++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index ba17b08c5..f84bc2fbd 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -201,6 +201,7 @@ export default function NotificationsPage() { title={t("notifications.title")} description={t("notifications.subtitle")} actions={headerActions} + stackActionsOnPhone /> {/* Desktop: table layout */} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index e806e5294..c6e8ef28e 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -26,6 +26,15 @@ import { cn } from "@/lib/utils"; * shells hand-rolled the whole block to get the second copy, which is how * they drifted apart from every module page in the first place. */ +/** + * A one-word German title ("Krankheitstagebuch") is wider than the column a + * phone leaves beside the actions, and without a break opportunity it ran + * underneath them. `hyphens-auto` breaks it by the page's `lang`; the + * `wrap-break-word` floor covers a word the hyphenation dictionary does not. + */ +const HEADING_CLASS = + "text-2xl font-bold tracking-tight hyphens-auto wrap-break-word"; + export function PageHeader({ title, titleId, @@ -34,6 +43,7 @@ export function PageHeader({ backLink, topSlot, actions, + stackActionsOnPhone = false, className, }: { title: ReactNode; @@ -44,37 +54,65 @@ export function PageHeader({ /** Optional block above the title (e.g. a hub back-link the shell owns). */ topSlot?: ReactNode; actions?: ReactNode; + /** + * Below `sm`, move the actions onto their own row under the description. + * For text-labelled actions (two outline links, say): beside the title they + * left the H1 a column narrower than its own word. Icon actions fit beside + * the title and leave this off. + */ + stackActionsOnPhone?: boolean; className?: string; }) { const heading = headingAs === "div" ? ( -
+
{title}
) : ( -

+

{title}

); + // Title, description and actions share one grid. On a phone the + // description spans the full row under the title + actions: a header with + // three or four icon actions otherwise left the sentence a ~130 px column + // that wrapped five or six times. From `sm` up the actions span both rows, + // so the description sits beside them exactly as before. return (
{backLink ? : null} {topSlot} -
-
- {heading} - {description ? ( -

{description}

- ) : null} -
+
+
{heading}
{actions ? ( -
{actions}
+
+ {actions} +
+ ) : null} + {description ? ( +

+ {description} +

) : null}
From 537ac3c7a147c262208abea9137017c7437426ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:43 +0200 Subject: [PATCH 13/16] fix(settings): stop settings and admin cards widening the page on a phone Three cards put a full sentence into the SettingsCardHeader status slot (mood reminder, low-stock warning, the app-log refresh button), which does not wrap: the row grew to 1000px and /settings/notifications panned sideways at every width. The sentence moves into the card body, and the slot is capped at the row so a long status wraps instead. Also on a phone: the provider-chain add control wraps, the report picker's standard-template action drops under its sentence instead of running past the card, and the dashboard layout rows put their switches and arrows on a second line so the tile name is no longer cut to five characters. --- src/components/admin/app-log-preview-section.tsx | 7 +++---- src/components/settings/_card-header.tsx | 7 ++++++- src/components/settings/ai/fallback-chain-card.tsx | 2 +- src/components/settings/dashboard-layout-section.tsx | 9 +++++++-- src/components/settings/low-stock-card.tsx | 3 +-- src/components/settings/mood-reminder-card.tsx | 3 +-- .../settings/report-selection/report-scope-picker.tsx | 7 +++++-- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 1cc49e2c1..f9b92a01d 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -125,7 +125,7 @@ export function AppLogPreviewSection() { } /> +

{t("admin.section.app-logs.processDetail")}

+
{status ? ( -
{status}
+ // `max-w-full` caps the slot at the row once it wraps under the + // title, so a status line longer than a phone row wraps inside + // the card instead of widening the page. +
+ {status} +
) : null}
{/* One sentence, one text run. This slot deliberately carries no diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 388a04b1a..80977ecfb 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -294,7 +294,7 @@ function AddProviderControl({ const [picked, setPicked] = useState(addable[0] ?? ""); return ( -
+
@@ -898,7 +898,12 @@ function SortableWidgetRow({ {labels.widgetLabel} -
+ {/* On a phone the two switches and two arrows (~250 px) left the name + five characters ("Gewic…"). Below `sm` the controls wrap onto a + second line, right-aligned under the column heads, and the name + keeps the first line to itself. */} +