From 768320f3d3cc6cd86bb09cd78b733a9077e4c2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 09:52:07 +0200 Subject: [PATCH] Restore what each medication was supposed to move The register said the drug comes back with no statement of what it was for, which is exactly what a pinned efficacy target is: a row exists ONLY where the person overrode the derived ATC / name resolution, so it is a decision and never a computation. Nothing recomputes it after a restore because there is nothing to recompute it from. The lab arm travels as a biomarker NAME rather than an id, the way a lab result's cross-reference already does. A portable restore mints a fresh catalogue id, and `Biomarker` is unique on `(userId, name)`, so the name is the only end of the reference that survives the trip. The obstacle was ordering, not machinery: the remap map already existed, but the drugs are rebuilt several hundred lines before the analytes are. The targets are collected against the id each drug actually got and written in a second pass once the biomarkers exist. The alternative was hoisting the biomarker restore above the medications, and it loses on two counts. `biomarkerByName` and `restoredBiomarkerIds` are read by the lab-result loop immediately below the block, so moving it would separate a map from its only other consumer and leave a later edit free to re-order them back with nothing noticing. And the dependency would read backwards: a pinned target is a leaf of the medication tree, and letting a leaf dictate where the clinical record gets rebuilt inverts what the file is about. The second pass states the constraint where it applies instead of hiding it in a line order. A named analyte the restore did not put back is dropped and reported as medicationTarget rather than written. `biomarkerId` is a real foreign key, so a dangling value would fail the constraint and cost the whole account over one override. Dropping beats nulling here because the override IS the reference: a row with neither arm set is what the resolver already reads as "no override", so keeping it would restore something that means nothing while still holding the primary slot. The round trip asserts the restored target points at the biomarker the restore itself wrote, resolved from the database rather than remembered from the fixture. Counting would have passed on a row whose biomarkerId came back null, and nothing else would have complained: the resolver falls back to deriving a target and the page still renders. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../api/admin/backups/[id]/restore/route.ts | 76 +++++++++++++++++++ src/components/admin/backups-section.tsx | 3 + .../__tests__/full-backup-payload.test.ts | 12 +++ src/lib/export/backup-plan.ts | 14 +++- src/lib/export/full-backup-payload.ts | 36 +++++++++ src/lib/export/restore-skips.ts | 13 +++- src/lib/validations/backup.ts | 30 ++++++++ tests/integration/backup-round-trip.test.ts | 53 +++++++++++++ 14 files changed, 246 insertions(+), 9 deletions(-) diff --git a/messages/de.json b/messages/de.json index 1c548a69d..ffdc1bf2d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Befundübernahme", "docsLink": "Dokumentation", "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", - "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens" + "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", + "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 27ec0d130..ab5924367 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Fact commitment", "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Personal best provenance", - "restoreSkippedEcgReference": "ECG strip provenance" + "restoreSkippedEcgReference": "ECG strip provenance", + "restoreSkippedMedicationTarget": "Medication target analyte" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 3bda9d5aa..02d3bba88 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Registro del dato", "docsLink": "Documentación", "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", - "restoreSkippedEcgReference": "Procedencia de un trazado de ECG" + "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", + "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 109c14a65..ad7830b76 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Enregistrement de la donnée", "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", - "restoreSkippedEcgReference": "Provenance d'un tracé ECG" + "restoreSkippedEcgReference": "Provenance d'un tracé ECG", + "restoreSkippedMedicationTarget": "Analyte cible d'un médicament" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 92d62e3de..606aee262 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Registrazione del dato", "docsLink": "Documentazione", "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", - "restoreSkippedEcgReference": "Provenienza di un tracciato ECG" + "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", + "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index 983ff3ed8..84dfdf332 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Zapis faktu", "docsLink": "Dokumentacja", "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", - "restoreSkippedEcgReference": "Pochodzenie zapisu EKG" + "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", + "restoreSkippedMedicationTarget": "Analit docelowy leku" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3c35f2952..5e4a31c63 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -566,6 +566,17 @@ const handler = apiHandler( const medByName = new Map(); const restoredMedicationIds = new Set(); + // 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 + // collected here against the id the drug ACTUALLY got and written in + // a second pass once the biomarkers exist; the pass itself says why + // hoisting the biomarker restore above the medications is the worse + // of the two orderings. + const pendingEfficacyTargets: Array<{ + medicationId: string; + target: (typeof payload.medications)[number]["efficacyTargets"][number]; + }> = []; for (const m of payload.medications) { const created = await tx.medication.create({ data: { @@ -771,6 +782,12 @@ const handler = apiHandler( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); + for (const target of m.efficacyTargets) { + pendingEfficacyTargets.push({ + medicationId: created.id, + target, + }); + } } if (payload.intakeEvents.length > 0) { @@ -1230,6 +1247,65 @@ const handler = apiHandler( // `committedRecordId` names the lab result it was committed to, and // that reference is resolved against the rows this loop writes. const restoredLabResultIds = new Set(); + // 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 + // target to reach the database at all. + // + // The second pass wins over hoisting the biomarker restore above the + // medications, for two reasons. `biomarkerByName` and + // `restoredBiomarkerIds` are read by the lab-result loop directly + // below, so moving the block would separate a map from its only + // other consumer and leave a future edit free to re-order them back + // without anything noticing. And the direction of the dependency + // would read backwards: a pinned target is a leaf of the medication + // tree, and letting a leaf dictate where the clinical record is + // rebuilt inverts what the file is about. A second pass states the + // constraint where it applies instead of hiding it in a line order. + // + // A named analyte the restore did not put back cannot be written: + // `biomarkerId` is a real foreign key, so a dangling value would + // fail the constraint and roll the entire account back over one + // override. The row is dropped and NAMED. Dropping rather than + // nulling is deliberate here: the override IS the reference, and a + // row with neither arm set is what the resolver already reads as "no + // override", so keeping it would restore something that means + // nothing while still claiming the primary slot. A target that + // carried no name in the first place is a genuine live state (the + // analyte was deleted before the export) and comes back as written. + const unresolvedTargetAnalytes: string[] = []; + const writableTargets = pendingEfficacyTargets.filter((pending) => { + const name = pending.target.biomarkerName; + if (!name || biomarkerByName.has(name)) return true; + unresolvedTargetAnalytes.push(name); + return false; + }); + if (writableTargets.length > 0) { + await tx.medicationEfficacyTarget.createMany({ + data: writableTargets.map(({ medicationId, target }) => ({ + ...(target.id ? { id: target.id } : {}), + medicationId, + measurementType: target.measurementType ?? null, + biomarkerId: target.biomarkerName + ? (biomarkerByName.get(target.biomarkerName) ?? null) + : null, + primary: target.primary ?? true, + ...(target.createdAt + ? { createdAt: new Date(target.createdAt) } + : {}), + ...(target.updatedAt + ? { updatedAt: new Date(target.updatedAt) } + : {}), + })), + }); + } + recordUnknownKeys( + skips, + "medicationTarget", + [...new Set(unresolvedTargetAnalytes)], + unresolvedTargetAnalytes, + ); + for (const lab of payload.labResults) { const biomarkerId = lab.biomarkerId !== undefined diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index a50a0d593..ce0575f7e 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -359,6 +359,9 @@ function catalogueLabel( if (catalogue === "ecgReference") { return t("admin.section.backups.restoreSkippedEcgReference"); } + if (catalogue === "medicationTarget") { + return t("admin.section.backups.restoreSkippedMedicationTarget"); + } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 7f0e37459..04a99afee 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -236,6 +236,18 @@ function makePrisma() { redValue: 180, redMode: "MINUTES", }, + // One pinned efficacy target, on the lab arm. The biomarker rides + // as a NAME, the way a lab result's cross-reference does. + efficacyTargets: [ + { + id: "target-canonical", + measurementType: null, + biomarker: { name: "Ferritin" }, + primary: true, + createdAt: new Date("2026-07-01T08:00:00.000Z"), + updatedAt: new Date("2026-07-01T08:00:00.000Z"), + }, + ], inventoryEvents: [ { id: "stock-in", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 10773ca00..dde330dc7 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -454,6 +454,18 @@ export const TWO_ENDED_MODELS = [ // fortnight at home, days after a restore that reported success. "EnvironmentContext", "EnvironmentTravelLocation", + // What each drug was supposed to move. The register said the drug comes + // back with no statement of what it was for, and that is exactly the row: + // one exists ONLY where the person overrode the derived ATC / name + // resolution, so it is a decision and never a computation. + // + // It travels by biomarker NAME rather than by id, the way a lab result's + // cross-reference does, because a portable restore mints a fresh catalogue + // id and `(userId, name)` is what the catalogue is unique on. The reference + // is a real foreign key, so a name the restore cannot resolve is dropped and + // reported rather than written — a dangling id here would fail the + // constraint and cost the whole account. + "MedicationEfficacyTarget", // The ECG strips. The register said the originating device may still hold // them, which is true for about as long as the phone beside it does — and // is not true at all once a Withings connection is revoked, because the @@ -506,8 +518,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { export const COVERAGE_PENDING: Readonly> = { MedicationScheduleRevision: "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", - MedicationEfficacyTarget: - "What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index c3d8a56c5..396f3504b 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -128,6 +128,7 @@ export interface FullBackupCounts medicationInventoryItems: number; medicationInventoryEvents: number; reminderPhaseConfigs: number; + medicationEfficacyTargets: number; moodEntries: number; customMoodTagCategories: number; hiddenMoodTags: number; @@ -425,6 +426,14 @@ export async function buildFullBackupPayload( inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, phaseConfig: true, + // What the drug was supposed to move. The biomarker is read by NAME + // rather than by id, exactly as the lab results beside it are: a + // portable restore mints a fresh biomarker id, and `(userId, name)` is + // the natural key the catalogue is already unique on. + efficacyTargets: { + orderBy: { createdAt: "asc" }, + include: { biomarker: { select: { name: true } } }, + }, }, }), prisma.medicationIntakeEvent.findMany({ @@ -797,6 +806,29 @@ export async function buildFullBackupPayload( reason: e.reason, occurredAt: e.occurredAt.toISOString(), })), + // What the drug is FOR, in the person's own words rather than the + // resolver's. A row exists only where they overrode the derived + // ATC / name inference, so it is the one durable statement of intent + // the "Wirkung" view holds — and the drug restores with no statement of + // what it was for without it. + // + // The lab arm travels as a NAME, the way a lab result's does. The id is + // fresh on a portable restore, and `Biomarker` is unique on + // `(userId, name)`, so the name is the only thing that survives the + // trip. It is resolved on the way back against the biomarkers the + // restore wrote. + efficacyTargets: m.efficacyTargets.map((t) => ({ + ...(disasterRecovery + ? { + id: t.id, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + } + : {}), + measurementType: t.measurementType, + biomarkerName: t.biomarker?.name ?? null, + primary: t.primary, + })), })), intakeEvents: intakeEvents.map((e) => ({ ...(disasterRecovery @@ -998,6 +1030,10 @@ export async function buildFullBackupPayload( 0, ), reminderPhaseConfigs: medications.filter((m) => m.phaseConfig).length, + medicationEfficacyTargets: medications.reduce( + (total, m) => total + m.efficacyTargets.length, + 0, + ), moodEntries: moodEntries.length, customMoodTagCategories: customMoodTagCategories.length, hiddenMoodTags: hiddenMoodTags.length, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 38927a546..0a110e7d5 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -113,7 +113,17 @@ * omits soft-deleted measurements, which is the ordinary way a live recording * ends up naming one the file does not carry. * - * The eleventh, `checkupClosure`, is not about a restore at all, and it borrows + * The eleventh, `medicationTarget`, names the lab analyte a medication's + * pinned efficacy target pointed at that the restore did not put back. It is + * the one kind here where the ROW is dropped rather than the pointer, and the + * reason is what the row is: an override exists only to say "this drug is for + * that analyte", so an override with nothing on the far side states nothing. + * The resolver already treats a target with neither arm set as "no override", + * so writing one back would restore a row that means nothing and still claims + * the primary slot. The drug, its schedule and its whole history come back; + * what is lost is one statement of intent, and the operator is told which. + * + * The twelfth, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -136,6 +146,7 @@ export type SkippedCatalogue = | "factCommitment" | "personalRecordReference" | "ecgReference" + | "medicationTarget" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 77c8caa62..ddc768dad 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -264,6 +264,27 @@ const reminderPhaseConfigSchema = z }) .passthrough(); +/** + * What a medication was supposed to move. + * + * The lab arm is a biomarker NAME rather than an id, the way a lab result's + * cross-reference is: the id is fresh on a portable restore, and `Biomarker` + * is unique on `(userId, name)`. Both arms are nullable because the live + * schema produces a row with neither — deleting a biomarker sets the column + * NULL and leaves the override behind as an orphan the resolver reads as "no + * override". + */ +const medicationEfficacyTargetSchema = z + .object({ + id: z.string().min(1).optional(), + measurementType: z.enum(MeasurementType).nullable().optional(), + biomarkerName: z.string().min(1).nullable().optional(), + primary: z.boolean().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -307,6 +328,9 @@ const medicationSchema = z inventoryItems: z.array(medicationInventoryItemSchema).default([]), inventoryEvents: z.array(medicationInventoryEventSchema).default([]), phaseConfig: reminderPhaseConfigSchema.nullable().optional(), + // Same default, same reason: an older file parses, and a drug whose + // target the resolver derives rather than the person pinning it writes []. + efficacyTargets: z.array(medicationEfficacyTargetSchema).default([]), }) .passthrough(); @@ -1697,6 +1721,8 @@ export interface BackupSummary { intakeEvents: number; /** Side effects recorded against a drug, across every medication. */ medicationSideEffects: number; + /** Pinned efficacy targets, across every medication. */ + medicationEfficacyTargets: number; moodEntries: number; /** v1.15.0 — observed cycle spans in the backup. */ cycles: number; @@ -1787,6 +1813,10 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, medication) => sum + medication.sideEffects.length, 0, ), + medicationEfficacyTargets: payload.medications.reduce( + (sum, medication) => sum + medication.efficacyTargets.length, + 0, + ), moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.length, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 56a617423..b785734cf 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -229,6 +229,9 @@ const COUNT_BACK: Record< EnvironmentTravelLocation: (p, userId) => p.environmentTravelLocation.count({ where: { userId } }), EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationEfficacyTarget: (p, userId) => + p.medicationEfficacyTarget.count({ where: { medication: { userId } } }), }; /** @@ -551,6 +554,17 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { takenAt: AT("2026-06-30T09:00:00.000Z"), }, }); + // What the drug above is FOR, pinned to the analyte just created. The two + // rows are seeded at opposite ends of this fixture on purpose: the drug is + // restored hundreds of lines before the analyte, so this pairing is what + // proves the second pass runs late enough to resolve the name. + await prisma.medicationEfficacyTarget.create({ + data: { + medicationId: medication.id, + biomarkerId: biomarker.id, + primary: true, + }, + }); await prisma.allergy.create({ data: { userId: OWNER_ID, substance: "Penicillin" }, }); @@ -1804,6 +1818,45 @@ describe("every model the plan claims two-ended survives a real restore", () => }, }); + // The pinned target, and the analyte it points at. + // + // Counting says one override returned. It cannot say the override still + // MEANS anything: a restore that wrote the row with `biomarkerId` nulled + // returns a drug with no statement of what it was for, which is precisely + // what the register said the alternative cost, and nothing anywhere would + // complain — the resolver silently falls back to deriving a target and the + // page still renders. + // + // Both ends are resolved from the DATABASE rather than remembered from the + // fixture. The property the second pass owes is that the target points at + // the biomarker THIS restore wrote, which an id carried over from the + // seeding would not distinguish from a restore that copied a stale value. + const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const restoredMedication = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const target = await prisma.medicationEfficacyTarget.findFirstOrThrow({ + where: { medication: { userId: OWNER_ID } }, + include: { biomarker: { select: { name: true, userId: true } } }, + }); + expect({ + medicationId: target.medicationId, + biomarkerId: target.biomarkerId, + biomarkerName: target.biomarker?.name ?? null, + biomarkerOwner: target.biomarker?.userId ?? null, + measurementType: target.measurementType, + primary: target.primary, + }).toEqual({ + medicationId: restoredMedication.id, + biomarkerId: restoredBiomarker.id, + biomarkerName: "Ferritin", + biomarkerOwner: OWNER_ID, + measurementType: null, + primary: true, + }); + // The strip, read back column by column. // // Counting proves a recording returned. It cannot tell that recording from