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] 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({