From e5b958cd86a7ade8fbb6b76f9e71defba5a9008a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 22:55:05 +0200 Subject: [PATCH 1/4] fix(backup): carry the account's own cycle symptoms into the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section builder returned them, the wire schema declared them, and the restore has read them since v1.33.1 — including the throw that replaced the silent drop. The payload assembly copied its keys out one by one and was never extended, so every backup written carried an empty list. An account with a custom symptom did not get the silent drop that release removed; it got a restore that failed outright on a key nothing had written back. Spread the cycle and records sections instead of hand-picking their keys, so a field added to either section interface reaches the wire by construction. Declare labelEncrypted on the cycle section, which the builder already emitted. --- src/lib/cycle/backup.ts | 10 +++++++ src/lib/export/full-backup-payload.ts | 43 +++++++++++++++++---------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 691a65875..ce511a02c 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -108,6 +108,16 @@ export interface CycleBackupSection { icon: string | null; sortOrder: number; isActive: boolean; + /** + * The user's own words for the symptom, carried as ciphertext verbatim. + * + * The builder has always emitted it and this interface did not declare it, + * which held while the payload was assembled key by key and stopped holding + * the moment the section is spread onto the wire: an undeclared field is a + * field the wire type says is not there. Declared, so the type describes + * the file. + */ + labelEncrypted: string | null; }>; } diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 20a5af75c..fe7b4707e 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -15,11 +15,15 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; -import { buildCycleBackupSection } from "@/lib/cycle/backup"; +import { + buildCycleBackupSection, + type CycleBackupSection, +} from "@/lib/cycle/backup"; import { buildRecordsBackupSection, countRecordsBackupSection, type RecordsBackupCounts, + type RecordsBackupSection, } from "@/lib/export/records-backup"; export interface FullBackupCounts extends RecordsBackupCounts { @@ -210,6 +214,11 @@ export async function buildFullBackupPayload( }), ]); + // Pinned to the section interfaces so the spreads below cannot quietly widen + // (or narrow) what reaches the wire. + const cycleSection: CycleBackupSection = cycle; + const recordsSection: RecordsBackupSection = records; + const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), @@ -335,20 +344,24 @@ export async function buildFullBackupPayload( scaleMax: tag.scaleMax, inverse: tag.inverse, })), - cycleProfile: cycle.cycleProfile, - cycles: cycle.cycles, - cycleDayLogs: cycle.cycleDayLogs, - // v1.28 backup-completeness — the domains the pre-existing shape never - // covered. `manifest` discloses the two deliberate exclusions (document - // binaries, workout GPS/sample time series) inline in the file itself, - // not just in the export UI copy. - labResults: records.labResults, - biomarkers: records.biomarkers, - illnessEpisodes: records.illnessEpisodes, - allergies: records.allergies, - familyHistory: records.familyHistory, - workouts: records.workouts, - documents: records.documents, + // Both sections are SPREAD, not hand-picked key by key. + // + // Hand-picking is what lost the custom cycle symptoms. `buildCycleBackupSection` + // returned them, the wire schema declared them, `restoreCycleData` read them + // and — since v1.33.1 — THREW on a key it could not resolve. The list of keys + // copied out here was simply never extended, so every backup written carried + // `customSymptoms: []`. An account with one custom symptom did not get the + // silent drop that release removed; it got a restore that failed outright on + // a key nothing had written back, and the fix was inert in production. + // + // Spreading makes the section interface the single declaration of what rides + // the wire: a field added to `CycleBackupSection` / `RecordsBackupSection` + // reaches the payload by construction rather than by someone remembering. + // `manifest` (from the records section) discloses the two deliberate + // exclusions — document binaries, workout GPS/sample series — in the file + // itself, not just in the export UI copy. + ...cycleSection, + ...recordsSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, From ddbce306be5ddd35b4aa94bd3a5ce8d5f5b1aa20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 00:09:10 +0200 Subject: [PATCH 2/4] fix(backup): drop the manifest key the records spread already carries The assembly picked `manifest` out of the records section by hand, one line under the comment explaining that both sections are spread precisely so nobody has to pick their keys. It resolved to the same value the spread had already placed, at the same position, so the emitted file is byte-identical either way. Left in, it is the pattern that lost the custom cycle symptoms sitting next to the note about how that happened. --- src/lib/export/full-backup-payload.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index fe7b4707e..f8bbe25a9 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -369,7 +369,6 @@ export async function buildFullBackupPayload( unit: n.unit, source: n.source, })), - manifest: records.manifest, }; return { From dd3a7915d6d0c2e33772d4be8628114410b7faaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 00:09:22 +0200 Subject: [PATCH 3/4] test(backup): cover the assembly a custom cycle symptom has to cross Both ends of this defect had passing tests for the whole time it was broken. The section builder returned the symptoms, the wire schema declared them, the restore read them, and each of those three was covered on its own. Nothing covered the object literal in between, which copied section keys out one at a time and never listed `customSymptoms`. So the test spans the seam and asserts on rows. It populates an account with a symptom of its own on a day-log, builds the file through the real `buildFullBackupPayload`, serialises it, parses it back through the real `parseBackupPayload`, and drives the real admin restore. Asserting that the payload carries the field would only re-test the writer that was already green. Deleting the symptom row before the restore is the case the field exists for, not a contrivance: `restoreCycleData` clears day-logs, cycles, and the profile but never the symptom catalogue, so a restore onto the same instance still finds the row sitting there. That is why the older round-trip test stayed green through the whole thing. Mutation check: hand-pick the cycle section's keys again and the restore answers 500 on `Unknown cycle symptom keys: custom:sacroiliac-ache`, which is the production failure verbatim. --- ...ackup-custom-cycle-symptom-restore.test.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/integration/backup-custom-cycle-symptom-restore.test.ts diff --git a/tests/integration/backup-custom-cycle-symptom-restore.test.ts b/tests/integration/backup-custom-cycle-symptom-restore.test.ts new file mode 100644 index 000000000..be4709966 --- /dev/null +++ b/tests/integration/backup-custom-cycle-symptom-restore.test.ts @@ -0,0 +1,214 @@ +/** + * The seam between the backup writer and the backup reader. + * + * Custom cycle symptoms had a green test on every end and shipped broken + * anyway. `buildCycleBackupSection` returned them, `backupPayloadSchema` + * declared them, `restoreCycleData` read them, and each of those three was + * covered. What nothing covered was the assembly in between: the payload was + * built as an object literal that copied section keys out one at a time, and + * `customSymptoms` was never on that list. Every backup written carried an + * empty array, so an account whose day-log referenced a symptom it had created + * itself could not be restored at all — the restore threw on a key nothing had + * written back and the route answered 500. + * + * So this test asserts on ROWS, never on the payload object. Asserting the + * payload carries `customSymptoms` would re-test the writer, which was already + * passing while production was broken. The account is populated, the REAL + * `buildFullBackupPayload` produces the file, the file is serialised and parsed + * back through the REAL `parseBackupPayload`, the account's own symptom row is + * removed so the file is the only place it survives, and the REAL admin restore + * has to put the row and its day-log link back. + * + * Removing the symptom row before restoring is the point, not a contrivance: + * `restoreCycleData` wipes day-logs, cycles, and the profile, but never the + * symptom catalogue. Restore onto the same instance and the row is still + * sitting there to resolve against, which is exactly why the older round-trip + * test stayed green. The file has to carry the symptom for the case the + * `customSymptoms` field exists for: a restore onto an instance that never had + * it. + * + * Mutation check: hand-pick the cycle section's keys in + * `buildFullBackupPayload` instead of spreading it, and this test reproduces + * the production failure — restore answers 500 on + * `Unknown cycle symptom keys: custom:sacroiliac-ache`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { decrypt, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +/** The user's own words for the symptom, as they are held at rest. */ +const SYMPTOM_LABEL = "Sacroiliac ache"; +const SYMPTOM_KEY = "custom:sacroiliac-ache"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +describe("a backup of an account with its own cycle symptom", () => { + it("restores the symptom and its day-log link onto an instance without it", async () => { + const prisma = getPrismaClient(); + + // The restore writes into the backup's owner, and the route refuses a + // payload whose declared owner differs from the backup row. Owner and + // operator are the same account here so the fixture stays about the + // symptom rather than about the ownership guard. + const owner = await prisma.user.create({ + data: { + username: "cycle-symptom-owner", + email: "cycle-symptom-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + const symptom = await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: SYMPTOM_KEY, + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt(SYMPTOM_LABEL), + sortOrder: 3, + }, + }); + await prisma.cycleProfile.create({ + data: { + userId: owner.id, + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 29, + }, + }); + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: owner.id, + startDate: "2026-07-01", + periodEndDate: "2026-07-05", + tz: "Europe/Berlin", + }, + }); + const dayLog = await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-03", + flow: "MEDIUM", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { create: { symptomId: symptom.id } }, + }, + }); + + // The real writer, the real wire format, the real reader. Nothing is + // asserted about `built` — the writer was green through the whole defect. + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "CUSTOM_CYCLE_SYMPTOM_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + + // Model the instance the `customSymptoms` field exists for: one that never + // had this account's own symptom. The restore clears day-logs, cycles, and + // the profile itself, but never the symptom catalogue, so the row has to go + // before the restore runs or the file is not what resolves the link. + await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } }); + await prisma.menstrualCycle.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleProfile.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } }); + expect( + await prisma.cycleSymptom.count({ where: { key: SYMPTOM_KEY } }), + ).toBe(0); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(200); + + // Rows, not payload keys. + const restoredSymptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: SYMPTOM_KEY }, + }); + expect(restoredSymptom).toEqual( + expect.objectContaining({ + userId: owner.id, + categoryId: category.id, + labelKey: "cycle.symptom.custom", + sortOrder: 3, + isActive: true, + }), + ); + // The label is ciphertext on the wire and stays readable after the trip: + // a restored symptom whose label came back empty is the same lost data in + // a quieter form. + expect(decrypt(restoredSymptom.labelEncrypted!)).toBe(SYMPTOM_LABEL); + + const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: dayLog.id }, + }); + expect(restoredDayLog.date).toBe("2026-07-03"); + expect( + await prisma.cycleSymptomLink.findMany({ + where: { dayLogId: restoredDayLog.id }, + select: { symptomId: true }, + }), + ).toEqual([{ symptomId: restoredSymptom.id }]); + }); +}); From 4d459dbb687b0fece8eab7c432c9e39feea7d0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 00:09:29 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.33.2=20=E2=80=94=20the?= =?UTF-8?q?=20cycle-symptom=20fix=20that=20did=20not=20reach=20the=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.33.1 claimed custom cycle symptoms were carried in backups. They were not, and the same release turned the old silent drop into a hard failure, so the accounts the fix was written for went from losing one symptom link to being unable to restore at all. --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index addeeb34e..d0fda05cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ ## [Unreleased] +## [1.33.2] — 2026-07-28 + +### The cycle-symptom fix in v1.33.1 did not work + +v1.33.1 said it had fixed custom cycle symptoms in backups. It had not. The +fix was real in three places and missing from the one that decides what ends +up in the file. The code that reads your cycle data returned the symptoms you +created yourself. The file format declared a field to hold them. The restore +knew how to put them back. But the step that assembles the file copies its +contents across one field at a time, and that list was never extended, so +every backup written since carried an empty list of custom symptoms. + +The same release also replaced a silent drop with an error. Before it, a +backup referencing a symptom the restoring instance did not have lost that +one link and finished. After it, the restore stops and reports a failure. +Together with the empty list, that took an account with a symptom of its own +from losing a single link to being unable to restore at all. That is the +group the fix was written for. + +Nothing in your stored data was damaged. But a backup written by v1.33.1 or +earlier does not hold the symptom definitions you created, and restoring one +of those files on this version will fail at the first day-log referencing +one. Take a fresh backup after updating and keep it alongside the older +files. + +The assembly step no longer lists fields by hand. It copies each section +whole, so a field added to a section reaches the file because of the way the +code is written rather than because somebody remembered. Both ends of this +were covered by tests that passed for the whole time it was broken, and +nothing covered the join between them, so there is now a test that writes a +real backup for an account with a symptom of its own, removes that symptom +from the database, restores the file, and checks the row and its day-log link +came back. + ## [1.33.1] — 2026-07-27 A patch release about the same thing as the one before it: places where the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 58dc9d0be..994139e02 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.33.1 + version: 1.33.2 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 6cca5848c..5115fa8d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.33.1", + "version": "1.33.2", "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 8d6d19c22..e97634d40 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.33.1"; + /* @sw-version-fallback */ "v1.33.2"; 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/*`