mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge pull request #822 from MBombeck/work/backup-clinical
Carry the clinical history: ECG strips, efficacy targets, schedule eras
This commit is contained in:
19 files changed
+1259
-14
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([]) },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user