Files
HealthLog/src/lib/export/workouts-backup.ts
T
Marc-André Bombeck 333dfe8746 fix(backup): give every export read a total order
Several export reads sorted only by a column two rows can share (a
creation time, a day, a name), and a few to-many reads (medication
schedules, mood tag links, cycle and illness symptom links) had no order
at all. Tied rows then came back in whatever order they sat on disk, so
two exports of one account could differ, and the parity goldens flapped
once the fixture's child rows shared their pinned timestamps.

Each read now ends its order in the model's primary key: the existing
sort key plus `id`, or the remaining key columns of a compound key
(nutrient `source`, hidden tag `moodTagId`, link `symptomId` /
`moodTagId` / `documentId`). Schedules sort by window start, then id.
The restore re-export reads through the same modules, so it inherits
the order.

The parity goldens are unchanged: they already hold the tie-broken
order, so no regeneration was needed and they still pin the output of
the pre-split exporter.

A new integration test runs both purposes, built and streamed, through
a client that records every findMany and every nested to-many read, and
fails on any whose order does not cover the primary key (keys pinned by
an equality in `where`, or by the foreign key to the parent, count as
covered). A second case writes the same tied rows in forward and
reverse physical order and requires byte-identical files.
2026-10-23 00:28:38 +02:00

114 lines
4.2 KiB
TypeScript

/**
* The workout summaries, with both backup ends in one file. GPS routes and
* per-sample series do not travel, and the file's manifest says so.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { toJson } from "@/lib/db";
import type { RestoreContext } from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
export interface WorkoutBackupEntry {
/** Present in canonical DR payloads for manual workouts without externalId. */
id?: string;
createdAt?: string;
updatedAt?: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
minHeartRate: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
source: string;
externalId: string | null;
/** Canonical DR only; portable workout summaries keep their existing shape. */
metadata?: Prisma.JsonValue;
/** Canonical DR only, beside `metadata`: the source's own version marker. */
externalSourceVersion?: string | null;
}
/** The workout slice of the records section: the summaries. */
export async function buildWorkoutsBackupSlice(
prisma: Pick<PrismaClient, "workout">,
userId: string,
disasterRecovery: boolean,
): Promise<{ workouts: WorkoutBackupEntry[] }> {
const workoutRows = await prisma.workout.findMany({
where: { userId },
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
});
const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({
...(disasterRecovery
? {
id: w.id,
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
...(w.metadata == null ? {} : { metadata: w.metadata }),
externalSourceVersion: w.externalSourceVersion,
}
: {}),
sportType: w.sportType,
startedAt: w.startedAt.toISOString(),
endedAt: w.endedAt.toISOString(),
durationSec: w.durationSec,
totalEnergyKcal: w.totalEnergyKcal,
totalDistanceM: w.totalDistanceM,
avgHeartRate: w.avgHeartRate,
maxHeartRate: w.maxHeartRate,
minHeartRate: w.minHeartRate,
stepCount: w.stepCount,
elevationM: w.elevationM,
pauseDurationSec: w.pauseDurationSec,
source: w.source,
externalId: w.externalId,
}));
return { workouts };
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function clearWorkouts(ctx: RestoreContext): Promise<void> {
const workouts = await ctx.tx.workout.deleteMany({
where: { userId: ctx.ownerId },
});
ctx.cleared.workouts = workouts.count;
}
export async function restoreWorkouts(ctx: RestoreContext): Promise<void> {
const { tx, ownerId, payload } = ctx;
if (payload.workouts.length === 0) return;
await tx.workout.createMany({
data: payload.workouts.map((workout) => ({
...(workout.id ? { id: workout.id } : {}),
userId: ownerId,
sportType: workout.sportType,
startedAt: new Date(workout.startedAt),
endedAt: new Date(workout.endedAt),
durationSec: workout.durationSec,
totalEnergyKcal: workout.totalEnergyKcal ?? null,
totalDistanceM: workout.totalDistanceM ?? null,
avgHeartRate: workout.avgHeartRate ?? null,
maxHeartRate: workout.maxHeartRate ?? null,
minHeartRate: workout.minHeartRate ?? null,
stepCount: workout.stepCount ?? null,
elevationM: workout.elevationM ?? null,
pauseDurationSec: workout.pauseDurationSec ?? null,
source: workout.source as never,
externalId: workout.externalId ?? null,
externalSourceVersion: workout.externalSourceVersion ?? null,
...(workout.metadata == null
? {}
: { metadata: toJson(workout.metadata) }),
...(workout.createdAt ? { createdAt: new Date(workout.createdAt) } : {}),
...(workout.updatedAt ? { updatedAt: new Date(workout.updatedAt) } : {}),
})),
});
}