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
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 00:13:54 +02:00
1 parent 1920321e44
commit 20ff0c1868
6 files changed
+187 -4

No files matched your search

+9
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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<unknown>) =>
cb(tx),
),
} as unknown as PrismaClient,
upsert,
probeFindFirst,
};
}
function mintedRestingCalls(upsert: ReturnType<typeof vi.fn>) {
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" });
});
});
@@ -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;