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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 00:28:38 +02:00
1 parent b09a6d2df8
commit 333dfe8746
24 files changed
+310 -62

No files matched your search

+7 -4
View File
@@ -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" }],
}),
]);
@@ -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() },
}),
}),
);
+1 -1
View File
@@ -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) => ({
+2 -2
View File
@@ -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,
}),
]);
+6 -6
View File
@@ -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,
}),
]);
+2 -2
View File
@@ -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,
}),
]);
+3 -3
View File
@@ -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,
}),
]);
+1 -1
View File
@@ -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) => ({
+1 -1
View File
@@ -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 {
+8 -3
View File
@@ -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" },
},
},
},
},
});
+1 -1
View File
@@ -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 {
+4 -4
View File
@@ -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 } } },
}),
]);
+10 -9
View File
@@ -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
+7 -4
View File
@@ -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 {
+1 -1
View File
@@ -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: {
+4 -4
View File
@@ -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" }],
}),
]);
+1 -1
View File
@@ -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([
+2 -2
View File
@@ -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,
}),
]);
+2 -2
View File
@@ -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,
}),
]);
+2 -2
View File
@@ -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,
}),
]);
+5 -5
View File
@@ -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,
}),
]);
+1 -1
View File
@@ -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) => ({
+4 -2
View File
@@ -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<MedicationCategoryLabelDto[]> {
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,
@@ -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<string, { type: string; isList: boolean }>;
primaryKey: string[];
/** The scalar columns each relation to a parent model is stored in. */
foreignKeys: Map<string, string[]>;
}
/** Every model's fields, primary key and foreign keys, from schema.prisma. */
function readSchema(): Map<string, SchemaModel> {
const source = readFileSync(
resolve(__dirname, "../../prisma/schema.prisma"),
"utf8",
);
const models = new Map<string, SchemaModel>();
for (const block of source.matchAll(/^model (\w+) \{([\s\S]*?)\n\}/gm)) {
const fields = new Map<string, { type: string; isList: boolean }>();
const foreignKeys = new Map<string, string[]>();
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<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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<string>): 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<string>();
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 = <T>(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]);
});
});