From 14638a6660426e775caec7e1009a19f7325c0691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 09:45:10 +0200 Subject: [PATCH 1/3] Bring the ECG strips back from a restore The register said the originating device may still hold them. That holds for about as long as the phone beside the watch does, and not at all once a Withings connection is revoked: the signal id stops resolving with it. So a self-hoster who exported and wiped had a file with no traces in it and a restore that reported success. Both ends live in src/lib/export/ecg-backup.ts, the arrangement the reminders, visits and Coach sections already use. Three things needed deciding. The waveform follows the note contract: ciphertext verbatim in a disaster-recovery payload, the micro-volt samples decrypted in a portable one. There is no placeholder for a strip that will not decrypt the way the Coach has one for a turn, because waveformEncrypted is a required column. Such a recording is left out of a portable file with a warning on the wide event, so the number of strips in the file is the number of strips that can be shown. measurementId is a REAL foreign key against the event row a strip was filed with, unlike the Coach references next door. A value that will not resolve does not dangle quietly here, it violates the constraint and rolls the whole restore back over one cross-reference. A portable export omits soft-deleted measurements, so that case is reachable rather than theoretical. The reference is resolved against the measurements the restore wrote, nulled when it cannot be, and reported as ecgReference. The strips are wiped and rewritten by the section itself. The wipe at the top of the restore never reached them: a recording hangs off the account, not off a measurement, so deleting the measurements only nulled the pointer and left the row behind. The round trip asserts the row column by column rather than counting it. A recording is three things at once and each is the whole value of the row on its own: a strip with no rhythm verdict is a squiggle nobody has read, a strip with no recordedAt belongs to no day, and a strip with no samples is a claim that an ECG exists which nothing can draw. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../[id]/restore/__tests__/round-trip.test.ts | 1 + .../api/admin/backups/[id]/restore/route.ts | 20 + .../export/__tests__/per-type-routes.test.ts | 2 + .../__tests__/soft-delete-filter.test.ts | 2 + .../export/encrypted/__tests__/route.test.ts | 1 + src/components/admin/backups-section.tsx | 3 + .../__tests__/full-backup-payload.test.ts | 4 + src/lib/export/backup-plan.ts | 22 +- src/lib/export/ecg-backup.ts | 352 ++++++++++++++++++ src/lib/export/full-backup-payload.ts | 19 +- src/lib/export/restore-skips.ts | 9 + src/lib/validations/backup.ts | 37 ++ tests/integration/backup-round-trip.test.ts | 83 +++++ 19 files changed, 564 insertions(+), 9 deletions(-) create mode 100644 src/lib/export/ecg-backup.ts diff --git a/messages/de.json b/messages/de.json index 1d22c97c3..1c548a69d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6919,7 +6919,8 @@ "restoreSkippedExtractedFact": "Dokumentbefund", "restoreSkippedFactCommitment": "Befundübernahme", "docsLink": "Dokumentation", - "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung" + "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", + "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index d01087845..27ec0d130 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6919,7 +6919,8 @@ "restoreSkippedExtractedFact": "Document fact", "restoreSkippedFactCommitment": "Fact commitment", "docsLink": "Documentation", - "restoreSkippedPersonalRecordReference": "Personal best provenance" + "restoreSkippedPersonalRecordReference": "Personal best provenance", + "restoreSkippedEcgReference": "ECG strip provenance" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 7d5de9ed8..3bda9d5aa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6919,7 +6919,8 @@ "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registro del dato", "docsLink": "Documentación", - "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal" + "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", + "restoreSkippedEcgReference": "Procedencia de un trazado de ECG" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index c6bae9f4c..109c14a65 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6919,7 +6919,8 @@ "restoreSkippedExtractedFact": "Donnée du document", "restoreSkippedFactCommitment": "Enregistrement de la donnée", "docsLink": "Documentation", - "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel" + "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", + "restoreSkippedEcgReference": "Provenance d'un tracé ECG" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 72f424c33..92d62e3de 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6919,7 +6919,8 @@ "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registrazione del dato", "docsLink": "Documentazione", - "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale" + "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", + "restoreSkippedEcgReference": "Provenienza di un tracciato ECG" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index d726cc0e8..983ff3ed8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6919,7 +6919,8 @@ "restoreSkippedExtractedFact": "Fakt z dokumentu", "restoreSkippedFactCommitment": "Zapis faktu", "docsLink": "Dokumentacja", - "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego" + "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", + "restoreSkippedEcgReference": "Pochodzenie zapisu EKG" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 5cd049395..4100c070d 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -314,6 +314,7 @@ function sourceClient() { // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3b25022d4..3c35f2952 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -59,6 +59,7 @@ import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup"; import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; +import { restoreEcgData } from "@/lib/export/ecg-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -120,6 +121,7 @@ interface RestoreResponse { userAchievements: number; environmentContexts: number; environmentTravelLocations: number; + ecgRecordings: number; }; } @@ -1678,6 +1680,23 @@ const handler = apiHandler( skips, ); + // The ECG strips. AFTER the measurements on purpose: a recording + // carries a `measurementId` that is a real foreign key against the + // EVENT row it was filed with, so a reference written before that + // row exists does not merely dangle — it violates the constraint + // and rolls the whole restore back. The id set is the one the + // measurement branch actually wrote, threaded in rather than + // re-queried so the function stays a pure reader of this + // transaction. Both ends of this section live in + // `src/lib/export/ecg-backup.ts`. + const ecgCleared = await restoreEcgData( + tx, + ownerId, + payload, + new Set(stableRows.map((row) => row.id)), + skips, + ); + // The per-day readings and the location periods that explain them. // Neither references anything but the account, so this section has // no ordering constraint against any other and sits here beside the @@ -1742,6 +1761,7 @@ const handler = apiHandler( environmentContexts: environmentCleared.environmentContexts, environmentTravelLocations: environmentCleared.environmentTravelLocations, + ecgRecordings: ecgCleared.ecgRecordings, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 2d5ef7f7c..f29671ba1 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -60,6 +60,7 @@ vi.mock("@/lib/db", () => ({ // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -151,6 +152,7 @@ beforeEach(() => { vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never); vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never); vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue( diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 40169cfe2..329405629 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -63,6 +63,7 @@ vi.mock("@/lib/db", () => ({ // What a document was filed against, and what was read out of it. documentConditionLink: { findMany: vi.fn() }, extractedFact: { findMany: vi.fn() }, + ecgRecording: { findMany: vi.fn() }, }, })); @@ -174,6 +175,7 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); }); describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 99b2e7139..f24aef437 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -58,6 +58,7 @@ vi.mock("@/lib/db", () => ({ // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 26f29a1ce..a50a0d593 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -356,6 +356,9 @@ function catalogueLabel( if (catalogue === "personalRecordReference") { return t("admin.section.backups.restoreSkippedPersonalRecordReference"); } + if (catalogue === "ecgReference") { + return t("admin.section.backups.restoreSkippedEcgReference"); + } // 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 42de97aa7..7f0e37459 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -373,6 +373,10 @@ function makePrisma() { // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + // The ECG strips. Empty for the same reason as the sections above: + // an account whose watch has never taken one is the ordinary case the + // builder must still answer for. + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 5afdce432..10773ca00 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -236,6 +236,11 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // the reason the visits comment gives. "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + // The ECG strips, same arrangement again. The recording reads through its + // OWN delegate here rather than riding the measurement it references, + // because that reference is a pointer between two independently carried + // tables and not a parent-child ride. + "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -252,6 +257,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/document-filing-backup.ts", "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -448,6 +454,20 @@ export const TWO_ENDED_MODELS = [ // fortnight at home, days after a restore that reported success. "EnvironmentContext", "EnvironmentTravelLocation", + // 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 + // signal id stops resolving with it. + // + // `measurementId` is the reference that made this more than a copy job. It + // is a REAL foreign key against the EVENT measurement the strip was filed + // with, so a value the restore cannot resolve does not dangle quietly the + // way the Coach's `sourceConversationId` does: it violates the constraint + // and takes the whole account down with it. A portable export omits + // soft-deleted measurements, so that case is reachable rather than + // theoretical. The reference is resolved against what the restore wrote, + // nulled when it cannot be, and reported either way. + "EcgRecording", ] as const; /** One model claimed to travel both ways. */ @@ -488,8 +508,6 @@ export const COVERAGE_PENDING: Readonly> = { "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.", - EcgRecording: - "ECG traces and their rhythm classification. The originating device may still hold them, but a self-hoster who exported and wiped has nothing to re-sync from.", 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/ecg-backup.ts b/src/lib/export/ecg-backup.ts new file mode 100644 index 000000000..c21aa2682 --- /dev/null +++ b/src/lib/export/ecg-backup.ts @@ -0,0 +1,352 @@ +/** + * The ECG strips, with both backup ends in one file. + * + * Same arrangement as `reminders-backup.ts`, `visits-backup.ts` and + * `coach-backup.ts`, for the same reason: a reader asking "is this carried at + * both ends?" answers it here, and a reader who greps only the restore ROUTE + * gets a false negative because the route delegates. + * + * One model rides. Its register entry read "ECG traces and their rhythm + * classification. The originating device may still hold them, but a + * self-hoster who exported and wiped has nothing to re-sync from." That is the + * whole case: a watch keeps a strip only as long as the phone beside it does, + * and the Withings signal id stops resolving the moment the connection is + * revoked. + * + * ## The trace and the verdict are different losses + * + * A recording is three things at once: WHEN a heart was recorded, WHAT the + * device concluded about the rhythm, and the micro-volt series behind that + * conclusion. A restore that returned the row with a defaulted + * `rhythmClassification` would hand back a strip nobody has read a verdict off, + * and a restore that returned it with a defaulted `recordedAt` would hand back + * a strip that belongs to no day. Both are carried verbatim, and both are + * asserted in `tests/integration/backup-round-trip.test.ts` rather than counted. + * + * ## The waveform follows the note contract, with one exception + * + * A disaster-recovery payload carries the stored ciphertext verbatim as base64, + * because the same instance's key reads it back unchanged. A portable export + * decrypts it, because a portable export exists to be readable by the person + * who owns it, exactly as it already decrypts medication notes and mood notes. + * + * The exception is what happens when a strip cannot be decrypted at all — a row + * written under a key the instance has since dropped. The Coach substitutes a + * placeholder sentence for a turn in that state, because the other nine hundred + * turns are still the person's and a thrown error would cost them all of it. + * There is no placeholder for a waveform: `waveformEncrypted` is a required + * column, so the choice is between writing a recording that describes a strip + * nothing can draw and leaving it out. The unreadable recording is left OUT of + * a portable file and a warning is raised on the wide event, so the number of + * strips in the file is the number of strips that can be shown. A + * disaster-recovery payload never decrypts and is therefore never affected. + * + * ## The one reference that needs care + * + * `measurementId` addresses the IRREGULAR_RHYTHM_NOTIFICATION event row the + * strip was filed against, and unlike the Coach's `sourceConversationId` it is + * a REAL foreign key. A value pointing at nothing does not quietly stop meaning + * anything here: it violates the constraint and rolls the WHOLE restore back, + * which hands the operator an empty account over one dangling id. That is not + * hypothetical in this section — a portable export omits soft-deleted + * measurements, so a strip filed against a measurement the account has since + * deleted names a row the file does not carry. + * + * So the reference is resolved against the measurements the restore actually + * wrote, and a miss is NULLED and reported rather than written. Nulling rather + * than dropping is deliberate: the strip is the record, the event row is a + * cross-reference, and losing the recording to save the pointer would be the + * wrong way round. + */ +import { Buffer } from "node:buffer"; + +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { + MeasurementSource, + RhythmClassification, +} from "@/generated/prisma/client"; + +import { getEvent } from "@/lib/logging/context"; +import { + decryptWaveformFromBytes, + encryptWaveformToBytes, +} from "@/lib/withings/ecg-waveform-codec"; +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface EcgBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One recorded strip. */ +export interface EcgRecordingBackupEntry { + /** Present in a disaster-recovery payload; a portable file mints a fresh id + * because nothing else in the file addresses a recording. */ + id?: string; + source: MeasurementSource; + externalRecordingId: string; + /** When the strip was taken on-device. Never the export time: a recording + * without its own instant belongs to no day. */ + recordedAt: string; + /** Ciphertext as base64. Present on a disaster-recovery payload only. */ + waveformEncrypted?: string; + /** The micro-volt sample array. Present on a portable payload only. */ + waveform?: number[]; + samplingFrequency: number; + sampleCount: number; + durationSeconds: number | null; + lead: string | null; + averageHeartRate: number | null; + /** The device's own rhythm verdict. Carried verbatim — nothing can re-derive + * a conclusion a device reached about a strip. */ + rhythmClassification: RhythmClassification | null; + /** The paired EVENT measurement, remapped on the way back. */ + measurementId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface EcgBackupSection { + ecgRecordings: EcgRecordingBackupEntry[]; +} + +export interface EcgBackupCounts { + ecgRecordings: number; +} + +/** + * Every restorable `EcgRecording` scalar. + * + * Named rather than inlined so a column added to the model shows up as a diff + * here rather than as a silent omission from the file. + */ +const ECG_RECORDING_BACKUP_SELECT = { + id: true, + source: true, + externalRecordingId: true, + recordedAt: true, + waveformEncrypted: true, + samplingFrequency: true, + sampleCount: true, + durationSeconds: true, + lead: true, + averageHeartRate: true, + rhythmClassification: true, + measurementId: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.EcgRecordingSelect; + +/** + * Build the ECG slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole client, matching the other + * section builders. The table carries no tombstone column, so both purposes + * read the same rows; they differ only in how the waveform travels. + */ +export async function buildEcgBackupSection( + prisma: Pick, + userId: string, + options: EcgBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const rows = await prisma.ecgRecording.findMany({ + where: { userId }, + orderBy: { recordedAt: "asc" }, + select: ECG_RECORDING_BACKUP_SELECT, + }); + + const ecgRecordings: EcgRecordingBackupEntry[] = []; + for (const row of rows) { + const common = { + source: row.source, + externalRecordingId: row.externalRecordingId, + recordedAt: row.recordedAt.toISOString(), + samplingFrequency: row.samplingFrequency, + sampleCount: row.sampleCount, + durationSeconds: row.durationSeconds, + lead: row.lead, + averageHeartRate: row.averageHeartRate, + rhythmClassification: row.rhythmClassification, + measurementId: row.measurementId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + + if (disasterRecovery) { + ecgRecordings.push({ + id: row.id, + waveformEncrypted: Buffer.from(row.waveformEncrypted).toString( + "base64", + ), + ...common, + }); + continue; + } + + // See the file header: a strip that will not decrypt is left out rather + // than written as a recording with no trace behind it. + let waveform: number[]; + try { + waveform = decryptWaveformFromBytes(row.waveformEncrypted); + } catch (err) { + getEvent()?.addWarning( + `ECG waveform decrypt failed, recording omitted from portable export: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + continue; + } + ecgRecordings.push({ waveform, ...common }); + } + + return { ecgRecordings }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countEcgBackupSection( + section: EcgBackupSection, +): EcgBackupCounts { + return { ecgRecordings: section.ecgRecordings.length }; +} + +/** Counts the ECG restore wiped, for the audit trail. */ +export interface EcgRestoreCleared { + ecgRecordings: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults the key, so every + * caller already satisfies this, and one that stops satisfying it fails to + * compile instead of restoring nothing and reporting success. + */ +export interface EcgRestoreInput { + ecgRecordings: RestoredEcgRecording[]; +} + +/** + * What the parser hands over, which is looser than what the builder writes: an + * older file carries no ECG key at all, and every optional column arrives as + * `undefined` rather than `null`. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredEcgRecording = Pick< + EcgRecordingBackupEntry, + | "source" + | "externalRecordingId" + | "recordedAt" + | "samplingFrequency" + | "sampleCount" + | "createdAt" + | "updatedAt" +> & + OptionalNullable< + Pick< + EcgRecordingBackupEntry, + | "id" + | "waveformEncrypted" + | "waveform" + | "durationSeconds" + | "lead" + | "averageHeartRate" + | "rhythmClassification" + | "measurementId" + > + >; + +/** + * Re-create the account's ECG recordings. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. The wipe at the top of the restore does NOT reach these: an ECG row + * hangs off the account, not off a measurement, so deleting the measurements + * only nulls its `measurementId` and leaves the strip behind. Without the + * delete here a restore would stack a second copy of every recording on top of + * the first, and the `(userId, source, recordedAt, samplingFrequency)` unique + * would fail the whole transaction on the first duplicate. + * + * MUST be called AFTER the measurements are restored: `measurementId` is a real + * foreign key against them, so running earlier would make every reference + * unresolvable — and, worse than unresolvable, would take the restore down with + * a constraint violation if it were written anyway. + * + * `measurementIds` is the set of measurements the restore actually put back, + * passed in rather than re-queried so this stays a pure function of the + * transaction it was handed. + */ +export async function restoreEcgData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: EcgRestoreInput, + measurementIds: ReadonlySet, + skips: RestoreSkipLog, +): Promise { + const cleared = await tx.ecgRecording.deleteMany({ + where: { userId: ownerId }, + }); + + const droppedMeasurementRefs: string[] = []; + for (const recording of payload.ecgRecordings) { + let measurementId = recording.measurementId ?? null; + if (measurementId && !measurementIds.has(measurementId)) { + droppedMeasurementRefs.push(measurementId); + measurementId = null; + } + await tx.ecgRecording.create({ + data: { + ...(recording.id ? { id: recording.id } : {}), + userId: ownerId, + source: recording.source, + externalRecordingId: recording.externalRecordingId, + recordedAt: new Date(recording.recordedAt), + waveformEncrypted: resolveWaveformBytes(recording), + samplingFrequency: recording.samplingFrequency, + sampleCount: recording.sampleCount, + durationSeconds: recording.durationSeconds ?? null, + lead: recording.lead ?? null, + averageHeartRate: recording.averageHeartRate ?? null, + // Verbatim, never re-derived. The verdict is what a device concluded + // looking at the signal, and this instance is not that device. + rhythmClassification: recording.rhythmClassification ?? null, + measurementId, + createdAt: new Date(recording.createdAt), + updatedAt: new Date(recording.updatedAt), + }, + }); + } + + recordUnknownKeys( + skips, + "ecgReference", + [...new Set(droppedMeasurementRefs)], + droppedMeasurementRefs, + ); + + return { ecgRecordings: cleared.count }; +} + +/** + * A strip's stored bytes, whichever end of the contract the file came from. + * + * A disaster-recovery file carries ciphertext that decodes straight back into + * the column. A portable file carries the sample array, which is encrypted on + * the way in under the TARGET instance's key — the whole point of a portable + * file being portable. + */ +function resolveWaveformBytes( + recording: RestoredEcgRecording, +): Uint8Array { + if (recording.waveformEncrypted !== undefined) { + const decoded = Buffer.from(recording.waveformEncrypted, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; + } + return encryptWaveformToBytes(recording.waveform ?? []); +} diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a12cc5a9c..c3d8a56c5 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -90,6 +90,12 @@ import { type EnvironmentBackupCounts, type EnvironmentBackupSection, } from "@/lib/export/environment-backup"; +import { + buildEcgBackupSection, + countEcgBackupSection, + type EcgBackupCounts, + type EcgBackupSection, +} from "@/lib/export/ecg-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -111,7 +117,8 @@ export interface FullBackupCounts SensitiveBackupCounts, DocumentFilingBackupCounts, AwardsBackupCounts, - EnvironmentBackupCounts { + EnvironmentBackupCounts, + EcgBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -341,6 +348,7 @@ export async function buildFullBackupPayload( documentFiling, awards, environment, + ecg, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -549,6 +557,12 @@ export async function buildFullBackupPayload( // `src/lib/export/environment-backup.ts`, and the purpose is absent for // the same reason as the awards above. buildEnvironmentBackupSection(prisma, userId), + // The ECG strips, their rhythm verdicts and their traces. Both ends live + // in `src/lib/export/ecg-backup.ts` beside each other, the same + // arrangement as the sections above and for the same reason. + buildEcgBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -588,6 +602,7 @@ export async function buildFullBackupPayload( const documentFilingSection: DocumentFilingBackupSection = documentFiling; const awardsSection: AwardsBackupSection = awards; const environmentSection: EnvironmentBackupSection = environment; + const ecgSection: EcgBackupSection = ecg; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -946,6 +961,7 @@ export async function buildFullBackupPayload( ...documentFilingSection, ...awardsSection, ...environmentSection, + ...ecgSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -1001,6 +1017,7 @@ export async function buildFullBackupPayload( ...countDocumentFilingBackupSection(documentFiling), ...countAwardsBackupSection(awards), ...countEnvironmentBackupSection(environment), + ...countEcgBackupSection(ecg), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index a0731bf2f..38927a546 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -104,6 +104,14 @@ * `ON DELETE SET NULL` in the first place. The ordinary way it fails to * resolve is a portable export whose source measurement was soft-deleted, and * a legacy file whose measurements carry no ids at all. + * The tenth, `ecgReference`, names the EVENT measurement an ECG strip was + * filed against that the restore did not put back. Unlike `coachReference` it + * is a real foreign key, so writing the dangling value would not quietly stop + * meaning anything — it would violate the constraint and roll the whole + * restore back over one cross-reference. What is dropped is the POINTER: the + * strip, its instant and its rhythm verdict all restore. A portable export + * 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 * this shape deliberately rather than growing a second reporting mechanism @@ -127,6 +135,7 @@ export type SkippedCatalogue = | "extractedFact" | "factCommitment" | "personalRecordReference" + | "ecgReference" | "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 8fcfd7a97..77c8caa62 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1514,6 +1514,36 @@ const documentBackupSchema = z }) .passthrough(); +/** + * One ECG strip. + * + * `waveformEncrypted` and `waveform` are the two ends of the same contract, so + * both are optional here and exactly one arrives: a disaster-recovery file + * carries the ciphertext, a portable file carries the micro-volt samples. + * Requiring either would refuse half the valid files. `samplingFrequency` and + * `sampleCount` stay required in both, because without them the samples are a + * list of numbers with no time axis. + */ +const ecgRecordingBackupSchema = z + .object({ + id: z.string().min(1).optional(), + source: z.enum(MeasurementSource), + externalRecordingId: z.string().min(1), + recordedAt: isoDateTime, + waveformEncrypted: base64BytesSchema.optional(), + waveform: z.array(z.number()).optional(), + samplingFrequency: z.number().int(), + sampleCount: z.number().int(), + durationSeconds: z.number().nullable().optional(), + lead: z.string().nullable().optional(), + averageHeartRate: z.number().int().nullable().optional(), + rhythmClassification: z.enum(RhythmClassification).nullable().optional(), + measurementId: z.string().min(1).nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + }) + .passthrough(); + const backupManifestSchema = z .object({ documents: z @@ -1627,6 +1657,10 @@ export const backupPayloadSchema = z environmentTravelLocations: z .array(environmentTravelLocationBackupSchema) .default([]), + // The ECG strips. Defaulted for the same reason as the sections above: a + // file written before the recordings travelled carries no key, and an + // account whose watch has never taken one writes []. + ecgRecordings: z.array(ecgRecordingBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt // (fail-soft nulls). Disclosed in the file so a nulled field is @@ -1737,6 +1771,8 @@ export interface BackupSummary { environmentContexts: number; /** Declared stretches spent away from home. */ environmentTravelLocations: number; + /** ECG strips, with their rhythm verdict and their trace. */ + ecgRecordings: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1815,6 +1851,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { userAchievements: payload.userAchievements.length, environmentContexts: payload.environmentContexts.length, environmentTravelLocations: payload.environmentTravelLocations.length, + ecgRecordings: payload.ecgRecordings.length, }; } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6964b8c34..56a617423 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -58,6 +58,10 @@ import { encryptFactData, encryptFactProvenance, } from "@/lib/documents/store"; +import { + decryptWaveformFromBytes, + encryptWaveformToBytes, +} from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; import { POST } from "@/app/api/admin/backups/[id]/restore/route"; @@ -109,6 +113,8 @@ const COACH_ASSISTANT_TURN = const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; +/** A short micro-volt strip. Long enough that a defaulted empty array shows. */ +const ECG_SAMPLES = [-12, 4, 118, -33, 7, 2, -5, 61]; beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -222,6 +228,7 @@ const COUNT_BACK: Record< p.environmentContext.count({ where: { userId } }), EnvironmentTravelLocation: (p, userId) => p.environmentTravelLocation.count({ where: { userId } }), + EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }), }; /** @@ -247,6 +254,37 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { source: "MANUAL", }, }); + // The EVENT row an ECG strip is filed against, and the strip itself. The + // pairing is the point: `EcgRecording.measurementId` is a real foreign key, + // so this is the reference whose remap has to survive the round trip rather + // than fail the transaction. + const rhythmEvent = await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "IRREGULAR_RHYTHM_NOTIFICATION", + value: 1, + unit: "event", + measuredAt: AT("2026-06-15T14:22:00.000Z"), + source: "WITHINGS", + rhythmClassification: "IRREGULAR", + }, + }); + await prisma.ecgRecording.create({ + data: { + userId: OWNER_ID, + source: "WITHINGS", + externalRecordingId: "round-trip-signal-1", + recordedAt: AT("2026-06-15T14:22:00.000Z"), + waveformEncrypted: encryptWaveformToBytes(ECG_SAMPLES), + samplingFrequency: 300, + sampleCount: ECG_SAMPLES.length, + durationSeconds: ECG_SAMPLES.length / 300, + lead: "I", + averageHeartRate: 88, + rhythmClassification: "IRREGULAR", + measurementId: rhythmEvent.id, + }, + }); await prisma.intradayCumulativeProfile.create({ data: { userId: OWNER_ID, @@ -1766,6 +1804,51 @@ describe("every model the plan claims two-ended survives a real restore", () => }, }); + // The strip, read back column by column. + // + // Counting proves a recording returned. It cannot tell that recording from + // one whose trace is an empty array, whose verdict defaulted to NULL and + // whose instant is the restore time — and each of those three is the whole + // value of the row on its own. A strip with no rhythm classification is a + // squiggle nobody has read; a strip with no `recordedAt` belongs to no day + // and cannot be put beside the event it explains; a strip with no samples + // is a claim that an ECG exists which nothing can draw. + // + // `measurementId` is resolved from the DATABASE rather than remembered + // from the fixture, because the property the remap owes is that the strip + // points at the event row THIS restore wrote. + const restoredEvent = await prisma.measurement.findFirstOrThrow({ + where: { userId: OWNER_ID, type: "IRREGULAR_RHYTHM_NOTIFICATION" }, + }); + const strip = await prisma.ecgRecording.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + source: strip.source, + externalRecordingId: strip.externalRecordingId, + recordedAt: strip.recordedAt.toISOString(), + samplingFrequency: strip.samplingFrequency, + sampleCount: strip.sampleCount, + durationSeconds: strip.durationSeconds, + lead: strip.lead, + averageHeartRate: strip.averageHeartRate, + rhythmClassification: strip.rhythmClassification, + measurementId: strip.measurementId, + waveform: decryptWaveformFromBytes(strip.waveformEncrypted), + }).toEqual({ + source: "WITHINGS", + externalRecordingId: "round-trip-signal-1", + recordedAt: "2026-06-15T14:22:00.000Z", + samplingFrequency: 300, + sampleCount: ECG_SAMPLES.length, + durationSeconds: ECG_SAMPLES.length / 300, + lead: "I", + averageHeartRate: 88, + rhythmClassification: "IRREGULAR", + measurementId: restoredEvent.id, + waveform: ECG_SAMPLES, + }); + // The appointment's reminder reference survives too — same remap, other // referrer. const restoredEncounter = await prisma.encounter.findFirstOrThrow({ 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 2/3] 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 From a74dc289a82245937e3f1d0ca55bdc1ab8d108df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 10:02:10 +0200 Subject: [PATCH 3/3] Carry the archived schedule eras, chain and all The register said a restored medication keeps today's schedule and loses the record of when the dose or the timing moved. Measured, it costs more than a record: the band minter reads each past day against the schedule that was live THEN, so an account that came back without its eras had its own compliance history rewritten against a plan it was never on. The hard part was supersededByRevisionId, the pointer from a corrected ARCHIVED era to the MANUAL row that replaced it. Three things about it had to be settled. It is a bare id column with no relation, so nothing in the database refuses a value that points at nothing. That makes losing it quieter and worse than a broken foreign key would be: every era consumer SKIPS a row that carries the pointer, so a corrected era whose link came back null goes live again beside its own correction, and the timeline shows one window twice with past days minted against whichever the query read first. A portable restore mints fresh ids, so the pointer cannot travel as one. It travels as a POSITION in the drug's own ordered era list. That list is the only address space both ends share: the builder sorts it totally (validFrom, then createdAt, then id) so two eras sharing an instant cannot swap places between two exports of the same account, and the pointer never crosses a drug, because every route that writes it scopes the update to one medication. The same mechanism serves both purposes rather than one carrying ids and the other positions. Writing it needs two passes, because the row a link addresses is as often written after the row addressing it as before. Pass one creates every era with the link null; pass two patches the links once every position has an id. A hand-edited file that names a position out of range, or an era's own position, gets the era and not the pointer, and the position is reported as scheduleRevisionLink with the JSON path into the file. Refusing the file would cost an operator every era of every drug over one bad integer, and writing the link would point a correction at whatever row happened to be there. A row that supersedes itself is treated the same way: every consumer would skip it, so the era would vanish from the timeline while still sitting in the table. The round trip seeds three eras with one supersede among them and asserts the shape of the chain, then asserts that exactly two are live. Three rows with every link null satisfies any count and has thrown away the only thing the pointer says. --- 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 | 86 +++++++ src/components/admin/backups-section.tsx | 3 + .../__tests__/full-backup-payload.test.ts | 23 ++ src/lib/export/backup-plan.ts | 17 +- src/lib/export/full-backup-payload.ts | 57 +++++ src/lib/export/restore-skips.ts | 12 +- src/lib/validations/backup.ts | 37 +++ tests/integration/backup-round-trip.test.ts | 218 ++++++++++++++++++ 14 files changed, 462 insertions(+), 9 deletions(-) diff --git a/messages/de.json b/messages/de.json index ffdc1bf2d..f47b95c2c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6921,7 +6921,8 @@ "docsLink": "Dokumentation", "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", - "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation" + "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", + "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index ab5924367..f335611ac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Personal best provenance", "restoreSkippedEcgReference": "ECG strip provenance", - "restoreSkippedMedicationTarget": "Medication target analyte" + "restoreSkippedMedicationTarget": "Medication target analyte", + "restoreSkippedScheduleRevisionLink": "Schedule era succession" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 02d3bba88..c33bbad2d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentación", "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", - "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento" + "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento", + "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index ad7830b76..9edb08991 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", "restoreSkippedEcgReference": "Provenance d'un tracé ECG", - "restoreSkippedMedicationTarget": "Analyte cible d'un médicament" + "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", + "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 606aee262..5df1f3bab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentazione", "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", - "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco" + "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", + "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index 84dfdf332..ef3622552 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6921,7 +6921,8 @@ "docsLink": "Dokumentacja", "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", - "restoreSkippedMedicationTarget": "Analit docelowy leku" + "restoreSkippedMedicationTarget": "Analit docelowy leku", + "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu" }, "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 5e4a31c63..f15f3bc8a 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -577,6 +577,12 @@ const handler = apiHandler( medicationId: string; target: (typeof payload.medications)[number]["efficacyTargets"][number]; }> = []; + // Supersede pointers the file states and the restore cannot honour. + // Reported as JSON paths into the file rather than as ids: the file + // is where an operator has to go to see what the position meant, and + // an id would be one the restore never wrote. + const unresolvedRevisionLinks: string[] = []; + let medicationIndex = 0; for (const m of payload.medications) { const created = await tx.medication.create({ data: { @@ -782,13 +788,93 @@ const handler = apiHandler( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); + + // ── The archived schedule eras, in two passes ──────────────── + // + // An era carries `supersededByRevisionId`, the pointer from a + // corrected ARCHIVED row to the MANUAL one that replaced it. The + // column has no `@relation`, so a value addressing nothing costs + // no error — it just stops meaning anything, which is the more + // dangerous shape here, because every era consumer SKIPS a row + // that carries the pointer. Lose it and the superseded original + // goes live again beside its own correction: two overlapping eras + // for one window, and past days minted against a plan the account + // had already corrected. + // + // A portable restore mints fresh ids, so the pointer cannot travel + // as one. It travels as a POSITION in this drug's own ordered era + // list, because that list is the only thing both ends agree on: + // the builder sorts it totally (validFrom, then createdAt, then + // id) so two eras sharing an instant cannot swap places between + // two exports of the same account, and the pointer never crosses a + // drug — every route that writes it scopes the update to one + // medication — so the drug's own list is a complete address space. + // + // Pass one writes every era with the link NULL, because the row a + // link addresses is as often written after the row addressing it + // as before. Pass two patches the links once every position has an + // id. + const revisionIds: string[] = []; + for (const revision of m.scheduleRevisions) { + const row = await tx.medicationScheduleRevision.create({ + data: { + ...(revision.id ? { id: revision.id } : {}), + medicationId: created.id, + validFrom: new Date(revision.validFrom), + validUntil: new Date(revision.validUntil), + payload: toJson(revision.payload), + source: revision.source ?? "ARCHIVED", + supersededByRevisionId: null, + ...(revision.createdAt + ? { createdAt: new Date(revision.createdAt) } + : {}), + }, + select: { id: true }, + }); + revisionIds.push(row.id); + } + for (const [index, revision] of m.scheduleRevisions.entries()) { + const to = revision.supersededByIndex; + if (to === null || to === undefined) continue; + // A hand-edited file can name a position this drug does not + // have, or the era's OWN position — and a row that supersedes + // itself is skipped by every consumer, so the era would + // disappear from the timeline while still sitting in the table. + // Neither can be honoured and neither may be guessed at, so the + // link stays NULL and the position is reported. The era still + // restores: the window and the plan it held are the history, and + // the correction pointer is the smaller loss. + if ( + !Number.isInteger(to) || + to < 0 || + to >= revisionIds.length || + to === index + ) { + unresolvedRevisionLinks.push( + `medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`, + ); + continue; + } + await tx.medicationScheduleRevision.update({ + where: { id: revisionIds[index] }, + data: { supersededByRevisionId: revisionIds[to] }, + }); + } + for (const target of m.efficacyTargets) { pendingEfficacyTargets.push({ medicationId: created.id, target, }); } + medicationIndex += 1; } + recordUnknownKeys( + skips, + "scheduleRevisionLink", + [...new Set(unresolvedRevisionLinks)], + unresolvedRevisionLinks, + ); if (payload.intakeEvents.length > 0) { // An event whose medication did not come back used to be mapped to diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index ce0575f7e..598d6aab9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -362,6 +362,9 @@ function catalogueLabel( if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } + if (catalogue === "scheduleRevisionLink") { + return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); + } // 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 04a99afee..99d035567 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -236,6 +236,29 @@ function makePrisma() { redValue: 180, redMode: "MINUTES", }, + // Two archived eras, the first corrected by the second. The + // pointer is what makes this more than two rows: the payload has + // to carry it as the SECOND era's position, not as its id. + scheduleRevisions: [ + { + id: "era-original", + validFrom: new Date("2026-01-01T00:00:00.000Z"), + validUntil: new Date("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00" }], + source: "ARCHIVED", + supersededByRevisionId: "era-correction", + createdAt: new Date("2026-03-01T00:00:01.000Z"), + }, + { + id: "era-correction", + validFrom: new Date("2026-03-01T00:00:00.000Z"), + validUntil: new Date("2026-05-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00" }], + source: "MANUAL", + supersededByRevisionId: null, + createdAt: new Date("2026-05-02T00:00:01.000Z"), + }, + ], // One pinned efficacy target, on the lab arm. The biomarker rides // as a NAME, the way a lab result's cross-reference does. efficacyTargets: [ diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index dde330dc7..5f47cadf2 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -454,6 +454,21 @@ export const TWO_ENDED_MODELS = [ // fortnight at home, days after a restore that reported success. "EnvironmentContext", "EnvironmentTravelLocation", + // How a schedule got to where it is. The register said a restored + // medication keeps today's schedule and loses the record of when the dose or + // the timing moved. Measured, it costs more than a record: the era minter + // reads past days against the schedule that was live THEN, so an account + // that came back without its eras has its own compliance history rewritten + // against a plan it was not on. + // + // `supersededByRevisionId` is the reference that made this its own problem. + // It is a bare id column with no `@relation`, so a dangling value costs no + // error and simply stops meaning anything — and every era consumer skips a + // row that carries it, which means losing the pointer puts a corrected era + // back on the timeline beside its own correction. It travels as a POSITION + // in the drug's own ordered era list, because a portable restore mints fresh + // ids and the list is the only address space both ends share. + "MedicationScheduleRevision", // 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 @@ -516,8 +531,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { * rather than by what is cheapest to write. */ 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.", 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 396f3504b..b04d6e6c6 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -129,6 +129,7 @@ export interface FullBackupCounts medicationInventoryEvents: number; reminderPhaseConfigs: number; medicationEfficacyTargets: number; + medicationScheduleRevisions: number; moodEntries: number; customMoodTagCategories: number; hiddenMoodTags: number; @@ -426,6 +427,18 @@ export async function buildFullBackupPayload( inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, phaseConfig: true, + // The archived schedule eras. Ordered TOTALLY, not just by the column + // that reads like the sort key: the position in this list is the + // identifier the supersede pointer travels as, so two eras sharing a + // `validFrom` must not be free to swap places between two exports of + // the same account. + scheduleRevisions: { + orderBy: [ + { validFrom: "asc" }, + { createdAt: "asc" }, + { id: "asc" }, + ] as const, + }, // 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 @@ -613,6 +626,19 @@ export async function buildFullBackupPayload( const environmentSection: EnvironmentBackupSection = environment; const ecgSection: EcgBackupSection = ecg; + // Where each archived era sits in its OWN drug's ordered list. Built once + // here rather than per row, and scoped per medication because the supersede + // pointer never crosses a drug — every route that writes it scopes the + // update to the medication it belongs to. + const revisionIndexByMedication = new Map( + medications.map((m) => [ + m.id, + new Map( + m.scheduleRevisions.map((revision, index) => [revision.id, index]), + ), + ]), + ); + const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), @@ -806,6 +832,33 @@ export async function buildFullBackupPayload( reason: e.reason, occurredAt: e.occurredAt.toISOString(), })), + // How the schedule got to where it is. The drug comes back on today's + // plan either way; without these the record of when the dose or the + // timing moved is gone, and the era minter reads every past day against + // the CURRENT schedule instead of the one that was live then — so a + // restored account's own compliance history quietly changes. + // + // `supersededByRevisionId` travels as a POSITION in this list rather + // than as an id. See the restore for the whole argument; the short form + // is that a portable restore mints fresh ids, so an id would point at + // nothing, and the list this array IS is the only stable thing both ends + // agree on. A pointer at a row outside this drug's own list cannot be + // expressed and is written as null — the schema allows it, no code path + // produces it, and inventing an index for it would be worse than saying + // nothing. + scheduleRevisions: m.scheduleRevisions.map((r) => ({ + ...(disasterRecovery ? { id: r.id } : {}), + validFrom: r.validFrom.toISOString(), + validUntil: r.validUntil.toISOString(), + payload: r.payload, + source: r.source, + supersededByIndex: r.supersededByRevisionId + ? (revisionIndexByMedication + .get(m.id) + ?.get(r.supersededByRevisionId) ?? null) + : null, + createdAt: r.createdAt.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 @@ -1034,6 +1087,10 @@ export async function buildFullBackupPayload( (total, m) => total + m.efficacyTargets.length, 0, ), + medicationScheduleRevisions: medications.reduce( + (total, m) => total + m.scheduleRevisions.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 0a110e7d5..ef8acb6c3 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -123,7 +123,16 @@ * 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 + * The twelfth, `scheduleRevisionLink`, names a supersede pointer between two + * archived schedule eras that the restore could not honour. Its key is a JSON + * PATH into the file rather than an id, because the pointer travels as a + * position in the drug's own era list and there is no id on either end that + * the file and the database both know. Only a hand-edited or truncated file + * can trip it: the builder writes a position it has just computed from the + * same list. What is lost is the pointer and not the era — the window and the + * plan it held still restore. + * + * The thirteenth, `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 @@ -147,6 +156,7 @@ export type SkippedCatalogue = | "personalRecordReference" | "ecgReference" | "medicationTarget" + | "scheduleRevisionLink" | "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 ddc768dad..9be51be04 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -264,6 +264,34 @@ const reminderPhaseConfigSchema = z }) .passthrough(); +/** + * One archived schedule era. + * + * `supersededByIndex` is the self-reference, and it is a POSITION in the + * drug's own `scheduleRevisions` array rather than an id: a portable restore + * mints fresh ids, so an id would address nothing. It stays a plain integer + * with no range check here on purpose — the file is the wrong place to learn + * that a position is out of range, because the array it indexes into is the + * array being parsed. The restore does that check, against the rows it + * actually wrote, and reports what it cannot resolve. + * + * `payload` is the snapshot of the superseded schedule rows, carried as + * written. Unknown on purpose rather than typed: it is an era's frozen copy of + * a shape that has changed several times, and a restore must not refuse a file + * because a five-release-old snapshot spells a field the current form does not. + */ +const medicationScheduleRevisionSchema = z + .object({ + id: z.string().min(1).optional(), + validFrom: isoDateTime, + validUntil: isoDateTime, + payload: z.unknown(), + source: z.string().min(1).optional(), + supersededByIndex: z.number().int().nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + /** * What a medication was supposed to move. * @@ -331,6 +359,9 @@ const medicationSchema = z // 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([]), + // Same default again. A drug whose plan has never been replaced has no + // archived era, and a file written before the eras travelled has no key. + scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), }) .passthrough(); @@ -1723,6 +1754,8 @@ export interface BackupSummary { medicationSideEffects: number; /** Pinned efficacy targets, across every medication. */ medicationEfficacyTargets: number; + /** Archived schedule eras, across every medication. */ + medicationScheduleRevisions: number; moodEntries: number; /** v1.15.0 — observed cycle spans in the backup. */ cycles: number; @@ -1817,6 +1850,10 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, medication) => sum + medication.efficacyTargets.length, 0, ), + medicationScheduleRevisions: payload.medications.reduce( + (sum, medication) => sum + medication.scheduleRevisions.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 b785734cf..a6daaf312 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -232,6 +232,8 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationEfficacyTarget: (p, userId) => p.medicationEfficacyTarget.count({ where: { medication: { userId } } }), + MedicationScheduleRevision: (p, userId) => + p.medicationScheduleRevision.count({ where: { medication: { userId } } }), }; /** @@ -467,6 +469,42 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, ], }); + // Three archived eras, and the CHAIN through them is the content. The + // middle one was corrected, so it points at the third; the first and the + // third stand on their own. A restore that returned three rows with every + // link null satisfies any count and has destroyed the only thing the + // pointer says — every era consumer skips a superseded row, so the + // corrected middle era would come back live beside the correction that + // replaced it and the timeline would show the same window twice. + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-01-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }], + source: "ARCHIVED", + }, + }); + const correctionEra = await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-03-01T00:00:00.000Z"), + validUntil: AT("2026-05-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }], + source: "MANUAL", + }, + }); + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-02-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }], + source: "ARCHIVED", + supersededByRevisionId: correctionEra.id, + }, + }); + await prisma.medicationPauseEra.createMany({ data: [ { @@ -1817,6 +1855,71 @@ describe("every model the plan claims two-ended survives a real restore", () => confidence: 0.94, }, }); + // The archived eras, and the CHAIN through them. + // + // Counting says three eras returned, and three eras with every link null + // would satisfy it exactly while having thrown away the only thing the + // pointer states. That is not a cosmetic loss: every era consumer skips a + // superseded row, so a corrected era whose link came back null goes live + // again beside the correction that replaced it, and the account gets two + // overlapping plans for one window with its past compliance minted against + // whichever the query happened to read. + // + // Asserted as the shape of the chain rather than as ids: the ids are fresh + // on a portable restore, and what the two-pass write owes is that era two + // still points at era three and that nothing else points anywhere. + const scheduleEras = await prisma.medicationScheduleRevision.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: [{ validFrom: "asc" }, { createdAt: "asc" }, { id: "asc" }], + }); + const eraPositionById = new Map( + scheduleEras.map((era, index) => [era.id, index]), + ); + expect( + scheduleEras.map((era) => ({ + validFrom: era.validFrom.toISOString(), + validUntil: era.validUntil.toISOString(), + source: era.source, + payload: era.payload, + supersedes: era.supersededByRevisionId + ? (eraPositionById.get(era.supersededByRevisionId) ?? "dangling") + : null, + })), + "the corrected era must come back still pointing at its correction", + ).toEqual([ + { + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2026-03-01T00:00:00.000Z", + source: "ARCHIVED", + payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }], + supersedes: null, + }, + { + validFrom: "2026-02-01T00:00:00.000Z", + validUntil: "2026-03-01T00:00:00.000Z", + source: "ARCHIVED", + payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }], + supersedes: 2, + }, + { + validFrom: "2026-03-01T00:00:00.000Z", + validUntil: "2026-05-01T00:00:00.000Z", + source: "MANUAL", + payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }], + supersedes: null, + }, + ]); + // What the pointer actually buys, stated as the thing the app reads: the + // timeline query drops superseded rows, so exactly two eras are live. + expect( + await prisma.medicationScheduleRevision.count({ + where: { + medication: { userId: OWNER_ID }, + supersededByRevisionId: null, + }, + }), + "a lost pointer puts a corrected era back on the timeline", + ).toBe(2); // The pinned target, and the analyte it points at. // @@ -2102,6 +2205,121 @@ describe("every model the plan claims two-ended survives a real restore", () => }); }); + /** + * A supersede pointer that names a position the file does not have. + * + * The builder computes the index from the same list it writes, so no file + * this release produces can trip this. A hand-edited or truncated one can, + * and the question it asks has to be answered somewhere: the era it names + * does not exist, the column is a bare id with no constraint to stop a + * wrong value, and inventing a neighbouring era to point at would be worse + * than either dropping or refusing. + * + * The answer is that the ERA restores and the POINTER does not, and the + * position is reported. Refusing the file would cost an operator every era + * of every drug over one bad integer; writing the link would put a + * correction pointer at whatever row happened to be there. + */ + it("keeps an archived era whose supersede pointer names a position out of range, and reports the position", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const medication = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Out-of-range tablet", dose: "5 mg" }, + }); + await prisma.medicationScheduleRevision.createMany({ + data: [ + { + medicationId: medication.id, + validFrom: AT("2026-01-01T00:00:00.000Z"), + validUntil: AT("2026-02-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00" }], + source: "ARCHIVED", + }, + { + medicationId: medication.id, + validFrom: AT("2026-02-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00" }], + source: "MANUAL", + }, + ], + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + // Hand-edit the file the way a person with a text editor would: point the + // first era at an era that is not there. + const medications = payload.medications as Array<{ + scheduleRevisions: Array<{ supersededByIndex: number | null }>; + }>; + medications[0].scheduleRevisions[0].supersededByIndex = 7; + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + 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 never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const reported = body.data.skipped.catalogueKeys as Array<{ + catalogue: string; + key: string; + links: number; + }>; + expect( + reported.filter((entry) => entry.catalogue === "scheduleRevisionLink"), + "the position the file named is not in the file, so dropping the link " + + "is correct and saying nothing about it is not", + ).toEqual([ + { + catalogue: "scheduleRevisionLink", + key: "medications[0].scheduleRevisions[0].supersededByIndex=7", + links: 1, + }, + ]); + + const restoredEras = await prisma.medicationScheduleRevision.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: { validFrom: "asc" }, + }); + expect( + restoredEras.map((era) => ({ + validFrom: era.validFrom.toISOString(), + source: era.source, + supersededByRevisionId: era.supersededByRevisionId, + })), + "both eras come back; only the pointer is lost", + ).toEqual([ + { + validFrom: "2026-01-01T00:00:00.000Z", + source: "ARCHIVED", + supersededByRevisionId: null, + }, + { + validFrom: "2026-02-01T00:00:00.000Z", + source: "MANUAL", + supersededByRevisionId: null, + }, + ]); + }); + /** * The reference a portable file genuinely lacks, and the slug the restore * must not judge.