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) } : {}), + })), + }); +}