From e7b31fe8e0e48dec9c141e7555284ac69da198d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 06:59:54 +0200 Subject: [PATCH] refactor(backup): run the restore as ordered steps from per-domain modules restoreBackup becomes an orchestrator: the checks before the first delete move to restore-preflight.ts, and the transaction runs the steps listed in backup-domains.ts in dependency order, emptying the account first and then rebuilding it domain by domain. The inline restores for measurements, medications, mood, labs, illness, allergies, family history, workouts, nutrient days, documents, the instance settings and the cleared notification channels move into one module per domain, beside the existing section modules. Cross-domain references travel through one typed context instead of local variables. The order of every write, the refusals and their audit rows, the skip report, the cleared counts and the reported phases are unchanged; the golden copies of the restored account still match. Guards that pinned the restore by path now point at the domain files. --- .../backup-plan-classification.test.ts | 4 +- .../backup-readers-stream-guard.test.ts | 2 +- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 6 +- .../baselines/version-stamp-comments.json | 5 +- ...asurement-write-plausibility-guard.test.ts | 2 +- .../measurement-write-tail-guard.test.ts | 2 +- ...ion-ingredient-backup-completeness.test.ts | 4 +- .../medication-intake-tracking-guard.test.ts | 2 +- .../workout-sport-provenance-guard.test.ts | 2 +- src/lib/export/allergies-backup.ts | 60 + src/lib/export/app-settings-backup.ts | 86 + src/lib/export/backup-domains.ts | 444 +++ src/lib/export/backup-plan.ts | 40 +- src/lib/export/documents-backup.ts | 123 + src/lib/export/family-history-backup.ts | 38 + src/lib/export/illness-backup.ts | 162 + src/lib/export/labs-backup.ts | 209 ++ src/lib/export/measurements-backup.ts | 429 +++ src/lib/export/medications-restore.ts | 673 ++++ src/lib/export/mood-restore.ts | 400 ++ .../export/notification-channels-backup.ts | 26 + src/lib/export/nutrients-backup.ts | 35 + src/lib/export/restore-backup.ts | 3344 ++--------------- src/lib/export/restore-context.ts | 204 + src/lib/export/restore-preflight.ts | 433 +++ src/lib/export/workouts-backup.ts | 46 + 27 files changed, 3778 insertions(+), 3008 deletions(-) create mode 100644 src/lib/export/allergies-backup.ts create mode 100644 src/lib/export/app-settings-backup.ts create mode 100644 src/lib/export/backup-domains.ts create mode 100644 src/lib/export/documents-backup.ts create mode 100644 src/lib/export/family-history-backup.ts create mode 100644 src/lib/export/illness-backup.ts create mode 100644 src/lib/export/labs-backup.ts create mode 100644 src/lib/export/measurements-backup.ts create mode 100644 src/lib/export/medications-restore.ts create mode 100644 src/lib/export/mood-restore.ts create mode 100644 src/lib/export/notification-channels-backup.ts create mode 100644 src/lib/export/nutrients-backup.ts create mode 100644 src/lib/export/restore-context.ts create mode 100644 src/lib/export/restore-preflight.ts create mode 100644 src/lib/export/workouts-backup.ts diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 6af03054a..ffe4fda3a 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -322,7 +322,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => { it("does not count a nested relation read as a restore write", () => { expect( touches( - ["src/lib/export/restore-backup.ts"], + ["src/lib/export/measurements-backup.ts"], "User", WRITE_OPS, WRITE_RELATION_OPS, @@ -569,7 +569,7 @@ describe("tables outside the schema have a backup verdict too", () => { "utf8", ); const restore = readFileSync( - join(SRC, "lib/export/restore-backup.ts"), + join(SRC, "lib/export/medications-restore.ts"), "utf8", ); expect(writer).toMatch(/getMedicationCategories\(/); diff --git a/src/__tests__/backup-readers-stream-guard.test.ts b/src/__tests__/backup-readers-stream-guard.test.ts index 5e81644df..8292f49d6 100644 --- a/src/__tests__/backup-readers-stream-guard.test.ts +++ b/src/__tests__/backup-readers-stream-guard.test.ts @@ -12,7 +12,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const READERS = [ - "src/lib/export/restore-backup.ts", + "src/lib/export/restore-preflight.ts", "src/app/api/admin/backups/[id]/summary/route.ts", "src/app/api/admin/backups/[id]/download/route.ts", "src/app/api/admin/backups/upload/route.ts", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 32d3dd21d..98c5891a9 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 129, - "total": 156231, + "count": 128, + "total": 153023, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -73,7 +73,6 @@ "lib/export/full-backup-payload.ts": 1520, "lib/export/profile-backup.ts": 1019, "lib/export/records-backup.ts": 834, - "lib/export/restore-backup.ts": 3208, "lib/fitbit/client.ts": 1529, "lib/fitbit/sync-core.ts": 968, "lib/gamification/achievements-result.ts": 1094, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 02291aeae..321c976e3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 415, - "total": 154643, + "count": 413, + "total": 149647, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -324,8 +324,6 @@ "lib/export/profile-backup.ts :: buildProfileBackupSection": 270, "lib/export/profile-backup.ts :: restoreProfileData": 291, "lib/export/records-backup.ts :: buildRecordsBackupSection": 308, - "lib/export/restore-backup.ts :: restoreBackup": 2833, - "lib/export/restore-backup.ts :: restoreBackup > prisma.$transaction(arg)": 2163, "lib/export/vaccinations-backup.ts :: restoreVaccinationsData": 216, "lib/export/visits-backup.ts :: restoreVisitsData": 261, "lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 2ab4f6d3f..6271a5df6 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2962, - "total": 10588, + "count": 2961, + "total": 10571, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2050,7 +2050,6 @@ "lib/export/profile-backup.ts": 10, "lib/export/records-backup.ts": 4, "lib/export/reminders-backup.ts": 4, - "lib/export/restore-backup.ts": 17, "lib/export/visits-backup.ts": 5, "lib/feature-flags/__tests__/index.test.ts": 2, "lib/fhir/build-bundle.ts": 10, diff --git a/src/__tests__/measurement-write-plausibility-guard.test.ts b/src/__tests__/measurement-write-plausibility-guard.test.ts index b0e38f993..1600b753c 100644 --- a/src/__tests__/measurement-write-plausibility-guard.test.ts +++ b/src/__tests__/measurement-write-plausibility-guard.test.ts @@ -123,7 +123,7 @@ const WRITERS: Record = { }, // ── Restore ─────────────────────────────────────────────────────────── - "lib/export/restore-backup.ts": { + "lib/export/measurements-backup.ts": { kind: "restore", why: "replays the record's own export; dropping rows would break the restore contract", }, diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index abca40d7d..30934f85d 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -81,7 +81,7 @@ const WRITE_SITES: Record = { "the import worker refolds the import's span and re-warms every imported type", "lib/import/health-connect/import.ts": "the Health Connect import worker refolds the import's span and re-warms every imported type", - "lib/export/restore-backup.ts": + "lib/export/measurements-backup.ts": "a restore replaces the whole record: it refolds the whole window and drops every per-user cache", // Value-preserving reshapes: the same readings folded into daily or hourly // rows. The rollups are refolded; the figures an assessment reads do not diff --git a/src/__tests__/medication-ingredient-backup-completeness.test.ts b/src/__tests__/medication-ingredient-backup-completeness.test.ts index 8361e202d..65b92c9c8 100644 --- a/src/__tests__/medication-ingredient-backup-completeness.test.ts +++ b/src/__tests__/medication-ingredient-backup-completeness.test.ts @@ -1,7 +1,7 @@ /** * v1.43 — pair guard binding the `MedicationIngredient` model to both ends of * the backup: the writer's `ingredients: m.ingredients.map(...)` block in - * `full-backup-payload.ts` and the nested create in `restore-backup.ts`. + * `full-backup-payload.ts` and the nested create in `medications-restore.ts`. * * The failure it catches is the one the measurement guard beside it was * written for: a column added to the composition in a later release, the @@ -87,7 +87,7 @@ describe("medication ingredient backup completeness", () => { it("the restore writes every column back", () => { const keys = blockKeys( - read("src", "lib", "export", "restore-backup.ts"), + read("src", "lib", "export", "medications-restore.ts"), "create: m.ingredients.map((i) => ({", ); expect(carried.filter((c) => !keys.has(c))).toEqual([]); diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index 5b91d591b..2ad178de8 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -95,7 +95,7 @@ const MAY_READ_EVERY_ROW: Record = { // Record-keeping: a backup, a restore and an export carry the medication // as stored, schedule and switch included. "lib/export/full-backup-payload.ts": "backup carries the stored rows", - "lib/export/restore-backup.ts": "restore writes the stored rows back", + "lib/export/medications-restore.ts": "restore writes the stored rows back", // v1.44 — the CSV import writes new medications and reads only their names // and doses to skip duplicates; it derives nothing due. "lib/import/medications-csv.ts": "imports new rows, derives nothing due", diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts index 9eda369d3..e44e79747 100644 --- a/src/__tests__/workout-sport-provenance-guard.test.ts +++ b/src/__tests__/workout-sport-provenance-guard.test.ts @@ -195,7 +195,7 @@ describe("T2 — the set of files writing Workout rows is frozen", () => { "app/api/workouts/batch/route.ts": "client-supplied enum", // Backup restore replays rows this app previously wrote, provenance and // all. - "lib/export/restore-backup.ts": "restore replay", + "lib/export/workouts-backup.ts": "restore replay", }; it("only the registered ingests and the declared non-mappers create Workout rows", () => { diff --git a/src/lib/export/allergies-backup.ts b/src/lib/export/allergies-backup.ts new file mode 100644 index 000000000..7030d969a --- /dev/null +++ b/src/lib/export/allergies-backup.ts @@ -0,0 +1,60 @@ +/** + * The allergy record, with both backup ends in one file. + */ +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +export async function clearAllergies(ctx: RestoreContext): Promise { + const allergies = await ctx.tx.allergy.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.allergies = allergies.count; +} + +/** Each free-text column: ciphertext verbatim, or plaintext sealed here. */ +export async function restoreAllergies(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + ctx.reportSection("allergies"); + for (const allergy of payload.allergies) { + await tx.allergy.create({ + data: { + id: allergy.id, + userId: ownerId, + substance: allergy.substance, + category: allergy.category as never, + type: allergy.type as never, + severity: (allergy.severity ?? null) as never, + status: allergy.status as never, + onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, + reactionEncrypted: + allergy.reactionEncrypted !== undefined + ? allergy.reactionEncrypted === null + ? null + : decodeEncryptedBytes(allergy.reactionEncrypted) + : allergy.reaction == null + ? null + : encryptToBytes(allergy.reaction), + notesEncrypted: + allergy.notesEncrypted !== undefined + ? allergy.notesEncrypted === null + ? null + : decodeEncryptedBytes(allergy.notesEncrypted) + : allergy.note == null + ? null + : encryptToBytes(allergy.note), + deletedAt: allergy.deletedAt ? new Date(allergy.deletedAt) : null, + ...(allergy.createdAt + ? { createdAt: new Date(allergy.createdAt) } + : {}), + ...(allergy.updatedAt + ? { updatedAt: new Date(allergy.updatedAt) } + : {}), + }, + }); + } +} diff --git a/src/lib/export/app-settings-backup.ts b/src/lib/export/app-settings-backup.ts new file mode 100644 index 000000000..0eeb318f9 --- /dev/null +++ b/src/lib/export/app-settings-backup.ts @@ -0,0 +1,86 @@ +/** + * The instance settings, the one section of a backup file that is not the + * account's. Both backup ends in one file. + * + * A disaster-recovery payload carries the singleton `app_settings` row — + * registration, the MFA requirement, the default locale and timezone, module + * availability, the notification and AI configuration, the document size cap + * and quota. Writing it back as a side effect of restoring ONE account + * reconfigures the host for everybody on it, silently and hours or weeks out + * of date: an operator putting a single record back on Wednesday has no + * reason to expect Monday's registration switch and Monday's upload cap to + * come with it. Rebuilding a host from a snapshot is a real case, so the + * write stays available — as an answer the operator gives + * (`restoreInstanceSettings`), not one the account restore assumes. + */ +import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels"; +import type { RestoreContext } from "@/lib/export/restore-context"; +import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +export async function restoreAppSettings(ctx: RestoreContext): Promise { + const { tx, payload } = ctx; + if (!(payload.appSettings && ctx.restoreInstanceSettings)) return; + const settings = payload.appSettings; + // A file from before 0343 carries the Coach's operator answer in module + // availability; carry it into the Coach switch. + const coachFold = foldLegacyCoachAvailability( + settings.moduleAvailabilityJson, + settings.assistantCoachEnabled, + ); + const settingsData = { + registrationEnabled: settings.registrationEnabled, + mfaRequired: settings.mfaRequired, + defaultLocale: settings.defaultLocale, + telegramGlobal: settings.telegramGlobal, + ntfyGlobal: settings.ntfyGlobal, + webPushGlobal: settings.webPushGlobal, + webPushVapidPublicKey: settings.webPushVapidPublicKey, + webPushVapidPrivateKeyEncrypted: settings.webPushVapidPrivateKeyEncrypted, + webPushVapidSubject: settings.webPushVapidSubject, + apiGlobal: settings.apiGlobal, + umamiEnabled: settings.umamiEnabled, + umamiScriptUrl: settings.umamiScriptUrl, + umamiWebsiteId: settings.umamiWebsiteId, + glitchtipEnabled: settings.glitchtipEnabled, + glitchtipDsn: settings.glitchtipDsn, + glitchtipEnvironment: settings.glitchtipEnvironment, + reminderLateMinutes: settings.reminderLateMinutes, + reminderMissedMinutes: settings.reminderMissedMinutes, + adminAiKeyEncrypted: settings.adminAiKeyEncrypted, + adminAiModel: settings.adminAiModel, + adminAiBaseUrl: settings.adminAiBaseUrl, + adminCodexAccessTokenEncrypted: settings.adminCodexAccessTokenEncrypted, + adminCodexRefreshTokenEncrypted: settings.adminCodexRefreshTokenEncrypted, + adminCodexAccountIdEncrypted: settings.adminCodexAccountIdEncrypted, + adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt + ? new Date(settings.adminCodexTokenExpiresAt) + : null, + adminCodexConnectedAt: settings.adminCodexConnectedAt + ? new Date(settings.adminCodexConnectedAt) + : null, + adminCodexConnectionStatus: settings.adminCodexConnectionStatus, + adminAiInsightsFeedbackSummary: + settings.adminAiInsightsFeedbackSummary as never, + defaultUserTimezone: settings.defaultUserTimezone, + assistantEnabled: settings.assistantEnabled, + assistantCoachEnabled: coachFold.assistantCoachEnabled, + assistantBriefingEnabled: settings.assistantBriefingEnabled, + assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled, + assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled ?? true, + // Absent on a file written before the controls existed, which restores + // them at their defaults: allowed, no cap. + aiReasoningEnabled: settings.aiReasoningEnabled ?? true, + aiReasoningMaxEffort: + settings.aiReasoningMaxEffort ?? DEFAULT_REASONING_MAX_EFFORT, + moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never, + documentMaxFileBytes: settings.documentMaxFileBytes, + documentQuotaBytes: BigInt(settings.documentQuotaBytes), + }; + await tx.appSettings.upsert({ + where: { id: settings.id }, + create: { id: settings.id, ...settingsData }, + update: settingsData, + }); +} diff --git a/src/lib/export/backup-domains.ts b/src/lib/export/backup-domains.ts new file mode 100644 index 000000000..7f4767abb --- /dev/null +++ b/src/lib/export/backup-domains.ts @@ -0,0 +1,444 @@ +/** + * 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 (`-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 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-backup"; +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; +} + +/** + * 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 { + 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, + ), + ); + }, + }, +]; diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index b981fea4c..ac0408e93 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -306,27 +306,27 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/medication-category.ts", ]; +const WRITER_FILES_WITHOUT_RESTORE = new Set([ + "src/lib/export/full-backup-payload.ts", + "src/lib/export/paged-measurements.ts", + "src/lib/export/records-backup.ts", + "src/lib/illness/dto.ts", +]); + export const BACKUP_RESTORE_FILES: readonly string[] = [ - "src/lib/export/restore-backup.ts", - "src/lib/export/profile-backup.ts", - "src/lib/export/intraday-profile-backup.ts", - "src/lib/export/health-score-backup.ts", - "src/lib/export/visits-backup.ts", - "src/lib/export/vaccinations-backup.ts", - "src/lib/export/reminders-backup.ts", - "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/export/onboarding-backup.ts", - "src/lib/export/ecg-backup.ts", - "src/lib/export/symptoms-backup.ts", - "src/lib/export/life-events-backup.ts", - "src/lib/export/relation-links-backup.ts", - "src/lib/cycle/backup.ts", - // The restore writes the medication category through the same helper. - "src/lib/medication-category.ts", + "src/lib/export/measurements-backup.ts", + "src/lib/export/medications-restore.ts", + "src/lib/export/mood-restore.ts", + "src/lib/export/labs-backup.ts", + "src/lib/export/illness-backup.ts", + "src/lib/export/allergies-backup.ts", + "src/lib/export/family-history-backup.ts", + "src/lib/export/workouts-backup.ts", + "src/lib/export/nutrients-backup.ts", + "src/lib/export/documents-backup.ts", + ...BACKUP_WRITER_FILES.filter( + (file) => !WRITER_FILES_WITHOUT_RESTORE.has(file), + ), ]; /** diff --git a/src/lib/export/documents-backup.ts b/src/lib/export/documents-backup.ts new file mode 100644 index 000000000..0ae41386c --- /dev/null +++ b/src/lib/export/documents-backup.ts @@ -0,0 +1,123 @@ +/** + * The document vault, with both backup ends in one file. + * + * A disaster-recovery file carries each document's stored bytes verbatim; a + * portable one carries metadata only and cannot be restored into documents. + */ +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; +import { normaliseSourceInstance } from "@/lib/validations/inbound-documents"; +import type { BackupPayload } from "@/lib/validations/backup"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +/** + * The first document the file carries as metadata only, if any. + * + * Portable exports intentionally omit document ciphertext. They remain valid + * upload/download artifacts, but cannot be used to manufacture an + * InboundDocument row. A DR restore must fail before any delete rather than + * inventing empty/plaintext content. + */ +export function findIncompleteDocument( + payload: Pick, +): BackupPayload["documents"][number] | undefined { + return payload.documents.find( + (document) => + document.contentEncrypted === undefined || + document.contentSha256 === undefined || + document.contentCodec === undefined || + document.providerType === undefined || + document.reportDate === undefined || + document.documentDate === undefined || + document.errorReason === undefined || + document.summaryEncrypted === undefined || + document.summaryGeneratedAt === undefined || + document.summaryState === undefined || + document.createdAt === undefined || + document.updatedAt === undefined, + ); +} + +export async function clearDocuments(ctx: RestoreContext): Promise { + const documents = await ctx.tx.inboundDocument.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.documents = documents.count; +} + +/** + * One document at a time, its stored bytes read from the file again + * (`forEachDocumentContent`): the first read left them out, because a vault + * held whole does not fit a 524 MB heap (#1239). The rows are in file order + * on both reads. + */ +export async function restoreDocuments(ctx: RestoreContext): Promise { + const { tx, ownerId, payload, streamed } = ctx; + if (payload.documents.length === 0) return; + let written = 0; + await streamed.forEachDocumentContent(async (content, index) => { + const document = payload.documents[index]; + if (!document || content === undefined) { + throw new Error( + `Document ${index} of the backup has no stored bytes on the second read`, + ); + } + await tx.inboundDocument.create({ + data: { + id: document.id, + userId: ownerId, + kind: document.kind as never, + title: document.title ?? null, + filename: document.filename ?? null, + mimeType: document.mimeType, + byteSize: document.byteSize, + contentEncrypted: decodeEncryptedBytes(content), + contentSha256: document.contentSha256 ?? null, + contentCodec: document.contentCodec!, + status: document.status as never, + providerType: document.providerType ?? null, + reportDate: document.reportDate ? new Date(document.reportDate) : null, + documentDate: document.documentDate + ? new Date(document.documentDate) + : null, + errorReason: document.errorReason ?? null, + summaryEncrypted: document.summaryEncrypted + ? decodeEncryptedBytes(document.summaryEncrypted) + : null, + summaryGeneratedAt: document.summaryGeneratedAt + ? new Date(document.summaryGeneratedAt) + : null, + summaryState: document.summaryState as never, + lastIndexAttemptAt: document.lastIndexAttemptAt + ? new Date(document.lastIndexAttemptAt) + : null, + lastIndexOutcome: document.lastIndexOutcome ?? null, + // An older file has no marker: those documents predate the hold and + // restore as ordinary ones. + aiReadDeferred: document.aiReadDeferred ?? false, + sourceSystem: document.sourceSystem ?? null, + sourceId: document.sourceSystem ? (document.sourceId ?? null) : null, + // A file written before v1.39.3 has no instance: the key then + // matches any instance of its system, as it did when stored. + sourceInstance: + document.sourceSystem && document.sourceId + ? document.sourceInstance + ? normaliseSourceInstance(document.sourceInstance) + : null + : null, + createdAt: new Date(document.createdAt!), + updatedAt: new Date(document.updatedAt!), + }, + select: { id: true }, + }); + written += 1; + }); + if (written !== payload.documents.length) { + throw new Error( + `The backup's second read found ${written} of ${payload.documents.length} documents`, + ); + } +} diff --git a/src/lib/export/family-history-backup.ts b/src/lib/export/family-history-backup.ts new file mode 100644 index 000000000..f678b7603 --- /dev/null +++ b/src/lib/export/family-history-backup.ts @@ -0,0 +1,38 @@ +/** + * The family history, with both backup ends in one file. + */ +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import type { RestoreContext } from "@/lib/export/restore-context"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +export async function clearFamilyHistory(ctx: RestoreContext): Promise { + const familyHistory = await ctx.tx.familyHistoryEntry.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.familyHistory = familyHistory.count; +} + +export async function restoreFamilyHistory(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + ctx.reportSection("familyHistory"); + for (const familyEntry of payload.familyHistory) { + await tx.familyHistoryEntry.create({ + data: { + id: familyEntry.id, + userId: ownerId, + relationship: familyEntry.relationship as never, + condition: familyEntry.condition, + ageAtOnset: familyEntry.ageAtOnset ?? null, + notesEncrypted: + familyEntry.note == null ? null : encryptToBytes(familyEntry.note), + ...(familyEntry.createdAt + ? { createdAt: new Date(familyEntry.createdAt) } + : {}), + ...(familyEntry.updatedAt + ? { updatedAt: new Date(familyEntry.updatedAt) } + : {}), + }, + }); + } +} diff --git a/src/lib/export/illness-backup.ts b/src/lib/export/illness-backup.ts new file mode 100644 index 000000000..31c649900 --- /dev/null +++ b/src/lib/export/illness-backup.ts @@ -0,0 +1,162 @@ +/** + * The illness history: episodes, their day logs, and the symptoms each day + * recorded. Both backup ends in one file. + */ +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; +import { recordUnknownKeys } from "@/lib/export/restore-skips"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +export async function clearIllness(ctx: RestoreContext): Promise { + const illnessEpisodes = await ctx.tx.illnessEpisode.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.illnessEpisodes = illnessEpisodes.count; +} + +/** The episodes, then the parent links once every episode exists. */ +async function restoreEpisodes(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + const episodeIds = ctx.links.episodeIds; + for (const episode of payload.illnessEpisodes) { + if ( + episode.parentConditionId && + !episodeIds.has(episode.parentConditionId) + ) { + throw new Error(`Unknown illness parent: ${episode.parentConditionId}`); + } + await tx.illnessEpisode.create({ + data: { + id: episode.id, + userId: ownerId, + label: episode.label, + type: episode.type as never, + lifecycle: episode.lifecycle as never, + onsetAt: new Date(episode.onsetAt), + resolvedAt: episode.resolvedAt ? new Date(episode.resolvedAt) : null, + parentConditionId: null, + noteEncrypted: + episode.noteEncrypted !== undefined + ? episode.noteEncrypted === null + ? null + : decodeEncryptedBytes(episode.noteEncrypted) + : episode.note == null + ? null + : encryptToBytes(episode.note), + // The same two-armed read as the note: ciphertext verbatim from a + // disaster-recovery file, plaintext re-encrypted from a portable one, + // nothing from a file older than the field. + bodySiteEncrypted: + episode.bodySiteEncrypted !== undefined && + episode.bodySiteEncrypted !== null + ? decodeEncryptedBytes(episode.bodySiteEncrypted) + : episode.bodySite?.trim() + ? encryptToBytes(episode.bodySite.trim()) + : null, + laterality: episode.laterality ?? null, + deletedAt: episode.deletedAt ? new Date(episode.deletedAt) : null, + ...(episode.createdAt + ? { createdAt: new Date(episode.createdAt) } + : {}), + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, + }); + } + for (const episode of payload.illnessEpisodes) { + if (episode.parentConditionId) { + await tx.illnessEpisode.update({ + where: { id: episode.id, userId: ownerId }, + data: { parentConditionId: episode.parentConditionId }, + }); + } + } +} + +/** + * The day logs with their symptom links. + * + * `IllnessSymptom` has no `userId` column at all — it is a purely seeded + * catalogue, so a key that will not resolve here can ONLY be catalogue drift + * and never a definition the file failed to carry. Which makes refusing the + * file over it the least defensible of the three: the file was never able to + * supply the missing row. + */ +async function restoreDayLogs(ctx: RestoreContext): Promise { + const { tx, ownerId, payload, skips } = ctx; + const referencedSymptomKeys = payload.illnessEpisodes.flatMap((episode) => + episode.dayLogs.flatMap((dayLog) => + dayLog.symptoms.map((symptom) => symptom.key), + ), + ); + const symptomKeys = [...new Set(referencedSymptomKeys)]; + const symptomRows = + symptomKeys.length === 0 + ? [] + : await tx.illnessSymptom.findMany({ + where: { key: { in: symptomKeys } }, + select: { id: true, key: true }, + }); + const symptomByKey = new Map( + symptomRows.map((symptom) => [symptom.key, symptom.id]), + ); + recordUnknownKeys( + skips, + "illnessSymptom", + symptomKeys.filter((key) => !symptomByKey.has(key)), + referencedSymptomKeys, + ); + for (const episode of payload.illnessEpisodes) { + for (const dayLog of episode.dayLogs) { + const symptomLinks = dayLog.symptoms.flatMap((symptom) => { + const symptomId = symptomByKey.get(symptom.key); + return symptomId + ? [{ symptomId, severity: symptom.severity ?? null }] + : []; + }); + await tx.illnessDayLog.create({ + data: { + ...(dayLog.id ? { id: dayLog.id } : {}), + userId: ownerId, + episodeId: episode.id, + date: dayLog.date, + functionalImpact: dayLog.functionalImpact ?? null, + feverC: dayLog.feverC ?? null, + noteEncrypted: + dayLog.noteEncrypted !== undefined + ? dayLog.noteEncrypted === null + ? null + : decodeEncryptedBytes(dayLog.noteEncrypted) + : dayLog.note == null + ? null + : encryptToBytes(dayLog.note), + tz: dayLog.tz ?? null, + deletedAt: dayLog.deletedAt ? new Date(dayLog.deletedAt) : null, + ...(dayLog.createdAt + ? { createdAt: new Date(dayLog.createdAt) } + : {}), + ...(dayLog.updatedAt + ? { updatedAt: new Date(dayLog.updatedAt) } + : {}), + ...(symptomLinks.length > 0 + ? { symptomLinks: { create: symptomLinks } } + : {}), + }, + }); + } + } +} + +export async function restoreIllness(ctx: RestoreContext): Promise { + ctx.reportSection("illness"); + for (const episode of ctx.payload.illnessEpisodes) { + ctx.links.episodeIds.add(episode.id); + } + await restoreEpisodes(ctx); + await restoreDayLogs(ctx); +} diff --git a/src/lib/export/labs-backup.ts b/src/lib/export/labs-backup.ts new file mode 100644 index 000000000..10b4adfa9 --- /dev/null +++ b/src/lib/export/labs-backup.ts @@ -0,0 +1,209 @@ +/** + * The lab record: markers with their other spellings, the merge ledger, and + * the results filed under them. Both backup ends in one file. + * + * A result travels with its marker's NAME as well as its id: a portable + * restore mints fresh marker ids, and `(userId, name)` is what the catalogue + * is unique on, so the name is what survives the trip. + */ +import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; +import { encryptNoteToBytes } from "@/lib/labs/store"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { annotate } from "@/lib/logging/context"; +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +export async function clearLabResults(ctx: RestoreContext): Promise { + const labResults = await ctx.tx.labResult.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.labResults = labResults.count; +} + +export async function clearBiomarkers(ctx: RestoreContext): Promise { + const biomarkers = await ctx.tx.biomarker.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.biomarkers = biomarkers.count; +} + +/** + * The merge ledger, against the markers just written: by id where the file + * carries one this restore kept, by name otherwise. A row whose target the + * file does not carry has nothing to point at and is left out (counted on the + * event). + */ +async function restoreBiomarkerMerges(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + const { biomarkerByName, restoredBiomarkerIds } = ctx.links; + let unresolvedMerges = 0; + const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => { + const targetId = + merge.targetBiomarkerId && + restoredBiomarkerIds.has(merge.targetBiomarkerId) + ? merge.targetBiomarkerId + : biomarkerByName.get(merge.targetName); + if (!targetId) { + unresolvedMerges += 1; + return []; + } + return [ + { + ...(merge.id ? { id: merge.id } : {}), + userId: ownerId, + targetBiomarkerId: targetId, + sourceBiomarkerId: merge.sourceBiomarkerId, + sourceName: merge.sourceName, + readingCount: merge.readingCount, + mergedAt: new Date(merge.mergedAt), + }, + ]; + }); + if (mergeRows.length > 0) { + await tx.biomarkerMerge.createMany({ data: mergeRows }); + } + if (unresolvedMerges > 0) { + annotate({ + action: { name: "backup.restore.biomarkerMerge.unresolved" }, + meta: { count: unresolvedMerges }, + }); + } +} + +/** The markers, each with its other spellings, then the merge ledger. */ +export async function restoreLabMarkers(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + const { biomarkerByName, restoredBiomarkerIds } = ctx.links; + ctx.reportSection("labs"); + for (const biomarker of payload.biomarkers) { + const created = await tx.biomarker.create({ + data: { + ...(biomarker.id ? { id: biomarker.id } : {}), + userId: ownerId, + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound ?? null, + upperBound: biomarker.upperBound ?? null, + panel: biomarker.panel ?? null, + hidden: biomarker.hidden ?? false, + contextEncrypted: + biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), + analyteKey: biomarker.analyteKey ?? null, + ...(biomarker.createdAt + ? { createdAt: new Date(biomarker.createdAt) } + : {}), + ...(biomarker.updatedAt + ? { updatedAt: new Date(biomarker.updatedAt) } + : {}), + }, + }); + biomarkerByName.set(biomarker.name, created.id); + restoredBiomarkerIds.add(created.id); + // The other spellings, folded the way the resolver reads them, so the + // next scan matches them again. + const aliasRows = (biomarker.aliases ?? []) + .map((alias) => ({ + id: alias.id, + name: alias.name.trim().slice(0, 120), + nameFolded: foldForMatch(alias.name).slice(0, 120), + source: alias.source, + createdAt: alias.createdAt, + })) + .filter( + (alias) => + alias.nameFolded !== "" && + alias.nameFolded !== foldForMatch(biomarker.name), + ); + if (aliasRows.length > 0) { + await tx.biomarkerAlias.createMany({ + data: aliasRows.map((alias) => ({ + ...(alias.id ? { id: alias.id } : {}), + userId: ownerId, + biomarkerId: created.id, + name: alias.name, + nameFolded: alias.nameFolded, + source: alias.source, + ...(alias.createdAt ? { createdAt: new Date(alias.createdAt) } : {}), + })), + // One spelling answers to one marker per account; a file that names + // it twice keeps the first. + skipDuplicates: true, + }); + } + } + await restoreBiomarkerMerges(ctx); +} + +/** + * The results, against the markers written above. The ids written are + * collected for the staged facts later on: an approved fact's + * `committedRecordId` names the lab result it was committed to. + */ +export async function restoreLabResults(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + const { + biomarkerByName, + restoredBiomarkerIds, + restoredLabResultIds, + labResultIdBySourceId, + } = ctx.links; + for (const lab of payload.labResults) { + const biomarkerId = + lab.biomarkerId !== undefined + ? lab.biomarkerId === null + ? null + : restoredBiomarkerIds.has(lab.biomarkerId) + ? lab.biomarkerId + : undefined + : lab.biomarkerName + ? biomarkerByName.get(lab.biomarkerName) + : null; + if (biomarkerId === undefined) { + throw new Error( + `Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`, + ); + } + const createdLab = await tx.labResult.create({ + data: { + ...(lab.id ? { id: lab.id } : {}), + userId: ownerId, + biomarkerId: biomarkerId ?? null, + panel: lab.panel ?? null, + analyte: lab.analyte, + value: lab.value ?? null, + valueText: lab.valueText ?? null, + unit: lab.unit, + referenceLow: lab.referenceLow ?? null, + referenceHigh: lab.referenceHigh ?? null, + sourceReferenceLow: lab.sourceReferenceLow ?? null, + sourceReferenceHigh: lab.sourceReferenceHigh ?? null, + sourceReferenceText: lab.sourceReferenceText ?? null, + sourceValue: lab.sourceValue ?? null, + sourceUnit: lab.sourceUnit ?? null, + takenAt: new Date(lab.takenAt), + source: lab.source, + noteEncrypted: + lab.noteEncrypted !== undefined + ? lab.noteEncrypted === null + ? null + : decodeEncryptedBytes(lab.noteEncrypted) + : lab.note == null + ? null + : encryptNoteToBytes(lab.note), + deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null, + ...(lab.createdAt ? { createdAt: new Date(lab.createdAt) } : {}), + ...(lab.updatedAt ? { updatedAt: new Date(lab.updatedAt) } : {}), + }, + }); + restoredLabResultIds.add(createdLab.id); + // Same arrangement as the medications' source-id map. + const labSourceId = lab.id ?? lab.ref; + if (labSourceId) labResultIdBySourceId.set(labSourceId, createdLab.id); + } +} diff --git a/src/lib/export/measurements-backup.ts b/src/lib/export/measurements-backup.ts new file mode 100644 index 000000000..5a8ebdb4d --- /dev/null +++ b/src/lib/export/measurements-backup.ts @@ -0,0 +1,429 @@ +/** + * The measurements, with both backup ends in one file. + * + * The restore reads the readings twice. The first read (`readStreamedBackup`, + * before anything is deleted) checks each one and notes what the second read + * has to know about the file as a whole: which `stats:` aggregates are live, + * and which nights the retired heart-rate variability filing shares with an + * RMSSD reading (`createMeasurementRestorePrepass`). The second read, inside + * the transaction, writes them a batch at a time (`restoreMeasurements`), so + * the restore never holds more than one batch. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { + isLegacyRmssdRow, + LEGACY_RMSSD_SOURCES, +} from "@/lib/export/legacy-rmssd"; +import { + insertMeasurementRows, + type MeasurementInsertRow, +} from "@/lib/export/measurement-bulk-insert"; +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; +import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; +import { + FOLDED_TYPES, + compactionTombstoneCoverId, +} from "@/lib/measurements/folded-window"; +import type { BackupMeasurement } from "@/lib/validations/backup"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +/** Both unique identities of a row, as it would be filed under `HRV_RMSSD`. */ +function rmssdTwinKeysOf(row: BackupMeasurement): string[] { + const keys = [ + `at|${row.source}|${new Date(row.measuredAt).toISOString()}|${row.sleepStage ?? ""}`, + ]; + if (row.externalId) keys.push(`ext|${row.source}|${row.externalId}`); + return keys; +} + +const foldStatsKey = (source: string, type: string, externalId: string) => + `${source}|${type}|${externalId}`; + +/** What the file's first read noted about its readings as a whole. */ +export interface MeasurementRestorePrepass { + /** Called for every reading as the first read checks it. */ + note(row: BackupMeasurement): void; + /** The live `stats:` ids of the folded types, keyed by source and type. */ + readonly liveFoldStatsIds: ReadonlySet; + /** A row the migration's rule leaves out under either type. */ + isShadowedLegacyRmssd(row: BackupMeasurement): boolean; +} + +/** + * The notes the measurement pass takes from the first read of the file. + * + * The live `stats:` ids of the folded types, so the measurement pass can tell + * a compaction tombstone from a person's deletion without a third read of the + * file. Keyed by source as well: Health Connect folds into the same `stats:` + * ids as Apple Health, and an Apple Health hour must not make a Health + * Connect deletion look folded. + * + * Migration 0420 refiled Fitbit and Google Health HRV as `HRV_RMSSD`: both + * providers report a nightly RMSSD, which their mappers had written as the + * SDNN series. A file written before that release still carries those rows + * as `HEART_RATE_VARIABILITY`, and restoring it verbatim would undo the + * migration for the account. The restore applies the migration's rule: + * + * - a legacy row moves to RMSSD, unless a LIVE RMSSD row of its source + * holds the same night under either unique identity (external id, or + * measured-at and sleep stage). Then the twin holds the night and the + * legacy row is not written under either type: as RMSSD it would break + * the unique key, as SDNN it would put RMSSD back into the SDNN series; + * - a legacy row whose only twin is a tombstone wins: the tombstone is the + * row left out, since it shares the key and holds nothing; + * - a legacy tombstone with any RMSSD twin is left out. + * + * The keys are gathered on the first read. + */ +export function createMeasurementRestorePrepass(): MeasurementRestorePrepass { + const liveFoldStatsIds = new Set(); + const foldedTypes = new Set(FOLDED_TYPES); + const foldSources = new Set(["APPLE_HEALTH", "HEALTH_CONNECT"]); + const noteLiveFoldRow = (row: BackupMeasurement) => { + if ( + row.deletedAt == null && + row.source !== undefined && + foldSources.has(row.source) && + foldedTypes.has(row.type) && + row.externalId?.startsWith("stats:") + ) { + liveFoldStatsIds.add(foldStatsKey(row.source, row.type, row.externalId)); + } + }; + const rmssdLiveKeys = new Set(); + const rmssdAnyKeys = new Set(); + const legacyLiveKeys = new Set(); + const noteRmssdTwin = (row: BackupMeasurement) => { + if (isLegacyRmssdRow(row)) { + if (row.deletedAt == null) { + for (const key of rmssdTwinKeysOf(row)) legacyLiveKeys.add(key); + } + return; + } + if ( + row.type === "HRV_RMSSD" && + row.source !== undefined && + LEGACY_RMSSD_SOURCES.has(row.source) + ) { + for (const key of rmssdTwinKeysOf(row)) { + rmssdAnyKeys.add(key); + if (row.deletedAt == null) rmssdLiveKeys.add(key); + } + } + }; + const anyKeyIn = (row: BackupMeasurement, keys: ReadonlySet) => + rmssdTwinKeysOf(row).some((key) => keys.has(key)); + const isShadowedLegacyRmssd = (row: BackupMeasurement): boolean => { + if (isLegacyRmssdRow(row)) { + return anyKeyIn( + row, + row.deletedAt == null ? rmssdLiveKeys : rmssdAnyKeys, + ); + } + // An RMSSD tombstone on the night a live legacy row is about to take. + return ( + row.type === "HRV_RMSSD" && + row.deletedAt != null && + row.source !== undefined && + LEGACY_RMSSD_SOURCES.has(row.source) && + anyKeyIn(row, legacyLiveKeys) && + !anyKeyIn(row, rmssdLiveKeys) + ); + }; + return { + note(row) { + noteLiveFoldRow(row); + noteRmssdTwin(row); + }, + liveFoldStatsIds, + isShadowedLegacyRmssd, + }; +} + +export async function clearMeasurements(ctx: RestoreContext): Promise { + const measurements = await ctx.tx.measurement.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.measurements = measurements.count; +} + +/** + * The persisted measurement rollup partition, wiped for the same reason as + * the mood one: kept, it would hold the previous owner's daily means after + * the underlying measurements were replaced, and the chart's `source=rollup` + * fast-path could surface a stale 30-day mean built from rows that no longer + * exist. The rebuild after the commit mints fresh rows from the restored set. + */ +export async function clearMeasurementRollups( + ctx: RestoreContext, +): Promise { + await ctx.tx.measurementRollup.deleteMany({ where: { userId: ctx.ownerId } }); +} + +function toRestoredMeasurementData(measurement: BackupMeasurement) { + return { + type: isLegacyRmssdRow(measurement) + ? ("HRV_RMSSD" as const) + : measurement.type, + value: measurement.value, + valueMin: measurement.valueMin ?? null, + valueMax: measurement.valueMax ?? null, + unit: measurement.unit, + source: measurement.source ?? "MANUAL", + measuredAt: new Date(measurement.measuredAt), + notes: null, + notesEncrypted: + measurement.notesEncrypted == null + ? encryptNote(measurement.notes ?? null) + : decodeEncryptedBytes(measurement.notesEncrypted), + externalId: measurement.externalId ?? null, + externalSourceVersion: measurement.externalSourceVersion ?? null, + // Authority of an Apple Health `stats:` aggregate. A backup + // written before this field rode the payload carries nothing, and + // restoring NULL would flatten the authority ladder: an export.xml + // source-day estimate could then overwrite a native HealthKit + // statistic, which is the ambiguity migration 0263 already + // repaired once. Such a row is exactly the case LEGACY_UNKNOWN + // exists for, so it comes back repairable instead of ambiguous. + // + // The gate is scoped exactly as 0263 scoped its backfill and as + // the cumulative drain scopes its own LEGACY_UNKNOWN writes: + // Apple Health rows only. Other providers mint `stats:` ids for + // their cumulative dailies too (Fitbit, Google Health) and have + // never carried provenance — marking those would invent a state + // they were never in. Ordinary point rows keep NULL honestly. + // + // Only an ABSENT field is the old-backup case. A current backup + // writes the field for every row, and an explicit null is the row's + // real state: the nightly daily-mean fold writes Apple Health + // `stats:` rows with no provenance. Treating that null as absent + // turned every such row into LEGACY_UNKNOWN on restore (#1031). + aggregationProvenance: (measurement.aggregationProvenance !== undefined + ? measurement.aggregationProvenance + : measurement.source === "APPLE_HEALTH" && + measurement.externalId?.startsWith("stats:") + ? "LEGACY_UNKNOWN" + : null) as never, + glucoseContext: (measurement.glucoseContext ?? null) as never, + sleepStage: (measurement.sleepStage ?? null) as never, + rhythmClassification: (measurement.rhythmClassification ?? null) as never, + deviceType: measurement.deviceType ?? null, + syncVersion: measurement.syncVersion ?? 1, + deletedAt: measurement.deletedAt ? new Date(measurement.deletedAt) : null, + ...(measurement.createdAt + ? { createdAt: new Date(measurement.createdAt) } + : {}), + ...(measurement.updatedAt + ? { updatedAt: new Date(measurement.updatedAt) } + : {}), + }; +} + +/** + * Which deleted readings the file carries are not written back. + * + * Deleted readings the file carries are restored as deleted rows + * (tombstones), with one exception: a tombstone deleted longer ago than + * `TOMBSTONE_RETENTION_DAYS` is not written at all. + * + * Such a row is exactly what the nightly tombstone purge + * (`measurement-tombstone-cleanup.ts`) hard-deletes, so writing it costs the + * restore a row in thirteen indexes for a row that is gone by the next + * morning. Nothing reads it in between: the delta feed answers + * `cursorExpired` for every cursor older than the same horizon, so no client + * that would still be served this tombstone exists; the step-consolidation + * repair and the hourly rebuild only look inside the horizon; and the undo of + * a delete is offered for what the purge has not removed yet, which this row + * would not survive either. + * + * Younger tombstones are written. They still have readers: a paired client + * that has not yet drained that deletion from the delta feed, the undo of a + * recent delete, the consolidation repairs that rebuild from folded samples, + * and the unique `(user, type, source, externalId)` key that stops a device + * from uploading a folded sample again. The backup file itself is unchanged + * and keeps every row. + * + * Nor is a compaction tombstone written, whatever its age: a raw sample the + * folds soft-deleted before v1.42, whose whole hour or day the fold had + * reached when it was deleted and which a live `stats:` row of the same + * source in the same file covers. The folds delete those outright now and the + * backlog purge removes the old ones, so writing one back would only hand the + * purge the same row again; the `folded_window` ingest guard is what keeps + * the sample from being uploaded again. Classified by the rule the purge and + * the sync feed use (`folded-window.ts`), against the `stats:` ids gathered + * on the file's first read and the zone the account will have after the + * restore. Counted with the expired ones. + */ +async function expiredTombstoneFilter( + ctx: RestoreContext, +): Promise<(measurement: BackupMeasurement) => boolean> { + const { tx, ownerId, payload } = ctx; + const tombstoneHorizonMs = Date.now() - TOMBSTONE_RETENTION_DAYS * 86_400_000; + const foldTz = resolveUserTimezone( + payload.accountSettings?.timezone ?? + ( + await tx.user.findUnique({ + where: { id: ownerId }, + select: { timezone: true }, + }) + )?.timezone ?? + null, + ); + // Only once the fold repair has been through the means this file holds. + // The repair recomputes a mean an older release folded from part of its + // day, and these tombstones are what it reads. A file exported after the + // account's repair finished carries repaired means, so its compaction + // tombstones can go. Any other file is written back whole, the account's + // repair marker is removed, and the repair runs again once the transaction + // commits; the purge then removes the tombstones as usual. + const foldRepair = await tx.measurementFoldRepair.findUnique({ + where: { userId: ownerId }, + select: { completedAt: true }, + }); + const dropCompactionTombstones = + foldRepair !== null && + new Date(payload.exportedAt).getTime() > foldRepair.completedAt.getTime(); + if (!dropCompactionTombstones) { + await tx.measurementFoldRepair.deleteMany({ + where: { userId: ownerId }, + }); + ctx.outputs.foldRepairReset = true; + } + const { liveFoldStatsIds } = ctx.measurementPrepass; + const isCompactionTombstone = (measurement: BackupMeasurement) => { + if (!dropCompactionTombstones) return false; + if (measurement.deletedAt == null) return false; + const source = measurement.source ?? "MANUAL"; + const covering = compactionTombstoneCoverId( + { + type: measurement.type, + source, + externalId: measurement.externalId ?? null, + measuredAt: new Date(measurement.measuredAt), + deletedAt: new Date(measurement.deletedAt), + }, + foldTz, + ); + return ( + covering !== null && + liveFoldStatsIds.has(foldStatsKey(source, measurement.type, covering)) + ); + }; + return (measurement: BackupMeasurement) => + measurement.deletedAt != null && + (new Date(measurement.deletedAt).getTime() < tombstoneHorizonMs || + isCompactionTombstone(measurement)); +} + +/** + * Two later sections point INTO the measurements (a record's + * `sourceMeasurementId`, an ECG strip's `measurementId`) and have to know + * which of those ids this transaction actually wrote. They used to be handed + * every written id; they only ever ask about the ones they reference, so only + * those are collected. + */ +function referencedMeasurementIdsOf( + payload: RestoreContext["payload"], +): Set { + const referenced = new Set(); + for (const record of payload.personalRecords) { + if (record.sourceMeasurementId) { + referenced.add(record.sourceMeasurementId); + } + } + for (const recording of payload.ecgRecordings) { + if (recording.measurementId) { + referenced.add(recording.measurementId); + } + } + return referenced; +} + +/** + * v1 payloads did not require stable ids. Their historical natural-key + * reconciliation is preserved without routing canonical v2 rows through it. + */ +async function reconcileWithoutId( + tx: Prisma.TransactionClient, + ownerId: string, + measurement: BackupMeasurement, +): Promise { + const restoredData = toRestoredMeasurementData(measurement); + const existing = await tx.measurement.findFirst({ + where: { + userId: ownerId, + type: measurement.type, + source: restoredData.source, + measuredAt: restoredData.measuredAt, + sleepStage: restoredData.sleepStage, + }, + select: { id: true }, + }); + if (existing) { + await tx.measurement.update({ + where: { id: existing.id, userId: ownerId }, + data: restoredData, + }); + } else { + await tx.measurement.create({ + data: { userId: ownerId, ...restoredData }, + }); + } +} + +/** + * The measurements, a batch at a time from a second read of the file, so the + * restore never holds more than one batch of them. + */ +export async function restoreMeasurements(ctx: RestoreContext): Promise { + const { tx, ownerId, payload, streamed } = ctx; + const referencedMeasurementIds = referencedMeasurementIdsOf(payload); + const writtenReferencedIds = ctx.links.writtenReferencedMeasurementIds; + const measurementBatchSize = 1_000; + const isExpiredTombstone = await expiredTombstoneFilter(ctx); + const { isShadowedLegacyRmssd } = ctx.measurementPrepass; + ctx.reportMeasurements(0); + await streamed.forEachMeasurementBatch( + measurementBatchSize, + async (fileBatch) => { + const kept = fileBatch.filter( + (measurement) => !isExpiredTombstone(measurement), + ); + ctx.outputs.expiredTombstonesSkipped += fileBatch.length - kept.length; + const batch = kept.filter( + (measurement) => !isShadowedLegacyRmssd(measurement), + ); + const stableRows = batch.flatMap((measurement): MeasurementInsertRow[] => + measurement.id + ? [ + { + id: measurement.id, + userId: ownerId, + ...toRestoredMeasurementData(measurement), + }, + ] + : [], + ); + // One statement per batch, not `createMany`: see + // `insertMeasurementRows` for what the latter cost here. + await insertMeasurementRows(tx, stableRows); + // Counted through the file, so the bar ends at the file's total. + ctx.reportMeasurements(fileBatch.length); + for (const row of stableRows) { + if (referencedMeasurementIds.has(row.id)) { + writtenReferencedIds.add(row.id); + } + } + for (const measurement of batch) { + if (measurement.id) continue; + await reconcileWithoutId(tx, ownerId, measurement); + } + }, + ); +} diff --git a/src/lib/export/medications-restore.ts b/src/lib/export/medications-restore.ts new file mode 100644 index 000000000..90c7d5cb2 --- /dev/null +++ b/src/lib/export/medications-restore.ts @@ -0,0 +1,673 @@ +/** + * The restore half of the medications domain: the account's own categories, + * each drug with everything that hangs off it, the courses and archived + * schedule eras, the dose history, and (once the markers exist) what each + * drug was supposed to move. The export half lives in + * `medications-backup.ts`. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { toJson } from "@/lib/db"; +import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; +import { recordUnknownKeys } from "@/lib/export/restore-skips"; +import { + encryptCategoryLabel, + mintCustomMedicationCategoryKey, + setMedicationCategory, +} from "@/lib/medication-category"; +import { + courseFromWindow, + dateOfDayKey, + projectCourseWindow, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format"; +import type { BackupPayload } from "@/lib/validations/backup"; + +type BackupMedication = BackupPayload["medications"][number]; +/** The columns of a just-written medication its course derivation reads. */ +interface RestoredMedication { + id: string; + startsOn: Date | null; + endsOn: Date | null; + createdAt: Date; +} + +export async function clearIntakeEvents(ctx: RestoreContext): Promise { + const intake = await ctx.tx.medicationIntakeEvent.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.intakeEvents = intake.count; +} + +/** The drugs; their children cascade with them. */ +export async function clearMedications(ctx: RestoreContext): Promise { + const meds = await ctx.tx.medication.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.medications = meds.count; +} + +/** + * The account's own medication categories, before the medications that name + * them by key. Delete-then-recreate like the medications themselves. A key + * held by another account on this instance (a portable file from elsewhere) + * is re-minted, and the medications are mapped onto the new key. + */ +async function restoreCustomCategories( + ctx: RestoreContext, +): Promise> { + const { tx, ownerId, payload } = ctx; + await tx.medicationCategoryLabel.deleteMany({ + where: { userId: ownerId }, + }); + const categoryKeyRemap = new Map(); + for (const c of payload.customMedicationCategories) { + const taken = await tx.medicationCategoryLabel.findUnique({ + where: { key: c.key }, + select: { id: true }, + }); + const key = taken ? mintCustomMedicationCategoryKey() : c.key; + if (key !== c.key) categoryKeyRemap.set(c.key, key); + await tx.medicationCategoryLabel.create({ + data: { + userId: ownerId, + key, + labelEncrypted: encryptCategoryLabel(c.label), + sortOrder: c.sortOrder, + isActive: c.isActive, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + }, + }); + } + return categoryKeyRemap; +} + +/** The medication row itself, before the children nested beside it. */ +function medicationRowData( + m: BackupMedication, + ownerId: string, + intakeEvents: BackupPayload["intakeEvents"], + restoreStartedAt: Date, +) { + return { + ...(m.id ? { id: m.id } : {}), + userId: ownerId, + name: m.name, + dose: m.dose, + treatmentClass: m.treatmentClass ?? "GENERIC", + dosesPerUnit: m.dosesPerUnit ?? null, + unitsPerDose: m.unitsPerDose ?? "1", + active: m.active ?? true, + notificationsEnabled: m.notificationsEnabled ?? true, + pausedAt: m.pausedAt ? new Date(m.pausedAt) : null, + snoozedUntil: m.snoozedUntil ? new Date(m.snoozedUntil) : null, + startsOn: m.startsOn ? new Date(m.startsOn) : null, + endsOn: m.endsOn ? new Date(m.endsOn) : null, + oneShot: m.oneShot ?? false, + asNeeded: m.asNeeded ?? false, + trackIntake: m.trackIntake ?? true, + // An option that is on always carries its floor, and a restored floor is + // never before the restore: a portable file carries no removed doses, so + // a dose the person deleted in the days before the export would + // otherwise be filled again as taken by the next auto-take pass. + autoMarkTaken: m.autoMarkTaken ?? false, + autoMarkTakenSince: m.autoMarkTaken + ? new Date( + Math.max( + m.autoMarkTakenSince ? Date.parse(m.autoMarkTakenSince) : 0, + restoreStartedAt.getTime(), + ), + ) + : null, + deliveryForm: m.deliveryForm ?? "ORAL", + trackInjectionSites: m.trackInjectionSites ?? false, + allowedInjectionSites: m.allowedInjectionSites ?? [], + liveActivityEnabled: m.liveActivityEnabled ?? false, + criticalAlarmEnabled: m.criticalAlarmEnabled ?? false, + atcCode: m.atcCode ?? null, + rxNormCode: m.rxNormCode ?? null, + lowStockNotifiedAt: m.lowStockNotifiedAt + ? new Date(m.lowStockNotifiedAt) + : null, + lowStockNotifiedThresholdDays: m.lowStockNotifiedThresholdDays ?? null, + reorderLeadDays: m.reorderLeadDays ?? null, + externalSource: m.externalSource ?? null, + externalId: m.externalId ?? null, + // A file from before v1.39.1 carries no creation date, and the restore + // time would hide every dose-history miss before it. The earliest thing + // the file records for the drug stands in. + ...(() => { + const createdAt = restoredMedicationCreatedAt( + m, + intakeEvents, + restoreStartedAt, + ); + return createdAt ? { createdAt } : {}; + })(), + ...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}), + }; +} + +/** + * The reminder phase timings: one row or none, so `create` on the optional + * side rather than a list. A file that says nothing leaves the drug untuned, + * which is what it was. + */ +function phaseConfigData(m: BackupMedication) { + if (!m.phaseConfig) return {}; + return { + phaseConfig: { + create: { + ...(m.phaseConfig.id ? { id: m.phaseConfig.id } : {}), + ...(m.phaseConfig.greenValue !== undefined + ? { greenValue: m.phaseConfig.greenValue } + : {}), + ...(m.phaseConfig.greenMode + ? { greenMode: m.phaseConfig.greenMode } + : {}), + ...(m.phaseConfig.yellowValue !== undefined + ? { yellowValue: m.phaseConfig.yellowValue } + : {}), + ...(m.phaseConfig.yellowMode + ? { yellowMode: m.phaseConfig.yellowMode } + : {}), + ...(m.phaseConfig.orangeValue !== undefined + ? { orangeValue: m.phaseConfig.orangeValue } + : {}), + ...(m.phaseConfig.orangeMode + ? { orangeMode: m.phaseConfig.orangeMode } + : {}), + ...(m.phaseConfig.redValue !== undefined + ? { redValue: m.phaseConfig.redValue } + : {}), + ...(m.phaseConfig.redMode ? { redMode: m.phaseConfig.redMode } : {}), + }, + }, + }; +} + +/** + * Everything nested in the medication's own `create`, so each foreign key + * binds to the id this row actually got. A canonical DR file preserves the + * medication's id and a portable one mints a fresh cuid; neither case needs + * the old `medicationId` from the file, which is why the payload does not + * carry it. + * + * Each note follows the measurement contract: ciphertext verbatim when the + * file has it, legacy plaintext encrypted on the way in, and the plaintext + * column left null either way. + */ +function medicationChildrenData(m: BackupMedication, ownerId: string) { + return { + schedules: { + create: m.schedules.map((s) => ({ + ...(s.id ? { id: s.id } : {}), + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + // #219 — per-schedule units per dose. Prisma coerces the Decimal + // string; NULL / absent stays NULL (inherit). + unitsPerDose: s.unitsPerDose ?? null, + daysOfWeek: s.daysOfWeek ?? null, + timesOfDay: s.timesOfDay ?? [], + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + scheduleType: s.scheduleType ?? "SCHEDULED", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: (s.doseWindows ?? null) as never, + })), + }, + sideEffects: { + create: m.sideEffects.map((s) => ({ + ...(s.id ? { id: s.id } : {}), + userId: ownerId, + occurredAt: new Date(s.occurredAt), + category: s.category, + entry: s.entry, + severity: s.severity, + notes: null, + notesEncrypted: + s.notesEncrypted == null + ? encryptNote(s.notes ?? null) + : decodeEncryptedBytes(s.notesEncrypted), + ...(s.createdAt ? { createdAt: new Date(s.createdAt) } : {}), + })), + }, + // The composition, restored as written; the SUPPLEMENT nutrient rows it + // produced travel in the nutrient section. + ingredients: { + create: m.ingredients.map((i) => ({ + ...(i.id ? { id: i.id } : {}), + userId: ownerId, + nutrient: i.nutrient, + amountPerUnit: i.amountPerUnit, + unit: i.unit, + enteredAmount: i.enteredAmount ?? null, + enteredUnit: i.enteredUnit ?? null, + source: i.source, + ...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}), + })), + }, + // `resumedAt` passes through as null when the era is open. It is not + // defaulted to the restore time: a pause that was still running when the + // backup was taken is still running after the restore, and closing it + // here would silently convert an ongoing decision into a historical one. + pauseEras: { + create: m.pauseEras.map((p) => ({ + ...(p.id ? { id: p.id } : {}), + userId: ownerId, + pausedAt: new Date(p.pausedAt), + resumedAt: p.resumedAt ? new Date(p.resumedAt) : null, + ...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}), + })), + }, + // The titration steps. + doseChanges: { + create: m.doseChanges.map((d) => ({ + ...(d.id ? { id: d.id } : {}), + effectiveFrom: new Date(d.effectiveFrom), + doseValue: d.doseValue, + doseUnit: d.doseUnit, + note: null, + noteEncrypted: + d.noteEncrypted == null + ? encryptNote(d.note ?? null) + : decodeEncryptedBytes(d.noteEncrypted), + ...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}), + })), + }, + // The packs, and the ledger behind their counts. `unitsRemaining` is + // written VERBATIM rather than recomputed from the events — it is what + // the server had resolved and what the person was shown, and + // recomputing here would silently correct a count they may have + // adjusted by hand. + inventoryItems: { + create: m.inventoryItems.map((i) => ({ + ...(i.id ? { id: i.id } : {}), + userId: ownerId, + state: (i.state ?? "ACTIVE") as never, + containerType: (i.containerType ?? "OTHER") as never, + unitsTotal: i.unitsTotal, + unitsRemaining: i.unitsRemaining, + firstUseAt: i.firstUseAt ? new Date(i.firstUseAt) : null, + expiresAt: i.expiresAt ? new Date(i.expiresAt) : null, + printedExpiry: i.printedExpiry ? new Date(i.printedExpiry) : null, + purchasedAt: i.purchasedAt ? new Date(i.purchasedAt) : null, + manufacturer: i.manufacturer ?? null, + doseStrength: i.doseStrength ?? null, + notes: null, + notesEncrypted: + i.notesEncrypted == null + ? encryptNote(i.notes ?? null) + : decodeEncryptedBytes(i.notesEncrypted), + ...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}), + ...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}), + })), + }, + ...phaseConfigData(m), + inventoryEvents: { + create: m.inventoryEvents.map((e) => ({ + ...(e.id ? { id: e.id } : {}), + delta: e.delta, + reason: e.reason, + occurredAt: new Date(e.occurredAt), + })), + }, + }; +} + +/** + * The courses, then the window they project. A file written before courses + * existed carries none; the medication's own window then describes its one + * course, derived exactly as the migration's backfill did (an end-only window + * runs from creation, or from its end day when that lies earlier). + */ +async function restoreCourses( + tx: Prisma.TransactionClient, + ownerId: string, + m: BackupMedication, + created: RestoredMedication, + restoreTz: string, +): Promise { + const courseRows: Array<{ + startsOn: Date; + endsOn: Date | null; + note: string | null; + createdAt?: Date; + }> = + m.courses !== undefined + ? m.courses.map((c) => ({ + startsOn: dateOfDayKey(c.startsOn), + endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null, + note: c.note ?? null, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + })) + : (() => { + const derived = courseFromWindow( + created.startsOn, + created.endsOn, + userDayKey(created.createdAt, restoreTz), + ); + return derived ? [{ ...derived, note: null }] : []; + })(); + if (courseRows.length === 0) return; + await tx.medicationCourse.createMany({ + data: courseRows.map((c) => ({ + medicationId: created.id, + userId: ownerId, + startsOn: c.startsOn, + endsOn: c.endsOn, + noteEncrypted: c.note ? encryptToBytes(c.note) : null, + ...(c.createdAt ? { createdAt: c.createdAt } : {}), + })), + }); + const window = projectCourseWindow(courseRows); + await tx.medication.update({ + where: { id: created.id }, + data: { startsOn: window.startsOn, endsOn: window.endsOn }, + }); +} + +/** + * The archived schedule eras, in two passes. + * + * An era carries `supersededByRevisionId`, the pointer from a corrected + * ARCHIVED row to the MANUAL one that replaced it. The column has no + * `@relation`, so a value addressing nothing costs no error — it just stops + * meaning anything, which is the more dangerous shape here, because every era + * consumer SKIPS a row that carries the pointer. Lose it and the superseded + * original goes live again beside its own correction: two overlapping eras + * for one window, and past days minted against a plan the account had already + * corrected. + * + * A portable restore mints fresh ids, so the pointer cannot travel as one. It + * travels as a POSITION in this drug's own ordered era list, because that + * list is the only thing both ends agree on: the builder sorts it totally + * (validFrom, then createdAt, then id) so two eras sharing an instant cannot + * swap places between two exports of the same account, and the pointer never + * crosses a drug — every route that writes it scopes the update to one + * medication — so the drug's own list is a complete address space. + * + * Pass one writes every era with the link NULL, because the row a link + * addresses is as often written after the row addressing it as before. Pass + * two patches the links once every position has an id. + */ +async function restoreScheduleRevisions( + tx: Prisma.TransactionClient, + m: BackupMedication, + medicationId: string, + medicationIndex: number, + unresolvedRevisionLinks: string[], +): Promise { + const revisionIds: string[] = []; + for (const revision of m.scheduleRevisions) { + const row = await tx.medicationScheduleRevision.create({ + data: { + ...(revision.id ? { id: revision.id } : {}), + medicationId, + validFrom: new Date(revision.validFrom), + validUntil: new Date(revision.validUntil), + payload: toJson(revision.payload), + source: revision.source ?? "ARCHIVED", + supersededByRevisionId: null, + ...(revision.createdAt + ? { createdAt: new Date(revision.createdAt) } + : {}), + }, + select: { id: true }, + }); + revisionIds.push(row.id); + } + for (const [index, revision] of m.scheduleRevisions.entries()) { + const to = revision.supersededByIndex; + if (to === null || to === undefined) continue; + // A hand-edited file can name a position this drug does not have, or the + // era's OWN position — and a row that supersedes itself is skipped by + // every consumer, so the era would disappear from the timeline while + // still sitting in the table. Neither can be honoured and neither may be + // guessed at, so the link stays NULL and the position is reported. The + // era still restores: the window and the plan it held are the history, + // and the correction pointer is the smaller loss. + if ( + !Number.isInteger(to) || + to < 0 || + to >= revisionIds.length || + to === index + ) { + unresolvedRevisionLinks.push( + `medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`, + ); + continue; + } + await tx.medicationScheduleRevision.update({ + where: { id: revisionIds[index] }, + data: { supersededByRevisionId: revisionIds[to] }, + }); + } +} + +/** + * The dose history. An event whose medication did not come back used to be + * mapped to null and filtered away: the dose history came back short and the + * restore reported success. That is the same silent-drop shape the cycle + * symptom links had, and it gets the same answer — name the unresolved + * reference and stop, so the operator learns from the error rather than from + * a compliance rate that quietly moved. + */ +async function restoreIntakeEvents( + ctx: RestoreContext, + medByName: ReadonlyMap, +): Promise { + const { tx, ownerId, payload } = ctx; + const restoredMedicationIds = ctx.links.restoredMedicationIds; + if (payload.intakeEvents.length === 0) return; + const unresolvedMedications = [ + ...new Set( + payload.intakeEvents + .filter( + (e) => + !(e.medicationId && restoredMedicationIds.has(e.medicationId)) && + !medByName.has(e.medication), + ) + .map((e) => e.medicationId ?? e.medication), + ), + ]; + if (unresolvedMedications.length > 0) { + throw new Error( + `Unknown medication references in intake events: ${unresolvedMedications.join( + ", ", + )}. The backup records doses against medications it does not carry.`, + ); + } + const rows = payload.intakeEvents + .map((e) => { + const medId = + e.medicationId && restoredMedicationIds.has(e.medicationId) + ? e.medicationId + : medByName.get(e.medication); + if (!medId) return null; + return { + ...(e.id ? { id: e.id } : {}), + userId: ownerId, + medicationId: medId, + scheduledFor: new Date(e.scheduledFor), + takenAt: e.takenAt ? new Date(e.takenAt) : null, + skipped: e.skipped ?? false, + autoMissed: e.autoMissed ?? false, + autoRecorded: e.autoRecorded ?? false, + attributionSource: e.attributionSource ?? "AUTO", + source: e.source ?? "WEB", + idempotencyKey: e.idempotencyKey ?? null, + ...(e.createdAt ? { createdAt: new Date(e.createdAt) } : {}), + injectionSite: e.injectionSite ?? null, + doseTaken: e.doseTaken ?? null, + inventoryConsumption: (e.inventoryConsumption ?? null) as never, + externalId: e.externalId ?? null, + ...(e.updatedAt ? { updatedAt: new Date(e.updatedAt) } : {}), + syncVersion: e.syncVersion ?? 0, + deletedAt: e.deletedAt ? new Date(e.deletedAt) : null, + }; + }) + .filter((r): r is NonNullable => r !== null); + if (rows.length > 0) { + await tx.medicationIntakeEvent.createMany({ data: rows }); + } +} + +/** + * The medications, each with everything nested in it, then the dose history. + * + * The efficacy targets cannot ride inside the medication create beside the + * schedules and the packs: `biomarkerId` is a foreign key into a catalogue + * this transaction has not restored yet. They are collected against the id + * the drug ACTUALLY got and written by `restoreEfficacyTargets` once the + * biomarkers exist. + * + * Supersede pointers the file states and the restore cannot honour are + * reported as JSON paths into the file rather than as ids: the file is where + * an operator has to go to see what the position meant, and an id would be + * one the restore never wrote. + */ +export async function restoreMedications(ctx: RestoreContext): Promise { + const { tx, ownerId, payload, skips, links } = ctx; + const medByName = new Map(); + const unresolvedRevisionLinks: string[] = []; + const categoryKeyRemap = await restoreCustomCategories(ctx); + + // The zone a derived course's creation day is read in: the file's own + // account settings, else the account's stored one. + const restoreTz = validTimezoneOr( + payload.accountSettings?.timezone ?? + ( + await tx.user.findUnique({ + where: { id: ownerId }, + select: { timezone: true }, + }) + )?.timezone, + DEFAULT_TIMEZONE, + ); + let medicationIndex = 0; + ctx.reportSection("medications"); + const restoreStartedAt = new Date(); + for (const m of payload.medications) { + const created = await tx.medication.create({ + data: { + ...medicationRowData( + m, + ownerId, + payload.intakeEvents, + restoreStartedAt, + ), + ...medicationChildrenData(m, ownerId), + }, + }); + links.restoredMedicationIds.add(created.id); + // The id each medication had in the source account, to the id it got + // here. A disaster-recovery file keeps the id, so this is the identity + // there; a portable file names the source row only through `ref`, and + // the relation links (written last) are re-pointed through this map + // instead of being dropped as "end is gone". + const medicationSourceId = m.id ?? m.ref; + if (medicationSourceId) { + links.medicationIdBySourceId.set(medicationSourceId, created.id); + } + if (!medByName.has(m.name)) medByName.set(m.name, created.id); + // The clinical category lives in its own table keyed on the medication + // id, so it is written after the row exists and inside the same + // transaction. OTHER is what a missing row already reads as. + if (m.category && m.category !== "OTHER") { + await setMedicationCategory( + created.id, + categoryKeyRemap.get(m.category) ?? m.category, + tx, + ); + } + await restoreCourses(tx, ownerId, m, created, restoreTz); + await restoreScheduleRevisions( + tx, + m, + created.id, + medicationIndex, + unresolvedRevisionLinks, + ); + for (const target of m.efficacyTargets) { + links.pendingEfficacyTargets.push({ medicationId: created.id, target }); + } + medicationIndex += 1; + } + recordUnknownKeys( + skips, + "scheduleRevisionLink", + [...new Set(unresolvedRevisionLinks)], + unresolvedRevisionLinks, + ); + await restoreIntakeEvents(ctx, medByName); +} + +/** + * What each drug was supposed to move, written once both ends exist: the + * drugs came back in the medications step and the analytes in the labs step, + * and one of those two had to move for a target to reach the database at + * all. + * + * A second pass wins over hoisting the biomarker restore above the + * medications, for two reasons. The marker maps are read by the lab-result + * pass directly after this one, so moving the marker restore would separate a + * map from its only other consumer. And the direction of the dependency would + * read backwards: a pinned target is a leaf of the medication tree, and + * letting a leaf dictate where the clinical record is rebuilt inverts what + * the file is about. A second pass states the constraint where it applies + * instead of hiding it in a line order. + * + * A named analyte the restore did not put back cannot be written: + * `biomarkerId` is a real foreign key, so a dangling value would fail the + * constraint and roll the entire account back over one override. The row is + * dropped and NAMED. Dropping rather than nulling is deliberate here: the + * override IS the reference, and a row with neither arm set is what the + * resolver already reads as "no override", so keeping it would restore + * something that means nothing while still claiming the primary slot. A + * target that carried no name in the first place is a genuine live state (the + * analyte was deleted before the export) and comes back as written. + */ +export async function restoreEfficacyTargets( + ctx: RestoreContext, +): Promise { + const { tx, skips } = ctx; + const { biomarkerByName, pendingEfficacyTargets } = ctx.links; + const unresolvedTargetAnalytes: string[] = []; + const writableTargets = pendingEfficacyTargets.filter((pending) => { + const name = pending.target.biomarkerName; + if (!name || biomarkerByName.has(name)) return true; + unresolvedTargetAnalytes.push(name); + return false; + }); + if (writableTargets.length > 0) { + await tx.medicationEfficacyTarget.createMany({ + data: writableTargets.map(({ medicationId, target }) => ({ + ...(target.id ? { id: target.id } : {}), + medicationId, + measurementType: target.measurementType ?? null, + biomarkerId: target.biomarkerName + ? (biomarkerByName.get(target.biomarkerName) ?? null) + : null, + primary: target.primary ?? true, + ...(target.createdAt ? { createdAt: new Date(target.createdAt) } : {}), + ...(target.updatedAt ? { updatedAt: new Date(target.updatedAt) } : {}), + })), + }); + } + recordUnknownKeys( + skips, + "medicationTarget", + [...new Set(unresolvedTargetAnalytes)], + unresolvedTargetAnalytes, + ); +} diff --git a/src/lib/export/mood-restore.ts b/src/lib/export/mood-restore.ts new file mode 100644 index 000000000..938195124 --- /dev/null +++ b/src/lib/export/mood-restore.ts @@ -0,0 +1,400 @@ +/** + * The restore half of the mood domain: the account's own categories, tags + * and hidden set, then the entries with their day context and their links. + * The export half lives in `mood-backup.ts`. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; +import { recordUnknownKeys } from "@/lib/export/restore-skips"; +import { sealForRestore } from "@/lib/export/sealed-text"; +import type { BackupPayload } from "@/lib/validations/backup"; + +type BackupMoodEntry = BackupPayload["moodEntries"][number]; + +export async function clearMoodEntries(ctx: RestoreContext): Promise { + const moods = await ctx.tx.moodEntry.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.moodEntries = moods.count; +} + +/** + * The persisted mood rollup partition, wiped so the next analytics read does + * not surface pre-restore daily means. The fold after the commit mints fresh + * rows from the restored mood entries. + */ +export async function clearMoodRollups(ctx: RestoreContext): Promise { + await ctx.tx.moodEntryRollup.deleteMany({ where: { userId: ctx.ownerId } }); +} + +/** + * The account's own categories and tags, written whether or not the file + * carries a mood entry: an account that set up its mood vocabulary before + * logging a single entry has them too, and they used to be written only + * inside the entries branch. + * + * The CATEGORIES go in before the tags. A custom tag's `categoryId` is a real + * foreign key, so writing the tag first threw `mood_tags_category_id_fkey` + * and rolled the entire restore back — an account that had grouped its own + * mood factors could not be restored at all, and the failure named a + * constraint rather than the thing that was missing. + * + * Both are upserted by key: `key` is globally unique, so a seeded category or + * tag that later claims the key must be left alone rather than overwritten by + * a restore. The links resolve either way. + */ +async function restoreMoodVocabulary(ctx: RestoreContext): Promise { + const { tx, ownerId, payload, skips } = ctx; + // Labels this host's keys do not open, by file path. + const unopenedMoodLabels: string[] = []; + for (const category of payload.customMoodTagCategories) { + await tx.moodTagCategory.upsert({ + where: { key: category.key }, + create: { + id: category.id, + userId: ownerId, + key: category.key, + labelKey: category.labelKey, + icon: category.icon ?? null, + sortOrder: category.sortOrder ?? 0, + isActive: category.isActive ?? true, + labelEncrypted: sealForRestore( + category.labelEncrypted, + category.label, + `customMoodTagCategories.${category.key}.labelEncrypted`, + unopenedMoodLabels, + ), + }, + update: {}, + }); + } + + // The account's own tag definitions. The entry lookup used to ask only for + // the seeded catalogue (`userId: null`), so a single custom rated tag made + // the whole restore throw "Unknown mood factor keys" — the file was + // complete and unusable. + for (const tag of payload.customMoodTags) { + await tx.moodTag.upsert({ + where: { key: tag.key }, + create: { + ...(tag.id ? { id: tag.id } : {}), + userId: ownerId, + key: tag.key, + labelKey: tag.labelKey, + categoryId: tag.categoryId, + kind: tag.kind, + isActive: tag.isActive, + icon: tag.icon ?? null, + sortOrder: tag.sortOrder, + labelEncrypted: sealForRestore( + tag.labelEncrypted, + tag.label, + `customMoodTags.${tag.key}.labelEncrypted`, + unopenedMoodLabels, + ), + scaleMin: tag.scaleMin, + scaleMax: tag.scaleMax, + inverse: tag.inverse, + }, + update: {}, + }); + } + recordUnknownKeys( + skips, + "moodLabelCiphertext", + unopenedMoodLabels, + unopenedMoodLabels, + ); +} + +/** + * Which tags the account hid. Delete-then-recreate like every other section, + * and resolved by KEY: what people hide is almost always a seeded tag, whose + * id differs on every instance. + * + * A key this instance no longer knows costs one hidden row and is REPORTED. + * Dropping it silently would un-hide a factor the person had deliberately + * taken out of their mood editor, and they would have no way to know why it + * came back. + */ +async function restoreHiddenMoodTags(ctx: RestoreContext): Promise { + const { tx, ownerId, payload, skips } = ctx; + await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } }); + if (payload.hiddenMoodTags.length === 0) return; + const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key); + const hiddenRows = await tx.moodTag.findMany({ + where: { + key: { in: [...new Set(hiddenKeys)] }, + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const hiddenByKey = new Map(hiddenRows.map((row) => [row.key, row.id])); + const unresolvedHidden: string[] = []; + const writableHidden = payload.hiddenMoodTags.filter((entry) => { + if (hiddenByKey.has(entry.key)) return true; + unresolvedHidden.push(entry.key); + return false; + }); + if (writableHidden.length > 0) { + await tx.moodTagHidden.createMany({ + data: writableHidden.map((entry) => ({ + userId: ownerId, + moodTagId: hiddenByKey.get(entry.key)!, + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + })), + skipDuplicates: true, + }); + } + recordUnknownKeys( + skips, + "moodTag", + [...new Set(unresolvedHidden)], + unresolvedHidden, + ); +} + +/** One entry's own columns, as the upsert writes them. */ +function moodEntryData(entry: BackupMoodEntry) { + const moodLoggedAt = new Date(entry.loggedAt); + return { + date: entry.date, + mood: entry.mood, + score: entry.score, + // The five level-A values, carried the same way `syncVersion` is: absent + // in the file means the file predates the columns, and an upsert whose + // update arm stated them would wipe live answers off every matched row + // when an older backup is restored over a going concern. A file that has + // them writes them, including an explicit null; a file that does not + // says nothing. Never derived — filling one in here would put a number + // the person never gave into a restored row. + ...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }), + ...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }), + ...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }), + ...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }), + ...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }), + tags: entry.tags ?? null, + // The note comes back the way every other dual-column note does: + // ciphertext verbatim when the file carries it, and a portable file's + // plaintext re-encrypted on the way in, with the legacy column left null + // either way. + note: null, + noteEncrypted: + entry.noteEncrypted == null + ? encryptNote(entry.note ?? null) + : decodeEncryptedBytes(entry.noteEncrypted), + source: entry.source ?? "MOODLOG", + externalId: entry.externalId ?? null, + moodLoggedAt, + // The zone the `date` string is anchored to. A file written before this + // rode the payload carries nothing, and NULL is what such a row meant + // anyway: the legacy Europe/Berlin reading. So an old file keeps its old + // day boundaries and a new one keeps the zone the person actually logged + // in. + tz: entry.tz ?? null, + // Mood reconciles last-writer-wins by `syncVersion`. Restoring at the + // schema default would hand the next sync round to any paired device + // still holding a higher number, and the device would overwrite the + // recovered row with what it had — a restore that loses to the client is + // worse than one that fails. An old file carries nothing here and keeps + // the default, which is the value those rows were written with. + ...(entry.syncVersion === undefined + ? {} + : { syncVersion: entry.syncVersion }), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + ...(entry.syncedAt ? { syncedAt: new Date(entry.syncedAt) } : {}), + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), + }; +} + +/** + * The day context, rebuilt after the entry so it can bind to whatever id that + * entry actually got. Delete-then-write rather than an upsert with a + * preserve-when-absent arm: the file states the whole context or states that + * there was none, and a restored entry carrying half a file's context and + * half a live row's would be a state neither of them describes. + */ +async function restoreMoodContext( + tx: Prisma.TransactionClient, + ownerId: string, + moodEntryId: string, + entry: BackupMoodEntry, +): Promise { + await tx.moodContext.deleteMany({ where: { moodEntryId } }); + if (!entry.context) return; + const context = entry.context; + await tx.moodContext.create({ + data: { + moodEntryId, + userId: ownerId, + workStatus: context.workStatus ?? null, + workMinutes: context.workMinutes ?? null, + overtimeMinutes: context.overtimeMinutes ?? null, + workLoad: context.workLoad ?? null, + workSatisfaction: context.workSatisfaction ?? null, + contactCircles: context.contactCircles ?? null, + contactForm: context.contactForm ?? null, + contactExtent: context.contactExtent ?? null, + contactQuality: context.contactQuality ?? null, + contactSupport: context.contactSupport ?? null, + leisureCategories: context.leisureCategories ?? null, + leisureMinutes: context.leisureMinutes ?? null, + leisureJoy: context.leisureJoy ?? null, + leisureRecovery: context.leisureRecovery ?? null, + eventType: context.eventType ?? null, + eventValence: context.eventValence ?? null, + eventAt: context.eventAt ? new Date(context.eventAt) : null, + // The note comes back the way every other dual-column note does: + // ciphertext verbatim when the file carries it, a portable file's + // plain text re-encrypted on the way in. + notesEncrypted: + context.notesEncrypted == null + ? encryptNote(context.note ?? null) + : decodeEncryptedBytes(context.notesEncrypted), + }, + }); +} + +/** One entry, its day context and its links. */ +async function restoreMoodEntry( + tx: Prisma.TransactionClient, + ownerId: string, + entry: BackupMoodEntry, + factorByKey: ReadonlyMap, + tagByKey: ReadonlyMap, +): Promise { + const restoredData = moodEntryData(entry); + const createData = { userId: ownerId, ...restoredData }; + const restored = entry.id + ? await tx.moodEntry.upsert({ + where: { id: entry.id, userId: ownerId }, + create: { id: entry.id, ...createData }, + update: restoredData, + }) + : entry.externalId + ? await tx.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId: ownerId, + source: restoredData.source, + externalId: entry.externalId, + }, + }, + create: createData, + update: restoredData, + }) + : await tx.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId: ownerId, + date: entry.date, + moodLoggedAt: restoredData.moodLoggedAt, + }, + }, + create: createData, + update: restoredData, + }); + + await restoreMoodContext(tx, ownerId, restored.id, entry); + + await tx.moodEntryTagLink.deleteMany({ + where: { moodEntryId: restored.id }, + }); + const factorLinks = entry.factors.flatMap((factor) => { + const moodTagId = factorByKey.get(factor.key); + return moodTagId + ? [{ moodEntryId: restored.id, moodTagId, rating: factor.rating }] + : []; + }); + // The other arm. A BINARY link carries no score — the presence of the row + // is the signal — so `rating` is NULL by definition, which is exactly what + // the backup's old tag-link filter took to mean "nothing here". + const tagLinks = entry.structuredTags.flatMap((key) => { + const moodTagId = tagByKey.get(key); + return moodTagId + ? [{ moodEntryId: restored.id, moodTagId, rating: null }] + : []; + }); + const links = [...factorLinks, ...tagLinks]; + if (links.length > 0) { + await tx.moodEntryTagLink.createMany({ data: links }); + } +} + +async function restoreMoodEntries(ctx: RestoreContext): Promise { + const { tx, ownerId, payload, skips } = ctx; + if (payload.moodEntries.length === 0) return; + const referencedFactorKeys = payload.moodEntries.flatMap((entry) => + entry.factors.map((factor) => factor.key), + ); + const factorKeys = [...new Set(referencedFactorKeys)]; + const referencedTagKeys = payload.moodEntries.flatMap( + (entry) => entry.structuredTags, + ); + const tagKeys = [...new Set(referencedTagKeys)]; + const factorRows = + factorKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: factorKeys }, + kind: "RATED", + // The seeded catalogue OR this account's own tags. + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const factorByKey = new Map( + factorRows.map((factor) => [factor.key, factor.id]), + ); + // A rated tag this instance does not know costs the entry's link to it, not + // the entry. The mood, the score, the date, the free tags — everything the + // person logged — comes back; the one rating that has nowhere to attach is + // dropped and named. Refusing the file instead meant a renamed seeded tag + // made the whole account unrecoverable. + recordUnknownKeys( + skips, + "moodFactor", + factorKeys.filter((key) => !factorByKey.has(key)), + referencedFactorKeys, + ); + + // The BINARY half of the same taxonomy, looked up the same way and reported + // the same way. It gets its own family because an operator reading the + // report needs to know whether a rated factor's score or a ticked tag went + // missing; they are different losses on the same entry. + const tagRows = + tagKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: tagKeys }, + kind: "BINARY", + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id])); + recordUnknownKeys( + skips, + "moodTag", + tagKeys.filter((key) => !tagByKey.has(key)), + referencedTagKeys, + ); + + for (const entry of payload.moodEntries) { + await restoreMoodEntry(tx, ownerId, entry, factorByKey, tagByKey); + } +} + +export async function restoreMood(ctx: RestoreContext): Promise { + ctx.reportSection("mood"); + await restoreMoodVocabulary(ctx); + await restoreHiddenMoodTags(ctx); + await restoreMoodEntries(ctx); +} diff --git a/src/lib/export/notification-channels-backup.ts b/src/lib/export/notification-channels-backup.ts new file mode 100644 index 000000000..d4ae82435 --- /dev/null +++ b/src/lib/export/notification-channels-backup.ts @@ -0,0 +1,26 @@ +/** + * The notification channels, which a backup deliberately never carries + * (`NOT_IN_BACKUP_MODELS` says why for each) and a restore therefore clears: + * a channel, a Web Push endpoint or a pending Telegram deletion from before + * the restore addresses a chat or a browser the restored account has not + * been through setup with on this host. + */ +import type { RestoreContext } from "@/lib/export/restore-context"; + +export async function clearNotificationChannels( + ctx: RestoreContext, +): Promise { + const { tx, ownerId } = ctx; + const channels = await tx.notificationChannel.deleteMany({ + where: { userId: ownerId }, + }); + const subs = await tx.pushSubscription.deleteMany({ + where: { userId: ownerId }, + }); + const tgDel = await tx.telegramScheduledDeletion.deleteMany({ + where: { userId: ownerId }, + }); + ctx.cleared.notificationChannels = channels.count; + ctx.cleared.pushSubscriptions = subs.count; + ctx.cleared.telegramScheduledDeletions = tgDel.count; +} diff --git a/src/lib/export/nutrients-backup.ts b/src/lib/export/nutrients-backup.ts new file mode 100644 index 000000000..c0efe157b --- /dev/null +++ b/src/lib/export/nutrients-backup.ts @@ -0,0 +1,35 @@ +/** + * The nutrient day totals, with both backup ends in one file. + */ +import type { RestoreContext } from "@/lib/export/restore-context"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +/** + * The export has written nutrient day totals since v1.29 and nothing here + * used to read them back: water and vitamin history was carried in the file, + * discarded on restore, and the restore reported success. + */ +export async function clearNutrientDays(ctx: RestoreContext): Promise { + const nutrientDays = await ctx.tx.nutrientIntakeDay.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.nutrientDays = nutrientDays.count; +} + +export async function restoreNutrientDays(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + if (payload.nutrientDays.length === 0) return; + await tx.nutrientIntakeDay.createMany({ + data: payload.nutrientDays.map((n) => ({ + userId: ownerId, + day: n.day, + nutrient: n.nutrient, + amount: n.amount, + unit: n.unit, + source: n.source, + externalSourceVersion: n.externalSourceVersion ?? null, + ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), + })), + }); +} diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 5531f32ce..9b80706ec 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -1,12 +1,12 @@ /** * The restore of one stored backup over its owner's record. * - * After owner and schema validation, one transaction replaces every - * serialized owner-scoped class: measurements, medication history including the - * side effects recorded against a drug, mood and rated factors, cycle data, - * labs/biomarkers, illness history, allergies, family history, workout - * summaries, and inbound documents. Document content and summary ciphertext are - * decoded from base64 and persisted verbatim. + * After owner and schema validation (`restore-preflight.ts`), one transaction + * replaces every serialized owner-scoped class. The transaction is a list of + * steps, one or a few per domain, run in dependency order + * (`backup-domains.ts`); each domain's module holds its own restore beside + * its export. Document content and summary ciphertext are decoded from base64 + * and persisted verbatim. * * Metadata-only portable document exports are rejected before mutation; the * importer never fabricates content. Audit rows remain outside the wipe, and @@ -14,7 +14,7 @@ * * Everything above is scoped to ONE account. The instance-wide settings a * disaster-recovery payload also carries are not, and they are opt-in for that - * reason: see the `restoreInstanceSettings` block below. + * reason: see `app-settings-backup.ts`. * * This used to be the body of `POST /api/admin/backups/[id]/restore`. On an * account of 1.25 million readings it ran for about a minute and a half inside @@ -23,120 +23,74 @@ * the copy opens, queues a `backup-restore` job and answers 202; the job * (`src/lib/jobs/backup-restore.ts`) calls `restoreBackup` and records what it * reports. Nothing about what a restore checks or writes changed in the move: - * every refusal below still happens before the first delete, and the - * replacement is still one transaction, so a failure part-way leaves the - * account exactly as it was. + * every refusal still happens before the first delete, and the replacement is + * still one transaction, so a failure part-way leaves the account exactly as + * it was. */ -import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format"; -import { - courseFromWindow, - dateOfDayKey, - projectCourseWindow, -} from "@/lib/medications/course-window"; -import { Buffer } from "node:buffer"; - -import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels"; -import { prisma, toJson } from "@/lib/db"; -import { normaliseSourceInstance } from "@/lib/validations/inbound-documents"; import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserData } from "@/lib/cache/invalidate"; +import { prisma } from "@/lib/db"; +import type { AccountSettingsRestoreResult } from "@/lib/export/account-settings-restore"; +import { + RESTORE_CLEAR_STEPS, + RESTORE_STEPS, +} from "@/lib/export/backup-domains"; +import { + createMeasurementRestorePrepass, + type MeasurementRestorePrepass, +} from "@/lib/export/measurements-backup"; +import { + emptyRestoreLinks, + orderedCleared, + RESTORE_SECTION_STEPS, + type RestoreCleared, + type RestoreContext, + type RestoreSectionStep, +} from "@/lib/export/restore-context"; +import { + classifyRestoreFailure, + RESTORE_FAILURE_CAUSE_MESSAGES, +} from "@/lib/export/restore-failure-cause"; +import { takeRestoreLock } from "@/lib/export/restore-lock"; +import { + checkBackupBeforeRestore, + openAndReadBackup, + RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS, + refused, + type ReadBackup, +} from "@/lib/export/restore-preflight"; +import { + summarizeRestoreSkips, + type RestoreSkipLog, + type RestoreSkipSummary, +} from "@/lib/export/restore-skips"; import { isStoredBackupReadError, - openStoredBackup, storedBackupRefusal, type StoredBackupRef, } from "@/lib/export/stored-backup"; -import { takeRestoreLock } from "@/lib/export/restore-lock"; -import { - insertMeasurementRows, - type MeasurementInsertRow, -} from "@/lib/export/measurement-bulk-insert"; -import { - readStreamedBackup, - type BackupSource, - type StreamedBackup, -} from "@/lib/export/streamed-backup"; -import { - assessBackupKeys, - BACKUP_KEY_MISSING_CODE, - describeBackupKeyProblem, -} from "@/lib/export/backup-key-ids"; import { assertNoNewForeignReferences, countForeignReferences, ForeignReferenceError, listTenantEdges, } from "@/lib/export/tenant-boundary"; -import { encryptNote } from "@/lib/crypto/note-cipher"; -import { sealForRestore } from "@/lib/export/sealed-text"; -import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; -import { encryptNoteToBytes } from "@/lib/labs/store"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; +import { enqueueMeasurementFoldRepair } from "@/lib/jobs/measurement-fold-repair"; import { annotate } from "@/lib/logging/context"; -import { foldForMatch } from "@/lib/i18n/fold-for-match"; -import type { BackupMeasurement } from "@/lib/validations/backup"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; +import { stampSyncReset } from "@/lib/sync/reset"; import { - isLegacyRmssdRow, - LEGACY_RMSSD_SOURCES, -} from "@/lib/export/legacy-rmssd"; -import { - isCompatibleSchemaVersion, summarizeBackup, type BackupSummary, } from "@/lib/validations/backup-summary"; -import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; -import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; -import { restoreCycleData } from "@/lib/cycle/backup"; -import { - findMissingBackupSections, - recordUnknownKeys, - summarizeRestoreSkips, - type RestoreSkipLog, - type RestoreSkipSummary, -} from "@/lib/export/restore-skips"; -import { restoreProfileData } from "@/lib/export/profile-backup"; -import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; -import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; -import { restoreOnboardingData } from "@/lib/export/onboarding-backup"; -import { - restoreAccountSettings, - type AccountSettingsRestoreResult, -} from "@/lib/export/account-settings-restore"; -import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; -import { restoreVisitsData } from "@/lib/export/visits-backup"; -import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; -import { restoreSensitiveData } from "@/lib/export/sensitive-backup"; -import { - restoreCoachData, - restoreCoachMemoryData, -} 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 { restoreEcgData } from "@/lib/export/ecg-backup"; -import { restoreSymptomsData } from "@/lib/export/symptoms-backup"; -import { restoreLifeEventsData } from "@/lib/export/life-events-backup"; -import { restoreRelationLinksData } from "@/lib/export/relation-links-backup"; -import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; -import { - encryptCategoryLabel, - mintCustomMedicationCategoryKey, - setMedicationCategory, -} from "@/lib/medication-category"; -import { invalidateUserData } from "@/lib/cache/invalidate"; -import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; -import { - FOLDED_TYPES, - compactionTombstoneCoverId, -} from "@/lib/measurements/folded-window"; -import { enqueueMeasurementFoldRepair } from "@/lib/jobs/measurement-fold-repair"; -import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; -import { stampSyncReset } from "@/lib/sync/reset"; -import { - classifyRestoreFailure, - RESTORE_FAILURE_CAUSE_MESSAGES, -} from "@/lib/export/restore-failure-cause"; -import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability"; + +export { RESTORE_SECTION_STEPS } from "@/lib/export/restore-context"; +export { + RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS, + restoreTransactionTimeoutMs, +} from "@/lib/export/restore-preflight"; export interface RestoreResponse { restored: true; @@ -149,102 +103,7 @@ export interface RestoreResponse { * restore that quietly loses links looks exactly like one that did not. */ skipped: RestoreSkipSummary; - cleared: { - measurements: number; - medications: number; - intakeEvents: number; - moodEntries: number; - notificationChannels: number; - pushSubscriptions: number; - telegramScheduledDeletions: number; - cycles: number; - cycleDayLogs: number; - cycleProfile: number; - labResults: number; - biomarkers: number; - nutrientDays: number; - illnessEpisodes: number; - allergies: number; - familyHistory: number; - workouts: number; - documents: number; - documentConditionLinks: number; - extractedFacts: number; - healthProfile: number; - healthProfileFactRevisions: number; - customMetrics: number; - correlationPatterns: number; - intradayProfiles: number; - healthScoreRecords: number; - onboardingRecords: number; - practitioners: number; - encounters: number; - encounterLinks: number; - vaccinations: number; - vaccinationLinks: number; - relationLinks: number; - customVaccines: number; - measurementReminders: number; - measurementReminderEvents: number; - coachConversations: number; - coachMessages: number; - coachConversationDocuments: number; - coachFacts: number; - coachPlans: number; - coachReminders: number; - mentalHealthAssessments: number; - consentReceipts: number; - personalRecords: number; - userAchievements: number; - environmentContexts: number; - environmentTravelLocations: number; - dawarichDays: number; - ecgRecordings: number; - symptomDefinitions: number; - lifeEvents: number; - }; -} - -/** - * How long the restore transaction may run, and with it every statement - * inside it (`SET LOCAL statement_timeout`, see the transaction below). - * - * The work is deleting the readings the account holds now and writing the - * ones the file carries. Both count: an account whose readings were folded - * into hourly averages still holds the originals as deleted rows until the - * nightly purge removes them, and the restore deletes and rewrites those too - * (#1031). Writing is the slow half, since every reading goes into thirteen - * indexes. The budget is two minutes, plus a second per 1 000 readings in the - * file and per 10 000 in the account. A seeded account of 1.89 million - * readings restored in 196 s on a fast host and in 19 min on a database held - * to half a CPU and 40 MB/s of writes, where its transaction ran 16 min of a - * 37 min budget. - */ -export function restoreTransactionTimeoutMs( - fileMeasurements: number, - currentMeasurements: number, -): number { - return 120_000 + fileMeasurements + Math.ceil(currentMeasurements / 10); -} - -function readingsPhrase(count: number): string { - return `${count} ${count === 1 ? "reading" : "readings"}`; -} - -/** Both unique identities of a row, as it would be filed under `HRV_RMSSD`. */ -function rmssdTwinKeysOf(row: BackupMeasurement): string[] { - const keys = [ - `at|${row.source}|${new Date(row.measuredAt).toISOString()}|${row.sleepStage ?? ""}`, - ]; - if (row.externalId) keys.push(`ext|${row.source}|${row.externalId}`); - return keys; -} - -function decodeEncryptedBytes(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; + cleared: RestoreCleared; } /** Where a running restore is. */ @@ -272,31 +131,6 @@ export type RestoreProgressSink = ( progress: RestoreProgress, ) => void; -/** - * The steps after the measurements that report as sections, in order. The - * restore names each one as it starts it (`reportSection`), so the count the - * status shows is the count the transaction walks. - */ -export const RESTORE_SECTION_STEPS = [ - "medications", - "mood", - "cycles", - "profile", - "labs", - "illness", - "allergies", - "familyHistory", - "documents", - "reminders", - "visits", - "vaccinations", - "coach", - "sensitive", - "awards", - "ecg", - "environment", -] as const; - /** * Why a restore did not happen, as a stable code. Every one of them is decided * before the first delete or rolls the whole transaction back, so each means @@ -353,35 +187,14 @@ export type RestoreBackupOutcome = meta?: Record; }; -/** - * Time kept free after the transaction for the rollup folds and the audit row, - * when the restore checks it fits the caller's deadline. - */ -export const RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS = 10 * 60 * 1000; - -function refused( - status: number, - code: RestoreFailureCode, - message: string, - meta?: Record, -): RestoreBackupOutcome { - return { ok: false, status, code, message, ...(meta ? { meta } : {}) }; +/** The phase and the counts, and the one way to report them. */ +export interface RestoreProgressTracker { + readonly progress: RestoreProgress; + report(next?: RestorePhase, force?: boolean): void; + reportSection(step: RestoreSectionStep): void; } -/** - * Restore one stored backup over its owner's record. Resolves with what the - * transaction did, or with why nothing was done; throws only for a fault - * outside the restore's own checks (the database gone, for instance). - */ -export async function restoreBackup( - input: RestoreBackupInput, -): Promise { - const { backup, restoreInstanceSettings } = input; - annotate({ - action: { name: "admin.backups.restore" }, - meta: { id: backup.id, restore_instance_settings: restoreInstanceSettings }, - }); - +function progressTracker(sink?: RestoreProgressSink): RestoreProgressTracker { const progress: RestoreProgress = { measurementsChecked: 0, measurementsTotal: null, @@ -399,2655 +212,271 @@ export async function restoreBackup( if (next === phase && !force && now - lastReportAt < 250) return; phase = next; lastReportAt = now; - input.progress?.(phase, { ...progress }); + sink?.(phase, { ...progress }); }; - const reportSection = (step: (typeof RESTORE_SECTION_STEPS)[number]) => { - progress.sectionsDone = RESTORE_SECTION_STEPS.indexOf(step); - report("sections", true); + return { + progress, + report, + reportSection(step) { + progress.sectionsDone = RESTORE_SECTION_STEPS.indexOf(step); + report("sections", true); + }, }; - report("validating", true); +} - // Opened, not unpacked: the JSON of a large record is longer than any - // string V8 can hold (#1031), so it is read as a stream below. Opening - // authenticates every stored piece first, and checks they add up to the - // copy that was written, before anything is read or deleted. - let source: BackupSource; - try { - source = await openStoredBackup(prisma, backup); - } catch (err) { - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId: backup.userId, - reason: err instanceof Error ? err.message : "decrypt_failed", - }, +interface TransactionResult { + cleared: RestoreCleared; + skipped: RestoreSkipSummary; + expiredTombstonesSkipped: number; + foldRepairReset: boolean; + accountSettings: AccountSettingsRestoreResult; +} + +/** + * The replacement itself: every step of every domain, in one transaction. + * + * Every connection carries the request limits from `src/lib/db.ts` (60 s per + * statement, 60 s idle inside a transaction). A restore is not a request: + * deleting two million readings is one statement, and on a slow disk it ran + * past 60 s, was cancelled, and the whole restore rolled back in its clearing + * step (#1031). The transaction has its own limit; SET LOCAL gives each + * statement the same one, and ends with the transaction. + * + * One restore of an account at a time, whatever let a second one start: the + * job table admits one queued or running job per account, and the restore + * lock holds even if a worker that was thought gone is still writing. A + * second transaction waits for the first to commit or roll back, then + * replaces the account again from its own file. The same lock orders the + * restore against the background passes that rewrite readings a day at a time + * (`restore-lock.ts`): they hold it shared for each day, so the delete never + * meets a day half folded, which is where the two used to deadlock. + * + * Every reference this restore writes stays inside the account: the + * references that leave it are counted before the first step and again after + * the last write, and any that grew rolls the whole transaction back + * (`tenant-boundary.ts` says why the check is derived from the schema rather + * than written per section). + */ +async function replaceAccount( + read: ReadBackup, + ownerId: string, + restoreInstanceSettings: boolean, + transactionTimeoutMs: number, + tracker: RestoreProgressTracker, + prepass: MeasurementRestorePrepass, +): Promise { + tracker.report("clearing"); + return prisma.$transaction( + async (tx) => { + await tx.$executeRaw`SELECT set_config('statement_timeout', ${String(transactionTimeoutMs)}, true), set_config('idle_in_transaction_session_timeout', ${String(transactionTimeoutMs)}, true)`; + await takeRestoreLock(tx, ownerId); + const tenantEdges = await listTenantEdges(tx); + const foreignBefore = await countForeignReferences( + tx, + ownerId, + tenantEdges, + ); + // Declared INSIDE the transaction so a rollback takes the report with + // it. An accumulator that outlived a failed attempt would carry drops + // that never happened into the next one. + const skips: RestoreSkipLog = []; + const ctx: RestoreContext = { + tx, + ownerId, + payload: read.payload, + streamed: read.streamed, + skips, + restoreInstanceSettings, + measurementPrepass: prepass, + links: emptyRestoreLinks(), + outputs: { + expiredTombstonesSkipped: 0, + foldRepairReset: false, + accountSettings: { applied: 0, refused: [], unitsChanged: false }, + }, + cleared: {}, + reportSection: tracker.reportSection, + reportMeasurements(written) { + tracker.progress.measurementsWritten += written; + tracker.report("measurements"); + }, + }; + for (const step of RESTORE_CLEAR_STEPS) await step.run(ctx); + for (const step of RESTORE_STEPS) await step.run(ctx); + + const cleared = orderedCleared(ctx.cleared); + assertNoNewForeignReferences( + foreignBefore, + await countForeignReferences(tx, ownerId, tenantEdges), + ); + // Paired clients' delta cursors are no longer valid (see + // `User.syncResetAt`). Stamped as the last write, so it commits with + // the data, and stamped again right after the commit to cover a cursor + // issued while this transaction was still running. + await stampSyncReset(tx, ownerId); + tracker.progress.sectionsDone = RESTORE_SECTION_STEPS.length; + tracker.report("sections", true); + return { + cleared, + skipped: summarizeRestoreSkips(skips), + expiredTombstonesSkipped: ctx.outputs.expiredTombstonesSkipped, + foldRepairReset: ctx.outputs.foldRepairReset, + accountSettings: ctx.outputs.accountSettings, + }; + }, + { + maxWait: 10_000, + timeout: transactionTimeoutMs, + }, + ); +} + +/** + * Why the transaction failed, recorded and answered. + * + * The raw Prisma / driver error is scrubbed from the answer — even on an + * admin endpoint, leaking column names, constraint names or query fragments + * lowers the cost of a future supply-chain attack against this surface. The + * verbose text still lands in the audit row (admin-readable) and the wide + * event (operator-readable), so root-cause investigation is unaffected. + */ +async function transactionRefusal( + input: RestoreBackupInput, + ownerId: string, + transactionTimeoutMs: number, + err: unknown, +): Promise { + const verbose = err instanceof Error ? err.message : String(err); + const classified = classifyRestoreFailure(err); + await auditLog("admin.backups.restore.failed", { + userId: input.actorUserId, + ipAddress: input.ipAddress, + details: { + backupId: input.backup.id, + ownerId, + reason: "transaction_failed", + cause: classified.cause, + code: classified.code, + message: verbose, + }, + }); + annotate({ + meta: { + restoreFailReason: verbose, + restoreFailCause: classified.cause, + restoreFailCode: classified.code, + restoreTransactionTimeoutMs: transactionTimeoutMs, + }, + }); + if (err instanceof ForeignReferenceError) { + return refused(422, "backup.foreign_reference", err.message, { + errorCode: "backup.foreign_reference", + references: err.edges, }); - // Bad stored input, not a broken server: a copy written under a key the - // operator has since dropped, or one whose bytes have changed. Refused - // above the transaction, so nothing was touched. + } + if (isStoredBackupReadError(err)) { + // The second read, inside the transaction, found the copy replaced or + // altered. The transaction rolled back, so nothing was changed. const refusal = storedBackupRefusal(err); return refused(refusal.status, refusal.code, refusal.message, { errorCode: refusal.code, }); } - - // Parsed once and kept, because the schema's per-section `.default([])` - // erases the difference between a section that is absent and one that is - // empty — and that difference is what the completeness check below reads. - // - // The measurements are the exception: `readStreamedBackup` checks each one - // against the element schema as it goes and keeps only their count, and - // the transaction reads them a second time, in batches, as it writes them. - // v1.42 — the live `stats:` ids of the folded types, gathered on the first - // read, so the measurement pass can tell a compaction tombstone from a - // person's deletion without a third read of the file. Keyed by source as - // well: Health Connect folds into the same `stats:` ids as Apple Health, and - // an Apple Health hour must not make a Health Connect deletion look folded. - const liveFoldStatsIds = new Set(); - const foldedTypes = new Set(FOLDED_TYPES); - const foldSources = new Set(["APPLE_HEALTH", "HEALTH_CONNECT"]); - const foldStatsKey = (source: string, type: string, externalId: string) => - `${source}|${type}|${externalId}`; - const noteLiveFoldRow = (row: BackupMeasurement) => { - if ( - row.deletedAt == null && - row.source !== undefined && - foldSources.has(row.source) && - foldedTypes.has(row.type) && - row.externalId?.startsWith("stats:") - ) { - liveFoldStatsIds.add(foldStatsKey(row.source, row.type, row.externalId)); - } - }; - // Migration 0420 refiled Fitbit and Google Health HRV as `HRV_RMSSD`: both - // providers report a nightly RMSSD, which their mappers had written as the - // SDNN series. A file written before that release still carries those rows - // as `HEART_RATE_VARIABILITY`, and restoring it verbatim would undo the - // migration for the account. The restore applies the migration's rule: - // - // - a legacy row moves to RMSSD, unless a LIVE RMSSD row of its source - // holds the same night under either unique identity (external id, or - // measured-at and sleep stage). Then the twin holds the night and the - // legacy row is not written under either type: as RMSSD it would break - // the unique key, as SDNN it would put RMSSD back into the SDNN series; - // - a legacy row whose only twin is a tombstone wins: the tombstone is the - // row left out, since it shares the key and holds nothing; - // - a legacy tombstone with any RMSSD twin is left out. - // - // The keys are gathered on the first read. - const rmssdLiveKeys = new Set(); - const rmssdAnyKeys = new Set(); - const legacyLiveKeys = new Set(); - const noteRmssdTwin = (row: BackupMeasurement) => { - if (isLegacyRmssdRow(row)) { - if (row.deletedAt == null) { - for (const key of rmssdTwinKeysOf(row)) legacyLiveKeys.add(key); - } - return; - } - if ( - row.type === "HRV_RMSSD" && - row.source !== undefined && - LEGACY_RMSSD_SOURCES.has(row.source) - ) { - for (const key of rmssdTwinKeysOf(row)) { - rmssdAnyKeys.add(key); - if (row.deletedAt == null) rmssdLiveKeys.add(key); - } - } - }; - const anyKeyIn = (row: BackupMeasurement, keys: ReadonlySet) => - rmssdTwinKeysOf(row).some((key) => keys.has(key)); - const isShadowedLegacyRmssd = (row: BackupMeasurement): boolean => { - if (isLegacyRmssdRow(row)) { - return anyKeyIn( - row, - row.deletedAt == null ? rmssdLiveKeys : rmssdAnyKeys, - ); - } - // An RMSSD tombstone on the night a live legacy row is about to take. - return ( - row.type === "HRV_RMSSD" && - row.deletedAt != null && - row.source !== undefined && - LEGACY_RMSSD_SOURCES.has(row.source) && - anyKeyIn(row, legacyLiveKeys) && - !anyKeyIn(row, rmssdLiveKeys) - ); - }; - // Set inside the transaction when the restore takes the account's fold - // repair marker away; the repair is queued once the transaction commits. - let foldRepairReset = false; - let raw: unknown; - let payload; - let streamed: StreamedBackup; - try { - streamed = await readStreamedBackup(source, { - onMeasurementChecked: (checked, row) => { - progress.measurementsChecked = checked; - report("validating"); - noteLiveFoldRow(row); - noteRmssdTwin(row); - }, - }); - progress.measurementsTotal = streamed.measurementCount; - report("validating", true); - raw = streamed.raw; - // Loaded here, not at import: the schema is ~12 MB of heap and a server - // that never validates a backup should not hold it (`backup-summary.ts`). - const { parseBackupPayload } = await import("@/lib/validations/backup"); - payload = parseBackupPayload(raw); - } catch (err) { - const readFailure = isStoredBackupReadError(err); - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId: backup.userId, - reason: readFailure ? "read_failed" : "schema_invalid", - message: err instanceof Error ? err.message : String(err), - }, - }); - if (readFailure) { - // The copy changed after it was opened: replaced by the weekly run, or - // altered. Nothing has been deleted yet. - const refusal = storedBackupRefusal(err); - return refused(refusal.status, refusal.code, refusal.message, { - errorCode: refusal.code, - }); - } - return refused( - 422, - "schema_invalid", - "Backup payload failed schema validation", - ); - } - - if (!isCompatibleSchemaVersion(payload.schemaVersion)) { - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId: backup.userId, - reason: "incompatible_schema_version", - schemaVersion: payload.schemaVersion, - }, - }); - return refused( - 422, - "incompatible_schema_version", - `Backup schema version '${payload.schemaVersion}' is not supported by this server`, - ); - } - - // The payload declares its own owner, and the backup ROW records who the - // backup was taken for. Those must agree. Taking the owner from the - // payload alone means the restored-into account is whatever the blob - // claims — so an admin who selects one user's backup could write into a - // different account without the interface ever showing it. Both values are - // already in hand here; refuse on mismatch rather than trusting the blob. - if (payload.userId !== backup.userId) { - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId: backup.userId, - reason: "owner_mismatch", - declaredOwnerId: payload.userId, - }, - }); - return refused( - 409, - "owner_mismatch", - "Backup payload declares a different owner than the backup record", - ); - } - - // The target of the restore is whoever the backup is for, NOT the - // admin running the operation. Make sure that user still exists — - // an upload referencing a since-deleted user would otherwise leave - // the operation half-done. - const ownerId = payload.userId; - const owner = await prisma.user.findUnique({ - where: { id: ownerId }, - select: { id: true, username: true }, - }); - if (!owner) { - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId, - reason: "owner_not_found", - }, - }); - return refused( - 422, - "owner_not_found", - `Backup owner '${ownerId}' no longer exists in this DB`, - ); - } - - // The ciphertext inside the file is written back verbatim, so every key it - // was written under has to be on this host, and has to be the key that - // wrote it. A copy taken before a key rotation still needs the key the - // rotation retired: the rotation re-seals the stored copy's envelope but - // cannot reach inside it. Refused here, above the first delete, because the - // alternative is an account whose notes, documents and coach history come - // back as rows no reader can open. The instance settings count only when - // the operator asked for them back. - const keyVerdict = assessBackupKeys(streamed.keys, { - ignoreSections: restoreInstanceSettings - ? new Set() - : new Set(["appSettings"]), - }); - const keyProblem = describeBackupKeyProblem(keyVerdict); - if (keyProblem) { - const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable]; - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId, - reason: "key_missing", - missingKeyIds: keyVerdict.missing, - unreadableKeyIds: keyVerdict.unreadable, - affectedValues: keyVerdict.affectedValues, - }, - }); - return refused(422, BACKUP_KEY_MISSING_CODE, keyProblem, { - errorCode: BACKUP_KEY_MISSING_CODE, - keyIds, - }); - } - - // Audit the *intent* before the transaction begins so the trail - // describing "an admin is about to restore " survives even - // if the operation crashes midway. - await auditLog("admin.backups.restore.start", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId, - ownerUsername: owner.username, - snapshotExportedAt: payload.exportedAt, - restoreInstanceSettings, - }, - }); - - // A section the file's own manifest says it carries, and does not. - // - // The restore's first act is to delete the class it is about to rebuild, so - // an absent section is not a gap in the file — it is an erasure of that part - // of the account, reported as a success. #237: a portable file whose - // document payload was gone emptied the vault and said nothing. The whole - // file is refused instead, above the transaction, for the same reason the - // metadata-only check below sits there. - // - // A DECLARED omission is not this. The portable export leaves the screener - // administrations and the consent receipts out on purpose and says so in the - // manifest; `findMissingBackupSections` reads the manifest and not the - // emptiness of an array, so those files stay restorable. So does every file - // written before the manifest existed, which declares nothing at all. - const missingSections = findMissingBackupSections(raw); - if (missingSections.length > 0) { - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId, - reason: "section_missing", - sections: missingSections, - }, - }); - return refused( - 422, - "backup.section.missing", - `Backup is missing ${missingSections.join(", ")} — the file's own manifest says it carries ${missingSections.length === 1 ? "that section" : "those sections"}. Nothing was changed. Use a disaster-recovery snapshot, or export the account again.`, - { errorCode: "backup.section.missing", sections: missingSections }, - ); - } - - // Portable exports intentionally omit document ciphertext. They remain - // valid upload/download artifacts, but cannot be used to manufacture an - // InboundDocument row. A DR restore must fail before any delete rather - // than inventing empty/plaintext content. - const incompleteDocument = payload.documents.find( - (document) => - document.contentEncrypted === undefined || - document.contentSha256 === undefined || - document.contentCodec === undefined || - document.providerType === undefined || - document.reportDate === undefined || - document.documentDate === undefined || - document.errorReason === undefined || - document.summaryEncrypted === undefined || - document.summaryGeneratedAt === undefined || - document.summaryState === undefined || - document.createdAt === undefined || - document.updatedAt === undefined, + // The wide event and the audit row carry the whole error. The job's row is + // read by whoever started the restore, so it names the cause, never the + // database's message, which can quote the rows involved. + return refused( + 500, + "transaction_failed", + RESTORE_FAILURE_CAUSE_MESSAGES[classified.cause], + { cause: classified.cause }, ); - if (incompleteDocument) { - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId, - reason: "document_ciphertext_missing", - documentId: incompleteDocument.id, - }, - }); - return refused( - 422, - "document_ciphertext_missing", - `Document '${incompleteDocument.id}' is metadata-only and cannot be restored`, - ); - } +} - // The transaction's own limit grows with the measurements. When a caller - // has a deadline of its own (the background job's time budget) and that - // limit would run past it, the restore is refused here, above the first - // delete, rather than started and cut off by the queue. - // Counted here, before the transaction, because the delete costs time in - // proportion to what the account holds, deleted rows included. - const currentMeasurements = await prisma.measurement.count({ - where: { userId: ownerId }, - }); - const transactionTimeoutMs = restoreTransactionTimeoutMs( - streamed.measurementCount, - currentMeasurements, - ); - if ( - input.deadline !== undefined && - Date.now() + transactionTimeoutMs + RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS > - input.deadline - ) { - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId, - reason: "time_budget", - measurements: streamed.measurementCount, - currentMeasurements, - }, - }); - // Both counts, because both cost time: the account's readings are deleted - // (removed ones included, until the nightly clean-up purges them) before - // the file's are written. - return refused( - 503, - "time_budget", - `Restoring ${readingsPhrase(streamed.measurementCount)} in the backup over the ${readingsPhrase(currentMeasurements)} this account holds now (removed readings count until the nightly clean-up) needs more time than the restore job allows. Nothing was changed.`, - ); - } - - let outcome: { - expiredTombstonesSkipped: number; - cleared: RestoreResponse["cleared"]; - skipped: RestoreSkipSummary; - accountSettings: AccountSettingsRestoreResult; - }; +/** Best-effort work after the commit; a failure is noted, never thrown. */ +async function bestEffort( + work: () => Promise, + failed: string, + error: string, +): Promise { try { - report("clearing"); - outcome = await prisma.$transaction( - async (tx) => { - // Every connection carries the request limits from `src/lib/db.ts` - // (60 s per statement, 60 s idle inside a transaction). A restore is - // not a request: deleting two million readings is one statement, and - // on a slow disk it ran past 60 s, was cancelled, and the whole - // restore rolled back in its clearing step (#1031). The transaction - // has its own limit; SET LOCAL gives each statement the same one, and - // ends with the transaction. - await tx.$executeRaw`SELECT set_config('statement_timeout', ${String(transactionTimeoutMs)}, true), set_config('idle_in_transaction_session_timeout', ${String(transactionTimeoutMs)}, true)`; - // One restore of an account at a time, whatever let a second one - // start: the job table admits one queued or running job per account, - // and this lock holds even if a worker that was thought gone is - // still writing. A second transaction waits here for the first to - // commit or roll back, then replaces the account again from its own - // file, which is what it was asked to do. - // - // The same lock orders the restore against the background passes that - // rewrite readings a day at a time (`restore-lock.ts`): they hold it - // shared for each day, so the delete below never meets a day half - // folded, which is where the two used to deadlock. - await takeRestoreLock(tx, ownerId); - // Every reference this restore writes stays inside the account: the - // references that leave it are counted now and again after the last - // write, and any that grew rolls the whole transaction back - // (`tenant-boundary.ts` says why the check is derived from the schema - // rather than written per section). - const tenantEdges = await listTenantEdges(tx); - const foreignBefore = await countForeignReferences( - tx, - ownerId, - tenantEdges, - ); - // Declared INSIDE the transaction so a rollback takes the report with - // it. An accumulator that outlived a failed attempt would carry drops - // that never happened into the next one. - const skips: RestoreSkipLog = []; - - // Delete every serialized owner-scoped partition before rebuilding it - // in the same transaction. Child rows either go first for counts or - // cascade from their serialized parent. - const intake = await tx.medicationIntakeEvent.deleteMany({ - where: { userId: ownerId }, - }); - const meds = await tx.medication.deleteMany({ - where: { userId: ownerId }, - }); - const measurements = await tx.measurement.deleteMany({ - where: { userId: ownerId }, - }); - const moods = await tx.moodEntry.deleteMany({ - where: { userId: ownerId }, - }); - // v1.4.39 — wipe the persisted mood rollup partition - // for this owner so the next analytics read doesn't surface - // pre-restore daily means. The fold below mints fresh rows - // from the restored mood entries. - await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } }); - // v1.4.39.1 — wipe the persisted measurement rollup partition - // for the same reason. Pre-fix the partition kept the previous - // owner's daily means even after the underlying measurements - // were replaced — the chart's `source=rollup` fast-path could - // surface a stale 30-day mean built from rows that no longer - // existed. The fold below mints fresh rows from the restored - // measurement set. - await tx.measurementRollup.deleteMany({ where: { userId: ownerId } }); - const channels = await tx.notificationChannel.deleteMany({ - where: { userId: ownerId }, - }); - const subs = await tx.pushSubscription.deleteMany({ - where: { userId: ownerId }, - }); - const tgDel = await tx.telegramScheduledDeletion.deleteMany({ - where: { userId: ownerId }, - }); - const documents = await tx.inboundDocument.deleteMany({ - where: { userId: ownerId }, - }); - const workouts = await tx.workout.deleteMany({ - where: { userId: ownerId }, - }); - const familyHistory = await tx.familyHistoryEntry.deleteMany({ - where: { userId: ownerId }, - }); - const allergies = await tx.allergy.deleteMany({ - where: { userId: ownerId }, - }); - const illnessEpisodes = await tx.illnessEpisode.deleteMany({ - where: { userId: ownerId }, - }); - const labResults = await tx.labResult.deleteMany({ - where: { userId: ownerId }, - }); - const biomarkers = await tx.biomarker.deleteMany({ - where: { userId: ownerId }, - }); - // The export has written nutrient day totals since v1.29 and nothing - // here ever read them back: water and vitamin history was carried in - // the file, discarded on restore, and the restore reported success. - const nutrientDays = await tx.nutrientIntakeDay.deleteMany({ - where: { userId: ownerId }, - }); - - // The one section of the file that is not this account's. - // - // A disaster-recovery payload carries the singleton `app_settings` - // row — registration, the MFA requirement, the default locale and - // timezone, module availability, the notification and AI - // configuration, the document size cap and quota. Writing it back as - // a side effect of restoring ONE account reconfigures the host for - // everybody on it, silently and hours or weeks out of date: an - // operator putting a single record back on Wednesday has no reason to - // expect Monday's registration switch and Monday's upload cap to come - // with it. Rebuilding a host from a snapshot is a real case, so the - // write stays available — as an answer the operator gives, not one - // the account restore assumes. - if (payload.appSettings && restoreInstanceSettings) { - const settings = payload.appSettings; - // A file from before 0343 carries the Coach's operator answer in - // module availability; carry it into the Coach switch. - const coachFold = foldLegacyCoachAvailability( - settings.moduleAvailabilityJson, - settings.assistantCoachEnabled, - ); - const settingsData = { - registrationEnabled: settings.registrationEnabled, - mfaRequired: settings.mfaRequired, - defaultLocale: settings.defaultLocale, - telegramGlobal: settings.telegramGlobal, - ntfyGlobal: settings.ntfyGlobal, - webPushGlobal: settings.webPushGlobal, - webPushVapidPublicKey: settings.webPushVapidPublicKey, - webPushVapidPrivateKeyEncrypted: - settings.webPushVapidPrivateKeyEncrypted, - webPushVapidSubject: settings.webPushVapidSubject, - apiGlobal: settings.apiGlobal, - umamiEnabled: settings.umamiEnabled, - umamiScriptUrl: settings.umamiScriptUrl, - umamiWebsiteId: settings.umamiWebsiteId, - glitchtipEnabled: settings.glitchtipEnabled, - glitchtipDsn: settings.glitchtipDsn, - glitchtipEnvironment: settings.glitchtipEnvironment, - reminderLateMinutes: settings.reminderLateMinutes, - reminderMissedMinutes: settings.reminderMissedMinutes, - adminAiKeyEncrypted: settings.adminAiKeyEncrypted, - adminAiModel: settings.adminAiModel, - adminAiBaseUrl: settings.adminAiBaseUrl, - adminCodexAccessTokenEncrypted: - settings.adminCodexAccessTokenEncrypted, - adminCodexRefreshTokenEncrypted: - settings.adminCodexRefreshTokenEncrypted, - adminCodexAccountIdEncrypted: settings.adminCodexAccountIdEncrypted, - adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt - ? new Date(settings.adminCodexTokenExpiresAt) - : null, - adminCodexConnectedAt: settings.adminCodexConnectedAt - ? new Date(settings.adminCodexConnectedAt) - : null, - adminCodexConnectionStatus: settings.adminCodexConnectionStatus, - adminAiInsightsFeedbackSummary: - settings.adminAiInsightsFeedbackSummary as never, - defaultUserTimezone: settings.defaultUserTimezone, - assistantEnabled: settings.assistantEnabled, - assistantCoachEnabled: coachFold.assistantCoachEnabled, - assistantBriefingEnabled: settings.assistantBriefingEnabled, - assistantInsightStatusEnabled: - settings.assistantInsightStatusEnabled, - assistantDocumentAiEnabled: - settings.assistantDocumentAiEnabled ?? true, - // v1.41 — absent on a file written before the controls existed, - // which restores them at their defaults: allowed, no cap. - aiReasoningEnabled: settings.aiReasoningEnabled ?? true, - aiReasoningMaxEffort: - settings.aiReasoningMaxEffort ?? DEFAULT_REASONING_MAX_EFFORT, - moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never, - documentMaxFileBytes: settings.documentMaxFileBytes, - documentQuotaBytes: BigInt(settings.documentQuotaBytes), - }; - await tx.appSettings.upsert({ - where: { id: settings.id }, - create: { id: settings.id, ...settingsData }, - update: settingsData, - }); - } - - const toRestoredMeasurementData = (measurement: BackupMeasurement) => ({ - type: isLegacyRmssdRow(measurement) - ? ("HRV_RMSSD" as const) - : measurement.type, - value: measurement.value, - valueMin: measurement.valueMin ?? null, - valueMax: measurement.valueMax ?? null, - unit: measurement.unit, - source: measurement.source ?? "MANUAL", - measuredAt: new Date(measurement.measuredAt), - notes: null, - notesEncrypted: - measurement.notesEncrypted == null - ? encryptNote(measurement.notes ?? null) - : decodeEncryptedBytes(measurement.notesEncrypted), - externalId: measurement.externalId ?? null, - externalSourceVersion: measurement.externalSourceVersion ?? null, - // Authority of an Apple Health `stats:` aggregate. A backup - // written before this field rode the payload carries nothing, and - // restoring NULL would flatten the authority ladder: an export.xml - // source-day estimate could then overwrite a native HealthKit - // statistic, which is the ambiguity migration 0263 already - // repaired once. Such a row is exactly the case LEGACY_UNKNOWN - // exists for, so it comes back repairable instead of ambiguous. - // - // The gate is scoped exactly as 0263 scoped its backfill and as - // the cumulative drain scopes its own LEGACY_UNKNOWN writes: - // Apple Health rows only. Other providers mint `stats:` ids for - // their cumulative dailies too (Fitbit, Google Health) and have - // never carried provenance — marking those would invent a state - // they were never in. Ordinary point rows keep NULL honestly. - // - // Only an ABSENT field is the old-backup case. A current backup - // writes the field for every row, and an explicit null is the row's - // real state: the nightly daily-mean fold writes Apple Health - // `stats:` rows with no provenance. Treating that null as absent - // turned every such row into LEGACY_UNKNOWN on restore (#1031). - aggregationProvenance: (measurement.aggregationProvenance !== - undefined - ? measurement.aggregationProvenance - : measurement.source === "APPLE_HEALTH" && - measurement.externalId?.startsWith("stats:") - ? "LEGACY_UNKNOWN" - : null) as never, - glucoseContext: (measurement.glucoseContext ?? null) as never, - sleepStage: (measurement.sleepStage ?? null) as never, - rhythmClassification: (measurement.rhythmClassification ?? - null) as never, - deviceType: measurement.deviceType ?? null, - syncVersion: measurement.syncVersion ?? 1, - deletedAt: measurement.deletedAt - ? new Date(measurement.deletedAt) - : null, - ...(measurement.createdAt - ? { createdAt: new Date(measurement.createdAt) } - : {}), - ...(measurement.updatedAt - ? { updatedAt: new Date(measurement.updatedAt) } - : {}), - }); - - // The measurements, a batch at a time from a second read of the - // file, so the restore never holds more than one batch of them. - // - // Two later sections point INTO the measurements (a record's - // `sourceMeasurementId`, an ECG strip's `measurementId`) and have - // to know which of those ids this transaction actually wrote. They - // used to be handed every written id; they only ever ask about the - // ones they reference, so only those are collected. - const referencedMeasurementIds = new Set(); - for (const record of payload.personalRecords) { - if (record.sourceMeasurementId) { - referencedMeasurementIds.add(record.sourceMeasurementId); - } - } - for (const recording of payload.ecgRecordings) { - if (recording.measurementId) { - referencedMeasurementIds.add(recording.measurementId); - } - } - const writtenReferencedIds = new Set(); - const measurementBatchSize = 1_000; - // Deleted readings the file carries are restored as deleted rows - // (tombstones), with one exception: a tombstone deleted longer ago - // than `TOMBSTONE_RETENTION_DAYS` is not written at all. - // - // Such a row is exactly what the nightly tombstone purge - // (`measurement-tombstone-cleanup.ts`) hard-deletes, so writing it - // costs the restore a row in thirteen indexes for a row that is gone - // by the next morning. Nothing reads it in between: the delta feed - // answers `cursorExpired` for every cursor older than the same - // horizon, so no client that would still be served this tombstone - // exists; the step-consolidation repair and the hourly rebuild only - // look inside the horizon; and the undo of a delete is offered for - // what the purge has not removed yet, which this row would not - // survive either. - // - // Younger tombstones are written. They still have readers: a paired - // client that has not yet drained that deletion from the delta feed, - // the undo of a recent delete, the consolidation repairs that rebuild - // from folded samples, and the unique `(user, type, source, - // externalId)` key that stops a device from uploading a folded - // sample again. The backup file itself is unchanged and keeps every - // row. - const tombstoneHorizonMs = - Date.now() - TOMBSTONE_RETENTION_DAYS * 86_400_000; - // - // v1.42 — nor is a compaction tombstone, whatever its age: a raw - // sample the folds soft-deleted before v1.42, whose whole hour or day - // the fold had reached when it was deleted and which a live `stats:` - // row of the same source in the same file covers. The folds - // delete those outright now and the backlog purge removes the old - // ones, so writing one back would only hand the purge the same row - // again; the `folded_window` ingest guard is what keeps the sample - // from being uploaded again. Classified by the rule the purge and - // the sync feed use (`folded-window.ts`), against the `stats:` ids - // gathered on the file's first read and the zone the account will - // have after the restore. Counted with the expired ones. - const foldTz = resolveUserTimezone( - payload.accountSettings?.timezone ?? - ( - await tx.user.findUnique({ - where: { id: ownerId }, - select: { timezone: true }, - }) - )?.timezone ?? - null, - ); - // Only once the fold repair has been through the means this file - // holds. The repair recomputes a mean an older release folded from - // part of its day, and these tombstones are what it reads. A file - // exported after the account's repair finished carries repaired - // means, so its compaction tombstones can go. Any other file is - // written back whole, the account's repair marker is removed, and the - // repair runs again once the transaction commits; the purge then - // removes the tombstones as usual. - const foldRepair = await tx.measurementFoldRepair.findUnique({ - where: { userId: ownerId }, - select: { completedAt: true }, - }); - const dropCompactionTombstones = - foldRepair !== null && - new Date(payload.exportedAt).getTime() > - foldRepair.completedAt.getTime(); - if (!dropCompactionTombstones) { - await tx.measurementFoldRepair.deleteMany({ - where: { userId: ownerId }, - }); - foldRepairReset = true; - } - const isCompactionTombstone = (measurement: BackupMeasurement) => { - if (!dropCompactionTombstones) return false; - if (measurement.deletedAt == null) return false; - const source = measurement.source ?? "MANUAL"; - const covering = compactionTombstoneCoverId( - { - type: measurement.type, - source, - externalId: measurement.externalId ?? null, - measuredAt: new Date(measurement.measuredAt), - deletedAt: new Date(measurement.deletedAt), - }, - foldTz, - ); - return ( - covering !== null && - liveFoldStatsIds.has( - foldStatsKey(source, measurement.type, covering), - ) - ); - }; - const isExpiredTombstone = (measurement: BackupMeasurement) => - measurement.deletedAt != null && - (new Date(measurement.deletedAt).getTime() < tombstoneHorizonMs || - isCompactionTombstone(measurement)); - let expiredTombstonesSkipped = 0; - report("measurements"); - await streamed.forEachMeasurementBatch( - measurementBatchSize, - async (fileBatch) => { - const kept = fileBatch.filter( - (measurement) => !isExpiredTombstone(measurement), - ); - expiredTombstonesSkipped += fileBatch.length - kept.length; - const batch = kept.filter( - (measurement) => !isShadowedLegacyRmssd(measurement), - ); - const stableRows = batch.flatMap( - (measurement): MeasurementInsertRow[] => - measurement.id - ? [ - { - id: measurement.id, - userId: ownerId, - ...toRestoredMeasurementData(measurement), - }, - ] - : [], - ); - // One statement per batch, not `createMany`: see - // `insertMeasurementRows` for what the latter cost here. - await insertMeasurementRows(tx, stableRows); - // Counted through the file, so the bar ends at the file's total. - progress.measurementsWritten += fileBatch.length; - report("measurements"); - for (const row of stableRows) { - if (referencedMeasurementIds.has(row.id)) { - writtenReferencedIds.add(row.id); - } - } - - // v1 payloads did not require stable ids. Preserve their - // historical natural-key reconciliation without routing - // canonical v2 rows through it. - for (const measurement of batch) { - if (measurement.id) continue; - const restoredData = toRestoredMeasurementData(measurement); - const existing = await tx.measurement.findFirst({ - where: { - userId: ownerId, - type: measurement.type, - source: restoredData.source, - measuredAt: restoredData.measuredAt, - sleepStage: restoredData.sleepStage, - }, - select: { id: true }, - }); - if (existing) { - await tx.measurement.update({ - where: { id: existing.id, userId: ownerId }, - data: restoredData, - }); - } else { - await tx.measurement.create({ - data: { userId: ownerId, ...restoredData }, - }); - } - } - }, - ); - - const medByName = new Map(); - const restoredMedicationIds = new Set(); - // The id each medication had in the source account, to the id it got - // here. A disaster-recovery file keeps the id, so this is the - // identity there; a portable file names the source row only through - // `ref`, and the relation links (written last) are re-pointed through - // this map instead of being dropped as "end is gone". - const medicationIdBySourceId = new Map(); - // The efficacy targets cannot ride inside the medication create - // beside the schedules and the packs: `biomarkerId` is a foreign key - // into a catalogue this transaction has not restored yet. They are - // collected here against the id the drug ACTUALLY got and written in - // a second pass once the biomarkers exist; the pass itself says why - // hoisting the biomarker restore above the medications is the worse - // of the two orderings. - const pendingEfficacyTargets: Array<{ - medicationId: string; - target: (typeof payload.medications)[number]["efficacyTargets"][number]; - }> = []; - // Supersede pointers the file states and the restore cannot honour. - // Reported as JSON paths into the file rather than as ids: the file - // is where an operator has to go to see what the position meant, and - // an id would be one the restore never wrote. - const unresolvedRevisionLinks: string[] = []; - // v1.40 (#1041) — the account's own medication categories, before - // the medications that name them by key. Delete-then-recreate like - // the medications themselves. A key held by another account on this - // instance (a portable file from elsewhere) is re-minted, and the - // medications below are mapped onto the new key. - await tx.medicationCategoryLabel.deleteMany({ - where: { userId: ownerId }, - }); - const categoryKeyRemap = new Map(); - for (const c of payload.customMedicationCategories) { - const taken = await tx.medicationCategoryLabel.findUnique({ - where: { key: c.key }, - select: { id: true }, - }); - const key = taken ? mintCustomMedicationCategoryKey() : c.key; - if (key !== c.key) categoryKeyRemap.set(c.key, key); - await tx.medicationCategoryLabel.create({ - data: { - userId: ownerId, - key, - labelEncrypted: encryptCategoryLabel(c.label), - sortOrder: c.sortOrder, - isActive: c.isActive, - ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), - }, - }); - } - - // v1.40 (#1024) — the zone a derived course's creation day is read - // in: the file's own account settings, else the account's stored one. - const restoreTz = validTimezoneOr( - payload.accountSettings?.timezone ?? - ( - await tx.user.findUnique({ - where: { id: ownerId }, - select: { timezone: true }, - }) - )?.timezone, - DEFAULT_TIMEZONE, - ); - let medicationIndex = 0; - reportSection("medications"); - const restoreStartedAt = new Date(); - for (const m of payload.medications) { - const created = await tx.medication.create({ - data: { - ...(m.id ? { id: m.id } : {}), - userId: ownerId, - name: m.name, - dose: m.dose, - treatmentClass: m.treatmentClass ?? "GENERIC", - dosesPerUnit: m.dosesPerUnit ?? null, - unitsPerDose: m.unitsPerDose ?? "1", - active: m.active ?? true, - notificationsEnabled: m.notificationsEnabled ?? true, - pausedAt: m.pausedAt ? new Date(m.pausedAt) : null, - snoozedUntil: m.snoozedUntil ? new Date(m.snoozedUntil) : null, - startsOn: m.startsOn ? new Date(m.startsOn) : null, - endsOn: m.endsOn ? new Date(m.endsOn) : null, - oneShot: m.oneShot ?? false, - asNeeded: m.asNeeded ?? false, - trackIntake: m.trackIntake ?? true, - // v1.42.2 — an option that is on always carries its floor, and - // a restored floor is never before the restore: a portable file - // carries no removed doses, so a dose the person deleted in the - // days before the export would otherwise be filled again as - // taken by the next auto-take pass. - autoMarkTaken: m.autoMarkTaken ?? false, - autoMarkTakenSince: m.autoMarkTaken - ? new Date( - Math.max( - m.autoMarkTakenSince - ? Date.parse(m.autoMarkTakenSince) - : 0, - restoreStartedAt.getTime(), - ), - ) - : null, - deliveryForm: m.deliveryForm ?? "ORAL", - trackInjectionSites: m.trackInjectionSites ?? false, - allowedInjectionSites: m.allowedInjectionSites ?? [], - liveActivityEnabled: m.liveActivityEnabled ?? false, - criticalAlarmEnabled: m.criticalAlarmEnabled ?? false, - atcCode: m.atcCode ?? null, - rxNormCode: m.rxNormCode ?? null, - lowStockNotifiedAt: m.lowStockNotifiedAt - ? new Date(m.lowStockNotifiedAt) - : null, - lowStockNotifiedThresholdDays: - m.lowStockNotifiedThresholdDays ?? null, - reorderLeadDays: m.reorderLeadDays ?? null, - externalSource: m.externalSource ?? null, - externalId: m.externalId ?? null, - // A file from before v1.39.1 carries no creation date, and the - // restore time would hide every dose-history miss before it. - // The earliest thing the file records for the drug stands in. - ...(() => { - const createdAt = restoredMedicationCreatedAt( - m, - payload.intakeEvents, - restoreStartedAt, - ); - return createdAt ? { createdAt } : {}; - })(), - ...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}), - schedules: { - create: m.schedules.map((s) => ({ - ...(s.id ? { id: s.id } : {}), - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label ?? null, - dose: s.dose ?? null, - // #219 — per-schedule units per dose. Prisma coerces the - // Decimal string; NULL / absent stays NULL (inherit). - unitsPerDose: s.unitsPerDose ?? null, - daysOfWeek: s.daysOfWeek ?? null, - timesOfDay: s.timesOfDay ?? [], - reminderGraceMinutes: s.reminderGraceMinutes ?? null, - rrule: s.rrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - scheduleType: s.scheduleType ?? "SCHEDULED", - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - doseWindows: (s.doseWindows ?? null) as never, - })), - }, - // Written in the same `create` as the drug, so the FK binds to - // the id this row actually got. A canonical DR file preserves - // the medication's id and a portable one mints a fresh cuid; - // neither case needs the old `medicationId` from the file, - // which is why the payload does not carry it. The note follows - // the measurement contract next to it: ciphertext verbatim when - // the file has it, legacy plaintext encrypted on the way in, - // and the plaintext column left null either way. - sideEffects: { - create: m.sideEffects.map((s) => ({ - ...(s.id ? { id: s.id } : {}), - userId: ownerId, - occurredAt: new Date(s.occurredAt), - category: s.category, - entry: s.entry, - severity: s.severity, - notes: null, - notesEncrypted: - s.notesEncrypted == null - ? encryptNote(s.notes ?? null) - : decodeEncryptedBytes(s.notesEncrypted), - ...(s.createdAt ? { createdAt: new Date(s.createdAt) } : {}), - })), - }, - // Nested for the same reason as the two above — the FK binds - // to the id this drug actually got, so a portable file that - // mints fresh ids needs no remap. - // - // `resumedAt` passes through as null when the era is open. It - // is not defaulted to the restore time: a pause that was still - // running when the backup was taken is still running after the - // restore, and closing it here would silently convert an - // ongoing decision into a historical one. - // v1.43 — the composition, nested so it binds to the id the - // drug actually got. Restored as written; the SUPPLEMENT - // nutrient rows it produced travel in the nutrient section. - ingredients: { - create: m.ingredients.map((i) => ({ - ...(i.id ? { id: i.id } : {}), - userId: ownerId, - nutrient: i.nutrient, - amountPerUnit: i.amountPerUnit, - unit: i.unit, - enteredAmount: i.enteredAmount ?? null, - enteredUnit: i.enteredUnit ?? null, - source: i.source, - ...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}), - })), - }, - pauseEras: { - create: m.pauseEras.map((p) => ({ - ...(p.id ? { id: p.id } : {}), - userId: ownerId, - pausedAt: new Date(p.pausedAt), - resumedAt: p.resumedAt ? new Date(p.resumedAt) : null, - ...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}), - })), - }, - // The titration steps, nested for the same reason. The note - // takes the measurement contract next to it: ciphertext when - // the file has it, legacy plaintext encrypted on the way in, - // and the plaintext column left null either way. - doseChanges: { - create: m.doseChanges.map((d) => ({ - ...(d.id ? { id: d.id } : {}), - effectiveFrom: new Date(d.effectiveFrom), - doseValue: d.doseValue, - doseUnit: d.doseUnit, - note: null, - noteEncrypted: - d.noteEncrypted == null - ? encryptNote(d.note ?? null) - : decodeEncryptedBytes(d.noteEncrypted), - ...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}), - })), - }, - // The packs, and the ledger behind their counts. Nested for - // the same reason as everything above it, and `unitsRemaining` - // is written VERBATIM rather than recomputed from the events — - // it is what the server had resolved and what the person was - // shown, and recomputing here would silently correct a count - // they may have adjusted by hand. - inventoryItems: { - create: m.inventoryItems.map((i) => ({ - ...(i.id ? { id: i.id } : {}), - userId: ownerId, - state: (i.state ?? "ACTIVE") as never, - containerType: (i.containerType ?? "OTHER") as never, - unitsTotal: i.unitsTotal, - unitsRemaining: i.unitsRemaining, - firstUseAt: i.firstUseAt ? new Date(i.firstUseAt) : null, - expiresAt: i.expiresAt ? new Date(i.expiresAt) : null, - printedExpiry: i.printedExpiry - ? new Date(i.printedExpiry) - : null, - purchasedAt: i.purchasedAt ? new Date(i.purchasedAt) : null, - manufacturer: i.manufacturer ?? null, - doseStrength: i.doseStrength ?? null, - notes: null, - notesEncrypted: - i.notesEncrypted == null - ? encryptNote(i.notes ?? null) - : decodeEncryptedBytes(i.notesEncrypted), - ...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}), - ...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}), - })), - }, - // One row or none, so `create` on the optional side rather - // than a list. A file that says nothing leaves the drug - // untuned, which is what it was. - ...(m.phaseConfig - ? { - phaseConfig: { - create: { - ...(m.phaseConfig.id ? { id: m.phaseConfig.id } : {}), - ...(m.phaseConfig.greenValue !== undefined - ? { greenValue: m.phaseConfig.greenValue } - : {}), - ...(m.phaseConfig.greenMode - ? { greenMode: m.phaseConfig.greenMode } - : {}), - ...(m.phaseConfig.yellowValue !== undefined - ? { yellowValue: m.phaseConfig.yellowValue } - : {}), - ...(m.phaseConfig.yellowMode - ? { yellowMode: m.phaseConfig.yellowMode } - : {}), - ...(m.phaseConfig.orangeValue !== undefined - ? { orangeValue: m.phaseConfig.orangeValue } - : {}), - ...(m.phaseConfig.orangeMode - ? { orangeMode: m.phaseConfig.orangeMode } - : {}), - ...(m.phaseConfig.redValue !== undefined - ? { redValue: m.phaseConfig.redValue } - : {}), - ...(m.phaseConfig.redMode - ? { redMode: m.phaseConfig.redMode } - : {}), - }, - }, - } - : {}), - inventoryEvents: { - create: m.inventoryEvents.map((e) => ({ - ...(e.id ? { id: e.id } : {}), - delta: e.delta, - reason: e.reason, - occurredAt: new Date(e.occurredAt), - })), - }, - }, - }); - restoredMedicationIds.add(created.id); - const medicationSourceId = m.id ?? m.ref; - if (medicationSourceId) { - medicationIdBySourceId.set(medicationSourceId, created.id); - } - if (!medByName.has(m.name)) medByName.set(m.name, created.id); - // v1.39.4 — the clinical category lives in its own table keyed on the - // medication id, so it is written after the row exists and inside the - // same transaction. OTHER is what a missing row already reads as. - if (m.category && m.category !== "OTHER") { - await setMedicationCategory( - created.id, - categoryKeyRemap.get(m.category) ?? m.category, - tx, - ); - } - - // v1.40 (#1024) — the courses, then the window they project. A file - // written before courses existed carries none; the medication's own - // window then describes its one course, derived exactly as the - // migration's backfill did (an end-only window runs from creation, - // or from its end day when that lies earlier). - const courseRows: Array<{ - startsOn: Date; - endsOn: Date | null; - note: string | null; - createdAt?: Date; - }> = - m.courses !== undefined - ? m.courses.map((c) => ({ - startsOn: dateOfDayKey(c.startsOn), - endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null, - note: c.note ?? null, - ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), - })) - : (() => { - const derived = courseFromWindow( - created.startsOn, - created.endsOn, - userDayKey(created.createdAt, restoreTz), - ); - return derived ? [{ ...derived, note: null }] : []; - })(); - if (courseRows.length > 0) { - await tx.medicationCourse.createMany({ - data: courseRows.map((c) => ({ - medicationId: created.id, - userId: ownerId, - startsOn: c.startsOn, - endsOn: c.endsOn, - noteEncrypted: c.note ? encryptToBytes(c.note) : null, - ...(c.createdAt ? { createdAt: c.createdAt } : {}), - })), - }); - const window = projectCourseWindow(courseRows); - await tx.medication.update({ - where: { id: created.id }, - data: { startsOn: window.startsOn, endsOn: window.endsOn }, - }); - } - - // ── The archived schedule eras, in two passes ──────────────── - // - // An era carries `supersededByRevisionId`, the pointer from a - // corrected ARCHIVED row to the MANUAL one that replaced it. The - // column has no `@relation`, so a value addressing nothing costs - // no error — it just stops meaning anything, which is the more - // dangerous shape here, because every era consumer SKIPS a row - // that carries the pointer. Lose it and the superseded original - // goes live again beside its own correction: two overlapping eras - // for one window, and past days minted against a plan the account - // had already corrected. - // - // A portable restore mints fresh ids, so the pointer cannot travel - // as one. It travels as a POSITION in this drug's own ordered era - // list, because that list is the only thing both ends agree on: - // the builder sorts it totally (validFrom, then createdAt, then - // id) so two eras sharing an instant cannot swap places between - // two exports of the same account, and the pointer never crosses a - // drug — every route that writes it scopes the update to one - // medication — so the drug's own list is a complete address space. - // - // Pass one writes every era with the link NULL, because the row a - // link addresses is as often written after the row addressing it - // as before. Pass two patches the links once every position has an - // id. - const revisionIds: string[] = []; - for (const revision of m.scheduleRevisions) { - const row = await tx.medicationScheduleRevision.create({ - data: { - ...(revision.id ? { id: revision.id } : {}), - medicationId: created.id, - validFrom: new Date(revision.validFrom), - validUntil: new Date(revision.validUntil), - payload: toJson(revision.payload), - source: revision.source ?? "ARCHIVED", - supersededByRevisionId: null, - ...(revision.createdAt - ? { createdAt: new Date(revision.createdAt) } - : {}), - }, - select: { id: true }, - }); - revisionIds.push(row.id); - } - for (const [index, revision] of m.scheduleRevisions.entries()) { - const to = revision.supersededByIndex; - if (to === null || to === undefined) continue; - // A hand-edited file can name a position this drug does not - // have, or the era's OWN position — and a row that supersedes - // itself is skipped by every consumer, so the era would - // disappear from the timeline while still sitting in the table. - // Neither can be honoured and neither may be guessed at, so the - // link stays NULL and the position is reported. The era still - // restores: the window and the plan it held are the history, and - // the correction pointer is the smaller loss. - if ( - !Number.isInteger(to) || - to < 0 || - to >= revisionIds.length || - to === index - ) { - unresolvedRevisionLinks.push( - `medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`, - ); - continue; - } - await tx.medicationScheduleRevision.update({ - where: { id: revisionIds[index] }, - data: { supersededByRevisionId: revisionIds[to] }, - }); - } - - for (const target of m.efficacyTargets) { - pendingEfficacyTargets.push({ - medicationId: created.id, - target, - }); - } - medicationIndex += 1; - } - recordUnknownKeys( - skips, - "scheduleRevisionLink", - [...new Set(unresolvedRevisionLinks)], - unresolvedRevisionLinks, - ); - - if (payload.intakeEvents.length > 0) { - // An event whose medication did not come back used to be mapped to - // null and filtered away: the dose history came back short and the - // restore reported success. That is the same silent-drop shape the - // cycle symptom links had, and it gets the same answer — name the - // unresolved reference and stop, so the operator learns from the - // error rather than from a compliance rate that quietly moved. - const unresolvedMedications = [ - ...new Set( - payload.intakeEvents - .filter( - (e) => - !( - e.medicationId && - restoredMedicationIds.has(e.medicationId) - ) && !medByName.has(e.medication), - ) - .map((e) => e.medicationId ?? e.medication), - ), - ]; - if (unresolvedMedications.length > 0) { - throw new Error( - `Unknown medication references in intake events: ${unresolvedMedications.join( - ", ", - )}. The backup records doses against medications it does not carry.`, - ); - } - const rows = payload.intakeEvents - .map((e) => { - const medId = - e.medicationId && restoredMedicationIds.has(e.medicationId) - ? e.medicationId - : medByName.get(e.medication); - if (!medId) return null; - return { - ...(e.id ? { id: e.id } : {}), - userId: ownerId, - medicationId: medId, - scheduledFor: new Date(e.scheduledFor), - takenAt: e.takenAt ? new Date(e.takenAt) : null, - skipped: e.skipped ?? false, - autoMissed: e.autoMissed ?? false, - autoRecorded: e.autoRecorded ?? false, - attributionSource: e.attributionSource ?? "AUTO", - source: e.source ?? "WEB", - idempotencyKey: e.idempotencyKey ?? null, - ...(e.createdAt ? { createdAt: new Date(e.createdAt) } : {}), - injectionSite: e.injectionSite ?? null, - doseTaken: e.doseTaken ?? null, - inventoryConsumption: (e.inventoryConsumption ?? null) as never, - externalId: e.externalId ?? null, - ...(e.updatedAt ? { updatedAt: new Date(e.updatedAt) } : {}), - syncVersion: e.syncVersion ?? 0, - deletedAt: e.deletedAt ? new Date(e.deletedAt) : null, - }; - }) - .filter((r): r is NonNullable => r !== null); - if (rows.length > 0) { - await tx.medicationIntakeEvent.createMany({ data: rows }); - } - } - - reportSection("mood"); - // The account's own categories, tags and hidden set are written - // whether or not the file carries a mood entry: an account that set - // up its mood vocabulary before logging a single entry has them too, - // and they used to be written only inside the entries branch. - // The account's own CATEGORIES go in before its tags. A custom - // tag's `categoryId` is a real foreign key, so writing the tag - // first threw `mood_tags_category_id_fkey` and rolled the entire - // restore back — an account that had grouped its own mood factors - // could not be restored at all, and the failure named a constraint - // rather than the thing that was missing. - // - // Upserted by key for the same reason the tags below are: `key` is - // globally unique, so a seeded category that later claims the key - // must be left alone rather than overwritten by a restore. - // Labels this host's keys do not open, by file path. - const unopenedMoodLabels: string[] = []; - for (const category of payload.customMoodTagCategories) { - await tx.moodTagCategory.upsert({ - where: { key: category.key }, - create: { - id: category.id, - userId: ownerId, - key: category.key, - labelKey: category.labelKey, - icon: category.icon ?? null, - sortOrder: category.sortOrder ?? 0, - isActive: category.isActive ?? true, - labelEncrypted: sealForRestore( - category.labelEncrypted, - category.label, - `customMoodTagCategories.${category.key}.labelEncrypted`, - unopenedMoodLabels, - ), - }, - update: {}, - }); - } - - // Re-create the account's own tag definitions first. The lookup - // below used to ask only for the seeded catalogue (`userId: null`), - // so a single custom rated tag made the whole restore throw - // "Unknown mood factor keys" — the file was complete and unusable. - for (const tag of payload.customMoodTags) { - await tx.moodTag.upsert({ - where: { key: tag.key }, - create: { - ...(tag.id ? { id: tag.id } : {}), - userId: ownerId, - key: tag.key, - labelKey: tag.labelKey, - categoryId: tag.categoryId, - kind: tag.kind, - isActive: tag.isActive, - icon: tag.icon ?? null, - sortOrder: tag.sortOrder, - labelEncrypted: sealForRestore( - tag.labelEncrypted, - tag.label, - `customMoodTags.${tag.key}.labelEncrypted`, - unopenedMoodLabels, - ), - scaleMin: tag.scaleMin, - scaleMax: tag.scaleMax, - inverse: tag.inverse, - }, - // `key` is globally unique, so a seeded key would collide. - // Leave the catalogue row alone; the links resolve either way. - update: {}, - }); - } - recordUnknownKeys( - skips, - "moodLabelCiphertext", - unopenedMoodLabels, - unopenedMoodLabels, - ); - - // Which tags the account hid. Delete-then-recreate like every - // other section, and resolved by KEY: what people hide is almost - // always a seeded tag, whose id differs on every instance. - // - // A key this instance no longer knows costs one hidden row and is - // REPORTED. Dropping it silently would un-hide a factor the person - // had deliberately taken out of their mood editor, and they would - // have no way to know why it came back. - await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } }); - if (payload.hiddenMoodTags.length > 0) { - const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key); - const hiddenRows = await tx.moodTag.findMany({ - where: { - key: { in: [...new Set(hiddenKeys)] }, - OR: [{ userId: null }, { userId: ownerId }], - }, - select: { id: true, key: true }, - }); - const hiddenByKey = new Map( - hiddenRows.map((row) => [row.key, row.id]), - ); - const unresolvedHidden: string[] = []; - const writableHidden = payload.hiddenMoodTags.filter((entry) => { - if (hiddenByKey.has(entry.key)) return true; - unresolvedHidden.push(entry.key); - return false; - }); - if (writableHidden.length > 0) { - await tx.moodTagHidden.createMany({ - data: writableHidden.map((entry) => ({ - userId: ownerId, - moodTagId: hiddenByKey.get(entry.key)!, - ...(entry.createdAt - ? { createdAt: new Date(entry.createdAt) } - : {}), - })), - skipDuplicates: true, - }); - } - recordUnknownKeys( - skips, - "moodTag", - [...new Set(unresolvedHidden)], - unresolvedHidden, - ); - } - - if (payload.moodEntries.length > 0) { - const referencedFactorKeys = payload.moodEntries.flatMap((entry) => - entry.factors.map((factor) => factor.key), - ); - const factorKeys = [...new Set(referencedFactorKeys)]; - const referencedTagKeys = payload.moodEntries.flatMap( - (entry) => entry.structuredTags, - ); - const tagKeys = [...new Set(referencedTagKeys)]; - const factorRows = - factorKeys.length === 0 - ? [] - : await tx.moodTag.findMany({ - where: { - key: { in: factorKeys }, - kind: "RATED", - // The seeded catalogue OR this account's own tags. - OR: [{ userId: null }, { userId: ownerId }], - }, - select: { id: true, key: true }, - }); - const factorByKey = new Map( - factorRows.map((factor) => [factor.key, factor.id]), - ); - // A rated tag this instance does not know costs the entry's link to - // it, not the entry. The mood, the score, the date, the free tags — - // everything the person logged — comes back; the one rating that - // has nowhere to attach is dropped and named. Refusing the file - // instead meant a renamed seeded tag made the whole account - // unrecoverable. - recordUnknownKeys( - skips, - "moodFactor", - factorKeys.filter((key) => !factorByKey.has(key)), - referencedFactorKeys, - ); - - // The BINARY half of the same taxonomy, looked up the same way and - // reported the same way. It gets its own family because an - // operator reading the report needs to know whether a rated - // factor's score or a ticked tag went missing; they are different - // losses on the same entry. - const tagRows = - tagKeys.length === 0 - ? [] - : await tx.moodTag.findMany({ - where: { - key: { in: tagKeys }, - kind: "BINARY", - OR: [{ userId: null }, { userId: ownerId }], - }, - select: { id: true, key: true }, - }); - const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id])); - recordUnknownKeys( - skips, - "moodTag", - tagKeys.filter((key) => !tagByKey.has(key)), - referencedTagKeys, - ); - - for (const entry of payload.moodEntries) { - const moodLoggedAt = new Date(entry.loggedAt); - const restoredData = { - date: entry.date, - mood: entry.mood, - score: entry.score, - // The five level-A values, carried the same way `syncVersion` - // is: absent in the file means the file predates the columns, - // and an upsert whose update arm stated them would wipe live - // answers off every matched row when an older backup is - // restored over a going concern. A file that has them writes - // them, including an explicit null; a file that does not says - // nothing. Never derived — filling one in here would put a - // number the person never gave into a restored row. - ...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }), - ...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }), - ...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }), - ...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }), - ...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }), - tags: entry.tags ?? null, - // The note comes back the way every other dual-column note - // does: ciphertext verbatim when the file carries it, and a - // portable file's plaintext re-encrypted on the way in, with - // the legacy column left null either way. - note: null, - noteEncrypted: - entry.noteEncrypted == null - ? encryptNote(entry.note ?? null) - : decodeEncryptedBytes(entry.noteEncrypted), - source: entry.source ?? "MOODLOG", - externalId: entry.externalId ?? null, - moodLoggedAt, - // The zone the `date` string is anchored to. A file written - // before this rode the payload carries nothing, and NULL is - // what such a row meant anyway: the legacy Europe/Berlin - // reading. So an old file keeps its old day boundaries and a - // new one keeps the zone the person actually logged in. - tz: entry.tz ?? null, - // Mood reconciles last-writer-wins by `syncVersion`. Restoring - // at the schema default would hand the next sync round to any - // paired device still holding a higher number, and the device - // would overwrite the recovered row with what it had — a - // restore that loses to the client is worse than one that - // fails. An old file carries nothing here and keeps the - // default, which is the value those rows were written with. - ...(entry.syncVersion === undefined - ? {} - : { syncVersion: entry.syncVersion }), - deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, - ...(entry.syncedAt ? { syncedAt: new Date(entry.syncedAt) } : {}), - ...(entry.createdAt - ? { createdAt: new Date(entry.createdAt) } - : {}), - ...(entry.updatedAt - ? { updatedAt: new Date(entry.updatedAt) } - : {}), - }; - const createData = { userId: ownerId, ...restoredData }; - const restored = entry.id - ? await tx.moodEntry.upsert({ - where: { id: entry.id, userId: ownerId }, - create: { id: entry.id, ...createData }, - update: restoredData, - }) - : entry.externalId - ? await tx.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId: ownerId, - source: restoredData.source, - externalId: entry.externalId, - }, - }, - create: createData, - update: restoredData, - }) - : await tx.moodEntry.upsert({ - where: { - userId_date_moodLoggedAt: { - userId: ownerId, - date: entry.date, - moodLoggedAt, - }, - }, - create: createData, - update: restoredData, - }); - - // The day context, rebuilt after the entry so it can bind to - // whatever id that entry actually got. Delete-then-write rather - // than an upsert with a preserve-when-absent arm: the file - // states the whole context or states that there was none, and a - // restored entry carrying half a file's context and half a live - // row's would be a state neither of them describes. - await tx.moodContext.deleteMany({ - where: { moodEntryId: restored.id }, - }); - if (entry.context) { - const context = entry.context; - await tx.moodContext.create({ - data: { - moodEntryId: restored.id, - userId: ownerId, - workStatus: context.workStatus ?? null, - workMinutes: context.workMinutes ?? null, - overtimeMinutes: context.overtimeMinutes ?? null, - workLoad: context.workLoad ?? null, - workSatisfaction: context.workSatisfaction ?? null, - contactCircles: context.contactCircles ?? null, - contactForm: context.contactForm ?? null, - contactExtent: context.contactExtent ?? null, - contactQuality: context.contactQuality ?? null, - contactSupport: context.contactSupport ?? null, - leisureCategories: context.leisureCategories ?? null, - leisureMinutes: context.leisureMinutes ?? null, - leisureJoy: context.leisureJoy ?? null, - leisureRecovery: context.leisureRecovery ?? null, - eventType: context.eventType ?? null, - eventValence: context.eventValence ?? null, - eventAt: context.eventAt ? new Date(context.eventAt) : null, - // The note comes back the way every other dual-column - // note does: ciphertext verbatim when the file carries - // it, a portable file's plain text re-encrypted on the - // way in. - notesEncrypted: - context.notesEncrypted == null - ? encryptNote(context.note ?? null) - : decodeEncryptedBytes(context.notesEncrypted), - }, - }); - } - - await tx.moodEntryTagLink.deleteMany({ - where: { moodEntryId: restored.id }, - }); - const factorLinks = entry.factors.flatMap((factor) => { - const moodTagId = factorByKey.get(factor.key); - return moodTagId - ? [ - { - moodEntryId: restored.id, - moodTagId, - rating: factor.rating, - }, - ] - : []; - }); - // The other arm. A BINARY link carries no score — the presence - // of the row is the signal — so `rating` is NULL by definition, - // which is exactly what the backup's old tag-link filter took to - // mean "nothing here". - const tagLinks = entry.structuredTags.flatMap((key) => { - const moodTagId = tagByKey.get(key); - return moodTagId - ? [{ moodEntryId: restored.id, moodTagId, rating: null }] - : []; - }); - const links = [...factorLinks, ...tagLinks]; - if (links.length > 0) { - await tx.moodEntryTagLink.createMany({ data: links }); - } - } - } - - // v1.15.0 — cycle tables (profile + observed spans + day-logs + - // symptom links). Delete-then-recreate, mirroring the contract - // above. `notesEncrypted` is restored as ciphertext verbatim. - reportSection("cycles"); - const cycleCleared = await restoreCycleData( - tx, - ownerId, - payload, - skips, - ); - - // Durable self-context + user-defined metrics. Both ends of this pair - // live in `src/lib/export/profile-backup.ts` beside its builder — a - // grep of THIS file alone will not find them, exactly as it does not - // find the cycle restore one line above. - reportSection("profile"); - const profileCleared = await restoreProfileData(tx, ownerId, payload); - - // The hourly shape of a cumulative day. Both ends live in - // `src/lib/export/intraday-profile-backup.ts` beside its builder, so - // a grep of THIS file alone will not find them either. - const intradayCleared = await restoreIntradayProfileData( - tx, - ownerId, - payload, - ); - - // The score as it was shown on each local day. Both ends live in - // `src/lib/export/health-score-backup.ts`, same as the two above. - const healthScoreCleared = await restoreHealthScoreData( - tx, - ownerId, - payload, - ); - - // The needs-based setup answers. Both ends live in - // `src/lib/export/onboarding-backup.ts`, same as the three above. - const onboardingCleared = await restoreOnboardingData( - tx, - ownerId, - payload, - ); - - // The account's own settings, onto the account row, column by column. - // Only what `USER_COLUMN_BACKUP_CLASS` calls a setting can be written; - // identity, credentials and this host's bookkeeping on the row stay as - // the receiving account has them. A value this host will not take is - // left out and named in the skip report. Both ends live in - // `src/lib/export/account-settings-backup.ts`. - const accountSettingsResult = await restoreAccountSettings( - tx, - ownerId, - payload, - ); - for (const column of accountSettingsResult.refused) { - skips.push({ catalogue: "accountSetting", key: column, links: 1 }); - } - - reportSection("labs"); - const biomarkerByName = new Map(); - const restoredBiomarkerIds = new Set(); - for (const biomarker of payload.biomarkers) { - const created = await tx.biomarker.create({ - data: { - ...(biomarker.id ? { id: biomarker.id } : {}), - userId: ownerId, - name: biomarker.name, - unit: biomarker.unit, - lowerBound: biomarker.lowerBound ?? null, - upperBound: biomarker.upperBound ?? null, - panel: biomarker.panel ?? null, - hidden: biomarker.hidden ?? false, - contextEncrypted: - biomarker.context == null - ? null - : encryptContextToBytes(biomarker.context), - analyteKey: biomarker.analyteKey ?? null, - ...(biomarker.createdAt - ? { createdAt: new Date(biomarker.createdAt) } - : {}), - ...(biomarker.updatedAt - ? { updatedAt: new Date(biomarker.updatedAt) } - : {}), - }, - }); - biomarkerByName.set(biomarker.name, created.id); - restoredBiomarkerIds.add(created.id); - // v1.43 — the other spellings, folded the way the resolver - // reads them, so the next scan matches them again. - const aliasRows = (biomarker.aliases ?? []) - .map((alias) => ({ - id: alias.id, - name: alias.name.trim().slice(0, 120), - nameFolded: foldForMatch(alias.name).slice(0, 120), - source: alias.source, - createdAt: alias.createdAt, - })) - .filter( - (alias) => - alias.nameFolded !== "" && - alias.nameFolded !== foldForMatch(biomarker.name), - ); - if (aliasRows.length > 0) { - await tx.biomarkerAlias.createMany({ - data: aliasRows.map((alias) => ({ - ...(alias.id ? { id: alias.id } : {}), - userId: ownerId, - biomarkerId: created.id, - name: alias.name, - nameFolded: alias.nameFolded, - source: alias.source, - ...(alias.createdAt - ? { createdAt: new Date(alias.createdAt) } - : {}), - })), - // One spelling answers to one marker per account; a file that - // names it twice keeps the first. - skipDuplicates: true, - }); - } - } - - // v1.43 — the merge ledger, against the markers just written: - // by id where the file carries one this restore kept, by name - // otherwise. A row whose target the file does not carry has nothing - // to point at and is left out (counted on the event). - let unresolvedMerges = 0; - const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => { - const targetId = - merge.targetBiomarkerId && - restoredBiomarkerIds.has(merge.targetBiomarkerId) - ? merge.targetBiomarkerId - : biomarkerByName.get(merge.targetName); - if (!targetId) { - unresolvedMerges += 1; - return []; - } - return [ - { - ...(merge.id ? { id: merge.id } : {}), - userId: ownerId, - targetBiomarkerId: targetId, - sourceBiomarkerId: merge.sourceBiomarkerId, - sourceName: merge.sourceName, - readingCount: merge.readingCount, - mergedAt: new Date(merge.mergedAt), - }, - ]; - }); - if (mergeRows.length > 0) { - await tx.biomarkerMerge.createMany({ data: mergeRows }); - } - if (unresolvedMerges > 0) { - annotate({ - action: { name: "backup.restore.biomarkerMerge.unresolved" }, - meta: { count: unresolvedMerges }, - }); - } - - // Collected for the staged facts further down: an approved fact's - // `committedRecordId` names the lab result it was committed to, and - // that reference is resolved against the rows this loop writes. - const restoredLabResultIds = new Set(); - // Same arrangement as `medicationIdBySourceId`, for the lab results. - const labResultIdBySourceId = new Map(); - // What each drug was supposed to move, written now that both ends - // exist. The drugs came back several hundred lines up and the - // analytes only just now, and one of those two had to move for a - // target to reach the database at all. - // - // The second pass wins over hoisting the biomarker restore above the - // medications, for two reasons. `biomarkerByName` and - // `restoredBiomarkerIds` are read by the lab-result loop directly - // below, so moving the block would separate a map from its only - // other consumer and leave a future edit free to re-order them back - // without anything noticing. And the direction of the dependency - // would read backwards: a pinned target is a leaf of the medication - // tree, and letting a leaf dictate where the clinical record is - // rebuilt inverts what the file is about. A second pass states the - // constraint where it applies instead of hiding it in a line order. - // - // A named analyte the restore did not put back cannot be written: - // `biomarkerId` is a real foreign key, so a dangling value would - // fail the constraint and roll the entire account back over one - // override. The row is dropped and NAMED. Dropping rather than - // nulling is deliberate here: the override IS the reference, and a - // row with neither arm set is what the resolver already reads as "no - // override", so keeping it would restore something that means - // nothing while still claiming the primary slot. A target that - // carried no name in the first place is a genuine live state (the - // analyte was deleted before the export) and comes back as written. - const unresolvedTargetAnalytes: string[] = []; - const writableTargets = pendingEfficacyTargets.filter((pending) => { - const name = pending.target.biomarkerName; - if (!name || biomarkerByName.has(name)) return true; - unresolvedTargetAnalytes.push(name); - return false; - }); - if (writableTargets.length > 0) { - await tx.medicationEfficacyTarget.createMany({ - data: writableTargets.map(({ medicationId, target }) => ({ - ...(target.id ? { id: target.id } : {}), - medicationId, - measurementType: target.measurementType ?? null, - biomarkerId: target.biomarkerName - ? (biomarkerByName.get(target.biomarkerName) ?? null) - : null, - primary: target.primary ?? true, - ...(target.createdAt - ? { createdAt: new Date(target.createdAt) } - : {}), - ...(target.updatedAt - ? { updatedAt: new Date(target.updatedAt) } - : {}), - })), - }); - } - recordUnknownKeys( - skips, - "medicationTarget", - [...new Set(unresolvedTargetAnalytes)], - unresolvedTargetAnalytes, - ); - - for (const lab of payload.labResults) { - const biomarkerId = - lab.biomarkerId !== undefined - ? lab.biomarkerId === null - ? null - : restoredBiomarkerIds.has(lab.biomarkerId) - ? lab.biomarkerId - : undefined - : lab.biomarkerName - ? biomarkerByName.get(lab.biomarkerName) - : null; - if (biomarkerId === undefined) { - throw new Error( - `Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`, - ); - } - const createdLab = await tx.labResult.create({ - data: { - ...(lab.id ? { id: lab.id } : {}), - userId: ownerId, - biomarkerId: biomarkerId ?? null, - panel: lab.panel ?? null, - analyte: lab.analyte, - value: lab.value ?? null, - valueText: lab.valueText ?? null, - unit: lab.unit, - referenceLow: lab.referenceLow ?? null, - referenceHigh: lab.referenceHigh ?? null, - sourceReferenceLow: lab.sourceReferenceLow ?? null, - sourceReferenceHigh: lab.sourceReferenceHigh ?? null, - sourceReferenceText: lab.sourceReferenceText ?? null, - sourceValue: lab.sourceValue ?? null, - sourceUnit: lab.sourceUnit ?? null, - takenAt: new Date(lab.takenAt), - source: lab.source, - noteEncrypted: - lab.noteEncrypted !== undefined - ? lab.noteEncrypted === null - ? null - : decodeEncryptedBytes(lab.noteEncrypted) - : lab.note == null - ? null - : encryptNoteToBytes(lab.note), - deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null, - ...(lab.createdAt ? { createdAt: new Date(lab.createdAt) } : {}), - ...(lab.updatedAt ? { updatedAt: new Date(lab.updatedAt) } : {}), - }, - }); - restoredLabResultIds.add(createdLab.id); - const labSourceId = lab.id ?? lab.ref; - if (labSourceId) - labResultIdBySourceId.set(labSourceId, createdLab.id); - } - - reportSection("illness"); - const episodeIds = new Set( - payload.illnessEpisodes.map((episode) => episode.id), - ); - for (const episode of payload.illnessEpisodes) { - if ( - episode.parentConditionId && - !episodeIds.has(episode.parentConditionId) - ) { - throw new Error( - `Unknown illness parent: ${episode.parentConditionId}`, - ); - } - await tx.illnessEpisode.create({ - data: { - id: episode.id, - userId: ownerId, - label: episode.label, - type: episode.type as never, - lifecycle: episode.lifecycle as never, - onsetAt: new Date(episode.onsetAt), - resolvedAt: episode.resolvedAt - ? new Date(episode.resolvedAt) - : null, - parentConditionId: null, - noteEncrypted: - episode.noteEncrypted !== undefined - ? episode.noteEncrypted === null - ? null - : decodeEncryptedBytes(episode.noteEncrypted) - : episode.note == null - ? null - : encryptToBytes(episode.note), - // v1.39.2 — the same two-armed read as the note: ciphertext - // verbatim from a disaster-recovery file, plaintext re-encrypted - // from a portable one, nothing from a file older than the field. - bodySiteEncrypted: - episode.bodySiteEncrypted !== undefined && - episode.bodySiteEncrypted !== null - ? decodeEncryptedBytes(episode.bodySiteEncrypted) - : episode.bodySite?.trim() - ? encryptToBytes(episode.bodySite.trim()) - : null, - laterality: episode.laterality ?? null, - deletedAt: episode.deletedAt ? new Date(episode.deletedAt) : null, - ...(episode.createdAt - ? { createdAt: new Date(episode.createdAt) } - : {}), - ...(episode.updatedAt - ? { updatedAt: new Date(episode.updatedAt) } - : {}), - }, - }); - } - for (const episode of payload.illnessEpisodes) { - if (episode.parentConditionId) { - await tx.illnessEpisode.update({ - where: { id: episode.id, userId: ownerId }, - data: { parentConditionId: episode.parentConditionId }, - }); - } - } - - const referencedSymptomKeys = payload.illnessEpisodes.flatMap( - (episode) => - episode.dayLogs.flatMap((dayLog) => - dayLog.symptoms.map((symptom) => symptom.key), - ), - ); - const symptomKeys = [...new Set(referencedSymptomKeys)]; - const symptomRows = - symptomKeys.length === 0 - ? [] - : await tx.illnessSymptom.findMany({ - where: { key: { in: symptomKeys } }, - select: { id: true, key: true }, - }); - const symptomByKey = new Map( - symptomRows.map((symptom) => [symptom.key, symptom.id]), - ); - // `IllnessSymptom` has no `userId` column at all — it is a purely - // seeded catalogue, so a key that will not resolve here can ONLY be - // catalogue drift and never a definition the file failed to carry. - // Which makes refusing the file over it the least defensible of the - // three: the file was never able to supply the missing row. - recordUnknownKeys( - skips, - "illnessSymptom", - symptomKeys.filter((key) => !symptomByKey.has(key)), - referencedSymptomKeys, - ); - for (const episode of payload.illnessEpisodes) { - for (const dayLog of episode.dayLogs) { - const symptomLinks = dayLog.symptoms.flatMap((symptom) => { - const symptomId = symptomByKey.get(symptom.key); - return symptomId - ? [{ symptomId, severity: symptom.severity ?? null }] - : []; - }); - await tx.illnessDayLog.create({ - data: { - ...(dayLog.id ? { id: dayLog.id } : {}), - userId: ownerId, - episodeId: episode.id, - date: dayLog.date, - functionalImpact: dayLog.functionalImpact ?? null, - feverC: dayLog.feverC ?? null, - noteEncrypted: - dayLog.noteEncrypted !== undefined - ? dayLog.noteEncrypted === null - ? null - : decodeEncryptedBytes(dayLog.noteEncrypted) - : dayLog.note == null - ? null - : encryptToBytes(dayLog.note), - tz: dayLog.tz ?? null, - deletedAt: dayLog.deletedAt ? new Date(dayLog.deletedAt) : null, - ...(dayLog.createdAt - ? { createdAt: new Date(dayLog.createdAt) } - : {}), - ...(dayLog.updatedAt - ? { updatedAt: new Date(dayLog.updatedAt) } - : {}), - ...(symptomLinks.length > 0 - ? { symptomLinks: { create: symptomLinks } } - : {}), - }, - }); - } - } - - // The person's own symptoms and their occurrences. After the episodes, - // because an occurrence may point at one; the id set is the one this - // branch just wrote. Both ends live in - // `src/lib/export/symptoms-backup.ts`. - const symptomsCleared = await restoreSymptomsData( - tx, - ownerId, - payload, - episodeIds, - skips, - ); - - // The person's life events (v1.42). They reference nothing but the - // account, so they have no ordering constraint and sit beside the - // other person-made rows. Both ends live in - // `src/lib/export/life-events-backup.ts`. - const lifeEventsCleared = await restoreLifeEventsData( - tx, - ownerId, - payload, - ); - - reportSection("allergies"); - for (const allergy of payload.allergies) { - await tx.allergy.create({ - data: { - id: allergy.id, - userId: ownerId, - substance: allergy.substance, - category: allergy.category as never, - type: allergy.type as never, - severity: (allergy.severity ?? null) as never, - status: allergy.status as never, - onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, - reactionEncrypted: - allergy.reactionEncrypted !== undefined - ? allergy.reactionEncrypted === null - ? null - : decodeEncryptedBytes(allergy.reactionEncrypted) - : allergy.reaction == null - ? null - : encryptToBytes(allergy.reaction), - notesEncrypted: - allergy.notesEncrypted !== undefined - ? allergy.notesEncrypted === null - ? null - : decodeEncryptedBytes(allergy.notesEncrypted) - : allergy.note == null - ? null - : encryptToBytes(allergy.note), - deletedAt: allergy.deletedAt ? new Date(allergy.deletedAt) : null, - ...(allergy.createdAt - ? { createdAt: new Date(allergy.createdAt) } - : {}), - ...(allergy.updatedAt - ? { updatedAt: new Date(allergy.updatedAt) } - : {}), - }, - }); - } - - reportSection("familyHistory"); - for (const familyEntry of payload.familyHistory) { - await tx.familyHistoryEntry.create({ - data: { - id: familyEntry.id, - userId: ownerId, - relationship: familyEntry.relationship as never, - condition: familyEntry.condition, - ageAtOnset: familyEntry.ageAtOnset ?? null, - notesEncrypted: - familyEntry.note == null - ? null - : encryptToBytes(familyEntry.note), - ...(familyEntry.createdAt - ? { createdAt: new Date(familyEntry.createdAt) } - : {}), - ...(familyEntry.updatedAt - ? { updatedAt: new Date(familyEntry.updatedAt) } - : {}), - }, - }); - } - - if (payload.workouts.length > 0) { - await tx.workout.createMany({ - data: payload.workouts.map((workout) => ({ - ...(workout.id ? { id: workout.id } : {}), - userId: ownerId, - sportType: workout.sportType, - startedAt: new Date(workout.startedAt), - endedAt: new Date(workout.endedAt), - durationSec: workout.durationSec, - totalEnergyKcal: workout.totalEnergyKcal ?? null, - totalDistanceM: workout.totalDistanceM ?? null, - avgHeartRate: workout.avgHeartRate ?? null, - maxHeartRate: workout.maxHeartRate ?? null, - minHeartRate: workout.minHeartRate ?? null, - stepCount: workout.stepCount ?? null, - elevationM: workout.elevationM ?? null, - pauseDurationSec: workout.pauseDurationSec ?? null, - source: workout.source as never, - externalId: workout.externalId ?? null, - externalSourceVersion: workout.externalSourceVersion ?? null, - ...(workout.metadata == null - ? {} - : { metadata: toJson(workout.metadata) }), - ...(workout.createdAt - ? { createdAt: new Date(workout.createdAt) } - : {}), - ...(workout.updatedAt - ? { updatedAt: new Date(workout.updatedAt) } - : {}), - })), - }); - } - - if (payload.nutrientDays.length > 0) { - await tx.nutrientIntakeDay.createMany({ - data: payload.nutrientDays.map((n) => ({ - userId: ownerId, - day: n.day, - nutrient: n.nutrient, - amount: n.amount, - unit: n.unit, - source: n.source, - externalSourceVersion: n.externalSourceVersion ?? null, - ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), - })), - }); - } - - if (payload.documents.length > 0) { - // One document at a time, its stored bytes read from the file - // again (`forEachDocumentContent`): the first read left them out, - // because a vault held whole does not fit a 524 MB heap (#1239). - // The rows are in file order on both reads. - let written = 0; - await streamed.forEachDocumentContent(async (content, index) => { - const document = payload.documents[index]; - if (!document || content === undefined) { - throw new Error( - `Document ${index} of the backup has no stored bytes on the second read`, - ); - } - await tx.inboundDocument.create({ - data: { - id: document.id, - userId: ownerId, - kind: document.kind as never, - title: document.title ?? null, - filename: document.filename ?? null, - mimeType: document.mimeType, - byteSize: document.byteSize, - contentEncrypted: decodeEncryptedBytes(content), - contentSha256: document.contentSha256 ?? null, - contentCodec: document.contentCodec!, - status: document.status as never, - providerType: document.providerType ?? null, - reportDate: document.reportDate - ? new Date(document.reportDate) - : null, - documentDate: document.documentDate - ? new Date(document.documentDate) - : null, - errorReason: document.errorReason ?? null, - summaryEncrypted: document.summaryEncrypted - ? decodeEncryptedBytes(document.summaryEncrypted) - : null, - summaryGeneratedAt: document.summaryGeneratedAt - ? new Date(document.summaryGeneratedAt) - : null, - summaryState: document.summaryState as never, - lastIndexAttemptAt: document.lastIndexAttemptAt - ? new Date(document.lastIndexAttemptAt) - : null, - lastIndexOutcome: document.lastIndexOutcome ?? null, - // An older file has no marker: those documents predate the - // hold and restore as ordinary ones. - aiReadDeferred: document.aiReadDeferred ?? false, - sourceSystem: document.sourceSystem ?? null, - sourceId: document.sourceSystem - ? (document.sourceId ?? null) - : null, - // A file written before v1.39.3 has no instance: the key then - // matches any instance of its system, as it did when stored. - sourceInstance: - document.sourceSystem && document.sourceId - ? document.sourceInstance - ? normaliseSourceInstance(document.sourceInstance) - : null - : null, - createdAt: new Date(document.createdAt!), - updatedAt: new Date(document.updatedAt!), - }, - select: { id: true }, - }); - written += 1; - }); - if (written !== payload.documents.length) { - throw new Error( - `The backup's second read found ${written} of ${payload.documents.length} documents`, - ); - } - } - - // 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 branches wrote, and resolving before they - // exist would null every commitment and still report success. Both - // ends of this section live in - // `src/lib/export/document-filing-backup.ts`. - reportSection("documents"); - const documentFilingCleared = await restoreDocumentFilingData( - tx, - ownerId, - payload, - { - documentIds: new Set( - payload.documents.map((document) => document.id), - ), - episodeIds, - committedRecordIds: new Set([ - ...restoredLabResultIds, - ...episodeIds, - ...restoredMedicationIds, - ]), - }, - skips, - ); - - // The Vorsorge reminders and their completion ledger (v1.37.20, - // #223 / iOS #68), BEFORE the visits and the vaccinations: both of - // those remap a `reminderId` against the reminders in the database, - // so the reminders have to be in the database first or every - // appointment and booster reference would read as unresolvable and - // drop. Both ends of this section live in - // `src/lib/export/reminders-backup.ts`. - reportSection("reminders"); - const remindersCleared = await restoreRemindersData( - tx, - ownerId, - payload, - skips, - ); - - // Visits, the address book and the three link tables. After the - // documents and the reminders on purpose: a link is written only - // when both of its ends exist, and the encounter's reminder - // reference remaps against the rows the branch above has just - // written. Both ends of this section live in - // `src/lib/export/visits-backup.ts`, so a grep of THIS file alone - // will not find them — the same delegation as the cycle, profile, - // intraday and score restores above. - reportSection("visits"); - const visitsCleared = await restoreVisitsData( - tx, - ownerId, - payload, - skips, - ); - - // The immunization log, after the visits: a dose remaps its - // practitioner, its encounter and its booster reminder against rows - // the branches above have just written, and its document link needs - // the documents that landed before that. Both ends of this section - // live in `src/lib/export/vaccinations-backup.ts`. - reportSection("vaccinations"); - const vaccinationsCleared = await restoreVaccinationsData( - tx, - ownerId, - payload, - skips, - ); - - // The five relation links (v1.43), 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. Both ends - // of this section live in `src/lib/export/relation-links-backup.ts`. - const relationLinksCleared = await restoreRelationLinksData( - tx, - ownerId, - payload, - skips, - { - medications: medicationIdBySourceId, - labResults: labResultIdBySourceId, - }, - ); - - // The Coach's threads, turns and document links. AFTER the - // documents on purpose: an attachment carries a `documentId` that - // is a foreign key against them, so running earlier would make - // every attachment unresolvable and drop the provenance that makes - // an old answer checkable. The id set is threaded in rather than - // re-queried so the function stays a pure reader of this - // transaction. Both ends of this section live in - // `src/lib/export/coach-backup.ts`. - reportSection("coach"); - const coachCleared = await restoreCoachData( - tx, - ownerId, - payload, - new Set(payload.documents.map((document) => document.id)), - skips, - ); - - // What the Coach keeps between threads. AFTER the transcript, - // because all three carry a `sourceConversationId` that is resolved - // against the threads the branch above has just written — resolving - // earlier would cut every fact loose from the conversation it came - // out of and still report success. - const coachMemoryCleared = await restoreCoachMemoryData( - tx, - ownerId, - payload, - new Set(payload.coachConversations.map((c) => c.id)), - new Set( - payload.coachConversations.flatMap((c) => - c.messages.map((m) => m.id), - ), - ), - skips, - ); - - // The screener history and the consent record. Neither references - // anything but the account, so the position here is free; it sits - // last because that is where the section was added, not because - // anything above it matters to these two. - reportSection("sensitive"); - const sensitiveCleared = await restoreSensitiveData( - tx, - ownerId, - 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`. - reportSection("awards"); - const awardsCleared = await restoreAwardsData( - tx, - ownerId, - payload, - writtenReferencedIds, - skips, - ); - - // The ECG strips. AFTER the measurements on purpose: a recording - // carries a `measurementId` that is a real foreign key against the - // EVENT row it was filed with, so a reference written before that - // row exists does not merely dangle — it violates the constraint - // and rolls the whole restore back. The id set is the one the - // measurement branch actually wrote, threaded in rather than - // re-queried so the function stays a pure reader of this - // transaction. Both ends of this section live in - // `src/lib/export/ecg-backup.ts`. - reportSection("ecg"); - const ecgCleared = await restoreEcgData( - tx, - ownerId, - payload, - writtenReferencedIds, - 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`. - reportSection("environment"); - const environmentCleared = await restoreEnvironmentData( - tx, - ownerId, - payload, - skips, - ); - - const cleared = { - measurements: measurements.count, - medications: meds.count, - intakeEvents: intake.count, - moodEntries: moods.count, - notificationChannels: channels.count, - pushSubscriptions: subs.count, - telegramScheduledDeletions: tgDel.count, - cycles: cycleCleared.cycles, - cycleDayLogs: cycleCleared.cycleDayLogs, - cycleProfile: cycleCleared.cycleProfile, - labResults: labResults.count, - biomarkers: biomarkers.count, - nutrientDays: nutrientDays.count, - illnessEpisodes: illnessEpisodes.count, - allergies: allergies.count, - familyHistory: familyHistory.count, - workouts: workouts.count, - documents: documents.count, - documentConditionLinks: documentFilingCleared.documentConditionLinks, - extractedFacts: documentFilingCleared.extractedFacts, - healthProfile: profileCleared.healthProfile, - healthProfileFactRevisions: profileCleared.healthProfileFactRevisions, - customMetrics: profileCleared.customMetrics, - correlationPatterns: profileCleared.correlationPatterns, - intradayProfiles: intradayCleared.intradayProfiles, - healthScoreRecords: healthScoreCleared.healthScoreRecords, - onboardingRecords: onboardingCleared.onboardingRecords, - practitioners: visitsCleared.practitioners, - encounters: visitsCleared.encounters, - encounterLinks: visitsCleared.encounterLinks, - vaccinations: vaccinationsCleared.vaccinations, - vaccinationLinks: vaccinationsCleared.vaccinationLinks, - relationLinks: relationLinksCleared.relationLinks, - customVaccines: vaccinationsCleared.customVaccines, - measurementReminders: remindersCleared.measurementReminders, - measurementReminderEvents: remindersCleared.measurementReminderEvents, - coachConversations: coachCleared.coachConversations, - coachMessages: coachCleared.coachMessages, - coachConversationDocuments: coachCleared.coachConversationDocuments, - coachFacts: coachMemoryCleared.coachFacts, - coachPlans: coachMemoryCleared.coachPlans, - coachReminders: coachMemoryCleared.coachReminders, - mentalHealthAssessments: sensitiveCleared.mentalHealthAssessments, - consentReceipts: sensitiveCleared.consentReceipts, - personalRecords: awardsCleared.personalRecords, - userAchievements: awardsCleared.userAchievements, - environmentContexts: environmentCleared.environmentContexts, - environmentTravelLocations: - environmentCleared.environmentTravelLocations, - dawarichDays: environmentCleared.dawarichDays, - ecgRecordings: ecgCleared.ecgRecordings, - symptomDefinitions: symptomsCleared.symptomDefinitions, - lifeEvents: lifeEventsCleared.lifeEvents, - }; - assertNoNewForeignReferences( - foreignBefore, - await countForeignReferences(tx, ownerId, tenantEdges), - ); - // Paired clients' delta cursors are no longer valid (see - // `User.syncResetAt`). Stamped as the last write, so it commits with - // the data, and stamped again right after the commit below to cover - // a cursor issued while this transaction was still running. - await stampSyncReset(tx, ownerId); - progress.sectionsDone = RESTORE_SECTION_STEPS.length; - report("sections", true); - return { - cleared, - skipped: summarizeRestoreSkips(skips), - expiredTombstonesSkipped, - accountSettings: accountSettingsResult, - }; - }, - { - maxWait: 10_000, - timeout: transactionTimeoutMs, - }, - ); + await work(); } catch (err) { - // Scrub the raw Prisma / driver error from the wire response — - // even on an admin endpoint, leaking column names, constraint - // names or query fragments lowers the cost of a future supply- - // chain attack against this surface. The verbose text still - // lands in the audit row (admin-readable) and the Wide Event - // (operator-readable), so root-cause investigation is unaffected. - const verbose = err instanceof Error ? err.message : String(err); - const classified = classifyRestoreFailure(err); - await auditLog("admin.backups.restore.failed", { - userId: input.actorUserId, - ipAddress: input.ipAddress, - details: { - backupId: backup.id, - ownerId, - reason: "transaction_failed", - cause: classified.cause, - code: classified.code, - message: verbose, - }, - }); annotate({ meta: { - restoreFailReason: verbose, - restoreFailCause: classified.cause, - restoreFailCode: classified.code, - restoreTransactionTimeoutMs: transactionTimeoutMs, + [failed]: true, + [error]: err instanceof Error ? err.message : String(err), }, }); - if (err instanceof ForeignReferenceError) { - return refused(422, "backup.foreign_reference", err.message, { - errorCode: "backup.foreign_reference", - references: err.edges, - }); - } - if (isStoredBackupReadError(err)) { - // The second read, inside the transaction, found the copy replaced or - // altered. The transaction rolled back, so nothing was changed. - const refusal = storedBackupRefusal(err); - return refused(refusal.status, refusal.code, refusal.message, { - errorCode: refusal.code, - }); - } - // The wide event and the audit row carry the whole error. The job's row - // is read by whoever started the restore, so it names the cause, never - // the database's message, which can quote the rows involved. - return refused( - 500, - "transaction_failed", - RESTORE_FAILURE_CAUSE_MESSAGES[classified.cause], - { cause: classified.cause }, - ); } +} +/** The counts the audit row lists under `restored`, in its order. */ +function restoredCounts(summary: BackupSummary) { + return { + measurements: summary.measurements, + medications: summary.medications, + intakeEvents: summary.intakeEvents, + moodEntries: summary.moodEntries, + cycles: summary.cycles, + cycleDayLogs: summary.cycleDayLogs, + labResults: summary.labResults, + biomarkers: summary.biomarkers, + nutrientDays: summary.nutrientDays, + illnessEpisodes: summary.illnessEpisodes, + illnessDayLogs: summary.illnessDayLogs, + allergies: summary.allergies, + familyHistory: summary.familyHistory, + workouts: summary.workouts, + documents: summary.documents, + healthProfile: summary.healthProfile, + healthProfileFactRevisions: summary.healthProfileFactRevisions, + customMetrics: summary.customMetrics, + customMetricEntries: summary.customMetricEntries, + correlationPatterns: summary.correlationPatterns, + practitioners: summary.practitioners, + encounters: summary.encounters, + encounterLinks: summary.encounterLinks, + vaccinations: summary.vaccinations, + vaccinationLinks: summary.vaccinationLinks, + relationLinks: summary.relationLinks, + customVaccines: summary.customVaccines, + measurementReminders: summary.measurementReminders, + measurementReminderEvents: summary.measurementReminderEvents, + }; +} + +/** + * Everything after the commit: the second sync stamp, the queued repair, the + * rollup rebuilds, the audit row and the cache. None of it can undo the + * restore. + */ +async function afterCommit( + input: RestoreBackupInput, + read: ReadBackup, + owner: { id: string; username: string }, + result: TransactionResult, + tracker: RestoreProgressTracker, +): Promise { + const ownerId = owner.id; // A pull that started before the commit read the pre-restore rows and may // have been issued a cursor after the stamp inside the transaction. The // second stamp expires that one too; it can only expire more cursors, so a // failure here costs a client at most one extra incremental page. - try { - await stampSyncReset(prisma, ownerId); - } catch (err) { - annotate({ - meta: { - restore_sync_reset_failed: true, - restore_sync_reset_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + await bestEffort( + () => stampSyncReset(prisma, ownerId), + "restore_sync_reset_failed", + "restore_sync_reset_error", + ); const { cleared, skipped, expiredTombstonesSkipped, accountSettings } = - outcome; + result; const measurementsRestored = - streamed.measurementCount - expiredTombstonesSkipped; + read.streamed.measurementCount - expiredTombstonesSkipped; annotate({ meta: { restore_expired_tombstones_skipped: expiredTombstonesSkipped }, }); - report("rebuilding", true); + tracker.report("rebuilding", true); await input.onCommitted?.(); // Pinned shape, not free text: a dashboard can alert on @@ -3061,90 +490,61 @@ export async function restoreBackup( }, }); - // v1.4.39.1 — re-fold the persistent measurement rollup tier from - // the just-restored measurements. Pre-fix the restore left the - // rollup table empty for the owner, so the dashboard chart's - // `source=rollup` fast-path silently returned zero buckets until - // the next worker boot ran the backfill discovery — multi-hour - // window of empty charts for the operator-restored account. Runs - // outside the transaction (the 5-year fold would otherwise hold a - // long write lock) and is best-effort so a populator hiccup never - // undoes the restore. The boot-time backfill is the safety net. - // - // The fold covers five years of the account in a handful of statements, - // right after the restore wrote every reading. On a slow disk one of them - // ran past the per-request statement limit, the fold was dropped, and the - // account's charts stayed empty until the next boot (#1031). It gets the - // time the deadline check above keeps free for it instead. // The restore took the fold repair marker away (see the measurement pass): // queue the repair now that the restored rows are committed. Best-effort; // the next boot's discovery queues it otherwise, and the purge waits // either way. - if (foldRepairReset) { - try { - await enqueueMeasurementFoldRepair(ownerId); - } catch (err) { - annotate({ - meta: { - fold_repair_enqueue_failed: true, - fold_repair_enqueue_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + if (result.foldRepairReset) { + await bestEffort( + () => enqueueMeasurementFoldRepair(ownerId), + "fold_repair_enqueue_failed", + "fold_repair_enqueue_error", + ); } + // Re-fold the persistent measurement rollup tier from the just-restored + // measurements, outside the transaction (the 5-year fold would otherwise + // hold a long write lock) and best-effort so a populator hiccup never + // undoes the restore; the boot-time backfill is the safety net. The fold + // covers five years in a handful of statements, right after the restore + // wrote every reading; on a slow disk one of them ran past the per-request + // statement limit (#1031), so it gets the time the deadline check keeps + // free for it. if (measurementsRestored > 0) { - try { - await recomputeUserRollups(ownerId, { - statementTimeoutMs: RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS, - }); - } catch (err) { - annotate({ - meta: { - measurement_rollup_restore_failed: true, - measurement_rollup_restore_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + await bestEffort( + () => + recomputeUserRollups(ownerId, { + statementTimeoutMs: RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS, + }), + "measurement_rollup_restore_failed", + "measurement_rollup_restore_error", + ); } - // v1.4.39 — re-fold the mood rollup tier from the just- - // restored entries. Runs outside the transaction so the (5-year) - // fold can't hold a long write lock; best-effort so a populator - // hiccup doesn't undo the restore. The boot-time backfill is the - // safety net — if this fails the next worker boot mints the rows. - if (payload.moodEntries.length > 0) { - try { - await recomputeUserMoodRollups(ownerId, { granularities: ["DAY"] }); - } catch (err) { - annotate({ - meta: { - mood_rollup_restore_failed: true, - mood_rollup_restore_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + // The mood rollup tier, the same way. + if (read.payload.moodEntries.length > 0) { + await bestEffort( + () => recomputeUserMoodRollups(ownerId, { granularities: ["DAY"] }), + "mood_rollup_restore_failed", + "mood_rollup_restore_error", + ); } const summary = { - ...summarizeBackup(payload), + ...summarizeBackup(read.payload), measurements: measurementsRestored, }; - await auditLog("admin.backups.restore", { userId: input.actorUserId, ipAddress: input.ipAddress, details: { - backupId: backup.id, + backupId: input.backup.id, ownerId, ownerUsername: owner.username, // Whether this restore also rewrote the host's own settings. The one // effect of a restore that reaches accounts other than `ownerId`, so // the trail has to be able to answer it later. - restoreInstanceSettings, + restoreInstanceSettings: input.restoreInstanceSettings, cleared, // How many of the account's own settings came back. The ones that did // not are named in `skipped` under `accountSetting`. @@ -3153,37 +553,7 @@ export async function restoreBackup( // looking at the screen; the audit row is still here next week when // someone asks why a day-log lost a symptom. skipped, - restored: { - measurements: summary.measurements, - medications: summary.medications, - intakeEvents: summary.intakeEvents, - moodEntries: summary.moodEntries, - cycles: summary.cycles, - cycleDayLogs: summary.cycleDayLogs, - labResults: summary.labResults, - biomarkers: summary.biomarkers, - nutrientDays: summary.nutrientDays, - illnessEpisodes: summary.illnessEpisodes, - illnessDayLogs: summary.illnessDayLogs, - allergies: summary.allergies, - familyHistory: summary.familyHistory, - workouts: summary.workouts, - documents: summary.documents, - healthProfile: summary.healthProfile, - healthProfileFactRevisions: summary.healthProfileFactRevisions, - customMetrics: summary.customMetrics, - customMetricEntries: summary.customMetricEntries, - correlationPatterns: summary.correlationPatterns, - practitioners: summary.practitioners, - encounters: summary.encounters, - encounterLinks: summary.encounterLinks, - vaccinations: summary.vaccinations, - vaccinationLinks: summary.vaccinationLinks, - relationLinks: summary.relationLinks, - customVaccines: summary.customVaccines, - measurementReminders: summary.measurementReminders, - measurementReminderEvents: summary.measurementReminderEvents, - }, + restored: restoredCounts(summary), }, }); @@ -3198,11 +568,47 @@ export async function restoreBackup( void refreshTextsAfterUnitChange(ownerId); } - const response: RestoreResponse = { - restored: true, - summary, - skipped, - cleared, - }; + return { restored: true, summary, skipped, cleared }; +} + +/** + * Restore one stored backup over its owner's record. Resolves with what the + * transaction did, or with why nothing was done; throws only for a fault + * outside the restore's own checks (the database gone, for instance). + */ +export async function restoreBackup( + input: RestoreBackupInput, +): Promise { + const { backup, restoreInstanceSettings } = input; + annotate({ + action: { name: "admin.backups.restore" }, + meta: { id: backup.id, restore_instance_settings: restoreInstanceSettings }, + }); + const tracker = progressTracker(input.progress); + tracker.report("validating", true); + + const prepass = createMeasurementRestorePrepass(); + const opened = await openAndReadBackup(input, tracker, prepass); + if (!opened.ok) return opened.outcome; + const { read } = opened; + const checked = await checkBackupBeforeRestore(input, read); + if (!checked.ok) return checked.outcome; + const { owner, transactionTimeoutMs } = checked; + + let result: TransactionResult; + try { + result = await replaceAccount( + read, + owner.id, + restoreInstanceSettings, + transactionTimeoutMs, + tracker, + prepass, + ); + } catch (err) { + return transactionRefusal(input, owner.id, transactionTimeoutMs, err); + } + + const response = await afterCommit(input, read, owner, result, tracker); return { ok: true, response }; } diff --git a/src/lib/export/restore-context.ts b/src/lib/export/restore-context.ts new file mode 100644 index 000000000..2a4f1dad7 --- /dev/null +++ b/src/lib/export/restore-context.ts @@ -0,0 +1,204 @@ +/** + * What every step of a restore is handed, and what the steps hand each other. + * + * A restore runs as one transaction made of steps, one or a few per domain + * (`backup-domains.ts` lists them in the order they run). Each step reads its + * own part of the file and writes its own tables. Where one domain points + * into another (an intake event at its medication, a lab result at its + * marker, a personal best at the reading it was found in), the step that + * writes the target records what it wrote in `links`, and the step that + * points reads it there. Nothing else passes between steps. + */ +import { Buffer } from "node:buffer"; + +import type { Prisma } from "@/generated/prisma/client"; +import type { BackupPayload } from "@/lib/validations/backup"; +import type { StreamedBackup } from "@/lib/export/streamed-backup"; +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; +import type { AccountSettingsRestoreResult } from "@/lib/export/account-settings-restore"; +import type { MeasurementRestorePrepass } from "@/lib/export/measurements-backup"; + +/** + * The steps after the measurements that report as sections, in order. The + * restore names each one as it starts it (`reportSection`), so the count the + * status shows is the count the transaction walks. + */ +export const RESTORE_SECTION_STEPS = [ + "medications", + "mood", + "cycles", + "profile", + "labs", + "illness", + "allergies", + "familyHistory", + "documents", + "reminders", + "visits", + "vaccinations", + "coach", + "sensitive", + "awards", + "ecg", + "environment", +] as const; + +export type RestoreSectionStep = (typeof RESTORE_SECTION_STEPS)[number]; + +/** + * How many rows of each kind the restore removed from the account before + * writing the file's, in the order the report lists them. + */ +export const RESTORE_CLEARED_KEYS = [ + "measurements", + "medications", + "intakeEvents", + "moodEntries", + "notificationChannels", + "pushSubscriptions", + "telegramScheduledDeletions", + "cycles", + "cycleDayLogs", + "cycleProfile", + "labResults", + "biomarkers", + "nutrientDays", + "illnessEpisodes", + "allergies", + "familyHistory", + "workouts", + "documents", + "documentConditionLinks", + "extractedFacts", + "healthProfile", + "healthProfileFactRevisions", + "customMetrics", + "correlationPatterns", + "intradayProfiles", + "healthScoreRecords", + "onboardingRecords", + "practitioners", + "encounters", + "encounterLinks", + "vaccinations", + "vaccinationLinks", + "relationLinks", + "customVaccines", + "measurementReminders", + "measurementReminderEvents", + "coachConversations", + "coachMessages", + "coachConversationDocuments", + "coachFacts", + "coachPlans", + "coachReminders", + "mentalHealthAssessments", + "consentReceipts", + "personalRecords", + "userAchievements", + "environmentContexts", + "environmentTravelLocations", + "dawarichDays", + "ecgRecordings", + "symptomDefinitions", + "lifeEvents", +] as const; + +export type RestoreClearedKey = (typeof RESTORE_CLEARED_KEYS)[number]; +export type RestoreCleared = Record; + +/** A drug's efficacy target, held until the markers it names exist. */ +export interface PendingEfficacyTarget { + medicationId: string; + target: BackupPayload["medications"][number]["efficacyTargets"][number]; +} + +/** + * What one domain wrote that a later one points at. Each field is filled by + * the step named beside it and read only after that step has run. + */ +export interface RestoreLinks { + /** + * Measurement ids a personal best or an ECG strip references, among the + * ones the measurement step actually wrote. Only those are collected. + */ + writtenReferencedMeasurementIds: Set; + /** Medications: every id the step wrote. */ + restoredMedicationIds: Set; + /** + * Medications: the id each one had in the source account (its id, or a + * portable file's `ref`) to the id it got here. + */ + medicationIdBySourceId: Map; + /** Medications: targets written once the markers exist (labs step). */ + pendingEfficacyTargets: PendingEfficacyTarget[]; + /** Labs: marker name to the id it got. */ + biomarkerByName: Map; + /** Labs: every marker id the step wrote. */ + restoredBiomarkerIds: Set; + /** Labs: every lab result id the step wrote. */ + restoredLabResultIds: Set; + /** Labs: source id (or `ref`) of each lab result to the id it got. */ + labResultIdBySourceId: Map; + /** Illness: the episode ids the file carries, all of which are written. */ + episodeIds: Set; +} + +/** What the steps report back to the orchestrator once they are done. */ +export interface RestoreOutputs { + /** Measurement tombstones left out (expired or folded). */ + expiredTombstonesSkipped: number; + /** Whether the fold repair marker was taken away and must be queued. */ + foldRepairReset: boolean; + accountSettings: AccountSettingsRestoreResult; +} + +export interface RestoreContext { + readonly tx: Prisma.TransactionClient; + readonly ownerId: string; + readonly payload: BackupPayload; + readonly streamed: StreamedBackup; + /** Declared inside the transaction, so a rollback takes the report with it. */ + readonly skips: RestoreSkipLog; + readonly restoreInstanceSettings: boolean; + /** What the file's first read noted about its readings. */ + readonly measurementPrepass: MeasurementRestorePrepass; + readonly links: RestoreLinks; + readonly outputs: RestoreOutputs; + readonly cleared: Partial; + /** Name the section that starts now, for the status. */ + reportSection(step: RestoreSectionStep): void; + /** The measurement pass: one more batch of the file's readings handled. */ + reportMeasurements(written: number): void; +} + +export function emptyRestoreLinks(): RestoreLinks { + return { + writtenReferencedMeasurementIds: new Set(), + restoredMedicationIds: new Set(), + medicationIdBySourceId: new Map(), + pendingEfficacyTargets: [], + biomarkerByName: new Map(), + restoredBiomarkerIds: new Set(), + restoredLabResultIds: new Set(), + labResultIdBySourceId: new Map(), + episodeIds: new Set(), + }; +} + +/** The report's cleared counts, in report order, from what the steps set. */ +export function orderedCleared( + cleared: Partial, +): RestoreCleared { + return Object.fromEntries( + RESTORE_CLEARED_KEYS.map((key) => [key, cleared[key]]), + ) as RestoreCleared; +} + +/** Base64 of stored ciphertext back to the bytes a `Bytes` column takes. */ +export function decodeEncryptedBytes(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} diff --git a/src/lib/export/restore-preflight.ts b/src/lib/export/restore-preflight.ts new file mode 100644 index 000000000..5695bf254 --- /dev/null +++ b/src/lib/export/restore-preflight.ts @@ -0,0 +1,433 @@ +/** + * Everything a restore checks before it deletes anything. + * + * Each check that fails writes an `admin.backups.restore.failed` audit row + * with its reason and answers with a refusal; the account has not been + * touched at that point. The checks run in a fixed order, and the order is + * part of the contract: a file that fails two of them is refused for the + * first. + */ +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + assessBackupKeys, + BACKUP_KEY_MISSING_CODE, + describeBackupKeyProblem, +} from "@/lib/export/backup-key-ids"; +import { findIncompleteDocument } from "@/lib/export/documents-backup"; +import type { MeasurementRestorePrepass } from "@/lib/export/measurements-backup"; +import type { + RestoreBackupInput, + RestoreBackupOutcome, + RestoreFailureCode, + RestoreProgressTracker, +} from "@/lib/export/restore-backup"; +import { findMissingBackupSections } from "@/lib/export/restore-skips"; +import { + isStoredBackupReadError, + openStoredBackup, + storedBackupRefusal, +} from "@/lib/export/stored-backup"; +import { + readStreamedBackup, + type BackupSource, + type StreamedBackup, +} from "@/lib/export/streamed-backup"; +import type { BackupPayload } from "@/lib/validations/backup"; +import { isCompatibleSchemaVersion } from "@/lib/validations/backup-summary"; + +/** + * Time kept free after the transaction for the rollup folds and the audit row, + * when the restore checks it fits the caller's deadline. + */ +export const RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS = 10 * 60 * 1000; + +/** + * How long the restore transaction may run, and with it every statement + * inside it (`SET LOCAL statement_timeout`, see the transaction). + * + * The work is deleting the readings the account holds now and writing the + * ones the file carries. Both count: an account whose readings were folded + * into hourly averages still holds the originals as deleted rows until the + * nightly purge removes them, and the restore deletes and rewrites those too + * (#1031). Writing is the slow half, since every reading goes into thirteen + * indexes. The budget is two minutes, plus a second per 1 000 readings in the + * file and per 10 000 in the account. A seeded account of 1.89 million + * readings restored in 196 s on a fast host and in 19 min on a database held + * to half a CPU and 40 MB/s of writes, where its transaction ran 16 min of a + * 37 min budget. + */ +export function restoreTransactionTimeoutMs( + fileMeasurements: number, + currentMeasurements: number, +): number { + return 120_000 + fileMeasurements + Math.ceil(currentMeasurements / 10); +} + +export function refused( + status: number, + code: RestoreFailureCode, + message: string, + meta?: Record, +): RestoreBackupOutcome { + return { ok: false, status, code, message, ...(meta ? { meta } : {}) }; +} + +function readingsPhrase(count: number): string { + return `${count} ${count === 1 ? "reading" : "readings"}`; +} + +async function auditRefusal( + input: RestoreBackupInput, + details: Record, +): Promise { + await auditLog("admin.backups.restore.failed", { + userId: input.actorUserId, + ipAddress: input.ipAddress, + details: { backupId: input.backup.id, ...details }, + }); +} + +export interface ReadBackup { + streamed: StreamedBackup; + raw: unknown; + payload: BackupPayload; +} + +/** + * Open the stored copy and read it once, checking every reading as it goes. + * + * Opened, not unpacked: the JSON of a large record is longer than any string + * V8 can hold (#1031), so it is read as a stream. Opening authenticates every + * stored piece first, and checks they add up to the copy that was written, + * before anything is read or deleted. + * + * The rest of the file is parsed once and kept, because the schema's + * per-section `.default([])` erases the difference between a section that is + * absent and one that is empty — and that difference is what the + * completeness check reads. The measurements are the exception: + * `readStreamedBackup` checks each one against the element schema as it goes + * and keeps only their count, and the transaction reads them a second time, + * in batches, as it writes them. + */ +export async function openAndReadBackup( + input: RestoreBackupInput, + tracker: RestoreProgressTracker, + prepass: MeasurementRestorePrepass, +): Promise< + { ok: true; read: ReadBackup } | { ok: false; outcome: RestoreBackupOutcome } +> { + const { backup } = input; + let source: BackupSource; + try { + source = await openStoredBackup(prisma, backup); + } catch (err) { + await auditRefusal(input, { + ownerId: backup.userId, + reason: err instanceof Error ? err.message : "decrypt_failed", + }); + // Bad stored input, not a broken server: a copy written under a key the + // operator has since dropped, or one whose bytes have changed. Refused + // above the transaction, so nothing was touched. + const refusal = storedBackupRefusal(err); + return { + ok: false, + outcome: refused(refusal.status, refusal.code, refusal.message, { + errorCode: refusal.code, + }), + }; + } + + try { + const streamed = await readStreamedBackup(source, { + onMeasurementChecked: (checked, row) => { + tracker.progress.measurementsChecked = checked; + tracker.report("validating"); + prepass.note(row); + }, + }); + tracker.progress.measurementsTotal = streamed.measurementCount; + tracker.report("validating", true); + const raw = streamed.raw; + // Loaded here, not at import: the schema is ~12 MB of heap and a server + // that never validates a backup should not hold it (`backup-summary.ts`). + const { parseBackupPayload } = await import("@/lib/validations/backup"); + const payload = parseBackupPayload(raw); + return { ok: true, read: { streamed, raw, payload } }; + } catch (err) { + const readFailure = isStoredBackupReadError(err); + await auditRefusal(input, { + ownerId: backup.userId, + reason: readFailure ? "read_failed" : "schema_invalid", + message: err instanceof Error ? err.message : String(err), + }); + if (readFailure) { + // The copy changed after it was opened: replaced by the weekly run, or + // altered. Nothing has been deleted yet. + const refusal = storedBackupRefusal(err); + return { + ok: false, + outcome: refused(refusal.status, refusal.code, refusal.message, { + errorCode: refusal.code, + }), + }; + } + return { + ok: false, + outcome: refused( + 422, + "schema_invalid", + "Backup payload failed schema validation", + ), + }; + } +} + +/** + * The file's version, and whose it is. + * + * The payload declares its own owner, and the backup ROW records who the + * backup was taken for. Those must agree. Taking the owner from the payload + * alone means the restored-into account is whatever the blob claims — so an + * admin who selects one user's backup could write into a different account + * without the interface ever showing it. Both values are already in hand + * here; refuse on mismatch rather than trusting the blob. + * + * The target of the restore is whoever the backup is for, NOT the admin + * running the operation. That user has to still exist — an upload + * referencing a since-deleted user would otherwise leave the operation + * half-done. + */ +async function checkOwnership( + input: RestoreBackupInput, + payload: BackupPayload, +): Promise< + | { ok: true; owner: { id: string; username: string } } + | { ok: false; outcome: RestoreBackupOutcome } +> { + const { backup } = input; + if (!isCompatibleSchemaVersion(payload.schemaVersion)) { + await auditRefusal(input, { + ownerId: backup.userId, + reason: "incompatible_schema_version", + schemaVersion: payload.schemaVersion, + }); + return { + ok: false, + outcome: refused( + 422, + "incompatible_schema_version", + `Backup schema version '${payload.schemaVersion}' is not supported by this server`, + ), + }; + } + + if (payload.userId !== backup.userId) { + await auditRefusal(input, { + ownerId: backup.userId, + reason: "owner_mismatch", + declaredOwnerId: payload.userId, + }); + return { + ok: false, + outcome: refused( + 409, + "owner_mismatch", + "Backup payload declares a different owner than the backup record", + ), + }; + } + + const ownerId = payload.userId; + const owner = await prisma.user.findUnique({ + where: { id: ownerId }, + select: { id: true, username: true }, + }); + if (!owner) { + await auditRefusal(input, { ownerId, reason: "owner_not_found" }); + return { + ok: false, + outcome: refused( + 422, + "owner_not_found", + `Backup owner '${ownerId}' no longer exists in this DB`, + ), + }; + } + return { ok: true, owner }; +} + +/** + * The keys the file's ciphertext needs, and the sections it says it carries. + * + * The ciphertext inside the file is written back verbatim, so every key it + * was written under has to be on this host, and has to be the key that wrote + * it. A copy taken before a key rotation still needs the key the rotation + * retired: the rotation re-seals the stored copy's envelope but cannot reach + * inside it. Refused here, above the first delete, because the alternative is + * an account whose notes, documents and coach history come back as rows no + * reader can open. The instance settings count only when the operator asked + * for them back. + */ +async function checkCarriage( + input: RestoreBackupInput, + read: ReadBackup, + owner: { id: string; username: string }, +): Promise { + const { payload, streamed, raw } = read; + const ownerId = owner.id; + const keyVerdict = assessBackupKeys(streamed.keys, { + ignoreSections: input.restoreInstanceSettings + ? new Set() + : new Set(["appSettings"]), + }); + const keyProblem = describeBackupKeyProblem(keyVerdict); + if (keyProblem) { + const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable]; + await auditRefusal(input, { + ownerId, + reason: "key_missing", + missingKeyIds: keyVerdict.missing, + unreadableKeyIds: keyVerdict.unreadable, + affectedValues: keyVerdict.affectedValues, + }); + return refused(422, BACKUP_KEY_MISSING_CODE, keyProblem, { + errorCode: BACKUP_KEY_MISSING_CODE, + keyIds, + }); + } + + // Audit the *intent* before the transaction begins so the trail describing + // "an admin is about to restore " survives even if the operation + // crashes midway. + await auditLog("admin.backups.restore.start", { + userId: input.actorUserId, + ipAddress: input.ipAddress, + details: { + backupId: input.backup.id, + ownerId, + ownerUsername: owner.username, + snapshotExportedAt: payload.exportedAt, + restoreInstanceSettings: input.restoreInstanceSettings, + }, + }); + + // A section the file's own manifest says it carries, and does not. + // + // The restore's first act is to delete the class it is about to rebuild, so + // an absent section is not a gap in the file — it is an erasure of that part + // of the account, reported as a success. #237: a portable file whose + // document payload was gone emptied the vault and said nothing. The whole + // file is refused instead, above the transaction, for the same reason the + // metadata-only check below sits there. + // + // A DECLARED omission is not this. The portable export leaves the screener + // administrations and the consent receipts out on purpose and says so in the + // manifest; `findMissingBackupSections` reads the manifest and not the + // emptiness of an array, so those files stay restorable. So does every file + // written before the manifest existed, which declares nothing at all. + const missingSections = findMissingBackupSections(raw); + if (missingSections.length > 0) { + await auditRefusal(input, { + ownerId, + reason: "section_missing", + sections: missingSections, + }); + return refused( + 422, + "backup.section.missing", + `Backup is missing ${missingSections.join(", ")} — the file's own manifest says it carries ${missingSections.length === 1 ? "that section" : "those sections"}. Nothing was changed. Use a disaster-recovery snapshot, or export the account again.`, + { errorCode: "backup.section.missing", sections: missingSections }, + ); + } + + const incompleteDocument = findIncompleteDocument(payload); + if (incompleteDocument) { + await auditRefusal(input, { + ownerId, + reason: "document_ciphertext_missing", + documentId: incompleteDocument.id, + }); + return refused( + 422, + "document_ciphertext_missing", + `Document '${incompleteDocument.id}' is metadata-only and cannot be restored`, + ); + } + return null; +} + +/** + * The transaction's own limit grows with the measurements. When a caller has + * a deadline of its own (the background job's time budget) and that limit + * would run past it, the restore is refused here, above the first delete, + * rather than started and cut off by the queue. Counted here, before the + * transaction, because the delete costs time in proportion to what the + * account holds, deleted rows included. + */ +async function checkTimeBudget( + input: RestoreBackupInput, + streamed: StreamedBackup, + ownerId: string, +): Promise< + | { ok: true; transactionTimeoutMs: number } + | { ok: false; outcome: RestoreBackupOutcome } +> { + const currentMeasurements = await prisma.measurement.count({ + where: { userId: ownerId }, + }); + const transactionTimeoutMs = restoreTransactionTimeoutMs( + streamed.measurementCount, + currentMeasurements, + ); + if ( + input.deadline !== undefined && + Date.now() + transactionTimeoutMs + RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS > + input.deadline + ) { + await auditRefusal(input, { + ownerId, + reason: "time_budget", + measurements: streamed.measurementCount, + currentMeasurements, + }); + // Both counts, because both cost time: the account's readings are deleted + // (removed ones included, until the nightly clean-up purges them) before + // the file's are written. + return { + ok: false, + outcome: refused( + 503, + "time_budget", + `Restoring ${readingsPhrase(streamed.measurementCount)} in the backup over the ${readingsPhrase(currentMeasurements)} this account holds now (removed readings count until the nightly clean-up) needs more time than the restore job allows. Nothing was changed.`, + ), + }; + } + return { ok: true, transactionTimeoutMs }; +} + +/** Every check between a parsed file and the first delete, in order. */ +export async function checkBackupBeforeRestore( + input: RestoreBackupInput, + read: ReadBackup, +): Promise< + | { + ok: true; + owner: { id: string; username: string }; + transactionTimeoutMs: number; + } + | { ok: false; outcome: RestoreBackupOutcome } +> { + const ownership = await checkOwnership(input, read.payload); + if (!ownership.ok) return ownership; + const { owner } = ownership; + const carriage = await checkCarriage(input, read, owner); + if (carriage) return { ok: false, outcome: carriage }; + const budget = await checkTimeBudget(input, read.streamed, owner.id); + if (!budget.ok) return budget; + return { + ok: true, + owner, + transactionTimeoutMs: budget.transactionTimeoutMs, + }; +} diff --git a/src/lib/export/workouts-backup.ts b/src/lib/export/workouts-backup.ts new file mode 100644 index 000000000..2dec89ca1 --- /dev/null +++ b/src/lib/export/workouts-backup.ts @@ -0,0 +1,46 @@ +/** + * The workout summaries, with both backup ends in one file. GPS routes and + * per-sample series do not travel, and the file's manifest says so. + */ +import { toJson } from "@/lib/db"; +import type { RestoreContext } from "@/lib/export/restore-context"; + +// ── Restore ────────────────────────────────────────────────────────────────── + +export async function clearWorkouts(ctx: RestoreContext): Promise { + const workouts = await ctx.tx.workout.deleteMany({ + where: { userId: ctx.ownerId }, + }); + ctx.cleared.workouts = workouts.count; +} + +export async function restoreWorkouts(ctx: RestoreContext): Promise { + const { tx, ownerId, payload } = ctx; + if (payload.workouts.length === 0) return; + await tx.workout.createMany({ + data: payload.workouts.map((workout) => ({ + ...(workout.id ? { id: workout.id } : {}), + userId: ownerId, + sportType: workout.sportType, + startedAt: new Date(workout.startedAt), + endedAt: new Date(workout.endedAt), + durationSec: workout.durationSec, + totalEnergyKcal: workout.totalEnergyKcal ?? null, + totalDistanceM: workout.totalDistanceM ?? null, + avgHeartRate: workout.avgHeartRate ?? null, + maxHeartRate: workout.maxHeartRate ?? null, + minHeartRate: workout.minHeartRate ?? null, + stepCount: workout.stepCount ?? null, + elevationM: workout.elevationM ?? null, + pauseDurationSec: workout.pauseDurationSec ?? null, + source: workout.source as never, + externalId: workout.externalId ?? null, + externalSourceVersion: workout.externalSourceVersion ?? null, + ...(workout.metadata == null + ? {} + : { metadata: toJson(workout.metadata) }), + ...(workout.createdAt ? { createdAt: new Date(workout.createdAt) } : {}), + ...(workout.updatedAt ? { updatedAt: new Date(workout.updatedAt) } : {}), + })), + }); +}