Files
HealthLog/src/lib/export/backup-domains.ts
T
Marc-André Bombeck 322652a86e refactor(backup): build the export from per-domain slices
buildFullBackupPayload now asks every domain for its slice at once and
lays the slices out in the file's key order (backup-export-sections.ts),
merging the manifest where it first appears. The measurement, medication,
mood, instance-settings and nutrient readers and serialisers move out of
the payload builder into their domain modules; the records section keeps
its composition and manifest, while labs, illness, allergies, family
history, workouts and documents each export from their own module. The
measurement restore half moves to measurements-restore.ts so the export
graph does not pull in the restore's dependencies.

The file, its counts and their order are unchanged; the golden copies
still match. The measurement completeness guard now reads the domain
module and also checks that every carried column is written back.
2026-10-22 07:46:02 +02:00

447 lines
15 KiB
TypeScript

/**
* The backup's domains, and the order a restore runs them in.
*
* Each domain keeps its schema slice, its export and its restore together in
* its own module (`<domain>-backup.ts`, with `-restore.ts` and
* `-backup-schema.ts` beside it where those are large). This file only says
* in which order the restore calls them (the export's order, which is the
* file's key order, is `backup-export-sections.ts`). The order is the whole
* content:
*
* - The account is emptied first, child before parent wherever a count
* is reported, because a parent's delete cascades into its children and
* the child's count would then read zero.
* - A step that points into another domain runs after the step that writes
* the target, and reads what that step wrote from `ctx.links`. Each
* ordering constraint is stated beside the step it binds.
*
* Everything runs inside the one restore transaction (`restore-backup.ts`),
* so a failure in any step leaves the account exactly as it was.
*/
import { restoreCycleData } from "@/lib/cycle/backup";
import { restoreAccountSettings } from "@/lib/export/account-settings-restore";
import {
clearAllergies,
restoreAllergies,
} from "@/lib/export/allergies-backup";
import { restoreAppSettings } from "@/lib/export/app-settings-backup";
import { restoreAwardsData } from "@/lib/export/awards-backup";
import {
restoreCoachData,
restoreCoachMemoryData,
} from "@/lib/export/coach-backup";
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
import {
clearDocuments,
restoreDocuments,
} from "@/lib/export/documents-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import {
clearFamilyHistory,
restoreFamilyHistory,
} from "@/lib/export/family-history-backup";
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { clearIllness, restoreIllness } from "@/lib/export/illness-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import {
clearBiomarkers,
clearLabResults,
restoreLabMarkers,
restoreLabResults,
} from "@/lib/export/labs-backup";
import { restoreLifeEventsData } from "@/lib/export/life-events-backup";
import {
clearMeasurementRollups,
clearMeasurements,
restoreMeasurements,
} from "@/lib/export/measurements-restore";
import {
clearIntakeEvents,
clearMedications,
restoreEfficacyTargets,
restoreMedications,
} from "@/lib/export/medications-restore";
import {
clearMoodEntries,
clearMoodRollups,
restoreMood,
} from "@/lib/export/mood-restore";
import { clearNotificationChannels } from "@/lib/export/notification-channels-backup";
import {
clearNutrientDays,
restoreNutrientDays,
} from "@/lib/export/nutrients-backup";
import { restoreOnboardingData } from "@/lib/export/onboarding-backup";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreRelationLinksData } from "@/lib/export/relation-links-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import type { RestoreContext } from "@/lib/export/restore-context";
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
import { restoreSymptomsData } from "@/lib/export/symptoms-backup";
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { clearWorkouts, restoreWorkouts } from "@/lib/export/workouts-backup";
/** One step of the restore transaction. */
export interface RestoreStep {
/** A name for the reader of this list; never shown or stored. */
readonly name: string;
run(ctx: RestoreContext): Promise<void>;
}
/**
* Empty every serialized owner-scoped partition before it is rebuilt in the
* same transaction. Child rows either go first for counts or cascade from
* their serialized parent. The sections not named here clear their own
* tables as their restore step begins.
*/
export const RESTORE_CLEAR_STEPS: readonly RestoreStep[] = [
{ name: "intake events", run: clearIntakeEvents },
{ name: "medications", run: clearMedications },
{ name: "measurements", run: clearMeasurements },
{ name: "mood entries", run: clearMoodEntries },
{ name: "mood rollups", run: clearMoodRollups },
{ name: "measurement rollups", run: clearMeasurementRollups },
{ name: "notification channels", run: clearNotificationChannels },
{ name: "documents", run: clearDocuments },
{ name: "workouts", run: clearWorkouts },
{ name: "family history", run: clearFamilyHistory },
{ name: "allergies", run: clearAllergies },
{ name: "illness", run: clearIllness },
{ name: "lab results", run: clearLabResults },
{ name: "biomarkers", run: clearBiomarkers },
{ name: "nutrient days", run: clearNutrientDays },
];
/** The ids of the documents the file carries, every one of which is written. */
function documentIdsOf(ctx: RestoreContext): Set<string> {
return new Set(ctx.payload.documents.map((document) => document.id));
}
/** The rebuild, in dependency order. */
export const RESTORE_STEPS: readonly RestoreStep[] = [
// The instance settings, only when the operator asked for them back.
{ name: "instance settings", run: restoreAppSettings },
{ name: "measurements", run: restoreMeasurements },
{ name: "medications", run: restoreMedications },
{ name: "mood", run: restoreMood },
{
// Profile, observed spans, day logs and symptom links; delete-then-
// recreate, `notesEncrypted` restored as ciphertext verbatim.
name: "cycles",
run: async (ctx) => {
ctx.reportSection("cycles");
Object.assign(
ctx.cleared,
await restoreCycleData(ctx.tx, ctx.ownerId, ctx.payload, ctx.skips),
);
},
},
{
// Durable self-context and user-defined metrics.
name: "profile",
run: async (ctx) => {
ctx.reportSection("profile");
Object.assign(
ctx.cleared,
await restoreProfileData(ctx.tx, ctx.ownerId, ctx.payload),
);
},
},
{
// The hourly shape of a cumulative day.
name: "intraday profiles",
run: async (ctx) => {
Object.assign(
ctx.cleared,
await restoreIntradayProfileData(ctx.tx, ctx.ownerId, ctx.payload),
);
},
},
{
// The score as it was shown on each local day.
name: "health score",
run: async (ctx) => {
Object.assign(
ctx.cleared,
await restoreHealthScoreData(ctx.tx, ctx.ownerId, ctx.payload),
);
},
},
{
// The needs-based setup answers.
name: "onboarding",
run: async (ctx) => {
Object.assign(
ctx.cleared,
await restoreOnboardingData(ctx.tx, ctx.ownerId, ctx.payload),
);
},
},
{
// The account's own settings, onto the account row, column by column.
// Only a setting can be written; identity, credentials and this host's
// bookkeeping stay as the receiving account has them. A value this host
// will not take is left out and named in the skip report.
name: "account settings",
run: async (ctx) => {
const result = await restoreAccountSettings(
ctx.tx,
ctx.ownerId,
ctx.payload,
);
for (const column of result.refused) {
ctx.skips.push({ catalogue: "accountSetting", key: column, links: 1 });
}
ctx.outputs.accountSettings = result;
},
},
{ name: "lab markers", run: restoreLabMarkers },
// After the markers: a drug's target names one by a real foreign key.
{ name: "efficacy targets", run: restoreEfficacyTargets },
{ name: "lab results", run: restoreLabResults },
{ name: "illness", run: restoreIllness },
{
// The person's own symptoms and their occurrences. After the episodes,
// because an occurrence may point at one.
name: "symptoms",
run: async (ctx) => {
Object.assign(
ctx.cleared,
await restoreSymptomsData(
ctx.tx,
ctx.ownerId,
ctx.payload,
ctx.links.episodeIds,
ctx.skips,
),
);
},
},
{
// The person's life events. They reference nothing but the account.
name: "life events",
run: async (ctx) => {
Object.assign(
ctx.cleared,
await restoreLifeEventsData(ctx.tx, ctx.ownerId, ctx.payload),
);
},
},
{ name: "allergies", run: restoreAllergies },
{ name: "family history", run: restoreFamilyHistory },
{ name: "workouts", run: restoreWorkouts },
{ name: "nutrient days", run: restoreNutrientDays },
{ name: "documents", run: restoreDocuments },
{
// What the vault was filed against, and what was read out of it. After
// the documents and the condition episodes because both are foreign keys
// here — a filing written before either exists does not drop quietly, it
// violates a constraint and costs the operator the whole restore. After
// the lab results and the medications for a second reason: an approved
// fact's `committedRecordId` is resolved against the rows those steps
// wrote, and resolving before they exist would null every commitment and
// still report success.
name: "document filing",
run: async (ctx) => {
ctx.reportSection("documents");
Object.assign(
ctx.cleared,
await restoreDocumentFilingData(
ctx.tx,
ctx.ownerId,
ctx.payload,
{
documentIds: documentIdsOf(ctx),
episodeIds: ctx.links.episodeIds,
committedRecordIds: new Set([
...ctx.links.restoredLabResultIds,
...ctx.links.episodeIds,
...ctx.links.restoredMedicationIds,
]),
},
ctx.skips,
),
);
},
},
{
// The Vorsorge reminders and their completion ledger, BEFORE the visits
// and the vaccinations: both of those remap a `reminderId` against the
// reminders in the database, so the reminders have to be there first or
// every appointment and booster reference would read as unresolvable.
name: "reminders",
run: async (ctx) => {
ctx.reportSection("reminders");
Object.assign(
ctx.cleared,
await restoreRemindersData(ctx.tx, ctx.ownerId, ctx.payload, ctx.skips),
);
},
},
{
// Visits, the address book and the three link tables. After the
// documents and the reminders: a link is written only when both of its
// ends exist.
name: "visits",
run: async (ctx) => {
ctx.reportSection("visits");
Object.assign(
ctx.cleared,
await restoreVisitsData(ctx.tx, ctx.ownerId, ctx.payload, ctx.skips),
);
},
},
{
// The immunization log, after the visits: a dose remaps its
// practitioner, its encounter and its booster reminder against rows the
// steps above have just written.
name: "vaccinations",
run: async (ctx) => {
ctx.reportSection("vaccinations");
Object.assign(
ctx.cleared,
await restoreVaccinationsData(
ctx.tx,
ctx.ownerId,
ctx.payload,
ctx.skips,
),
);
},
},
{
// The five relation links, last of the record sections they join: every
// end is a foreign key against a lab result, document, visit, dose,
// medication or condition, all written above.
name: "relation links",
run: async (ctx) => {
Object.assign(
ctx.cleared,
await restoreRelationLinksData(
ctx.tx,
ctx.ownerId,
ctx.payload,
ctx.skips,
{
medications: ctx.links.medicationIdBySourceId,
labResults: ctx.links.labResultIdBySourceId,
},
),
);
},
},
{
// The Coach's threads, turns and document links. AFTER the documents: an
// attachment carries a `documentId` that is a foreign key against them.
name: "coach",
run: async (ctx) => {
ctx.reportSection("coach");
Object.assign(
ctx.cleared,
await restoreCoachData(
ctx.tx,
ctx.ownerId,
ctx.payload,
documentIdsOf(ctx),
ctx.skips,
),
);
},
},
{
// What the Coach keeps between threads. AFTER the transcript, because
// all three carry a `sourceConversationId` resolved against the threads
// just written.
name: "coach memory",
run: async (ctx) => {
Object.assign(
ctx.cleared,
await restoreCoachMemoryData(
ctx.tx,
ctx.ownerId,
ctx.payload,
new Set(ctx.payload.coachConversations.map((c) => c.id)),
new Set(
ctx.payload.coachConversations.flatMap((c) =>
c.messages.map((m) => m.id),
),
),
ctx.skips,
),
);
},
},
{
// The screener history and the consent record. Neither references
// anything but the account.
name: "sensitive",
run: async (ctx) => {
ctx.reportSection("sensitive");
Object.assign(
ctx.cleared,
await restoreSensitiveData(ctx.tx, ctx.ownerId, ctx.payload),
);
},
},
{
// The bests and the badges. AFTER the measurements:
// `PersonalRecord.sourceMeasurementId` is a real foreign key against
// `measurements` (migration 0054), so a pointer resolved before the
// measurements exist would make Postgres refuse the insert and roll the
// whole restore back over one provenance column.
name: "awards",
run: async (ctx) => {
ctx.reportSection("awards");
Object.assign(
ctx.cleared,
await restoreAwardsData(
ctx.tx,
ctx.ownerId,
ctx.payload,
ctx.links.writtenReferencedMeasurementIds,
ctx.skips,
),
);
},
},
{
// The ECG strips. AFTER the measurements: a recording's `measurementId`
// is a real foreign key against the EVENT row it was filed with.
name: "ecg",
run: async (ctx) => {
ctx.reportSection("ecg");
Object.assign(
ctx.cleared,
await restoreEcgData(
ctx.tx,
ctx.ownerId,
ctx.payload,
ctx.links.writtenReferencedMeasurementIds,
ctx.skips,
),
);
},
},
{
// The per-day readings and the location periods that explain them, one
// function for both: readings restored without their periods get
// re-resolved to the home location and overwritten by the next
// environment refresh.
name: "environment",
run: async (ctx) => {
ctx.reportSection("environment");
Object.assign(
ctx.cleared,
await restoreEnvironmentData(
ctx.tx,
ctx.ownerId,
ctx.payload,
ctx.skips,
),
);
},
},
];