Carry the bests, the badges and the environmental history through a restore

Four models leave the coverage-pending register with both ends and a
round-trip proof: PersonalRecord, UserAchievement, EnvironmentContext and
EnvironmentTravelLocation. Two new section modules hold reader and restore
beside each other, the arrangement the visits, vaccinations, reminders and
Coach sections already use.

The register understated two of them. The bests are "recomputable in
principle", which is true and does not help: nothing recomputes them, and a
recomputation over a restored history would find different rows, because
sample series and GPS routes never travel, a portable file omits deleted
readings, and a record set in a reading that has since been corrected cannot
be found twice. The badge's unlock date is worse. The evaluator prefers a
persisted date over a derived one, and several of the dates it can still
derive come from counters no backup carries, so a dropped row does not relock
the badge, it re-earns it today.

PersonalRecord.sourceMeasurementId is the reference that needed care, and it
is the dangerous kind. The schema declares no relation for it; migration 0054
created the column as a real foreign key against measurements. Writing it
blind is not a dangling pointer, it is a constraint violation that rolls the
whole restore back and hands the operator an empty account. Measured, with the
resolution removed: "Foreign key constraint violated on the constraint:
personal_records_source_measurement_id_fkey", HTTP 500, nothing restored. So
the restore runs after the measurements, resolves the pointer against the rows
it actually wrote, nulls what it cannot resolve and reports it under a new
personalRecordReference skip kind.

An unlocked badge names a definition in the code catalogue, which drifts
between the release that writes a file and the one that reads it. The id is
carried and written back verbatim rather than judged, the same answer the
vaccination restore gives an antigen slug it cannot resolve.

The readings and the location periods travel as a pair because carrying the
readings alone is worse than losing them. The environment resolver reads the
periods live, so with them gone the nightly seven-day lookback re-resolves
every trip day to the home location and upserts over the row: an account would
come back with a fortnight abroad and then have it quietly rewritten as a
fortnight at home, days after a restore that reported success. Day keys stay
strings end to end, and fetchedAt is restored verbatim so a two-year-old
provisional reading does not claim it was confirmed today.

The round trip seeds bests that share a metric type and an instant and differ
only in the sport slot, two badges earned years apart (one of them under an id
this build no longer defines), and a trip day beside a home day, then reads
each back column by column. Counting alone passes every one of the failures
worth fearing here: slots dropped so a 5 km best is presented as a 10 km best,
unlock dates stamped at restore time, a trip day re-attributed to the home
city, a location period whose bounds no longer cover the reading it explains.
Each of those was applied on purpose and each failed on its own assertion.

Two schema comments claimed no read path selects a column that this backup now
selects; both say what reads it instead of staying wrong.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 11:14:15 +02:00
1 parent 974a90f28f
commit f95fb95295
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,
});
});
/**