Files
HealthLog/src/lib/export/vaccinations-backup.ts
T
Marc-André Bombeck 333dfe8746 fix(backup): give every export read a total order
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.
2026-10-23 00:28:38 +02:00

556 lines
19 KiB
TypeScript

/**
* The immunization log, with both backup ends in one file.
*
* Same arrangement as `visits-backup.ts` and for the same reason: a reader
* asking "is this carried at both ways?" answers it here, and a reader who
* greps only the restore ROUTE gets a false negative because the route
* delegates.
*
* Three models ride: the dose, the link that keeps the scanned Impfpass page
* beside it, and since v1.42 (#1005) the person's own vaccine definitions a
* dose may name. Both are carried rather than owed. `DocumentConditionLink` on the
* debt register says what the alternative costs — documents and conditions
* both restore, the filing between them does not — and an immunization history
* is the record with the least chance of being reconstructed from anywhere
* else, so the same regret is not repeated here.
*
* Each model uses its own named select constant and its own delegate call
* rather than riding a parent's `include`: `documentLinks` is a relation field
* name on three different models now, so a relation-shaped write would be
* attributed to whichever of them a matcher happened to find first.
*
* Three references need care on the way back:
*
* - `practitionerId` and `encounterId` point at models that restore in the
* same run. They remap to the restored rows, and drop to NULL when the
* file references a row it does not carry.
* - `reminderId` points at a `MeasurementReminder`, which also restores in
* the same run (v1.37.20, #223 / iOS #68 — it was on the coverage-pending
* register before that and every reference was a known drop). It remaps
* the same way, and resolves to NULL, with the drop reported, only when
* the file genuinely lacks the reminder — a portable export omits
* tombstoned ones.
* - `customVaccineId` points at a definition restored by this same section
* first. It drops to NULL, with the drop named, when the file lacks the
* definition; the dose then keeps its own `vaccineName`.
* - `antigenSlug` is restored verbatim with no validation against the
* current catalogue. A slug this release no longer resolves must still
* come back; the renderer degrades to the free-text arm, which is the
* guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`
* holds.
*/
import {
encodeSealedBytes,
openSealedBytesForExport,
sealBytesForRestore,
} from "@/lib/export/sealed-text";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { VaccinationSite } from "@/generated/prisma/client";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
export interface VaccinationsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One administered dose, as the Pass recorded it. */
export interface VaccinationBackupEntry {
/** Always carried: the document link addresses a dose by it. */
id: string;
occurredAt: string;
/**
* Carried verbatim, never checked against the catalogue this release ships.
* A slug that stopped resolving still describes what the person was given.
*/
antigenSlug: string | null;
vaccineName: string | null;
doseNumber: number | null;
seriesDoses: number | null;
lotNumber: string | null;
site: VaccinationSite | null;
practitionerId: string | null;
encounterId: string | null;
/**
* The booster reminder this dose recorded satisfying, which restores in the
* same run since v1.37.20. The restore remaps it against the restored
* reminders and drops it to NULL, with the drop named, only when the file
* lacks the reminder.
*/
reminderId: string | null;
/** The person's own definition the dose names (v1.42, #1005). */
customVaccineId: string | null;
/** Base64 ciphertext; disaster-recovery payloads only. */
noteEncrypted?: string | null;
/** The note readable; portable payloads only. */
note?: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/**
* One of the person's own vaccine definitions (v1.42, #1005). `components`
* travels verbatim, never checked against the antigen list this release
* ships, for the same reason a dose's `antigenSlug` does.
*/
export interface CustomVaccineBackupEntry {
id: string;
name: string;
components: string[];
typicalSeriesDoses: number | null;
boosterIntervalMonths: number | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One edge from a dose to the page it was transcribed from. */
export interface VaccinationDocumentLinkBackupEntry {
vaccinationId: string;
targetId: string;
createdAt: string;
}
export interface VaccinationsBackupSection {
customVaccines: CustomVaccineBackupEntry[];
vaccinations: VaccinationBackupEntry[];
vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[];
}
export interface VaccinationsBackupCounts {
customVaccines: number;
vaccinations: number;
vaccinationLinks: number;
}
/**
* Named select constants, one per model — a structural matcher binds a model
* to the literal beside its delegate call, and an inline object inside a
* `Promise.all` is the shape earlier work here had to refactor away from.
*/
const VACCINATION_BACKUP_SELECT = {
id: true,
occurredAt: true,
antigenSlug: true,
vaccineName: true,
doseNumber: true,
seriesDoses: true,
lotNumber: true,
site: true,
practitionerId: true,
encounterId: true,
reminderId: true,
customVaccineId: true,
noteEncrypted: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const CUSTOM_VACCINE_BACKUP_SELECT = {
id: true,
name: true,
components: true,
typicalSeriesDoses: true,
boosterIntervalMonths: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = {
vaccinationId: true,
documentId: true,
createdAt: true,
} as const;
/**
* Build the immunization slice of a user's full backup.
*
* Takes the delegates it uses rather than a whole client, matching the other
* section builders. A portable export omits tombstones so a restore cannot
* resurrect them; a disaster-recovery payload keeps them so the account comes
* back as itself.
*/
export async function buildVaccinationsBackupSection(
prisma: Pick<
PrismaClient,
"vaccinationRecord" | "vaccinationDocumentLink" | "customVaccine"
>,
userId: string,
options: VaccinationsBackupOptions = {},
): Promise<VaccinationsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [customVaccineRows, vaccinationRows, documentLinkRows] =
await Promise.all([
prisma.customVaccine.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: [{ name: "asc" }, { id: "asc" }],
select: CUSTOM_VACCINE_BACKUP_SELECT,
}),
prisma.vaccinationRecord.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
select: VACCINATION_BACKUP_SELECT,
}),
prisma.vaccinationDocumentLink.findMany({
where: { userId },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT,
}),
]);
return {
customVaccines: customVaccineRows.map((row) => ({
id: row.id,
name: row.name,
components: [...row.components],
typicalSeriesDoses: row.typicalSeriesDoses,
boosterIntervalMonths: row.boosterIntervalMonths,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
vaccinations: vaccinationRows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName,
doseNumber: row.doseNumber,
seriesDoses: row.seriesDoses,
lotNumber: row.lotNumber,
site: row.site,
practitionerId: row.practitionerId,
encounterId: row.encounterId,
reminderId: row.reminderId,
customVaccineId: row.customVaccineId,
...(disasterRecovery
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
: {
note: openSealedBytesForExport(
row.noteEncrypted,
"vaccination note",
),
}),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
vaccinationDocumentLinks: documentLinkRows.map((row) => ({
vaccinationId: row.vaccinationId,
targetId: row.documentId,
createdAt: row.createdAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countVaccinationsBackupSection(
section: VaccinationsBackupSection,
): VaccinationsBackupCounts {
return {
customVaccines: section.customVaccines.length,
vaccinations: section.vaccinations.length,
vaccinationLinks: section.vaccinationDocumentLinks.length,
};
}
/** Counts the immunization restore wiped, for the audit trail. */
export interface VaccinationsRestoreCleared {
customVaccines: number;
vaccinations: number;
vaccinationLinks: number;
}
/**
* The slice of a parsed backup this restore consumes.
*
* Required rather than optional: the payload schema defaults every key, so
* every caller already satisfies this, and one that stops satisfying it fails
* to compile instead of passing `undefined` into a loop that iterates zero
* times and reports success.
*/
export interface VaccinationsRestoreInput {
customVaccines: RestoredCustomVaccine[];
vaccinations: RestoredVaccination[];
vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[];
}
/** A definition as the parsed file presents it. */
export type RestoredCustomVaccine = Pick<
CustomVaccineBackupEntry,
"id" | "name" | "createdAt" | "updatedAt"
> & {
components?: string[] | undefined;
typicalSeriesDoses?: number | null | undefined;
boosterIntervalMonths?: number | null | undefined;
deletedAt?: string | null | undefined;
};
/**
* What the restore reads, as the parsed file actually presents it — a wider
* set than what this release writes: a portable export omits the tombstone,
* and every optional column arrives as `undefined` rather than `null`.
*/
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredVaccination = Pick<
VaccinationBackupEntry,
"id" | "occurredAt" | "createdAt" | "updatedAt"
> &
OptionalNullable<
Pick<
VaccinationBackupEntry,
| "antigenSlug"
| "vaccineName"
| "doseNumber"
| "seriesDoses"
| "lotNumber"
| "site"
| "practitionerId"
| "encounterId"
| "reminderId"
| "customVaccineId"
| "noteEncrypted"
| "note"
>
> & { deletedAt?: string | null };
/**
* Re-create the account's immunization log and its document links.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section. The dose first, then the link, because a link addresses both of its
* ends by id.
*
* MUST be called after the practitioners, encounters, documents and
* measurement reminders have been restored: the dose remaps three of those
* references against the database and the link is written only when the
* document actually came back. Calling it earlier would drop every reference
* and be counted as a successful restore of a thinner record.
*/
export async function restoreVaccinationsData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: VaccinationsRestoreInput,
skips: RestoreSkipLog,
): Promise<VaccinationsRestoreCleared> {
const clearedLinks = await tx.vaccinationDocumentLink.deleteMany({
where: { userId: ownerId },
});
const clearedVaccinations = await tx.vaccinationRecord.deleteMany({
where: { userId: ownerId },
});
// After the doses that point at them, so neither count reads short.
const clearedCustomVaccines = await tx.customVaccine.deleteMany({
where: { userId: ownerId },
});
// The definitions first: a dose addresses one by id.
if (payload.customVaccines.length > 0) {
await tx.customVaccine.createMany({
data: payload.customVaccines.map((entry) => ({
id: entry.id,
userId: ownerId,
name: entry.name,
// Verbatim, like a dose's slug: an antigen this release does not know
// still says what the person meant, and the resolver skips it.
components: entry.components ?? [],
typicalSeriesDoses: entry.typicalSeriesDoses ?? null,
boosterIntervalMonths: entry.boosterIntervalMonths ?? null,
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
})),
});
}
const restoredCustomVaccines = new Set(
payload.customVaccines.map((entry) => entry.id),
);
// All three far sides are restored by other branches of the same
// transaction, so the check is against the database rather than against the
// payload — this is the only place that can see every section at once.
const [practitionerIds, encounterIds, reminderIds] = await Promise.all([
tx.practitioner.findMany({
where: { userId: ownerId },
select: { id: true },
}),
tx.encounter.findMany({
where: { userId: ownerId },
select: { id: true },
}),
tx.measurementReminder.findMany({
where: { userId: ownerId },
select: { id: true },
}),
]);
const restoredPractitioners = new Set(practitionerIds.map((row) => row.id));
const restoredEncounters = new Set(encounterIds.map((row) => row.id));
const restoredReminders = new Set(reminderIds.map((row) => row.id));
// A row the file references but the restore did not bring back cannot be
// invented, and a dangling id would fail the foreign key and roll the whole
// restore back over one dose. The reference drops to NULL instead — the
// dose, its date, its antigen and its batch code all survive. That includes
// the reminder since v1.37.20: it restores in the same run, so a drop here
// now means the file genuinely lacked it (a portable export omits
// tombstoned reminders), not that reminders never travel.
const droppedPractitioners: string[] = [];
const droppedEncounters: string[] = [];
const droppedReminders: string[] = [];
const droppedCustomVaccines: string[] = [];
// Sealed notes this host's keys do not open, by file path.
const unopened: string[] = [];
if (payload.vaccinations.length > 0) {
await tx.vaccinationRecord.createMany({
data: payload.vaccinations.map((entry) => {
const practitionerId =
entry.practitionerId &&
restoredPractitioners.has(entry.practitionerId)
? entry.practitionerId
: null;
if (entry.practitionerId && practitionerId === null) {
droppedPractitioners.push(entry.practitionerId);
}
const encounterId =
entry.encounterId && restoredEncounters.has(entry.encounterId)
? entry.encounterId
: null;
if (entry.encounterId && encounterId === null) {
droppedEncounters.push(entry.encounterId);
}
const reminderId =
entry.reminderId && restoredReminders.has(entry.reminderId)
? entry.reminderId
: null;
if (entry.reminderId && reminderId === null) {
droppedReminders.push(entry.reminderId);
}
const customVaccineId =
entry.customVaccineId &&
restoredCustomVaccines.has(entry.customVaccineId)
? entry.customVaccineId
: null;
if (entry.customVaccineId && customVaccineId === null) {
droppedCustomVaccines.push(entry.customVaccineId);
}
return {
id: entry.id,
userId: ownerId,
occurredAt: new Date(entry.occurredAt),
// Verbatim, deliberately: a slug the current catalogue no longer
// resolves still describes what was given, and the renderer falls
// back to `vaccineName`.
antigenSlug: entry.antigenSlug ?? null,
vaccineName: entry.vaccineName ?? null,
doseNumber: entry.doseNumber ?? null,
seriesDoses: entry.seriesDoses ?? null,
lotNumber: entry.lotNumber ?? null,
site: entry.site ?? null,
practitionerId,
encounterId,
reminderId,
customVaccineId,
noteEncrypted: sealBytesForRestore(
entry.noteEncrypted,
entry.note,
`vaccinations.${entry.id}.noteEncrypted`,
unopened,
),
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
};
}),
});
}
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedPractitioners)],
droppedPractitioners,
);
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedEncounters)],
droppedEncounters,
);
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedReminders)],
droppedReminders,
);
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedCustomVaccines)],
droppedCustomVaccines,
);
recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened);
const restoredVaccinations = new Set(
payload.vaccinations.map((entry) => entry.id),
);
const documentIds = await tx.inboundDocument.findMany({
where: { userId: ownerId },
select: { id: true },
});
const restoredDocuments = new Set(documentIds.map((row) => row.id));
const writableLinks: Array<{
vaccinationId: string;
documentId: string;
createdAt: Date;
}> = [];
const droppedLinks: string[] = [];
for (const entry of payload.vaccinationDocumentLinks) {
if (
restoredVaccinations.has(entry.vaccinationId) &&
restoredDocuments.has(entry.targetId)
) {
writableLinks.push({
vaccinationId: entry.vaccinationId,
documentId: entry.targetId,
createdAt: new Date(entry.createdAt),
});
} else {
droppedLinks.push(entry.targetId);
}
}
if (writableLinks.length > 0) {
await tx.vaccinationDocumentLink.createMany({
data: writableLinks.map((row) => ({
userId: ownerId,
vaccinationId: row.vaccinationId,
documentId: row.documentId,
createdAt: row.createdAt,
})),
});
}
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedLinks)],
droppedLinks,
);
return {
customVaccines: clearedCustomVaccines.count,
vaccinations: clearedVaccinations.count,
vaccinationLinks: clearedLinks.count,
};
}