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]); + }); +});