diff --git a/messages/de.json b/messages/de.json index 1d22c97c3..f47b95c2c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Dokumentbefund", "restoreSkippedFactCommitment": "Befundübernahme", "docsLink": "Dokumentation", - "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung" + "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", + "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", + "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 d01087845..f335611ac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Document fact", "restoreSkippedFactCommitment": "Fact commitment", "docsLink": "Documentation", - "restoreSkippedPersonalRecordReference": "Personal best provenance" + "restoreSkippedPersonalRecordReference": "Personal best provenance", + "restoreSkippedEcgReference": "ECG strip provenance", + "restoreSkippedMedicationTarget": "Medication target analyte", + "restoreSkippedScheduleRevisionLink": "Schedule era succession" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 7d5de9ed8..c33bbad2d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6919,7 +6919,10 @@ "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", + "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 c6bae9f4c..9edb08991 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6919,7 +6919,10 @@ "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", + "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 72f424c33..5df1f3bab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6919,7 +6919,10 @@ "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", + "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 d726cc0e8..ef3622552 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Fakt z dokumentu", "restoreSkippedFactCommitment": "Zapis faktu", "docsLink": "Dokumentacja", - "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego" + "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", + "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", + "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/__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..f15f3bc8a 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; }; } @@ -564,6 +566,23 @@ 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]; + }> = []; + // 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: { @@ -769,7 +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 @@ -1228,6 +1333,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 @@ -1678,6 +1842,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 +1923,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..598d6aab9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -356,6 +356,15 @@ function catalogueLabel( if (catalogue === "personalRecordReference") { return t("admin.section.backups.restoreSkippedPersonalRecordReference"); } + if (catalogue === "ecgReference") { + return t("admin.section.backups.restoreSkippedEcgReference"); + } + 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 42de97aa7..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,41 @@ 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: [ + { + 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", @@ -373,6 +408,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..5f47cadf2 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,47 @@ 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 + // 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 + // 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. */ @@ -484,12 +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.", - 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..b04d6e6c6 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; @@ -121,6 +128,8 @@ export interface FullBackupCounts medicationInventoryItems: number; medicationInventoryEvents: number; reminderPhaseConfigs: number; + medicationEfficacyTargets: number; + medicationScheduleRevisions: number; moodEntries: number; customMoodTagCategories: number; hiddenMoodTags: number; @@ -341,6 +350,7 @@ export async function buildFullBackupPayload( documentFiling, awards, environment, + ecg, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -417,6 +427,26 @@ 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 + // the natural key the catalogue is already unique on. + efficacyTargets: { + orderBy: { createdAt: "asc" }, + include: { biomarker: { select: { name: true } } }, + }, }, }), prisma.medicationIntakeEvent.findMany({ @@ -549,6 +579,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 +624,20 @@ export async function buildFullBackupPayload( const documentFilingSection: DocumentFilingBackupSection = documentFiling; const awardsSection: AwardsBackupSection = awards; 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, @@ -782,6 +832,56 @@ 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 + // 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 @@ -946,6 +1046,7 @@ export async function buildFullBackupPayload( ...documentFilingSection, ...awardsSection, ...environmentSection, + ...ecgSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -982,6 +1083,14 @@ export async function buildFullBackupPayload( 0, ), reminderPhaseConfigs: medications.filter((m) => m.phaseConfig).length, + medicationEfficacyTargets: medications.reduce( + (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, @@ -1001,6 +1110,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..ef8acb6c3 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -104,8 +104,35 @@ * `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 + * 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, `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 @@ -127,6 +154,9 @@ export type SkippedCatalogue = | "extractedFact" | "factCommitment" | "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 8fcfd7a97..9be51be04 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -264,6 +264,55 @@ 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. + * + * 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 +356,12 @@ 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([]), + // 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(); @@ -1514,6 +1569,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 +1712,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 @@ -1663,6 +1752,10 @@ export interface BackupSummary { intakeEvents: number; /** Side effects recorded against a drug, across every medication. */ 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; @@ -1737,6 +1830,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 { @@ -1751,6 +1846,14 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, medication) => sum + medication.sideEffects.length, 0, ), + medicationEfficacyTargets: payload.medications.reduce( + (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, @@ -1815,6 +1918,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..a6daaf312 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,12 @@ 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 } }), + // 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 } } }), }; /** @@ -247,6 +259,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, @@ -426,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: [ { @@ -513,6 +592,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" }, }); @@ -1765,6 +1855,155 @@ 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. + // + // 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 + // 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. @@ -1966,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.