mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The register said the originating device may still hold them. That holds for about as long as the phone beside the watch does, and not at all once a Withings connection is revoked: the signal id stops resolving with it. So a self-hoster who exported and wiped had a file with no traces in it and a restore that reported success. Both ends live in src/lib/export/ecg-backup.ts, the arrangement the reminders, visits and Coach sections already use. Three things needed deciding. The waveform follows the note contract: ciphertext verbatim in a disaster-recovery payload, the micro-volt samples decrypted in a portable one. There is no placeholder for a strip that will not decrypt the way the Coach has one for a turn, because waveformEncrypted is a required column. Such a recording is left out of a portable file with a warning on the wide event, so the number of strips in the file is the number of strips that can be shown. measurementId is a REAL foreign key against the event row a strip was filed with, unlike the Coach references next door. A value that will not resolve does not dangle quietly here, it violates the constraint and rolls the whole restore back over one cross-reference. A portable export omits soft-deleted measurements, so that case is reachable rather than theoretical. The reference is resolved against the measurements the restore wrote, nulled when it cannot be, and reported as ecgReference. The strips are wiped and rewritten by the section itself. The wipe at the top of the restore never reached them: a recording hangs off the account, not off a measurement, so deleting the measurements only nulled the pointer and left the row behind. The round trip asserts the row column by column rather than counting it. A recording is three things at once and each is the whole value of the row on its own: a strip with no rhythm verdict is a squiggle nobody has read, a strip with no recordedAt belongs to no day, and a strip with no samples is a claim that an ECG exists which nothing can draw.
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
/**
|
|
* 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 ?? []);
|
|
}
|