Merge branch 'main' into release/v1.30.28

# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 05:32:41 +02:00
commit 5c2bee79d8
11 files changed
+377 -27

No files matched your search

+8
View File
@@ -9,6 +9,14 @@ The daily briefing counts its spend too.
- **Generating the daily briefing did not count against any budget.** It is the one remaining path that a sync can set off on its own: new sleep data arriving in the morning triggers a fresh briefing, and each attempt — including its correction passes — could reach a provider without being recorded. All of them now reserve before and settle after, against the same ceiling as everything else, so a self-hoster on their own key is measured against their own limit.
- Reaching the ceiling now skips the run rather than reporting a failure, because a failure schedules a retry — and retrying against a limit that does not move until the next day would loop.
## [1.30.27] — 2026-07-19
Three quiet miscounts.
- **The resting heart-rate series stopped at the point where older readings get condensed.** Once a day's raw readings are condensed, its resting figure is kept as a derived value — but the reading side treated the presence of any such value as "this account reports resting directly" and stopped estimating for the recent days that have not been condensed yet. The series now uses the stored value for days that have one and the estimate for days that do not. Where any part is estimated it is still labelled as an estimate: a partly-estimated series called "resting heart rate" would be wrong about those days, while calling a partly-stored one an estimate is merely cautious.
- **Recomputing summaries from a point in the middle of a day or month replaced the whole period with a partial figure.** The recompute now starts at the beginning of the period it touches. Existing summaries recompute when that day is written to again or on the next scheduled pass.
- **Two entries for the same daily total in one upload silently kept the older one.** The newer value now wins, matching how a repeat upload behaves.
No breaking changes.
## [1.30.26] — 2026-07-19
+4
View File
@@ -36,7 +36,11 @@ 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__) ||
<<<<<<< HEAD
/* @sw-version-fallback */ "v1.30.28";
=======
/* @sw-version-fallback */ "v1.30.27";
>>>>>>> main
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/*`
@@ -409,3 +409,89 @@ describe("POST /api/measurements/batch — stats tombstone resurrection", () =>
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
});
});
/**
* Two entries carrying the SAME `stats:` key inside ONE batch. Neither is in
* the DB yet, so before the fix both missed the existing-row probe, both
* landed in the bulk insert, and `skipDuplicates` kept whichever the unique
* index saw first — the OLDER snapshot won and the newer, larger figure was
* dropped silently while both entries still reported `inserted`.
*
* `stats:` rows are overwrite-by-contract, so the LAST entry for a key wins.
*/
describe("POST /api/measurements/batch — intra-batch `stats:` supersession", () => {
it("keeps only the LAST entry when one batch carries the same stats: key twice", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(HR_BUCKET_ID, 70), // earlier snapshot of a filling bucket
hrBucketEntry(HR_BUCKET_ID, 78), // later, authoritative snapshot
],
}),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
// Exactly ONE row reaches the insert, carrying the NEWER value.
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const rows = (createArg as { data: { value: number }[] }).data;
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(78);
// The superseded entry is reported honestly, not as a phantom insert.
expect(data.entries[0].status).toBe("duplicate");
expect(data.entries[0].reason).toBe("superseded_in_batch");
expect(data.entries[1].status).toBe("inserted");
expect(data.inserted).toBe(1);
expect(data.duplicates).toBe(1);
});
it("overwrites an existing row exactly once, with the LAST value in the batch", async () => {
// The key is already stored, so both entries take the overwrite branch.
// Only the authoritative last snapshot may reach the DB.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
] as never);
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(HR_BUCKET_ID, 70),
hrBucketEntry(HR_BUCKET_ID, 78),
],
}),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
expect(data.entries[0].status).toBe("duplicate");
expect(data.entries[0].reason).toBe("superseded_in_batch");
expect(data.entries[1].status).toBe("updated");
expect(data.updated).toBe(1);
});
it("leaves DISTINCT stats: keys in one batch untouched", async () => {
const OTHER =
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:10:00.000Z";
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 2 });
const res = await POST(
makeRequest({
entries: [hrBucketEntry(HR_BUCKET_ID, 70), hrBucketEntry(OTHER, 78)],
}),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
expect((createArg as { data: unknown[] }).data).toHaveLength(2);
expect(data.inserted).toBe(2);
expect(data.duplicates).toBe(0);
});
});
+35
View File
@@ -480,10 +480,45 @@ async function postBatch(request: NextRequest): Promise<Response> {
return false;
}
// Intra-batch `stats:` supersession. Two entries carrying the same
// (type, source, externalId) `stats:` key inside ONE batch both miss
// `existingSet` — neither is in the DB yet — so the overwrite branch
// never runs, both land in `toInsert`, and
// `createMany({ skipDuplicates: true })` keeps whichever row the unique
// index saw first. The OLDER snapshot of a per-day cumulative total (or
// of a still-filling ten-minute heart-rate bucket) therefore won and the
// newer, larger figure was dropped silently: both entries reported
// `inserted`, the counters still summed, and the client checkpointed
// past both. `stats:` rows carry overwrite semantics by contract — a
// re-post replaces value / unit / measuredAt — so within one batch the
// LAST entry for a key is the authoritative one. Resolve it here and
// mark the superseded earlier entries `duplicate`, the same status the
// client already checkpoints past for a composite-key duplicate.
const lastStatsIndexByKey = new Map<string, number>();
for (const p of prepared) {
if (!isStatsExternalId(p.row.externalId as string)) continue;
lastStatsIndexByKey.set(
`${p.row.type}::${p.row.source}::${p.row.externalId}`,
p.index,
);
}
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
if (
isStatsExternalId(p.row.externalId as string) &&
lastStatsIndexByKey.get(key) !== p.index
) {
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount += 1;
continue;
}
if (existingSet.has(key)) {
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
// intentionally overwritten on a re-post so today's tile reflects
@@ -187,6 +187,79 @@ describe("resolveRestingPulseSeries", () => {
resolveRestingPulseSeries({ restingSamples: [], pulseSamples: [] }),
).toEqual({ series: [], which: "none" });
});
/**
* The shape a proxy account actually has once the retention fold has run
* for a while: derived RESTING_HEART_RATE rows for the OLD days the fold
* has already processed, and nothing but raw PULSE for the RECENT days it
* has not reached yet. The all-or-nothing resolver saw the derived rows,
* took the resting branch, and dropped the proxy — so the series the user
* saw stopped dead at the fold horizon.
*/
it("carries the series past the fold horizon — derived rows for old days, PULSE for recent ones", () => {
// Days 01-05: folded, one derived resting row each. No PULSE left —
// the fold tombstoned the raw readings it derived them from.
const restingSamples: PulseSample[] = [
day("2026-06-01T04:00:00", 61),
day("2026-06-02T04:00:00", 62),
day("2026-06-03T04:00:00", 63),
day("2026-06-04T04:00:00", 64),
day("2026-06-05T04:00:00", 65),
];
// Days 06-08: not yet folded. Raw PULSE only, no resting row at all.
const pulseSamples: PulseSample[] = [];
for (const d of ["06", "07", "08"]) {
pulseSamples.push(
day(`2026-06-${d}T07:00:00`, 66),
day(`2026-06-${d}T07:10:00`, 68),
day(`2026-06-${d}T07:20:00`, 70),
day(`2026-06-${d}T18:00:00`, 155), // workout burst, must not win
);
}
const { series, which } = resolveRestingPulseSeries({
restingSamples,
pulseSamples,
});
// Five folded days + three proxy days — the recent days MUST appear.
expect(series).toHaveLength(8);
const days = series.map((p) =>
p.measuredAt.toLocaleDateString("en-CA", { timeZone: PROXY_DAY_ZONE }),
);
expect(days).toEqual([
"2026-06-01",
"2026-06-02",
"2026-06-03",
"2026-06-04",
"2026-06-05",
"2026-06-06",
"2026-06-07",
"2026-06-08",
]);
// The proxy days track the resting floor, not the workout burst.
for (const point of series.slice(5)) {
expect(point.value).toBeLessThan(100);
}
// Mixed series — hedge the label rather than call an estimate native.
expect(which).toBe("proxy");
});
it("keeps the resting row on a day that has both a resting row and PULSE", () => {
// A native day must not be second-guessed by the proxy, and a native
// account with no gap days keeps the honest 'resting' label.
const { series, which } = resolveRestingPulseSeries({
restingSamples: [day("2026-06-01T04:00:00", 58)],
pulseSamples: [
day("2026-06-01T07:00:00", 80),
day("2026-06-01T07:10:00", 82),
day("2026-06-01T07:20:00", 84),
],
});
expect(series).toHaveLength(1);
expect(series[0].value).toBe(58);
expect(which).toBe("resting");
});
});
describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", () => {
+21 -2
View File
@@ -68,6 +68,13 @@ export interface IntradayPulseResult {
resolution: IntradayResolution;
}
/**
* How old the newest RESTING_HEART_RATE row may be before the baseline stops
* trusting the resting rows alone and pulls the PULSE history in as well. Two
* days tolerates a single missed night without widening the read.
*/
const RESTING_STALE_AFTER_MS = 2 * 24 * 60 * 60 * 1000;
/** Median (p50), or null on an empty series. */
function median(values: number[]): number | null {
if (values.length === 0) return null;
@@ -103,9 +110,21 @@ async function resolveBaseline(
select: { value: true, measuredAt: true },
});
// Only reach for the heavier PULSE-history read when resting rows are thin.
// Only reach for the heavier PULSE-history read when the resting rows
// cannot carry the baseline on their own: too few of them, OR the newest
// one is already stale. Staleness matters because the retention fold mints
// derived resting rows only for the days it has ALREADY folded, so a proxy
// account accumulates plenty of resting rows for OLD days while the RECENT
// days still hold nothing but raw PULSE. Gating on the row count alone
// pinned the baseline to the fold horizon and never advanced it past it.
// A genuinely native account reports a resting row daily, never trips the
// staleness arm, and pays for no extra read.
const newestRestingAt = restingRows[0]?.measuredAt ?? null;
const restingIsStale =
newestRestingAt === null ||
Date.now() - newestRestingAt.getTime() > RESTING_STALE_AFTER_MS;
const pulseHistory =
restingRows.length >= MIN_BASELINE_DAYS
restingRows.length >= MIN_BASELINE_DAYS && !restingIsStale
? []
: await prisma.measurement.findMany({
where: { userId, type: "PULSE", deletedAt: null },
+44 -15
View File
@@ -19,10 +19,12 @@
*
* The fix: judge a RESTING band against a RESTING series.
*
* 1. **Prefer `RESTING_HEART_RATE`** when the user has it (Apple
* 1. **Prefer `RESTING_HEART_RATE`** on every day that has one (Apple
* already separated the clean signal for us).
* 2. **Fallback proxy from `PULSE`** when no `RESTING_HEART_RATE`
* exists (manual-only users, older imports): take a robust
* 2. **Fallback proxy from `PULSE`** on the days that have no
* `RESTING_HEART_RATE` (manual-only users, older imports, and the
* recent days of an account whose derived rows only reach as far
* as the retention fold has run): take a robust
* low-percentile of each calendar day's PULSE samples — the resting
* heart rate is the floor of the day, not its average, so the daily
* 20th percentile estimates resting while excluding the workout
@@ -112,11 +114,27 @@ export function deriveRestingProxyFromPulse(
/**
* Resolve the resting-pulse series a resting-target surface should
* score. Prefers the clean `RESTING_HEART_RATE` rows; falls back to the
* low-percentile PULSE proxy when the user has no resting rows.
* score. Merges PER DAY: a day that has a `RESTING_HEART_RATE` row is
* represented by that row; a day that has none falls back to the
* low-percentile PULSE proxy.
*
* `which` reports the branch taken so callers can annotate / label
* honestly ("resting heart rate" vs "estimated from heart rate").
* v1.30.16 made the dense-intraday retention fold mint derived
* `RESTING_HEART_RATE` rows (source `COMPUTED`) for the days it folds.
* A proxy account therefore now carries derived rows for OLD folded
* days and nothing but raw `PULSE` for RECENT not-yet-folded days. The
* previous all-or-nothing shape returned the resting branch the moment
* a single resting row existed and never consulted the proxy again, so
* the visible series stopped dead at the fold horizon. Merging per day
* keeps the clean signal where it exists and carries the series
* forward across the days that only have the proxy.
*
* `which` reports how to LABEL the series, and it is deliberately
* conservative: `"resting"` only when EVERY point came from a real
* resting row, `"proxy"` as soon as one point is proxy-derived. The two
* error directions are not symmetric — calling a partly-estimated
* series "resting heart rate" is a false statement about the estimated
* days, whereas calling a partly-native series "estimated from heart
* rate" merely hedges. A labelling signal should hedge, never lie.
*/
export function resolveRestingPulseSeries(input: {
restingSamples: ReadonlyArray<PulseSample>;
@@ -124,13 +142,24 @@ export function resolveRestingPulseSeries(input: {
/** Optional day-key for the proxy buckets (defaults to Berlin-day). */
dayKeyOf?: (d: Date) => string;
}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } {
if (input.restingSamples.length > 0) {
const series = [...input.restingSamples].sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
);
return { series, which: "resting" };
const dayKeyOf = input.dayKeyOf ?? toBerlinDayKey;
const byTime = (a: PulseSample, b: PulseSample) =>
a.measuredAt.getTime() - b.measuredAt.getTime();
const resting = [...input.restingSamples].sort(byTime);
// 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)));
if (resting.length === 0) {
return proxy.length > 0
? { series: proxy, which: "proxy" }
: { series: [], which: "none" };
}
const proxy = deriveRestingProxyFromPulse(input.pulseSamples, input.dayKeyOf);
if (proxy.length > 0) return { series: proxy, which: "proxy" };
return { series: [], which: "none" };
if (proxy.length === 0) return { series: resting, which: "resting" };
return { series: [...resting, ...proxy].sort(byTime), which: "proxy" };
}
@@ -124,9 +124,18 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
measuredAt: new Date(now.getTime() - (i % 30) * dayMs),
});
}
// Clean resting series, comfortably inside a 60-100 band.
// Clean resting series, comfortably inside a 60-100 band, covering the
// SAME 30-day span as the PULSE above. The span has to match for the
// fixture to mean what the test name claims: the resolver merges per
// day, so a day with no resting row of its own is estimated from that
// day's PULSE rather than dropped. Leaving ten of the thirty days
// resting-free made this assert the merge's gap-fill behaviour by
// accident — on days whose only readings are a 150 bpm workout, the
// honest estimate IS out of band. Gap-day behaviour is pinned directly
// in `resting-pulse.test.ts`; this case is about preferring the clean
// signal over workout HR where both exist.
const restingRecords: Array<{ value: number; measuredAt: Date }> = [];
for (let d = 0; d < 20; d++) {
for (let d = 0; d < 30; d++) {
restingRecords.push({
value: 72,
measuredAt: new Date(now.getTime() - d * dayMs),
@@ -423,12 +423,15 @@ export async function runDenseIntradayRetention(
};
// iOS#34 — per-user memo of whether the user has ANY native
// `RESTING_HEART_RATE` row. The read-path resolver
// (`resolveRestingPulseSeries`) is all-or-nothing: a single native
// resting row makes it ignore the PULSE proxy entirely. So the derived
// resting row is minted ONLY for proxy users (zero native rows) — minting
// alongside native rows would double-count. Memoised so the per-day fold
// does not re-query, and cleared per user at the start of the walk.
// `RESTING_HEART_RATE` row. The derived resting row is minted ONLY for
// proxy users (zero native rows): a native account already reports the
// clean signal for those days, so a derived row would add nothing and
// would let a fold artefact outrank a device reading. The read-path
// resolver (`resolveRestingPulseSeries`) merges resting and proxy PER DAY
// and prefers a resting row wherever one exists, so the mint's job is
// narrow — preserve the day's figure before the fold tombstones the raw
// readings it was derived from. Memoised so the per-day fold does not
// re-query, and cleared per user at the start of the walk.
const nativeRestingByUser = new Map<string, boolean>();
async function userHasNativeResting(userId: string): Promise<boolean> {
const cached = nativeRestingByUser.get(userId);
@@ -487,6 +487,70 @@ describe("persistRollupRows — large path (via recomputeUserRollups)", () => {
expect(deleteMany).toHaveBeenCalledTimes(1);
});
/**
* The aggregate filters measurements by `measured_at` but GROUPS them by
* `date_trunc`, and `persistRollupRows` then deletes every rollup row in
* the bucket range it writes. A `from` that lands mid-bucket therefore
* replaced a COMPLETE bucket with an aggregate built only from the
* readings after that instant — and because every caller passes a moving
* instant, a different oldest bucket was corrupted on each run and nothing
* repaired it. The window must be snapped out to whole buckets first.
*/
describe("bucket alignment of the recompute window", () => {
it("snaps a mid-day `from` down to the start of its DAY bucket", async () => {
queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1));
txExecuteRawUnsafe.mockResolvedValueOnce(1);
// 14:37:12Z on 2024-03-10 — squarely inside a day bucket.
const midDay = new Date(Date.UTC(2024, 2, 10, 14, 37, 12, 500));
await recomputeUserRollups("user-1", {
granularities: ["DAY"],
from: midDay,
to: new Date(Date.UTC(2024, 2, 20, 9, 5, 0)),
});
// runRollupAggregate → $queryRawUnsafe(sql, userId, from, to)
const [, userId, fromArg, toArg] = queryRawUnsafe.mock.calls[0];
expect(userId).toBe("user-1");
// Whole-bucket lower edge — the morning of the 10th must be included.
expect((fromArg as Date).toISOString()).toBe("2024-03-10T00:00:00.000Z");
// Upper edge snaps out to the end of the closing bucket.
expect((toArg as Date).toISOString()).toBe("2024-03-21T00:00:00.000Z");
});
it("snaps a mid-month `from` back to the 1st for MONTH granularity", async () => {
queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1));
txExecuteRawUnsafe.mockResolvedValueOnce(1);
await recomputeUserRollups("user-1", {
granularities: ["MONTH"],
from: new Date(Date.UTC(2024, 4, 17, 8, 0, 0)),
to: new Date(Date.UTC(2024, 6, 3, 0, 0, 0)),
});
const [, , fromArg, toArg] = queryRawUnsafe.mock.calls[0];
expect((fromArg as Date).toISOString()).toBe("2024-05-01T00:00:00.000Z");
expect((toArg as Date).toISOString()).toBe("2024-08-01T00:00:00.000Z");
});
it("leaves an already-aligned window untouched", async () => {
queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1));
txExecuteRawUnsafe.mockResolvedValueOnce(1);
const from = new Date(Date.UTC(2024, 2, 10));
const to = new Date(Date.UTC(2024, 2, 20));
await recomputeUserRollups("user-1", {
granularities: ["DAY"],
from,
to,
});
const [, , fromArg, toArg] = queryRawUnsafe.mock.calls[0];
expect((fromArg as Date).getTime()).toBe(from.getTime());
expect((toArg as Date).getTime()).toBe(to.getTime());
});
});
it("the ≤500-row path runs through the same interactive upsert transaction", async () => {
queryRawUnsafe.mockResolvedValueOnce(syntheticRows(2));
txExecuteRawUnsafe.mockResolvedValueOnce(2);
+22 -2
View File
@@ -190,12 +190,32 @@ export async function recomputeUserRollups(
let rowsUpserted = 0;
for (const granularity of granularities) {
// Snap the window out to whole buckets before aggregating. The
// aggregate filters rows by `measured_at` but GROUPS by
// `date_trunc(<granularity>, measured_at)`, and `persistRollupRows`
// then deletes every rollup row in the [min, max] bucket range it is
// about to write. A `from` that lands mid-bucket therefore replaced a
// COMPLETE bucket with an aggregate computed only from the readings
// after that instant — and because the callers pass a moving instant
// (`Date.now() - 90d` on the read-path refresh, an arbitrary
// client-supplied instant on the coverage fallback, `now - 5y` on the
// boot backfill and the restore path), a different oldest day was
// corrupted on every invocation and nothing ever repaired it. Same
// hazard on the `to` edge for any row measured later in the closing
// bucket. `bucketSpan` is the canonical truncator — its own contract
// note already required every recompute to cover whole spans; this is
// the caller that did not.
const alignedFrom = bucketSpan(from, granularity).from;
const alignedTo =
to > alignedFrom
? bucketSpan(new Date(to.getTime() - 1), granularity).to
: bucketSpan(from, granularity).to;
const rows = await runRollupAggregate({
userId,
types: opts.types,
granularity,
from,
to,
from: alignedFrom,
to: alignedTo,
});
if (rows.length === 0) continue;