mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
7 files changed
+289
-19
No files matched your search
@@ -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,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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user