Merge pull request #685 from MBombeck/fix/restore-unknown-symptom-key

Let a restore survive a catalogue key that no longer exists
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 09:27:26 +02:00
commit 8febdbab26
14 files changed
+927 -70

No files matched your search

+7
View File
@@ -6569,6 +6569,13 @@
"restoreInProgress": "Wiederherstellung läuft…",
"restoreSuccess": "Wiederherstellung abgeschlossen",
"restoreFailed": "Wiederherstellung fehlgeschlagen",
"restoreSkippedToast": "Wiederherstellung abgeschlossen, {links} Symptomverknüpfungen konnten nicht zurückgeschrieben werden",
"restoreSkippedTitle": "{links} Verknüpfungen wurden nicht wiederhergestellt",
"restoreSkippedDescription": "Die Datei verweist auf Einträge, die diese Installation nicht kennt. Alles andere wurde wiederhergestellt. Dieselbe Liste steht im Audit-Log.",
"restoreSkippedLinks": "{count} Verknüpfungen",
"restoreSkippedCycleSymptom": "Zyklussymptom",
"restoreSkippedIllnessSymptom": "Krankheitssymptom",
"restoreSkippedMoodFactor": "Stimmungsfaktor",
"docsLink": "Dokumentation"
},
"danger-zone": {
+7
View File
@@ -6569,6 +6569,13 @@
"restoreInProgress": "Restoring…",
"restoreSuccess": "Restore complete",
"restoreFailed": "Restore failed",
"restoreSkippedToast": "Restore complete, {links} symptom links could not be put back",
"restoreSkippedTitle": "{links} links were not restored",
"restoreSkippedDescription": "The file references entries this installation does not know. Everything else was restored. The same list is in the audit log.",
"restoreSkippedLinks": "{count} links",
"restoreSkippedCycleSymptom": "Cycle symptom",
"restoreSkippedIllnessSymptom": "Illness symptom",
"restoreSkippedMoodFactor": "Mood factor",
"docsLink": "Documentation"
},
"danger-zone": {
+7
View File
@@ -6569,6 +6569,13 @@
"restoreInProgress": "Restaurando...",
"restoreSuccess": "Restauración completada",
"restoreFailed": "La restauración falló",
"restoreSkippedToast": "Restauración completada, {links} vínculos de síntomas no se pudieron recuperar",
"restoreSkippedTitle": "{links} vínculos no se restauraron",
"restoreSkippedDescription": "El archivo hace referencia a entradas que esta instalación no conoce. Todo lo demás se restauró. La misma lista está en el registro de auditoría.",
"restoreSkippedLinks": "{count} vínculos",
"restoreSkippedCycleSymptom": "Síntoma del ciclo",
"restoreSkippedIllnessSymptom": "Síntoma de enfermedad",
"restoreSkippedMoodFactor": "Factor de ánimo",
"docsLink": "Documentación"
},
"danger-zone": {
+7
View File
@@ -6569,6 +6569,13 @@
"restoreInProgress": "Restauration en cours...",
"restoreSuccess": "Restauration terminée",
"restoreFailed": "Échec de la restauration",
"restoreSkippedToast": "Restauration terminée, {links} liens de symptômes n'ont pas pu être rétablis",
"restoreSkippedTitle": "{links} liens n'ont pas été restaurés",
"restoreSkippedDescription": "Le fichier référence des entrées que cette installation ne connaît pas. Tout le reste a été restauré. La même liste figure dans le journal d'audit.",
"restoreSkippedLinks": "{count} liens",
"restoreSkippedCycleSymptom": "Symptôme de cycle",
"restoreSkippedIllnessSymptom": "Symptôme de maladie",
"restoreSkippedMoodFactor": "Facteur d'humeur",
"docsLink": "Documentation"
},
"danger-zone": {
+7
View File
@@ -6569,6 +6569,13 @@
"restoreInProgress": "Ripristino in corso...",
"restoreSuccess": "Ripristino completato",
"restoreFailed": "Ripristino non riuscito",
"restoreSkippedToast": "Ripristino completato, {links} collegamenti ai sintomi non sono stati recuperati",
"restoreSkippedTitle": "{links} collegamenti non sono stati ripristinati",
"restoreSkippedDescription": "Il file fa riferimento a voci che questa installazione non conosce. Tutto il resto è stato ripristinato. Lo stesso elenco è nel registro di audit.",
"restoreSkippedLinks": "{count} collegamenti",
"restoreSkippedCycleSymptom": "Sintomo del ciclo",
"restoreSkippedIllnessSymptom": "Sintomo di malattia",
"restoreSkippedMoodFactor": "Fattore dell'umore",
"docsLink": "Documentazione"
},
"danger-zone": {
+7
View File
@@ -6569,6 +6569,13 @@
"restoreInProgress": "Przywracanie...",
"restoreSuccess": "Przywracanie zakończone",
"restoreFailed": "Przywracanie nie powiodło się",
"restoreSkippedToast": "Przywracanie zakończone, {links} powiązań objawów nie udało się odtworzyć",
"restoreSkippedTitle": "{links} powiązań nie zostało przywróconych",
"restoreSkippedDescription": "Plik odwołuje się do wpisów, których ta instalacja nie zna. Wszystko pozostałe zostało przywrócone. Ta sama lista znajduje się w dzienniku audytu.",
"restoreSkippedLinks": "{count} powiązań",
"restoreSkippedCycleSymptom": "Objaw cyklu",
"restoreSkippedIllnessSymptom": "Objaw choroby",
"restoreSkippedMoodFactor": "Czynnik nastroju",
"docsLink": "Dokumentacja"
},
"danger-zone": {
+106 -45
View File
@@ -38,6 +38,12 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import {
recordUnknownKeys,
summarizeRestoreSkips,
type RestoreSkipLog,
type RestoreSkipSummary,
} from "@/lib/export/restore-skips";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
@@ -48,6 +54,14 @@ export const dynamic = "force-dynamic";
interface RestoreResponse {
restored: true;
summary: BackupSummary;
/**
* What the file carried that this instance could not resolve, named.
*
* `links: 0` with an empty list is the normal answer and says so: nothing
* was dropped. Anything else has to reach the operator's screen, because a
* restore that quietly loses links looks exactly like one that did not.
*/
skipped: RestoreSkipSummary;
cleared: {
measurements: number;
medications: number;
@@ -276,10 +290,18 @@ const handler = apiHandler(
);
}
let cleared: RestoreResponse["cleared"];
let outcome: {
cleared: RestoreResponse["cleared"];
skipped: RestoreSkipSummary;
};
try {
cleared = await prisma.$transaction(
outcome = await prisma.$transaction(
async (tx) => {
// Declared INSIDE the transaction so a rollback takes the report with
// it. An accumulator that outlived a failed attempt would carry drops
// that never happened into the next one.
const skips: RestoreSkipLog = [];
// Delete every serialized owner-scoped partition before rebuilding it
// in the same transaction. Child rows either go first for counts or
// cascade from their serialized parent.
@@ -631,13 +653,10 @@ const handler = apiHandler(
}
if (payload.moodEntries.length > 0) {
const factorKeys = [
...new Set(
payload.moodEntries.flatMap((entry) =>
entry.factors.map((factor) => factor.key),
),
),
];
const referencedFactorKeys = payload.moodEntries.flatMap((entry) =>
entry.factors.map((factor) => factor.key),
);
const factorKeys = [...new Set(referencedFactorKeys)];
// Re-create the account's own tag definitions first. The lookup
// below used to ask only for the seeded catalogue (`userId: null`),
// so a single custom rated tag made the whole restore throw
@@ -681,12 +700,18 @@ const handler = apiHandler(
const factorByKey = new Map(
factorRows.map((factor) => [factor.key, factor.id]),
);
if (factorByKey.size !== factorKeys.length) {
const missing = factorKeys.filter((key) => !factorByKey.has(key));
throw new Error(
`Unknown mood factor keys: ${missing.join(", ")}`,
);
}
// A rated tag this instance does not know costs the entry's link to
// it, not the entry. The mood, the score, the date, the free tags —
// everything the person logged — comes back; the one rating that
// has nowhere to attach is dropped and named. Refusing the file
// instead meant a renamed seeded tag made the whole account
// unrecoverable.
recordUnknownKeys(
skips,
"moodFactor",
factorKeys.filter((key) => !factorByKey.has(key)),
referencedFactorKeys,
);
for (const entry of payload.moodEntries) {
const moodLoggedAt = new Date(entry.loggedAt);
@@ -734,14 +759,20 @@ const handler = apiHandler(
await tx.moodEntryTagLink.deleteMany({
where: { moodEntryId: restored.id },
});
if (entry.factors.length > 0) {
await tx.moodEntryTagLink.createMany({
data: entry.factors.map((factor) => ({
moodEntryId: restored.id,
moodTagId: factorByKey.get(factor.key)!,
rating: factor.rating,
})),
});
const factorLinks = entry.factors.flatMap((factor) => {
const moodTagId = factorByKey.get(factor.key);
return moodTagId
? [
{
moodEntryId: restored.id,
moodTagId,
rating: factor.rating,
},
]
: [];
});
if (factorLinks.length > 0) {
await tx.moodEntryTagLink.createMany({ data: factorLinks });
}
}
}
@@ -749,7 +780,12 @@ const handler = apiHandler(
// v1.15.0 — cycle tables (profile + observed spans + day-logs +
// symptom links). Delete-then-recreate, mirroring the contract
// above. `notesEncrypted` is restored as ciphertext verbatim.
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
const cycleCleared = await restoreCycleData(
tx,
ownerId,
payload,
skips,
);
// Durable self-context + user-defined metrics. Both ends of this pair
// live in `src/lib/export/profile-backup.ts` beside its builder — a
@@ -905,15 +941,13 @@ const handler = apiHandler(
}
}
const symptomKeys = [
...new Set(
payload.illnessEpisodes.flatMap((episode) =>
episode.dayLogs.flatMap((dayLog) =>
dayLog.symptoms.map((symptom) => symptom.key),
),
const referencedSymptomKeys = payload.illnessEpisodes.flatMap(
(episode) =>
episode.dayLogs.flatMap((dayLog) =>
dayLog.symptoms.map((symptom) => symptom.key),
),
),
];
);
const symptomKeys = [...new Set(referencedSymptomKeys)];
const symptomRows =
symptomKeys.length === 0
? []
@@ -924,14 +958,25 @@ const handler = apiHandler(
const symptomByKey = new Map(
symptomRows.map((symptom) => [symptom.key, symptom.id]),
);
if (symptomByKey.size !== symptomKeys.length) {
const missing = symptomKeys.filter((key) => !symptomByKey.has(key));
throw new Error(
`Unknown illness symptom keys: ${missing.join(", ")}`,
);
}
// `IllnessSymptom` has no `userId` column at all — it is a purely
// seeded catalogue, so a key that will not resolve here can ONLY be
// catalogue drift and never a definition the file failed to carry.
// Which makes refusing the file over it the least defensible of the
// three: the file was never able to supply the missing row.
recordUnknownKeys(
skips,
"illnessSymptom",
symptomKeys.filter((key) => !symptomByKey.has(key)),
referencedSymptomKeys,
);
for (const episode of payload.illnessEpisodes) {
for (const dayLog of episode.dayLogs) {
const symptomLinks = dayLog.symptoms.flatMap((symptom) => {
const symptomId = symptomByKey.get(symptom.key);
return symptomId
? [{ symptomId, severity: symptom.severity ?? null }]
: [];
});
await tx.illnessDayLog.create({
data: {
...(dayLog.id ? { id: dayLog.id } : {}),
@@ -958,12 +1003,9 @@ const handler = apiHandler(
...(dayLog.updatedAt
? { updatedAt: new Date(dayLog.updatedAt) }
: {}),
symptomLinks: {
create: dayLog.symptoms.map((symptom) => ({
symptomId: symptomByKey.get(symptom.key)!,
severity: symptom.severity ?? null,
})),
},
...(symptomLinks.length > 0
? { symptomLinks: { create: symptomLinks } }
: {}),
},
});
}
@@ -1113,7 +1155,7 @@ const handler = apiHandler(
});
}
return {
const cleared = {
measurements: measurements.count,
medications: meds.count,
intakeEvents: intake.count,
@@ -1140,6 +1182,7 @@ const handler = apiHandler(
intradayProfiles: intradayCleared.intradayProfiles,
healthScoreRecords: healthScoreCleared.healthScoreRecords,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
{
maxWait: 10_000,
@@ -1168,6 +1211,19 @@ const handler = apiHandler(
return apiError("Restore failed", 500);
}
const { cleared, skipped } = outcome;
// Pinned shape, not free text: a dashboard can alert on
// `restoreSkippedLinks > 0` and the key list says which catalogue drifted.
annotate({
meta: {
restoreSkippedLinks: skipped.links,
restoreSkippedKeys: skipped.catalogueKeys
.map((entry) => `${entry.catalogue}:${entry.key}`)
.join(","),
},
});
// v1.4.39.1 — re-fold the persistent measurement rollup tier from
// the just-restored measurements. Pre-fix the restore left the
// rollup table empty for the owner, so the dashboard chart's
@@ -1247,6 +1303,10 @@ const handler = apiHandler(
ownerId,
ownerUsername: owner.username,
cleared,
// The durable half of the report. The response reaches whoever was
// looking at the screen; the audit row is still here next week when
// someone asks why a day-log lost a symptom.
skipped,
restored: {
measurements: summary.measurements,
medications: summary.medications,
@@ -1278,6 +1338,7 @@ const handler = apiHandler(
const response: RestoreResponse = {
restored: true,
summary,
skipped,
cleared,
};
return apiSuccess(response);
@@ -0,0 +1,81 @@
/**
* The restore's skip report has to reach a human.
*
* The server names every catalogue key it could not resolve, in the response
* and in the audit row, and the integration suite proves that end. This is the
* other end: a report that a person never sees is the same silence the naming
* exists to end, so the panel gets its own coverage rather than riding on the
* fact that a state setter is wired up somewhere.
*
* Pinned here:
* 1. every skipped key is printed, not just a total,
* 2. each key carries its own link count and its catalogue,
* 3. an empty report renders nothing at all — a panel that appears after a
* clean restore trains the operator to ignore it,
* 4. the copy resolves in more than one locale, so a self-hoster running the
* German UI is told the same thing.
*
* Mutation check: total the links instead of listing them (drop the `<ul>`) and
* cases 1 and 2 go red; return the panel unconditionally and case 3 goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RestoreSkipReport } from "@/components/admin/backups-section";
import type { RestoreSkipSummary } from "@/lib/export/restore-skips";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/backups",
}));
const REPORT: RestoreSkipSummary = {
links: 4,
catalogueKeys: [
{ catalogue: "cycleSymptom", key: "cs.retired_symptom", links: 2 },
{ catalogue: "illnessSymptom", key: "ill.retired_symptom", links: 1 },
{ catalogue: "moodFactor", key: "mood.retired_factor", links: 1 },
],
};
function render(report: RestoreSkipSummary, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<RestoreSkipReport report={report} onDismiss={() => {}} />
</I18nProvider>,
);
}
describe("RestoreSkipReport", () => {
it("names every skipped key rather than only the total", () => {
const html = render(REPORT);
expect(html).toContain('data-slot="restore-skip-report"');
for (const entry of REPORT.catalogueKeys) {
expect(html).toContain(entry.key);
}
});
it("gives each key its own link count and catalogue", () => {
const html = render(REPORT);
// The cycle key cost two links and the other two cost one each. A panel
// that printed the grand total against every row would read as six.
expect(html).toContain("2 links");
expect(html).toContain("1 links");
expect(html).toContain("Cycle symptom");
expect(html).toContain("Illness symptom");
expect(html).toContain("Mood factor");
expect(html).toContain("4 links were not restored");
});
it("renders nothing when the restore dropped nothing", () => {
expect(render({ links: 0, catalogueKeys: [] })).toBe("");
});
it("speaks the operator's language", () => {
const german = render(REPORT, "de");
expect(german).toContain("4 Verknüpfungen wurden nicht wiederhergestellt");
expect(german).toContain("Zyklussymptom");
expect(german).toContain("cs.retired_symptom");
});
});
+127 -3
View File
@@ -13,6 +13,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
BookOpen,
Database,
Download,
@@ -45,6 +46,10 @@ import { ListRow } from "@/components/ui/list-row";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow, BackupsList } from "@/types/backups";
import type {
RestoreSkipSummary,
SkippedCatalogue,
} from "@/lib/export/restore-skips";
import { getApiErrorMessage } from "./_shared";
import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
@@ -171,6 +176,98 @@ function formatBackupType(
return type;
}
/**
* Which catalogue a skipped key belonged to, as a label.
*
* Spelled out with literal `t()` calls rather than an interpolated key, so
* `i18n-call-site-coverage.test.ts` can see each one and prove the bundle has
* it. An interpolated key is invisible to that guard and shows up as raw dot
* notation in production the day someone renames a catalogue.
*/
function catalogueLabel(
catalogue: SkippedCatalogue,
t: ReturnType<typeof useTranslations>["t"],
) {
if (catalogue === "cycleSymptom") {
return t("admin.section.backups.restoreSkippedCycleSymptom");
}
if (catalogue === "illnessSymptom") {
return t("admin.section.backups.restoreSkippedIllnessSymptom");
}
return t("admin.section.backups.restoreSkippedMoodFactor");
}
/**
* What the last restore could not put back.
*
* Its own component, and exported, so it can be rendered against a report in a
* test instead of only through a state transition the SSR smoke tests here
* cannot drive. A report that reaches the response and never reaches the screen
* is the same silence the reporting exists to end, so the render is worth
* proving on its own.
*
* It names every key rather than totalling them away: an operator who cannot
* see WHICH symptom went missing cannot decide whether it mattered.
*/
export function RestoreSkipReport({
report,
onDismiss,
}: {
report: RestoreSkipSummary;
onDismiss: () => void;
}) {
const { t } = useTranslations();
if (report.links === 0) return null;
return (
<div
role="status"
data-slot="restore-skip-report"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.restoreSkippedTitle", {
links: String(report.links),
})}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.restoreSkippedDescription")}
</p>
<ul className="mt-2 space-y-1">
{report.catalogueKeys.map((entry) => (
<li key={`${entry.catalogue}:${entry.key}`} className="text-xs">
<span className="font-mono">{entry.key}</span>{" "}
<span className="text-muted-foreground">
{catalogueLabel(entry.catalogue, t)}
{" · "}
{t("admin.section.backups.restoreSkippedLinks", {
count: String(entry.links),
})}
</span>
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="min-h-11"
>
{t("common.dismiss")}
</Button>
</div>
</div>
);
}
export function BackupsSection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -301,6 +398,12 @@ export function BackupsSection() {
if (file) upload.mutate(file);
}
// What the last restore could not put back. Held in state rather than left
// to the toast: a toast for "twelve symptom links are gone" disappears in
// four seconds and the operator has no way to get it back. This panel stays
// until they dismiss it.
const [skipped, setSkipped] = useState<RestoreSkipSummary | null>(null);
// Restore: typed-confirmation dialog. The mutation is keyed by row id
// and used inline by `<RestoreRowDialog>` below — keeping the
// mutation here lets the parent invalidate the list query on success.
@@ -310,14 +413,28 @@ export function BackupsSection() {
// destructive transaction. Include the row id so two different
// backups can both be restored independently in the same minute.
const idempotencyKey = `restore-${row.id}-${crypto.randomUUID()}`;
return apiPost<{ restored: true }>(
return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>(
`/api/admin/backups/${row.id}/restore`,
{ confirm: "RESTORE" },
{ headers: { "Idempotency-Key": idempotencyKey } },
);
},
onSuccess: () => {
toast.success(t("admin.section.backups.restoreSuccess"));
onSuccess: (data) => {
// A restore that dropped links is not a plain success and must not read
// as one. The count and the exact keys go on screen; the same report is
// in the audit row for anyone asking later.
const report = data.skipped;
if (report && report.links > 0) {
setSkipped(report);
toast.warning(
t("admin.section.backups.restoreSkippedToast", {
links: String(report.links),
}),
);
} else {
setSkipped(null);
toast.success(t("admin.section.backups.restoreSuccess"));
}
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
// Restore touches every personal-data table; nuke the broader
// cache so dashboards / lists rebuild against the new state.
@@ -467,6 +584,13 @@ export function BackupsSection() {
</div>
</div>
{skipped ? (
<RestoreSkipReport
report={skipped}
onDismiss={() => setSkipped(null)}
/>
) : null}
{isLoading ? (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
+32 -20
View File
@@ -26,6 +26,10 @@ import type {
SecondarySymptom,
} from "@/generated/prisma/client";
import type { BackupPayload } from "@/lib/validations/backup";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
/** The cycle slice of a backup payload. */
export interface CycleBackupOptions {
@@ -375,6 +379,11 @@ function enumOrNull<T extends string>(
* file recorded for it, or none if the file recorded none. `notesEncrypted` is
* written back as the ciphertext envelope verbatim — never re-encrypted.
*
* A key that resolves against nothing is dropped from the day's links and
* recorded on `skips`. It is a required argument rather than an optional one:
* a caller that forgets to pass it would restore exactly as before and report
* nothing, which is the failure this reporting exists to end.
*
* Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty
* cycle arrays + null profile) wipes nothing and recreates nothing.
*/
@@ -382,6 +391,7 @@ export async function restoreCycleData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: BackupPayload,
skips: RestoreSkipLog,
): Promise<CycleRestoreCleared> {
// Wipe (child links cascade off the day-log delete).
const dayLogs = await tx.cycleDayLog.deleteMany({
@@ -470,9 +480,10 @@ export async function restoreCycleData(
}
// Resolve the symptom catalogue once for the link re-creation.
const allKeys = Array.from(
new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])),
const referencedKeys = payload.cycleDayLogs.flatMap(
(d) => d.symptomKeys ?? [],
);
const allKeys = Array.from(new Set(referencedKeys));
const symptomIdByKey = new Map<string, string>();
if (allKeys.length > 0) {
const rows = await tx.cycleSymptom.findMany({
@@ -484,19 +495,18 @@ export async function restoreCycleData(
});
for (const r of rows) symptomIdByKey.set(r.key, r.id);
// Three lines below, an unknown CYCLE reference throws. An unknown symptom
// used to be filtered out instead: the day-log came back with one of its
// symptoms quietly missing, and the restore reported success. Same
// function, same class of missing reference, two different answers — so
// this one is loud now too.
// A cycle reference below still throws, and that difference is the point.
// A `cycleId` names a row THIS FILE was supposed to carry, so a dangling
// one means the file contradicts itself and nothing good comes of writing
// it. A symptom key names a row the INSTANCE owns: the seeded catalogue is
// reference data that drifts across releases, so a key the file wrote a
// year ago and this instance no longer seeds is an ordinary, expected
// mismatch. Refusing the file over it threw away every measurement, every
// dose, and every note to protect a symptom chip. The link is dropped and
// named instead — see `restore-skips.ts` for why neither throwing nor
// filtering in silence was an acceptable answer.
const unresolved = allKeys.filter((k) => !symptomIdByKey.has(k));
if (unresolved.length > 0) {
throw new Error(
`Unknown cycle symptom keys: ${unresolved.join(", ")}. ` +
"The backup references symptoms that are neither in this instance's " +
"seeded catalogue nor carried in the file.",
);
}
recordUnknownKeys(skips, "cycleSymptom", unresolved, referencedKeys);
}
// Recreate day-logs (with symptom links). The owning cycle is the latest
@@ -524,13 +534,15 @@ export async function restoreCycleData(
if (severity !== null) severityByKey.set(s.key, severity);
}
// Every key was proven resolvable above, so this maps rather than filters:
// a `.filter(Boolean)` here would silently re-open the hole that check
// just closed.
const symptomLinks = (d.symptomKeys ?? []).map((k) => {
// Every unresolvable key was counted into `skips` above, so dropping it
// here is a reported drop rather than a silent one. The rest of the day —
// flow, temperature, the encrypted note, every observation the person
// actually wrote — is unaffected by a symptom chip that will not resolve.
const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => {
const id = symptomIdByKey.get(k);
if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`);
return { symptomId: id, severity: severityByKey.get(k) ?? null };
return id
? [{ symptomId: id, severity: severityByKey.get(k) ?? null }]
: [];
});
const cycleId =
d.cycleId !== undefined
+89
View File
@@ -0,0 +1,89 @@
/**
* What a restore could not put back, and how much of it there was.
*
* Three of the restore's lookups resolve a key against a SEEDED CATALOGUE —
* cycle symptoms, illness symptoms, mood factors. The catalogue is reference
* data the instance owns, not data the file carries, so it legitimately drifts
* between the day a backup is written and the day it is read: a key gets
* renamed, a symptom is retired, the file is a year old, the instance is three
* releases newer. That is the ordinary case for a backup, not a corrupt file.
*
* Each of those lookups used to throw, which rolled the whole transaction back
* and answered 500 — one renamed symptom and the operator got none of the
* account back. Before that they filtered the unresolvable key away and
* reported success, which lost the same link with no trace at all. Neither is
* honest: the first destroys a recoverable restore, the second hides a real
* loss.
*
* So the unresolvable link is dropped and NAMED. This module is the naming.
* Every drop lands here, and the accumulated report rides the restore response,
* the audit row, and the wide event, so the count and the exact keys reach the
* person who ran the restore instead of a log line nobody reads.
*
* What is dropped is deliberately narrow: one (row, symptom) association. The
* day-log, the mood entry, the note, the flow, the temperature — everything the
* row itself holds — comes back. A link table is the only place this applies,
* because it is the only place where a value that will not resolve costs an
* edge rather than a record.
*/
/** Which seeded catalogue a key failed to resolve against. */
export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor";
/** One key this instance does not know, and the links it cost. */
export interface SkippedCatalogueKey {
catalogue: SkippedCatalogue;
/** The key exactly as the file wrote it. Reported verbatim so an operator
* can grep the file for it and see which days it was on. */
key: string;
/** How many links referenced it — twelve day-logs is twelve, not one. */
links: number;
}
/** Mutable accumulator threaded through one restore transaction. */
export type RestoreSkipLog = SkippedCatalogueKey[];
/** The report shape carried by the response, the audit row, and the UI. */
export interface RestoreSkipSummary {
/** Distinct unknown keys, ordered by catalogue then key. */
catalogueKeys: SkippedCatalogueKey[];
/** Total links dropped across every catalogue. Zero means nothing was lost. */
links: number;
}
/**
* Record the keys a catalogue lookup could not resolve.
*
* `referenced` is the FLAT list of keys the file used, one entry per link, not
* the deduplicated set the lookup queried with. A key that appears on twelve
* day-logs cost twelve links, and reporting it as one would understate the loss
* by a factor of twelve — which is the quiet-drop failure again, wearing a
* number.
*/
export function recordUnknownKeys(
log: RestoreSkipLog,
catalogue: SkippedCatalogue,
unresolved: readonly string[],
referenced: readonly string[],
): void {
for (const key of unresolved) {
log.push({
catalogue,
key,
links: referenced.filter((candidate) => candidate === key).length,
});
}
}
/** Fold the accumulator into the report the callers surface. */
export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary {
const catalogueKeys = [...log].sort((a, b) =>
a.catalogue === b.catalogue
? a.key.localeCompare(b.key)
: a.catalogue.localeCompare(b.catalogue),
);
return {
catalogueKeys,
links: catalogueKeys.reduce((total, entry) => total + entry.links, 0),
};
}
@@ -1074,6 +1074,20 @@ describe("canonical disaster-recovery backup round-trip", () => {
expect(invalidateUserData).not.toHaveBeenCalled();
});
/**
* The contract here is the rollback, not the trigger. The trigger used to be
* a mood factor key the instance did not know, and that is no longer a
* failure at all: a seeded catalogue legitimately drifts between the day a
* backup is written and the day it is read, so an unresolvable catalogue key
* now drops one link and names it in the response rather than costing the
* whole file.
*
* An intake event naming a medication the file does not carry is a different
* animal and still throws. That reference points at a row THIS FILE was
* supposed to contain, so a dangling one means the file contradicts itself,
* and writing a dose history against a medication that does not exist is not
* a recoverable restore. It is the honest trigger for the rollback contract.
*/
it("rolls back a failed full restore before invalidating caches", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
@@ -1092,6 +1106,15 @@ describe("canonical disaster-recovery backup round-trip", () => {
exportedAt: "2026-07-04T08:00:00.000Z",
userId: admin.id,
measurements: [],
medications: [],
intakeEvents: [
{
id: "failed-restore-intake",
medication: "a medication this file never carried",
scheduledFor: "2026-07-04T08:00:00.000Z",
takenAt: "2026-07-04T08:05:00.000Z",
},
],
moodEntries: [
{
id: "failed-restore-mood",
@@ -1100,7 +1123,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
score: 3,
tags: null,
loggedAt: "2026-07-04T08:00:00.000Z",
factors: [{ key: "missing-backup-factor", rating: 4 }],
factors: [],
},
],
});
@@ -0,0 +1,419 @@
/**
* A catalogue key the instance no longer knows must not cost the file.
*
* Three of the restore's lookups resolve a key against a seeded catalogue —
* cycle symptoms, illness symptoms, mood factors. Each of them used to throw on
* a key it could not resolve, and the throw was inside the one transaction that
* wipes and rebuilds the account. So one retired symptom rolled everything back:
* no measurements, no doses, no notes, no cycle history, HTTP 500. A backup a
* year old, restored onto an instance three releases newer, is the ordinary
* case for a backup, not a corrupt file.
*
* The link is dropped and reported now. This test proves both halves against
* the REAL admin restore route and against ROWS, never against a helper's
* return value:
*
* - everything else in the file lands (the measurement is the load-bearing
* assertion — it is the data the old behaviour destroyed to protect a
* symptom chip),
* - the surviving symptom keeps its link,
* - the retired key's link is gone AND named in the response, with the count,
* - the retired key is not resurrected in the catalogue: a restore does not
* get to invent reference data on the instance's behalf.
*
* Every owner row is deleted before the restore runs. That is the vacuousness
* guard, not tidiness: leave the fixture in place and a restore that did
* nothing at all would still find the rows sitting there and pass.
*
* Mutation check: put the `throw new Error("Unknown cycle symptom keys: …")`
* back in `restoreCycleData` and this test fails on `expected 500 to be 200`.
* Excise the whole `prisma.$transaction` body and it fails on the measurement
* that never came back.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { 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(),
}));
/**
* Catalogue rows the fixture owns, so the test never mutates the migration
* seed other tests in the same container depend on. `userId: null` on the two
* cycle rows is what makes them CATALOGUE rows: the payload builder carries
* only `where: { userId }` symptoms in `customSymptoms`, so a NULL-owner key
* the instance drops afterwards has nothing in the file that could put it back.
* That is exactly the drift being modelled.
*/
const KEPT_CYCLE_KEY = "test:cycle-symptom-that-stays";
const RETIRED_CYCLE_KEY = "test:cycle-symptom-retired-later";
const KEPT_ILLNESS_KEY = "test:illness-symptom-that-stays";
const RETIRED_ILLNESS_KEY = "test:illness-symptom-retired-later";
const KEPT_MOOD_KEY = "test:mood-factor-that-stays";
const RETIRED_MOOD_KEY = "test:mood-factor-retired-later";
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 naming a catalogue key this instance retired", () => {
it("restores the file, drops only the unresolvable links, and names them", async () => {
const prisma = getPrismaClient();
// Owner and operator are the same account so the fixture stays about the
// catalogue rather than about the ownership guard.
const owner = await prisma.user.create({
data: {
username: "catalogue-drift-owner",
email: "catalogue-drift-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);
/* ── the catalogues, before the drift ──────────────────────────────── */
const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow();
const keptCycleSymptom = await prisma.cycleSymptom.create({
data: {
categoryId: cycleCategory.id,
key: KEPT_CYCLE_KEY,
labelKey: "cycle.symptom.kept",
sortOrder: 1,
},
});
const retiredCycleSymptom = await prisma.cycleSymptom.create({
data: {
categoryId: cycleCategory.id,
key: RETIRED_CYCLE_KEY,
labelKey: "cycle.symptom.retired",
sortOrder: 2,
},
});
const keptIllnessSymptom = await prisma.illnessSymptom.create({
data: { key: KEPT_ILLNESS_KEY, labelKey: "illness.symptom.kept" },
});
const retiredIllnessSymptom = await prisma.illnessSymptom.create({
data: { key: RETIRED_ILLNESS_KEY, labelKey: "illness.symptom.retired" },
});
const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({
where: { userId: null },
});
const keptMoodTag = await prisma.moodTag.create({
data: {
categoryId: moodCategory.id,
key: KEPT_MOOD_KEY,
labelKey: "mood.factor.kept",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
},
});
const retiredMoodTag = await prisma.moodTag.create({
data: {
categoryId: moodCategory.id,
key: RETIRED_MOOD_KEY,
labelKey: "mood.factor.retired",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
},
});
/* ── the account's data ────────────────────────────────────────────── */
// The measurement carries no catalogue reference at all, which is the
// point: it is what the whole-file refusal was destroying.
await prisma.measurement.create({
data: {
userId: owner.id,
type: "WEIGHT",
value: 81.4,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-07-03T06:30:00.000Z"),
},
});
const cycle = await prisma.menstrualCycle.create({
data: {
userId: owner.id,
startDate: "2026-07-01",
periodEndDate: "2026-07-05",
tz: "Europe/Berlin",
},
});
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: keptCycleSymptom.id, severity: 2 },
{ symptomId: retiredCycleSymptom.id, severity: 4 },
],
},
},
});
// A SECOND day carrying the retired symptom. The report has to count links,
// not distinct keys: one key across two days is two lost links, and
// reporting it as one understates the loss by half.
await prisma.cycleDayLog.create({
data: {
userId: owner.id,
cycleId: cycle.id,
date: "2026-07-04",
flow: "LIGHT",
source: "MANUAL",
tz: "Europe/Berlin",
symptomLinks: { create: [{ symptomId: retiredCycleSymptom.id }] },
},
});
const episode = await prisma.illnessEpisode.create({
data: {
userId: owner.id,
label: "Summer cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: new Date("2026-07-02T00:00:00.000Z"),
dayLogs: {
create: {
userId: owner.id,
date: "2026-07-03",
functionalImpact: 2,
symptomLinks: {
create: [
{ symptomId: keptIllnessSymptom.id, severity: 1 },
{ symptomId: retiredIllnessSymptom.id, severity: 3 },
],
},
},
},
},
});
const moodEntry = await prisma.moodEntry.create({
data: {
userId: owner.id,
date: "2026-07-03",
mood: "GOOD",
score: 4,
source: "MOODLOG",
moodLoggedAt: new Date("2026-07-03T19:00:00.000Z"),
tagLinks: {
create: [
{ moodTagId: keptMoodTag.id, rating: 3 },
{ moodTagId: retiredMoodTag.id, rating: 5 },
],
},
},
});
/* ── the real writer, the real wire format, the real reader ────────── */
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: "CATALOGUE_DRIFT_ROUND_TRIP",
data: encrypt(JSON.stringify(parsed)),
},
});
/* ── the drift: three keys the instance stops carrying ─────────────── */
await prisma.cycleSymptom.delete({ where: { id: retiredCycleSymptom.id } });
await prisma.illnessSymptom.delete({
where: { id: retiredIllnessSymptom.id },
});
await prisma.moodTag.delete({ where: { id: retiredMoodTag.id } });
// Every owner row goes before the restore runs, so nothing below can pass
// on a row the fixture left behind. A restore that did nothing at all has
// to fail this test.
await prisma.measurement.deleteMany({ where: { userId: owner.id } });
await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } });
await prisma.menstrualCycle.deleteMany({ where: { userId: owner.id } });
await prisma.illnessEpisode.deleteMany({ where: { userId: owner.id } });
await prisma.moodEntry.deleteMany({ where: { userId: owner.id } });
expect(
await prisma.measurement.count({ where: { userId: owner.id } }),
).toBe(0);
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
// The whole file came back. Pre-fix this was 500 and the account was empty.
expect(response.status).toBe(200);
/* ── what actually landed in the database ──────────────────────────── */
// The data the old behaviour threw away to protect a symptom chip.
const measurements = await prisma.measurement.findMany({
where: { userId: owner.id },
select: { type: true, value: true, unit: true },
});
expect(measurements).toEqual([{ type: "WEIGHT", value: 81.4, unit: "kg" }]);
const restoredDayLogs = await prisma.cycleDayLog.findMany({
where: { userId: owner.id },
orderBy: { date: "asc" },
include: { symptomLinks: { include: { symptom: true } } },
});
// Both days come back, including the one whose ONLY symptom was retired.
// A day-log stripped of its last link is still the day the person logged:
// the flow, the timezone, the note are all still theirs.
expect(restoredDayLogs.map((d) => d.flow)).toEqual(["MEDIUM", "LIGHT"]);
// The surviving symptom keeps its link AND its intensity; the retired one
// is simply not there.
expect(
restoredDayLogs[0].symptomLinks.map((link) => ({
key: link.symptom.key,
severity: link.severity,
})),
).toEqual([{ key: KEPT_CYCLE_KEY, severity: 2 }]);
expect(restoredDayLogs[1].symptomLinks).toEqual([]);
const restoredIllnessDayLog = await prisma.illnessDayLog.findFirstOrThrow({
where: { userId: owner.id },
include: { symptomLinks: { include: { symptom: true } } },
});
expect(restoredIllnessDayLog.episodeId).toBe(episode.id);
expect(restoredIllnessDayLog.functionalImpact).toBe(2);
expect(
restoredIllnessDayLog.symptomLinks.map((link) => ({
key: link.symptom.key,
severity: link.severity,
})),
).toEqual([{ key: KEPT_ILLNESS_KEY, severity: 1 }]);
const restoredMood = await prisma.moodEntry.findFirstOrThrow({
where: { userId: owner.id },
include: { tagLinks: { include: { moodTag: true } } },
});
expect(restoredMood.id).toBe(moodEntry.id);
expect(restoredMood.score).toBe(4);
expect(
restoredMood.tagLinks.map((link) => ({
key: link.moodTag.key,
rating: link.rating,
})),
).toEqual([{ key: KEPT_MOOD_KEY, rating: 3 }]);
/* ── what the operator is told ─────────────────────────────────────── */
const body = (await response.json()) as {
data: {
skipped: {
links: number;
catalogueKeys: Array<{
catalogue: string;
key: string;
links: number;
}>;
};
};
};
// Four links across three keys — the cycle symptom was on two days.
expect(body.data.skipped.links).toBe(4);
expect(body.data.skipped.catalogueKeys).toEqual([
{ catalogue: "cycleSymptom", key: RETIRED_CYCLE_KEY, links: 2 },
{ catalogue: "illnessSymptom", key: RETIRED_ILLNESS_KEY, links: 1 },
{ catalogue: "moodFactor", key: RETIRED_MOOD_KEY, links: 1 },
]);
// The durable half. A screen gets closed; the audit row is still here.
const auditRow = await prisma.auditLog.findFirstOrThrow({
where: { action: "admin.backups.restore" },
orderBy: { createdAt: "desc" },
});
// `AuditLog.details` is a JSON string column, not a Json column.
const auditDetails = JSON.parse(auditRow.details ?? "{}") as {
skipped?: {
links?: number;
catalogueKeys?: Array<{ key: string }>;
};
};
expect(auditDetails.skipped?.links).toBe(4);
expect(auditDetails.skipped?.catalogueKeys?.map((e) => e.key)).toEqual([
RETIRED_CYCLE_KEY,
RETIRED_ILLNESS_KEY,
RETIRED_MOOD_KEY,
]);
// A restore reads reference data; it does not get to write it. Minting the
// retired keys back into the catalogue would preserve the links at the cost
// of a row that collides with whatever the next migration seeds under that
// key, and of a label nothing in the app can translate.
expect(
await prisma.cycleSymptom.count({ where: { key: RETIRED_CYCLE_KEY } }),
).toBe(0);
expect(
await prisma.illnessSymptom.count({
where: { key: RETIRED_ILLNESS_KEY },
}),
).toBe(0);
expect(
await prisma.moodTag.count({ where: { key: RETIRED_MOOD_KEY } }),
).toBe(0);
});
});
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup";
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
import { parseBackupPayload } from "@/lib/validations/backup";
process.env.ENCRYPTION_KEY ??=
@@ -219,9 +220,14 @@ describe("cycle backup — round-trips through build + restore", () => {
});
// Restore into a clean transaction (wipes then recreates).
const skips: RestoreSkipLog = [];
await prisma.$transaction(async (tx) => {
await restoreCycleData(tx, USER_ID, payload);
await restoreCycleData(tx, USER_ID, payload, skips);
});
// A file whose every symptom resolves reports nothing skipped. Absence
// reading as absence cuts both ways: the report has to stay empty when
// nothing was lost, or an operator learns to ignore it.
expect(skips).toEqual([]);
const restored = await prisma.cycleDayLog.findMany({
where: { userId: USER_ID },