Files
HealthLog/src/lib/export/restore-backup.ts
T
Marc-André Bombeck c79fc6cc7b feat: lay down the day view and timeline contract
Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).

Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.

src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.

The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.

The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
2026-10-08 13:03:37 +02:00

2946 lines
125 KiB
TypeScript

/**
* The restore of one stored backup over its owner's record.
*
* After owner and schema validation, one transaction replaces every
* serialized owner-scoped class: measurements, medication history including the
* side effects recorded against a drug, mood and rated factors, cycle data,
* labs/biomarkers, illness history, allergies, family history, workout
* summaries, and inbound documents. Document content and summary ciphertext are
* decoded from base64 and persisted verbatim.
*
* Metadata-only portable document exports are rejected before mutation; the
* importer never fabricates content. Audit rows remain outside the wipe, and
* cache invalidation runs only after the complete restore transaction.
*
* Everything above is scoped to ONE account. The instance-wide settings a
* disaster-recovery payload also carries are not, and they are opt-in for that
* reason: see the `restoreInstanceSettings` block below.
*
* This used to be the body of `POST /api/admin/backups/[id]/restore`. On an
* account of 1.25 million readings it ran for about a minute and a half inside
* that request, and a reverse proxy that cuts a request at 60 s showed the
* operator an error for a restore that went on to finish. The route now checks
* the copy opens, queues a `backup-restore` job and answers 202; the job
* (`src/lib/jobs/backup-restore.ts`) calls `restoreBackup` and records what it
* reports. Nothing about what a restore checks or writes changed in the move:
* every refusal below still happens before the first delete, and the
* replacement is still one transaction, so a failure part-way leaves the
* account exactly as it was.
*/
import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format";
import {
courseFromWindow,
dateOfDayKey,
projectCourseWindow,
} from "@/lib/medications/course-window";
import { Buffer } from "node:buffer";
import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels";
import { prisma, toJson } from "@/lib/db";
import { normaliseSourceInstance } from "@/lib/validations/inbound-documents";
import { auditLog } from "@/lib/auth/audit";
import {
isStoredBackupReadError,
openStoredBackup,
storedBackupRefusal,
type StoredBackupRef,
} from "@/lib/export/stored-backup";
import { takeRestoreLock } from "@/lib/export/restore-lock";
import {
insertMeasurementRows,
type MeasurementInsertRow,
} from "@/lib/export/measurement-bulk-insert";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import {
assessBackupKeys,
BACKUP_KEY_MISSING_CODE,
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import {
assertNoNewForeignReferences,
countForeignReferences,
ForeignReferenceError,
listTenantEdges,
} from "@/lib/export/tenant-boundary";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { sealForRestore } from "@/lib/export/sealed-text";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import type { BackupMeasurement } from "@/lib/validations/backup";
import {
isCompatibleSchemaVersion,
summarizeBackup,
type BackupSummary,
} from "@/lib/validations/backup-summary";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
recomputeUserMedicationCompliance,
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import {
findMissingBackupSections,
recordUnknownKeys,
summarizeRestoreSkips,
type RestoreSkipLog,
type RestoreSkipSummary,
} from "@/lib/export/restore-skips";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { restoreOnboardingData } from "@/lib/export/onboarding-backup";
import {
restoreAccountSettings,
type AccountSettingsRestoreResult,
} from "@/lib/export/account-settings-backup";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
import {
restoreCoachData,
restoreCoachMemoryData,
} from "@/lib/export/coach-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { restoreSymptomsData } from "@/lib/export/symptoms-backup";
import { restoreLifeEventsData } from "@/lib/export/life-events-backup";
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
import {
encryptCategoryLabel,
mintCustomMedicationCategoryKey,
setMedicationCategory,
} from "@/lib/medication-category";
import { invalidateUserData } from "@/lib/cache/invalidate";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import {
FOLDED_TYPES,
coveringStatsExternalId,
isFoldedWindowCandidate,
} from "@/lib/measurements/folded-window";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
import { stampSyncReset } from "@/lib/sync/reset";
import {
classifyRestoreFailure,
RESTORE_FAILURE_CAUSE_MESSAGES,
} from "@/lib/export/restore-failure-cause";
import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability";
export interface RestoreResponse {
restored: true;
summary: BackupSummary;
/**
* What the file carried that this instance could not resolve, named.
*
* `links: 0` with an empty list is the normal answer and says so: nothing
* was dropped. Anything else has to reach the operator's screen, because a
* restore that quietly loses links looks exactly like one that did not.
*/
skipped: RestoreSkipSummary;
cleared: {
measurements: number;
medications: number;
intakeEvents: number;
moodEntries: number;
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
cycles: number;
cycleDayLogs: number;
cycleProfile: number;
labResults: number;
biomarkers: number;
nutrientDays: number;
illnessEpisodes: number;
allergies: number;
familyHistory: number;
workouts: number;
documents: number;
documentConditionLinks: number;
extractedFacts: number;
healthProfile: number;
healthProfileFactRevisions: number;
customMetrics: number;
correlationPatterns: number;
intradayProfiles: number;
healthScoreRecords: number;
onboardingRecords: number;
practitioners: number;
encounters: number;
encounterLinks: number;
vaccinations: number;
vaccinationLinks: number;
measurementReminders: number;
measurementReminderEvents: number;
coachConversations: number;
coachMessages: number;
coachConversationDocuments: number;
coachFacts: number;
coachPlans: number;
coachReminders: number;
mentalHealthAssessments: number;
consentReceipts: number;
personalRecords: number;
userAchievements: number;
environmentContexts: number;
environmentTravelLocations: number;
ecgRecordings: number;
symptomDefinitions: number;
lifeEvents: number;
};
}
/**
* How long the restore transaction may run, and with it every statement
* inside it (`SET LOCAL statement_timeout`, see the transaction below).
*
* The work is deleting the readings the account holds now and writing the
* ones the file carries. Both count: an account whose readings were folded
* into hourly averages still holds the originals as deleted rows until the
* nightly purge removes them, and the restore deletes and rewrites those too
* (#1031). Writing is the slow half, since every reading goes into thirteen
* indexes. The budget is two minutes, plus a second per 1 000 readings in the
* file and per 10 000 in the account. A seeded account of 1.89 million
* readings restored in 196 s on a fast host and in 19 min on a database held
* to half a CPU and 40 MB/s of writes, where its transaction ran 16 min of a
* 37 min budget.
*/
export function restoreTransactionTimeoutMs(
fileMeasurements: number,
currentMeasurements: number,
): number {
return 120_000 + fileMeasurements + Math.ceil(currentMeasurements / 10);
}
function readingsPhrase(count: number): string {
return `${count} ${count === 1 ? "reading" : "readings"}`;
}
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;
}
/** Where a running restore is. */
export type RestorePhase =
"validating" | "clearing" | "measurements" | "sections" | "rebuilding";
/**
* How far a running restore has got. Counts, never content.
*
* `measurementsTotal` is null until the first read of the file has counted
* them; the other sections are small next to the measurements and are counted
* as steps, `sectionsDone` of `sectionsTotal`.
*/
export interface RestoreProgress {
measurementsChecked: number;
measurementsTotal: number | null;
measurementsWritten: number;
sectionsDone: number;
sectionsTotal: number;
}
/** Receives the phase and the counts as the restore moves. Cheap to call. */
export type RestoreProgressSink = (
phase: RestorePhase,
progress: RestoreProgress,
) => void;
/**
* The steps after the measurements that report as sections, in order. The
* restore names each one as it starts it (`reportSection`), so the count the
* status shows is the count the transaction walks.
*/
export const RESTORE_SECTION_STEPS = [
"medications",
"mood",
"cycles",
"profile",
"labs",
"illness",
"allergies",
"familyHistory",
"documents",
"reminders",
"visits",
"vaccinations",
"coach",
"sensitive",
"awards",
"ecg",
"environment",
] as const;
/**
* Why a restore did not happen, as a stable code. Every one of them is decided
* before the first delete or rolls the whole transaction back, so each means
* the account is unchanged.
*/
export type RestoreFailureCode =
| "backup_not_found"
| "backup_changed"
| "backup.payload.undecryptable"
| "schema_invalid"
| "incompatible_schema_version"
| "owner_mismatch"
| "owner_not_found"
| "backup.section.missing"
| "document_ciphertext_missing"
| "backup.key.missing"
| "backup.foreign_reference"
| "time_budget"
| "transaction_failed"
| "interrupted"
| "unexpected";
export interface RestoreBackupInput {
/** The stored copy, as read from `data_backups` (`STORED_BACKUP_SELECT`). */
backup: StoredBackupRef;
/** The admin who asked for the restore; the audit rows name them. */
actorUserId: string;
/** Where the request came from, when there was one. */
ipAddress: string | null;
restoreInstanceSettings: boolean;
progress?: RestoreProgressSink;
/**
* Awaited right after the transaction commits, before the rollup rebuild.
* The caller records that the account now holds the restored data, so a
* restore interrupted after this point is never run a second time.
*/
onCommitted?: () => Promise<void>;
/**
* Epoch milliseconds by which the restore has to be over. When the
* transaction's own time limit would run past it, the restore is refused
* before anything is deleted rather than started and cut off.
*/
deadline?: number;
}
export type RestoreBackupOutcome =
| { ok: true; response: RestoreResponse }
| {
ok: false;
/** The HTTP status the synchronous route used to answer with. */
status: number;
code: RestoreFailureCode;
message: string;
meta?: Record<string, unknown>;
};
/**
* Time kept free after the transaction for the rollup folds and the audit row,
* when the restore checks it fits the caller's deadline.
*/
export const RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS = 10 * 60 * 1000;
function refused(
status: number,
code: RestoreFailureCode,
message: string,
meta?: Record<string, unknown>,
): RestoreBackupOutcome {
return { ok: false, status, code, message, ...(meta ? { meta } : {}) };
}
/**
* Restore one stored backup over its owner's record. Resolves with what the
* transaction did, or with why nothing was done; throws only for a fault
* outside the restore's own checks (the database gone, for instance).
*/
export async function restoreBackup(
input: RestoreBackupInput,
): Promise<RestoreBackupOutcome> {
const { backup, restoreInstanceSettings } = input;
annotate({
action: { name: "admin.backups.restore" },
meta: { id: backup.id, restore_instance_settings: restoreInstanceSettings },
});
const progress: RestoreProgress = {
measurementsChecked: 0,
measurementsTotal: null,
measurementsWritten: 0,
sectionsDone: 0,
sectionsTotal: RESTORE_SECTION_STEPS.length,
};
let phase: RestorePhase = "validating";
// Called once per measurement while the file is read, so a repeat of the
// same phase is passed on at most four times a second; a new phase or a new
// section always is.
let lastReportAt = 0;
const report = (next: RestorePhase = phase, force = false) => {
const now = Date.now();
if (next === phase && !force && now - lastReportAt < 250) return;
phase = next;
lastReportAt = now;
input.progress?.(phase, { ...progress });
};
const reportSection = (step: (typeof RESTORE_SECTION_STEPS)[number]) => {
progress.sectionsDone = RESTORE_SECTION_STEPS.indexOf(step);
report("sections", true);
};
report("validating", true);
// Opened, not unpacked: the JSON of a large record is longer than any
// string V8 can hold (#1031), so it is read as a stream below. Opening
// authenticates every stored piece first, and checks they add up to the
// copy that was written, before anything is read or deleted.
let source: BackupSource;
try {
source = await openStoredBackup(prisma, backup);
} catch (err) {
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId: backup.userId,
reason: err instanceof Error ? err.message : "decrypt_failed",
},
});
// 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 refused(refusal.status, refusal.code, refusal.message, {
errorCode: refusal.code,
});
}
// Parsed once and kept, because the schema's per-section `.default([])`
// erases the difference between a section that is absent and one that is
// empty — and that difference is what the completeness check below reads.
//
// The measurements are the exception: `readStreamedBackup` checks each one
// against the element schema as it goes and keeps only their count, and
// the transaction reads them a second time, in batches, as it writes them.
// v1.42 — the live `stats:` ids of the folded types, gathered on the first
// read, so the measurement pass can tell a compaction tombstone from a
// person's deletion without a third read of the file.
const liveFoldStatsIds = new Set<string>();
const foldedTypes = new Set<string>(FOLDED_TYPES);
const noteLiveFoldRow = (row: BackupMeasurement) => {
if (
row.deletedAt == null &&
row.source === "APPLE_HEALTH" &&
foldedTypes.has(row.type) &&
row.externalId?.startsWith("stats:")
) {
liveFoldStatsIds.add(`${row.type}|${row.externalId}`);
}
};
let raw: unknown;
let payload;
let streamed: StreamedBackup;
try {
streamed = await readStreamedBackup(source, {
onMeasurementChecked: (checked, row) => {
progress.measurementsChecked = checked;
report("validating");
noteLiveFoldRow(row);
},
});
progress.measurementsTotal = streamed.measurementCount;
report("validating", true);
raw = streamed.raw;
// Loaded here, not at import: the schema is ~12 MB of heap and a server
// that never validates a backup should not hold it (`backup-summary.ts`).
const { parseBackupPayload } = await import("@/lib/validations/backup");
payload = parseBackupPayload(raw);
} catch (err) {
const readFailure = isStoredBackupReadError(err);
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId: backup.userId,
reason: readFailure ? "read_failed" : "schema_invalid",
message: err instanceof Error ? err.message : String(err),
},
});
if (readFailure) {
// The copy changed after it was opened: replaced by the weekly run, or
// altered. Nothing has been deleted yet.
const refusal = storedBackupRefusal(err);
return refused(refusal.status, refusal.code, refusal.message, {
errorCode: refusal.code,
});
}
return refused(
422,
"schema_invalid",
"Backup payload failed schema validation",
);
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId: backup.userId,
reason: "incompatible_schema_version",
schemaVersion: payload.schemaVersion,
},
});
return refused(
422,
"incompatible_schema_version",
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
);
}
// The payload declares its own owner, and the backup ROW records who the
// backup was taken for. Those must agree. Taking the owner from the
// payload alone means the restored-into account is whatever the blob
// claims — so an admin who selects one user's backup could write into a
// different account without the interface ever showing it. Both values are
// already in hand here; refuse on mismatch rather than trusting the blob.
if (payload.userId !== backup.userId) {
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId: backup.userId,
reason: "owner_mismatch",
declaredOwnerId: payload.userId,
},
});
return refused(
409,
"owner_mismatch",
"Backup payload declares a different owner than the backup record",
);
}
// The target of the restore is whoever the backup is for, NOT the
// admin running the operation. Make sure that user still exists —
// an upload referencing a since-deleted user would otherwise leave
// the operation half-done.
const ownerId = payload.userId;
const owner = await prisma.user.findUnique({
where: { id: ownerId },
select: { id: true, username: true },
});
if (!owner) {
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
reason: "owner_not_found",
},
});
return refused(
422,
"owner_not_found",
`Backup owner '${ownerId}' no longer exists in this DB`,
);
}
// The ciphertext inside the file is written back verbatim, so every key it
// was written under has to be on this host, and has to be the key that
// wrote it. A copy taken before a key rotation still needs the key the
// rotation retired: the rotation re-seals the stored copy's envelope but
// cannot reach inside it. Refused here, above the first delete, because the
// alternative is an account whose notes, documents and coach history come
// back as rows no reader can open. The instance settings count only when
// the operator asked for them back.
const keyVerdict = assessBackupKeys(streamed.keys, {
ignoreSections: restoreInstanceSettings
? new Set<string>()
: new Set(["appSettings"]),
});
const keyProblem = describeBackupKeyProblem(keyVerdict);
if (keyProblem) {
const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable];
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
reason: "key_missing",
missingKeyIds: keyVerdict.missing,
unreadableKeyIds: keyVerdict.unreadable,
affectedValues: keyVerdict.affectedValues,
},
});
return refused(422, BACKUP_KEY_MISSING_CODE, keyProblem, {
errorCode: BACKUP_KEY_MISSING_CODE,
keyIds,
});
}
// Audit the *intent* before the transaction begins so the trail
// describing "an admin is about to restore <user>" survives even
// if the operation crashes midway.
await auditLog("admin.backups.restore.start", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
ownerUsername: owner.username,
snapshotExportedAt: payload.exportedAt,
restoreInstanceSettings,
},
});
// A section the file's own manifest says it carries, and does not.
//
// The restore's first act is to delete the class it is about to rebuild, so
// an absent section is not a gap in the file — it is an erasure of that part
// of the account, reported as a success. #237: a portable file whose
// document payload was gone emptied the vault and said nothing. The whole
// file is refused instead, above the transaction, for the same reason the
// metadata-only check below sits there.
//
// A DECLARED omission is not this. The portable export leaves the screener
// administrations and the consent receipts out on purpose and says so in the
// manifest; `findMissingBackupSections` reads the manifest and not the
// emptiness of an array, so those files stay restorable. So does every file
// written before the manifest existed, which declares nothing at all.
const missingSections = findMissingBackupSections(raw);
if (missingSections.length > 0) {
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
reason: "section_missing",
sections: missingSections,
},
});
return refused(
422,
"backup.section.missing",
`Backup is missing ${missingSections.join(", ")} — the file's own manifest says it carries ${missingSections.length === 1 ? "that section" : "those sections"}. Nothing was changed. Use a disaster-recovery snapshot, or export the account again.`,
{ errorCode: "backup.section.missing", sections: missingSections },
);
}
// Portable exports intentionally omit document ciphertext. They remain
// valid upload/download artifacts, but cannot be used to manufacture an
// InboundDocument row. A DR restore must fail before any delete rather
// than inventing empty/plaintext content.
const incompleteDocument = payload.documents.find(
(document) =>
document.contentEncrypted === undefined ||
document.contentSha256 === undefined ||
document.contentCodec === undefined ||
document.providerType === undefined ||
document.reportDate === undefined ||
document.documentDate === undefined ||
document.errorReason === undefined ||
document.summaryEncrypted === undefined ||
document.summaryGeneratedAt === undefined ||
document.summaryState === undefined ||
document.createdAt === undefined ||
document.updatedAt === undefined,
);
if (incompleteDocument) {
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
reason: "document_ciphertext_missing",
documentId: incompleteDocument.id,
},
});
return refused(
422,
"document_ciphertext_missing",
`Document '${incompleteDocument.id}' is metadata-only and cannot be restored`,
);
}
// The transaction's own limit grows with the measurements. When a caller
// has a deadline of its own (the background job's time budget) and that
// limit would run past it, the restore is refused here, above the first
// delete, rather than started and cut off by the queue.
// Counted here, before the transaction, because the delete costs time in
// proportion to what the account holds, deleted rows included.
const currentMeasurements = await prisma.measurement.count({
where: { userId: ownerId },
});
const transactionTimeoutMs = restoreTransactionTimeoutMs(
streamed.measurementCount,
currentMeasurements,
);
if (
input.deadline !== undefined &&
Date.now() + transactionTimeoutMs + RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS >
input.deadline
) {
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
reason: "time_budget",
measurements: streamed.measurementCount,
currentMeasurements,
},
});
// Both counts, because both cost time: the account's readings are deleted
// (removed ones included, until the nightly clean-up purges them) before
// the file's are written.
return refused(
503,
"time_budget",
`Restoring ${readingsPhrase(streamed.measurementCount)} in the backup over the ${readingsPhrase(currentMeasurements)} this account holds now (removed readings count until the nightly clean-up) needs more time than the restore job allows. Nothing was changed.`,
);
}
let outcome: {
expiredTombstonesSkipped: number;
cleared: RestoreResponse["cleared"];
skipped: RestoreSkipSummary;
accountSettings: AccountSettingsRestoreResult;
};
try {
report("clearing");
outcome = await prisma.$transaction(
async (tx) => {
// Every connection carries the request limits from `src/lib/db.ts`
// (60 s per statement, 60 s idle inside a transaction). A restore is
// not a request: deleting two million readings is one statement, and
// on a slow disk it ran past 60 s, was cancelled, and the whole
// restore rolled back in its clearing step (#1031). The transaction
// has its own limit; SET LOCAL gives each statement the same one, and
// ends with the transaction.
await tx.$executeRaw`SELECT set_config('statement_timeout', ${String(transactionTimeoutMs)}, true), set_config('idle_in_transaction_session_timeout', ${String(transactionTimeoutMs)}, true)`;
// One restore of an account at a time, whatever let a second one
// start: the job table admits one queued or running job per account,
// and this lock holds even if a worker that was thought gone is
// still writing. A second transaction waits here for the first to
// commit or roll back, then replaces the account again from its own
// file, which is what it was asked to do.
//
// The same lock orders the restore against the background passes that
// rewrite readings a day at a time (`restore-lock.ts`): they hold it
// shared for each day, so the delete below never meets a day half
// folded, which is where the two used to deadlock.
await takeRestoreLock(tx, ownerId);
// Every reference this restore writes stays inside the account: the
// references that leave it are counted now and again after the last
// write, and any that grew rolls the whole transaction back
// (`tenant-boundary.ts` says why the check is derived from the schema
// rather than written per section).
const tenantEdges = await listTenantEdges(tx);
const foreignBefore = await countForeignReferences(
tx,
ownerId,
tenantEdges,
);
// Declared INSIDE the transaction so a rollback takes the report with
// it. An accumulator that outlived a failed attempt would carry drops
// that never happened into the next one.
const skips: RestoreSkipLog = [];
// Delete every serialized owner-scoped partition before rebuilding it
// in the same transaction. Child rows either go first for counts or
// cascade from their serialized parent.
const intake = await tx.medicationIntakeEvent.deleteMany({
where: { userId: ownerId },
});
const meds = await tx.medication.deleteMany({
where: { userId: ownerId },
});
const measurements = await tx.measurement.deleteMany({
where: { userId: ownerId },
});
const moods = await tx.moodEntry.deleteMany({
where: { userId: ownerId },
});
// v1.4.39 W-MOOD — wipe the persisted mood rollup partition
// for this owner so the next analytics read doesn't surface
// pre-restore daily means. The fold below mints fresh rows
// from the restored mood entries.
await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } });
// v1.4.39.1 — wipe the persisted measurement rollup partition
// for the same reason. Pre-fix the partition kept the previous
// owner's daily means even after the underlying measurements
// were replaced — the chart's `source=rollup` fast-path could
// surface a stale 30-day mean built from rows that no longer
// existed. The fold below mints fresh rows from the restored
// measurement set.
await tx.measurementRollup.deleteMany({ where: { userId: ownerId } });
const channels = await tx.notificationChannel.deleteMany({
where: { userId: ownerId },
});
const subs = await tx.pushSubscription.deleteMany({
where: { userId: ownerId },
});
const tgDel = await tx.telegramScheduledDeletion.deleteMany({
where: { userId: ownerId },
});
const documents = await tx.inboundDocument.deleteMany({
where: { userId: ownerId },
});
const workouts = await tx.workout.deleteMany({
where: { userId: ownerId },
});
const familyHistory = await tx.familyHistoryEntry.deleteMany({
where: { userId: ownerId },
});
const allergies = await tx.allergy.deleteMany({
where: { userId: ownerId },
});
const illnessEpisodes = await tx.illnessEpisode.deleteMany({
where: { userId: ownerId },
});
const labResults = await tx.labResult.deleteMany({
where: { userId: ownerId },
});
const biomarkers = await tx.biomarker.deleteMany({
where: { userId: ownerId },
});
// The export has written nutrient day totals since v1.29 and nothing
// here ever read them back: water and vitamin history was carried in
// the file, discarded on restore, and the restore reported success.
const nutrientDays = await tx.nutrientIntakeDay.deleteMany({
where: { userId: ownerId },
});
// The one section of the file that is not this account's.
//
// A disaster-recovery payload carries the singleton `app_settings`
// row — registration, the MFA requirement, the default locale and
// timezone, module availability, the notification and AI
// configuration, the document size cap and quota. Writing it back as
// a side effect of restoring ONE account reconfigures the host for
// everybody on it, silently and hours or weeks out of date: an
// operator putting a single record back on Wednesday has no reason to
// expect Monday's registration switch and Monday's upload cap to come
// with it. Rebuilding a host from a snapshot is a real case, so the
// write stays available — as an answer the operator gives, not one
// the account restore assumes.
if (payload.appSettings && restoreInstanceSettings) {
const settings = payload.appSettings;
// A file from before 0343 carries the Coach's operator answer in
// module availability; carry it into the Coach switch.
const coachFold = foldLegacyCoachAvailability(
settings.moduleAvailabilityJson,
settings.assistantCoachEnabled,
);
const settingsData = {
registrationEnabled: settings.registrationEnabled,
mfaRequired: settings.mfaRequired,
defaultLocale: settings.defaultLocale,
telegramGlobal: settings.telegramGlobal,
ntfyGlobal: settings.ntfyGlobal,
webPushGlobal: settings.webPushGlobal,
webPushVapidPublicKey: settings.webPushVapidPublicKey,
webPushVapidPrivateKeyEncrypted:
settings.webPushVapidPrivateKeyEncrypted,
webPushVapidSubject: settings.webPushVapidSubject,
apiGlobal: settings.apiGlobal,
umamiEnabled: settings.umamiEnabled,
umamiScriptUrl: settings.umamiScriptUrl,
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment,
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
adminAiKeyEncrypted: settings.adminAiKeyEncrypted,
adminAiModel: settings.adminAiModel,
adminAiBaseUrl: settings.adminAiBaseUrl,
adminCodexAccessTokenEncrypted:
settings.adminCodexAccessTokenEncrypted,
adminCodexRefreshTokenEncrypted:
settings.adminCodexRefreshTokenEncrypted,
adminCodexAccountIdEncrypted: settings.adminCodexAccountIdEncrypted,
adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt
? new Date(settings.adminCodexTokenExpiresAt)
: null,
adminCodexConnectedAt: settings.adminCodexConnectedAt
? new Date(settings.adminCodexConnectedAt)
: null,
adminCodexConnectionStatus: settings.adminCodexConnectionStatus,
adminAiInsightsFeedbackSummary:
settings.adminAiInsightsFeedbackSummary as never,
defaultUserTimezone: settings.defaultUserTimezone,
assistantEnabled: settings.assistantEnabled,
assistantCoachEnabled: coachFold.assistantCoachEnabled,
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled:
settings.assistantInsightStatusEnabled,
assistantDocumentAiEnabled:
settings.assistantDocumentAiEnabled ?? true,
// v1.41 — absent on a file written before the controls existed,
// which restores them at their defaults: allowed, no cap.
aiReasoningEnabled: settings.aiReasoningEnabled ?? true,
aiReasoningMaxEffort:
settings.aiReasoningMaxEffort ?? DEFAULT_REASONING_MAX_EFFORT,
moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
};
await tx.appSettings.upsert({
where: { id: settings.id },
create: { id: settings.id, ...settingsData },
update: settingsData,
});
}
const toRestoredMeasurementData = (measurement: BackupMeasurement) => ({
type: measurement.type,
value: measurement.value,
valueMin: measurement.valueMin ?? null,
valueMax: measurement.valueMax ?? null,
unit: measurement.unit,
source: measurement.source ?? "MANUAL",
measuredAt: new Date(measurement.measuredAt),
notes: null,
notesEncrypted:
measurement.notesEncrypted == null
? encryptNote(measurement.notes ?? null)
: decodeEncryptedBytes(measurement.notesEncrypted),
externalId: measurement.externalId ?? null,
externalSourceVersion: measurement.externalSourceVersion ?? null,
// Authority of an Apple Health `stats:` aggregate. A backup
// written before this field rode the payload carries nothing, and
// restoring NULL would flatten the authority ladder: an export.xml
// source-day estimate could then overwrite a native HealthKit
// statistic, which is the ambiguity migration 0263 already
// repaired once. Such a row is exactly the case LEGACY_UNKNOWN
// exists for, so it comes back repairable instead of ambiguous.
//
// The gate is scoped exactly as 0263 scoped its backfill and as
// the cumulative drain scopes its own LEGACY_UNKNOWN writes:
// Apple Health rows only. Other providers mint `stats:` ids for
// their cumulative dailies too (Fitbit, Google Health) and have
// never carried provenance — marking those would invent a state
// they were never in. Ordinary point rows keep NULL honestly.
//
// Only an ABSENT field is the old-backup case. A current backup
// writes the field for every row, and an explicit null is the row's
// real state: the nightly daily-mean fold writes Apple Health
// `stats:` rows with no provenance. Treating that null as absent
// turned every such row into LEGACY_UNKNOWN on restore (#1031).
aggregationProvenance: (measurement.aggregationProvenance !==
undefined
? measurement.aggregationProvenance
: measurement.source === "APPLE_HEALTH" &&
measurement.externalId?.startsWith("stats:")
? "LEGACY_UNKNOWN"
: null) as never,
glucoseContext: (measurement.glucoseContext ?? null) as never,
sleepStage: (measurement.sleepStage ?? null) as never,
rhythmClassification: (measurement.rhythmClassification ??
null) as never,
deviceType: measurement.deviceType ?? null,
syncVersion: measurement.syncVersion ?? 1,
deletedAt: measurement.deletedAt
? new Date(measurement.deletedAt)
: null,
...(measurement.createdAt
? { createdAt: new Date(measurement.createdAt) }
: {}),
...(measurement.updatedAt
? { updatedAt: new Date(measurement.updatedAt) }
: {}),
});
// The measurements, a batch at a time from a second read of the
// file, so the restore never holds more than one batch of them.
//
// Two later sections point INTO the measurements (a record's
// `sourceMeasurementId`, an ECG strip's `measurementId`) and have
// to know which of those ids this transaction actually wrote. They
// used to be handed every written id; they only ever ask about the
// ones they reference, so only those are collected.
const referencedMeasurementIds = new Set<string>();
for (const record of payload.personalRecords) {
if (record.sourceMeasurementId) {
referencedMeasurementIds.add(record.sourceMeasurementId);
}
}
for (const recording of payload.ecgRecordings) {
if (recording.measurementId) {
referencedMeasurementIds.add(recording.measurementId);
}
}
const writtenReferencedIds = new Set<string>();
const measurementBatchSize = 1_000;
// Deleted readings the file carries are restored as deleted rows
// (tombstones), with one exception: a tombstone deleted longer ago
// than `TOMBSTONE_RETENTION_DAYS` is not written at all.
//
// Such a row is exactly what the nightly tombstone purge
// (`measurement-tombstone-cleanup.ts`) hard-deletes, so writing it
// costs the restore a row in thirteen indexes for a row that is gone
// by the next morning. Nothing reads it in between: the delta feed
// answers `cursorExpired` for every cursor older than the same
// horizon, so no client that would still be served this tombstone
// exists; the step-consolidation repair and the hourly rebuild only
// look inside the horizon; and the undo of a delete is offered for
// what the purge has not removed yet, which this row would not
// survive either.
//
// Younger tombstones are written. They still have readers: a paired
// client that has not yet drained that deletion from the delta feed,
// the undo of a recent delete, the consolidation repairs that rebuild
// from folded samples, and the unique `(user, type, source,
// externalId)` key that stops a device from uploading a folded
// sample again. The backup file itself is unchanged and keeps every
// row.
const tombstoneHorizonMs =
Date.now() - TOMBSTONE_RETENTION_DAYS * 86_400_000;
//
// v1.42 — nor is a compaction tombstone, whatever its age: a raw
// Apple Health sample the folds soft-deleted before v1.42, whose hour
// or day a live `stats:` row in the same file covers. The folds
// delete those outright now and the backlog purge removes the old
// ones, so writing one back would only hand the purge the same row
// again; the `folded_window` ingest guard is what keeps the sample
// from being uploaded again. Classified by the rule the purge and
// the sync feed use (`folded-window.ts`), against the `stats:` ids
// gathered on the file's first read and the zone the account will
// have after the restore. Counted with the expired ones.
const foldTz = resolveUserTimezone(
payload.accountSettings?.timezone ??
(
await tx.user.findUnique({
where: { id: ownerId },
select: { timezone: true },
})
)?.timezone ??
null,
);
const isCompactionTombstone = (measurement: BackupMeasurement) => {
if (measurement.deletedAt == null) return false;
const row = {
type: measurement.type,
source: measurement.source ?? "MANUAL",
externalId: measurement.externalId ?? null,
measuredAt: new Date(measurement.measuredAt),
};
if (!isFoldedWindowCandidate(row, new Date(measurement.deletedAt))) {
return false;
}
const covering = coveringStatsExternalId(row, foldTz);
return (
covering !== null &&
liveFoldStatsIds.has(`${measurement.type}|${covering}`)
);
};
const isExpiredTombstone = (measurement: BackupMeasurement) =>
measurement.deletedAt != null &&
(new Date(measurement.deletedAt).getTime() < tombstoneHorizonMs ||
isCompactionTombstone(measurement));
let expiredTombstonesSkipped = 0;
report("measurements");
await streamed.forEachMeasurementBatch(
measurementBatchSize,
async (fileBatch) => {
const batch = fileBatch.filter(
(measurement) => !isExpiredTombstone(measurement),
);
expiredTombstonesSkipped += fileBatch.length - batch.length;
const stableRows = batch.flatMap(
(measurement): MeasurementInsertRow[] =>
measurement.id
? [
{
id: measurement.id,
userId: ownerId,
...toRestoredMeasurementData(measurement),
},
]
: [],
);
// One statement per batch, not `createMany`: see
// `insertMeasurementRows` for what the latter cost here.
await insertMeasurementRows(tx, stableRows);
// Counted through the file, so the bar ends at the file's total.
progress.measurementsWritten += fileBatch.length;
report("measurements");
for (const row of stableRows) {
if (referencedMeasurementIds.has(row.id)) {
writtenReferencedIds.add(row.id);
}
}
// v1 payloads did not require stable ids. Preserve their
// historical natural-key reconciliation without routing
// canonical v2 rows through it.
for (const measurement of batch) {
if (measurement.id) continue;
const restoredData = toRestoredMeasurementData(measurement);
const existing = await tx.measurement.findFirst({
where: {
userId: ownerId,
type: measurement.type,
source: restoredData.source,
measuredAt: restoredData.measuredAt,
sleepStage: restoredData.sleepStage,
},
select: { id: true },
});
if (existing) {
await tx.measurement.update({
where: { id: existing.id, userId: ownerId },
data: restoredData,
});
} else {
await tx.measurement.create({
data: { userId: ownerId, ...restoredData },
});
}
}
},
);
const medByName = new Map<string, string>();
const restoredMedicationIds = new Set<string>();
// The efficacy targets cannot ride inside the medication create
// beside the schedules and the packs: `biomarkerId` is a foreign key
// into a catalogue this transaction has not restored yet. They are
// collected here against the id the drug ACTUALLY got and written in
// a second pass once the biomarkers exist; the pass itself says why
// hoisting the biomarker restore above the medications is the worse
// of the two orderings.
const pendingEfficacyTargets: Array<{
medicationId: string;
target: (typeof payload.medications)[number]["efficacyTargets"][number];
}> = [];
// Supersede pointers the file states and the restore cannot honour.
// Reported as JSON paths into the file rather than as ids: the file
// is where an operator has to go to see what the position meant, and
// an id would be one the restore never wrote.
const unresolvedRevisionLinks: string[] = [];
// v1.40 (#1041) — the account's own medication categories, before
// the medications that name them by key. Delete-then-recreate like
// the medications themselves. A key held by another account on this
// instance (a portable file from elsewhere) is re-minted, and the
// medications below are mapped onto the new key.
await tx.medicationCategoryLabel.deleteMany({
where: { userId: ownerId },
});
const categoryKeyRemap = new Map<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) } : {}),
},
});
}
// v1.40 (#1024) — the zone a derived course's creation day is read
// in: the file's own account settings, else the account's stored one.
const restoreTz = validTimezoneOr(
payload.accountSettings?.timezone ??
(
await tx.user.findUnique({
where: { id: ownerId },
select: { timezone: true },
})
)?.timezone,
DEFAULT_TIMEZONE,
);
let medicationIndex = 0;
reportSection("medications");
const restoreStartedAt = new Date();
for (const m of payload.medications) {
const created = await tx.medication.create({
data: {
...(m.id ? { id: m.id } : {}),
userId: ownerId,
name: m.name,
dose: m.dose,
treatmentClass: m.treatmentClass ?? "GENERIC",
dosesPerUnit: m.dosesPerUnit ?? null,
unitsPerDose: m.unitsPerDose ?? "1",
active: m.active ?? true,
notificationsEnabled: m.notificationsEnabled ?? true,
pausedAt: m.pausedAt ? new Date(m.pausedAt) : null,
snoozedUntil: m.snoozedUntil ? new Date(m.snoozedUntil) : null,
startsOn: m.startsOn ? new Date(m.startsOn) : null,
endsOn: m.endsOn ? new Date(m.endsOn) : null,
oneShot: m.oneShot ?? false,
asNeeded: m.asNeeded ?? false,
trackIntake: m.trackIntake ?? true,
deliveryForm: m.deliveryForm ?? "ORAL",
trackInjectionSites: m.trackInjectionSites ?? false,
allowedInjectionSites: m.allowedInjectionSites ?? [],
liveActivityEnabled: m.liveActivityEnabled ?? false,
criticalAlarmEnabled: m.criticalAlarmEnabled ?? false,
atcCode: m.atcCode ?? null,
rxNormCode: m.rxNormCode ?? null,
lowStockNotifiedAt: m.lowStockNotifiedAt
? new Date(m.lowStockNotifiedAt)
: null,
lowStockNotifiedThresholdDays:
m.lowStockNotifiedThresholdDays ?? null,
reorderLeadDays: m.reorderLeadDays ?? null,
externalSource: m.externalSource ?? null,
externalId: m.externalId ?? null,
// A file from before v1.39.1 carries no creation date, and the
// restore time would hide every dose-history miss before it.
// The earliest thing the file records for the drug stands in.
...(() => {
const createdAt = restoredMedicationCreatedAt(
m,
payload.intakeEvents,
restoreStartedAt,
);
return createdAt ? { createdAt } : {};
})(),
...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}),
schedules: {
create: m.schedules.map((s) => ({
...(s.id ? { id: s.id } : {}),
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
// #219 — per-schedule units per dose. Prisma coerces the
// Decimal string; NULL / absent stays NULL (inherit).
unitsPerDose: s.unitsPerDose ?? null,
daysOfWeek: s.daysOfWeek ?? null,
timesOfDay: s.timesOfDay ?? [],
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: (s.doseWindows ?? null) as never,
})),
},
// Written in the same `create` as the drug, so the FK binds to
// the id this row actually got. A canonical DR file preserves
// the medication's id and a portable one mints a fresh cuid;
// neither case needs the old `medicationId` from the file,
// which is why the payload does not carry it. The note follows
// the measurement contract next to it: ciphertext verbatim when
// the file has it, legacy plaintext encrypted on the way in,
// and the plaintext column left null either way.
sideEffects: {
create: m.sideEffects.map((s) => ({
...(s.id ? { id: s.id } : {}),
userId: ownerId,
occurredAt: new Date(s.occurredAt),
category: s.category,
entry: s.entry,
severity: s.severity,
notes: null,
notesEncrypted:
s.notesEncrypted == null
? encryptNote(s.notes ?? null)
: decodeEncryptedBytes(s.notesEncrypted),
...(s.createdAt ? { createdAt: new Date(s.createdAt) } : {}),
})),
},
// Nested for the same reason as the two above — the FK binds
// to the id this drug actually got, so a portable file that
// mints fresh ids needs no remap.
//
// `resumedAt` passes through as null when the era is open. It
// is not defaulted to the restore time: a pause that was still
// running when the backup was taken is still running after the
// restore, and closing it here would silently convert an
// ongoing decision into a historical one.
pauseEras: {
create: m.pauseEras.map((p) => ({
...(p.id ? { id: p.id } : {}),
userId: ownerId,
pausedAt: new Date(p.pausedAt),
resumedAt: p.resumedAt ? new Date(p.resumedAt) : null,
...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}),
})),
},
// The titration steps, nested for the same reason. The note
// takes the measurement contract next to it: ciphertext when
// the file has it, legacy plaintext encrypted on the way in,
// and the plaintext column left null either way.
doseChanges: {
create: m.doseChanges.map((d) => ({
...(d.id ? { id: d.id } : {}),
effectiveFrom: new Date(d.effectiveFrom),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
note: null,
noteEncrypted:
d.noteEncrypted == null
? encryptNote(d.note ?? null)
: decodeEncryptedBytes(d.noteEncrypted),
...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}),
})),
},
// The packs, and the ledger behind their counts. Nested for
// the same reason as everything above it, and `unitsRemaining`
// is written VERBATIM rather than recomputed from the events —
// it is what the server had resolved and what the person was
// shown, and recomputing here would silently correct a count
// they may have adjusted by hand.
inventoryItems: {
create: m.inventoryItems.map((i) => ({
...(i.id ? { id: i.id } : {}),
userId: ownerId,
state: (i.state ?? "ACTIVE") as never,
containerType: (i.containerType ?? "OTHER") as never,
unitsTotal: i.unitsTotal,
unitsRemaining: i.unitsRemaining,
firstUseAt: i.firstUseAt ? new Date(i.firstUseAt) : null,
expiresAt: i.expiresAt ? new Date(i.expiresAt) : null,
printedExpiry: i.printedExpiry
? new Date(i.printedExpiry)
: null,
purchasedAt: i.purchasedAt ? new Date(i.purchasedAt) : null,
manufacturer: i.manufacturer ?? null,
doseStrength: i.doseStrength ?? null,
notes: null,
notesEncrypted:
i.notesEncrypted == null
? encryptNote(i.notes ?? null)
: decodeEncryptedBytes(i.notesEncrypted),
...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}),
...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}),
})),
},
// One row or none, so `create` on the optional side rather
// than a list. A file that says nothing leaves the drug
// untuned, which is what it was.
...(m.phaseConfig
? {
phaseConfig: {
create: {
...(m.phaseConfig.id ? { id: m.phaseConfig.id } : {}),
...(m.phaseConfig.greenValue !== undefined
? { greenValue: m.phaseConfig.greenValue }
: {}),
...(m.phaseConfig.greenMode
? { greenMode: m.phaseConfig.greenMode }
: {}),
...(m.phaseConfig.yellowValue !== undefined
? { yellowValue: m.phaseConfig.yellowValue }
: {}),
...(m.phaseConfig.yellowMode
? { yellowMode: m.phaseConfig.yellowMode }
: {}),
...(m.phaseConfig.orangeValue !== undefined
? { orangeValue: m.phaseConfig.orangeValue }
: {}),
...(m.phaseConfig.orangeMode
? { orangeMode: m.phaseConfig.orangeMode }
: {}),
...(m.phaseConfig.redValue !== undefined
? { redValue: m.phaseConfig.redValue }
: {}),
...(m.phaseConfig.redMode
? { redMode: m.phaseConfig.redMode }
: {}),
},
},
}
: {}),
inventoryEvents: {
create: m.inventoryEvents.map((e) => ({
...(e.id ? { id: e.id } : {}),
delta: e.delta,
reason: e.reason,
occurredAt: new Date(e.occurredAt),
})),
},
},
});
restoredMedicationIds.add(created.id);
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
// v1.39.4 — the clinical category lives in its own table keyed on the
// medication id, so it is written after the row exists and inside the
// same transaction. OTHER is what a missing row already reads as.
if (m.category && m.category !== "OTHER") {
await setMedicationCategory(
created.id,
categoryKeyRemap.get(m.category) ?? m.category,
tx,
);
}
// v1.40 (#1024) — the courses, then the window they project. A file
// written before courses existed carries none; the medication's own
// window then describes its one course, derived exactly as the
// migration's backfill did (an end-only window runs from creation,
// or from its end day when that lies earlier).
const courseRows: Array<{
startsOn: Date;
endsOn: Date | null;
note: string | null;
createdAt?: Date;
}> =
m.courses !== undefined
? m.courses.map((c) => ({
startsOn: dateOfDayKey(c.startsOn),
endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null,
note: c.note ?? null,
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
}))
: (() => {
const derived = courseFromWindow(
created.startsOn,
created.endsOn,
userDayKey(created.createdAt, restoreTz),
);
return derived ? [{ ...derived, note: null }] : [];
})();
if (courseRows.length > 0) {
await tx.medicationCourse.createMany({
data: courseRows.map((c) => ({
medicationId: created.id,
userId: ownerId,
startsOn: c.startsOn,
endsOn: c.endsOn,
noteEncrypted: c.note ? encryptToBytes(c.note) : null,
...(c.createdAt ? { createdAt: c.createdAt } : {}),
})),
});
const window = projectCourseWindow(courseRows);
await tx.medication.update({
where: { id: created.id },
data: { startsOn: window.startsOn, endsOn: window.endsOn },
});
}
// ── The archived schedule eras, in two passes ────────────────
//
// An era carries `supersededByRevisionId`, the pointer from a
// corrected ARCHIVED row to the MANUAL one that replaced it. The
// column has no `@relation`, so a value addressing nothing costs
// no error — it just stops meaning anything, which is the more
// dangerous shape here, because every era consumer SKIPS a row
// that carries the pointer. Lose it and the superseded original
// goes live again beside its own correction: two overlapping eras
// for one window, and past days minted against a plan the account
// had already corrected.
//
// A portable restore mints fresh ids, so the pointer cannot travel
// as one. It travels as a POSITION in this drug's own ordered era
// list, because that list is the only thing both ends agree on:
// the builder sorts it totally (validFrom, then createdAt, then
// id) so two eras sharing an instant cannot swap places between
// two exports of the same account, and the pointer never crosses a
// drug — every route that writes it scopes the update to one
// medication — so the drug's own list is a complete address space.
//
// Pass one writes every era with the link NULL, because the row a
// link addresses is as often written after the row addressing it
// as before. Pass two patches the links once every position has an
// id.
const revisionIds: string[] = [];
for (const revision of m.scheduleRevisions) {
const row = await tx.medicationScheduleRevision.create({
data: {
...(revision.id ? { id: revision.id } : {}),
medicationId: created.id,
validFrom: new Date(revision.validFrom),
validUntil: new Date(revision.validUntil),
payload: toJson(revision.payload),
source: revision.source ?? "ARCHIVED",
supersededByRevisionId: null,
...(revision.createdAt
? { createdAt: new Date(revision.createdAt) }
: {}),
},
select: { id: true },
});
revisionIds.push(row.id);
}
for (const [index, revision] of m.scheduleRevisions.entries()) {
const to = revision.supersededByIndex;
if (to === null || to === undefined) continue;
// A hand-edited file can name a position this drug does not
// have, or the era's OWN position — and a row that supersedes
// itself is skipped by every consumer, so the era would
// disappear from the timeline while still sitting in the table.
// Neither can be honoured and neither may be guessed at, so the
// link stays NULL and the position is reported. The era still
// restores: the window and the plan it held are the history, and
// the correction pointer is the smaller loss.
if (
!Number.isInteger(to) ||
to < 0 ||
to >= revisionIds.length ||
to === index
) {
unresolvedRevisionLinks.push(
`medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`,
);
continue;
}
await tx.medicationScheduleRevision.update({
where: { id: revisionIds[index] },
data: { supersededByRevisionId: revisionIds[to] },
});
}
for (const target of m.efficacyTargets) {
pendingEfficacyTargets.push({
medicationId: created.id,
target,
});
}
medicationIndex += 1;
}
recordUnknownKeys(
skips,
"scheduleRevisionLink",
[...new Set(unresolvedRevisionLinks)],
unresolvedRevisionLinks,
);
if (payload.intakeEvents.length > 0) {
// An event whose medication did not come back used to be mapped to
// null and filtered away: the dose history came back short and the
// restore reported success. That is the same silent-drop shape the
// cycle symptom links had, and it gets the same answer — name the
// unresolved reference and stop, so the operator learns from the
// error rather than from a compliance rate that quietly moved.
const unresolvedMedications = [
...new Set(
payload.intakeEvents
.filter(
(e) =>
!(
e.medicationId &&
restoredMedicationIds.has(e.medicationId)
) && !medByName.has(e.medication),
)
.map((e) => e.medicationId ?? e.medication),
),
];
if (unresolvedMedications.length > 0) {
throw new Error(
`Unknown medication references in intake events: ${unresolvedMedications.join(
", ",
)}. The backup records doses against medications it does not carry.`,
);
}
const rows = payload.intakeEvents
.map((e) => {
const medId =
e.medicationId && restoredMedicationIds.has(e.medicationId)
? e.medicationId
: medByName.get(e.medication);
if (!medId) return null;
return {
...(e.id ? { id: e.id } : {}),
userId: ownerId,
medicationId: medId,
scheduledFor: new Date(e.scheduledFor),
takenAt: e.takenAt ? new Date(e.takenAt) : null,
skipped: e.skipped ?? false,
autoMissed: e.autoMissed ?? false,
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 });
}
}
reportSection("mood");
// The account's own categories, tags and hidden set are written
// whether or not the file carries a mood entry: an account that set
// up its mood vocabulary before logging a single entry has them too,
// and they used to be written only inside the entries branch.
// The account's own CATEGORIES go in before its tags. A custom
// tag's `categoryId` is a real foreign key, so writing the tag
// first threw `mood_tags_category_id_fkey` and rolled the entire
// restore back — an account that had grouped its own mood factors
// could not be restored at all, and the failure named a constraint
// rather than the thing that was missing.
//
// Upserted by key for the same reason the tags below are: `key` is
// globally unique, so a seeded category that later claims the key
// must be left alone rather than overwritten by a restore.
// Labels this host's keys do not open, by file path.
const unopenedMoodLabels: string[] = [];
for (const category of payload.customMoodTagCategories) {
await tx.moodTagCategory.upsert({
where: { key: category.key },
create: {
id: category.id,
userId: ownerId,
key: category.key,
labelKey: category.labelKey,
icon: category.icon ?? null,
sortOrder: category.sortOrder ?? 0,
isActive: category.isActive ?? true,
labelEncrypted: sealForRestore(
category.labelEncrypted,
category.label,
`customMoodTagCategories.${category.key}.labelEncrypted`,
unopenedMoodLabels,
),
},
update: {},
});
}
// Re-create the account's own tag definitions first. The lookup
// below used to ask only for the seeded catalogue (`userId: null`),
// so a single custom rated tag made the whole restore throw
// "Unknown mood factor keys" — the file was complete and unusable.
for (const tag of payload.customMoodTags) {
await tx.moodTag.upsert({
where: { key: tag.key },
create: {
...(tag.id ? { id: tag.id } : {}),
userId: ownerId,
key: tag.key,
labelKey: tag.labelKey,
categoryId: tag.categoryId,
kind: tag.kind,
isActive: tag.isActive,
icon: tag.icon ?? null,
sortOrder: tag.sortOrder,
labelEncrypted: sealForRestore(
tag.labelEncrypted,
tag.label,
`customMoodTags.${tag.key}.labelEncrypted`,
unopenedMoodLabels,
),
scaleMin: tag.scaleMin,
scaleMax: tag.scaleMax,
inverse: tag.inverse,
},
// `key` is globally unique, so a seeded key would collide.
// Leave the catalogue row alone; the links resolve either way.
update: {},
});
}
recordUnknownKeys(
skips,
"moodLabelCiphertext",
unopenedMoodLabels,
unopenedMoodLabels,
);
// Which tags the account hid. Delete-then-recreate like every
// other section, and resolved by KEY: what people hide is almost
// always a seeded tag, whose id differs on every instance.
//
// A key this instance no longer knows costs one hidden row and is
// REPORTED. Dropping it silently would un-hide a factor the person
// had deliberately taken out of their mood editor, and they would
// have no way to know why it came back.
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
if (payload.hiddenMoodTags.length > 0) {
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
const hiddenRows = await tx.moodTag.findMany({
where: {
key: { in: [...new Set(hiddenKeys)] },
OR: [{ userId: null }, { userId: ownerId }],
},
select: { id: true, key: true },
});
const hiddenByKey = new Map(
hiddenRows.map((row) => [row.key, row.id]),
);
const unresolvedHidden: string[] = [];
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
if (hiddenByKey.has(entry.key)) return true;
unresolvedHidden.push(entry.key);
return false;
});
if (writableHidden.length > 0) {
await tx.moodTagHidden.createMany({
data: writableHidden.map((entry) => ({
userId: ownerId,
moodTagId: hiddenByKey.get(entry.key)!,
...(entry.createdAt
? { createdAt: new Date(entry.createdAt) }
: {}),
})),
skipDuplicates: true,
});
}
recordUnknownKeys(
skips,
"moodTag",
[...new Set(unresolvedHidden)],
unresolvedHidden,
);
}
if (payload.moodEntries.length > 0) {
const referencedFactorKeys = payload.moodEntries.flatMap((entry) =>
entry.factors.map((factor) => factor.key),
);
const factorKeys = [...new Set(referencedFactorKeys)];
const referencedTagKeys = payload.moodEntries.flatMap(
(entry) => entry.structuredTags,
);
const tagKeys = [...new Set(referencedTagKeys)];
const factorRows =
factorKeys.length === 0
? []
: await tx.moodTag.findMany({
where: {
key: { in: factorKeys },
kind: "RATED",
// The seeded catalogue OR this account's own tags.
OR: [{ userId: null }, { userId: ownerId }],
},
select: { id: true, key: true },
});
const factorByKey = new Map(
factorRows.map((factor) => [factor.key, factor.id]),
);
// A rated tag this instance does not know costs the entry's link to
// it, not the entry. The mood, the score, the date, the free tags —
// everything the person logged — comes back; the one rating that
// has nowhere to attach is dropped and named. Refusing the file
// instead meant a renamed seeded tag made the whole account
// unrecoverable.
recordUnknownKeys(
skips,
"moodFactor",
factorKeys.filter((key) => !factorByKey.has(key)),
referencedFactorKeys,
);
// The BINARY half of the same taxonomy, looked up the same way and
// reported the same way. It gets its own family because an
// operator reading the report needs to know whether a rated
// factor's score or a ticked tag went missing; they are different
// losses on the same entry.
const tagRows =
tagKeys.length === 0
? []
: await tx.moodTag.findMany({
where: {
key: { in: tagKeys },
kind: "BINARY",
OR: [{ userId: null }, { userId: ownerId }],
},
select: { id: true, key: true },
});
const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id]));
recordUnknownKeys(
skips,
"moodTag",
tagKeys.filter((key) => !tagByKey.has(key)),
referencedTagKeys,
);
for (const entry of payload.moodEntries) {
const moodLoggedAt = new Date(entry.loggedAt);
const restoredData = {
date: entry.date,
mood: entry.mood,
score: entry.score,
// The five level-A values, carried the same way `syncVersion`
// is: absent in the file means the file predates the columns,
// and an upsert whose update arm stated them would wipe live
// answers off every matched row when an older backup is
// restored over a going concern. A file that has them writes
// them, including an explicit null; a file that does not says
// nothing. Never derived — filling one in here would put a
// number the person never gave into a restored row.
...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }),
...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }),
...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }),
...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }),
...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }),
tags: entry.tags ?? null,
// The note comes back the way every other dual-column note
// does: ciphertext verbatim when the file carries it, and a
// portable file's plaintext re-encrypted on the way in, with
// the legacy column left null either way.
note: null,
noteEncrypted:
entry.noteEncrypted == null
? encryptNote(entry.note ?? null)
: decodeEncryptedBytes(entry.noteEncrypted),
source: entry.source ?? "MOODLOG",
externalId: entry.externalId ?? null,
moodLoggedAt,
// The zone the `date` string is anchored to. A file written
// before this rode the payload carries nothing, and NULL is
// what such a row meant anyway: the legacy Europe/Berlin
// reading. So an old file keeps its old day boundaries and a
// new one keeps the zone the person actually logged in.
tz: entry.tz ?? null,
// Mood reconciles last-writer-wins by `syncVersion`. Restoring
// at the schema default would hand the next sync round to any
// paired device still holding a higher number, and the device
// would overwrite the recovered row with what it had — a
// restore that loses to the client is worse than one that
// fails. An old file carries nothing here and keeps the
// default, which is the value those rows were written with.
...(entry.syncVersion === undefined
? {}
: { syncVersion: entry.syncVersion }),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
...(entry.syncedAt ? { syncedAt: new Date(entry.syncedAt) } : {}),
...(entry.createdAt
? { createdAt: new Date(entry.createdAt) }
: {}),
...(entry.updatedAt
? { updatedAt: new Date(entry.updatedAt) }
: {}),
};
const createData = { userId: ownerId, ...restoredData };
const restored = entry.id
? await tx.moodEntry.upsert({
where: { id: entry.id, userId: ownerId },
create: { id: entry.id, ...createData },
update: restoredData,
})
: entry.externalId
? await tx.moodEntry.upsert({
where: {
userId_source_externalId: {
userId: ownerId,
source: restoredData.source,
externalId: entry.externalId,
},
},
create: createData,
update: restoredData,
})
: await tx.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId: ownerId,
date: entry.date,
moodLoggedAt,
},
},
create: createData,
update: restoredData,
});
// The day context, rebuilt after the entry so it can bind to
// whatever id that entry actually got. Delete-then-write rather
// than an upsert with a preserve-when-absent arm: the file
// states the whole context or states that there was none, and a
// restored entry carrying half a file's context and half a live
// row's would be a state neither of them describes.
await tx.moodContext.deleteMany({
where: { moodEntryId: restored.id },
});
if (entry.context) {
const context = entry.context;
await tx.moodContext.create({
data: {
moodEntryId: restored.id,
userId: ownerId,
workStatus: context.workStatus ?? null,
workMinutes: context.workMinutes ?? null,
overtimeMinutes: context.overtimeMinutes ?? null,
workLoad: context.workLoad ?? null,
workSatisfaction: context.workSatisfaction ?? null,
contactCircles: context.contactCircles ?? null,
contactForm: context.contactForm ?? null,
contactExtent: context.contactExtent ?? null,
contactQuality: context.contactQuality ?? null,
contactSupport: context.contactSupport ?? null,
leisureCategories: context.leisureCategories ?? null,
leisureMinutes: context.leisureMinutes ?? null,
leisureJoy: context.leisureJoy ?? null,
leisureRecovery: context.leisureRecovery ?? null,
eventType: context.eventType ?? null,
eventValence: context.eventValence ?? null,
eventAt: context.eventAt ? new Date(context.eventAt) : null,
// The note comes back the way every other dual-column
// note does: ciphertext verbatim when the file carries
// it, a portable file's plain text re-encrypted on the
// way in.
notesEncrypted:
context.notesEncrypted == null
? encryptNote(context.note ?? null)
: decodeEncryptedBytes(context.notesEncrypted),
},
});
}
await tx.moodEntryTagLink.deleteMany({
where: { moodEntryId: restored.id },
});
const factorLinks = entry.factors.flatMap((factor) => {
const moodTagId = factorByKey.get(factor.key);
return moodTagId
? [
{
moodEntryId: restored.id,
moodTagId,
rating: factor.rating,
},
]
: [];
});
// The other arm. A BINARY link carries no score — the presence
// of the row is the signal — so `rating` is NULL by definition,
// which is exactly what the backup's old tag-link filter took to
// mean "nothing here".
const tagLinks = entry.structuredTags.flatMap((key) => {
const moodTagId = tagByKey.get(key);
return moodTagId
? [{ moodEntryId: restored.id, moodTagId, rating: null }]
: [];
});
const links = [...factorLinks, ...tagLinks];
if (links.length > 0) {
await tx.moodEntryTagLink.createMany({ data: links });
}
}
}
// v1.15.0 — cycle tables (profile + observed spans + day-logs +
// symptom links). Delete-then-recreate, mirroring the contract
// above. `notesEncrypted` is restored as ciphertext verbatim.
reportSection("cycles");
const cycleCleared = await restoreCycleData(
tx,
ownerId,
payload,
skips,
);
// Durable self-context + user-defined metrics. Both ends of this pair
// live in `src/lib/export/profile-backup.ts` beside its builder — a
// grep of THIS file alone will not find them, exactly as it does not
// find the cycle restore one line above.
reportSection("profile");
const profileCleared = await restoreProfileData(tx, ownerId, payload);
// The hourly shape of a cumulative day. Both ends live in
// `src/lib/export/intraday-profile-backup.ts` beside its builder, so
// a grep of THIS file alone will not find them either.
const intradayCleared = await restoreIntradayProfileData(
tx,
ownerId,
payload,
);
// The score as it was shown on each local day. Both ends live in
// `src/lib/export/health-score-backup.ts`, same as the two above.
const healthScoreCleared = await restoreHealthScoreData(
tx,
ownerId,
payload,
);
// The needs-based setup answers. Both ends live in
// `src/lib/export/onboarding-backup.ts`, same as the three above.
const onboardingCleared = await restoreOnboardingData(
tx,
ownerId,
payload,
);
// The account's own settings, onto the account row, column by column.
// Only what `USER_COLUMN_BACKUP_CLASS` calls a setting can be written;
// identity, credentials and this host's bookkeeping on the row stay as
// the receiving account has them. A value this host will not take is
// left out and named in the skip report. Both ends live in
// `src/lib/export/account-settings-backup.ts`.
const accountSettingsResult = await restoreAccountSettings(
tx,
ownerId,
payload,
);
for (const column of accountSettingsResult.refused) {
skips.push({ catalogue: "accountSetting", key: column, links: 1 });
}
reportSection("labs");
const biomarkerByName = new Map<string, string>();
const restoredBiomarkerIds = new Set<string>();
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),
...(biomarker.createdAt
? { createdAt: new Date(biomarker.createdAt) }
: {}),
...(biomarker.updatedAt
? { updatedAt: new Date(biomarker.updatedAt) }
: {}),
},
});
biomarkerByName.set(biomarker.name, created.id);
restoredBiomarkerIds.add(created.id);
}
// Collected for the staged facts further down: an approved fact's
// `committedRecordId` names the lab result it was committed to, and
// that reference is resolved against the rows this loop writes.
const restoredLabResultIds = new Set<string>();
// What each drug was supposed to move, written now that both ends
// exist. The drugs came back several hundred lines up and the
// analytes only just now, and one of those two had to move for a
// target to reach the database at all.
//
// The second pass wins over hoisting the biomarker restore above the
// medications, for two reasons. `biomarkerByName` and
// `restoredBiomarkerIds` are read by the lab-result loop directly
// below, so moving the block would separate a map from its only
// other consumer and leave a future edit free to re-order them back
// without anything noticing. And the direction of the dependency
// would read backwards: a pinned target is a leaf of the medication
// tree, and letting a leaf dictate where the clinical record is
// rebuilt inverts what the file is about. A second pass states the
// constraint where it applies instead of hiding it in a line order.
//
// A named analyte the restore did not put back cannot be written:
// `biomarkerId` is a real foreign key, so a dangling value would
// fail the constraint and roll the entire account back over one
// override. The row is dropped and NAMED. Dropping rather than
// nulling is deliberate here: the override IS the reference, and a
// row with neither arm set is what the resolver already reads as "no
// override", so keeping it would restore something that means
// nothing while still claiming the primary slot. A target that
// carried no name in the first place is a genuine live state (the
// analyte was deleted before the export) and comes back as written.
const unresolvedTargetAnalytes: string[] = [];
const writableTargets = pendingEfficacyTargets.filter((pending) => {
const name = pending.target.biomarkerName;
if (!name || biomarkerByName.has(name)) return true;
unresolvedTargetAnalytes.push(name);
return false;
});
if (writableTargets.length > 0) {
await tx.medicationEfficacyTarget.createMany({
data: writableTargets.map(({ medicationId, target }) => ({
...(target.id ? { id: target.id } : {}),
medicationId,
measurementType: target.measurementType ?? null,
biomarkerId: target.biomarkerName
? (biomarkerByName.get(target.biomarkerName) ?? null)
: null,
primary: target.primary ?? true,
...(target.createdAt
? { createdAt: new Date(target.createdAt) }
: {}),
...(target.updatedAt
? { updatedAt: new Date(target.updatedAt) }
: {}),
})),
});
}
recordUnknownKeys(
skips,
"medicationTarget",
[...new Set(unresolvedTargetAnalytes)],
unresolvedTargetAnalytes,
);
for (const lab of payload.labResults) {
const biomarkerId =
lab.biomarkerId !== undefined
? lab.biomarkerId === null
? null
: restoredBiomarkerIds.has(lab.biomarkerId)
? lab.biomarkerId
: undefined
: lab.biomarkerName
? biomarkerByName.get(lab.biomarkerName)
: null;
if (biomarkerId === undefined) {
throw new Error(
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
);
}
const createdLab = await tx.labResult.create({
data: {
...(lab.id ? { id: lab.id } : {}),
userId: ownerId,
biomarkerId: biomarkerId ?? null,
panel: lab.panel ?? null,
analyte: lab.analyte,
value: lab.value ?? null,
valueText: lab.valueText ?? null,
unit: lab.unit,
referenceLow: lab.referenceLow ?? null,
referenceHigh: lab.referenceHigh ?? null,
sourceReferenceLow: lab.sourceReferenceLow ?? null,
sourceReferenceHigh: lab.sourceReferenceHigh ?? null,
sourceReferenceText: lab.sourceReferenceText ?? null,
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);
}
reportSection("illness");
const episodeIds = new Set(
payload.illnessEpisodes.map((episode) => episode.id),
);
for (const episode of payload.illnessEpisodes) {
if (
episode.parentConditionId &&
!episodeIds.has(episode.parentConditionId)
) {
throw new Error(
`Unknown illness parent: ${episode.parentConditionId}`,
);
}
await tx.illnessEpisode.create({
data: {
id: episode.id,
userId: ownerId,
label: episode.label,
type: episode.type as never,
lifecycle: episode.lifecycle as never,
onsetAt: new Date(episode.onsetAt),
resolvedAt: episode.resolvedAt
? new Date(episode.resolvedAt)
: null,
parentConditionId: null,
noteEncrypted:
episode.noteEncrypted !== undefined
? episode.noteEncrypted === null
? null
: decodeEncryptedBytes(episode.noteEncrypted)
: episode.note == null
? null
: encryptToBytes(episode.note),
// v1.39.2 — the same two-armed read as the note: ciphertext
// verbatim from a disaster-recovery file, plaintext re-encrypted
// from a portable one, nothing from a file older than the field.
bodySiteEncrypted:
episode.bodySiteEncrypted !== undefined &&
episode.bodySiteEncrypted !== null
? decodeEncryptedBytes(episode.bodySiteEncrypted)
: episode.bodySite?.trim()
? encryptToBytes(episode.bodySite.trim())
: null,
laterality: episode.laterality ?? null,
deletedAt: episode.deletedAt ? new Date(episode.deletedAt) : null,
...(episode.createdAt
? { createdAt: new Date(episode.createdAt) }
: {}),
...(episode.updatedAt
? { updatedAt: new Date(episode.updatedAt) }
: {}),
},
});
}
for (const episode of payload.illnessEpisodes) {
if (episode.parentConditionId) {
await tx.illnessEpisode.update({
where: { id: episode.id, userId: ownerId },
data: { parentConditionId: episode.parentConditionId },
});
}
}
const referencedSymptomKeys = payload.illnessEpisodes.flatMap(
(episode) =>
episode.dayLogs.flatMap((dayLog) =>
dayLog.symptoms.map((symptom) => symptom.key),
),
);
const symptomKeys = [...new Set(referencedSymptomKeys)];
const symptomRows =
symptomKeys.length === 0
? []
: await tx.illnessSymptom.findMany({
where: { key: { in: symptomKeys } },
select: { id: true, key: true },
});
const symptomByKey = new Map(
symptomRows.map((symptom) => [symptom.key, symptom.id]),
);
// `IllnessSymptom` has no `userId` column at all — it is a purely
// seeded catalogue, so a key that will not resolve here can ONLY be
// catalogue drift and never a definition the file failed to carry.
// Which makes refusing the file over it the least defensible of the
// three: the file was never able to supply the missing row.
recordUnknownKeys(
skips,
"illnessSymptom",
symptomKeys.filter((key) => !symptomByKey.has(key)),
referencedSymptomKeys,
);
for (const episode of payload.illnessEpisodes) {
for (const dayLog of episode.dayLogs) {
const symptomLinks = dayLog.symptoms.flatMap((symptom) => {
const symptomId = symptomByKey.get(symptom.key);
return symptomId
? [{ symptomId, severity: symptom.severity ?? null }]
: [];
});
await tx.illnessDayLog.create({
data: {
...(dayLog.id ? { id: dayLog.id } : {}),
userId: ownerId,
episodeId: episode.id,
date: dayLog.date,
functionalImpact: dayLog.functionalImpact ?? null,
feverC: dayLog.feverC ?? null,
noteEncrypted:
dayLog.noteEncrypted !== undefined
? dayLog.noteEncrypted === null
? null
: decodeEncryptedBytes(dayLog.noteEncrypted)
: dayLog.note == null
? null
: encryptToBytes(dayLog.note),
tz: dayLog.tz ?? null,
deletedAt: dayLog.deletedAt ? new Date(dayLog.deletedAt) : null,
...(dayLog.createdAt
? { createdAt: new Date(dayLog.createdAt) }
: {}),
...(dayLog.updatedAt
? { updatedAt: new Date(dayLog.updatedAt) }
: {}),
...(symptomLinks.length > 0
? { symptomLinks: { create: symptomLinks } }
: {}),
},
});
}
}
// The person's own symptoms and their occurrences. After the episodes,
// because an occurrence may point at one; the id set is the one this
// branch just wrote. Both ends live in
// `src/lib/export/symptoms-backup.ts`.
const symptomsCleared = await restoreSymptomsData(
tx,
ownerId,
payload,
episodeIds,
skips,
);
// The person's life events (v1.42). They reference nothing but the
// account, so they have no ordering constraint and sit beside the
// other person-made rows. Both ends live in
// `src/lib/export/life-events-backup.ts`.
const lifeEventsCleared = await restoreLifeEventsData(
tx,
ownerId,
payload,
);
reportSection("allergies");
for (const allergy of payload.allergies) {
await tx.allergy.create({
data: {
id: allergy.id,
userId: ownerId,
substance: allergy.substance,
category: allergy.category as never,
type: allergy.type as never,
severity: (allergy.severity ?? null) as never,
status: allergy.status as never,
onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null,
reactionEncrypted:
allergy.reactionEncrypted !== undefined
? allergy.reactionEncrypted === null
? null
: decodeEncryptedBytes(allergy.reactionEncrypted)
: allergy.reaction == null
? null
: encryptToBytes(allergy.reaction),
notesEncrypted:
allergy.notesEncrypted !== undefined
? allergy.notesEncrypted === null
? null
: decodeEncryptedBytes(allergy.notesEncrypted)
: allergy.note == null
? null
: encryptToBytes(allergy.note),
deletedAt: allergy.deletedAt ? new Date(allergy.deletedAt) : null,
...(allergy.createdAt
? { createdAt: new Date(allergy.createdAt) }
: {}),
...(allergy.updatedAt
? { updatedAt: new Date(allergy.updatedAt) }
: {}),
},
});
}
reportSection("familyHistory");
for (const familyEntry of payload.familyHistory) {
await tx.familyHistoryEntry.create({
data: {
id: familyEntry.id,
userId: ownerId,
relationship: familyEntry.relationship as never,
condition: familyEntry.condition,
ageAtOnset: familyEntry.ageAtOnset ?? null,
notesEncrypted:
familyEntry.note == null
? null
: encryptToBytes(familyEntry.note),
...(familyEntry.createdAt
? { createdAt: new Date(familyEntry.createdAt) }
: {}),
...(familyEntry.updatedAt
? { updatedAt: new Date(familyEntry.updatedAt) }
: {}),
},
});
}
if (payload.workouts.length > 0) {
await tx.workout.createMany({
data: payload.workouts.map((workout) => ({
...(workout.id ? { id: workout.id } : {}),
userId: ownerId,
sportType: workout.sportType,
startedAt: new Date(workout.startedAt),
endedAt: new Date(workout.endedAt),
durationSec: workout.durationSec,
totalEnergyKcal: workout.totalEnergyKcal ?? null,
totalDistanceM: workout.totalDistanceM ?? null,
avgHeartRate: workout.avgHeartRate ?? null,
maxHeartRate: workout.maxHeartRate ?? null,
minHeartRate: workout.minHeartRate ?? null,
stepCount: workout.stepCount ?? null,
elevationM: workout.elevationM ?? null,
pauseDurationSec: workout.pauseDurationSec ?? null,
source: workout.source as never,
externalId: workout.externalId ?? null,
externalSourceVersion: workout.externalSourceVersion ?? null,
...(workout.metadata == null
? {}
: { metadata: toJson(workout.metadata) }),
...(workout.createdAt
? { createdAt: new Date(workout.createdAt) }
: {}),
...(workout.updatedAt
? { updatedAt: new Date(workout.updatedAt) }
: {}),
})),
});
}
if (payload.nutrientDays.length > 0) {
await tx.nutrientIntakeDay.createMany({
data: payload.nutrientDays.map((n) => ({
userId: ownerId,
day: n.day,
nutrient: n.nutrient,
amount: n.amount,
unit: n.unit,
source: n.source,
externalSourceVersion: n.externalSourceVersion ?? null,
...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}),
})),
});
}
if (payload.documents.length > 0) {
await tx.inboundDocument.createMany({
data: payload.documents.map((document) => ({
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(
document.contentEncrypted!,
),
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!),
})),
});
}
// What the vault was filed against, and what was read out of it.
// AFTER the documents and the condition episodes because both are
// foreign keys here — a filing written before either exists does not
// drop quietly, it violates a constraint and costs the operator the
// whole restore. AFTER the lab results and the medications for a
// second reason: an approved fact's `committedRecordId` is resolved
// against the rows those branches wrote, and resolving before they
// exist would null every commitment and still report success. Both
// ends of this section live in
// `src/lib/export/document-filing-backup.ts`.
reportSection("documents");
const documentFilingCleared = await restoreDocumentFilingData(
tx,
ownerId,
payload,
{
documentIds: new Set(
payload.documents.map((document) => document.id),
),
episodeIds,
committedRecordIds: new Set([
...restoredLabResultIds,
...episodeIds,
...restoredMedicationIds,
]),
},
skips,
);
// The Vorsorge reminders and their completion ledger (v1.37.20,
// #223 / iOS #68), BEFORE the visits and the vaccinations: both of
// those remap a `reminderId` against the reminders in the database,
// so the reminders have to be in the database first or every
// appointment and booster reference would read as unresolvable and
// drop. Both ends of this section live in
// `src/lib/export/reminders-backup.ts`.
reportSection("reminders");
const remindersCleared = await restoreRemindersData(
tx,
ownerId,
payload,
skips,
);
// Visits, the address book and the three link tables. After the
// documents and the reminders on purpose: a link is written only
// when both of its ends exist, and the encounter's reminder
// reference remaps against the rows the branch above has just
// written. Both ends of this section live in
// `src/lib/export/visits-backup.ts`, so a grep of THIS file alone
// will not find them — the same delegation as the cycle, profile,
// intraday and score restores above.
reportSection("visits");
const visitsCleared = await restoreVisitsData(
tx,
ownerId,
payload,
skips,
);
// The immunization log, after the visits: a dose remaps its
// practitioner, its encounter and its booster reminder against rows
// the branches above have just written, and its document link needs
// the documents that landed before that. Both ends of this section
// live in `src/lib/export/vaccinations-backup.ts`.
reportSection("vaccinations");
const vaccinationsCleared = await restoreVaccinationsData(
tx,
ownerId,
payload,
skips,
);
// The Coach's threads, turns and document links. AFTER the
// documents on purpose: an attachment carries a `documentId` that
// is a foreign key against them, so running earlier would make
// every attachment unresolvable and drop the provenance that makes
// an old answer checkable. The id set is threaded in rather than
// re-queried so the function stays a pure reader of this
// transaction. Both ends of this section live in
// `src/lib/export/coach-backup.ts`.
reportSection("coach");
const coachCleared = await restoreCoachData(
tx,
ownerId,
payload,
new Set(payload.documents.map((document) => document.id)),
skips,
);
// What the Coach keeps between threads. AFTER the transcript,
// because all three carry a `sourceConversationId` that is resolved
// against the threads the branch above has just written — resolving
// earlier would cut every fact loose from the conversation it came
// out of and still report success.
const coachMemoryCleared = await restoreCoachMemoryData(
tx,
ownerId,
payload,
new Set(payload.coachConversations.map((c) => c.id)),
new Set(
payload.coachConversations.flatMap((c) =>
c.messages.map((m) => m.id),
),
),
skips,
);
// The screener history and the consent record. Neither references
// anything but the account, so the position here is free; it sits
// last because that is where the section was added, not because
// anything above it matters to these two.
reportSection("sensitive");
const sensitiveCleared = await restoreSensitiveData(
tx,
ownerId,
payload,
);
// The bests and the badges. AFTER the measurements, and this one is
// not a preference: `PersonalRecord.sourceMeasurementId` is a real
// foreign key against `measurements` (migration 0054) even though
// `prisma/schema.prisma` declares no relation for it, so a pointer
// resolved before the measurements exist would not drop quietly.
// Postgres would refuse the insert and roll the whole restore back
// over one provenance column. The id set is the measurements this
// transaction actually wrote with a stable id, threaded in rather
// than re-queried so the function stays a pure reader of it. Both
// ends of this section live in `src/lib/export/awards-backup.ts`.
reportSection("awards");
const awardsCleared = await restoreAwardsData(
tx,
ownerId,
payload,
writtenReferencedIds,
skips,
);
// The ECG strips. AFTER the measurements on purpose: a recording
// carries a `measurementId` that is a real foreign key against the
// EVENT row it was filed with, so a reference written before that
// row exists does not merely dangle — it violates the constraint
// and rolls the whole restore back. The id set is the one the
// measurement branch actually wrote, threaded in rather than
// re-queried so the function stays a pure reader of this
// transaction. Both ends of this section live in
// `src/lib/export/ecg-backup.ts`.
reportSection("ecg");
const ecgCleared = await restoreEcgData(
tx,
ownerId,
payload,
writtenReferencedIds,
skips,
);
// The per-day readings and the location periods that explain them.
// Neither references anything but the account, so this section has
// no ordering constraint against any other and sits here beside the
// rest. What it does owe is atomicity WITH ITSELF, which is why one
// function writes both: readings restored without their periods get
// re-resolved to the home location and overwritten by the next
// environment refresh. Both ends of this section live in
// `src/lib/export/environment-backup.ts`.
reportSection("environment");
const environmentCleared = await restoreEnvironmentData(
tx,
ownerId,
payload,
);
const cleared = {
measurements: measurements.count,
medications: meds.count,
intakeEvents: intake.count,
moodEntries: moods.count,
notificationChannels: channels.count,
pushSubscriptions: subs.count,
telegramScheduledDeletions: tgDel.count,
cycles: cycleCleared.cycles,
cycleDayLogs: cycleCleared.cycleDayLogs,
cycleProfile: cycleCleared.cycleProfile,
labResults: labResults.count,
biomarkers: biomarkers.count,
nutrientDays: nutrientDays.count,
illnessEpisodes: illnessEpisodes.count,
allergies: allergies.count,
familyHistory: familyHistory.count,
workouts: workouts.count,
documents: documents.count,
documentConditionLinks: documentFilingCleared.documentConditionLinks,
extractedFacts: documentFilingCleared.extractedFacts,
healthProfile: profileCleared.healthProfile,
healthProfileFactRevisions: profileCleared.healthProfileFactRevisions,
customMetrics: profileCleared.customMetrics,
correlationPatterns: profileCleared.correlationPatterns,
intradayProfiles: intradayCleared.intradayProfiles,
healthScoreRecords: healthScoreCleared.healthScoreRecords,
onboardingRecords: onboardingCleared.onboardingRecords,
practitioners: visitsCleared.practitioners,
encounters: visitsCleared.encounters,
encounterLinks: visitsCleared.encounterLinks,
vaccinations: vaccinationsCleared.vaccinations,
vaccinationLinks: vaccinationsCleared.vaccinationLinks,
measurementReminders: remindersCleared.measurementReminders,
measurementReminderEvents: remindersCleared.measurementReminderEvents,
coachConversations: coachCleared.coachConversations,
coachMessages: coachCleared.coachMessages,
coachConversationDocuments: coachCleared.coachConversationDocuments,
coachFacts: coachMemoryCleared.coachFacts,
coachPlans: coachMemoryCleared.coachPlans,
coachReminders: coachMemoryCleared.coachReminders,
mentalHealthAssessments: sensitiveCleared.mentalHealthAssessments,
consentReceipts: sensitiveCleared.consentReceipts,
personalRecords: awardsCleared.personalRecords,
userAchievements: awardsCleared.userAchievements,
environmentContexts: environmentCleared.environmentContexts,
environmentTravelLocations:
environmentCleared.environmentTravelLocations,
ecgRecordings: ecgCleared.ecgRecordings,
symptomDefinitions: symptomsCleared.symptomDefinitions,
lifeEvents: lifeEventsCleared.lifeEvents,
};
assertNoNewForeignReferences(
foreignBefore,
await countForeignReferences(tx, ownerId, tenantEdges),
);
// Paired clients' delta cursors are no longer valid (see
// `User.syncResetAt`). Stamped as the last write, so it commits with
// the data, and stamped again right after the commit below to cover
// a cursor issued while this transaction was still running.
await stampSyncReset(tx, ownerId);
progress.sectionsDone = RESTORE_SECTION_STEPS.length;
report("sections", true);
return {
cleared,
skipped: summarizeRestoreSkips(skips),
expiredTombstonesSkipped,
accountSettings: accountSettingsResult,
};
},
{
maxWait: 10_000,
timeout: transactionTimeoutMs,
},
);
} catch (err) {
// Scrub the raw Prisma / driver error from the wire response —
// even on an admin endpoint, leaking column names, constraint
// names or query fragments lowers the cost of a future supply-
// chain attack against this surface. The verbose text still
// lands in the audit row (admin-readable) and the Wide Event
// (operator-readable), so root-cause investigation is unaffected.
const verbose = err instanceof Error ? err.message : String(err);
const classified = classifyRestoreFailure(err);
await auditLog("admin.backups.restore.failed", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
reason: "transaction_failed",
cause: classified.cause,
code: classified.code,
message: verbose,
},
});
annotate({
meta: {
restoreFailReason: verbose,
restoreFailCause: classified.cause,
restoreFailCode: classified.code,
restoreTransactionTimeoutMs: transactionTimeoutMs,
},
});
if (err instanceof ForeignReferenceError) {
return refused(422, "backup.foreign_reference", err.message, {
errorCode: "backup.foreign_reference",
references: err.edges,
});
}
if (isStoredBackupReadError(err)) {
// The second read, inside the transaction, found the copy replaced or
// altered. The transaction rolled back, so nothing was changed.
const refusal = storedBackupRefusal(err);
return refused(refusal.status, refusal.code, refusal.message, {
errorCode: refusal.code,
});
}
// The wide event and the audit row carry the whole error. The job's row
// is read by whoever started the restore, so it names the cause, never
// the database's message, which can quote the rows involved.
return refused(
500,
"transaction_failed",
RESTORE_FAILURE_CAUSE_MESSAGES[classified.cause],
{ cause: classified.cause },
);
}
// A pull that started before the commit read the pre-restore rows and may
// have been issued a cursor after the stamp inside the transaction. The
// second stamp expires that one too; it can only expire more cursors, so a
// failure here costs a client at most one extra incremental page.
try {
await stampSyncReset(prisma, ownerId);
} catch (err) {
annotate({
meta: {
restore_sync_reset_failed: true,
restore_sync_reset_error:
err instanceof Error ? err.message : String(err),
},
});
}
const { cleared, skipped, expiredTombstonesSkipped, accountSettings } =
outcome;
const measurementsRestored =
streamed.measurementCount - expiredTombstonesSkipped;
annotate({
meta: { restore_expired_tombstones_skipped: expiredTombstonesSkipped },
});
report("rebuilding", true);
await input.onCommitted?.();
// Pinned shape, not free text: a dashboard can alert on
// `restoreSkippedLinks > 0` and the key list says which catalogue drifted.
annotate({
meta: {
restoreSkippedLinks: skipped.links,
restoreSkippedKeys: skipped.catalogueKeys
.map((entry) => `${entry.catalogue}:${entry.key}`)
.join(","),
},
});
// v1.4.39.1 — re-fold the persistent measurement rollup tier from
// the just-restored measurements. Pre-fix the restore left the
// rollup table empty for the owner, so the dashboard chart's
// `source=rollup` fast-path silently returned zero buckets until
// the next worker boot ran the backfill discovery — multi-hour
// window of empty charts for the operator-restored account. Runs
// outside the transaction (the 5-year fold would otherwise hold a
// long write lock) and is best-effort so a populator hiccup never
// undoes the restore. The boot-time backfill is the safety net.
//
// The fold covers five years of the account in a handful of statements,
// right after the restore wrote every reading. On a slow disk one of them
// ran past the per-request statement limit, the fold was dropped, and the
// account's charts stayed empty until the next boot (#1031). It gets the
// time the deadline check above keeps free for it instead.
if (measurementsRestored > 0) {
try {
await recomputeUserRollups(ownerId, {
statementTimeoutMs: RESTORE_AFTER_TRANSACTION_ALLOWANCE_MS,
});
} catch (err) {
annotate({
meta: {
measurement_rollup_restore_failed: true,
measurement_rollup_restore_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
// v1.4.39 W-MOOD — re-fold the mood rollup tier from the just-
// restored entries. Runs outside the transaction so the (5-year)
// fold can't hold a long write lock; best-effort so a populator
// hiccup doesn't undo the restore. The boot-time backfill is the
// safety net — if this fails the next worker boot mints the rows.
if (payload.moodEntries.length > 0) {
try {
await recomputeUserMoodRollups(ownerId, { granularities: ["DAY"] });
} catch (err) {
annotate({
meta: {
mood_rollup_restore_failed: true,
mood_rollup_restore_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
// v1.4.39 W-MED — re-fold the medication-compliance rollup tier
// from the restored intake events. The medication delete inside the
// transaction already cascaded the existing rollup partition for
// this owner via the FK, so the fold mints fresh rows. Boot-time
// backfill is the safety net if this best-effort call fails.
if (payload.intakeEvents.length > 0) {
try {
const restoreUser = await prisma.user.findUnique({
where: { id: ownerId },
select: { timezone: true },
});
await recomputeUserMedicationCompliance(
ownerId,
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
restoreUser?.timezone ?? null,
);
} catch (err) {
annotate({
meta: {
medication_compliance_rollup_restore_failed: true,
medication_compliance_rollup_restore_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
const summary = {
...summarizeBackup(payload),
measurements: measurementsRestored,
};
await auditLog("admin.backups.restore", {
userId: input.actorUserId,
ipAddress: input.ipAddress,
details: {
backupId: backup.id,
ownerId,
ownerUsername: owner.username,
// Whether this restore also rewrote the host's own settings. The one
// effect of a restore that reaches accounts other than `ownerId`, so
// the trail has to be able to answer it later.
restoreInstanceSettings,
cleared,
// How many of the account's own settings came back. The ones that did
// not are named in `skipped` under `accountSetting`.
accountSettingsApplied: accountSettings.applied,
// The durable half of the report. The response reaches whoever was
// looking at the screen; the audit row is still here next week when
// someone asks why a day-log lost a symptom.
skipped,
restored: {
measurements: summary.measurements,
medications: summary.medications,
intakeEvents: summary.intakeEvents,
moodEntries: summary.moodEntries,
cycles: summary.cycles,
cycleDayLogs: summary.cycleDayLogs,
labResults: summary.labResults,
biomarkers: summary.biomarkers,
nutrientDays: summary.nutrientDays,
illnessEpisodes: summary.illnessEpisodes,
illnessDayLogs: summary.illnessDayLogs,
allergies: summary.allergies,
familyHistory: summary.familyHistory,
workouts: summary.workouts,
documents: summary.documents,
healthProfile: summary.healthProfile,
healthProfileFactRevisions: summary.healthProfileFactRevisions,
customMetrics: summary.customMetrics,
customMetricEntries: summary.customMetricEntries,
correlationPatterns: summary.correlationPatterns,
practitioners: summary.practitioners,
encounters: summary.encounters,
encounterLinks: summary.encounterLinks,
vaccinations: summary.vaccinations,
vaccinationLinks: summary.vaccinationLinks,
measurementReminders: summary.measurementReminders,
measurementReminderEvents: summary.measurementReminderEvents,
},
},
});
// The complete transaction succeeded; only now evict owner-scoped caches.
invalidateUserData(ownerId);
// A restored unit preference leaves the stored status notes and period
// narratives quoting the old unit, the same gap the settings routes close
// on a unit change, closed the same way. Fire-and-forget: the restore has
// committed and never fails over a re-warm.
if (accountSettings.unitsChanged) {
void refreshTextsAfterUnitChange(ownerId);
}
const response: RestoreResponse = {
restored: true,
summary,
skipped,
cleared,
};
return { ok: true, response };
}