fix(backup): bring every relation link back from a portable restore

A portable file carries no id for a medication or a lab result, so the
restore mints fresh ones and the links that named the old ids were
dropped as having no end. Carry the source id as ref on both entries and
re-point the links through an old-to-new map on restore.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:25:50 +02:00
1 parent c508ce61e3
commit 1a7050d95d
6 files changed
+297 -5

No files matched your search

+3 -1
View File
@@ -1094,7 +1094,9 @@ export async function buildFullBackupPayload(
externalId: m.externalId,
updatedAt: m.updatedAt.toISOString(),
}
: {}),
: // No id: a portable restore mints a fresh one. The relation links
// still name this drug, so the file says which row they mean.
{ ref: m.id }),
name: m.name,
dose: m.dose,
active: m.active,
+5
View File
@@ -83,6 +83,8 @@ function readForExport(
export interface LabResultBackupEntry {
/** Present in canonical DR payloads so rows keep a stable identity. */
id?: string;
/** The source row's id in a portable payload, for the relation links. */
ref?: string;
panel: string | null;
analyte: string;
value: number | null;
@@ -455,6 +457,9 @@ export async function buildRecordsBackupSection(
note: null,
}
: {
// No id: a portable restore mints a fresh one. The relation links
// still name this row, so the file says which row they mean.
ref: r.id,
note: readForExport(
r.noteEncrypted,
"lab note",
+35 -4
View File
@@ -244,6 +244,19 @@ function placeable<T>(
return kept;
}
/**
* The id each medication and lab result had in the file, to the id the
* restore gave it. A portable file carries neither row's id (a restore there
* mints fresh ones), only a `ref` naming the source row, so a link that names
* the source id is re-pointed through this map. An id the map does not hold is
* used as it is: a disaster-recovery file keeps its ids, and a hand-edited one
* falls through to the placement check below.
*/
export interface RelationLinkSourceIds {
medications: ReadonlyMap<string, string>;
labResults: ReadonlyMap<string, string>;
}
/**
* Re-create the five link tables.
*
@@ -261,7 +274,13 @@ export async function restoreRelationLinksData(
ownerId: string,
payload: RelationLinksBackupSection,
skips: RestoreSkipLog,
sourceIds: RelationLinkSourceIds = {
medications: new Map(),
labResults: new Map(),
},
): Promise<RelationLinksRestoreCleared> {
const medication = (id: string) => sourceIds.medications.get(id) ?? id;
const labResult = (id: string) => sourceIds.labResults.get(id) ?? id;
const owned = { where: { userId: ownerId } };
const [c1, c2, c3, c4, c5] = await Promise.all([
tx.labResultDocumentLink.deleteMany(owned),
@@ -278,7 +297,10 @@ export async function restoreRelationLinksData(
});
const labDocs = placeable(
payload.labResultDocumentLinks,
payload.labResultDocumentLinks.map((r) => ({
...r,
labResultId: labResult(r.labResultId),
})),
[
[(r) => r.labResultId, written.labResults],
[(r) => r.documentId, written.documents],
@@ -297,7 +319,10 @@ export async function restoreRelationLinksData(
}
const encounterMeds = placeable(
payload.encounterMedicationLinks,
payload.encounterMedicationLinks.map((r) => ({
...r,
medicationId: medication(r.medicationId),
})),
[
[(r) => r.encounterId, written.encounters],
[(r) => r.medicationId, written.medications],
@@ -335,7 +360,10 @@ export async function restoreRelationLinksData(
}
const medDocs = placeable(
payload.medicationDocumentLinks,
payload.medicationDocumentLinks.map((r) => ({
...r,
medicationId: medication(r.medicationId),
})),
[
[(r) => r.medicationId, written.medications],
[(r) => r.documentId, written.documents],
@@ -354,7 +382,10 @@ export async function restoreRelationLinksData(
}
const medConditions = placeable(
payload.medicationConditionLinks,
payload.medicationConditionLinks.map((r) => ({
...r,
medicationId: medication(r.medicationId),
})),
[
[(r) => r.medicationId, written.medications],
[(r) => r.episodeId, written.episodes],
+19
View File
@@ -1158,6 +1158,12 @@ export async function restoreBackup(
const medByName = new Map<string, string>();
const restoredMedicationIds = new Set<string>();
// The id each medication had in the source account, to the id it got
// here. A disaster-recovery file keeps the id, so this is the
// identity there; a portable file names the source row only through
// `ref`, and the relation links (written last) are re-pointed through
// this map instead of being dropped as "end is gone".
const medicationIdBySourceId = new Map<string, string>();
// The efficacy targets cannot ride inside the medication create
// beside the schedules and the packs: `biomarkerId` is a foreign key
// into a catalogue this transaction has not restored yet. They are
@@ -1454,6 +1460,10 @@ export async function restoreBackup(
},
});
restoredMedicationIds.add(created.id);
const medicationSourceId = m.id ?? m.ref;
if (medicationSourceId) {
medicationIdBySourceId.set(medicationSourceId, created.id);
}
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
// v1.39.4 — the clinical category lives in its own table keyed on the
// medication id, so it is written after the row exists and inside the
@@ -2172,6 +2182,8 @@ export async function restoreBackup(
// `committedRecordId` names the lab result it was committed to, and
// that reference is resolved against the rows this loop writes.
const restoredLabResultIds = new Set<string>();
// Same arrangement as `medicationIdBySourceId`, for the lab results.
const labResultIdBySourceId = new Map<string, string>();
// What each drug was supposed to move, written now that both ends
// exist. The drugs came back several hundred lines up and the
// analytes only just now, and one of those two had to move for a
@@ -2280,6 +2292,9 @@ export async function restoreBackup(
},
});
restoredLabResultIds.add(createdLab.id);
const labSourceId = lab.id ?? lab.ref;
if (labSourceId)
labResultIdBySourceId.set(labSourceId, createdLab.id);
}
reportSection("illness");
@@ -2683,6 +2698,10 @@ export async function restoreBackup(
ownerId,
payload,
skips,
{
medications: medicationIdBySourceId,
labResults: labResultIdBySourceId,
},
);
// The Coach's threads, turns and document links. AFTER the
+6
View File
@@ -392,6 +392,10 @@ const customMedicationCategorySchema = z
const medicationSchema = z
.object({
id: z.string().min(1).optional(),
// The source row's id in a portable file, which carries no `id`: the
// relation links name the medication by it, and the restore re-points
// them at the row it writes (`relation-links-backup.ts`).
ref: z.string().min(1).optional(),
name: z.string().min(1),
dose: z.string(),
treatmentClass: z.enum(MedicationCategory).optional(),
@@ -1126,6 +1130,8 @@ const appSettingsBackupSchema = z
const labResultBackupSchema = z
.object({
id: z.string().min(1).optional(),
// The source row's id in a portable file, as on a medication.
ref: z.string().min(1).optional(),
panel: z.string().nullable().optional(),
analyte: z.string().min(1),
value: z.number().nullable().optional(),
@@ -0,0 +1,229 @@
/**
* The five relation link tables survive a PORTABLE export and restore.
*
* A portable file carries no row id for a medication or a lab result: the
* restore mints fresh ones, because the file may land on another instance or
* another account where the original ids mean nothing. The link tables name
* both of their ends by id, so a link that rode the file under the OLD id of
* either end used to point at nothing after the restore, and the restore's
* own placement check dropped it as "end is gone". Four of the five tables
* have a medication or a lab result at one end; only the visit to dose link
* came back.
*
* Driven through the real restore route. A portable file with documents is
* refused (no document bytes, see `backup-portable-file-restore.test.ts`), so
* the file restored here is the portable payload with the document entries
* taken from the disaster-recovery build of the same account: everything else
* (medications, lab results, links) is the portable bytes exactly as the
* export writes them.
*
* Mutation check: drop the `exportRef` from the portable medication or lab
* result entry and the matching link count goes to zero.
*/
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 { linkTargets } from "@/lib/links";
import { parseBackupPayload } from "@/lib/validations/backup";
import { POST } from "./restore-job-driver";
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(),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seed() {
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "portable-links-owner",
email: "portable-links-owner@example.test",
role: "ADMIN",
},
});
const userId = owner.id;
const session = await prisma.session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
const encounter = await prisma.encounter.create({
data: {
userId,
occurredAt: new Date("2026-07-01T09:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
},
});
const document = await prisma.inboundDocument.create({
data: {
userId,
kind: "LAB_RESULT",
title: "Thyroid panel",
mimeType: "application/pdf",
byteSize: 12,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: new Date("2026-06-30T00:00:00.000Z"),
},
});
const biomarker = await prisma.biomarker.create({
data: { userId, name: "TSH", unit: "mU/L" },
});
const labResult = await prisma.labResult.create({
data: {
userId,
biomarkerId: biomarker.id,
analyte: "TSH",
value: 2.1,
unit: "mU/L",
takenAt: new Date("2026-06-30T09:00:00.000Z"),
},
});
const episode = await prisma.illnessEpisode.create({
data: {
userId,
label: "Thyroid",
type: "CHRONIC",
onsetAt: new Date("2026-01-20T00:00:00.000Z"),
},
});
const medication = await prisma.medication.create({
data: { userId, name: "Levothyroxine", dose: "50 µg" },
});
// A second drug with no links, so a restore that bound every link to the
// first medication it found would be told apart from one that maps by key.
await prisma.medication.create({
data: { userId, name: "Vitamin D", dose: "1000 IU" },
});
const vaccination = await prisma.vaccinationRecord.create({
data: {
userId,
occurredAt: new Date("2026-05-14T00:00:00.000Z"),
antigenSlug: "tdap",
vaccineName: "Tdap",
},
});
const link = (
sourceKind: Parameters<typeof linkTargets>[1]["sourceKind"],
sourceId: string,
targetKind: Parameters<typeof linkTargets>[1]["targetKind"],
targetId: string,
) =>
linkTargets(prisma, {
userId,
sourceKind,
sourceId,
targetKind,
targetIds: [targetId],
});
await link("labResult", labResult.id, "document", document.id);
await link("encounter", encounter.id, "medication", medication.id);
await link("encounter", encounter.id, "vaccination", vaccination.id);
await link("medication", medication.id, "document", document.id);
await link("medication", medication.id, "conditionEpisode", episode.id);
return { userId, medication, labResult };
}
describe("a portable restore of the five relation link tables", () => {
it("brings every link back, bound to the records the restore wrote", async () => {
const prisma = getPrismaClient();
const { userId, medication, labResult } = await seed();
const portable = parseBackupPayload(
JSON.stringify((await buildFullBackupPayload(prisma, userId)).payload),
);
const dr = parseBackupPayload(
JSON.stringify(
(
await buildFullBackupPayload(prisma, userId, {
purpose: "disaster-recovery",
})
).payload,
),
);
// The premise: the portable file names no medication or lab result id.
expect(portable.medications.every((m) => m.id === undefined)).toBe(true);
expect(portable.labResults.every((l) => l.id === undefined)).toBe(true);
const file = parseBackupPayload(
JSON.stringify({ ...portable, documents: dr.documents }),
);
const backup = await prisma.dataBackup.create({
data: {
userId,
type: "PORTABLE_LINKS_RESTORE",
data: encrypt(JSON.stringify(file)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, await response.clone().text()).toBe(200);
// The ends were re-minted, so the old ids are gone.
const restoredMed = await prisma.medication.findFirstOrThrow({
where: { userId, name: "Levothyroxine" },
});
const restoredLab = await prisma.labResult.findFirstOrThrow({
where: { userId, analyte: "TSH" },
});
expect(restoredMed.id).not.toBe(medication.id);
expect(restoredLab.id).not.toBe(labResult.id);
const where = { userId };
const [labDocs, encMeds, encDoses, medDocs, medConds] = await Promise.all([
prisma.labResultDocumentLink.findMany({ where }),
prisma.encounterMedicationLink.findMany({ where }),
prisma.encounterVaccinationLink.findMany({ where }),
prisma.medicationDocumentLink.findMany({ where }),
prisma.medicationConditionLink.findMany({ where }),
]);
expect(labDocs.map((r) => r.labResultId)).toEqual([restoredLab.id]);
expect(encMeds.map((r) => r.medicationId)).toEqual([restoredMed.id]);
expect(encDoses).toHaveLength(1);
expect(medDocs.map((r) => r.medicationId)).toEqual([restoredMed.id]);
expect(medConds.map((r) => r.medicationId)).toEqual([restoredMed.id]);
});
});