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