From a3f1d780e7971686d2e3050510c4645a26fa5785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 15:00:27 +0200 Subject: [PATCH 1/4] feat(export): remember the practice name between doctor reports The doctor-report contract has always described the practice / clinic name as a stored preference, and `/api/auth/me` has returned `User.lastReportPracticeName` since it shipped, but nothing ever wrote the column. Every report asked for the clinic name again. The export route now persists the sanitised name after the requested artefact has actually been produced, so a generation that throws cannot overwrite the remembered value with a half-typed one. Clearing the field persists null, which keeps the cover line cleared next time. The export panel seeds its input from the auth payload during render, gated per user id, so a late resolve never clobbers a field the user is already typing into, and a finished export invalidates the cached payload so a remount reads the new value. --- .../health-record/__tests__/route.test.ts | 73 ++++++++++++++ src/app/api/export/health-record/route.ts | 26 +++++ .../__tests__/disable-coach-card.test.tsx | 1 + ...ealth-record-export-practice-name.test.tsx | 97 +++++++++++++++++++ .../__tests__/modules-section.test.tsx | 1 + .../__tests__/time-format-select.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + .../settings/health-record-export-panel.tsx | 21 ++++ src/hooks/use-auth.ts | 7 ++ .../__tests__/coach-user-disable.test.tsx | 1 + 10 files changed, 229 insertions(+) create mode 100644 src/components/settings/__tests__/health-record-export-practice-name.test.tsx diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 08ad8e15b..2edf0ab3c 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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" } }), + ); + }); +}); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 83253d1ff..adde3d743 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -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({ diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 945933485..b6b326e09 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -74,6 +74,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/__tests__/health-record-export-practice-name.test.tsx b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx new file mode 100644 index 000000000..24c5c298e --- /dev/null +++ b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx @@ -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( + "@/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( + + + , + ); +} + +function practiceInputValue(html: string): string | null { + const input = html.match(/]*id="hr-practice"[^>]*>/)?.[0]; + if (!input) return null; + return input.match(/value="([^"]*)"/)?.[1] ?? ""; +} + +describe(" — 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(""); + }); +}); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 88c93e4d1..a77475675 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -107,6 +107,7 @@ function buildUser( insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules, moduleAvailability: {}, diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index cea538c04..27c89a7c8 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -52,6 +52,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index e944c2ddc..9fc1945dd 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -55,6 +55,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index 385efb790..70bc7d956 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -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("pdf"); const [days, setDays] = useState(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(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 { diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 537ed1dac..af8208231 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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 diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index c85a9a275..4dc6553aa 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; From f8f460bb66bf266c6176158bac9cb5f7cb205cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 15:03:44 +0200 Subject: [PATCH 2/4] refactor(measurements): drop the write-only aggregation columns (migration 0272) Migration 0263 added three columns beside the Apple Health `stats:*` authority ladder. Only `aggregation_provenance` is ever read back: the external-measurement reconciler selects it and refuses a lower-authority write against a higher-authority canonical row, and the cumulative drain reads it too. That column stays. `aggregation_contributor_count` and `aggregation_selected_source_hash` are written by three call sites and read by none. No select, no raw SQL, no export, no backup, no API contract, no repair path. The import-time selection keeps its deterministic tiebreak, which compares an in-memory map key computed per flush rather than a value read back from the table. Both are plain nullable scalars with no index, unique, check or foreign key, so the drop is a catalog-only operation that touches no measured value. --- .../migration.sql | 36 +++++++++++++++++++ prisma/schema.prisma | 8 +---- src/app/api/measurements/batch/route.ts | 2 -- .../import-apple-health-export.test.ts | 8 ----- .../reconcile-external-measurement.test.ts | 10 ------ .../drain-per-sample-cumulative.ts | 2 -- .../import-apple-health-export.ts | 7 ++-- .../measurement-identity-reconcile.test.ts | 10 ------ tests/integration/measurements-batch.test.ts | 6 ---- 9 files changed, 41 insertions(+), 48 deletions(-) create mode 100644 prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql diff --git a/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql b/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql new file mode 100644 index 000000000..2f81a3122 --- /dev/null +++ b/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a4920eca..e2d69c31f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 1fe4aee66..8aa5ec5db 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -544,8 +544,6 @@ async function postBatch(request: NextRequest): Promise { .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" }, ), diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 389ba7d41..030f94be0 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -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 diff --git a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts index 1a755dc72..5a2d012fd 100644 --- a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts +++ b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts @@ -40,8 +40,6 @@ function row(overrides: Record = {}): Record { 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 }, }), }), diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 9fb503f77..81973acc7 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -352,8 +352,6 @@ export async function drainPerSampleCumulative( externalId, deletedAt: null, aggregationProvenance: "LEGACY_UNKNOWN", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: { increment: 1 }, ...(slotIsFreeForTarget ? { measuredAt: canonicalTimestamp } diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 030cd5b10..320e9c46f 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -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" }, ), diff --git a/tests/integration/measurement-identity-reconcile.test.ts b/tests/integration/measurement-identity-reconcile.test.ts index 9ebfa33ba..21ebecc12 100644 --- a/tests/integration/measurement-identity-reconcile.test.ts +++ b/tests/integration/measurement-identity-reconcile.test.ts @@ -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" }, ); diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index d0e4c9ee7..1617bbc5f 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -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, }); }); From 2a396dd23e307d678d6ae68dc03cd868bd2b38cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 15:20:47 +0200 Subject: [PATCH 3/4] fix(backup): carry the aggregation provenance through backup and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disaster-recovery backup selected every Measurement scalar except the aggregation fields, so a restore brought every `stats:` aggregate back with no provenance and the Apple Health authority ladder came back flat: an export.xml source-day estimate could then overwrite a native HealthKit statistic. `aggregationProvenance` now rides the select, the disaster-recovery serialization, the payload schema and the restore mapping, following `externalSourceVersion` exactly — including staying out of the reduced portable shape, which carries the human-readable subset only. The schema field is optional, so every backup written before this parses unchanged. Restoring one of those older backups no longer recreates the ambiguity migration 0263 repaired: an Apple Health row under a `stats:` identity with no provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain already writes in that situation, so a native sync or archive reprocess can repair it. The gate is scoped as 0263 scoped its backfill — other providers mint `stats:` identities for their cumulative dailies and have never carried provenance, and ordinary point rows keep NULL. --- .../api/admin/backups/[id]/restore/route.ts | 19 +++ .../__tests__/full-backup-payload.test.ts | 71 +++++++++ src/lib/export/full-backup-payload.ts | 2 + src/lib/onboarding/goals.ts | 8 +- src/lib/validations/backup.ts | 7 + .../admin-backups-canonical-roundtrip.test.ts | 138 ++++++++++++++++++ 6 files changed, 242 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index d417d0568..879a0a42e 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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 ?? diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 82170b68e..aac92b01f 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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>; + 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>; + // 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"); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5e870a023..e0c813eb5 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -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, diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts index 3a9655502..bf5385d91 100644 --- a/src/lib/onboarding/goals.ts +++ b/src/lib/onboarding/goals.ts @@ -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 diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 326bec119..854b1a7be 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 506fad988..cfd86f30e 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -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[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[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 }, + ]); + }); }); From fe2e20a14be9b907656da7cb54c1990f679c3474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 15:28:39 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.32.35=20=E2=80=94=20re?= =?UTF-8?q?member=20the=20practice=20name,=20keep=20the=20source=20provena?= =?UTF-8?q?nce=20in=20a=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a4daf39..902c31222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 289e03009..e9884298b 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.32.34 + version: 1.32.35 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 e73c58a5f..997bd607c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index c42b23c88..8978d4ac0 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.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/*`