mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(backup): run the restore as ordered steps from per-domain modules
restoreBackup becomes an orchestrator: the checks before the first delete move to restore-preflight.ts, and the transaction runs the steps listed in backup-domains.ts in dependency order, emptying the account first and then rebuilding it domain by domain. The inline restores for measurements, medications, mood, labs, illness, allergies, family history, workouts, nutrient days, documents, the instance settings and the cleared notification channels move into one module per domain, beside the existing section modules. Cross-domain references travel through one typed context instead of local variables. The order of every write, the refusals and their audit rows, the skip report, the cleared counts and the reported phases are unchanged; the golden copies of the restored account still match. Guards that pinned the restore by path now point at the domain files.
This commit is contained in:
1 parent
dcf49dd14e
commit
e7b31fe8e0
27 files changed
+3778
-3008
No files matched your search
@@ -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\(/);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -123,7 +123,7 @@ const WRITERS: Record<string, Disposition> = {
|
||||
},
|
||||
|
||||
// ── 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",
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ const WRITE_SITES: Record<string, string> = {
|
||||
"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
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -95,7 +95,7 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
// 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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 (`<domain>-backup.ts`, with `-restore.ts` and
|
||||
* `-backup-schema.ts` beside it where those are large). This file only says
|
||||
* in which order the restore calls them. The 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty every serialized owner-scoped partition before it is rebuilt in the
|
||||
* same transaction. Child rows either go first for counts or cascade from
|
||||
* their serialized parent. The sections not named here clear their own
|
||||
* tables as their restore step begins.
|
||||
*/
|
||||
export const RESTORE_CLEAR_STEPS: readonly RestoreStep[] = [
|
||||
{ name: "intake events", run: clearIntakeEvents },
|
||||
{ name: "medications", run: clearMedications },
|
||||
{ name: "measurements", run: clearMeasurements },
|
||||
{ name: "mood entries", run: clearMoodEntries },
|
||||
{ name: "mood rollups", run: clearMoodRollups },
|
||||
{ name: "measurement rollups", run: clearMeasurementRollups },
|
||||
{ name: "notification channels", run: clearNotificationChannels },
|
||||
{ name: "documents", run: clearDocuments },
|
||||
{ name: "workouts", run: clearWorkouts },
|
||||
{ name: "family history", run: clearFamilyHistory },
|
||||
{ name: "allergies", run: clearAllergies },
|
||||
{ name: "illness", run: clearIllness },
|
||||
{ name: "lab results", run: clearLabResults },
|
||||
{ name: "biomarkers", run: clearBiomarkers },
|
||||
{ name: "nutrient days", run: clearNutrientDays },
|
||||
];
|
||||
|
||||
/** The ids of the documents the file carries, every one of which is written. */
|
||||
function documentIdsOf(ctx: RestoreContext): Set<string> {
|
||||
return new Set(ctx.payload.documents.map((document) => document.id));
|
||||
}
|
||||
|
||||
/** The rebuild, in dependency order. */
|
||||
export const RESTORE_STEPS: readonly RestoreStep[] = [
|
||||
// The instance settings, only when the operator asked for them back.
|
||||
{ name: "instance settings", run: restoreAppSettings },
|
||||
{ name: "measurements", run: restoreMeasurements },
|
||||
{ name: "medications", run: restoreMedications },
|
||||
{ name: "mood", run: restoreMood },
|
||||
{
|
||||
// Profile, observed spans, day logs and symptom links; delete-then-
|
||||
// recreate, `notesEncrypted` restored as ciphertext verbatim.
|
||||
name: "cycles",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("cycles");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreCycleData(ctx.tx, ctx.ownerId, ctx.payload, ctx.skips),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Durable self-context and user-defined metrics.
|
||||
name: "profile",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("profile");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreProfileData(ctx.tx, ctx.ownerId, ctx.payload),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The hourly shape of a cumulative day.
|
||||
name: "intraday profiles",
|
||||
run: async (ctx) => {
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreIntradayProfileData(ctx.tx, ctx.ownerId, ctx.payload),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The score as it was shown on each local day.
|
||||
name: "health score",
|
||||
run: async (ctx) => {
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreHealthScoreData(ctx.tx, ctx.ownerId, ctx.payload),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The needs-based setup answers.
|
||||
name: "onboarding",
|
||||
run: async (ctx) => {
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreOnboardingData(ctx.tx, ctx.ownerId, ctx.payload),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The account's own settings, onto the account row, column by column.
|
||||
// Only a setting can be written; identity, credentials and this host's
|
||||
// bookkeeping stay as the receiving account has them. A value this host
|
||||
// will not take is left out and named in the skip report.
|
||||
name: "account settings",
|
||||
run: async (ctx) => {
|
||||
const result = await restoreAccountSettings(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
);
|
||||
for (const column of result.refused) {
|
||||
ctx.skips.push({ catalogue: "accountSetting", key: column, links: 1 });
|
||||
}
|
||||
ctx.outputs.accountSettings = result;
|
||||
},
|
||||
},
|
||||
{ name: "lab markers", run: restoreLabMarkers },
|
||||
// After the markers: a drug's target names one by a real foreign key.
|
||||
{ name: "efficacy targets", run: restoreEfficacyTargets },
|
||||
{ name: "lab results", run: restoreLabResults },
|
||||
{ name: "illness", run: restoreIllness },
|
||||
{
|
||||
// The person's own symptoms and their occurrences. After the episodes,
|
||||
// because an occurrence may point at one.
|
||||
name: "symptoms",
|
||||
run: async (ctx) => {
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreSymptomsData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
ctx.links.episodeIds,
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The person's life events. They reference nothing but the account.
|
||||
name: "life events",
|
||||
run: async (ctx) => {
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreLifeEventsData(ctx.tx, ctx.ownerId, ctx.payload),
|
||||
);
|
||||
},
|
||||
},
|
||||
{ name: "allergies", run: restoreAllergies },
|
||||
{ name: "family history", run: restoreFamilyHistory },
|
||||
{ name: "workouts", run: restoreWorkouts },
|
||||
{ name: "nutrient days", run: restoreNutrientDays },
|
||||
{ name: "documents", run: restoreDocuments },
|
||||
{
|
||||
// What the vault was filed against, and what was read out of it. After
|
||||
// the documents and the condition episodes because both are foreign keys
|
||||
// here — a filing written before either exists does not drop quietly, it
|
||||
// violates a constraint and costs the operator the whole restore. After
|
||||
// the lab results and the medications for a second reason: an approved
|
||||
// fact's `committedRecordId` is resolved against the rows those steps
|
||||
// wrote, and resolving before they exist would null every commitment and
|
||||
// still report success.
|
||||
name: "document filing",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("documents");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreDocumentFilingData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
{
|
||||
documentIds: documentIdsOf(ctx),
|
||||
episodeIds: ctx.links.episodeIds,
|
||||
committedRecordIds: new Set([
|
||||
...ctx.links.restoredLabResultIds,
|
||||
...ctx.links.episodeIds,
|
||||
...ctx.links.restoredMedicationIds,
|
||||
]),
|
||||
},
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The Vorsorge reminders and their completion ledger, BEFORE the visits
|
||||
// and the vaccinations: both of those remap a `reminderId` against the
|
||||
// reminders in the database, so the reminders have to be there first or
|
||||
// every appointment and booster reference would read as unresolvable.
|
||||
name: "reminders",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("reminders");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreRemindersData(ctx.tx, ctx.ownerId, ctx.payload, ctx.skips),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Visits, the address book and the three link tables. After the
|
||||
// documents and the reminders: a link is written only when both of its
|
||||
// ends exist.
|
||||
name: "visits",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("visits");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreVisitsData(ctx.tx, ctx.ownerId, ctx.payload, ctx.skips),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The immunization log, after the visits: a dose remaps its
|
||||
// practitioner, its encounter and its booster reminder against rows the
|
||||
// steps above have just written.
|
||||
name: "vaccinations",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("vaccinations");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreVaccinationsData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The five relation links, last of the record sections they join: every
|
||||
// end is a foreign key against a lab result, document, visit, dose,
|
||||
// medication or condition, all written above.
|
||||
name: "relation links",
|
||||
run: async (ctx) => {
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreRelationLinksData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
ctx.skips,
|
||||
{
|
||||
medications: ctx.links.medicationIdBySourceId,
|
||||
labResults: ctx.links.labResultIdBySourceId,
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The Coach's threads, turns and document links. AFTER the documents: an
|
||||
// attachment carries a `documentId` that is a foreign key against them.
|
||||
name: "coach",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("coach");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreCoachData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
documentIdsOf(ctx),
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// What the Coach keeps between threads. AFTER the transcript, because
|
||||
// all three carry a `sourceConversationId` resolved against the threads
|
||||
// just written.
|
||||
name: "coach memory",
|
||||
run: async (ctx) => {
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreCoachMemoryData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
new Set(ctx.payload.coachConversations.map((c) => c.id)),
|
||||
new Set(
|
||||
ctx.payload.coachConversations.flatMap((c) =>
|
||||
c.messages.map((m) => m.id),
|
||||
),
|
||||
),
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The screener history and the consent record. Neither references
|
||||
// anything but the account.
|
||||
name: "sensitive",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("sensitive");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreSensitiveData(ctx.tx, ctx.ownerId, ctx.payload),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The bests and the badges. AFTER the measurements:
|
||||
// `PersonalRecord.sourceMeasurementId` is a real foreign key against
|
||||
// `measurements` (migration 0054), so a pointer resolved before the
|
||||
// measurements exist would make Postgres refuse the insert and roll the
|
||||
// whole restore back over one provenance column.
|
||||
name: "awards",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("awards");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreAwardsData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
ctx.links.writtenReferencedMeasurementIds,
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The ECG strips. AFTER the measurements: a recording's `measurementId`
|
||||
// is a real foreign key against the EVENT row it was filed with.
|
||||
name: "ecg",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("ecg");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreEcgData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
ctx.links.writtenReferencedMeasurementIds,
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// The per-day readings and the location periods that explain them, one
|
||||
// function for both: readings restored without their periods get
|
||||
// re-resolved to the home location and overwritten by the next
|
||||
// environment refresh.
|
||||
name: "environment",
|
||||
run: async (ctx) => {
|
||||
ctx.reportSection("environment");
|
||||
Object.assign(
|
||||
ctx.cleared,
|
||||
await restoreEnvironmentData(
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
ctx.skips,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">,
|
||||
): 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<void> {
|
||||
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<void> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
const familyHistory = await ctx.tx.familyHistoryEntry.deleteMany({
|
||||
where: { userId: ctx.ownerId },
|
||||
});
|
||||
ctx.cleared.familyHistory = familyHistory.count;
|
||||
}
|
||||
|
||||
export async function restoreFamilyHistory(ctx: RestoreContext): Promise<void> {
|
||||
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) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
ctx.reportSection("illness");
|
||||
for (const episode of ctx.payload.illnessEpisodes) {
|
||||
ctx.links.episodeIds.add(episode.id);
|
||||
}
|
||||
await restoreEpisodes(ctx);
|
||||
await restoreDayLogs(ctx);
|
||||
}
|
||||
@@ -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<void> {
|
||||
const labResults = await ctx.tx.labResult.deleteMany({
|
||||
where: { userId: ctx.ownerId },
|
||||
});
|
||||
ctx.cleared.labResults = labResults.count;
|
||||
}
|
||||
|
||||
export async function clearBiomarkers(ctx: RestoreContext): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
/** 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<string>();
|
||||
const foldedTypes = new Set<string>(FOLDED_TYPES);
|
||||
const foldSources = new Set<string>(["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<string>();
|
||||
const rmssdAnyKeys = new Set<string>();
|
||||
const legacyLiveKeys = new Set<string>();
|
||||
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<string>) =>
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const referenced = new Set<string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<Map<string, string>> {
|
||||
const { tx, ownerId, payload } = ctx;
|
||||
await tx.medicationCategoryLabel.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
const categoryKeyRemap = new Map<string, string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string>,
|
||||
): Promise<void> {
|
||||
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<typeof r> => 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<void> {
|
||||
const { tx, ownerId, payload, skips, links } = ctx;
|
||||
const medByName = new Map<string, string>();
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string>,
|
||||
tagByKey: ReadonlyMap<string, string>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
ctx.reportSection("mood");
|
||||
await restoreMoodVocabulary(ctx);
|
||||
await restoreHiddenMoodTags(ctx);
|
||||
await restoreMoodEntries(ctx);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
const nutrientDays = await ctx.tx.nutrientIntakeDay.deleteMany({
|
||||
where: { userId: ctx.ownerId },
|
||||
});
|
||||
ctx.cleared.nutrientDays = nutrientDays.count;
|
||||
}
|
||||
|
||||
export async function restoreNutrientDays(ctx: RestoreContext): Promise<void> {
|
||||
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) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
+375
-2969
File diff suppressed because it is too large.
Load diff
@@ -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<RestoreClearedKey, number>;
|
||||
|
||||
/** 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<string>;
|
||||
/** Medications: every id the step wrote. */
|
||||
restoredMedicationIds: Set<string>;
|
||||
/**
|
||||
* 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<string, string>;
|
||||
/** Medications: targets written once the markers exist (labs step). */
|
||||
pendingEfficacyTargets: PendingEfficacyTarget[];
|
||||
/** Labs: marker name to the id it got. */
|
||||
biomarkerByName: Map<string, string>;
|
||||
/** Labs: every marker id the step wrote. */
|
||||
restoredBiomarkerIds: Set<string>;
|
||||
/** Labs: every lab result id the step wrote. */
|
||||
restoredLabResultIds: Set<string>;
|
||||
/** Labs: source id (or `ref`) of each lab result to the id it got. */
|
||||
labResultIdBySourceId: Map<string, string>;
|
||||
/** Illness: the episode ids the file carries, all of which are written. */
|
||||
episodeIds: Set<string>;
|
||||
}
|
||||
|
||||
/** 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<RestoreCleared>;
|
||||
/** 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>,
|
||||
): 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<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
): 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<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<RestoreBackupOutcome | null> {
|
||||
const { payload, streamed, raw } = read;
|
||||
const ownerId = owner.id;
|
||||
const keyVerdict = assessBackupKeys(streamed.keys, {
|
||||
ignoreSections: input.restoreInstanceSettings
|
||||
? new Set<string>()
|
||||
: 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 <user>" 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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
const workouts = await ctx.tx.workout.deleteMany({
|
||||
where: { userId: ctx.ownerId },
|
||||
});
|
||||
ctx.cleared.workouts = workouts.count;
|
||||
}
|
||||
|
||||
export async function restoreWorkouts(ctx: RestoreContext): Promise<void> {
|
||||
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) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user