From 20ff0c1868038749819e7ec3dd91acca3e364b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 00:13:54 +0200 Subject: [PATCH] fix(measurements): stop the derived resting mint from suppressing itself The dense-intraday fold tombstones the raw heart-rate readings a proxy account's resting figure is derived from, and mints one derived RESTING_HEART_RATE row per folded day to carry the figure forward. The mint is skipped for accounts that already hold native resting rows, because the read resolver ignores the proxy for them. The probe behind that skip matched every source, including the COMPUTED rows the mint itself writes. From the second run onward it therefore reported native resting data for every proxy account, skipped the mint, and let the fold delete that day's raw readings anyway - losing the resting figure permanently, once per run. Scope the probe to non-computed sources. Adds a regression test that reproduces the second-run state and fails on the previous code. chore(release): v1.30.16 --- CHANGELOG.md | 9 + docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../dense-intraday-resting-mint.test.ts | 161 ++++++++++++++++++ .../measurements/dense-intraday-retention.ts | 15 +- 6 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b13ebb8d1..76bea5ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.30.16] — 2026-07-19 + +Stops an ongoing loss of resting heart-rate history. + +- **Resting heart rate is preserved again for accounts without a device-supplied resting figure.** Older heart-rate readings are condensed on a schedule to keep the database small, and a resting value is derived and stored before the raw readings go. The check that decides whether an account already has its own resting data was matching the derived values it had written itself, so from the second run onward it concluded the data was already there and stopped deriving — while the raw readings it derives from were still being cleared. Each run lost another day's resting figure permanently. The check now looks only at readings the account actually supplied. +- Days already affected cannot be reconstructed; from this release forward, nothing further is lost. + +No breaking changes. + ## [1.30.15] — 2026-07-18 Generated health texts now come in the language you set. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c989d9dc7..bcbe4e279 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.15 + version: 1.30.16 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 0965332cf..788a1f387 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.15", + "version": "1.30.16", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 59c89043e..abcee3e04 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.15"; + /* @sw-version-fallback */ "v1.30.16"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts b/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts new file mode 100644 index 000000000..92d88f07b --- /dev/null +++ b/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts @@ -0,0 +1,161 @@ +/** + * The derived-resting mint must survive its own previous runs. + * + * The fold deletes the raw PULSE samples a proxy user's resting figure is + * derived from, and mints one `RESTING_HEART_RATE` / `COMPUTED` row per folded + * day to carry that figure forward. The mint is skipped for users who already + * have NATIVE resting rows, because the read resolver ignores the proxy for + * them and a derived row would double-count. + * + * The probe that answers "does this user have native resting data?" used to + * match every source — including the `COMPUTED` rows the mint itself had + * written. So from the second nightly run onward it answered yes for every + * proxy user, the mint stopped, and the fold kept tombstoning the raw readings + * anyway: each run destroyed another day of resting history that cannot be + * recomputed from anything still in the database. + * + * These tests fail on that code. The probe mock filters like the database + * does, so the assertion is on behaviour (does the row get minted) rather than + * on the shape of the query. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { recomputeBucketsForMeasurement } = vi.hoisted(() => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement, +})); + +import { + runDenseIntradayRetention, + DENSE_INTRADAY_RETENTION_DAYS, +} from "../dense-intraday-retention"; +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; + +/** A day well outside the retention window, so the fold definitely runs. */ +const FOLD_DAY = new Date( + Date.now() - (DENSE_INTRADAY_RETENTION_DAYS + 10) * 24 * 60 * 60 * 1000, +); + +/** Enough spread that the 20th-percentile resting derivation yields a value. */ +function pulseRows() { + const base = new Date(FOLD_DAY); + base.setUTCHours(9, 0, 0, 0); + return [58, 61, 64, 70, 88, 132, 141].map((value, i) => ({ + id: `p${i}`, + type: "PULSE" as MeasurementType, + value, + measuredAt: new Date(base.getTime() + i * 5 * 60 * 1000), + externalId: `uuid-${i}`, + unit: "bpm", + })); +} + +/** + * @param existingResting rows the probe reads, each with the source the row + * was actually written under. The mock applies the query's `source` filter + * the way Postgres would, so a probe that omits the filter sees them all. + */ +function buildPrismaMock(existingResting: Array<{ source: string }>) { + const upsert = vi.fn().mockResolvedValue({ id: "minted-resting" }); + const create = vi.fn().mockResolvedValue({ id: "minted-hourly" }); + const update = vi.fn().mockResolvedValue({}); + const updateMany = vi.fn().mockResolvedValue({ count: 0 }); + const txFindFirst = vi.fn().mockResolvedValue(null); + + // The probe. Honours a `source: { not: X }` clause exactly like the DB. + const probeFindFirst = vi.fn( + async (args: { where: { source?: { not?: string } } }) => { + const excluded = args.where?.source?.not; + const match = existingResting.find((r) => + excluded === undefined ? true : r.source !== excluded, + ); + return match ? { id: "native-resting" } : null; + }, + ); + + const tx = { + measurement: { create, update, findFirst: txFindFirst, updateMany, upsert }, + }; + + return { + mock: { + user: { + findMany: vi + .fn() + .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]), + }, + measurement: { + findMany: vi.fn(async (args: { where: { type: string } }) => + args.where.type === "PULSE" ? pulseRows() : [], + ), + findFirst: probeFindFirst, + upsert, + }, + $transaction: vi.fn(async (cb: (t: unknown) => Promise) => + cb(tx), + ), + } as unknown as PrismaClient, + upsert, + probeFindFirst, + }; +} + +function mintedRestingCalls(upsert: ReturnType) { + return upsert.mock.calls.filter( + (c) => + (c[0] as { create?: { type?: string } })?.create?.type === + "RESTING_HEART_RATE", + ); +} + +beforeEach(() => { + recomputeBucketsForMeasurement.mockClear(); +}); + +describe("dense-intraday retention — derived resting mint", () => { + it("still mints when the only resting rows are its OWN computed output", async () => { + // The state after run 1: one COMPUTED row exists, no native data. + const { mock, upsert } = buildPrismaMock([{ source: "COMPUTED" }]); + + await runDenseIntradayRetention(mock); + + // Run 2 must still mint. Before the fix the probe matched the COMPUTED + // row, concluded "user has native resting", and skipped the mint - while + // the fold deleted that day's raw PULSE regardless. + expect(mintedRestingCalls(upsert).length).toBeGreaterThan(0); + }); + + it("skips the mint when the user genuinely has native resting rows", async () => { + const { mock, upsert } = buildPrismaMock([{ source: "APPLE_HEALTH" }]); + + await runDenseIntradayRetention(mock); + + // The read resolver ignores the proxy for these users, so a derived row + // would double-count. This is the behaviour the probe is FOR. + expect(mintedRestingCalls(upsert)).toHaveLength(0); + }); + + it("mints for a user with no resting rows at all", async () => { + const { mock, upsert } = buildPrismaMock([]); + + await runDenseIntradayRetention(mock); + + expect(mintedRestingCalls(upsert).length).toBeGreaterThan(0); + }); + + it("asks the database to exclude computed rows", async () => { + // Belt and braces: the behavioural tests above rely on the mock honouring + // the filter, so pin that the query actually carries it. + const { mock, probeFindFirst } = buildPrismaMock([]); + + await runDenseIntradayRetention(mock); + + expect(probeFindFirst).toHaveBeenCalled(); + const where = probeFindFirst.mock.calls[0][0].where as { + source?: { not?: string }; + }; + expect(where.source).toEqual({ not: "COMPUTED" }); + }); +}); diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index 47e083eac..2bc1a9455 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -434,7 +434,20 @@ export async function runDenseIntradayRetention( const cached = nativeRestingByUser.get(userId); if (cached !== undefined) return cached; const native = await prismaClient.measurement.findFirst({ - where: { userId, type: "RESTING_HEART_RATE", deletedAt: null }, + // `source` MUST be excluded here. The mint below writes its derived row + // as `RESTING_HEART_RATE` / `COMPUTED`, so a probe that matched every + // source found its own output on the next nightly run, concluded the + // user had native resting data, and stopped minting — while the fold + // kept tombstoning the raw readings those figures are derived from. + // Every run after the first then destroyed a day's resting history for + // good. Only a genuinely native row (device- or user-supplied) may + // suppress the mint. + where: { + userId, + type: "RESTING_HEART_RATE", + deletedAt: null, + source: { not: "COMPUTED" }, + }, select: { id: true }, }); const has = native !== null;