mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Several export reads sorted only by a column two rows can share (a creation time, a day, a name), and a few to-many reads (medication schedules, mood tag links, cycle and illness symptom links) had no order at all. Tied rows then came back in whatever order they sat on disk, so two exports of one account could differ, and the parity goldens flapped once the fixture's child rows shared their pinned timestamps. Each read now ends its order in the model's primary key: the existing sort key plus `id`, or the remaining key columns of a compound key (nutrient `source`, hidden tag `moodTagId`, link `symptomId` / `moodTagId` / `documentId`). Schedules sort by window start, then id. The restore re-export reads through the same modules, so it inherits the order. The parity goldens are unchanged: they already hold the tie-broken order, so no regeneration was needed and they still pin the output of the pre-split exporter. A new integration test runs both purposes, built and streamed, through a client that records every findMany and every nested to-many read, and fails on any whose order does not cover the primary key (keys pinned by an equality in `where`, or by the foreign key to the parent, count as covered). A second case writes the same tied rows in forward and reverse physical order and requires byte-identical files.
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
/**
|
|
* The lab record: markers with their other spellings, the merge ledger, and
|
|
* the results filed under them. Both backup ends in one file.
|
|
*
|
|
* A result travels with its marker's NAME as well as its id: a portable
|
|
* restore mints fresh marker ids, and `(userId, name)` is what the catalogue
|
|
* is unique on, so the name is what survives the trip.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import {
|
|
decryptContextFromBytes,
|
|
encryptContextToBytes,
|
|
} from "@/lib/labs/biomarker-store";
|
|
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
|
|
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
|
import {
|
|
decodeEncryptedBytes,
|
|
type RestoreContext,
|
|
} from "@/lib/export/restore-context";
|
|
|
|
// ── Export ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Decrypt an encrypted free-text column for a portable export, or say plainly
|
|
* that this one could not be read.
|
|
*
|
|
* A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view
|
|
* that is right — one bad row must not take the page down. In an export it is
|
|
* not: `null` is exactly what a column that was never written looks like, so
|
|
* the restore importer writes nothing and a note the person did write
|
|
* disappears without anyone being told. The Coach transcript and the doctor
|
|
* report both refuse that trade, one with a placeholder sentence and one with
|
|
* a rendered flag; this follows the same rule.
|
|
*
|
|
* The warning the soft helpers emitted is kept, so a systemic key gap still
|
|
* shows up in the wide event rather than only in the file.
|
|
*/
|
|
function readForExport(
|
|
buf: Uint8Array | null,
|
|
label: string,
|
|
decode: (bytes: Uint8Array) => string,
|
|
): string | null {
|
|
if (!buf || buf.byteLength === 0) return null;
|
|
try {
|
|
return decode(buf);
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`${label} decrypt failed: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
);
|
|
return UNREADABLE_EXPORT_MARKER;
|
|
}
|
|
}
|
|
|
|
export interface LabResultBackupEntry {
|
|
/** Present in canonical DR payloads so rows keep a stable identity. */
|
|
id?: string;
|
|
/** The source row's id in a portable payload, for the relation links. */
|
|
ref?: string;
|
|
panel: string | null;
|
|
analyte: string;
|
|
value: number | null;
|
|
valueText: string | null;
|
|
unit: string;
|
|
referenceLow: number | null;
|
|
referenceHigh: number | null;
|
|
/** The window the reading's own source report printed, verbatim + derived. */
|
|
sourceReferenceLow: number | null;
|
|
sourceReferenceHigh: number | null;
|
|
sourceReferenceText: string | null;
|
|
/**
|
|
* The value and unit the source report printed, kept when a write
|
|
* converted the reading into its marker's unit.
|
|
*/
|
|
sourceValue?: number | null;
|
|
sourceUnit?: string | null;
|
|
takenAt: string;
|
|
source: string;
|
|
/** Human-readable cross-reference into `biomarkers` below, not an id. */
|
|
/** Stable canonical FK; legacy portable payloads use biomarkerName. */
|
|
biomarkerId?: string | null;
|
|
biomarkerName: string | null;
|
|
note: string | null;
|
|
/** Base64 ciphertext in canonical DR payloads. */
|
|
noteEncrypted?: string | null;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
deletedAt?: string | null;
|
|
}
|
|
|
|
export interface BiomarkerBackupEntry {
|
|
/** Present in canonical DR payloads; name remains the natural key. */
|
|
id?: string;
|
|
name: string;
|
|
unit: string;
|
|
lowerBound: number | null;
|
|
upperBound: number | null;
|
|
panel: string | null;
|
|
hidden: boolean;
|
|
context: string | null;
|
|
/**
|
|
* The context's ciphertext as base64, disaster-recovery files only, where
|
|
* `context` is null. A file without it carries the readable context.
|
|
*/
|
|
contextEncrypted?: string | null;
|
|
/** The catalogue key the marker's name and unit resolve to. */
|
|
analyteKey?: string | null;
|
|
/**
|
|
* The other spellings the marker answers to, with where
|
|
* each came from. Absent on files written before them.
|
|
*/
|
|
aliases?: BiomarkerAliasBackupEntry[];
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export interface BiomarkerAliasBackupEntry {
|
|
/** Present in canonical DR payloads. */
|
|
id?: string;
|
|
name: string;
|
|
source: "MERGE" | "IMPORT_CHOICE" | "MANUAL";
|
|
createdAt?: string;
|
|
}
|
|
|
|
/**
|
|
* One marker folded into another. The target travels by id
|
|
* in a disaster-recovery file and by name in both, the way a lab result's
|
|
* marker does; the source is gone, so its id and name are the record.
|
|
*/
|
|
export interface BiomarkerMergeBackupEntry {
|
|
id?: string;
|
|
targetBiomarkerId?: string;
|
|
targetName: string;
|
|
sourceBiomarkerId: string;
|
|
sourceName: string;
|
|
readingCount: number;
|
|
mergedAt: string;
|
|
}
|
|
|
|
/** The lab slice of the records section: results, markers, merge ledger. */
|
|
export async function buildLabsBackupSlice(
|
|
prisma: Pick<
|
|
PrismaClient,
|
|
"labResult" | "biomarker" | "biomarkerAlias" | "biomarkerMerge"
|
|
>,
|
|
userId: string,
|
|
disasterRecovery: boolean,
|
|
): Promise<{
|
|
labResults: LabResultBackupEntry[];
|
|
biomarkers: BiomarkerBackupEntry[];
|
|
biomarkerMerges: BiomarkerMergeBackupEntry[];
|
|
}> {
|
|
const [labResultRows, biomarkerRows, biomarkerAliasRows, biomarkerMergeRows] =
|
|
await Promise.all([
|
|
prisma.labResult.findMany({
|
|
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
orderBy: [{ takenAt: "desc" }, { id: "asc" }],
|
|
include: { biomarker: { select: { name: true } } },
|
|
}),
|
|
prisma.biomarker.findMany({
|
|
where: { userId },
|
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
|
}),
|
|
prisma.biomarkerAlias.findMany({
|
|
where: { userId },
|
|
orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }, { id: "asc" }],
|
|
select: {
|
|
id: true,
|
|
biomarkerId: true,
|
|
name: true,
|
|
source: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
prisma.biomarkerMerge.findMany({
|
|
where: { userId },
|
|
orderBy: [{ mergedAt: "asc" }, { id: "asc" }],
|
|
include: { target: { select: { name: true } } },
|
|
}),
|
|
]);
|
|
|
|
const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: r.id,
|
|
biomarkerId: r.biomarkerId,
|
|
noteEncrypted: r.noteEncrypted
|
|
? Buffer.from(r.noteEncrypted).toString("base64")
|
|
: null,
|
|
createdAt: r.createdAt.toISOString(),
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
deletedAt: r.deletedAt?.toISOString() ?? null,
|
|
note: null,
|
|
}
|
|
: {
|
|
// No id: a portable restore mints a fresh one. The relation links
|
|
// still name this row, so the file says which row they mean.
|
|
ref: r.id,
|
|
note: readForExport(
|
|
r.noteEncrypted,
|
|
"lab note",
|
|
decryptNoteFromBytes,
|
|
),
|
|
}),
|
|
panel: r.panel,
|
|
analyte: r.analyte,
|
|
value: r.value,
|
|
valueText: r.valueText,
|
|
unit: r.unit,
|
|
referenceLow: r.referenceLow,
|
|
referenceHigh: r.referenceHigh,
|
|
sourceReferenceLow: r.sourceReferenceLow,
|
|
sourceReferenceHigh: r.sourceReferenceHigh,
|
|
sourceReferenceText: r.sourceReferenceText,
|
|
sourceValue: r.sourceValue,
|
|
sourceUnit: r.sourceUnit,
|
|
takenAt: r.takenAt.toISOString(),
|
|
source: r.source,
|
|
biomarkerName: r.biomarker?.name ?? null,
|
|
}));
|
|
|
|
const aliasesByMarker = new Map<string, BiomarkerAliasBackupEntry[]>();
|
|
for (const alias of biomarkerAliasRows) {
|
|
const list = aliasesByMarker.get(alias.biomarkerId) ?? [];
|
|
list.push({
|
|
name: alias.name,
|
|
source: alias.source as BiomarkerAliasBackupEntry["source"],
|
|
...(disasterRecovery
|
|
? { id: alias.id, createdAt: alias.createdAt.toISOString() }
|
|
: {}),
|
|
});
|
|
aliasesByMarker.set(alias.biomarkerId, list);
|
|
}
|
|
|
|
const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map(
|
|
(m) => ({
|
|
...(disasterRecovery
|
|
? { id: m.id, targetBiomarkerId: m.targetBiomarkerId }
|
|
: {}),
|
|
targetName: m.target.name,
|
|
sourceBiomarkerId: m.sourceBiomarkerId,
|
|
sourceName: m.sourceName,
|
|
readingCount: m.readingCount,
|
|
mergedAt: m.mergedAt.toISOString(),
|
|
}),
|
|
);
|
|
|
|
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: b.id,
|
|
createdAt: b.createdAt.toISOString(),
|
|
updatedAt: b.updatedAt.toISOString(),
|
|
}
|
|
: {}),
|
|
name: b.name,
|
|
unit: b.unit,
|
|
lowerBound: b.lowerBound,
|
|
upperBound: b.upperBound,
|
|
panel: b.panel,
|
|
hidden: b.hidden,
|
|
// Ciphertext verbatim in a disaster-recovery file, like every other
|
|
// sealed column; the readable context in a portable one.
|
|
...(disasterRecovery
|
|
? {
|
|
context: null,
|
|
contextEncrypted: b.contextEncrypted
|
|
? Buffer.from(b.contextEncrypted).toString("base64")
|
|
: null,
|
|
}
|
|
: {
|
|
context: readForExport(
|
|
b.contextEncrypted,
|
|
"biomarker context",
|
|
decryptContextFromBytes,
|
|
),
|
|
}),
|
|
analyteKey: b.analyteKey,
|
|
aliases: aliasesByMarker.get(b.id) ?? [],
|
|
}));
|
|
|
|
return { labResults, biomarkers, biomarkerMerges };
|
|
}
|
|
|
|
// ── Restore ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function clearLabResults(ctx: RestoreContext): Promise<void> {
|
|
const labResults = await ctx.tx.labResult.deleteMany({
|
|
where: { userId: ctx.ownerId },
|
|
});
|
|
ctx.cleared.labResults = labResults.count;
|
|
}
|
|
|
|
export async function clearBiomarkers(ctx: RestoreContext): Promise<void> {
|
|
const biomarkers = await ctx.tx.biomarker.deleteMany({
|
|
where: { userId: ctx.ownerId },
|
|
});
|
|
ctx.cleared.biomarkers = biomarkers.count;
|
|
}
|
|
|
|
/**
|
|
* The merge ledger, against the markers just written: by id where the file
|
|
* carries one this restore kept, by name otherwise. A row whose target the
|
|
* file does not carry has nothing to point at and is left out (counted on the
|
|
* event).
|
|
*/
|
|
async function restoreBiomarkerMerges(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload } = ctx;
|
|
const { biomarkerByName, restoredBiomarkerIds } = ctx.links;
|
|
let unresolvedMerges = 0;
|
|
const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => {
|
|
const targetId =
|
|
merge.targetBiomarkerId &&
|
|
restoredBiomarkerIds.has(merge.targetBiomarkerId)
|
|
? merge.targetBiomarkerId
|
|
: biomarkerByName.get(merge.targetName);
|
|
if (!targetId) {
|
|
unresolvedMerges += 1;
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
...(merge.id ? { id: merge.id } : {}),
|
|
userId: ownerId,
|
|
targetBiomarkerId: targetId,
|
|
sourceBiomarkerId: merge.sourceBiomarkerId,
|
|
sourceName: merge.sourceName,
|
|
readingCount: merge.readingCount,
|
|
mergedAt: new Date(merge.mergedAt),
|
|
},
|
|
];
|
|
});
|
|
if (mergeRows.length > 0) {
|
|
await tx.biomarkerMerge.createMany({ data: mergeRows });
|
|
}
|
|
if (unresolvedMerges > 0) {
|
|
annotate({
|
|
action: { name: "backup.restore.biomarkerMerge.unresolved" },
|
|
meta: { count: unresolvedMerges },
|
|
});
|
|
}
|
|
}
|
|
|
|
/** The markers, each with its other spellings, then the merge ledger. */
|
|
export async function restoreLabMarkers(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload } = ctx;
|
|
const { biomarkerByName, restoredBiomarkerIds } = ctx.links;
|
|
ctx.reportSection("labs");
|
|
for (const biomarker of payload.biomarkers) {
|
|
const created = await tx.biomarker.create({
|
|
data: {
|
|
...(biomarker.id ? { id: biomarker.id } : {}),
|
|
userId: ownerId,
|
|
name: biomarker.name,
|
|
unit: biomarker.unit,
|
|
lowerBound: biomarker.lowerBound ?? null,
|
|
upperBound: biomarker.upperBound ?? null,
|
|
panel: biomarker.panel ?? null,
|
|
hidden: biomarker.hidden ?? false,
|
|
contextEncrypted:
|
|
biomarker.contextEncrypted !== undefined
|
|
? biomarker.contextEncrypted === null
|
|
? null
|
|
: decodeEncryptedBytes(biomarker.contextEncrypted)
|
|
: biomarker.context == null
|
|
? null
|
|
: encryptContextToBytes(biomarker.context),
|
|
analyteKey: biomarker.analyteKey ?? null,
|
|
...(biomarker.createdAt
|
|
? { createdAt: new Date(biomarker.createdAt) }
|
|
: {}),
|
|
...(biomarker.updatedAt
|
|
? { updatedAt: new Date(biomarker.updatedAt) }
|
|
: {}),
|
|
},
|
|
});
|
|
biomarkerByName.set(biomarker.name, created.id);
|
|
restoredBiomarkerIds.add(created.id);
|
|
// The other spellings, folded the way the resolver reads them, so the
|
|
// next scan matches them again.
|
|
const aliasRows = (biomarker.aliases ?? [])
|
|
.map((alias) => ({
|
|
id: alias.id,
|
|
name: alias.name.trim().slice(0, 120),
|
|
nameFolded: foldForMatch(alias.name).slice(0, 120),
|
|
source: alias.source,
|
|
createdAt: alias.createdAt,
|
|
}))
|
|
.filter(
|
|
(alias) =>
|
|
alias.nameFolded !== "" &&
|
|
alias.nameFolded !== foldForMatch(biomarker.name),
|
|
);
|
|
if (aliasRows.length > 0) {
|
|
await tx.biomarkerAlias.createMany({
|
|
data: aliasRows.map((alias) => ({
|
|
...(alias.id ? { id: alias.id } : {}),
|
|
userId: ownerId,
|
|
biomarkerId: created.id,
|
|
name: alias.name,
|
|
nameFolded: alias.nameFolded,
|
|
source: alias.source,
|
|
...(alias.createdAt ? { createdAt: new Date(alias.createdAt) } : {}),
|
|
})),
|
|
// One spelling answers to one marker per account; a file that names
|
|
// it twice keeps the first.
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
}
|
|
await restoreBiomarkerMerges(ctx);
|
|
}
|
|
|
|
/**
|
|
* The results, against the markers written above. The ids written are
|
|
* collected for the staged facts later on: an approved fact's
|
|
* `committedRecordId` names the lab result it was committed to.
|
|
*/
|
|
export async function restoreLabResults(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload } = ctx;
|
|
const {
|
|
biomarkerByName,
|
|
restoredBiomarkerIds,
|
|
restoredLabResultIds,
|
|
labResultIdBySourceId,
|
|
} = ctx.links;
|
|
for (const lab of payload.labResults) {
|
|
const biomarkerId =
|
|
lab.biomarkerId !== undefined
|
|
? lab.biomarkerId === null
|
|
? null
|
|
: restoredBiomarkerIds.has(lab.biomarkerId)
|
|
? lab.biomarkerId
|
|
: undefined
|
|
: lab.biomarkerName
|
|
? biomarkerByName.get(lab.biomarkerName)
|
|
: null;
|
|
if (biomarkerId === undefined) {
|
|
throw new Error(
|
|
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
|
|
);
|
|
}
|
|
const createdLab = await tx.labResult.create({
|
|
data: {
|
|
...(lab.id ? { id: lab.id } : {}),
|
|
userId: ownerId,
|
|
biomarkerId: biomarkerId ?? null,
|
|
panel: lab.panel ?? null,
|
|
analyte: lab.analyte,
|
|
value: lab.value ?? null,
|
|
valueText: lab.valueText ?? null,
|
|
unit: lab.unit,
|
|
referenceLow: lab.referenceLow ?? null,
|
|
referenceHigh: lab.referenceHigh ?? null,
|
|
sourceReferenceLow: lab.sourceReferenceLow ?? null,
|
|
sourceReferenceHigh: lab.sourceReferenceHigh ?? null,
|
|
sourceReferenceText: lab.sourceReferenceText ?? null,
|
|
sourceValue: lab.sourceValue ?? null,
|
|
sourceUnit: lab.sourceUnit ?? null,
|
|
takenAt: new Date(lab.takenAt),
|
|
source: lab.source,
|
|
noteEncrypted:
|
|
lab.noteEncrypted !== undefined
|
|
? lab.noteEncrypted === null
|
|
? null
|
|
: decodeEncryptedBytes(lab.noteEncrypted)
|
|
: lab.note == null
|
|
? null
|
|
: encryptNoteToBytes(lab.note),
|
|
deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null,
|
|
...(lab.createdAt ? { createdAt: new Date(lab.createdAt) } : {}),
|
|
...(lab.updatedAt ? { updatedAt: new Date(lab.updatedAt) } : {}),
|
|
},
|
|
});
|
|
restoredLabResultIds.add(createdLab.id);
|
|
// Same arrangement as the medications' source-id map.
|
|
const labSourceId = lab.id ?? lab.ref;
|
|
if (labSourceId) labResultIdBySourceId.set(labSourceId, createdLab.id);
|
|
}
|
|
}
|