Merge pull request #638 from MBombeck/release/v1.32.35

release/v1.32.35 — remember the practice name, keep the source provenance in a backup
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-25 15:44:30 +02:00
commit a792d4015c
29 files changed
+543 -54

No files matched your search

+28
View File
@@ -2,6 +2,34 @@
## [Unreleased]
## [1.32.35] — 2026-07-25
The doctor report stops asking for your practice name every time.
You type a clinic or practice name onto the cover of a health record export,
and until now that was all it did. The field emptied itself again the moment
you left the panel, so next month's report started from a blank line. The name
you used last time is now kept and filled in for you. Clearing the line works
the same way and is remembered too, so a cover without a practice line stays
without one. The name is saved only once the report has actually been
produced, which means an export that fails partway leaves the previous value
alone.
A backup now carries the origin of your daily totals. Steps, distance and
similar figures that arrive from Apple Health as one number per day are stored
with a note of where that number came from, so a rough estimate read out of an
archive can never overwrite a total the phone measured itself. That note was
missing from the backup file, so a restore brought the figures back without it
and the protection went quiet. It rides the backup now. Restoring an older
backup written before this fix marks the affected daily totals as unclassified
instead of leaving them ambiguous, which is the state a later sync knows how
to repair.
Migration 0272 drops two columns that were written and never read. They held a
contributor count and a source fingerprint for archive derived daily totals,
and no screen, export or repair path has ever looked at either. The figures
themselves and the origin note above are untouched.
## [1.32.34] — 2026-07-25
Housekeeping on the interface. Most of it you will not see.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.34
version: 1.32.35
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.32.34",
"version": "1.32.35",
"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",
@@ -0,0 +1,36 @@
-- v1.32.35 — drop the two write-only aggregation columns on `measurements`.
--
-- Migration 0263 added three columns alongside the Apple Health `stats:*`
-- authority ladder. Only one of them was ever read back:
--
-- * `aggregation_provenance` is load-bearing and STAYS. The external
-- measurement reconciler selects it and refuses a lower-authority write
-- against a higher-authority canonical row, which is what keeps an
-- export.xml source-day estimate from clobbering a native HealthKit
-- statistic. The cumulative drain reads it too.
-- * `aggregation_contributor_count` and `aggregation_selected_source_hash`
-- are written and never read. Three call sites write them (the batch
-- ingest and the cumulative drain write explicit NULLs, the export.xml
-- import writes real values); nothing selects them, no raw SQL names
-- them, they reach no export, no backup, no API contract and no repair
-- path. Verified by a repo-wide search over every tracked file for both
-- the Prisma field names and the column names.
--
-- The import-time selection algorithm keeps its deterministic tiebreak: the
-- source hash it compares is an in-memory map key computed per flush, not a
-- value read back from this table. Only the persisted copies go.
--
-- DATA BOUNDARY — read before editing.
--
-- Both columns are plain nullable scalars added by 0263 with no index, no
-- unique, no check and no foreign key, so `DROP COLUMN` rewrites the tuples of
-- `measurements` and stops there. `DROP COLUMN` never cascades into another
-- table on its own, and there is no CASCADE keyword here. No measured value,
-- timestamp, unit, note or identity field is touched, and the documented
-- LEGACY_UNKNOWN repair path operates on `aggregation_provenance`, which
-- survives. In Postgres this is a catalog-only operation, so it is cheap even
-- on the largest table in the schema.
ALTER TABLE "measurements"
DROP COLUMN IF EXISTS "aggregation_contributor_count",
DROP COLUMN IF EXISTS "aggregation_selected_source_hash";
+1 -7
View File
@@ -1636,13 +1636,7 @@ model Measurement {
// source-day estimate derived from export.xml; migrated ambiguous rows are
// explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs
// them.
aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance")
// Distinct source identities contributing to an export.xml source-day
// estimate. Source labels themselves are never persisted.
aggregationContributorCount Int? @map("aggregation_contributor_count")
// SHA-256 of the deterministically selected source identity. Non-identifying
// provenance for repeatability without retaining device/source labels.
aggregationSelectedSourceHash String? @map("aggregation_selected_source_hash")
aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
+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.32.34";
/* @sw-version-fallback */ "v1.32.35";
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/*`
@@ -407,6 +407,25 @@ const handler = apiHandler(
: decodeEncryptedBytes(measurement.notesEncrypted),
externalId: measurement.externalId ?? null,
externalSourceVersion: measurement.externalSourceVersion ?? null,
// Authority of an Apple Health `stats:` aggregate. A backup
// written before this field rode the payload carries nothing, and
// restoring NULL would flatten the authority ladder: an export.xml
// source-day estimate could then overwrite a native HealthKit
// statistic, which is the ambiguity migration 0263 already
// repaired once. Such a row is exactly the case LEGACY_UNKNOWN
// exists for, so it comes back repairable instead of ambiguous.
//
// The gate is scoped exactly as 0263 scoped its backfill and as
// the cumulative drain scopes its own LEGACY_UNKNOWN writes:
// Apple Health rows only. Other providers mint `stats:` ids for
// their cumulative dailies too (Fitbit, Google Health) and have
// never carried provenance — marking those would invent a state
// they were never in. Ordinary point rows keep NULL honestly.
aggregationProvenance: (measurement.aggregationProvenance ??
(measurement.source === "APPLE_HEALTH" &&
measurement.externalId?.startsWith("stats:")
? "LEGACY_UNKNOWN"
: null)) as never,
glucoseContext: (measurement.glucoseContext ?? null) as never,
sleepStage: (measurement.sleepStage ?? null) as never,
rhythmClassification: (measurement.rhythmClassification ??
@@ -249,3 +249,76 @@ describe("POST /api/export/health-record — outputs", () => {
);
});
});
/**
* The practice name is a remembered preference (`User.lastReportPracticeName`),
* so a monthly report does not ask for the clinic name again every time. The
* write happens only once the artefact exists — a generation that throws must
* not overwrite the remembered value with a half-typed one.
*
* Mutation check: drop the `rememberPracticeName` call in the route and the
* first two cases go red; move it above the generation and the
* failed-generation case goes red.
*/
describe("POST /api/export/health-record — remembered practice name", () => {
it("persists the sanitised practice name after a PDF export", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq({ format: "pdf", practiceName: " Sample Practice " }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastReportPracticeName: "Sample Practice" },
});
});
it("persists the practice name after a FHIR export", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq({ format: "fhir", practiceName: "Sample Practice" }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastReportPracticeName: "Sample Practice" },
});
});
it("persists null when the user cleared the practice name", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "pdf" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastReportPracticeName: null },
});
});
it("writes nothing when the selection is rejected", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "xml", practiceName: "Sample" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("writes nothing when the generation fails", async () => {
vi.mocked(prisma.measurement.findMany).mockRejectedValue(
new Error("read failed"),
);
const { POST } = await import("../route");
const res = await POST(
mkReq({ format: "pdf", practiceName: "Sample Practice" }),
);
expect(res.status).toBe(500);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("narrows the update to the session user, never the body", async () => {
const { POST } = await import("../route");
await POST(mkReq({ format: "fhir", practiceName: "Sample Practice" }));
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "user-1" } }),
);
});
});
+26
View File
@@ -54,6 +54,28 @@ import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* Remember the practice / clinic name for the next report, so the user does
* not retype it every time (`User.lastReportPracticeName`, the preference the
* doctor-report contract has always promised).
*
* Called only once the requested artefact has actually been produced: a
* generation that throws must not overwrite the remembered name with a
* half-typed one. `null` is a legitimate value — the user cleared the line and
* the next cover should stay cleared. The value is the already-sanitised one
* (`sanitisePracticeName`, 120-char cap), and the update names its single
* field explicitly rather than spreading the parsed selection.
*/
async function rememberPracticeName(
userId: string,
practiceName: string | null,
) {
await prisma.user.update({
where: { id: userId },
data: { lastReportPracticeName: practiceName },
});
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "export.health-record.build" } });
@@ -189,6 +211,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
fhirRecords,
);
const json = JSON.stringify(bundle);
await rememberPracticeName(user.id, practiceName);
annotate({
meta: { format: "fhir", bytes: json.length, days: range.days },
});
@@ -212,6 +235,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
includeCharts,
aiSummary,
});
// Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the
// artefact exists from here on.
await rememberPracticeName(user.id, practiceName);
if (selection.format === "pdf") {
annotate({
-2
View File
@@ -544,8 +544,6 @@ async function postBatch(request: NextRequest): Promise<Response> {
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
deviceType: p.row.deviceType as string | null,
aggregationProvenance: statsRow ? "HEALTHKIT_STATISTICS" : null,
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
},
{ exactExternalMatch: statsRow ? "update" : "duplicate" },
),
@@ -74,6 +74,7 @@ function buildUser(disableCoach: boolean): AuthUser {
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
cycleTrackingEnabled: false,
modules: {},
};
@@ -0,0 +1,97 @@
/**
* Settings → Gesundheitsakte: the practice name is remembered between reports.
*
* `User.lastReportPracticeName` is persisted by the export route after a
* successful generation and rides the `/api/auth/me` payload. This suite pins
* the read half: the panel opens pre-filled with the last-used name instead of
* asking for the clinic name again on every report.
*
* SSR-only, per the project convention for this surface (node environment,
* no `@testing-library/react`). The seed runs during render — a `useState`
* initializer would read `undefined` while `useAuth` is still resolving and
* stick at empty — so the static markup already carries the seeded value.
*
* Mutation check: revert the seeding block in the panel and the first case
* goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { AuthUser } from "@/hooks/use-auth";
const authSpy = vi.fn<() => { user: AuthUser | null }>();
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { HealthRecordExportPanel } from "../health-record-export-panel";
function buildUser(lastReportPracticeName: string | null): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName,
cycleTrackingEnabled: false,
modules: {},
} as AuthUser;
}
function renderPanel(user: AuthUser | null): string {
authSpy.mockReturnValue({ user });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<HealthRecordExportPanel />
</I18nProvider>,
);
}
function practiceInputValue(html: string): string | null {
const input = html.match(/<input[^>]*id="hr-practice"[^>]*>/)?.[0];
if (!input) return null;
return input.match(/value="([^"]*)"/)?.[1] ?? "";
}
describe("<HealthRecordExportPanel> — remembered practice name", () => {
it("pre-fills the input with the last-used practice name", () => {
const html = renderPanel(buildUser("Sample Practice"));
expect(practiceInputValue(html)).toBe("Sample Practice");
});
it("leaves the input empty when no name was ever remembered", () => {
const html = renderPanel(buildUser(null));
expect(practiceInputValue(html)).toBe("");
});
it("leaves the input empty while the auth payload is still resolving", () => {
const html = renderPanel(null);
expect(practiceInputValue(html)).toBe("");
});
});
@@ -107,6 +107,7 @@ function buildUser(
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
cycleTrackingEnabled: false,
modules,
moduleAvailability: {},
@@ -52,6 +52,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
cycleTrackingEnabled: false,
modules: {},
};
@@ -55,6 +55,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
cycleTrackingEnabled: false,
modules: {},
};
@@ -22,6 +22,7 @@
import Link from "next/link";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
Download,
@@ -41,6 +42,7 @@ import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
type ExportFormat = "pdf" | "fhir" | "package";
@@ -126,9 +128,13 @@ function buildSelectionSections(s: SectionState) {
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(90);
const [practiceName, setPracticeName] = useState("");
const [practiceNameSeededUserId, setPracticeNameSeededUserId] = useState<
string | null
>(null);
const [includeCharts, setIncludeCharts] = useState(true);
const [includeAiSummary, setIncludeAiSummary] = useState(false);
const [sections, setSections] = useState<SectionState>(DEFAULT_SECTIONS);
@@ -139,6 +145,17 @@ export function HealthRecordExportPanel() {
const [includedDataOpen, setIncludedDataOpen] = useState(false);
const includedDataPanelId = useId();
// Seed the practice name from the last export once the auth payload lands.
// `useAuth` resolves asynchronously, so a `useState` initializer would read
// `undefined` and stick at empty. Store-the-id-alongside-the-state (the same
// pattern the account form uses) syncs during render instead of in an
// effect, and the per-user gate means a late re-resolve never clobbers a
// field the user is already typing into.
if (user && user.id !== practiceNameSeededUserId) {
setPracticeNameSeededUserId(user.id);
setPracticeName(user.lastReportPracticeName ?? "");
}
const isPdfLike = format === "pdf" || format === "package";
const isFhirLike = format === "fhir" || format === "package";
@@ -199,6 +216,10 @@ export function HealthRecordExportPanel() {
a.click();
a.remove();
URL.revokeObjectURL(url);
// The route just persisted the practice name, so the cached `/me`
// payload is stale. Refresh it, or a remount of this panel would seed
// from the previous value.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
+7
View File
@@ -116,6 +116,13 @@ export interface AuthUser {
*/
insurerIkNumber: string | null;
insuranceNumber: string | null;
/**
* Practice / clinic name from the last health-record export, so the export
* panel opens pre-filled instead of asking for it again every month. The
* export route persists it after a successful generation; `null` means the
* user has never set one, or deliberately cleared the line.
*/
lastReportPracticeName: string | null;
/**
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
* + the per-user opt-in. The cycle nav entry + page hide when false; every
@@ -204,6 +204,7 @@ function installSectionMocks() {
notesEncrypted: measurementNote,
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
aggregationProvenance: "HEALTHKIT_STATISTICS",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
@@ -297,6 +298,7 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
notesEncrypted: Buffer.from(measurementNote).toString("base64"),
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
aggregationProvenance: "HEALTHKIT_STATISTICS",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
@@ -371,3 +373,72 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
).rejects.toBe(failure);
});
});
/**
* `aggregationProvenance` is what stops an export.xml source-day estimate from
* overwriting a native HealthKit statistic. A disaster-recovery backup that
* drops it restores a flattened authority ladder, so it rides the select and
* the disaster-recovery serialization the same way `externalSourceVersion`
* does — and stays out of the reduced portable shape, also like
* `externalSourceVersion`.
*
* Mutation check: drop `aggregationProvenance: true` from the select, or drop
* the field from the disaster-recovery arm, and the first two cases go red.
*/
describe("buildFullBackupPayload — aggregation provenance", () => {
it("selects the provenance column", async () => {
installSectionMocks();
const prisma = makePrisma();
await buildFullBackupPayload(prisma as never, "user-1", {
purpose: "disaster-recovery",
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
});
expect(mocks.iterateMeasurementPages).toHaveBeenCalledWith(
prisma,
expect.anything(),
expect.objectContaining({ aggregationProvenance: true }),
);
});
it("carries the provenance into the disaster-recovery payload", async () => {
installSectionMocks();
const prisma = makePrisma();
const { payload } = await buildFullBackupPayload(
prisma as never,
"user-1",
{
purpose: "disaster-recovery",
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
},
);
const rows = payload.measurements as Array<Record<string, unknown>>;
expect(rows[0]).toHaveProperty(
"aggregationProvenance",
"HEALTHKIT_STATISTICS",
);
});
it("leaves the provenance out of the reduced portable shape", async () => {
installSectionMocks();
const prisma = makePrisma();
const { payload } = await buildFullBackupPayload(
prisma as never,
"user-1",
{
purpose: "portable-export",
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
},
);
const rows = payload.measurements as Array<Record<string, unknown>>;
// The portable arm carries the human-readable subset only — no
// externalSourceVersion, no glucoseContext, and no provenance either.
expect(rows[0]).not.toHaveProperty("aggregationProvenance");
expect(rows[0]).not.toHaveProperty("externalSourceVersion");
});
});
+2
View File
@@ -83,6 +83,7 @@ export async function buildFullBackupPayload(
notesEncrypted: true,
externalId: true,
externalSourceVersion: true,
aggregationProvenance: true,
glucoseContext: true,
sleepStage: true,
rhythmClassification: true,
@@ -112,6 +113,7 @@ export async function buildFullBackupPayload(
: null,
externalId: measurement.externalId,
externalSourceVersion: measurement.externalSourceVersion,
aggregationProvenance: measurement.aggregationProvenance,
glucoseContext: measurement.glucoseContext,
sleepStage: measurement.sleepStage,
rhythmClassification: measurement.rhythmClassification,
@@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser {
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
cycleTrackingEnabled: false,
modules: {},
};
@@ -652,11 +652,7 @@ describe("streamParseExportXml — cumulative source-day estimates", () => {
expect(prisma._measurements[0]).toMatchObject({
value: 8_526,
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: 2,
});
expect(prisma._measurements[0]?.aggregationSelectedSourceHash).toMatch(
/^[a-f0-9]{64}$/,
);
expect(result.cumulativeEstimates).toEqual({ days: 1, rows: 1 });
});
@@ -675,7 +671,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => {
type: mapping.measurementType,
value: 30,
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: 2,
});
},
);
@@ -694,7 +689,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => {
expect(prisma._measurements[0]).toMatchObject({
type: mapping.measurementType,
value: 50,
aggregationContributorCount: 1,
});
},
);
@@ -712,7 +706,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => {
expect(prisma._measurements).toHaveLength(1);
expect(prisma._measurements[0]).toMatchObject({
value: 100,
aggregationContributorCount: 1,
});
});
@@ -1171,7 +1164,6 @@ describe("streamParseExportXml — memory ceiling", () => {
expect(prisma._measurements).toHaveLength(1);
expect(prisma._measurements[0]).toMatchObject({
value: 10_000,
aggregationContributorCount: 1,
});
// Heap delta should be modest — we tolerate a generous 100 MB so a
@@ -40,8 +40,6 @@ function row(overrides: Record<string, unknown> = {}): Record<string, unknown> {
externalId: "sleep:new",
externalSourceVersion: null,
aggregationProvenance: null,
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
@@ -224,8 +222,6 @@ describe("reconcileExternalMeasurement", () => {
sleepStage: null,
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
});
const { tx, measurement } = transaction([native]);
@@ -240,8 +236,6 @@ describe("reconcileExternalMeasurement", () => {
sleepStage: null,
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: 2,
aggregationSelectedSourceHash: "source-hash",
}),
{ exactExternalMatch: "update" },
);
@@ -350,8 +344,6 @@ describe("reconcileExternalMeasurement", () => {
sleepStage: null,
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
}),
{ exactExternalMatch: "update" },
);
@@ -366,8 +358,6 @@ describe("reconcileExternalMeasurement", () => {
data: expect.objectContaining({
value: 8_600,
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
syncVersion: { increment: 1 },
}),
}),
@@ -352,8 +352,6 @@ export async function drainPerSampleCumulative(
externalId,
deletedAt: null,
aggregationProvenance: "LEGACY_UNKNOWN",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
syncVersion: { increment: 1 },
...(slotIsFreeForTarget
? { measuredAt: canonicalTimestamp }
@@ -680,7 +680,10 @@ export async function streamParseExportXml(
}
}
if (!selected) continue;
const [selectedSourceHash, selectedSubtotal] = selected;
// Only the subtotal is carried forward. The source hash stays an
// in-memory map key — it is what makes the max-subtotal selection
// above deterministic on a tie, and it is not persisted.
const [, selectedSubtotal] = selected;
if (validateMeasurementRange(type, selectedSubtotal) !== null) {
unknown[`${type}::aggregate_out_of_range`] =
(unknown[`${type}::aggregate_out_of_range`] ?? 0) + 1;
@@ -704,8 +707,6 @@ export async function streamParseExportXml(
sleepStage: null,
deviceType: null,
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: bySource.size,
aggregationSelectedSourceHash: selectedSourceHash,
},
{ exactExternalMatch: "update" },
),
+5 -3
View File
@@ -6,9 +6,11 @@
* personalization signal: which dashboard tiles get promoted to the
* top and made visible. Goals are NOT a clinical target store — the
* targets surface (`/api/insights/targets`) is fully derived from
* height/age/gender + clinical references. Their only leverage is
* (a) dashboard tile pinning (here) and (b) reminder suggestions
* (read by the reminders panel from `User.onboardingGoals`).
* height/age/gender + clinical references. Their only leverage is the
* one-time dashboard tile pinning below, applied by
* `/api/onboarding/step` while the layout is still unset. Nothing else
* reads `User.onboardingGoals` — the column is collected, seeded from
* once, and then inert.
*
* One home for the slug enum so the API validation
* (`/api/onboarding/step`), the client picker, and the seeding all
+7
View File
@@ -43,6 +43,7 @@ import {
InjectionSite,
IntakeAttributionSource,
IntakeSource,
MeasurementAggregationProvenance,
MeasurementSource,
MeasurementType,
MedicationCategory,
@@ -86,6 +87,12 @@ const measurementSchema = z
notesEncrypted: base64BytesSchema.nullable().optional(),
externalId: z.string().nullable().optional(),
externalSourceVersion: z.string().nullable().optional(),
// Authority of a `stats:` aggregate. `.optional()` keeps every backup
// written before this field rode the payload parseable.
aggregationProvenance: z
.enum(MeasurementAggregationProvenance)
.nullable()
.optional(),
glucoseContext: z.enum(GlucoseContext).nullable().optional(),
sleepStage: z.enum(SleepStage).nullable().optional(),
rhythmClassification: z.enum(RhythmClassification).nullable().optional(),
@@ -1130,4 +1130,142 @@ describe("canonical disaster-recovery backup round-trip", () => {
);
expect(invalidateUserData).not.toHaveBeenCalled();
});
/**
* `aggregationProvenance` is what keeps an export.xml source-day estimate
* from overwriting a native HealthKit statistic. A backup that drops it
* restores a flattened authority ladder, so it has to survive the round
* trip — and a backup written before it rode the payload has to land in the
* documented repairable state rather than reproduce the ambiguity migration
* 0263 already repaired once.
*
* Mutation check: drop `aggregationProvenance` from the backup select or
* from the restore mapping and the first case goes red; drop the
* Apple-Health `stats:` fallback and the second goes red.
*/
it("round-trips the aggregation provenance of a stats aggregate", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-25T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: "provenance-native",
type: "ACTIVITY_STEPS",
value: 8_600,
unit: "steps",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
aggregationProvenance: "HEALTHKIT_STATISTICS",
},
{
id: "provenance-xml",
type: "ACTIVITY_STEPS",
value: 8_526,
unit: "steps",
measuredAt: "2026-07-21T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-21",
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_ROUNDTRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
orderBy: { measuredAt: "asc" },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([
{
id: "provenance-native",
aggregationProvenance: "HEALTHKIT_STATISTICS",
},
{ id: "provenance-xml", aggregationProvenance: "EXPORT_XML_SOURCE_MAX" },
]);
});
it("marks an unlabelled Apple Health stats aggregate as legacy-unknown on restore", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
// A backup taken before the provenance rode the payload: no field at all.
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-25T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: "legacy-apple-stats",
type: "ACTIVITY_STEPS",
value: 8_526,
unit: "steps",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
},
{
// Another provider's cumulative daily. It mints a `stats:` id too but
// has never carried provenance, so inventing one would be a lie.
id: "legacy-fitbit-stats",
type: "ACTIVITY_STEPS",
value: 7_100,
unit: "steps",
measuredAt: "2026-07-21T10:00:00.000Z",
source: "FITBIT",
externalId: "stats:steps",
},
{
// An ordinary point reading never had provenance either.
id: "legacy-point-row",
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: "2026-07-22T10:00:00.000Z",
source: "MANUAL",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_LEGACY",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
orderBy: { measuredAt: "asc" },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([
{ id: "legacy-apple-stats", aggregationProvenance: "LEGACY_UNKNOWN" },
{ id: "legacy-fitbit-stats", aggregationProvenance: null },
{ id: "legacy-point-row", aggregationProvenance: null },
]);
});
});
@@ -114,8 +114,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => {
measuredAt: new Date("2026-07-20T09:00:00.000Z"),
sleepStage: null,
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: 2,
aggregationSelectedSourceHash: "xml-source-hash",
},
),
},
@@ -132,8 +130,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => {
externalSourceVersion: "native-v1",
deviceType: "Apple Watch",
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
}),
},
});
@@ -147,8 +143,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => {
measuredAt: nativeMeasuredAt,
sleepStage: null,
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: 2,
aggregationSelectedSourceHash: "xml-source-hash",
}),
{ exactExternalMatch: "update" },
);
@@ -171,8 +165,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => {
externalSourceVersion: "native-v1",
deviceType: "Apple Watch",
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
});
expect(
await prisma.measurement.findUniqueOrThrow({
@@ -194,8 +186,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => {
externalSourceVersion: "native-v2",
deviceType: "Apple Watch",
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
}),
{ exactExternalMatch: "update" },
);
@@ -601,8 +601,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(stored[0]!.value).toBeCloseTo(5200, 5);
expect(stored[0]).toMatchObject({
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
syncVersion: 2,
});
});
@@ -1217,7 +1215,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(estimated[0]).toMatchObject({
value: 8_526,
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: 2,
syncVersion: 1,
});
@@ -1235,8 +1232,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(canonical[0]).toMatchObject({
value: 8_600,
aggregationProvenance: "HEALTHKIT_STATISTICS",
aggregationContributorCount: null,
aggregationSelectedSourceHash: null,
syncVersion: 2,
});
});
@@ -1297,7 +1292,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(rows[0]).toMatchObject({
value: 8_526,
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
aggregationContributorCount: 2,
syncVersion: 2,
});
});