mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
20 files changed
+1350
-21
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([]) },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user