/** * 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 ?? []); }