diff --git a/src/__tests__/tombstone-read-guard.test.ts b/src/__tests__/tombstone-read-guard.test.ts new file mode 100644 index 000000000..58895e9a0 --- /dev/null +++ b/src/__tests__/tombstone-read-guard.test.ts @@ -0,0 +1,175 @@ +/** + * Structural guard: a read that feeds analysis or a model prompt never sees a + * deleted row. + * + * Several record types delete softly: the row stays with `deletedAt` set so + * sync can propagate the delete and undo can bring it back. Four per-metric + * status snapshots read mood entries with `where: { userId }` alone, so an + * entry the person had deleted kept shaping the text written about them, and + * the blood-pressure snapshot did the same with measurements. + * + * The models are derived from the schema (every model with a `deletedAt` + * column), not listed by hand, so a model that gains soft delete is covered + * the day it does. The scope is every directory whose output is analysis, + * a score, an insight, or model input. A read passes when its argument names + * `deletedAt`, or when it filters through a local object that does. The + * entries below read tombstones on purpose and say why. Comments are + * stripped first; the planted cases prove the matcher fires. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +const SCOPES = [ + "lib/insights/", + "lib/analytics/", + "lib/ai/", + "lib/rollups/", + "lib/doctor-report/", + "lib/day/", + "lib/timeline/", + "lib/targets/", + "lib/gamification/", + "lib/mood/", + "app/api/insights/", + "app/api/analytics/", + "app/api/dashboard/", +]; + +/** `model@file` → why this read includes deleted rows. */ +const ALLOWED: Record = { + "measurement@lib/rollups/measurement-rollups.ts": + "Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.", + "moodEntry@lib/rollups/mood-rollups.ts": + "Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.", + "coachFact@lib/ai/coach/memory/trail-read.ts": + "Withholds medication texts from the recall trail; a deleted fact still withholds, which errs toward saying less.", + "coachFact@lib/ai/coach/memory/remember.ts": + "Answer-once check on the message that proposed the fact, inside its lock.", +}; + +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +function tombstonedModels(): string[] { + const schema = readFileSync( + join(process.cwd(), "prisma/schema.prisma"), + "utf8", + ); + const models = new Set(); + let current: string | null = null; + for (const line of schema.split("\n")) { + const head = /^model\s+(\w+)\s*\{/.exec(line); + if (head) current = head[1]; + else if (/^\}/.test(line)) current = null; + else if (current && /^\s+deletedAt\s+DateTime\?/.test(line)) { + models.add(current[0].toLowerCase() + current.slice(1)); + } + } + return [...models].sort(); +} + +/** The local object a read filters through carries `deletedAt`. */ +function filtersThroughLiveObject( + code: string, + arg: string, + at: number, +): boolean { + const names = [...arg.matchAll(/(?:where:\s*|\.\.\.)(\w+)/g)].map( + (m) => m[1], + ); + if (/(?:^|[{,])\s*where\s*[,}]/.test(arg)) names.push("where"); + return names.some((name) => { + // The nearest declaration above the read is the one it uses. + const decl = new RegExp( + `(?:const|let)\\s+${name}\\b[^=]*=\\s*\\{([\\s\\S]{0,600}?)\\};`, + "g", + ); + const before = [...code.slice(0, at).matchAll(decl)].at(-1); + return before !== undefined && /\bdeletedAt\b/.test(before[1]); + }); +} + +export function unfilteredReads( + code: string, + models: string[], +): { model: string; arg: string }[] { + const re = new RegExp( + `\\.(${models.join("|")})\\s*\\.\\s*(findMany|findFirst|count|groupBy|aggregate)\\s*\\(`, + "g", + ); + const out: { model: string; arg: string }[] = []; + for (const m of code.matchAll(re)) { + let i = m.index + m[0].length; + let depth = 1; + while (depth > 0 && i < code.length) { + if (code[i] === "(") depth += 1; + else if (code[i] === ")") depth -= 1; + i += 1; + } + const arg = code.slice(m.index + m[0].length, i - 1); + if (/\bdeletedAt\b/.test(arg)) continue; + if (filtersThroughLiveObject(code, arg, m.index)) continue; + out.push({ model: m[1], arg: arg.replace(/\s+/g, " ").slice(0, 120) }); + } + return out; +} + +describe("analysis reads skip deleted rows", () => { + const models = tombstonedModels(); + + it("derives the soft-deleting models from the schema", () => { + expect(models.length).toBeGreaterThanOrEqual(20); + expect(models).toContain("moodEntry"); + expect(models).toContain("measurement"); + }); + + it("the matcher fires on an unfiltered read and passes a filtered one", () => { + const planted = `const rows = await prisma.moodEntry + .findMany({ where: { userId }, take: 90 });`; + expect(unfilteredReads(planted, models)).toHaveLength(1); + expect( + unfilteredReads( + "await prisma.moodEntry.findMany({ where: { userId, deletedAt: null } });", + models, + ), + ).toEqual([]); + const viaLocal = `const where = { userId, deletedAt: null }; + await prisma.measurement.count({ where });`; + expect(unfilteredReads(viaLocal, models)).toEqual([]); + const viaLocalWithout = `const where = { userId }; + await prisma.measurement.count({ where });`; + expect(unfilteredReads(viaLocalWithout, models)).toHaveLength(1); + }); + + it("no read in scope includes deleted rows outside the list", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }).filter( + (file) => + SCOPES.some((scope) => file.startsWith(scope)) && + !file.includes("__tests__/") && + !/\.test\.tsx?$/.test(file), + ); + expect(files.length).toBeGreaterThan(400); + + let reads = 0; + const readRe = new RegExp(`\\.(${models.join("|")})\\s*\\.\\s*find`, "g"); + const offenders: string[] = []; + for (const file of files) { + const code = stripComments(readFileSync(join(SRC, file), "utf8")); + reads += [...code.matchAll(readRe)].length; + for (const hit of unfilteredReads(code, models)) { + const key = `${hit.model}@${file}`; + if (!(key in ALLOWED)) offenders.push(`${key}: ${hit.arg}`); + } + } + // The scope reads these models in well over a hundred places; a sweep + // that finds a handful read the wrong tree. + expect(reads).toBeGreaterThan(100); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 3d4bd5a0e..470c6362a 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -245,7 +245,11 @@ export const GET = apiHandler(async () => { let hasSdnn = true; if (!recoveryEnabled) { const sdnnRow = await prisma.measurement.findFirst({ - where: { userId: user.id, type: "HEART_RATE_VARIABILITY" }, + where: { + userId: user.id, + type: "HEART_RATE_VARIABILITY", + deletedAt: null, + }, select: { id: true }, }); hasSdnn = sdnnRow !== null; diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts index 1a0cf88c6..b3eddcdb2 100644 --- a/src/app/api/insights/breathing-screening/route.ts +++ b/src/app/api/insights/breathing-screening/route.ts @@ -48,6 +48,7 @@ export const GET = apiHandler(async () => { const rows = await prisma.measurement.findMany({ where: { userId: user.id, + deletedAt: null, type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] }, measuredAt: { gte: since }, }, diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 463073a7e..2063bbed6 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { readRecentMoodScores } from "@/lib/mood/recent-scores"; import { getBloodPressureSystemPrompt, getBloodPressureUserPrompt, @@ -238,6 +239,7 @@ export async function prepareBloodPressureStatusForUser( .findMany({ where: { userId, + deletedAt: null, type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], }, @@ -457,14 +459,7 @@ export async function prepareBloodPressureStatusForUser( // Fetch mood context (optional — for enrichment only). v1.4.28 // Cap at 90 entries (~3 months) so power users don't pull // unbounded rows before the bucket-series budget runs. - const moodEntries = await prisma.moodEntry - .findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "desc" }, - take: 90, - select: { date: true, score: true, moodLoggedAt: true }, - }) - .then((rows) => rows.reverse()); + const moodEntries = await readRecentMoodScores(userId, 90); const moodSeries = applyPayloadBudget( moodEntries.map((entry) => ({ diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 7e5230b54..0c69dfd90 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { readRecentMoodScores } from "@/lib/mood/recent-scores"; import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt, @@ -309,14 +310,7 @@ export async function prepareGeneralStatusForUser( // Fetch mood context (optional — for enrichment only). v1.4.28 // Cap at 90 entries. - const moodEntries = await prisma.moodEntry - .findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "desc" }, - take: 90, - select: { date: true, score: true, moodLoggedAt: true }, - }) - .then((rows) => rows.reverse()); + const moodEntries = await readRecentMoodScores(userId, 90); const moodRecords = moodEntries.map((entry) => ({ measuredAt: entry.moodLoggedAt, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 7cc878483..e19388e09 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -241,6 +241,7 @@ export async function prepareMoodStatusForUser( .findMany({ where: { userId, + deletedAt: null, }, orderBy: { date: "desc" }, take: 90, diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 9262f3fba..2cd31d862 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { readRecentMoodScores } from "@/lib/mood/recent-scores"; import { getPulseSystemPrompt, getPulseUserPrompt, @@ -252,14 +253,7 @@ export async function preparePulseStatusForUser( // Fetch mood context (optional — for enrichment only). Cap at 90 // entries (~3 months); the bucket-series budget then summarises // further. v1.4.28 FB-D2 — prevent unbounded reads for power users. - const moodEntries = await prisma.moodEntry - .findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "desc" }, - take: 90, - select: { date: true, score: true, moodLoggedAt: true }, - }) - .then((rows) => rows.reverse()); + const moodEntries = await readRecentMoodScores(userId, 90); const moodPoints = moodEntries.map((entry) => ({ measuredAt: entry.moodLoggedAt, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 053ba36e4..c5ec126e2 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { readRecentMoodScores } from "@/lib/mood/recent-scores"; import { getWeightSystemPrompt, getWeightUserPrompt, @@ -356,14 +357,7 @@ export async function prepareWeightStatusForUser( // Fetch mood context (optional — for enrichment only). v1.4.28 // Cap at 90 entries. - const moodEntries = await prisma.moodEntry - .findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "desc" }, - take: 90, - select: { date: true, score: true, moodLoggedAt: true }, - }) - .then((rows) => rows.reverse()); + const moodEntries = await readRecentMoodScores(userId, 90); const moodSeries = applyPayloadBudget( moodEntries.map((entry) => ({ diff --git a/src/lib/mood/__tests__/recent-scores.test.ts b/src/lib/mood/__tests__/recent-scores.test.ts new file mode 100644 index 000000000..9cd2080a3 --- /dev/null +++ b/src/lib/mood/__tests__/recent-scores.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +const { moodEntry } = vi.hoisted(() => ({ + moodEntry: { findMany: vi.fn() }, +})); + +vi.mock("@/lib/db", () => ({ prisma: { moodEntry } })); + +import { readRecentMoodScores } from "../recent-scores"; + +describe("readRecentMoodScores", () => { + it("reads live entries only, newest first, and hands them back oldest first", async () => { + const newer = { + date: "2026-10-02", + score: 4, + moodLoggedAt: new Date("2026-10-02T08:00:00Z"), + }; + const older = { + date: "2026-10-01", + score: 2, + moodLoggedAt: new Date("2026-10-01T08:00:00Z"), + }; + moodEntry.findMany.mockResolvedValueOnce([newer, older]); + + const rows = await readRecentMoodScores("u1", 30); + + expect(moodEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "u1", deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + take: 30, + }), + ); + expect(rows).toEqual([older, newer]); + }); +}); diff --git a/src/lib/mood/recent-scores.ts b/src/lib/mood/recent-scores.ts new file mode 100644 index 000000000..13c757506 --- /dev/null +++ b/src/lib/mood/recent-scores.ts @@ -0,0 +1,27 @@ +/** + * The recent-mood context read shared by the per-metric status snapshots + * (pulse, weight, blood pressure, general). A deleted entry is a tombstone + * kept for sync and undo; it must never reach a model prompt or a summary, + * so the filter lives here rather than at each call site. + */ +import { prisma } from "@/lib/db"; + +export interface RecentMoodScore { + date: string; + score: number; + moodLoggedAt: Date; +} + +/** Most recent `take` live entries, returned oldest first. */ +export async function readRecentMoodScores( + userId: string, + take = 90, +): Promise { + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + take, + select: { date: true, score: true, moodLoggedAt: true }, + }); + return rows.reverse(); +}