Merge pull request #820 from MBombeck/work/backup-awards

Carry the bests, the badges and the environment days through backup and restore
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 11:37:24 +02:00
commit a6c239d952
20 files changed
+1350 -21

No files matched your search

+2 -1
View File
@@ -6918,7 +6918,8 @@
"restoreSkippedDocumentConditionLink": "Dokumentzuordnung",
"restoreSkippedExtractedFact": "Dokumentbefund",
"restoreSkippedFactCommitment": "Befundübernahme",
"docsLink": "Dokumentation"
"docsLink": "Dokumentation",
"restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung"
},
"danger-zone": {
"title": "Gefahrenzone",
+2 -1
View File
@@ -6918,7 +6918,8 @@
"restoreSkippedDocumentConditionLink": "Document filing",
"restoreSkippedExtractedFact": "Document fact",
"restoreSkippedFactCommitment": "Fact commitment",
"docsLink": "Documentation"
"docsLink": "Documentation",
"restoreSkippedPersonalRecordReference": "Personal best provenance"
},
"danger-zone": {
"title": "Danger Zone",
+2 -1
View File
@@ -6918,7 +6918,8 @@
"restoreSkippedDocumentConditionLink": "Clasificación de documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registro del dato",
"docsLink": "Documentación"
"docsLink": "Documentación",
"restoreSkippedPersonalRecordReference": "Procedencia de un récord personal"
},
"danger-zone": {
"title": "Zona de peligro",
+2 -1
View File
@@ -6918,7 +6918,8 @@
"restoreSkippedDocumentConditionLink": "Classement du document",
"restoreSkippedExtractedFact": "Donnée du document",
"restoreSkippedFactCommitment": "Enregistrement de la donnée",
"docsLink": "Documentation"
"docsLink": "Documentation",
"restoreSkippedPersonalRecordReference": "Provenance d'un record personnel"
},
"danger-zone": {
"title": "Zone de danger",
+2 -1
View File
@@ -6918,7 +6918,8 @@
"restoreSkippedDocumentConditionLink": "Classificazione del documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registrazione del dato",
"docsLink": "Documentazione"
"docsLink": "Documentazione",
"restoreSkippedPersonalRecordReference": "Provenienza di un primato personale"
},
"danger-zone": {
"title": "Zona pericolosa",
+2 -1
View File
@@ -6918,7 +6918,8 @@
"restoreSkippedDocumentConditionLink": "Przypisanie dokumentu",
"restoreSkippedExtractedFact": "Fakt z dokumentu",
"restoreSkippedFactCommitment": "Zapis faktu",
"docsLink": "Dokumentacja"
"docsLink": "Dokumentacja",
"restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego"
},
"danger-zone": {
"title": "Strefa zagrożenia",
+11 -5
View File
@@ -1053,8 +1053,10 @@ model EnvironmentContext {
lat Float
lon Float
/// @internal: human-readable label of the resolved location, stored as
/// operator/debug provenance beside the coordinates; readers select the
/// numeric and weather columns only, never this label.
/// operator/debug provenance beside the coordinates; surfaces select the
/// numeric and weather columns only, never this label. The backup selects it
/// (`src/lib/export/environment-backup.ts`) with the rest of the row: a
/// restored reading has to keep saying where it was read.
locationLabel String @map("location_label")
source EnvironmentLocationSource
@@ -2426,9 +2428,13 @@ model PersonalRecord {
/// FK to the Measurement row that achieved the record. SET NULL
/// on delete so the historical fact survives even if the
/// underlying measurement is later removed.
/// @internal: audit-trail provenance written by the PR detection worker;
/// no read path selects it — the record row itself carries the surfaced
/// value/unit/achievedAt.
/// @internal: audit-trail provenance written by the PR detection worker; no
/// SURFACE selects it: the record row itself carries the value, unit and
/// achievedAt a reader sees. The backup does select it
/// (`src/lib/export/awards-backup.ts`), because the column is a real foreign
/// key in the database even though no relation is declared here, so a
/// restore has to resolve it against the measurements it wrote instead of
/// writing it blind and failing the constraint.
sourceMeasurementId String? @map("source_measurement_id")
source MeasurementSource @default(MANUAL)
externalId String? @map("external_id")
@@ -296,6 +296,12 @@ function sourceClient() {
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// The bests, the badges, and the environmental history with the location
// periods that explain it. Empty for the same reason as the tables above.
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
@@ -57,6 +57,8 @@ import {
} from "@/lib/export/coach-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -114,6 +116,10 @@ interface RestoreResponse {
coachReminders: number;
mentalHealthAssessments: number;
consentReceipts: number;
personalRecords: number;
userAchievements: number;
environmentContexts: number;
environmentTravelLocations: number;
};
}
@@ -1654,6 +1660,38 @@ const handler = apiHandler(
payload,
);
// The bests and the badges. AFTER the measurements, and this one is
// not a preference: `PersonalRecord.sourceMeasurementId` is a real
// foreign key against `measurements` (migration 0054) even though
// `prisma/schema.prisma` declares no relation for it, so a pointer
// resolved before the measurements exist would not drop quietly.
// Postgres would refuse the insert and roll the whole restore back
// over one provenance column. The id set is the measurements this
// transaction actually wrote with a stable id, threaded in rather
// than re-queried so the function stays a pure reader of it. Both
// ends of this section live in `src/lib/export/awards-backup.ts`.
const awardsCleared = await restoreAwardsData(
tx,
ownerId,
payload,
new Set(stableRows.map((row) => row.id)),
skips,
);
// The per-day readings and the location periods that explain them.
// Neither references anything but the account, so this section has
// no ordering constraint against any other and sits here beside the
// rest. What it does owe is atomicity WITH ITSELF, which is why one
// function writes both: readings restored without their periods get
// re-resolved to the home location and overwritten by the next
// environment refresh. Both ends of this section live in
// `src/lib/export/environment-backup.ts`.
const environmentCleared = await restoreEnvironmentData(
tx,
ownerId,
payload,
);
const cleared = {
measurements: measurements.count,
medications: meds.count,
@@ -1699,6 +1737,11 @@ const handler = apiHandler(
coachReminders: coachMemoryCleared.coachReminders,
mentalHealthAssessments: sensitiveCleared.mentalHealthAssessments,
consentReceipts: sensitiveCleared.consentReceipts,
personalRecords: awardsCleared.personalRecords,
userAchievements: awardsCleared.userAchievements,
environmentContexts: environmentCleared.environmentContexts,
environmentTravelLocations:
environmentCleared.environmentTravelLocations,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
@@ -42,6 +42,12 @@ vi.mock("@/lib/db", () => ({
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// The bests, the badges, and the environmental history with the location
// periods that explain it. Empty for the same reason as the tables above.
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
@@ -155,6 +161,12 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.environmentContext.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue(
[] as never,
);
});
afterEach(() => {
@@ -40,6 +40,12 @@ vi.mock("@/lib/db", () => ({
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// The bests, the badges, and the environmental history with the location
// periods that explain it. Empty for the same reason as the tables above.
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
+3
View File
@@ -353,6 +353,9 @@ function catalogueLabel(
if (catalogue === "moodFactor") {
return t("admin.section.backups.restoreSkippedMoodFactor");
}
if (catalogue === "personalRecordReference") {
return t("admin.section.backups.restoreSkippedPersonalRecordReference");
}
// Not a fallback: the chain above is exhaustive and this line is what makes
// the compiler say so. A catalogue added without a label here now stops the
// build instead of shipping under a label that belongs to something else,
@@ -352,6 +352,12 @@ function makePrisma() {
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// The bests, the badges, and the environmental history with the location
// periods that explain it. Empty for the same reason as the tables above.
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
// The Coach transcript. Deliberately NOT `?? []` at the call site, so a
// future `include` that forgets the relation fails here loudly instead of
// exporting an account whose Coach never spoke.
+346
View File
@@ -0,0 +1,346 @@
/**
* 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 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[] = [];
if (payload.personalRecords.length > 0) {
await tx.personalRecord.createMany({
data: payload.personalRecords.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,
};
}
+36 -8
View File
@@ -230,6 +230,12 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// documents themselves are read by `records-backup.ts`; these two read
// through their OWN delegates here, for the reason the visits comment gives.
"src/lib/export/document-filing-backup.ts",
// The bests and the badges, and the environmental history with the location
// periods that explain it. Two files rather than one because they are two
// unrelated parts of the record, both with their ends beside each other for
// the reason the visits comment gives.
"src/lib/export/awards-backup.ts",
"src/lib/export/environment-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -244,6 +250,8 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/coach-backup.ts",
"src/lib/export/sensitive-backup.ts",
"src/lib/export/document-filing-backup.ts",
"src/lib/export/awards-backup.ts",
"src/lib/export/environment-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -412,6 +420,34 @@ export const TWO_ENDED_MODELS = [
// nulled together with its type when it resolves to nothing.
"DocumentConditionLink",
"ExtractedFact",
// What the account earned. The register called the bests "recomputable in
// principle", which is true and does not help: nothing recomputes them, and
// a recomputation over a restored history would not find the same rows:
// sample series and GPS routes never travel, a portable file omits deleted
// readings, and a best found in a reading that has since been corrected is
// not findable twice. The badge's unlock date is worse than that. The
// evaluator prefers a persisted date over a derived one, and several of the
// dates it can derive come from counters this backup deliberately excludes,
// so a dropped row does not relock the badge, it re-earns it today.
//
// `PersonalRecord.sourceMeasurementId` is the reference that needs care, and
// it is the dangerous kind: the schema declares no relation, but migration
// 0054 created the column as a real foreign key against `measurements`. The
// restore therefore runs after the measurements and nulls what it cannot
// resolve, because the alternative is not a dangling pointer, it is Postgres
// refusing the file and handing back an empty account.
"PersonalRecord",
"UserAchievement",
// The per-day readings and the location periods, which travel as a pair
// because the register's own two lines say why: the readings are "joined to
// the record", and the periods are "what makes the environmental readings
// mean anything". Measured, the pairing is stronger than context. Carrying
// the readings alone would let the nightly seven-day refresh re-resolve every
// trip day to the home location and UPSERT over it, so an account would come
// back with a fortnight abroad and then quietly have it rewritten as a
// fortnight at home, days after a restore that reported success.
"EnvironmentContext",
"EnvironmentTravelLocation",
] as const;
/** One model claimed to travel both ways. */
@@ -458,14 +494,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
WorkoutSamples:
"Per-sample heart-rate and pace series behind a workout summary. Same disclosed-exclusion status as the routes above, same cost.",
PersonalRecord:
"Bests the account accumulated. Recomputable in principle from measurements and workouts, but nothing recomputes them today, so in practice they are lost.",
UserAchievement:
"Milestones the account earned, with the date each was reached. The date is the part that cannot be recovered.",
EnvironmentContext:
"Per-day environmental readings joined to the record. Re-fetchable for recent days only; older history is gone once the provider window closes.",
EnvironmentTravelLocation:
"Where the person was on a given day, which is what makes the environmental readings mean anything. Never re-derivable.",
};
/**
+358
View File
@@ -0,0 +1,358 @@
/**
* The per-day environmental readings and the location periods that explain
* them, 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.
*
* ## The two travel as a pair, and the pairing is not decoration
*
* The register listed them separately, as "per-day environmental readings joined
* to the record", "where the person was on a given day, which is what makes
* the environmental readings mean anything", and the second line is the one
* that decides the design.
*
* A reading carries the coarse location it was fetched for (`lat`, `lon`,
* `locationLabel`) and the precedence rule that chose it (`source`). A
* `TRAVEL` reading exists because an explicit dated location period covered
* that day. `resolveLocationForDay` in `src/lib/environment/service.ts` reads
* those periods live, every time; nothing else can produce a `TRAVEL`
* verdict.
*
* So a restore that carried the readings and not the periods would not merely
* hand back readings with less context. The nightly refresh runs a seven-day
* lookback and an operator backfill re-resolves whatever range it is given;
* both UPSERT. With the periods gone, every trip day inside the range
* re-resolves to the home location and the upsert overwrites the row: the
* coordinates, the label, the source and every weather field. The account
* would end up with a fortnight abroad recorded as a fortnight of weather at
* home, written by the app itself, some days after a restore that reported
* success. That is why these two land together or not at all.
*
* The other direction is a plain loss with no rewrite behind it: periods
* without readings leave the correlation surfaces empty for the history, and
* the archive feed only reaches back so far before the older days are simply
* unfetchable.
*
* ## Day keys stay strings
*
* `date`, `startDate` and `endDate` are `YYYY-MM-DD`, and they cross the wire
* as the strings they are stored as. The resolver compares them
* lexicographically against other day keys, so nothing here needs a `Date`,
* and parsing one into a `Date` and formatting it back is exactly how a day
* key loses a day under a negative UTC offset. The row stamps
* (`fetchedAt`, `createdAt`, `updatedAt`) are real instants and ride as
* ISO-8601.
*
* `fetchedAt` is carried verbatim rather than stamped on the way in. It says
* when the upstream feed was last read for that day, and the archive feed
* settles over a few days after the fact, so a restore that wrote "now" would
* claim a provisional reading from two years ago had just been confirmed.
*
* ## 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 type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
/** One day's environmental observation, at the location resolved for that day. */
export interface EnvironmentContextBackupEntry {
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
date: string;
lat: number;
lon: number;
locationLabel: string;
/**
* Which precedence rule chose the location. Carried because it is the only
* record that this day was NOT the home city, and because a re-resolve
* cannot recover it once the period behind it is gone.
*/
source: EnvironmentLocationSource;
tempMin: number | null;
tempMax: number | null;
tempMean: number | null;
apparentMean: number | null;
sunshineSec: number | null;
daylightSec: number | null;
precipSum: number | null;
pressureMean: number | null;
pressureDelta: number | null;
humidityMean: number | null;
cloudMean: number | null;
weatherCode: number | null;
/** When the feed was last read for this day. Verbatim; see the file header. */
fetchedAt: string;
createdAt: string;
updatedAt: string;
}
/** One declared stretch spent somewhere other than home. */
export interface EnvironmentTravelLocationBackupEntry {
/** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */
startDate: string;
endDate: string;
lat: number;
lon: number;
label: string;
createdAt: string;
updatedAt: string;
}
export interface EnvironmentBackupSection {
environmentContexts: EnvironmentContextBackupEntry[];
environmentTravelLocations: EnvironmentTravelLocationBackupEntry[];
}
export interface EnvironmentBackupCounts {
environmentContexts: number;
environmentTravelLocations: 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 or in the database addresses one of
* these rows, and a reading is identified by its day.
*/
const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
date: true,
lat: true,
lon: true,
locationLabel: true,
source: true,
tempMin: true,
tempMax: true,
tempMean: true,
apparentMean: true,
sunshineSec: true,
daylightSec: true,
precipSum: true,
pressureMean: true,
pressureDelta: true,
humidityMean: true,
cloudMean: true,
weatherCode: true,
fetchedAt: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.EnvironmentContextSelect;
const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.EnvironmentTravelLocationSelect;
/**
* Build the environment 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 buildEnvironmentBackupSection(
prisma: Pick<
PrismaClient,
"environmentContext" | "environmentTravelLocation"
>,
userId: string,
): Promise<EnvironmentBackupSection> {
const [contextRows, travelRows] = await Promise.all([
prisma.environmentContext.findMany({
where: { userId },
orderBy: { date: "asc" },
select: ENVIRONMENT_CONTEXT_BACKUP_SELECT,
}),
prisma.environmentTravelLocation.findMany({
where: { userId },
orderBy: { startDate: "asc" },
select: ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT,
}),
]);
return {
environmentContexts: contextRows.map((row) => ({
date: row.date,
lat: row.lat,
lon: row.lon,
locationLabel: row.locationLabel,
source: row.source,
tempMin: row.tempMin,
tempMax: row.tempMax,
tempMean: row.tempMean,
apparentMean: row.apparentMean,
sunshineSec: row.sunshineSec,
daylightSec: row.daylightSec,
precipSum: row.precipSum,
pressureMean: row.pressureMean,
pressureDelta: row.pressureDelta,
humidityMean: row.humidityMean,
cloudMean: row.cloudMean,
weatherCode: row.weatherCode,
fetchedAt: row.fetchedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
environmentTravelLocations: travelRows.map((row) => ({
startDate: row.startDate,
endDate: row.endDate,
lat: row.lat,
lon: row.lon,
label: row.label,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countEnvironmentBackupSection(
section: EnvironmentBackupSection,
): EnvironmentBackupCounts {
return {
environmentContexts: section.environmentContexts.length,
environmentTravelLocations: section.environmentTravelLocations.length,
};
}
/** Counts the environment restore wiped, for the audit trail. */
export interface EnvironmentRestoreCleared {
environmentContexts: number;
environmentTravelLocations: number;
}
/**
* What the restore reads, as the parsed file actually presents it. Wider than
* what this release writes, because every weather column arrives as
* `undefined` rather than `null` from a file written before it existed.
*
* `source` stays strictly required. It is the field that says this day was not
* the home city, and defaulting it would quietly re-attribute a trip.
*/
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredEnvironmentContext = Pick<
EnvironmentContextBackupEntry,
"date" | "lat" | "lon" | "locationLabel" | "source"
> &
OptionalNullable<
Pick<
EnvironmentContextBackupEntry,
| "tempMin"
| "tempMax"
| "tempMean"
| "apparentMean"
| "sunshineSec"
| "daylightSec"
| "precipSum"
| "pressureMean"
| "pressureDelta"
| "humidityMean"
| "cloudMean"
| "weatherCode"
| "fetchedAt"
| "createdAt"
| "updatedAt"
>
>;
export type RestoredEnvironmentTravelLocation = Pick<
EnvironmentTravelLocationBackupEntry,
"startDate" | "endDate" | "lat" | "lon" | "label"
> &
OptionalNullable<
Pick<EnvironmentTravelLocationBackupEntry, "createdAt" | "updatedAt">
>;
export interface EnvironmentRestoreInput {
environmentContexts: RestoredEnvironmentContext[];
environmentTravelLocations: RestoredEnvironmentTravelLocation[];
}
/**
* Re-create the account's environmental history and its location periods.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section.
*
* Neither model references anything but the account, so this section has no
* ordering constraint against any other one and can run anywhere in the
* restore. The ordering that does matter is INSIDE it, and it is the reason
* the two live in one function: the periods and the readings must land in the
* same transaction, because a set of readings whose periods are missing is a
* history the next refresh will silently rewrite to the home location. The
* periods go first so a reader of this function meets the explanation before
* the thing it explains.
*/
export async function restoreEnvironmentData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: EnvironmentRestoreInput,
): Promise<EnvironmentRestoreCleared> {
const [clearedTravel, clearedContexts] = await Promise.all([
tx.environmentTravelLocation.deleteMany({ where: { userId: ownerId } }),
tx.environmentContext.deleteMany({ where: { userId: ownerId } }),
]);
if (payload.environmentTravelLocations.length > 0) {
await tx.environmentTravelLocation.createMany({
data: payload.environmentTravelLocations.map((entry) => ({
userId: ownerId,
// Day keys, written as they were read. See the file header for why
// neither bound goes near a `Date`.
startDate: entry.startDate,
endDate: entry.endDate,
lat: entry.lat,
lon: entry.lon,
label: entry.label,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
});
}
if (payload.environmentContexts.length > 0) {
await tx.environmentContext.createMany({
data: payload.environmentContexts.map((entry) => ({
userId: ownerId,
date: entry.date,
lat: entry.lat,
lon: entry.lon,
locationLabel: entry.locationLabel,
source: entry.source,
tempMin: entry.tempMin ?? null,
tempMax: entry.tempMax ?? null,
tempMean: entry.tempMean ?? null,
apparentMean: entry.apparentMean ?? null,
sunshineSec: entry.sunshineSec ?? null,
daylightSec: entry.daylightSec ?? null,
precipSum: entry.precipSum ?? null,
pressureMean: entry.pressureMean ?? null,
pressureDelta: entry.pressureDelta ?? null,
humidityMean: entry.humidityMean ?? null,
cloudMean: entry.cloudMean ?? null,
weatherCode: entry.weatherCode ?? null,
// Verbatim, not stamped: this says when the feed was read, not when
// the row was written back.
...(entry.fetchedAt ? { fetchedAt: new Date(entry.fetchedAt) } : {}),
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
});
}
return {
environmentContexts: clearedContexts.count,
environmentTravelLocations: clearedTravel.count,
};
}
+33 -1
View File
@@ -78,6 +78,18 @@ import {
type DocumentFilingBackupCounts,
type DocumentFilingBackupSection,
} from "@/lib/export/document-filing-backup";
import {
buildAwardsBackupSection,
countAwardsBackupSection,
type AwardsBackupCounts,
type AwardsBackupSection,
} from "@/lib/export/awards-backup";
import {
buildEnvironmentBackupSection,
countEnvironmentBackupSection,
type EnvironmentBackupCounts,
type EnvironmentBackupSection,
} from "@/lib/export/environment-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
@@ -97,7 +109,9 @@ export interface FullBackupCounts
CoachBackupCounts,
CoachMemoryBackupCounts,
SensitiveBackupCounts,
DocumentFilingBackupCounts {
DocumentFilingBackupCounts,
AwardsBackupCounts,
EnvironmentBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -325,6 +339,8 @@ export async function buildFullBackupPayload(
coachMemory,
sensitive,
documentFiling,
awards,
environment,
nutrientDays,
] = await Promise.all([
disasterRecovery
@@ -523,6 +539,16 @@ export async function buildFullBackupPayload(
buildDocumentFilingBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The bests and the badges. Both ends live in
// `src/lib/export/awards-backup.ts`, the same arrangement as the sections
// above. No `purpose`: neither model tombstones and neither holds
// ciphertext, so both purposes carry the same bytes.
buildAwardsBackupSection(prisma, userId),
// The per-day readings and the location periods that explain them, which
// is why one section carries both. Both ends live in
// `src/lib/export/environment-backup.ts`, and the purpose is absent for
// the same reason as the awards above.
buildEnvironmentBackupSection(prisma, userId),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -560,6 +586,8 @@ export async function buildFullBackupPayload(
};
const sensitiveManifest: SensitiveBackupManifest = sensitive.manifest;
const documentFilingSection: DocumentFilingBackupSection = documentFiling;
const awardsSection: AwardsBackupSection = awards;
const environmentSection: EnvironmentBackupSection = environment;
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
@@ -916,6 +944,8 @@ export async function buildFullBackupPayload(
// depending on spread order.
manifest: { ...recordsSection.manifest, ...sensitiveManifest },
...documentFilingSection,
...awardsSection,
...environmentSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -969,6 +999,8 @@ export async function buildFullBackupPayload(
...countCoachMemoryBackupSection(coachMemory),
...countSensitiveBackupSection(sensitive),
...countDocumentFilingBackupSection(documentFiling),
...countAwardsBackupSection(awards),
...countEnvironmentBackupSection(environment),
},
};
}
+15
View File
@@ -92,6 +92,20 @@
* cannot name.
*
* The thirteenth, `checkupClosure`, is not about a restore at all, and it borrows
* The tenth, `personalRecordReference`, names the measurement a personal best
* was found in. It reads like `coachReference` and is the opposite case in the
* one way that matters: `PersonalRecord.sourceMeasurementId` LOOKS like a bare
* id column in `prisma/schema.prisma`, which declares no relation for it, but
* migration 0054 created it as a real foreign key against `measurements`. So a
* dangling value here does not quietly stop meaning something. Postgres
* refuses the statement and the whole restore rolls back over one provenance
* pointer. What is dropped is therefore the POINTER and not the row: the best
* itself is the historical fact, which is why the column was declared
* `ON DELETE SET NULL` in the first place. The ordinary way it fails to
* resolve is a portable export whose source measurement was soft-deleted, and
* a legacy file whose measurements carry no ids at all.
*
* The eleventh, `checkupClosure`, is not about a restore at all, and it borrows
* this shape deliberately rather than growing a second reporting mechanism
* beside it. The situation is the same one: something a write was asked to do
* could not be done, the record itself survives, and the person is told which
@@ -112,6 +126,7 @@ export type SkippedCatalogue =
| "documentConditionLink"
| "extractedFact"
| "factCommitment"
| "personalRecordReference"
| "checkupClosure";
/** One key this instance does not know, and the links it cost. */
+130
View File
@@ -36,6 +36,7 @@ import {
EncounterStatus,
ExtractedFactStatus,
ExtractedFactType,
EnvironmentLocationSource,
FamilyRelationship,
FlowLevel,
GlucoseContext,
@@ -61,6 +62,7 @@ import {
MedicationSideEffectCategory,
MedicationSideEffectEntry,
OvulationTest,
PersonalRecordDirection,
ReminderOrigin,
RhythmClassification,
SecondarySymptom,
@@ -1105,6 +1107,106 @@ const measurementReminderEventBackupSchema = z
})
.passthrough();
/**
* One personal best.
*
* `direction` is required rather than defaulted: it says whether higher or
* lower wins, the read path orders by it, and a best time defaulted to MAX
* would present the account's worst time as its record. `sourceMeasurementId`
* is the provenance pointer the restore resolves, and a real foreign key in the
* database, whatever `prisma/schema.prisma` says, so the restore nulls what it
* cannot resolve rather than letting Postgres refuse the whole file.
*/
const personalRecordBackupSchema = z
.object({
metricType: z.enum(MeasurementType),
metricSlot: z.string().nullable().optional(),
direction: z.enum(PersonalRecordDirection),
value: z.number(),
unit: z.string().min(1),
achievedAt: isoDateTime,
sourceMeasurementId: z.string().nullable().optional(),
source: z.enum(MeasurementSource).optional(),
externalId: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
})
.passthrough();
/**
* One unlocked badge.
*
* `achievementId` is a free string on purpose: it names a definition in the
* code catalogue, which drifts between the release that wrote the file and the
* one that reads it, and a row is evidence that a person earned something on a
* day rather than a claim about what this build ships.
*
* `unlockedAt` is REQUIRED, unlike almost every other stamp in this file. It
* is the field that cannot be recovered from anywhere else, and a file that
* does not state it must fail rather than restore every badge to today.
*/
const userAchievementBackupSchema = z
.object({
achievementId: z.string().min(1),
unlockedAt: isoDateTime,
createdAt: isoDateTime.optional(),
})
.passthrough();
/** `YYYY-MM-DD`, the day key both environment tables are addressed by. */
const environmentDayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
message: "Expected a YYYY-MM-DD day key",
});
/**
* One day's environmental reading.
*
* `source` is required: it is the only record that the day resolved somewhere
* other than the home city, and a defaulted value would re-attribute a trip to
* home. Every weather column is optional, because the feed is allowed to be partial
* and a file written before a column existed says nothing about it.
*/
const environmentContextBackupSchema = z
.object({
date: environmentDayKey,
lat: z.number(),
lon: z.number(),
locationLabel: z.string(),
source: z.enum(EnvironmentLocationSource),
tempMin: z.number().nullable().optional(),
tempMax: z.number().nullable().optional(),
tempMean: z.number().nullable().optional(),
apparentMean: z.number().nullable().optional(),
sunshineSec: z.number().int().nullable().optional(),
daylightSec: z.number().int().nullable().optional(),
precipSum: z.number().nullable().optional(),
pressureMean: z.number().nullable().optional(),
pressureDelta: z.number().nullable().optional(),
humidityMean: z.number().nullable().optional(),
cloudMean: z.number().nullable().optional(),
weatherCode: z.number().int().nullable().optional(),
fetchedAt: isoDateTime.optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
/**
* One declared stretch spent away from home. Both bounds are day keys rather
* than instants: the resolver compares them lexicographically against other
* day keys, and routing one through a `Date` is how a day key loses a day.
*/
const environmentTravelLocationBackupSchema = z
.object({
startDate: environmentDayKey,
endDate: environmentDayKey,
lat: z.number(),
lon: z.number(),
label: z.string(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
/**
* One Coach turn.
*
@@ -1512,6 +1614,19 @@ export const backupPayloadSchema = z
measurementReminderEvents: z
.array(measurementReminderEventBackupSchema)
.default([]),
// The bests and the badges. Defaulted for the same reason as the sections
// above: a file written before they travelled carries no key, and an
// account that has earned neither writes [].
personalRecords: z.array(personalRecordBackupSchema).default([]),
userAchievements: z.array(userAchievementBackupSchema).default([]),
// The per-day readings and the location periods that explain them. Two
// keys rather than one because they are two tables, but they are written
// and restored as a pair: readings whose periods are missing get rewritten
// to the home location by the next refresh.
environmentContexts: z.array(environmentContextBackupSchema).default([]),
environmentTravelLocations: z
.array(environmentTravelLocationBackupSchema)
.default([]),
manifest: backupManifestSchema.nullable().default(null),
// v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt
// (fail-soft nulls). Disclosed in the file so a nulled field is
@@ -1614,6 +1729,14 @@ export interface BackupSummary {
mentalHealthAssessments: number;
/** Consent records. Disaster-recovery payloads only. */
consentReceipts: number;
/** Personal bests across every metric and sport slot. */
personalRecords: number;
/** Badges the account has unlocked, each with the day it was earned. */
userAchievements: number;
/** Local days with an environmental reading. */
environmentContexts: number;
/** Declared stretches spent away from home. */
environmentTravelLocations: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
@@ -1685,6 +1808,13 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
coachReminders: payload.coachReminders.length,
mentalHealthAssessments: payload.mentalHealthAssessments.length,
consentReceipts: payload.consentReceipts.length,
// Counted from the release that carries them, so the admin's "what did I
// just restore" answer never under-counts a file with bests, badges or an
// environmental history the way it once did for visits.
personalRecords: payload.personalRecords.length,
userAchievements: payload.userAchievements.length,
environmentContexts: payload.environmentContexts.length,
environmentTravelLocations: payload.environmentTravelLocations.length,
};
}
+333 -1
View File
@@ -215,6 +215,13 @@ const COUNT_BACK: Record<
DocumentConditionLink: (p, userId) =>
p.documentConditionLink.count({ where: { userId } }),
ExtractedFact: (p, userId) => p.extractedFact.count({ where: { userId } }),
PersonalRecord: (p, userId) => p.personalRecord.count({ where: { userId } }),
UserAchievement: (p, userId) =>
p.userAchievement.count({ where: { userId } }),
EnvironmentContext: (p, userId) =>
p.environmentContext.count({ where: { userId } }),
EnvironmentTravelLocation: (p, userId) =>
p.environmentTravelLocation.count({ where: { userId } }),
};
/**
@@ -227,7 +234,10 @@ const COUNT_BACK: Record<
* make the link tables look restored when nothing restored them.
*/
async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
await prisma.measurement.create({
// Held rather than discarded: the personal best below points at this row
// through a column that is a real foreign key in the database, so the
// fixture needs the id to build the reference the restore has to resolve.
const weightMeasurement = await prisma.measurement.create({
data: {
userId: OWNER_ID,
type: "WEIGHT",
@@ -945,6 +955,140 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
documentId: document.id,
},
});
// Three bests, chosen for what a count cannot see.
//
// The first points at the measurement it was found in. That column is a
// foreign key in the database while the Prisma schema declares no relation
// for it, so this row is what proves the pointer resolves against the
// measurements the restore wrote, and would prove the opposite loudly, by
// failing the constraint and taking the whole restore with it.
//
// The other two share a metric type AND an instant and differ only in the
// sport slot: the same 5 km and 10 km bests a runner has. A restore that
// dropped the slot would return two rows describing one record twice, and
// the faster time would be presented as the 10 km best.
await prisma.personalRecord.create({
data: {
userId: OWNER_ID,
metricType: "WEIGHT",
direction: "MIN",
value: 74.2,
unit: "kg",
achievedAt: AT("2026-07-01T07:00:00.000Z"),
sourceMeasurementId: weightMeasurement.id,
source: "MANUAL",
},
});
await prisma.personalRecord.createMany({
data: [
{
userId: OWNER_ID,
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_5km_time",
direction: "MIN",
value: 1512,
unit: "s",
achievedAt: AT("2026-05-18T09:20:00.000Z"),
source: "APPLE_HEALTH",
externalId: "workout-pr-5km",
},
{
userId: OWNER_ID,
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_10km_time",
direction: "MIN",
value: 3184,
unit: "s",
achievedAt: AT("2026-05-18T09:20:00.000Z"),
source: "APPLE_HEALTH",
externalId: "workout-pr-10km",
},
],
});
// Two badges, both earned long before this fixture runs. The second names a
// definition no catalogue in this build ships: a file can be older than the
// release reading it, and the restore must carry the row rather than judge
// it, exactly as it carries a retired antigen slug.
await prisma.userAchievement.createMany({
data: [
{
userId: OWNER_ID,
achievementId: "intake-total-10",
unlockedAt: AT("2026-02-21T10:15:00.000Z"),
},
{
userId: OWNER_ID,
achievementId: "retired-badge-from-an-older-release",
unlockedAt: AT("2025-11-05T08:00:00.000Z"),
},
],
});
// A fortnight in Barcelona, and one day of it recorded as a reading.
//
// The pair is the fixture. The home day and the trip day carry different
// coordinates and different weather, and the trip day's reading only makes
// sense next to the period that explains it: with the period gone, the next
// refresh re-resolves 2026-06-15 to Berlin and upserts Berlin's weather over
// it. The assertion after the restore reads the two back TOGETHER.
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER_ID,
startDate: "2026-06-10",
endDate: "2026-06-20",
lat: 41.3874,
lon: 2.1686,
label: "Barcelona",
},
});
await prisma.environmentContext.createMany({
data: [
{
userId: OWNER_ID,
date: "2026-06-15",
lat: 41.3874,
lon: 2.1686,
locationLabel: "Barcelona",
source: "TRAVEL",
tempMin: 19.4,
tempMax: 28.1,
tempMean: 23.6,
apparentMean: 25.2,
sunshineSec: 39_600,
daylightSec: 52_800,
precipSum: 0,
pressureMean: 1016.4,
pressureDelta: 3.2,
humidityMean: 63,
cloudMean: 12,
weatherCode: 1,
fetchedAt: AT("2026-06-16T03:15:00.000Z"),
},
{
userId: OWNER_ID,
date: "2026-07-01",
lat: 52.52,
lon: 13.405,
locationLabel: "Berlin",
source: "HOME",
tempMin: 13.1,
tempMax: 22.7,
tempMean: 17.9,
apparentMean: 17.1,
sunshineSec: 28_800,
daylightSec: 59_400,
precipSum: 4.6,
pressureMean: 1008.9,
pressureDelta: 7.8,
humidityMean: 74,
cloudMean: 68,
weatherCode: 61,
fetchedAt: AT("2026-07-02T03:15:00.000Z"),
},
],
});
}
async function createOwner(prisma: PrismaClient) {
@@ -1632,6 +1776,194 @@ describe("every model the plan claims two-ended survives a real restore", () =>
restoredEncounter.reminderId,
"the encounter's reminder reference must survive now that the reminder travels",
).toBe(restoredReminder.id);
// The bests, read back column by column.
//
// Three rows came back, which the count above already said. What it could
// not say is that they still describe three different records: the two
// running bests share a metric type and an instant, so the slot is the
// only thing separating "best 5 km" from "best 10 km", and `direction` is
// the only thing that stops a best time being read as a worst one. The
// measurement pointer is asserted against the row the RESTORE wrote rather
// than against the id the fixture used, because resolving to something
// that exists is the property the reference owes.
const restoredMeasurement = await prisma.measurement.findFirstOrThrow({
where: { userId: OWNER_ID, type: "WEIGHT" },
});
const bests = await prisma.personalRecord.findMany({
where: { userId: OWNER_ID },
orderBy: [{ achievedAt: "asc" }, { metricSlot: "asc" }],
});
expect(
bests.map((best) => ({
metricType: best.metricType,
metricSlot: best.metricSlot,
direction: best.direction,
value: best.value,
unit: best.unit,
achievedAt: best.achievedAt.toISOString(),
sourceMeasurementId: best.sourceMeasurementId,
source: best.source,
externalId: best.externalId,
})),
"each best must come back as its own record, with the direction and the sport slot that make it one",
).toEqual([
{
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_10km_time",
direction: "MIN",
value: 3184,
unit: "s",
achievedAt: "2026-05-18T09:20:00.000Z",
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: "workout-pr-10km",
},
{
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "running_5km_time",
direction: "MIN",
value: 1512,
unit: "s",
achievedAt: "2026-05-18T09:20:00.000Z",
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: "workout-pr-5km",
},
{
metricType: "WEIGHT",
metricSlot: null,
direction: "MIN",
value: 74.2,
unit: "kg",
achievedAt: "2026-07-01T07:00:00.000Z",
// Not `null`: the pointer resolved against a measurement this restore
// actually wrote. A restore that nulled it would report the drop, and
// one that wrote it before the measurements existed would fail the
// foreign key rather than reach this line.
sourceMeasurementId: restoredMeasurement.id,
source: "MANUAL",
externalId: null,
},
]);
// The badges, and the only field on them that matters.
//
// Both rows would come back from a restore that stamped `unlockedAt` with
// the moment of the restore, and every count in this file would still be
// green, while the account was handed a wall of badges it had apparently
// earned all at once, today. Several of these dates cannot be re-derived
// at all: the evaluator prefers a persisted date, and the counters behind
// the login and Easter-egg badges are not in any backup.
const badges = await prisma.userAchievement.findMany({
where: { userId: OWNER_ID },
orderBy: { unlockedAt: "asc" },
});
expect(
badges.map((badge) => ({
achievementId: badge.achievementId,
unlockedAt: badge.unlockedAt.toISOString(),
})),
"a badge comes back with the day it was earned, and with the id it was earned under",
).toEqual([
{
// A definition this build does not ship, kept verbatim rather than
// judged against the catalogue, the same answer the restore gives a
// retired antigen slug.
achievementId: "retired-badge-from-an-older-release",
unlockedAt: "2025-11-05T08:00:00.000Z",
},
{
achievementId: "intake-total-10",
unlockedAt: "2026-02-21T10:15:00.000Z",
},
]);
// The environmental history, read back as a PAIR.
//
// Counting says two readings and one location period returned. It cannot
// say that the trip day still knows it was a trip day, which is the whole
// content of these rows: the coordinates, the label and `source` are what
// separate a fortnight in Barcelona from a fortnight at home, and the
// period is what keeps the next refresh from re-resolving the day and
// upserting Berlin's weather over it.
const readings = await prisma.environmentContext.findMany({
where: { userId: OWNER_ID },
orderBy: { date: "asc" },
});
expect(
readings.map((reading) => ({
date: reading.date,
lat: reading.lat,
lon: reading.lon,
locationLabel: reading.locationLabel,
source: reading.source,
tempMean: reading.tempMean,
pressureDelta: reading.pressureDelta,
daylightSec: reading.daylightSec,
weatherCode: reading.weatherCode,
// Verbatim, not re-stamped: this says when the feed was read, and a
// restore that wrote "now" would claim a two-year-old provisional
// reading had just been confirmed.
fetchedAt: reading.fetchedAt.toISOString(),
})),
"each day comes back at the place it was actually read for",
).toEqual([
{
date: "2026-06-15",
lat: 41.3874,
lon: 2.1686,
locationLabel: "Barcelona",
source: "TRAVEL",
tempMean: 23.6,
pressureDelta: 3.2,
daylightSec: 52_800,
weatherCode: 1,
fetchedAt: "2026-06-16T03:15:00.000Z",
},
{
date: "2026-07-01",
lat: 52.52,
lon: 13.405,
locationLabel: "Berlin",
source: "HOME",
tempMean: 17.9,
pressureDelta: 7.8,
daylightSec: 59_400,
weatherCode: 61,
fetchedAt: "2026-07-02T03:15:00.000Z",
},
]);
// And the join between them, computed from what the restore wrote rather
// than from the fixture: the trip day has to fall inside the restored
// period and carry its coordinates. Both halves can be individually
// non-empty and still disagree: a period whose bounds shifted by a day no
// longer covers the reading it explains, and the reading goes back to
// being weather from nowhere.
const trip = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const travelDay = readings.find((reading) => reading.source === "TRAVEL")!;
expect(
{
startDate: trip.startDate,
endDate: trip.endDate,
label: trip.label,
covers:
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
sameLat: trip.lat === travelDay.lat,
sameLon: trip.lon === travelDay.lon,
},
"the period must still explain the reading it was exported beside",
).toEqual({
startDate: "2026-06-10",
endDate: "2026-06-20",
label: "Barcelona",
covers: true,
sameLat: true,
sameLon: true,
});
});
/**