diff --git a/messages/de.json b/messages/de.json index 22defd4fa..319ae5a90 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Coach-Dokumentverknüpfung", "restoreSkippedCoachReference": "Coach-Verweis", "restoreSkippedDocumentConditionLink": "Dokumentzuordnung", + "restoreSkippedRelationLink": "Verknüpfung zwischen Einträgen", "restoreSkippedExtractedFact": "Dokumentbefund", "restoreSkippedFactCommitment": "Befundübernahme", "docsLink": "Dokumentation", diff --git a/messages/en.json b/messages/en.json index 874ee9856..a53ab20cd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Coach document link", "restoreSkippedCoachReference": "Coach reference", "restoreSkippedDocumentConditionLink": "Document filing", + "restoreSkippedRelationLink": "Link between records", "restoreSkippedExtractedFact": "Document fact", "restoreSkippedFactCommitment": "Fact commitment", "docsLink": "Documentation", diff --git a/messages/es.json b/messages/es.json index e27e98043..bc30b1b5c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Enlace de documento del Coach", "restoreSkippedCoachReference": "Referencia del Coach", "restoreSkippedDocumentConditionLink": "Clasificación de documento", + "restoreSkippedRelationLink": "Vínculo entre registros", "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registro del dato", "docsLink": "Documentación", diff --git a/messages/fr.json b/messages/fr.json index 2743484ab..e624f5a3b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Lien de document du Coach", "restoreSkippedCoachReference": "Référence du Coach", "restoreSkippedDocumentConditionLink": "Classement du document", + "restoreSkippedRelationLink": "Lien entre entrées", "restoreSkippedExtractedFact": "Donnée du document", "restoreSkippedFactCommitment": "Enregistrement de la donnée", "docsLink": "Documentation", diff --git a/messages/it.json b/messages/it.json index 80f4e0504..eee5718db 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Collegamento documento del Coach", "restoreSkippedCoachReference": "Riferimento del Coach", "restoreSkippedDocumentConditionLink": "Classificazione del documento", + "restoreSkippedRelationLink": "Collegamento tra voci", "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registrazione del dato", "docsLink": "Documentazione", diff --git a/messages/ko.json b/messages/ko.json index 29f8be638..e78bac6cf 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7853,6 +7853,7 @@ "restoreSkippedCoachAttachment": "코치 문서 연결", "restoreSkippedCoachReference": "코치 참조", "restoreSkippedDocumentConditionLink": "문서 분류", + "restoreSkippedRelationLink": "기록 간 연결", "restoreSkippedExtractedFact": "문서 추출 정보", "restoreSkippedFactCommitment": "추출 정보 확정", "docsLink": "문서", diff --git a/messages/pl.json b/messages/pl.json index 8b9d69342..1a9c56897 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7878,6 +7878,7 @@ "restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem", "restoreSkippedCoachReference": "Odwołanie Coacha", "restoreSkippedDocumentConditionLink": "Przypisanie dokumentu", + "restoreSkippedRelationLink": "Powiązanie między wpisami", "restoreSkippedExtractedFact": "Fakt z dokumentu", "restoreSkippedFactCommitment": "Zapis faktu", "docsLink": "Dokumentacja", diff --git a/src/__tests__/relation-links-backup-completeness.test.ts b/src/__tests__/relation-links-backup-completeness.test.ts new file mode 100644 index 000000000..e4237c9b6 --- /dev/null +++ b/src/__tests__/relation-links-backup-completeness.test.ts @@ -0,0 +1,139 @@ +/** + * v1.43 — pair guard binding the five relation link tables in + * `prisma/schema.prisma` to the backup that carries them + * (`src/lib/export/relation-links-backup.ts`), on the pattern of + * `environment-backup-completeness.test.ts`. + * + * A link table is two ids and an instant, and the failure this guards is the + * quiet one: a table the backup does not read comes back empty after a + * restore, every record it joined still there and no longer pointing at its + * partner, and nothing fails. So each end is compared as a literal: + * + * - every relation link table of the matrix is a two-ended model of the + * backup plan, not on the debt register; + * - every scalar column of each table is read by the export's select (or + * excluded here with a reason); + * - every read column is written back by the restore's `createMany`; + * - the wire schema names the same two ends for the section key. + * + * Mutation check: delete `documentId: true` from the lab-result select and + * the export case names it; delete the `MedicationConditionLink` line from + * `TWO_ENDED_MODELS` and the plan case names it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + BACKED_UP_MODELS, + COVERAGE_PENDING, + TWO_ENDED_MODELS, +} from "@/lib/export/backup-plan"; +import { RELATION_LINK_SECTION_KEYS } from "@/lib/export/relation-links-backup"; + +const ROOT = process.cwd(); +const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8"); +const source = readFileSync( + join(ROOT, "src", "lib", "export", "relation-links-backup.ts"), + "utf8", +); +const wire = readFileSync( + join(ROOT, "src", "lib", "validations", "backup.ts"), + "utf8", +); + +/** The five tables v1.43 adds to close the relation matrix. */ +const RELATION_LINK_MODELS = [ + "LabResultDocumentLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "MedicationDocumentLink", + "MedicationConditionLink", +] as const; + +/** Columns the backup is right to leave out, with the reason. */ +const EXCLUDED: Record = { + id: "nothing addresses a link row by id; the pair is its identity (unique)", + userId: + "user-scoped backup; the owner is re-derived from the restoring account", +}; + +function block(text: string, start: string, end: string): string { + const from = text.indexOf(start); + expect(from, `${start} not found`).toBeGreaterThan(-1); + const to = text.indexOf(end, from); + expect(to, `${end} not found after ${start}`).toBeGreaterThan(from); + return text.slice(from, to); +} + +function scalarColumns(model: string): string[] { + const models = new Set( + [...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]), + ); + const out: string[] = []; + for (const raw of block(schema, `model ${model} {`, "\n}") + .split("\n") + .slice(1)) { + const line = raw.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line); + if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue; + out.push(m[1]); + } + return out; +} + +const delegate = (model: string) => model[0].toLowerCase() + model.slice(1); + +describe("relation link tables in the backup plan", () => { + it.each(RELATION_LINK_MODELS)("%s travels both ways", (model) => { + expect(BACKED_UP_MODELS as readonly string[]).toContain(model); + expect(TWO_ENDED_MODELS as readonly string[]).toContain(model); + expect(COVERAGE_PENDING).not.toHaveProperty(model); + }); + + it("names one section key per table", () => { + expect([...RELATION_LINK_SECTION_KEYS].sort()).toEqual( + RELATION_LINK_MODELS.map((m) => `${delegate(m)}s`).sort(), + ); + }); +}); + +describe.each(RELATION_LINK_MODELS)("%s backup completeness", (model) => { + const columns = scalarColumns(model); + const name = delegate(model); + const exported = block(source, `prisma.${name}.findMany(`, "}),"); + const restored = block(source, `tx.${name}.createMany(`, "skipDuplicates"); + const wired = block(wire, ` ${name}s: z`, ".default([])"); + const ends = columns.filter((c) => !(c in EXCLUDED) && c !== "createdAt"); + + it("parses both ends into a plausible shape", () => { + expect(columns).toEqual( + expect.arrayContaining(["id", "userId", "createdAt"]), + ); + expect(ends).toHaveLength(2); + }); + + it("the export reads every column", () => { + const missing = columns.filter( + (c) => !(c in EXCLUDED) && !new RegExp(`\\b${c}: true\\b`).test(exported), + ); + expect(missing, `the export omits these ${model} columns`).toEqual([]); + }); + + it("the restore writes every column back", () => { + const unwritten = ends.filter( + (c) => !new RegExp(`\\b${c}: r\\.${c}\\b`).test(restored), + ); + expect(unwritten, `the restore drops these ${model} columns`).toEqual([]); + // `stamp(...)` writes the owner and the original filing instant. + expect(restored).toMatch(/\.\.\.stamp\(r\.createdAt\)/); + }); + + it("the wire schema names the same two ends", () => { + for (const end of ends) { + expect(wired, `${name}s wire schema`).toContain(`"${end}"`); + } + }); +}); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 115bf4bc6..611488051 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -77,6 +77,11 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -179,6 +184,21 @@ beforeEach(() => { vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResultDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounterMedicationLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounterVaccinationLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationConditionLink.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( diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index edbfc9d0b..bf475f213 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -79,6 +79,11 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findMany: vi.fn() }, symptomDefinition: { findMany: vi.fn() }, lifeEvent: { findMany: vi.fn() }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 7ea794bdd..1eccf71b8 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -69,6 +69,11 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index f9fe01412..d1047e4f0 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -448,6 +448,9 @@ function catalogueLabel( if (catalogue === "documentConditionLink") { return t("admin.section.backups.restoreSkippedDocumentConditionLink"); } + if (catalogue === "relationLink") { + return t("admin.section.backups.restoreSkippedRelationLink"); + } if (catalogue === "extractedFact") { return t("admin.section.backups.restoreSkippedExtractedFact"); } diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 38e639672..bc2dffa1f 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -446,6 +446,11 @@ function makePrisma() { ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { 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. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index a4dbcd92b..31de7e8c8 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -319,6 +319,11 @@ function sourceClient() { ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index aeba3f647..7779c1bd4 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -210,7 +210,9 @@ export const BACKED_UP_MODELS = [ // A marker's other spellings and its merge ledger, a supplement's // composition, the Dawarich daily values, and the five relation links that // close the relation matrix. Each is owned data nothing recomputes; each is - // on COVERAGE_PENDING until its section carries it. + // on COVERAGE_PENDING until its section carries it. The relation links + // travel since the release that introduces them + // (`src/lib/export/relation-links-backup.ts`). "BiomarkerAlias", "BiomarkerMerge", "MedicationIngredient", @@ -294,6 +296,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/export/symptoms-backup.ts", // The person's life events, both ends beside each other (v1.42). "src/lib/export/life-events-backup.ts", + // The five relation links that close the relation matrix (v1.43), both + // ends beside each other, each through its OWN delegate. + "src/lib/export/relation-links-backup.ts", "src/lib/cycle/backup.ts", // The medication category is read through the one helper that normalises // it; the payload writer calls the helper rather than the delegate. The @@ -318,6 +323,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/ecg-backup.ts", "src/lib/export/symptoms-backup.ts", "src/lib/export/life-events-backup.ts", + "src/lib/export/relation-links-backup.ts", "src/lib/cycle/backup.ts", // The restore writes the medication category through the same helper. "src/lib/medication-category.ts", @@ -584,6 +590,14 @@ export const TWO_ENDED_MODELS = [ // a plain 404 instead of leading to the marker it went into. "BiomarkerAlias", "BiomarkerMerge", + // v1.43 — the five relation links that close the relation matrix, carried + // from the release that introduces them. Each is two ids and the instant it + // was filed, so carrying them cost no more than owing them would have. + "LabResultDocumentLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "MedicationDocumentLink", + "MedicationConditionLink", ] as const; /** One model claimed to travel both ways. */ @@ -626,16 +640,6 @@ export const COVERAGE_PENDING: Readonly> = { "What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.", DawarichDay: "The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.", - LabResultDocumentLink: - "Which stored report a lab result was read from. Without it the result and the report stop pointing at each other, and the result loses its source.", - EncounterMedicationLink: - "Which medications a visit prescribed, changed or reviewed. Without it the visit and the medication stop pointing at each other.", - EncounterVaccinationLink: - "Which doses were given at a visit. Without it the visit and the dose stop pointing at each other.", - MedicationDocumentLink: - "Which documents belong to a medication: the prescription, the plan, the package insert. Without it they stop pointing at each other.", - MedicationConditionLink: - "Which condition a medication treats (its indication). Without it the medication and the condition stop pointing at each other.", 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: diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts index 1109963f0..756f8d3bf 100644 --- a/src/lib/export/backup-preview.ts +++ b/src/lib/export/backup-preview.ts @@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [ "encounterLinks", "vaccinations", "vaccinationLinks", + "relationLinks", "customVaccines", "measurementReminders", "measurementReminderEvents", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a96f9c250..c3559798d 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -91,6 +91,12 @@ import { type DocumentFilingBackupCounts, type DocumentFilingBackupSection, } from "@/lib/export/document-filing-backup"; +import { + buildRelationLinksBackupSection, + countRelationLinksBackupSection, + type RelationLinksBackupCounts, + type RelationLinksBackupSection, +} from "@/lib/export/relation-links-backup"; import { buildAwardsBackupSection, countAwardsBackupSection, @@ -155,6 +161,7 @@ export interface FullBackupCounts CoachMemoryBackupCounts, SensitiveBackupCounts, DocumentFilingBackupCounts, + RelationLinksBackupCounts, AwardsBackupCounts, EnvironmentBackupCounts, OnboardingBackupCounts, @@ -736,6 +743,7 @@ export async function buildFullBackupPayload( ecg, symptoms, lifeEvents, + relationLinks, nutrientDays, accountSettings, ] = await Promise.all([ @@ -925,6 +933,11 @@ export async function buildFullBackupPayload( buildLifeEventsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The five relation links that close the relation matrix (v1.43). Both + // ends live in `src/lib/export/relation-links-backup.ts`. + buildRelationLinksBackupSection(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"). @@ -980,6 +993,7 @@ export async function buildFullBackupPayload( const ecgSection: EcgBackupSection = ecg; const symptomsSection: SymptomsBackupSection = symptoms; const lifeEventsSection: LifeEventsBackupSection = lifeEvents; + const relationLinksSection: RelationLinksBackupSection = relationLinks; const accountSettingsSection: AccountSettingsBackupSection = accountSettings; // The three unbounded tables, either read into arrays or left as markers @@ -1387,6 +1401,7 @@ export async function buildFullBackupPayload( ...ecgSection, ...symptomsSection, ...lifeEventsSection, + ...relationLinksSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -1456,6 +1471,7 @@ export async function buildFullBackupPayload( ...countEcgBackupSection(ecg), ...countSymptomsBackupSection(symptoms), ...countLifeEventsBackupSection(lifeEvents), + ...countRelationLinksBackupSection(relationLinks), }, }; } diff --git a/src/lib/export/relation-links-backup.ts b/src/lib/export/relation-links-backup.ts new file mode 100644 index 000000000..e9a0f91c4 --- /dev/null +++ b/src/lib/export/relation-links-backup.ts @@ -0,0 +1,378 @@ +/** + * The relation links that close the relation matrix (v1.43), with both backup + * ends in one file. + * + * Same arrangement as `document-filing-backup.ts`, `visits-backup.ts` and the + * other section files: 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. + * + * Five tables ride, each a pair of an account's own records: + * + * - `LabResultDocumentLink`: the report a lab result was read from; + * - `EncounterMedicationLink`: a medication prescribed, changed or reviewed + * at a visit; + * - `EncounterVaccinationLink`: a dose given at a visit; + * - `MedicationDocumentLink`: a prescription, a plan, a package insert; + * - `MedicationConditionLink`: the condition a medication treats. + * + * None holds anything but two ids and the instant the link was filed, so both + * purposes carry the same bytes. Both ends are foreign keys that cascade, so + * an end that cannot resolve does not dangle quietly: it violates the + * constraint and costs the operator the WHOLE restore. The builder therefore + * carries a link only when both of its ends are carried (mirroring the + * tombstone choices of the sections that carry the records), and the restore + * checks every link against the rows this transaction actually wrote and + * reports what it could not place, because a hand-edited or truncated file is + * the case the write-time filter cannot cover. + * + * Written directly rather than through the link service, under the standing + * restore exemption the visit and vaccination backups hold: a restore rebuilds + * an account rather than filing something in one, and it keeps the instant + * each link was originally filed at, which the service's signature cannot + * express. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface RelationLinksBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +export interface LabResultDocumentLinkBackupEntry { + labResultId: string; + documentId: string; + createdAt: string; +} + +export interface EncounterMedicationLinkBackupEntry { + encounterId: string; + medicationId: string; + createdAt: string; +} + +export interface EncounterVaccinationLinkBackupEntry { + encounterId: string; + vaccinationId: string; + createdAt: string; +} + +export interface MedicationDocumentLinkBackupEntry { + medicationId: string; + documentId: string; + createdAt: string; +} + +export interface MedicationConditionLinkBackupEntry { + medicationId: string; + episodeId: string; + createdAt: string; +} + +export interface RelationLinksBackupSection { + labResultDocumentLinks: LabResultDocumentLinkBackupEntry[]; + encounterMedicationLinks: EncounterMedicationLinkBackupEntry[]; + encounterVaccinationLinks: EncounterVaccinationLinkBackupEntry[]; + medicationDocumentLinks: MedicationDocumentLinkBackupEntry[]; + medicationConditionLinks: MedicationConditionLinkBackupEntry[]; +} + +/** The five tables summed, as the visit links are in the summary. */ +export interface RelationLinksBackupCounts { + relationLinks: number; +} + +/** The section keys, in wire order. */ +export const RELATION_LINK_SECTION_KEYS = [ + "labResultDocumentLinks", + "encounterMedicationLinks", + "encounterVaccinationLinks", + "medicationDocumentLinks", + "medicationConditionLinks", +] as const satisfies readonly (keyof RelationLinksBackupSection)[]; + +export async function buildRelationLinksBackupSection( + prisma: Pick< + PrismaClient, + | "labResultDocumentLink" + | "encounterMedicationLink" + | "encounterVaccinationLink" + | "medicationDocumentLink" + | "medicationConditionLink" + >, + userId: string, + options: RelationLinksBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + // The document reader skips tombstoned documents in BOTH purposes; lab + // results, visits, doses and conditions are skipped in a portable export + // only. A medication carries no tombstone. + const liveUnlessDr = disasterRecovery ? {} : { deletedAt: null }; + const order = { createdAt: "asc" } as const; + + const [labDocs, encounterMeds, encounterDoses, medDocs, medConditions] = + await Promise.all([ + prisma.labResultDocumentLink.findMany({ + where: { + userId, + labResult: liveUnlessDr, + document: { deletedAt: null }, + }, + orderBy: order, + select: { labResultId: true, documentId: true, createdAt: true }, + }), + prisma.encounterMedicationLink.findMany({ + where: { userId, encounter: liveUnlessDr }, + orderBy: order, + select: { encounterId: true, medicationId: true, createdAt: true }, + }), + prisma.encounterVaccinationLink.findMany({ + where: { + userId, + encounter: liveUnlessDr, + vaccination: liveUnlessDr, + }, + orderBy: order, + select: { encounterId: true, vaccinationId: true, createdAt: true }, + }), + prisma.medicationDocumentLink.findMany({ + where: { userId, document: { deletedAt: null } }, + orderBy: order, + select: { medicationId: true, documentId: true, createdAt: true }, + }), + prisma.medicationConditionLink.findMany({ + where: { userId, episode: liveUnlessDr }, + orderBy: order, + select: { medicationId: true, episodeId: true, createdAt: true }, + }), + ]); + + const at = (row: T) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + }); + return { + labResultDocumentLinks: labDocs.map(at), + encounterMedicationLinks: encounterMeds.map(at), + encounterVaccinationLinks: encounterDoses.map(at), + medicationDocumentLinks: medDocs.map(at), + medicationConditionLinks: medConditions.map(at), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countRelationLinksBackupSection( + section: RelationLinksBackupSection, +): RelationLinksBackupCounts { + return { + relationLinks: RELATION_LINK_SECTION_KEYS.reduce( + (total, key) => total + section[key].length, + 0, + ), + }; +} + +/** Counts this restore wiped, for the audit trail. */ +export type RelationLinksRestoreCleared = RelationLinksBackupCounts; + +/** The ids of every record this restore has written, by kind. */ +async function writtenIds( + tx: Prisma.TransactionClient, + ownerId: string, +): Promise<{ + labResults: Set; + documents: Set; + encounters: Set; + vaccinations: Set; + medications: Set; + episodes: Set; +}> { + const where = { userId: ownerId }; + const select = { id: true } as const; + const [ + labResults, + documents, + encounters, + vaccinations, + medications, + episodes, + ] = await Promise.all([ + tx.labResult.findMany({ where, select }), + tx.inboundDocument.findMany({ where, select }), + tx.encounter.findMany({ where, select }), + tx.vaccinationRecord.findMany({ where, select }), + tx.medication.findMany({ where, select }), + tx.illnessEpisode.findMany({ where, select }), + ]); + const ids = (rows: Array<{ id: string }>) => new Set(rows.map((r) => r.id)); + return { + labResults: ids(labResults), + documents: ids(documents), + encounters: ids(encounters), + vaccinations: ids(vaccinations), + medications: ids(medications), + episodes: ids(episodes), + }; +} + +/** + * Keep the links whose two ends both exist, and report the rest under the end + * that is missing, so an operator can tell "the report is gone" from "the + * result is gone". + */ +function placeable( + rows: readonly T[], + ends: [(row: T) => string, ReadonlySet][], + skips: RestoreSkipLog, +): T[] { + const dropped: string[] = []; + const kept = rows.filter((row) => { + for (const [end, written] of ends) { + const id = end(row); + if (!written.has(id)) { + dropped.push(id); + return false; + } + } + return true; + }); + recordUnknownKeys(skips, "relationLink", [...new Set(dropped)], dropped); + return kept; +} + +/** + * Re-create the five link tables. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. Each table cascades from both of its ends, so the record wipes + * earlier in the restore have already emptied them; they are counted and + * cleared explicitly anyway, so the cleared numbers are truthful. + * + * MUST be called AFTER the lab results, documents, visits, doses, medications + * and conditions are restored: every end is a foreign key, and a link written + * before its end exists aborts the transaction. + */ +export async function restoreRelationLinksData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: RelationLinksBackupSection, + skips: RestoreSkipLog, +): Promise { + const owned = { where: { userId: ownerId } }; + const [c1, c2, c3, c4, c5] = await Promise.all([ + tx.labResultDocumentLink.deleteMany(owned), + tx.encounterMedicationLink.deleteMany(owned), + tx.encounterVaccinationLink.deleteMany(owned), + tx.medicationDocumentLink.deleteMany(owned), + tx.medicationConditionLink.deleteMany(owned), + ]); + + const written = await writtenIds(tx, ownerId); + const stamp = (createdAt: string) => ({ + userId: ownerId, + createdAt: new Date(createdAt), + }); + + const labDocs = placeable( + payload.labResultDocumentLinks, + [ + [(r) => r.labResultId, written.labResults], + [(r) => r.documentId, written.documents], + ], + skips, + ); + if (labDocs.length > 0) { + await tx.labResultDocumentLink.createMany({ + data: labDocs.map((r) => ({ + labResultId: r.labResultId, + documentId: r.documentId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const encounterMeds = placeable( + payload.encounterMedicationLinks, + [ + [(r) => r.encounterId, written.encounters], + [(r) => r.medicationId, written.medications], + ], + skips, + ); + if (encounterMeds.length > 0) { + await tx.encounterMedicationLink.createMany({ + data: encounterMeds.map((r) => ({ + encounterId: r.encounterId, + medicationId: r.medicationId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const encounterDoses = placeable( + payload.encounterVaccinationLinks, + [ + [(r) => r.encounterId, written.encounters], + [(r) => r.vaccinationId, written.vaccinations], + ], + skips, + ); + if (encounterDoses.length > 0) { + await tx.encounterVaccinationLink.createMany({ + data: encounterDoses.map((r) => ({ + encounterId: r.encounterId, + vaccinationId: r.vaccinationId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const medDocs = placeable( + payload.medicationDocumentLinks, + [ + [(r) => r.medicationId, written.medications], + [(r) => r.documentId, written.documents], + ], + skips, + ); + if (medDocs.length > 0) { + await tx.medicationDocumentLink.createMany({ + data: medDocs.map((r) => ({ + medicationId: r.medicationId, + documentId: r.documentId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + const medConditions = placeable( + payload.medicationConditionLinks, + [ + [(r) => r.medicationId, written.medications], + [(r) => r.episodeId, written.episodes], + ], + skips, + ); + if (medConditions.length > 0) { + await tx.medicationConditionLink.createMany({ + data: medConditions.map((r) => ({ + medicationId: r.medicationId, + episodeId: r.episodeId, + ...stamp(r.createdAt), + })), + skipDuplicates: true, + }); + } + + return { + relationLinks: c1.count + c2.count + c3.count + c4.count + c5.count, + }; +} diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 2ee7c4416..1cce836e8 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -116,6 +116,7 @@ import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; import { restoreSymptomsData } from "@/lib/export/symptoms-backup"; import { restoreLifeEventsData } from "@/lib/export/life-events-backup"; +import { restoreRelationLinksData } from "@/lib/export/relation-links-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; import { encryptCategoryLabel, @@ -181,6 +182,7 @@ export interface RestoreResponse { encounterLinks: number; vaccinations: number; vaccinationLinks: number; + relationLinks: number; customVaccines: number; measurementReminders: number; measurementReminderEvents: number; @@ -2655,6 +2657,17 @@ export async function restoreBackup( skips, ); + // The five relation links (v1.43), last of the record sections they + // join: every end is a foreign key against a lab result, document, + // visit, dose, medication or condition, all written above. Both ends + // of this section live in `src/lib/export/relation-links-backup.ts`. + const relationLinksCleared = await restoreRelationLinksData( + tx, + ownerId, + payload, + skips, + ); + // The Coach's threads, turns and document links. AFTER the // documents on purpose: an attachment carries a `documentId` that // is a foreign key against them, so running earlier would make @@ -2787,6 +2800,7 @@ export async function restoreBackup( encounterLinks: visitsCleared.encounterLinks, vaccinations: vaccinationsCleared.vaccinations, vaccinationLinks: vaccinationsCleared.vaccinationLinks, + relationLinks: relationLinksCleared.relationLinks, customVaccines: vaccinationsCleared.customVaccines, measurementReminders: remindersCleared.measurementReminders, measurementReminderEvents: remindersCleared.measurementReminderEvents, @@ -3066,6 +3080,7 @@ export async function restoreBackup( encounterLinks: summary.encounterLinks, vaccinations: summary.vaccinations, vaccinationLinks: summary.vaccinationLinks, + relationLinks: summary.relationLinks, customVaccines: summary.customVaccines, measurementReminders: summary.measurementReminders, measurementReminderEvents: summary.measurementReminderEvents, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e73dcb712..98003837c 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -166,6 +166,12 @@ * (`customMoodTags..labelEncrypted`): the tag or category restores, under * its key, without the person's own name for it. * + * `relationLink` (v1.43) is the same row kind as `documentConditionLink`, for + * the five link tables that close the relation matrix: a link whose lab + * result, document, visit, dose, medication or condition the restore did not + * put back. Every end is a real foreign key, so the LINK is dropped and both + * records it joined (whichever exist) restore. + * * The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`, * are the same again for the free text of the visits record (a practitioner's * note, address and phone; a visit's reason, outcome and body site) and of a @@ -183,6 +189,7 @@ export type SkippedCatalogue = | "coachAttachment" | "coachReference" | "documentConditionLink" + | "relationLink" | "extractedFact" | "factCommitment" | "personalRecordReference" diff --git a/src/lib/validations/backup-summary.ts b/src/lib/validations/backup-summary.ts index 855f6dea9..080adc01c 100644 --- a/src/lib/validations/backup-summary.ts +++ b/src/lib/validations/backup-summary.ts @@ -85,6 +85,8 @@ export interface BackupSummary { vaccinations: number; /** v1.37.19 (A6-8) — vaccination↔document links. */ vaccinationLinks: number; + /** v1.43 — the five relation link tables, summed. */ + relationLinks: number; /** v1.42 (#1005) — the person's own vaccine definitions. */ customVaccines: number; /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ @@ -178,6 +180,12 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { payload.encounterConditionLinks.length, vaccinations: payload.vaccinations.length, vaccinationLinks: payload.vaccinationDocumentLinks.length, + relationLinks: + payload.labResultDocumentLinks.length + + payload.encounterMedicationLinks.length + + payload.encounterVaccinationLinks.length + + payload.medicationDocumentLinks.length + + payload.medicationConditionLinks.length, customVaccines: payload.customVaccines.length, // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, // so the admin's "what did I just restore" answer never under-counts a diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 3cdb869bb..0c74fb251 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1409,6 +1409,23 @@ const vaccinationLinkBackupSchema = z }) .passthrough(); +/** + * v1.43 — one row of a relation link table: its two ends, by the column + * names of the table, and the instant it was filed. + */ +function relationLinkBackupSchema( + a: A, + b: B, +) { + return z + .object({ + [a]: z.string().min(1), + [b]: z.string().min(1), + createdAt: isoDateTime, + } as { [K in A | B]: z.ZodString } & { createdAt: typeof isoDateTime }) + .passthrough(); +} + /** * One Vorsorge reminder, and one row of its completion ledger (v1.37.20, * #223 / iOS #68). @@ -2183,6 +2200,23 @@ export const backupPayloadSchema = z customVaccines: z.array(customVaccineBackupSchema).default([]), vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), + // v1.43 — the five relation links that close the relation matrix. + // Defaulted: a file written before v1.43 carries no key. + labResultDocumentLinks: z + .array(relationLinkBackupSchema("labResultId", "documentId")) + .default([]), + encounterMedicationLinks: z + .array(relationLinkBackupSchema("encounterId", "medicationId")) + .default([]), + encounterVaccinationLinks: z + .array(relationLinkBackupSchema("encounterId", "vaccinationId")) + .default([]), + medicationDocumentLinks: z + .array(relationLinkBackupSchema("medicationId", "documentId")) + .default([]), + medicationConditionLinks: z + .array(relationLinkBackupSchema("medicationId", "episodeId")) + .default([]), // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / // iOS #68). Defaulted for the same reason as the sections above: a file // written before the reminders travelled carries no key, and an account diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 1f2497960..e9227a777 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -291,6 +291,16 @@ const COUNT_BACK: Record< p.vaccinationRecord.count({ where: { userId } }), VaccinationDocumentLink: (p, userId) => p.vaccinationDocumentLink.count({ where: { userId } }), + LabResultDocumentLink: (p, userId) => + p.labResultDocumentLink.count({ where: { userId } }), + EncounterMedicationLink: (p, userId) => + p.encounterMedicationLink.count({ where: { userId } }), + EncounterVaccinationLink: (p, userId) => + p.encounterVaccinationLink.count({ where: { userId } }), + MedicationDocumentLink: (p, userId) => + p.medicationDocumentLink.count({ where: { userId } }), + MedicationConditionLink: (p, userId) => + p.medicationConditionLink.count({ where: { userId } }), CustomVaccine: (p, userId) => p.customVaccine.count({ where: { userId } }), MeasurementReminder: (p, userId) => p.measurementReminder.count({ where: { userId } }), @@ -1368,6 +1378,44 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); + // v1.43 — one row in each of the five relation link tables, joining the + // records seeded above. + await prisma.labResultDocumentLink.create({ + data: { + userId: OWNER_ID, + labResultId: labResult.id, + documentId: document.id, + }, + }); + await prisma.encounterMedicationLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + medicationId: medication.id, + }, + }); + await prisma.encounterVaccinationLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + vaccinationId: vaccination.id, + }, + }); + await prisma.medicationDocumentLink.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + documentId: document.id, + }, + }); + await prisma.medicationConditionLink.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + episodeId: episode.id, + }, + }); + // Three bests, chosen for what a count cannot see. // // The first points at the measurement it was found in. That column is a