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] 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,