Merge pull request #661 from MBombeck/fix/v1.33.2-restore-cycle-symptoms

v1.33.2 — restore the cycle symptoms v1.33.1 said it had fixed
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-28 00:23:40 +02:00
commit 592705550f
7 files changed
+289 -19

No files matched your search

+34
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+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.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/*`
+10
View File
@@ -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;
}>;
}
+28 -16
View File
@@ -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,
@@ -356,7 +369,6 @@ export async function buildFullBackupPayload(
unit: n.unit,
source: n.source,
})),
manifest: records.manifest,
};
return {
@@ -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<typeof POST>[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 }]);
});
});