Merge pull request #822 from MBombeck/work/backup-clinical

Carry the clinical history: ECG strips, efficacy targets, schedule eras
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 12:23:21 +02:00
commit 1c2e6c5c80
19 files changed
+1259 -14

No files matched your search

+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
@@ -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([
{
@@ -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<string, string>();
const restoredMedicationIds = new Set<string>();
// 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<string>();
// 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) };
},
@@ -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(
@@ -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", () => {
@@ -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([]) },
+9
View File
@@ -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,
@@ -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.
+47 -6
View File
@@ -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<Record<string, string>> = {
* rather than by what is cheapest to write.
*/
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
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:
+352
View File
@@ -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<PrismaClient, "ecgRecording">,
userId: string,
options: EcgBackupOptions = {},
): Promise<EcgBackupSection> {
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<T> = { [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<string>,
skips: RestoreSkipLog,
): Promise<EcgRestoreCleared> {
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<ArrayBuffer> {
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 ?? []);
}
+111 -1
View File
@@ -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),
},
};
}
+31 -1
View File
@@ -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. */
+104
View File
@@ -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,
};
}
+354
View File
@@ -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<void> {
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<void> {
},
],
});
// 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<void> {
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.