From 333dfe874672c1fdb3815771e6613711a8943927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 00:28:38 +0200 Subject: [PATCH] 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. --- src/lib/cycle/backup.ts | 11 +- .../__tests__/full-backup-payload.test.ts | 2 +- src/lib/export/allergies-backup.ts | 2 +- src/lib/export/awards-backup.ts | 4 +- src/lib/export/coach-backup.ts | 12 +- src/lib/export/document-filing-backup.ts | 4 +- src/lib/export/environment-backup.ts | 6 +- src/lib/export/family-history-backup.ts | 2 +- src/lib/export/health-score-backup.ts | 2 +- src/lib/export/illness-backup.ts | 11 +- src/lib/export/intraday-profile-backup.ts | 2 +- src/lib/export/labs-backup.ts | 8 +- src/lib/export/medications-backup.ts | 19 +- src/lib/export/mood-backup.ts | 11 +- src/lib/export/nutrients-backup.ts | 2 +- src/lib/export/profile-backup.ts | 8 +- src/lib/export/relation-links-backup.ts | 2 +- src/lib/export/reminders-backup.ts | 4 +- src/lib/export/sensitive-backup.ts | 4 +- src/lib/export/vaccinations-backup.ts | 4 +- src/lib/export/visits-backup.ts | 10 +- src/lib/export/workouts-backup.ts | 2 +- src/lib/medication-category.ts | 6 +- .../backup-export-total-order.test.ts | 234 ++++++++++++++++++ 24 files changed, 310 insertions(+), 62 deletions(-) create mode 100644 tests/integration/backup-export-total-order.test.ts diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 6bf55b3d4..6e6285c89 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -232,20 +232,23 @@ export async function buildCycleBackupSection( where: disasterRecovery ? { userId } : { userId, deletedAt: null, isPredicted: false }, - orderBy: { startDate: "asc" }, + orderBy: [{ startDate: "asc" }, { id: "asc" }], }), prisma.cycleDayLog.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { date: "asc" }, + orderBy: [{ date: "asc" }, { id: "asc" }], include: { - symptomLinks: { include: { symptom: { select: { key: true } } } }, + symptomLinks: { + include: { symptom: { select: { key: true } } }, + orderBy: { symptomId: "asc" }, + }, }, }), // `userId` set means the user made it. The NULL rows are the seeded // catalogue, which the restoring instance already has. prisma.cycleSymptom.findMany({ where: { userId }, - orderBy: { key: "asc" }, + orderBy: [{ key: "asc" }, { id: "asc" }], }), ]); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index d8270f380..c418c854e 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -1561,7 +1561,7 @@ describe("buildFullBackupPayload — mood arms", () => { expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ select: expect.objectContaining({ - tagLinks: { select: expect.any(Object) }, + tagLinks: { select: expect.any(Object), orderBy: expect.anything() }, }), }), ); diff --git a/src/lib/export/allergies-backup.ts b/src/lib/export/allergies-backup.ts index 002998548..03632d929 100644 --- a/src/lib/export/allergies-backup.ts +++ b/src/lib/export/allergies-backup.ts @@ -27,7 +27,7 @@ export async function buildAllergiesBackupSlice( ): Promise<{ allergies: CanonicalAllergy[] }> { const allergyRows = await prisma.allergy.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], }); const allergies: CanonicalAllergy[] = disasterRecovery ? allergyRows.map((row) => ({ diff --git a/src/lib/export/awards-backup.ts b/src/lib/export/awards-backup.ts index 2a37d3606..08bd1a1a3 100644 --- a/src/lib/export/awards-backup.ts +++ b/src/lib/export/awards-backup.ts @@ -184,12 +184,12 @@ export async function buildAwardsBackupSection( const [recordRows, achievementRows] = await Promise.all([ prisma.personalRecord.findMany({ where: { userId }, - orderBy: { achievedAt: "asc" }, + orderBy: [{ achievedAt: "asc" }, { id: "asc" }], select: PERSONAL_RECORD_BACKUP_SELECT, }), prisma.userAchievement.findMany({ where: { userId }, - orderBy: { unlockedAt: "asc" }, + orderBy: [{ unlockedAt: "asc" }, { id: "asc" }], select: USER_ACHIEVEMENT_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts index 0f7f0345d..64dd209c4 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -166,7 +166,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = { createdAt: true, updatedAt: true, messages: { - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { id: true, role: true, @@ -182,7 +182,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = { }, }, attachments: { - orderBy: { addedAt: "asc" }, + orderBy: [{ addedAt: "asc" }, { documentId: "asc" }], select: { documentId: true, addedAt: true }, }, } satisfies Prisma.CoachConversationSelect; @@ -225,7 +225,7 @@ export async function buildCoachBackupSection( const rows = await prisma.coachConversation.findMany({ where: { userId }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: COACH_CONVERSATION_BACKUP_SELECT, }); @@ -682,17 +682,17 @@ export async function buildCoachMemoryBackupSection( const [factRows, planRows, reminderRows] = await Promise.all([ prisma.coachFact.findMany({ where: scope, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: COACH_FACT_BACKUP_SELECT, }), prisma.coachPlan.findMany({ where: scope, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: COACH_PLAN_BACKUP_SELECT, }), prisma.coachReminder.findMany({ where: scope, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: COACH_REMINDER_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/document-filing-backup.ts b/src/lib/export/document-filing-backup.ts index b310fe874..0d1cb9a62 100644 --- a/src/lib/export/document-filing-backup.ts +++ b/src/lib/export/document-filing-backup.ts @@ -184,12 +184,12 @@ export async function buildDocumentFilingBackupSection( document: { deletedAt: null }, ...(disasterRecovery ? {} : { episode: { deletedAt: null } }), }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: DOCUMENT_CONDITION_LINK_BACKUP_SELECT, }), prisma.extractedFact.findMany({ where: { userId, document: { deletedAt: null } }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: EXTRACTED_FACT_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/environment-backup.ts b/src/lib/export/environment-backup.ts index df94bda81..0305bc75b 100644 --- a/src/lib/export/environment-backup.ts +++ b/src/lib/export/environment-backup.ts @@ -344,17 +344,17 @@ export async function buildEnvironmentBackupSection( const [contextRows, travelRows, dawarichRows] = await Promise.all([ prisma.environmentContext.findMany({ where: { userId }, - orderBy: { date: "asc" }, + orderBy: [{ date: "asc" }, { id: "asc" }], select: ENVIRONMENT_CONTEXT_BACKUP_SELECT, }), prisma.environmentTravelLocation.findMany({ where: { userId }, - orderBy: { startDate: "asc" }, + orderBy: [{ startDate: "asc" }, { id: "asc" }], select: ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT, }), prisma.dawarichDay.findMany({ where: { userId }, - orderBy: { date: "asc" }, + orderBy: [{ date: "asc" }, { id: "asc" }], select: DAWARICH_DAY_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/family-history-backup.ts b/src/lib/export/family-history-backup.ts index 6a597711d..c51841bb3 100644 --- a/src/lib/export/family-history-backup.ts +++ b/src/lib/export/family-history-backup.ts @@ -39,7 +39,7 @@ export async function buildFamilyHistoryBackupSlice( ): Promise<{ familyHistory: CanonicalFamilyHistoryEntry[] }> { const familyRows = await prisma.familyHistoryEntry.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], }); const familyHistory: CanonicalFamilyHistoryEntry[] = disasterRecovery ? familyRows.map((row) => ({ diff --git a/src/lib/export/health-score-backup.ts b/src/lib/export/health-score-backup.ts index a0a70a0b8..0ee2e0068 100644 --- a/src/lib/export/health-score-backup.ts +++ b/src/lib/export/health-score-backup.ts @@ -100,7 +100,7 @@ export async function buildHealthScoreBackupSection( // short of a wipe — so both purposes read the same set. const rows = await prisma.healthScoreRecord.findMany({ where: { userId }, - orderBy: { dayKey: "desc" }, + orderBy: [{ dayKey: "desc" }, { id: "asc" }], }); return { diff --git a/src/lib/export/illness-backup.ts b/src/lib/export/illness-backup.ts index 19eb4423c..7c7afd203 100644 --- a/src/lib/export/illness-backup.ts +++ b/src/lib/export/illness-backup.ts @@ -44,12 +44,17 @@ export async function buildIllnessBackupSlice( ): Promise<{ illnessEpisodes: CanonicalIllnessEpisode[] }> { const episodeRows = await prisma.illnessEpisode.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { onsetAt: "desc" }, + orderBy: [{ onsetAt: "desc" }, { id: "asc" }], include: { dayLogs: { ...(disasterRecovery ? {} : { where: { deletedAt: null } }), - orderBy: { date: "asc" }, - include: dayLogSymptomInclude, + orderBy: [{ date: "asc" }, { id: "asc" }], + include: { + symptomLinks: { + ...dayLogSymptomInclude.symptomLinks, + orderBy: { symptomId: "asc" }, + }, + }, }, }, }); diff --git a/src/lib/export/intraday-profile-backup.ts b/src/lib/export/intraday-profile-backup.ts index 1be5b099e..556ed0091 100644 --- a/src/lib/export/intraday-profile-backup.ts +++ b/src/lib/export/intraday-profile-backup.ts @@ -108,7 +108,7 @@ export async function buildIntradayProfileBackupSection( // both purposes read the same set and only identity differs. const rows = await prisma.intradayCumulativeProfile.findMany({ where: { userId }, - orderBy: [{ dateKey: "desc" }, { type: "asc" }], + orderBy: [{ dateKey: "desc" }, { type: "asc" }, { id: "asc" }], }); return { diff --git a/src/lib/export/labs-backup.ts b/src/lib/export/labs-backup.ts index 2205385d9..21e14ba6d 100644 --- a/src/lib/export/labs-backup.ts +++ b/src/lib/export/labs-backup.ts @@ -159,16 +159,16 @@ export async function buildLabsBackupSlice( await Promise.all([ prisma.labResult.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "asc" }], include: { biomarker: { select: { name: true } } }, }), prisma.biomarker.findMany({ where: { userId }, - orderBy: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], }), prisma.biomarkerAlias.findMany({ where: { userId }, - orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }], + orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }, { id: "asc" }], select: { id: true, biomarkerId: true, @@ -179,7 +179,7 @@ export async function buildLabsBackupSlice( }), prisma.biomarkerMerge.findMany({ where: { userId }, - orderBy: { mergedAt: "asc" }, + orderBy: [{ mergedAt: "asc" }, { id: "asc" }], include: { target: { select: { name: true } } }, }), ]); diff --git a/src/lib/export/medications-backup.ts b/src/lib/export/medications-backup.ts index 990d579e5..34b8f98eb 100644 --- a/src/lib/export/medications-backup.ts +++ b/src/lib/export/medications-backup.ts @@ -31,17 +31,17 @@ import { dayKeyOfDate } from "@/lib/medications/course-window"; // no id to remap: Prisma binds the child to whatever id the parent row // actually got. const MEDICATION_BACKUP_INCLUDE = { - schedules: true, - sideEffects: { orderBy: { occurredAt: "desc" } }, - pauseEras: { orderBy: { pausedAt: "asc" } }, - courses: { orderBy: { startsOn: "asc" } }, - doseChanges: { orderBy: { effectiveFrom: "asc" } }, - inventoryItems: { orderBy: { createdAt: "asc" } }, - inventoryEvents: { orderBy: { occurredAt: "asc" } }, + schedules: { orderBy: [{ windowStart: "asc" }, { id: "asc" }] }, + sideEffects: { orderBy: [{ occurredAt: "desc" }, { id: "asc" }] }, + pauseEras: { orderBy: [{ pausedAt: "asc" }, { id: "asc" }] }, + courses: { orderBy: [{ startsOn: "asc" }, { id: "asc" }] }, + doseChanges: { orderBy: [{ effectiveFrom: "asc" }, { id: "asc" }] }, + inventoryItems: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] }, + inventoryEvents: { orderBy: [{ occurredAt: "asc" }, { id: "asc" }] }, phaseConfig: true, // The composition per unit; ordered so two exports of the // same account write it identically. - ingredients: { orderBy: { nutrient: "asc" } }, + ingredients: { orderBy: [{ nutrient: "asc" }, { id: "asc" }] }, // The archived schedule eras. Ordered TOTALLY, not just by the column // that reads like the sort key: the position in this list is the // identifier the supersede pointer travels as, so two eras sharing a @@ -62,7 +62,7 @@ const MEDICATION_BACKUP_INCLUDE = { // portable restore mints a fresh biomarker id, and `(userId, name)` is // the natural key the catalogue is already unique on. efficacyTargets: { - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], include: { biomarker: { select: { name: true } } }, }, } satisfies Prisma.MedicationInclude; @@ -476,6 +476,7 @@ export async function buildMedicationsBackupPart( const [medications, intakeEvents] = await Promise.all([ prisma.medication.findMany({ where: { userId }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], include: MEDICATION_BACKUP_INCLUDE, }), ctx.deferBulk diff --git a/src/lib/export/mood-backup.ts b/src/lib/export/mood-backup.ts index 69f4cc380..d6d4604b1 100644 --- a/src/lib/export/mood-backup.ts +++ b/src/lib/export/mood-backup.ts @@ -116,7 +116,10 @@ const MOOD_ENTRY_QUERY_SELECT = { // `rating: { not: null }`, and a BINARY link carries a NULL rating by // definition — so the present/absent tags, the kind most people pick, // were absent from the file and could not come back from it. - tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT }, + tagLinks: { + select: MOOD_ENTRY_TAG_LINK_SELECT, + orderBy: { moodTagId: "asc" }, + }, // The day context, when the entry has one. Sparse by design — most // entries carry none, and the join answers null on those. context: { select: MOOD_CONTEXT_BACKUP_SELECT }, @@ -314,7 +317,7 @@ export async function buildMoodBackupPart( // custom rated tag could not be restored at all. prisma.moodTag.findMany({ where: { userId }, - orderBy: { key: "asc" }, + orderBy: [{ key: "asc" }, { id: "asc" }], }), // The account's OWN categories. Seeded ones (`userId: null`) belong to the // instance and are deliberately not carried — but a custom tag's @@ -326,7 +329,7 @@ export async function buildMoodBackupPart( // restored. prisma.moodTagCategory.findMany({ where: { userId }, - orderBy: { sortOrder: "asc" }, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], }), // Which tags the account chose to hide. Carried by KEY rather than id: // what people hide is almost always a SEEDED tag, whose id differs on @@ -334,7 +337,7 @@ export async function buildMoodBackupPart( prisma.moodTagHidden.findMany({ where: { userId }, select: { moodTag: { select: { key: true } }, createdAt: true }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { moodTagId: "asc" }], }), ]); return { diff --git a/src/lib/export/nutrients-backup.ts b/src/lib/export/nutrients-backup.ts index ecfef114a..eae067d8b 100644 --- a/src/lib/export/nutrients-backup.ts +++ b/src/lib/export/nutrients-backup.ts @@ -35,7 +35,7 @@ export async function buildNutrientsBackupPart( createdAt: true, updatedAt: true, }, - orderBy: [{ day: "desc" }, { nutrient: "asc" }], + orderBy: [{ day: "desc" }, { nutrient: "asc" }, { source: "asc" }], }); return { section: { diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 0f1952c89..7671e470a 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -318,24 +318,24 @@ export async function buildProfileBackupSection( // A portable export never resurrects a tombstone; a DR payload restores // the account exactly as it stood, deletions included. where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], include: { // v1.37.20 — entries soft-delete now: a portable export // omits tombstoned readings (a restore must not resurrect them); a // DR payload carries them, tombstone included, like the parent. entries: { where: disasterRecovery ? {} : { deletedAt: null }, - orderBy: { measuredAt: "asc" }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], }, }, }), prisma.healthProfileFactRevision.findMany({ where: { userId }, - orderBy: [{ kind: "asc" }, { validFrom: "asc" }], + orderBy: [{ kind: "asc" }, { validFrom: "asc" }, { id: "asc" }], }), prisma.correlationPattern.findMany({ where: { userId }, - orderBy: { canonicalKey: "asc" }, + orderBy: [{ canonicalKey: "asc" }, { id: "asc" }], }), ]); diff --git a/src/lib/export/relation-links-backup.ts b/src/lib/export/relation-links-backup.ts index ed9ab58ce..9f2f1cecd 100644 --- a/src/lib/export/relation-links-backup.ts +++ b/src/lib/export/relation-links-backup.ts @@ -112,7 +112,7 @@ export async function buildRelationLinksBackupSection( // 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 order = [{ createdAt: "asc" as const }, { id: "asc" as const }]; const [labDocs, encounterMeds, encounterDoses, medDocs, medConditions] = await Promise.all([ diff --git a/src/lib/export/reminders-backup.ts b/src/lib/export/reminders-backup.ts index ed3991166..9a8d441a0 100644 --- a/src/lib/export/reminders-backup.ts +++ b/src/lib/export/reminders-backup.ts @@ -162,12 +162,12 @@ export async function buildRemindersBackupSection( const [reminderRows, eventRows] = await Promise.all([ prisma.measurementReminder.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: MEASUREMENT_REMINDER_BACKUP_SELECT, }), prisma.measurementReminderEvent.findMany({ where: { userId }, - orderBy: { occurredAt: "asc" }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }], select: MEASUREMENT_REMINDER_EVENT_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/sensitive-backup.ts b/src/lib/export/sensitive-backup.ts index da02ccdf9..383d90ce8 100644 --- a/src/lib/export/sensitive-backup.ts +++ b/src/lib/export/sensitive-backup.ts @@ -195,12 +195,12 @@ export async function buildSensitiveBackupSection( const [assessmentRows, receiptRows] = await Promise.all([ prisma.mentalHealthAssessment.findMany({ where: { userId }, - orderBy: { takenAt: "asc" }, + orderBy: [{ takenAt: "asc" }, { id: "asc" }], select: MENTAL_HEALTH_BACKUP_SELECT, }), prisma.consentReceipt.findMany({ where: { userId }, - orderBy: { signedAt: "asc" }, + orderBy: [{ signedAt: "asc" }, { id: "asc" }], select: CONSENT_RECEIPT_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index ebf3da8c5..013ad6619 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -194,12 +194,12 @@ export async function buildVaccinationsBackupSection( }), prisma.vaccinationRecord.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { occurredAt: "desc" }, + orderBy: [{ occurredAt: "desc" }, { id: "asc" }], select: VACCINATION_BACKUP_SELECT, }), prisma.vaccinationDocumentLink.findMany({ where: { userId }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 2baa603e7..6543d2305 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -235,27 +235,27 @@ export async function buildVisitsBackupSection( { vaccinations: { some: { deletedAt: null } } }, ], }, - orderBy: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], select: PRACTITIONER_BACKUP_SELECT, }), prisma.encounter.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { occurredAt: "desc" }, + orderBy: [{ occurredAt: "desc" }, { id: "asc" }], select: ENCOUNTER_BACKUP_SELECT, }), prisma.encounterDocumentLink.findMany({ where: { userId }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: ENCOUNTER_DOCUMENT_LINK_BACKUP_SELECT, }), prisma.encounterLabLink.findMany({ where: { userId }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: ENCOUNTER_LAB_LINK_BACKUP_SELECT, }), prisma.encounterConditionLink.findMany({ where: { userId }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: ENCOUNTER_CONDITION_LINK_BACKUP_SELECT, }), ]); diff --git a/src/lib/export/workouts-backup.ts b/src/lib/export/workouts-backup.ts index af3915364..683c3a1a6 100644 --- a/src/lib/export/workouts-backup.ts +++ b/src/lib/export/workouts-backup.ts @@ -41,7 +41,7 @@ export async function buildWorkoutsBackupSlice( ): Promise<{ workouts: WorkoutBackupEntry[] }> { const workoutRows = await prisma.workout.findMany({ where: { userId }, - orderBy: { startedAt: "desc" }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], }); const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({ diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index 00ef279ee..880ab14ac 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -59,6 +59,7 @@ export async function resolveMedicationCategories( const rows = await client.medicationCategoryAssignment.findMany({ where: { medicationId: { in: medicationIds } }, + orderBy: { medicationId: "asc" }, select: { medicationId: true, category: true, @@ -78,6 +79,7 @@ export async function resolveMedicationCategories( ? [] : await client.medicationCategoryLabel.findMany({ where: { key: { in: customKeys } }, + orderBy: { id: "asc" }, select: { key: true, userId: true, labelEncrypted: true }, }); const labelByKey = new Map(labels.map((l) => [l.key, l])); @@ -220,7 +222,7 @@ export async function listMedicationCategoryLabels( ): Promise { const rows = await client.medicationCategoryLabel.findMany({ where: { userId }, - orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], select: { key: true, labelEncrypted: true, @@ -269,7 +271,7 @@ export async function readMedicationCategoryLabelsForBackup( > { const rows = await client.medicationCategoryLabel.findMany({ where: { userId }, - orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], select: { key: true, labelEncrypted: true, diff --git a/tests/integration/backup-export-total-order.test.ts b/tests/integration/backup-export-total-order.test.ts new file mode 100644 index 000000000..e307369ff --- /dev/null +++ b/tests/integration/backup-export-total-order.test.ts @@ -0,0 +1,234 @@ +/** + * Every read the backup export makes has a total order. + * + * A list read ordered only by a column two rows can share (a creation time, + * a day) comes back in whatever order the rows sit on disk, so two exports of + * one account could write two files, and the parity golden copies flapped + * the day the fixture's rows shared their timestamps. A to-many relation read + * with no order at all is the same thing. + * + * - The guard runs both export purposes, built and streamed, through a client + * that records every query and every nested to-many read, and requires + * each to be ordered by keys that end in the model's primary key. + * - The behaviour test writes the same tied rows in two physical orders and + * requires the two exports to be identical. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; + +import { + createOwner, + OWNER_ID, + seedColumnShapes, + seedEveryTwoEndedModel, +} from "./backup-fixture"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +beforeEach(() => truncateAllTables(prisma)); + +interface SchemaModel { + fields: Map; + primaryKey: string[]; + /** The scalar columns each relation to a parent model is stored in. */ + foreignKeys: Map; +} + +/** Every model's fields, primary key and foreign keys, from schema.prisma. */ +function readSchema(): Map { + const source = readFileSync( + resolve(__dirname, "../../prisma/schema.prisma"), + "utf8", + ); + const models = new Map(); + for (const block of source.matchAll(/^model (\w+) \{([\s\S]*?)\n\}/gm)) { + const fields = new Map(); + const foreignKeys = new Map(); + let primaryKey: string[] = []; + for (const raw of block[2].split("\n")) { + const line = raw.trim(); + const compound = /^@@id\(\[([^\]]+)\]/.exec(line); + if (compound) { + primaryKey = compound[1].split(",").map((s) => s.trim()); + continue; + } + const m = /^(\w+)\s+(\w+)(\[\])?\??\s*(.*)$/.exec(line); + if (!m || line.startsWith("//") || line.startsWith("@@")) continue; + fields.set(m[1], { type: m[2], isList: Boolean(m[3]) }); + if (/(^|\s)@id\b/.test(m[4])) primaryKey = [m[1]]; + const relation = /@relation\([^)]*fields: \[([^\]]+)\]/.exec(m[4]); + if (relation) { + foreignKeys.set( + m[2], + relation[1].split(",").map((s) => s.trim()), + ); + } + } + models.set(block[1], { fields, primaryKey, foreignKeys }); + } + return models; +} + +const SCHEMA = readSchema(); + +function orderKeys(orderBy: unknown): string[] { + const list = Array.isArray(orderBy) ? orderBy : orderBy ? [orderBy] : []; + return list.flatMap((entry) => + entry && typeof entry === "object" ? Object.keys(entry) : [], + ); +} + +/** + * Whether an order tells every two rows of the read apart: it names every + * primary-key column the read does not already pin, either by an equality in + * its `where` or, for a nested read, by the foreign key to the parent row. + */ +function isTotal(model: string, args: Args, parent?: string): boolean { + const schema = SCHEMA.get(model); + if (!schema || schema.primaryKey.length === 0) return false; + const keys = orderKeys(args?.orderBy); + const where = (args?.where ?? {}) as Record; + const pinned = new Set([ + ...Object.keys(where).filter((key) => typeof where[key] === "string"), + ...(parent ? (schema.foreignKeys.get(parent) ?? []) : []), + ]); + return schema.primaryKey.every( + (key) => pinned.has(key) || keys.includes(key), + ); +} + +type Args = Record | undefined; + +/** Walk the nested include/select of one query for to-many reads. */ +function walkRelations(model: string, args: Args, path: string, out: string[]) { + for (const key of ["include", "select"] as const) { + const nested = args?.[key] as Record | undefined; + if (!nested || typeof nested !== "object") continue; + for (const [field, value] of Object.entries(nested)) { + if (!value) continue; + const relation = SCHEMA.get(model)?.fields.get(field); + if (!relation || !SCHEMA.has(relation.type)) continue; + const child = typeof value === "object" ? (value as Args) : undefined; + if (relation.isList && !isTotal(relation.type, child, model)) { + out.push(`${path}.${field} (${relation.type})`); + } + walkRelations(relation.type, child, `${path}.${field}`, out); + } + } +} + +/** A client that records every read the export makes that has no total order. */ +function recordingClient(out: string[], seen: Set): PrismaClient { + return prisma.$extends({ + query: { + $allModels: { + async $allOperations({ model, operation, args, query }) { + if (operation === "findMany" && !isTotal(model, args as Args)) { + out.push(`${model}.findMany`); + } + if (operation === "findMany") seen.add(model); + if (operation.startsWith("find")) { + walkRelations(model, args as Args, `${model}.${operation}`, out); + } + return query(args); + }, + }, + }, + }) as unknown as PrismaClient; +} + +describe("the backup export", () => { + it("orders every read it makes totally, in both purposes", async () => { + await createOwner(prisma); + await seedEveryTwoEndedModel(prisma); + await seedColumnShapes(prisma); + + const unordered: string[] = []; + const seen = new Set(); + const client = recordingClient(unordered, seen); + for (const purpose of ["disaster-recovery", "portable-export"] as const) { + await buildFullBackupPayload(client, OWNER_ID, { purpose }); + await streamFullBackupJson(client, OWNER_ID, () => {}, { purpose }); + } + // The recorder saw the export's reads at all: an empty set would pass + // the assertion below without having looked at anything. + expect(seen.size).toBeGreaterThan(50); + expect([...new Set(unordered)].sort()).toEqual([]); + }); + + it("writes the same file whatever order tied rows sit in on disk", async () => { + const at = new Date("2026-05-01T08:00:00.000Z"); + + async function seedTied(order: "forward" | "reverse") { + await truncateAllTables(prisma); + await createOwner(prisma); + const medication = await prisma.medication.create({ + data: { + id: "med-tied", + userId: OWNER_ID, + name: "Tied", + dose: "5 mg", + createdAt: at, + updatedAt: at, + }, + }); + const schedules = [ + { id: "sched-a", label: "A", windowStart: "08:00", windowEnd: "09:00" }, + { id: "sched-b", label: "B", windowStart: "08:00", windowEnd: "09:00" }, + ]; + const doseChanges = [ + { id: "dose-a", doseValue: 5 }, + { id: "dose-b", doseValue: 10 }, + ]; + const allergies = [ + { id: "allergy-a", substance: "Pollen" }, + { id: "allergy-b", substance: "Dust" }, + ]; + const pick = (rows: T[]) => + order === "forward" ? rows : [...rows].reverse(); + for (const s of pick(schedules)) { + await prisma.medicationSchedule.create({ + data: { ...s, medicationId: medication.id }, + }); + } + for (const d of pick(doseChanges)) { + await prisma.medicationDoseChange.create({ + data: { + ...d, + medicationId: medication.id, + doseUnit: "mg", + effectiveFrom: at, + createdAt: at, + }, + }); + } + for (const a of pick(allergies)) { + await prisma.allergy.create({ + data: { ...a, userId: OWNER_ID, createdAt: at, updatedAt: at }, + }); + } + } + + const files: string[] = []; + for (const order of ["forward", "reverse"] as const) { + await seedTied(order); + for (const purpose of ["disaster-recovery", "portable-export"] as const) { + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose, + exportedAt: at, + }); + files.push(JSON.stringify(payload)); + } + } + // Forward and reverse, per purpose. + expect(files[2]).toBe(files[0]); + expect(files[3]).toBe(files[1]); + }); +});