mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|