Files
HealthLog/src/lib/export/awards-backup.ts
T
Marc-André Bombeck 8e2e4f658b fix(backup): restore pre-0420 Fitbit and Google Health HRV as RMSSD
A backup written before migration 0420 files Fitbit and Google Health
HRV as HEART_RATE_VARIABILITY, and the restore wrote it back verbatim,
undoing the migration for the account (and able to duplicate an
HRV_RMSSD row of the same night, since type is part of the unique key).
The restore now applies the same move: those rows come back as
HRV_RMSSD, a legacy row whose RMSSD twin the file also holds is skipped,
and SDNN personal records set by them are left out as the migration
deleted them.
2026-10-21 16:25:25 +02:00

359 lines
14 KiB
TypeScript

/**
* What the account EARNED: its personal bests and its unlocked badges, with
* both backup ends in one file.
*
* Same arrangement as `reminders-backup.ts` and `coach-backup.ts`, for the same
* reason: a reader asking "is this carried at both ends?" answers it here, and
* a reader who greps only the restore ROUTE gets a false negative because the
* route delegates.
*
* Two models ride. The register called the bests "recomputable in principle
* from measurements and workouts, but nothing recomputes them today, so in
* practice they are lost", and the badges "milestones the account earned, with
* the date each was reached. The date is the part that cannot be recovered".
* Both entries understate it slightly, and the difference is worth writing
* down because it decides what this file has to carry.
*
* ## Neither one is really recomputable
*
* A best is a claim about a history, and the history a restored account has is
* not the one the best was found in. Workout sample series and GPS routes do
* not travel at all (disclosed in the file's own manifest), a portable export
* omits soft-deleted measurements, and a record found in a reading that has
* since been corrected can never be found again. So a "recomputation" would
* not reproduce these rows; it would produce different ones and call them the
* same.
*
* The badge is worse, because the date is load-bearing and half the dates have
* no source left. `achievements-result.ts` merges persisted unlock dates with
* dates it can still derive, and persisted wins. Several metrics it derives
* from are things this backup deliberately does not carry: passkey and
* password login counts, the doctor-PDF and locale-flip counters. Drop the
* rows and those badges do not come back with an older date; they come back
* with today's, or they relock.
*
* ## The one reference that can take the whole restore down
*
* `PersonalRecord.sourceMeasurementId` looks like a bare id column in
* `prisma/schema.prisma`, which declares no relation for it. The DATABASE
* disagrees: migration 0054 created it as
* `REFERENCES "measurements"("id") ON DELETE SET NULL`, so it is a real
* foreign key that the Prisma client does not know about.
*
* That is the dangerous combination rather than the harmless one. The client
* writes whatever value it is handed, and Postgres refuses the statement,
* which rolls back the whole transaction and returns an account with NOTHING
* in it over a single provenance pointer. The mood categories were the same
* shape of defect measured a release earlier: a 500 and an empty account.
*
* So the restore resolves the pointer against the measurements it actually
* wrote, NULLS what it cannot resolve, and reports the miss. Nulling rather
* than dropping is the point: the record itself is the historical fact, which
* is exactly why the column was declared `ON DELETE SET NULL` in the first
* place. Losing the pointer costs an audit trail nothing reads; losing the row
* costs a best that cannot be found again.
*
* The ordinary way a pointer fails to resolve is a portable file: the
* measurement behind the record was soft-deleted, so the file omits it while
* the record still names it.
*
* ## An unlocked badge is not judged against the catalogue
*
* `UserAchievement.achievementId` names a definition in
* `src/lib/gamification/achievements.ts`. That is code, not a table, so nothing in
* the database constrains it and an unknown value costs no error. It is
* carried and written back verbatim, and the restore does not check it against
* the catalogue this build ships. The catalogue is the part that drifts: a
* file can be older than the release reading it, or newer. A row is evidence
* that a person earned something on a day, and a build that no longer defines
* the badge is not evidence that they did not. The same decision the
* vaccination restore makes for an antigen slug it cannot resolve, for the
* same reason.
*
* ## One wire shape, both purposes
*
* Neither model has a tombstone column and neither holds ciphertext, so a
* portable export and a disaster-recovery payload carry byte-identical
* sections. This builder therefore takes no `purpose`: a parameter that
* changes nothing would advertise a distinction the file does not have.
*/
import { isLegacyRmssdRow } from "@/lib/export/legacy-rmssd";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type {
MeasurementSource,
MeasurementType,
PersonalRecordDirection,
} from "@/generated/prisma/client";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
/** One best, as the detection worker recorded it. */
export interface PersonalRecordBackupEntry {
metricType: MeasurementType;
/**
* The sport dimension for a workout-driven best (`running_5km_time`), NULL
* for a measurement-driven one. Carried because it is what separates two
* bests that share a metric: without it a best 5k and a best 10k are the
* same row said twice.
*/
metricSlot: string | null;
/**
* Whether higher or lower wins. Not derivable from the row: the read path
* orders by `value` in the direction this column names, so a best time
* restored as MAX becomes the account's WORST time, presented as its record.
*/
direction: PersonalRecordDirection;
value: number;
unit: string;
/** The day the best was set. The whole point of the row. */
achievedAt: string;
/**
* The measurement the record was found in. A real foreign key in the
* database despite the schema declaring no relation; see the file header.
*/
sourceMeasurementId: string | null;
source: MeasurementSource;
externalId: string | null;
createdAt: string;
}
/** One badge, and the day it was earned. */
export interface UserAchievementBackupEntry {
/** A definition id in the code catalogue. Carried verbatim, never judged. */
achievementId: string;
/**
* When the badge was earned. The irreplaceable field: the evaluator prefers
* a persisted date over a derived one, and for several metrics no derivable
* date survives a restore at all.
*/
unlockedAt: string;
createdAt: string;
}
export interface AwardsBackupSection {
personalRecords: PersonalRecordBackupEntry[];
userAchievements: UserAchievementBackupEntry[];
}
export interface AwardsBackupCounts {
personalRecords: number;
userAchievements: number;
}
/**
* Named select constants, one per model, because a structural matcher binds a model
* to the literal beside its delegate call, matching the other section files.
*
* No `id` on either. Nothing in the file addresses a best or a badge, so a
* stable id would be carried for its own sake; the one id that IS carried is
* the measurement a best points at, because that one is a reference the
* restore has to resolve.
*/
const PERSONAL_RECORD_BACKUP_SELECT = {
metricType: true,
metricSlot: true,
direction: true,
value: true,
unit: true,
achievedAt: true,
sourceMeasurementId: true,
source: true,
externalId: true,
createdAt: true,
} as const satisfies Prisma.PersonalRecordSelect;
const USER_ACHIEVEMENT_BACKUP_SELECT = {
achievementId: true,
unlockedAt: true,
createdAt: true,
} as const satisfies Prisma.UserAchievementSelect;
/**
* Build the awards slice of a user's full backup.
*
* Takes the delegates it uses rather than a whole client, matching the other
* section builders.
*/
export async function buildAwardsBackupSection(
prisma: Pick<PrismaClient, "personalRecord" | "userAchievement">,
userId: string,
): Promise<AwardsBackupSection> {
const [recordRows, achievementRows] = await Promise.all([
prisma.personalRecord.findMany({
where: { userId },
orderBy: { achievedAt: "asc" },
select: PERSONAL_RECORD_BACKUP_SELECT,
}),
prisma.userAchievement.findMany({
where: { userId },
orderBy: { unlockedAt: "asc" },
select: USER_ACHIEVEMENT_BACKUP_SELECT,
}),
]);
return {
personalRecords: recordRows.map((row) => ({
metricType: row.metricType,
metricSlot: row.metricSlot,
direction: row.direction,
value: row.value,
unit: row.unit,
achievedAt: row.achievedAt.toISOString(),
sourceMeasurementId: row.sourceMeasurementId,
source: row.source,
externalId: row.externalId,
createdAt: row.createdAt.toISOString(),
})),
userAchievements: achievementRows.map((row) => ({
achievementId: row.achievementId,
unlockedAt: row.unlockedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countAwardsBackupSection(
section: AwardsBackupSection,
): AwardsBackupCounts {
return {
personalRecords: section.personalRecords.length,
userAchievements: section.userAchievements.length,
};
}
/** Counts the awards restore wiped, for the audit trail. */
export interface AwardsRestoreCleared {
personalRecords: number;
userAchievements: number;
}
/**
* What the restore reads, as the parsed file actually presents it. Wider than
* what this release writes, because every optional column arrives as
* `undefined` rather than `null` from a file written before it existed.
*
* `unlockedAt` stays strictly required. A file that does not say when a badge
* was earned must not be silently restored to restore-day, and making the
* field optional here would let exactly that compile.
*/
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredPersonalRecord = Pick<
PersonalRecordBackupEntry,
"metricType" | "direction" | "value" | "unit" | "achievedAt"
> &
OptionalNullable<
Pick<
PersonalRecordBackupEntry,
"metricSlot" | "sourceMeasurementId" | "source" | "externalId"
>
> & { createdAt?: string };
export type RestoredUserAchievement = Pick<
UserAchievementBackupEntry,
"achievementId" | "unlockedAt"
> & { createdAt?: string };
export interface AwardsRestoreInput {
personalRecords: RestoredPersonalRecord[];
userAchievements: RestoredUserAchievement[];
}
/**
* Re-create the account's bests and badges.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section.
*
* MUST be called AFTER the measurements are restored. `sourceMeasurementId` is
* a foreign key the Prisma schema does not declare (see the file header), so
* running earlier would not quietly drop the provenance. It would fail the
* constraint and roll the entire restore back.
*
* `measurementIds` is the set of measurements the restore actually wrote with
* a stable id, passed in rather than re-queried so this stays a pure function
* of the transaction it was handed. Legacy v1 files carry measurements with no
* id at all; those rows are minted fresh and are unaddressable by definition,
* so a best that names one lands with a NULL pointer and a report.
*/
export async function restoreAwardsData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: AwardsRestoreInput,
measurementIds: ReadonlySet<string>,
skips: RestoreSkipLog,
): Promise<AwardsRestoreCleared> {
const [clearedRecords, clearedAchievements] = await Promise.all([
tx.personalRecord.deleteMany({ where: { userId: ownerId } }),
tx.userAchievement.deleteMany({ where: { userId: ownerId } }),
]);
const danglingMeasurementRefs: string[] = [];
// A file written before migration 0420 can hold an SDNN best that a
// Fitbit or Google Health RMSSD reading set. The migration deleted those
// (that series keeps no records, and the detector re-derives the SDNN bests
// from what remains), so the restore leaves them out too.
const records = payload.personalRecords.filter(
(entry) =>
!isLegacyRmssdRow({
type: entry.metricType,
source: entry.source ?? null,
}),
);
if (records.length > 0) {
await tx.personalRecord.createMany({
data: records.map((entry) => {
let sourceMeasurementId = entry.sourceMeasurementId ?? null;
if (sourceMeasurementId && !measurementIds.has(sourceMeasurementId)) {
danglingMeasurementRefs.push(sourceMeasurementId);
sourceMeasurementId = null;
}
return {
userId: ownerId,
metricType: entry.metricType,
metricSlot: entry.metricSlot ?? null,
direction: entry.direction,
value: entry.value,
unit: entry.unit,
achievedAt: new Date(entry.achievedAt),
sourceMeasurementId,
// A file written before the column existed says nothing about it,
// and the schema default is what those rows were living as.
source: entry.source ?? "MANUAL",
externalId: entry.externalId ?? null,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
};
}),
});
}
recordUnknownKeys(
skips,
"personalRecordReference",
[...new Set(danglingMeasurementRefs)],
danglingMeasurementRefs,
);
if (payload.userAchievements.length > 0) {
await tx.userAchievement.createMany({
data: payload.userAchievements.map((entry) => ({
userId: ownerId,
// Verbatim, and not checked against this build's catalogue. See the
// file header: the catalogue is code and drifts, the row is evidence.
achievementId: entry.achievementId,
// Verbatim, and the reason this section exists at all.
unlockedAt: new Date(entry.unlockedAt),
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
})),
});
}
return {
personalRecords: clearedRecords.count,
userAchievements: clearedAchievements.count,
};
}