diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 92f353364..a5ff23c07 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20564,12 +20564,14 @@ paths: post: tags: - Admin - summary: Restore one stored backup over its owner's record - description: "Replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file - carries. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the - rest of its class. The target is the account the snapshot was taken for, never the admin running it, and a - payload declaring a different owner than the stored row is refused. Admin session cookie required; Bearer tokens - cannot reach admin endpoints." + summary: Queue the restore of one stored backup over its owner's record + description: "Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, + under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the + account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the + snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown + backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) + run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. + One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints." parameters: - in: path name: id @@ -20586,12 +20588,12 @@ paths: schema: $ref: "#/components/schemas/AdminBackupRestoreRequest" responses: - "200": - description: The restore ran. + "202": + description: The restore is queued. Its progress and outcome are at `statusUrl`. content: application/json: schema: - $ref: "#/components/schemas/AdminBackupRestoreResponse" + $ref: "#/components/schemas/AdminBackupRestoreQueuedResponse" "400": description: Body was not JSON. content: @@ -20601,8 +20603,9 @@ paths: "403": *a50 "404": *a51 "409": - description: Either the payload declares a different owner than the stored row, or a request under the same - `Idempotency-Key` is still in flight. Nothing was changed. + description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, + `meta.jobId` names it), or a request under the same `Idempotency-Key` is still in flight. Nothing was + changed. content: application/json: schema: @@ -20614,10 +20617,59 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: "The request or the file was refused before anything was written: `confirm` missing, the copy undecryptable - (`meta.errorCode` = `backup.payload.undecryptable`), the payload failing schema validation, an unsupported - `schemaVersion`, a section the file's own manifest says it carries and does not (`meta.errorCode` = - `backup.section.missing`), a metadata-only document entry, or an owner who no longer exists here." + description: "The request was refused before anything was queued: `confirm` missing, or the copy undecryptable + (`meta.errorCode` = `backup.payload.undecryptable`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "503": + description: The restore could not be handed to the background worker. Nothing was changed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/admin/backups/restores: + get: + tags: + - Admin + summary: List the restores the backups console shows + description: Every queued or running restore, and those created in the last day, newest first, at most 20. What the + console reads when it opens, so a reload during a restore picks its progress up again. Admin session cookie + required; Bearer tokens cannot reach admin endpoints. + responses: + "200": + description: The restore jobs. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" + "403": *a50 + /api/admin/backups/restores/{jobId}: + get: + tags: + - Admin + summary: Read one restore job + description: The status, phase and counts of one restore, and its report or its failure once it finished. Admin session + cookie required; Bearer tokens cannot reach admin endpoints. + parameters: + - in: path + name: jobId + schema: + type: string + description: The `jobId` the restore request answered with. + required: true + description: The `jobId` the restore request answered with. + responses: + "200": + description: The restore job. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupRestoreJobResponse" + "403": *a50 + "404": + description: No restore job with this id. content: application/json: schema: @@ -30377,39 +30429,40 @@ components: anamnesis.fact.invalidValue; auth — auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — - backup.payload.undecryptable, backup.section.missing; coach — coach.budget.exceeded, - coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, - coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, - coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, - coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; - consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; - cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound, - documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound; encounter — encounter.invalid, - encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict, - encounter.reminder-not-found; environment — environment.invalid, environment.no_home, - environment.range_too_large, environment.travel.not_found; export — export.selection.empty, - export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; - health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness — - illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, - illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — - import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, - insights.generate.outboundScreened, insights.generate.profileScopeChanged, - insights.generate.scopeChanged; labs — labs.biomarker.create.invalid, labs.biomarker.duplicate, - labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, - labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, - labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + backup.payload.undecryptable, backup.restore.active, backup.section.missing; coach — + coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, + coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, + coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, + coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, + coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid, + cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, + cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, + cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.uploadBusy, documents.inbound.uploadTimeout, + documents.inbound.vaccinationNotFound; encounter — encounter.invalid, encounter.link.invalid, + encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment + — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; + export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; + feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, + health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, + illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, + illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — + ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, + insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — + labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, + labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — @@ -71214,11 +71267,11 @@ components: additionalProperties: {} description: "The backup payload as the writer serialised it: `schemaVersion`, `exportedAt`, `userId`, and one array or object per serialised class." - AdminBackupRestoreResponse: + AdminBackupRestoreQueuedResponse: type: object properties: data: - $ref: "#/components/schemas/AdminBackupRestoreResult" + $ref: "#/components/schemas/AdminBackupRestoreQueued" error: type: "null" meta: @@ -71231,49 +71284,196 @@ components: - data - error additionalProperties: false - AdminBackupRestoreResult: + AdminBackupRestoreQueued: type: object properties: - restored: - type: boolean - const: true - summary: - type: object - propertyNames: - type: string - additionalProperties: {} - skipped: + jobId: + type: string + status: + type: string + const: queued + statusUrl: + type: string + required: + - jobId + - status + - statusUrl + additionalProperties: false + description: The restore was accepted and queued. Poll `statusUrl` (`GET /api/admin/backups/restores/{jobId}`) for its + progress and outcome. + AdminBackupRestoreJobListResponse: + type: object + properties: + data: type: object properties: - catalogueKeys: + jobs: type: array items: - $ref: "#/components/schemas/AdminBackupRestoreSkipEntry" - links: - type: integer + $ref: "#/components/schemas/AdminBackupRestoreJob" + required: + - jobs + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminBackupRestoreJob: + type: object + properties: + id: + type: string + userId: + type: string + username: + anyOf: + - type: string + - type: "null" + backupId: + type: string + restoreInstanceSettings: + type: boolean + status: + type: string + enum: + - queued + - running + - succeeded + - failed + phase: + anyOf: + - $ref: "#/components/schemas/AdminBackupRestorePhase" + - type: "null" + progress: + anyOf: + - $ref: "#/components/schemas/AdminBackupRestoreProgress" + - type: "null" + result: + anyOf: + - type: object + properties: + summary: + type: object + propertyNames: + type: string + additionalProperties: {} + skipped: + type: object + properties: + catalogueKeys: + type: array + items: + $ref: "#/components/schemas/AdminBackupRestoreSkipEntry" + links: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - catalogueKeys + - links + additionalProperties: false + cleared: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - summary + - skipped + - cleared + additionalProperties: false + - type: "null" + failure: + anyOf: + - $ref: "#/components/schemas/AdminBackupRestoreFailure" + - type: "null" + attempts: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + createdAt: + type: string + startedAt: + anyOf: + - type: string + - type: "null" + completedAt: + anyOf: + - type: string + - type: "null" + required: + - id + - userId + - username + - backupId + - restoreInstanceSettings + - status + - phase + - progress + - result + - failure + - attempts + - createdAt + - startedAt + - completedAt + additionalProperties: false + description: "One restore job. `phase` and `progress` describe a running job; `result` is set once it `succeeded` (the + same report the synchronous restore used to answer with, less `restored`); `failure` once it `failed`. + `attempts` counts the starts: a job whose worker stopped is started again, at most twice." + AdminBackupRestorePhase: + type: string + enum: + - validating + - clearing + - measurements + - sections + - rebuilding + description: "Where a running restore is: reading and checking the file, clearing the account's current data, writing + the readings, writing the other sections, rebuilding the chart tiers after the commit." + AdminBackupRestoreProgress: + type: object + properties: + measurementsChecked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + measurementsTotal: + anyOf: + - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - required: - - catalogueKeys - - links - additionalProperties: false - cleared: - type: object - propertyNames: - type: string - additionalProperties: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + - type: "null" + measurementsWritten: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + sectionsDone: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + sectionsTotal: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 required: - - restored - - summary - - skipped - - cleared + - measurementsChecked + - measurementsTotal + - measurementsWritten + - sectionsDone + - sectionsTotal additionalProperties: false - description: What the transaction wrote. `cleared` counts the rows each class held before it was rebuilt from the file, - `summary` counts what the file carried, and `skipped.links` is the number of links dropped because a catalogue - key in the file does not exist here — zero is the normal answer. + description: Counts only. `measurementsTotal` is null until the first read of the file has counted the readings. AdminBackupRestoreSkipEntry: type: object properties: @@ -71291,6 +71491,45 @@ components: - links additionalProperties: false description: One catalogue key the file referenced and this instance could not resolve, with the number of links it cost. + AdminBackupRestoreFailure: + type: object + properties: + code: + type: string + description: "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, + `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, + `document_ciphertext_missing`, `time_budget`, `transaction_failed`, `interrupted`, `not_started`, + `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the + account was not changed." + message: + type: string + sections: + type: array + items: + type: string + required: + - code + - message + additionalProperties: false + description: Why the restore did not happen, in a sentence an operator can act on. `sections` names the missing sections + for `backup.section.missing`. + AdminBackupRestoreJobResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AdminBackupRestoreJob" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false __schema0: anyOf: - type: string diff --git a/messages/de.json b/messages/de.json index 5670f4dd4..0a198ed70 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7244,7 +7244,39 @@ "unknownDetail": "Noch kein nächtlicher Lauf hat dieses Konto erfasst.", "age": "vor {hours} h", "scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei." - } + }, + "restoreStarted": "Wiederherstellung gestartet. Der Fortschritt steht über der Liste.", + "restoreActive": "Für dieses Konto läuft schon eine Wiederherstellung. Warte, bis sie fertig ist.", + "restoreJobQueuedTitle": "Wiederherstellung von {username} wartet auf den Start", + "restoreJobQueuedNote": "Sie beginnt, sobald der Hintergrunddienst sie übernimmt.", + "restoreJobRunningTitle": "{username} wird wiederhergestellt", + "restoreJobRunningNote": "Das Konto zeigt seine bisherigen Daten, bis die Wiederherstellung fertig ist. Du kannst die Seite verlassen; die Wiederherstellung läuft auf dem Server weiter.", + "restoreJobResumed": "Nach einem Neustart des Servers erneut gestartet.", + "restoreJobPhaseValidating": "Backup-Datei wird geprüft: {checked} Messwerte geprüft", + "restoreJobPhaseClearing": "Bisherige Daten des Kontos werden entfernt", + "restoreJobPhaseMeasurements": "Messwerte werden geschrieben: {done} von {total}", + "restoreJobPhaseSections": "Übrige Einträge werden geschrieben: {done} von {total} Bereichen", + "restoreJobPhaseRebuilding": "Diagramme und Zusammenfassungen werden neu berechnet", + "restoreJobSucceededTitle": "Wiederherstellung von {username} abgeschlossen", + "restoreJobSucceededDescription": "{count} Datensätze wiederhergestellt. Fertig {when}.", + "restoreJobFailedTitle": "Wiederherstellung von {username} nicht abgeschlossen", + "restoreJobFailedKept": "Es wurde nichts geändert. Das Backup bleibt erhalten, du kannst die Wiederherstellung also über seine Zeile erneut starten, ohne es noch einmal hochzuladen.", + "restoreFailureNotFound": "Das Backup wurde gelöscht, bevor die Wiederherstellung es lesen konnte.", + "restoreFailureChanged": "Das Backup wurde durch eine neuere Kopie ersetzt, während die Wiederherstellung wartete. Prüfe die neue Kopie und starte die Wiederherstellung dann erneut.", + "restoreFailureUndecryptable": "Das Backup ließ sich nicht entschlüsseln. Der Schlüssel, mit dem es geschrieben wurde, ist nicht mehr eingerichtet, oder die gespeicherte Kopie wurde verändert.", + "restoreFailureSchema": "Die Backup-Datei ist kein gültiges HealthLog-Backup.", + "restoreFailureVersion": "Das Backup stammt von einer neueren HealthLog-Version. Aktualisiere zuerst diesen Server.", + "restoreFailureOwnerMismatch": "Die Datei im Backup gehört zu einem anderen Konto als die Backup-Zeile.", + "restoreFailureOwnerMissing": "Das Konto, zu dem dieses Backup gehört, gibt es nicht mehr.", + "restoreFailureSectionMissing": "Die Datei ist unvollständig: Ihr Manifest nennt einen Bereich, den sie nicht enthält.", + "restoreFailureDocument": "Die Datei enthält Dokumente ohne deren Inhalt, wie ein portabler Export. Nimm eine Notfall-Sicherung.", + "restoreFailureTimeBudget": "Dieses Backup ist zu groß, um innerhalb des Zeitlimits des Hintergrundauftrags wiederhergestellt zu werden.", + "restoreFailureTransaction": "Die Datenbank hat die Wiederherstellung mittendrin abgelehnt, deshalb wurde alles zurückgerollt. Das Server-Log nennt den Fehler.", + "restoreFailureInterrupted": "Die Wiederherstellung wurde durch einen Neustart des Servers unterbrochen und zurückgerollt. Bricht sie erneut ab, reicht dem Server wahrscheinlich der Arbeitsspeicher nicht.", + "restoreFailureNotStarted": "Der Hintergrunddienst hat die Wiederherstellung nicht übernommen. Prüfe, ob er läuft.", + "restoreFailureEnqueue": "Die Wiederherstellung konnte nicht an den Hintergrunddienst übergeben werden. Prüfe, ob er läuft.", + "restoreFailureAfterCommit": "Die Daten wurden wiederhergestellt, aber ein Schritt danach ist fehlgeschlagen. Diagramme holen das womöglich erst beim nächsten nächtlichen Lauf nach.", + "restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler." }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 692477404..23ef4eadb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7244,7 +7244,39 @@ "unknownDetail": "No nightly run has recorded this account yet.", "age": "{hours} h ago", "scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not." - } + }, + "restoreStarted": "Restore started. Its progress shows above the list.", + "restoreActive": "A restore of this account is already running. Wait for it to finish.", + "restoreJobQueuedTitle": "Restore of {username} is waiting to start", + "restoreJobQueuedNote": "It starts as soon as the background worker picks it up.", + "restoreJobRunningTitle": "Restoring {username}", + "restoreJobRunningNote": "The account keeps showing its current data until the restore finishes. You can leave this page; the restore continues on the server.", + "restoreJobResumed": "Started again after the server restarted.", + "restoreJobPhaseValidating": "Checking the backup file: {checked} readings checked", + "restoreJobPhaseClearing": "Clearing the account's current data", + "restoreJobPhaseMeasurements": "Writing readings: {done} of {total}", + "restoreJobPhaseSections": "Writing the other records: {done} of {total} sections", + "restoreJobPhaseRebuilding": "Rebuilding charts and summaries", + "restoreJobSucceededTitle": "Restore of {username} finished", + "restoreJobSucceededDescription": "{count} records restored. Finished {when}.", + "restoreJobFailedTitle": "Restore of {username} did not complete", + "restoreJobFailedKept": "Nothing was changed. The backup is kept, so you can start the restore again from its row without uploading it again.", + "restoreFailureNotFound": "The backup was deleted before the restore could read it.", + "restoreFailureChanged": "The backup was replaced by a newer copy while the restore waited. Check the new copy, then start the restore again.", + "restoreFailureUndecryptable": "The backup could not be decrypted. The key that wrote it is no longer configured, or the stored copy was changed.", + "restoreFailureSchema": "The backup file is not a valid HealthLog backup.", + "restoreFailureVersion": "The backup was written by a newer version of HealthLog. Update this server first.", + "restoreFailureOwnerMismatch": "The file inside the backup belongs to a different account than the backup row.", + "restoreFailureOwnerMissing": "The account this backup belongs to no longer exists.", + "restoreFailureSectionMissing": "The file is incomplete: its manifest names a section it does not carry.", + "restoreFailureDocument": "The file carries documents without their content, as a portable export does. Use a disaster-recovery backup.", + "restoreFailureTimeBudget": "This backup is too large to restore within the background job's time limit.", + "restoreFailureTransaction": "The database refused the restore part-way, so everything was rolled back. The server log names the error.", + "restoreFailureInterrupted": "The restore stopped when the server restarted, and was rolled back. If it stops again, the server is likely running out of memory.", + "restoreFailureNotStarted": "The background worker did not pick the restore up. Check that the worker is running.", + "restoreFailureEnqueue": "The restore could not be handed to the background worker. Check that the worker is running.", + "restoreFailureAfterCommit": "The data was restored, but a step after it failed. Charts may take until the next nightly run to catch up.", + "restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error." }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index afafe8e32..4b84a3931 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7244,7 +7244,39 @@ "unknownDetail": "Ninguna ejecución nocturna ha registrado esta cuenta todavía.", "age": "hace {hours} h", "scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos." - } + }, + "restoreStarted": "Restauración iniciada. Su progreso aparece encima de la lista.", + "restoreActive": "Ya hay una restauración en curso para esta cuenta. Espera a que termine.", + "restoreJobQueuedTitle": "La restauración de {username} está esperando para empezar", + "restoreJobQueuedNote": "Empieza en cuanto el proceso en segundo plano la recoja.", + "restoreJobRunningTitle": "Restaurando {username}", + "restoreJobRunningNote": "La cuenta sigue mostrando sus datos actuales hasta que termine la restauración. Puedes salir de esta página; la restauración continúa en el servidor.", + "restoreJobResumed": "Iniciada de nuevo tras reiniciarse el servidor.", + "restoreJobPhaseValidating": "Comprobando el archivo de copia: {checked} lecturas comprobadas", + "restoreJobPhaseClearing": "Borrando los datos actuales de la cuenta", + "restoreJobPhaseMeasurements": "Escribiendo lecturas: {done} de {total}", + "restoreJobPhaseSections": "Escribiendo el resto de registros: {done} de {total} secciones", + "restoreJobPhaseRebuilding": "Recalculando gráficos y resúmenes", + "restoreJobSucceededTitle": "Restauración de {username} terminada", + "restoreJobSucceededDescription": "{count} registros restaurados. Terminó {when}.", + "restoreJobFailedTitle": "La restauración de {username} no se completó", + "restoreJobFailedKept": "No se cambió nada. La copia se conserva, así que puedes volver a iniciar la restauración desde su fila sin subirla otra vez.", + "restoreFailureNotFound": "La copia se eliminó antes de que la restauración pudiera leerla.", + "restoreFailureChanged": "La copia se sustituyó por una más reciente mientras la restauración esperaba. Revisa la copia nueva y vuelve a iniciar la restauración.", + "restoreFailureUndecryptable": "No se pudo descifrar la copia. La clave con la que se escribió ya no está configurada, o la copia guardada se modificó.", + "restoreFailureSchema": "El archivo no es una copia de seguridad válida de HealthLog.", + "restoreFailureVersion": "La copia la escribió una versión más reciente de HealthLog. Actualiza primero este servidor.", + "restoreFailureOwnerMismatch": "El archivo de la copia pertenece a otra cuenta que la fila de la copia.", + "restoreFailureOwnerMissing": "La cuenta a la que pertenece esta copia ya no existe.", + "restoreFailureSectionMissing": "El archivo está incompleto: su manifiesto nombra una sección que no contiene.", + "restoreFailureDocument": "El archivo contiene documentos sin su contenido, como una exportación portátil. Usa una copia de recuperación ante desastres.", + "restoreFailureTimeBudget": "Esta copia es demasiado grande para restaurarse dentro del límite de tiempo del proceso en segundo plano.", + "restoreFailureTransaction": "La base de datos rechazó la restauración a mitad, así que se deshizo todo. El registro del servidor indica el error.", + "restoreFailureInterrupted": "La restauración se detuvo al reiniciarse el servidor y se deshizo. Si vuelve a detenerse, probablemente al servidor le falta memoria.", + "restoreFailureNotStarted": "El proceso en segundo plano no recogió la restauración. Comprueba que está en marcha.", + "restoreFailureEnqueue": "No se pudo entregar la restauración al proceso en segundo plano. Comprueba que está en marcha.", + "restoreFailureAfterCommit": "Los datos se restauraron, pero falló un paso posterior. Puede que los gráficos no se pongan al día hasta la próxima ejecución nocturna.", + "restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error." }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index dbc7b9952..1174510f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7244,7 +7244,39 @@ "unknownDetail": "Aucun cycle nocturne n'a encore enregistré ce compte.", "age": "il y a {hours} h", "scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit." - } + }, + "restoreStarted": "Restauration lancée. Sa progression s'affiche au-dessus de la liste.", + "restoreActive": "Une restauration de ce compte est déjà en cours. Attends qu'elle se termine.", + "restoreJobQueuedTitle": "La restauration de {username} attend de démarrer", + "restoreJobQueuedNote": "Elle démarre dès que le processus en arrière-plan la prend en charge.", + "restoreJobRunningTitle": "Restauration de {username} en cours", + "restoreJobRunningNote": "Le compte affiche ses données actuelles jusqu'à la fin de la restauration. Tu peux quitter cette page ; la restauration continue sur le serveur.", + "restoreJobResumed": "Relancée après le redémarrage du serveur.", + "restoreJobPhaseValidating": "Vérification du fichier de sauvegarde : {checked} mesures vérifiées", + "restoreJobPhaseClearing": "Suppression des données actuelles du compte", + "restoreJobPhaseMeasurements": "Écriture des mesures : {done} sur {total}", + "restoreJobPhaseSections": "Écriture des autres entrées : {done} sections sur {total}", + "restoreJobPhaseRebuilding": "Recalcul des graphiques et des résumés", + "restoreJobSucceededTitle": "Restauration de {username} terminée", + "restoreJobSucceededDescription": "{count} entrées restaurées. Terminée {when}.", + "restoreJobFailedTitle": "La restauration de {username} n'a pas abouti", + "restoreJobFailedKept": "Rien n'a été modifié. La sauvegarde est conservée : tu peux relancer la restauration depuis sa ligne sans la téléverser à nouveau.", + "restoreFailureNotFound": "La sauvegarde a été supprimée avant que la restauration puisse la lire.", + "restoreFailureChanged": "La sauvegarde a été remplacée par une copie plus récente pendant l'attente. Vérifie la nouvelle copie, puis relance la restauration.", + "restoreFailureUndecryptable": "La sauvegarde n'a pas pu être déchiffrée. La clé qui l'a écrite n'est plus configurée, ou la copie stockée a été modifiée.", + "restoreFailureSchema": "Le fichier n'est pas une sauvegarde HealthLog valide.", + "restoreFailureVersion": "La sauvegarde a été écrite par une version plus récente de HealthLog. Mets d'abord ce serveur à jour.", + "restoreFailureOwnerMismatch": "Le fichier de la sauvegarde appartient à un autre compte que la ligne de sauvegarde.", + "restoreFailureOwnerMissing": "Le compte auquel appartient cette sauvegarde n'existe plus.", + "restoreFailureSectionMissing": "Le fichier est incomplet : son manifeste nomme une section qu'il ne contient pas.", + "restoreFailureDocument": "Le fichier contient des documents sans leur contenu, comme un export portable. Utilise une sauvegarde de reprise après sinistre.", + "restoreFailureTimeBudget": "Cette sauvegarde est trop volumineuse pour être restaurée dans le temps imparti au processus en arrière-plan.", + "restoreFailureTransaction": "La base de données a refusé la restauration en cours de route, tout a donc été annulé. Le journal du serveur indique l'erreur.", + "restoreFailureInterrupted": "La restauration s'est arrêtée au redémarrage du serveur et a été annulée. Si elle s'arrête encore, le serveur manque probablement de mémoire.", + "restoreFailureNotStarted": "Le processus en arrière-plan n'a pas pris la restauration en charge. Vérifie qu'il tourne.", + "restoreFailureEnqueue": "La restauration n'a pas pu être transmise au processus en arrière-plan. Vérifie qu'il tourne.", + "restoreFailureAfterCommit": "Les données ont été restaurées, mais une étape suivante a échoué. Les graphiques ne seront peut-être à jour qu'après la prochaine exécution nocturne.", + "restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur." }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 622062c21..c687ebcfb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7244,7 +7244,39 @@ "unknownDetail": "Nessun giro notturno ha ancora registrato questo account.", "age": "{hours} h fa", "scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due." - } + }, + "restoreStarted": "Ripristino avviato. L'avanzamento compare sopra l'elenco.", + "restoreActive": "Per questo account è già in corso un ripristino. Aspetta che finisca.", + "restoreJobQueuedTitle": "Il ripristino di {username} è in attesa di partire", + "restoreJobQueuedNote": "Parte appena il processo in background lo prende in carico.", + "restoreJobRunningTitle": "Ripristino di {username} in corso", + "restoreJobRunningNote": "L'account continua a mostrare i dati attuali finché il ripristino non termina. Puoi lasciare questa pagina: il ripristino prosegue sul server.", + "restoreJobResumed": "Riavviato dopo il riavvio del server.", + "restoreJobPhaseValidating": "Controllo del file di backup: {checked} misurazioni controllate", + "restoreJobPhaseClearing": "Rimozione dei dati attuali dell'account", + "restoreJobPhaseMeasurements": "Scrittura delle misurazioni: {done} di {total}", + "restoreJobPhaseSections": "Scrittura degli altri dati: {done} di {total} sezioni", + "restoreJobPhaseRebuilding": "Ricalcolo di grafici e riepiloghi", + "restoreJobSucceededTitle": "Ripristino di {username} completato", + "restoreJobSucceededDescription": "{count} record ripristinati. Terminato {when}.", + "restoreJobFailedTitle": "Il ripristino di {username} non è stato completato", + "restoreJobFailedKept": "Non è stato modificato nulla. Il backup resta disponibile, quindi puoi riavviare il ripristino dalla sua riga senza caricarlo di nuovo.", + "restoreFailureNotFound": "Il backup è stato eliminato prima che il ripristino potesse leggerlo.", + "restoreFailureChanged": "Il backup è stato sostituito da una copia più recente mentre il ripristino aspettava. Controlla la nuova copia, poi riavvia il ripristino.", + "restoreFailureUndecryptable": "Non è stato possibile decifrare il backup. La chiave con cui è stato scritto non è più configurata, oppure la copia salvata è stata modificata.", + "restoreFailureSchema": "Il file non è un backup HealthLog valido.", + "restoreFailureVersion": "Il backup è stato scritto da una versione più recente di HealthLog. Aggiorna prima questo server.", + "restoreFailureOwnerMismatch": "Il file nel backup appartiene a un account diverso da quello della riga di backup.", + "restoreFailureOwnerMissing": "L'account a cui appartiene questo backup non esiste più.", + "restoreFailureSectionMissing": "Il file è incompleto: il suo manifesto nomina una sezione che non contiene.", + "restoreFailureDocument": "Il file contiene documenti senza il loro contenuto, come un'esportazione portabile. Usa un backup di disaster recovery.", + "restoreFailureTimeBudget": "Questo backup è troppo grande per essere ripristinato entro il tempo concesso al processo in background.", + "restoreFailureTransaction": "Il database ha rifiutato il ripristino a metà, quindi tutto è stato annullato. Il log del server indica l'errore.", + "restoreFailureInterrupted": "Il ripristino si è interrotto al riavvio del server ed è stato annullato. Se si interrompe di nuovo, probabilmente al server manca memoria.", + "restoreFailureNotStarted": "Il processo in background non ha preso in carico il ripristino. Controlla che sia in esecuzione.", + "restoreFailureEnqueue": "Non è stato possibile passare il ripristino al processo in background. Controlla che sia in esecuzione.", + "restoreFailureAfterCommit": "I dati sono stati ripristinati, ma un passaggio successivo non è riuscito. I grafici potrebbero aggiornarsi solo con la prossima esecuzione notturna.", + "restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore." }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index a4be37e2d..cd61a437d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7223,7 +7223,39 @@ "unknownDetail": "아직 어떤 야간 실행도 이 계정을 기록하지 않았습니다.", "age": "{hours}시간 전", "scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다." - } + }, + "restoreStarted": "복원을 시작했어요. 진행 상황은 목록 위에 표시돼요.", + "restoreActive": "이 계정의 복원이 이미 진행 중이에요. 끝날 때까지 기다려 주세요.", + "restoreJobQueuedTitle": "{username} 복원이 시작을 기다리고 있어요", + "restoreJobQueuedNote": "백그라운드 작업자가 가져가는 즉시 시작돼요.", + "restoreJobRunningTitle": "{username} 복원 중", + "restoreJobRunningNote": "복원이 끝날 때까지 계정에는 기존 데이터가 그대로 보여요. 이 페이지를 떠나도 복원은 서버에서 계속돼요.", + "restoreJobResumed": "서버가 다시 시작된 뒤 복원을 다시 시작했어요.", + "restoreJobPhaseValidating": "백업 파일 확인 중: 측정값 {checked}개 확인", + "restoreJobPhaseClearing": "계정의 기존 데이터를 지우는 중", + "restoreJobPhaseMeasurements": "측정값 기록 중: {total}개 중 {done}개", + "restoreJobPhaseSections": "나머지 기록 작성 중: {total}개 항목 중 {done}개", + "restoreJobPhaseRebuilding": "차트와 요약을 다시 계산하는 중", + "restoreJobSucceededTitle": "{username} 복원이 끝났어요", + "restoreJobSucceededDescription": "기록 {count}개를 복원했어요. 완료: {when}.", + "restoreJobFailedTitle": "{username} 복원이 완료되지 않았어요", + "restoreJobFailedKept": "아무것도 바뀌지 않았어요. 백업은 그대로 남아 있으니 다시 업로드하지 않고 해당 행에서 복원을 다시 시작할 수 있어요.", + "restoreFailureNotFound": "복원이 읽기 전에 백업이 삭제됐어요.", + "restoreFailureChanged": "복원이 기다리는 동안 백업이 더 최신 사본으로 바뀌었어요. 새 사본을 확인한 뒤 복원을 다시 시작해 주세요.", + "restoreFailureUndecryptable": "백업을 복호화할 수 없어요. 백업을 쓴 키가 더 이상 설정되어 있지 않거나, 저장된 사본이 바뀌었어요.", + "restoreFailureSchema": "올바른 HealthLog 백업 파일이 아니에요.", + "restoreFailureVersion": "더 새로운 HealthLog 버전이 쓴 백업이에요. 먼저 이 서버를 업데이트해 주세요.", + "restoreFailureOwnerMismatch": "백업 안의 파일이 백업 행과 다른 계정의 것이에요.", + "restoreFailureOwnerMissing": "이 백업이 속한 계정이 더 이상 없어요.", + "restoreFailureSectionMissing": "파일이 불완전해요. 매니페스트에 적힌 항목이 파일에 없어요.", + "restoreFailureDocument": "휴대용 내보내기처럼 내용 없이 문서만 담긴 파일이에요. 재해 복구용 백업을 사용해 주세요.", + "restoreFailureTimeBudget": "이 백업은 너무 커서 백그라운드 작업의 시간 제한 안에 복원할 수 없어요.", + "restoreFailureTransaction": "데이터베이스가 복원 도중에 거부해서 모든 변경을 되돌렸어요. 서버 로그에 오류가 나와 있어요.", + "restoreFailureInterrupted": "서버가 다시 시작되면서 복원이 멈췄고 되돌려졌어요. 또 멈춘다면 서버 메모리가 부족할 가능성이 커요.", + "restoreFailureNotStarted": "백그라운드 작업자가 복원을 가져가지 않았어요. 작업자가 실행 중인지 확인해 주세요.", + "restoreFailureEnqueue": "복원을 백그라운드 작업자에게 넘기지 못했어요. 작업자가 실행 중인지 확인해 주세요.", + "restoreFailureAfterCommit": "데이터는 복원됐지만 그다음 단계가 실패했어요. 차트는 다음 야간 실행 때 따라잡을 수 있어요.", + "restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요." }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 82df9291c..9d673396e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7244,7 +7244,39 @@ "unknownDetail": "Żaden nocny przebieg nie zarejestrował jeszcze tego konta.", "age": "{hours} h temu", "scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa." - } + }, + "restoreStarted": "Przywracanie rozpoczęte. Postęp widać nad listą.", + "restoreActive": "Dla tego konta trwa już przywracanie. Poczekaj, aż się skończy.", + "restoreJobQueuedTitle": "Przywracanie {username} czeka na start", + "restoreJobQueuedNote": "Zacznie się, gdy tylko proces w tle je przejmie.", + "restoreJobRunningTitle": "Przywracanie {username}", + "restoreJobRunningNote": "Konto pokazuje dotychczasowe dane, dopóki przywracanie się nie zakończy. Możesz opuścić tę stronę; przywracanie trwa dalej na serwerze.", + "restoreJobResumed": "Uruchomione ponownie po restarcie serwera.", + "restoreJobPhaseValidating": "Sprawdzanie pliku kopii: sprawdzono {checked} pomiarów", + "restoreJobPhaseClearing": "Usuwanie bieżących danych konta", + "restoreJobPhaseMeasurements": "Zapisywanie pomiarów: {done} z {total}", + "restoreJobPhaseSections": "Zapisywanie pozostałych wpisów: {done} z {total} sekcji", + "restoreJobPhaseRebuilding": "Przeliczanie wykresów i podsumowań", + "restoreJobSucceededTitle": "Przywracanie {username} zakończone", + "restoreJobSucceededDescription": "Przywrócono {count} wpisów. Zakończono {when}.", + "restoreJobFailedTitle": "Przywracanie {username} nie zostało ukończone", + "restoreJobFailedKept": "Nic nie zostało zmienione. Kopia zostaje, więc możesz ponownie uruchomić przywracanie z jej wiersza bez ponownego wgrywania.", + "restoreFailureNotFound": "Kopia została usunięta, zanim przywracanie zdążyło ją odczytać.", + "restoreFailureChanged": "Kopia została zastąpiona nowszą, gdy przywracanie czekało. Sprawdź nową kopię, a potem uruchom przywracanie ponownie.", + "restoreFailureUndecryptable": "Nie udało się odszyfrować kopii. Klucz, którym ją zapisano, nie jest już skonfigurowany albo zapisana kopia została zmieniona.", + "restoreFailureSchema": "Plik nie jest prawidłową kopią HealthLog.", + "restoreFailureVersion": "Kopię zapisała nowsza wersja HealthLog. Najpierw zaktualizuj ten serwer.", + "restoreFailureOwnerMismatch": "Plik w kopii należy do innego konta niż wiersz kopii.", + "restoreFailureOwnerMissing": "Konto, do którego należy ta kopia, już nie istnieje.", + "restoreFailureSectionMissing": "Plik jest niekompletny: jego manifest wymienia sekcję, której nie zawiera.", + "restoreFailureDocument": "Plik zawiera dokumenty bez ich treści, jak eksport przenośny. Użyj kopii do odtwarzania po awarii.", + "restoreFailureTimeBudget": "Ta kopia jest za duża, by przywrócić ją w limicie czasu procesu w tle.", + "restoreFailureTransaction": "Baza danych odrzuciła przywracanie w trakcie, więc wszystko wycofano. Dziennik serwera podaje błąd.", + "restoreFailureInterrupted": "Przywracanie zatrzymało się przy restarcie serwera i zostało wycofane. Jeśli zatrzyma się znowu, serwerowi prawdopodobnie brakuje pamięci.", + "restoreFailureNotStarted": "Proces w tle nie przejął przywracania. Sprawdź, czy działa.", + "restoreFailureEnqueue": "Nie udało się przekazać przywracania do procesu w tle. Sprawdź, czy działa.", + "restoreFailureAfterCommit": "Dane zostały przywrócone, ale krok po nich się nie powiódł. Wykresy mogą nadrobić to dopiero przy następnym nocnym przebiegu.", + "restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd." }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/prisma/migrations/0347_backup_restore_jobs/migration.sql b/prisma/migrations/0347_backup_restore_jobs/migration.sql new file mode 100644 index 000000000..2cd30c357 --- /dev/null +++ b/prisma/migrations/0347_backup_restore_jobs/migration.sql @@ -0,0 +1,47 @@ +-- Backup restores run as a background job. +-- +-- A restore used to run inside the request that asked for it. On a large +-- account that request outlived a reverse proxy's 60-second limit, so the +-- operator saw an error while the restore went on to finish. The request now +-- writes one of these rows and answers 202; the worker records progress and +-- the outcome here. Additive: nothing existing changes. + +-- CreateTable +CREATE TABLE "backup_restore_jobs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "actor_user_id" TEXT NOT NULL, + "backup_id" TEXT NOT NULL, + "backup_digest" TEXT NOT NULL, + "restore_instance_settings" BOOLEAN NOT NULL DEFAULT false, + "pg_boss_job_id" TEXT, + "status" TEXT NOT NULL DEFAULT 'queued', + "phase" TEXT, + "progress" JSONB NOT NULL DEFAULT '{}', + "result" JSONB, + "failure" JSONB, + "attempts" INTEGER NOT NULL DEFAULT 0, + "heartbeat_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "backup_restore_jobs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "backup_restore_jobs_user_id_created_at_idx" ON "backup_restore_jobs"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "backup_restore_jobs_status_heartbeat_at_idx" ON "backup_restore_jobs"("status", "heartbeat_at"); + +-- One queued or running restore per account. A second request for the same +-- account is refused with 409 by the insert this index rejects, so two +-- concurrent requests cannot both pass a read-then-write check. +CREATE UNIQUE INDEX "backup_restore_jobs_one_active_per_user" + ON "backup_restore_jobs" ("user_id") + WHERE "status" IN ('queued', 'running'); + +-- AddForeignKey +ALTER TABLE "backup_restore_jobs" ADD CONSTRAINT "backup_restore_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70acf8e51..86bb15dc2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -750,6 +750,7 @@ model User { notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] dataBackups DataBackup[] + backupRestoreJobs BackupRestoreJob[] offhostBackupState OffhostBackupState? achievements UserAchievement[] recommendationFeedback RecommendationFeedback[] @@ -983,7 +984,7 @@ model User { // record. 1:1, and keyed by record rather than by the person at the // keyboard so a guardian running a managed profile's setup writes the // child's row, not their own. See `OnboardingRecord`. - onboardingRecord OnboardingRecord? + onboardingRecord OnboardingRecord? // v1.15.20 — user-authored "about me" context for the AI surfaces // (1:1, encrypted free text). See `UserHealthProfile`. @@ -4711,14 +4712,14 @@ model AppSettings { // id or an unreachable host all parse perfectly and deliver nothing. /// Instant the last report left successfully. Consumer: the GlitchTip card's /// badge, which is only green while this is inside the recent window. - glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at") + glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at") /// Instant the last attempt failed. Consumer: the GlitchTip card's status /// line, which names the failure rather than showing a reassuring badge. - glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at") + glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at") /// Short classification of that failure (`invalid_dsn`, `http_403`, /// `network`) — never a response body, which is a remote host's text. /// Consumer: the same status line. - glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason") + glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason") // Medication reminder thresholds (minutes after windowEnd) reminderLateMinutes Int @default(120) @map("reminder_late_minutes") @@ -4957,6 +4958,94 @@ model DataBackup { @@map("data_backups") } +// ─── Backup restore jobs (v1.39.1) ────────────────────────── +// +// A restore of a stored backup runs on the `backup-restore` pg-boss queue, not +// inside the request that asked for it: rebuilding an account of 1.25 million +// readings takes about a minute and a half, 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 request checks the copy opens, writes one of these rows and +// answers 202; the worker runs the restore and writes its progress and its +// outcome here, and `GET /api/admin/backups/restores/{id}` reads them back. +// +// The staged file is the `DataBackup` row the restore reads. It is already +// encrypted at rest and stays where it is whatever the outcome, so a failed +// restore is retried from the same row without uploading anything again. +// +// At most one queued or running job per account: the partial unique index +// `backup_restore_jobs_one_active_per_user` (migration 0347, raw SQL because +// Prisma cannot express a partial index) is what makes a second request a 409 +// rather than a race. +// +// Status state machine: +// queued → running → succeeded +// ↘ failed +// running (worker gone) → queued again at the next worker boot, at most +// twice, then failed. +model BackupRestoreJob { + id String @id @default(cuid()) + /// The account the restore replaces: the backup's owner, never the admin. + /// Consumer: the admission check, the worker, and the status routes. + userId String @map("user_id") + /// The admin who asked for the restore. A plain column rather than a + /// relation, so deleting that admin later does not erase the record of who + /// replaced this account's data. Consumer: the worker's audit rows. + actorUserId String @map("actor_user_id") + /// The `DataBackup` row being restored. A plain column: the backup may be + /// deleted while the job waits, which the worker reports as a failure rather + /// than a cascade deleting the job. Consumer: the worker and the status + /// routes (the console marks the row being restored). + backupId String @map("backup_id") + /// SHA-256 of `DataBackup.data` when the restore was requested. The weekly + /// job overwrites its row in place, so the worker checks the copy it reads is + /// the one the operator previewed and confirmed. Consumer: the worker. + backupDigest String @map("backup_digest") + /// The operator's answer to "also restore the instance settings". Consumer: + /// the worker, and the status routes. + restoreInstanceSettings Boolean @default(false) @map("restore_instance_settings") + /// `pg-boss` job id of the latest delivery. Consumer: the boot sweep, which + /// completes a delivery whose worker is gone so it does not later surface as + /// a timed-out job. + pgBossJobId String? @map("pg_boss_job_id") + /// `queued | running | succeeded | failed`. Consumer: every reader. + status String @default("queued") + /// Where a running restore is: `validating | clearing | measurements | + /// sections | rebuilding`. NULL while queued and once finished. Consumer: + /// the status routes. + phase String? + /// Counts the worker writes as it goes (`RestoreProgress` in + /// `src/lib/export/restore-backup.ts`). Consumer: the status routes. + progress Json @default("{}") + /// On success: what the restore wrote, cleared and skipped. Counts and + /// catalogue keys only, the same facts the audit row carries. Consumer: the + /// status routes. + result Json? + /// On failure: a stable `code`, a sentence an operator can act on, and the + /// missing sections when that was the reason. Consumer: the status routes. + failure Json? + /// How many times a worker has started this job. Consumer: the boot sweep, + /// which stops re-queueing after the second attempt. + attempts Int @default(0) + /// Bumped every few seconds while a worker runs the job. A running row whose + /// heartbeat has gone stale belongs to a worker that is gone. Consumer: the + /// admission check and the boot sweep. + heartbeatAt DateTime? @map("heartbeat_at") + createdAt DateTime @default(now()) @map("created_at") + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // `backup_restore_jobs_one_active_per_user` is a partial UNIQUE index over + // `(user_id) WHERE status IN ('queued', 'running')` — see migration 0347. + // Prisma can't express a partial index in the schema language, so it lives + // in raw SQL and is intentionally omitted here. + @@index([userId, createdAt]) + @@index([status, heartbeatAt]) + @@map("backup_restore_jobs") +} + // ─── Off-host backup ledger ────────────────────────────────── // // One row per account, written by the nightly off-host job the moment an @@ -5338,7 +5427,7 @@ model MeasurementReminder { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Back-relation for the appointment arm: a PLANNED `Encounter` mints /// exactly one ENCOUNTER-origin row and points at it. SetNull on the far /// side, so deleting the reminder leaves the visit intact. @@ -5622,15 +5711,15 @@ model CustomMetric { /// snapshot of the metric's unit at write time (historical truth, mirroring the /// LabResult per-row unit). Charts read these LIVE (no rollup tier). model CustomMetricEntry { - id String @id @default(cuid()) - userId String @map("user_id") - customMetricId String @map("custom_metric_id") + id String @id @default(cuid()) + userId String @map("user_id") + customMetricId String @map("custom_metric_id") value Float /// Unit snapshot from the metric at write time. unit String - measuredAt DateTime @map("measured_at") + measuredAt DateTime @map("measured_at") note String? - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") /// v1.37.20 — soft-delete tombstone, peer parity with Measurement / /// MoodEntry (every other entry surface deletes recoverably with an undo /// toast; custom-metric entries were the one hard delete). NULL = live. @@ -6138,28 +6227,28 @@ model CoachReminder { // must never land in the table as plaintext. A cleared profile keeps the // row with a NULL payload so `updatedAt` documents the deletion instant. model UserHealthProfile { - id String @id @default(cuid()) - userId String @unique @map("user_id") + id String @id @default(cuid()) + userId String @unique @map("user_id") // Free text, hard-capped at 4 000 chars by the route's Zod schema // BEFORE encryption. NULL = cleared / never written. - aboutMeEncrypted Bytes? @map("about_me_encrypted") + aboutMeEncrypted Bytes? @map("about_me_encrypted") // v1.16.0 — structured self-context companions to the free text. // Short answers (each capped at 500 chars BEFORE encryption). // Age/gender deliberately do NOT live here — they come from the User // profile and are merged into the prompt block at assembly time so // the data is never duplicated. - conditionsEncrypted Bytes? @map("conditions_encrypted") - allergiesEncrypted Bytes? @map("allergies_encrypted") - coachFocusEncrypted Bytes? @map("coach_focus_encrypted") + conditionsEncrypted Bytes? @map("conditions_encrypted") + allergiesEncrypted Bytes? @map("allergies_encrypted") + coachFocusEncrypted Bytes? @map("coach_focus_encrypted") // v1.16.0 — up to 3 clarifying questions the server derives after a // save (AI-generated when a provider + budget allow, deterministic // hints otherwise). Encrypted JSON string[]; the Coach composer // renders them as tappable suggestion chips. NULL = none pending. - pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") + pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") // Closed inclusion list for self-context sent to Coach and briefing prompts. // The static default preserves every pre-existing prompt section for legacy // rows without automatically opting future enum members in. - aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections") + aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections") // Emergency ("Notfalldaten") facts, surfaced on page one of the doctor // report and edited in Settings -> Anamnese. Three closed-set enums are // plaintext (read directly by the report, like `User.gender`); three free @@ -6167,14 +6256,14 @@ model UserHealthProfile { // `*Encrypted` column. All six are nullable: an unset emergency profile is // absent, not empty. The report page is withheld unless the EMERGENCY leaf is // admitted AND at least one of these holds a value. - emergencyBloodType EmergencyBloodType? @map("emergency_blood_type") - organDonorStatus OrganDonorStatus? @map("organ_donor_status") - advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status") - emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted") - emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted") - emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + emergencyBloodType EmergencyBloodType? @map("emergency_blood_type") + organDonorStatus OrganDonorStatus? @map("organ_donor_status") + advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status") + emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted") + emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted") + emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -6285,10 +6374,10 @@ model HealthProfileFactRevision { // secondary signal used by the frontend to render a "≈ N replies left" // hint without exposing raw token figures. model CoachUsage { - id String @id @default(cuid()) - userId String @map("user_id") - dateKey String @map("date_key") - totalTokens Int @default(0) @map("total_tokens") + id String @id @default(cuid()) + userId String @map("user_id") + dateKey String @map("date_key") + totalTokens Int @default(0) @map("total_tokens") /// v1.38.19 — the share of `totalTokens` served by a provider the /// OPERATOR funds (`admin-openai` / `admin-codex`). The operator-cost cap is /// enforced against this counter and the user-plan cap against diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index fca11a857..0da7c7f34 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -321,7 +321,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => { it("does not count a nested relation read as a restore write", () => { expect( touches( - ["src/app/api/admin/backups/[id]/restore/route.ts"], + ["src/lib/export/restore-backup.ts"], "User", WRITE_OPS, WRITE_RELATION_OPS, diff --git a/src/__tests__/backup-readers-stream-guard.test.ts b/src/__tests__/backup-readers-stream-guard.test.ts index 8798c6586..5e81644df 100644 --- a/src/__tests__/backup-readers-stream-guard.test.ts +++ b/src/__tests__/backup-readers-stream-guard.test.ts @@ -12,7 +12,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const READERS = [ - "src/app/api/admin/backups/[id]/restore/route.ts", + "src/lib/export/restore-backup.ts", "src/app/api/admin/backups/[id]/summary/route.ts", "src/app/api/admin/backups/[id]/download/route.ts", "src/app/api/admin/backups/upload/route.ts", diff --git a/src/__tests__/measurement-write-plausibility-guard.test.ts b/src/__tests__/measurement-write-plausibility-guard.test.ts index cc4428f9f..b35b56f35 100644 --- a/src/__tests__/measurement-write-plausibility-guard.test.ts +++ b/src/__tests__/measurement-write-plausibility-guard.test.ts @@ -114,7 +114,7 @@ const WRITERS: Record = { }, // ── Restore ─────────────────────────────────────────────────────────── - "app/api/admin/backups/[id]/restore/route.ts": { + "lib/export/restore-backup.ts": { kind: "restore", why: "replays the record's own export; dropping rows would break the restore contract", }, diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts index 47af62008..72195978b 100644 --- a/src/__tests__/workout-sport-provenance-guard.test.ts +++ b/src/__tests__/workout-sport-provenance-guard.test.ts @@ -177,7 +177,7 @@ describe("T2 — the set of files writing Workout rows is frozen", () => { "app/api/workouts/batch/route.ts": "client-supplied enum", // Backup restore replays rows this app previously wrote, provenance and // all. - "app/api/admin/backups/[id]/restore/route.ts": "restore replay", + "lib/export/restore-backup.ts": "restore replay", }; it("only the registered ingests and the declared non-mappers create Workout rows", () => { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 7f8f2b4af..017cfca63 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -1,172 +1,44 @@ /** * POST /api/admin/backups/[id]/restore — admin-only disaster recovery. * - * 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. + * Queues the restore of one stored backup over its owner's record and answers + * 202 with the job's id. The restore itself (`restoreBackup` in + * `src/lib/export/restore-backup.ts`) runs on the `backup-restore` queue: on a + * large account it takes longer than a reverse proxy lets a request live, and + * an operator who saw the proxy's error could not tell that the restore went + * on to finish. `GET /api/admin/backups/restores/{jobId}` reports how it went. * - * 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. + * What this request still decides on the spot, because each is quick and each + * is the operator's to fix: the typed confirmation, that the backup exists, + * and that its stored copy opens under the keys this host holds. A copy that + * will not open is refused here, before anything is queued. The file's own + * checks (schema, owner, manifest, documents) read all of it and so run in the + * job, still before the first delete. * - * 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. + * One restore per account at a time: a second request while one is queued or + * running answers 409 and names the running job. */ -import { Buffer } from "node:buffer"; - import { NextRequest } from "next/server"; -import { prisma, toJson } from "@/lib/db"; + import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; import { BACKUP_UNDECRYPTABLE_CODE, BACKUP_UNDECRYPTABLE_ERROR, openBackupBlob, } from "@/lib/export/backup-blob"; -import { - insertMeasurementRows, - type MeasurementInsertRow, -} from "@/lib/export/measurement-bulk-insert"; -import { - readStreamedBackup, - type BackupSource, - type StreamedBackup, -} from "@/lib/export/streamed-backup"; -import { encryptNote } from "@/lib/crypto/note-cipher"; -import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; -import { encryptNoteToBytes } from "@/lib/labs/store"; import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency"; +import { + admitBackupRestore, + backupDigest, + BACKUP_RESTORE_ACTIVE_CODE, +} from "@/lib/jobs/backup-restore"; import { annotate } from "@/lib/logging/context"; -import { - parseBackupPayload, - isCompatibleSchemaVersion, - summarizeBackup, - type BackupMeasurement, - type BackupSummary, -} from "@/lib/validations/backup"; -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 { 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 { invalidateUserData } from "@/lib/cache/invalidate"; -import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability"; export const dynamic = "force-dynamic"; -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; - }; -} - -/** - * How long the restore transaction may run. Two minutes was the whole budget - * when a record was a few hundred thousand rows; deleting and rewriting 1.25 - * million measurements in one transaction takes longer than that (#1031). On - * the test host the whole restore of that account, reading the file twice - * included, took 94 s. The budget grows by a second per 5 000 measurements, - * which gives that account six minutes, about four times what it needed, and - * never drops below the old two minutes. - */ -function restoreTransactionTimeoutMs(measurementCount: number): number { - return 120_000 + Math.ceil(measurementCount / 5); -} - -function decodeEncryptedBytes(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; -} - const handler = apiHandler( async ( request: NextRequest, @@ -210,7 +82,7 @@ const handler = apiHandler( const backup = await prisma.dataBackup.findUnique({ where: { id }, - include: { user: { select: { id: true, username: true } } }, + select: { id: true, userId: true, data: true }, }); if (!backup) { await auditLog("admin.backups.restore.denied", { @@ -221,12 +93,12 @@ const handler = apiHandler( throw new HttpError(404, "Backup not found"); } - // 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 the whole ciphertext first, as unpacking did. - let source: BackupSource; + // Opening authenticates the whole ciphertext (and decompresses nothing), + // so a copy written under a key this host no longer holds is refused now, + // in the answer the operator is looking at, rather than minutes later in + // a job. Bad stored input, not a broken server: 422, nothing queued. try { - source = openBackupBlob(backup.data); + openBackupBlob(backup.data); } catch (err) { await auditLog("admin.backups.restore.failed", { userId: admin.id, @@ -237,2029 +109,82 @@ const handler = apiHandler( 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. Every - // other bad-input arm on this route answers 4xx, and a 500 here would - // also page the error reporter for a rotation mistake. Refused above the - // transaction, so nothing was touched. return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, { errorCode: BACKUP_UNDECRYPTABLE_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. - let raw: unknown; - let payload; - let streamed: StreamedBackup; - try { - streamed = await readStreamedBackup(source); - raw = streamed.raw; - payload = parseBackupPayload(raw); - } catch (err) { - await auditLog("admin.backups.restore.failed", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId: backup.userId, - reason: "schema_invalid", - message: err instanceof Error ? err.message : String(err), - }, - }); - return apiError("Backup payload failed schema validation", 422); - } + const admission = await admitBackupRestore({ + userId: backup.userId, + actorUserId: admin.id, + backupId: backup.id, + backupDigest: backupDigest(backup.data), + restoreInstanceSettings, + }); - if (!isCompatibleSchemaVersion(payload.schemaVersion)) { - await auditLog("admin.backups.restore.failed", { + if (!admission.admitted && admission.reason === "active") { + await auditLog("admin.backups.restore.denied", { userId: admin.id, ipAddress: getClientIp(request), details: { + reason: "restore_active", backupId: id, ownerId: backup.userId, - reason: "incompatible_schema_version", - schemaVersion: payload.schemaVersion, + activeJobId: admission.activeJobId, }, }); return apiError( - `Backup schema version '${payload.schemaVersion}' is not supported by this server`, - 422, - ); - } - - // 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: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId: backup.userId, - reason: "owner_mismatch", - declaredOwnerId: payload.userId, - }, - }); - return apiError( - "Backup payload declares a different owner than the backup record", + "A restore of this account is already running. Wait for it to finish before starting another.", 409, + { errorCode: BACKUP_RESTORE_ACTIVE_CODE, jobId: admission.activeJobId }, ); } - - // 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) { + if (!admission.admitted) { await auditLog("admin.backups.restore.failed", { userId: admin.id, ipAddress: getClientIp(request), details: { + reason: "enqueue_failed", backupId: id, - ownerId, - reason: "owner_not_found", + ownerId: backup.userId, + jobId: admission.jobId, }, }); return apiError( - `Backup owner '${ownerId}' no longer exists in this DB`, - 422, + "The restore could not be handed to the background worker. Nothing was changed.", + 503, ); } - // Audit the *intent* before the transaction begins so the trail - // describing "an admin is about to restore " survives even - // if the operation crashes midway. - await auditLog("admin.backups.restore.start", { + await auditLog("admin.backups.restore.queued", { userId: admin.id, ipAddress: getClientIp(request), details: { backupId: id, - ownerId, - ownerUsername: owner.username, - snapshotExportedAt: payload.exportedAt, + ownerId: backup.userId, + jobId: admission.jobId, restoreInstanceSettings, }, }); + annotate({ meta: { restore_job_id: admission.jobId } }); - // 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: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId, - reason: "section_missing", - sections: missingSections, - }, - }); - return apiError( - `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.`, - 422, - { 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, + return apiSuccess( + { + jobId: admission.jobId, + status: "queued" as const, + statusUrl: `/api/admin/backups/restores/${admission.jobId}`, + }, + 202, ); - if (incompleteDocument) { - await auditLog("admin.backups.restore.failed", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId, - reason: "document_ciphertext_missing", - documentId: incompleteDocument.id, - }, - }); - return apiError( - `Document '${incompleteDocument.id}' is metadata-only and cannot be restored`, - 422, - ); - } - - let outcome: { - cleared: RestoreResponse["cleared"]; - skipped: RestoreSkipSummary; - }; - try { - outcome = await prisma.$transaction( - async (tx) => { - // 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, - 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. - aggregationProvenance: (measurement.aggregationProvenance ?? - (measurement.source === "APPLE_HEALTH" && - measurement.externalId?.startsWith("stats:") - ? "LEGACY_UNKNOWN" - : null)) as never, - glucoseContext: (measurement.glucoseContext ?? null) as never, - sleepStage: (measurement.sleepStage ?? null) as never, - rhythmClassification: (measurement.rhythmClassification ?? - null) as never, - deviceType: measurement.deviceType ?? null, - syncVersion: measurement.syncVersion ?? 1, - deletedAt: measurement.deletedAt - ? new Date(measurement.deletedAt) - : null, - ...(measurement.createdAt - ? { createdAt: new Date(measurement.createdAt) } - : {}), - ...(measurement.updatedAt - ? { updatedAt: new Date(measurement.updatedAt) } - : {}), - }); - - // The measurements, a batch at a time from a second read of the - // file, so the restore never holds more than one batch of them. - // - // Two later sections point INTO the measurements (a record's - // `sourceMeasurementId`, an ECG strip's `measurementId`) and have - // to know which of those ids this transaction actually wrote. They - // used to be handed every written id; they only ever ask about the - // ones they reference, so only those are collected. - const referencedMeasurementIds = new Set(); - for (const record of payload.personalRecords) { - if (record.sourceMeasurementId) { - referencedMeasurementIds.add(record.sourceMeasurementId); - } - } - for (const recording of payload.ecgRecordings) { - if (recording.measurementId) { - referencedMeasurementIds.add(recording.measurementId); - } - } - const writtenReferencedIds = new Set(); - const measurementBatchSize = 1_000; - await streamed.forEachMeasurementBatch( - measurementBatchSize, - async (batch) => { - 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); - for (const row of stableRows) { - if (referencedMeasurementIds.has(row.id)) { - writtenReferencedIds.add(row.id); - } - } - - // v1 payloads did not require stable ids. Preserve their - // historical natural-key reconciliation without routing - // canonical v2 rows through it. - for (const measurement of batch) { - if (measurement.id) continue; - const restoredData = toRestoredMeasurementData(measurement); - const existing = await tx.measurement.findFirst({ - where: { - userId: ownerId, - type: measurement.type, - source: restoredData.source, - measuredAt: restoredData.measuredAt, - sleepStage: restoredData.sleepStage, - }, - select: { id: true }, - }); - if (existing) { - await tx.measurement.update({ - where: { id: existing.id, userId: ownerId }, - data: restoredData, - }); - } else { - await tx.measurement.create({ - data: { userId: ownerId, ...restoredData }, - }); - } - } - }, - ); - - const medByName = new Map(); - const restoredMedicationIds = new Set(); - // The 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[] = []; - let medicationIndex = 0; - 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, - 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, - ...(m.createdAt ? { createdAt: new Date(m.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); - - // ── 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 => r !== null); - if (rows.length > 0) { - await tx.medicationIntakeEvent.createMany({ data: rows }); - } - } - - 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)]; - // 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. - 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: category.labelEncrypted ?? null, - }, - 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: tag.labelEncrypted ?? null, - 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: {}, - }); - } - - // 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, - ); - } - - 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. - 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. - 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, - ); - - const biomarkerByName = new Map(); - const restoredBiomarkerIds = new Set(); - for (const biomarker of payload.biomarkers) { - const created = await tx.biomarker.create({ - data: { - ...(biomarker.id ? { id: biomarker.id } : {}), - userId: ownerId, - name: biomarker.name, - unit: biomarker.unit, - lowerBound: biomarker.lowerBound ?? null, - upperBound: biomarker.upperBound ?? null, - panel: biomarker.panel ?? null, - hidden: biomarker.hidden ?? false, - contextEncrypted: - biomarker.context == null - ? null - : encryptContextToBytes(biomarker.context), - ...(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(); - // 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); - } - - 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), - 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 }, - 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 } } - : {}), - }, - }); - } - } - - 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) } - : {}), - }, - }); - } - - 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, - ...(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, - })), - }); - } - - 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, - 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`. - 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`. - 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. - 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`. - 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`. - 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)), - 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. - 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`. - 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`. - 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`. - 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, - }; - return { cleared, skipped: summarizeRestoreSkips(skips) }; - }, - { - maxWait: 10_000, - timeout: restoreTransactionTimeoutMs(streamed.measurementCount), - }, - ); - } 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); - await auditLog("admin.backups.restore.failed", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId, - reason: "transaction_failed", - message: verbose, - }, - }); - annotate({ meta: { restoreFailReason: verbose } }); - return apiError("Restore failed", 500); - } - - const { cleared, skipped } = outcome; - - // 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. - if (streamed.measurementCount > 0) { - try { - await recomputeUserRollups(ownerId); - } 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: streamed.measurementCount, - }; - - await auditLog("admin.backups.restore", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: 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, - // 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); - - const response: RestoreResponse = { - restored: true, - summary, - skipped, - cleared, - }; - return apiSuccess(response); }, ); -// `withIdempotency` wraps the apiHandler so a duplicate retry with the -// same `Idempotency-Key` replays the original 200 instead of executing -// the destructive transaction twice. The default resolver picks up -// either the cookie session OR a Bearer token; for an admin endpoint -// only cookie sessions ever get past `requireAdmin()` upstream, but -// keeping the default keeps the contract uniform. +// `withIdempotency` wraps the apiHandler so a duplicate retry with the same +// `Idempotency-Key` replays the original 202 and its job id instead of queuing +// a second restore. The default resolver picks up either the cookie session +// OR a Bearer token; for an admin endpoint only cookie sessions ever get past +// `requireAdmin()` upstream, but keeping the default keeps the contract +// uniform. export const POST = withIdempotency(handler, async () => { return defaultUserIdResolver(); }); diff --git a/src/app/api/admin/backups/restores/[jobId]/route.ts b/src/app/api/admin/backups/restores/[jobId]/route.ts new file mode 100644 index 000000000..7270bb688 --- /dev/null +++ b/src/app/api/admin/backups/restores/[jobId]/route.ts @@ -0,0 +1,34 @@ +/** + * GET /api/admin/backups/restores/[jobId] — how one restore is going. + * + * The status, the phase and the counts while it runs; what it wrote, cleared + * and skipped once it succeeded; a stable code and a sentence an operator can + * act on once it failed. + */ +import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + evictAfterRestore, + readBackupRestoreJob, +} from "@/lib/jobs/backup-restore"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler( + async ( + _request: Request, + { params }: { params: Promise<{ jobId: string }> }, + ) => { + await requireAdmin(); + const { jobId } = await params; + annotate({ + action: { name: "admin.backups.restores.read" }, + meta: { jobId }, + }); + const job = await readBackupRestoreJob(jobId); + if (!job) throw new HttpError(404, "Restore job not found"); + evictAfterRestore(job); + return apiSuccess(job); + }, +); diff --git a/src/app/api/admin/backups/restores/route.ts b/src/app/api/admin/backups/restores/route.ts new file mode 100644 index 000000000..9e3d2ffb7 --- /dev/null +++ b/src/app/api/admin/backups/restores/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/admin/backups/restores — the restores the backups console shows. + * + * Every queued or running restore, and those that finished in the last day, + * newest first. The console reads it when it opens, so a reload during a + * restore picks the progress up again, and a restore that finished while + * nobody was looking still says how it ended. + */ +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + evictAfterRestore, + listRecentBackupRestoreJobs, +} from "@/lib/jobs/backup-restore"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.backups.restores.list" } }); + const jobs = await listRecentBackupRestoreJobs(); + for (const job of jobs) evictAfterRestore(job); + return apiSuccess({ jobs }); +}); diff --git a/src/components/admin/__tests__/backup-restore-status.test.tsx b/src/components/admin/__tests__/backup-restore-status.test.tsx new file mode 100644 index 000000000..94b604c10 --- /dev/null +++ b/src/components/admin/__tests__/backup-restore-status.test.tsx @@ -0,0 +1,179 @@ +/** + * What the backups console shows while a restore runs and after it ends. + * + * The restore runs as a background job; this is the only place an operator + * learns how it is going. Pinned here: + * 1. a running restore names its phase with the counts and draws a bar + * only where the share is known, and says the account still shows its + * current data; + * 2. a failed restore says what went wrong and that nothing changed, except + * for the one failure after the commit, where saying so would be false; + * 3. a succeeded restore reports through the outcome module, so an empty + * file restored to an empty account is not drawn as a tick; + * 4. the panels shown are every active job and the newest finished one not + * dismissed; + * 5. the copy resolves in more than one locale. + * + * Mutation check: always print the "nothing was changed" line and case 2's + * after-commit test goes red; render the bar in every phase and case 1's + * clearing test goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + RestoreJobStatus, + visibleRestoreJobs, +} from "@/components/admin/backup-restore-status"; +import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/backups", +})); + +function job(overrides: Partial): BackupRestoreJobView { + return { + id: "job-1", + userId: "user-1", + username: "alex", + backupId: "backup-1", + restoreInstanceSettings: false, + status: "running", + phase: "measurements", + progress: { + measurementsChecked: 1_250_000, + measurementsTotal: 1_250_000, + measurementsWritten: 400_000, + sectionsDone: 0, + sectionsTotal: 17, + }, + result: null, + failure: null, + attempts: 1, + createdAt: "2026-09-25T08:00:00.000Z", + startedAt: "2026-09-25T08:00:01.000Z", + completedAt: null, + ...overrides, + }; +} + +function render(view: BackupRestoreJobView, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe("RestoreJobStatus while it runs", () => { + it("names the phase with its counts and draws the share done", () => { + const html = render(job({})); + expect(html).toContain("Restoring alex"); + expect(html).toContain("Writing readings: 400,000 of 1,250,000"); + expect(html).toContain('data-slot="restore-job-progress"'); + expect(html).toContain("keeps showing its current data"); + }); + + it("draws no bar where the share is not known", () => { + const html = render(job({ phase: "clearing" })); + expect(html).toContain("Clearing the account"); + expect(html).not.toContain('data-slot="restore-job-progress"'); + }); + + it("says a queued restore is waiting", () => { + const html = render(job({ status: "queued", phase: null, progress: null })); + expect(html).toContain("waiting to start"); + }); + + it("says when it was started again after a restart", () => { + expect(render(job({ attempts: 2 }))).toContain("Started again"); + }); +}); + +describe("RestoreJobStatus once it failed", () => { + it("says why, and that nothing changed", () => { + const html = render( + job({ + status: "failed", + phase: null, + failure: { code: "interrupted", message: "server text" }, + }), + ); + expect(html).toContain("did not complete"); + expect(html).toContain("server restarted"); + expect(html).toContain("Nothing was changed"); + expect(html).toContain('data-failure-code="interrupted"'); + }); + + it("does not claim nothing changed when the data was restored", () => { + const html = render( + job({ + status: "failed", + phase: null, + failure: { code: "failed_after_commit", message: "server text" }, + }), + ); + expect(html).toContain("The data was restored"); + expect(html).not.toContain("Nothing was changed"); + }); +}); + +describe("RestoreJobStatus once it succeeded", () => { + const result = { + summary: { measurements: 1_250_000, medications: 3 }, + skipped: { links: 0, catalogueKeys: [] }, + cleared: { measurements: 10 }, + }; + + it("reports the count through the outcome module", () => { + const html = render( + job({ + status: "succeeded", + phase: null, + result, + completedAt: "2026-09-25T08:02:00.000Z", + }), + ); + expect(html).toContain("Restore of alex finished"); + expect(html).toContain("1,250,003 records restored"); + expect(html).toContain('data-outcome="success"'); + }); + + it("does not draw a tick for a file that restored nothing", () => { + const html = render( + job({ + status: "succeeded", + phase: null, + result: { ...result, summary: {} }, + }), + ); + expect(html).toContain('data-outcome="empty"'); + }); +}); + +describe("visibleRestoreJobs", () => { + it("shows every active job and the newest finished one not dismissed", () => { + const jobs = [ + job({ id: "running", status: "running" }), + job({ id: "newest-done", status: "succeeded" }), + job({ id: "older-done", status: "failed" }), + ]; + expect(visibleRestoreJobs(jobs, new Set()).map((j) => j.id)).toEqual([ + "running", + "newest-done", + ]); + expect( + visibleRestoreJobs(jobs, new Set(["newest-done"])).map((j) => j.id), + ).toEqual(["running", "older-done"]); + }); +}); + +describe("RestoreJobStatus in another language", () => { + it("speaks German", () => { + const html = render(job({}), "de"); + expect(html).toContain("alex wird wiederhergestellt"); + expect(html).toContain("Messwerte werden geschrieben"); + }); +}); diff --git a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx index d818af957..0553d6859 100644 --- a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx +++ b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx @@ -3,7 +3,7 @@ * * The server refuses a file whose manifest claims a section the file does not * carry, before it deletes anything. That refusal reaches a person through the - * `meta.errorCode` on the error envelope, and a bare "restore failed" toast + * `code` on the restore job's failure, and a bare "restore failed" toast * would waste it: the operator on the other end is frequently holding the only * copy of an account, and the two facts they can act on — which section is * missing, and that nothing was changed — have to stay on screen. @@ -14,7 +14,7 @@ * 3. no sections means no notice — a panel that appears after every restore * trains the operator to ignore it, * 4. the copy resolves in more than one locale, - * 5. `missingSectionsOf` keys on the errorCode and not on the message text, + * 5. `missingSectionsOf` keys on the failure code and not on the message text, * so rewording the server's prose cannot silently remove the panel. * * Mutation check: render a count instead of the list and case 1 goes red; drop @@ -30,7 +30,6 @@ import { RestoreRefusalNotice, missingSectionsOf, } from "@/components/admin/backups-section"; -import { ApiError } from "@/lib/api/api-fetch"; import type { MissingBackupSection } from "@/lib/export/restore-skips"; vi.mock("next/navigation", () => ({ @@ -79,33 +78,24 @@ describe("RestoreRefusalNotice", () => { }); describe("missingSectionsOf", () => { - it("reads the sections off the refusal envelope", () => { - const err = new ApiError("Backup is missing documents", 422, { - errorCode: "backup.section.missing", - sections: ["documents"], - }); - - expect(missingSectionsOf(err)).toEqual(["documents"]); + it("reads the sections off the failed job", () => { + expect( + missingSectionsOf({ + code: "backup.section.missing", + sections: ["documents"], + }), + ).toEqual(["documents"]); }); - it("ignores an error whose message merely looks like one", () => { + it("ignores a failure whose message merely looks like one", () => { // The message is prose the server may reword. Only the code counts. - const err = new ApiError("Backup is missing documents", 422, { - errorCode: "something_else", - sections: ["documents"], - }); - - expect(missingSectionsOf(err)).toEqual([]); - expect(missingSectionsOf(new Error("Backup is missing documents"))).toEqual( - [], - ); + expect( + missingSectionsOf({ code: "something_else", sections: ["documents"] }), + ).toEqual([]); + expect(missingSectionsOf(null)).toEqual([]); }); it("survives a refusal with no section list", () => { - const err = new ApiError("Refused", 422, { - errorCode: "backup.section.missing", - }); - - expect(missingSectionsOf(err)).toEqual([]); + expect(missingSectionsOf({ code: "backup.section.missing" })).toEqual([]); }); }); diff --git a/src/components/admin/backup-restore-status.tsx b/src/components/admin/backup-restore-status.tsx new file mode 100644 index 000000000..7707f7aa4 --- /dev/null +++ b/src/components/admin/backup-restore-status.tsx @@ -0,0 +1,311 @@ +"use client"; + +/** + * `` — one restore job as the backups console shows it. + * + * A restore runs on the server as a background job (see + * `src/lib/jobs/backup-restore.ts`). The console polls + * `GET /api/admin/backups/restores` while a job is queued or running and + * renders each one here: what it is doing and how far it has got, then how it + * ended. Because the list comes from the server, a reload during a restore + * picks it up again where the page left it. + * + * Three rules, each for a way the synchronous restore misled an operator: + * + * - A running restore says the account still shows its current data. It + * does: nothing the restore writes is visible until it commits. + * - A failed restore says whether anything changed, and every failure but + * one changed nothing. It also says the backup is kept, because the next + * question is "do I have to upload it again", and the answer is no. + * - Only the job's own row decides the outcome. A poll that fails on the + * network is not a failed restore and is never shown as one. + */ +import { AlertTriangle, Loader2 } from "lucide-react"; + +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; + +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + BackupRestoreFailureCode, + BackupRestoreJobView, +} from "@/lib/jobs/backup-restore"; + +/** Whether a job still holds its account's one restore slot. */ +export function isActiveRestore(job: BackupRestoreJobView): boolean { + return job.status === "queued" || job.status === "running"; +} + +/** + * The jobs worth a panel: every active one, and the newest finished one the + * operator has not dismissed. + */ +export function visibleRestoreJobs( + jobs: readonly BackupRestoreJobView[], + dismissed: ReadonlySet, +): BackupRestoreJobView[] { + const active = jobs.filter(isActiveRestore); + const finished = jobs.find( + (job) => !isActiveRestore(job) && !dismissed.has(job.id), + ); + return finished ? [...active, finished] : active; +} + +/** Total records a succeeded restore wrote, from its summary counts. */ +function restoredCount(job: BackupRestoreJobView): number { + let total = 0; + for (const value of Object.values(job.result?.summary ?? {})) { + if (typeof value === "number") total += value; + } + return total; +} + +/** + * The failure as a sentence in the operator's language. + * + * Literal `t()` calls, so `i18n-call-site-coverage.test.ts` sees every key. + * The `never` at the foot makes the compiler say the chain is exhaustive: a + * failure code added on the server without a sentence here stops the build. + */ +function failureText( + code: BackupRestoreFailureCode, + t: ReturnType["t"], +): string { + switch (code) { + case "backup_not_found": + return t("admin.section.backups.restoreFailureNotFound"); + case "backup_changed": + return t("admin.section.backups.restoreFailureChanged"); + case "backup.payload.undecryptable": + return t("admin.section.backups.restoreFailureUndecryptable"); + case "schema_invalid": + return t("admin.section.backups.restoreFailureSchema"); + case "incompatible_schema_version": + return t("admin.section.backups.restoreFailureVersion"); + case "owner_mismatch": + return t("admin.section.backups.restoreFailureOwnerMismatch"); + case "owner_not_found": + return t("admin.section.backups.restoreFailureOwnerMissing"); + case "backup.section.missing": + return t("admin.section.backups.restoreFailureSectionMissing"); + case "document_ciphertext_missing": + return t("admin.section.backups.restoreFailureDocument"); + case "time_budget": + return t("admin.section.backups.restoreFailureTimeBudget"); + case "transaction_failed": + return t("admin.section.backups.restoreFailureTransaction"); + case "interrupted": + return t("admin.section.backups.restoreFailureInterrupted"); + case "not_started": + return t("admin.section.backups.restoreFailureNotStarted"); + case "enqueue_failed": + return t("admin.section.backups.restoreFailureEnqueue"); + case "failed_after_commit": + return t("admin.section.backups.restoreFailureAfterCommit"); + case "unexpected": + return t("admin.section.backups.restoreFailureUnexpected"); + default: { + const unhandled: never = code; + return unhandled; + } + } +} + +/** What a running job is doing, and the share of it done when that is known. */ +function runningLine( + job: BackupRestoreJobView, + t: ReturnType["t"], + fmt: ReturnType, +): { text: string; percent: number | null } { + const progress = job.progress; + switch (job.phase) { + case "clearing": + return { + text: t("admin.section.backups.restoreJobPhaseClearing"), + percent: null, + }; + case "measurements": { + const total = progress?.measurementsTotal ?? 0; + const done = progress?.measurementsWritten ?? 0; + return { + text: t("admin.section.backups.restoreJobPhaseMeasurements", { + done: fmt.integer(done), + total: fmt.integer(total), + }), + percent: total > 0 ? Math.min(100, (done / total) * 100) : null, + }; + } + case "sections": { + const total = progress?.sectionsTotal ?? 0; + const done = progress?.sectionsDone ?? 0; + return { + text: t("admin.section.backups.restoreJobPhaseSections", { + done: fmt.integer(done), + total: fmt.integer(total), + }), + percent: total > 0 ? Math.min(100, (done / total) * 100) : null, + }; + } + case "rebuilding": + return { + text: t("admin.section.backups.restoreJobPhaseRebuilding"), + percent: null, + }; + case "validating": + default: + return { + text: t("admin.section.backups.restoreJobPhaseValidating", { + checked: fmt.integer(progress?.measurementsChecked ?? 0), + }), + percent: null, + }; + } +} + +export function RestoreJobStatus({ + job, + onDismiss, +}: { + job: BackupRestoreJobView; + onDismiss: () => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const username = job.username ?? job.userId; + + if (isActiveRestore(job)) { + const line = + job.status === "running" + ? runningLine(job, t, fmt) + : { + text: t("admin.section.backups.restoreJobQueuedNote"), + percent: null, + }; + return ( +
+
+
+
+ ); + } + + if (job.status === "succeeded") { + const count = restoredCount(job); + return ( +
+
+
+

+ {t("admin.section.backups.restoreJobSucceededTitle", { + username, + })} +

+ {/* An empty file restores to an empty account: honest, and not a + tick. The outcome module decides which it looks like. */} + 0 ? "success" : "empty"} + testId="restore-job-outcome" + message={t( + "admin.section.backups.restoreJobSucceededDescription", + { + count: fmt.integer(count), + when: job.completedAt ? fmt.dateTime(job.completedAt) : "", + }, + )} + /> +
+ +
+
+ ); + } + + const code = job.failure?.code ?? "unexpected"; + const changedNothing = code !== "failed_after_commit"; + return ( +
+
+
+
+ +
+
+ ); +} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 5fc81ab58..b3bde1269 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -57,7 +57,13 @@ import type { RestoreSkipSummary, SkippedCatalogue, } from "@/lib/export/restore-skips"; +import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore"; import { getApiErrorMessage } from "./_shared"; +import { + isActiveRestore, + RestoreJobStatus, + visibleRestoreJobs, +} from "./backup-restore-status"; import { ApiError, apiFetch, @@ -448,13 +454,41 @@ function missingSectionLabel( * carry, and that nothing was changed — have to survive long enough to be read. */ /** - * The refused sections carried on an `ApiError`, or none. + * The refused sections a failed restore job names, or none. * - * Keyed on `meta.errorCode` and not on the message text: the message is prose + * Keyed on the failure's `code` and not on its message: the message is prose * the server may reword, and matching on it would turn a copy edit into a * silent loss of the panel. */ +const DISMISSED_RESTORES_KEY = "healthlog.admin.backups.dismissedRestores"; + +/** Dismissed restore panels, from this browser. Empty when storage is off. */ +function readDismissedRestores(): string[] { + try { + if (typeof window === "undefined") return []; + const raw = window.localStorage.getItem(DISMISSED_RESTORES_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) + ? parsed.filter((id): id is string => typeof id === "string") + : []; + } catch { + return []; + } +} + +function writeDismissedRestores(ids: string[]): void { + try { + // The list only ever shows a day of jobs; fifty ids is weeks of them. + window.localStorage.setItem( + DISMISSED_RESTORES_KEY, + JSON.stringify(ids.slice(-50)), + ); + } catch { + // A private window: the panel comes back on reload, nothing else breaks. + } +} + /** * The upload body for a backup file: the file itself when it is already * gzip, otherwise the file compressed in the browser. The server accepts @@ -468,10 +502,11 @@ async function uploadBody(file: File): Promise { ).blob(); } -export function missingSectionsOf(err: unknown): MissingBackupSection[] { - if (!(err instanceof ApiError)) return []; - if (err.meta?.errorCode !== "backup.section.missing") return []; - const sections = err.meta?.sections; +export function missingSectionsOf( + failure: { code: string; sections?: readonly unknown[] } | null | undefined, +): MissingBackupSection[] { + if (failure?.code !== "backup.section.missing") return []; + const sections = failure.sections; if (!Array.isArray(sections)) return []; return sections.filter( (section): section is MissingBackupSection => typeof section === "string", @@ -820,9 +855,77 @@ export function BackupsSection() { MissingBackupSection[] >([]); - // Restore: typed-confirmation dialog. The mutation is keyed by row id - // and used inline by `` below — keeping the - // mutation here lets the parent invalidate the list query on success. + // A job this page watched while it ran, and has now seen finish. Only those + // raise a toast and refresh the page's data: a job that had already finished + // when the page opened is shown in its panel and nothing more. Read in the + // poll's own callback, where each answer arrives, rather than in an effect. + const watched = useRef>(new Map()); + function noticeFinishedRestores(jobs: readonly BackupRestoreJobView[]) { + for (const job of jobs) { + const before = watched.current.get(job.id); + watched.current.set(job.id, job.status); + if (before !== "queued" && before !== "running") continue; + if (job.status === "succeeded") { + setRefusedSections([]); + const report = job.result?.skipped; + if (report && report.links > 0) { + setSkipped(report as RestoreSkipSummary); + toast.warning( + t("admin.section.backups.restoreSkippedToast", { + links: String(report.links), + }), + ); + } else { + setSkipped(null); + toast.success(t("admin.section.backups.restoreSuccess")); + } + // Restore touches every personal-data table; nuke the broader cache + // so dashboards / lists rebuild against the new state. + void queryClient.invalidateQueries(); + } else if (job.status === "failed") { + setRefusedSections(missingSectionsOf(job.failure)); + toast.error(t("admin.section.backups.restoreFailed")); + } + } + } + + // The restore jobs, read from the server so a reload during a restore finds + // it again. Polled every two seconds while any is queued or running, and + // not at all otherwise. A poll that fails keeps the last answer on screen: + // a network blip is not a failed restore. + const restores = useQuery({ + queryKey: queryKeys.adminBackupRestores(), + queryFn: async () => { + const data = await apiGet<{ jobs: BackupRestoreJobView[] }>( + "/api/admin/backups/restores", + ); + noticeFinishedRestores(data.jobs); + return data; + }, + refetchInterval: (query) => + query.state.data?.jobs.some(isActiveRestore) ? 2000 : false, + }); + const restoreJobs = restores.data?.jobs ?? []; + const activeOwners = new Set( + restoreJobs.filter(isActiveRestore).map((job) => job.userId), + ); + + // Finished restores the operator dismissed. Remembered in this browser only, + // for convenience; the list itself forgets a job after a day. + const [dismissedRestores, setDismissedRestores] = useState>( + () => new Set(readDismissedRestores()), + ); + function dismissRestore(id: string) { + setDismissedRestores((previous) => { + const next = new Set(previous); + next.add(id); + writeDismissedRestores([...next]); + return next; + }); + } + + // Restore: typed-confirmation dialog. The request only queues the restore; + // the panel above the list follows it from there. const restore = useMutation({ mutationFn: async ({ row, @@ -831,47 +934,35 @@ export function BackupsSection() { row: BackupRow; restoreInstanceSettings: boolean; }) => { - // Idempotency-Key prevents a double-click from re-running the - // destructive transaction. Include the row id so two different - // backups can both be restored independently in the same minute. + // Idempotency-Key prevents a double-click from queuing a second + // restore. Include the row id so two different backups can both be + // restored independently in the same minute. const idempotencyKey = `restore-${row.id}-${randomId()}`; - return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>( + return apiPost<{ jobId: string; status: "queued" }>( `/api/admin/backups/${row.id}/restore`, { confirm: "RESTORE", restoreInstanceSettings }, - // No request timeout: rebuilding a large account takes minutes. - { headers: { "Idempotency-Key": idempotencyKey }, signal: null }, + { headers: { "Idempotency-Key": idempotencyKey } }, ); }, onSuccess: (data) => { - // A restore that dropped links is not a plain success and must not read - // as one. The count and the exact keys go on screen; the same report is - // in the audit row for anyone asking later. setRefusedSections([]); - const report = data.skipped; - if (report && report.links > 0) { - setSkipped(report); - toast.warning( - t("admin.section.backups.restoreSkippedToast", { - links: String(report.links), - }), - ); - } else { - setSkipped(null); - toast.success(t("admin.section.backups.restoreSuccess")); - } - queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); - // Restore touches every personal-data table; nuke the broader - // cache so dashboards / lists rebuild against the new state. - queryClient.invalidateQueries(); + setSkipped(null); + watched.current.set(data.jobId, "queued"); + // Neutral, not a success: nothing has been restored yet. + toast.info(t("admin.section.backups.restoreStarted")); + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminBackupRestores(), + }); }, onError: (err) => { - // The server refuses an incomplete file before it deletes anything, and - // names the sections in `meta`. That is the one restore failure the - // operator can act on, so it gets the panel rather than only a toast. - const sections = missingSectionsOf(err); - if (sections.length > 0) { - setRefusedSections(sections); - toast.error(t("admin.section.backups.restoreMissingTitle")); + if ( + err instanceof ApiError && + err.meta?.errorCode === "backup.restore.active" + ) { + toast.error(t("admin.section.backups.restoreActive")); + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminBackupRestores(), + }); return; } setRefusedSections([]); @@ -1002,6 +1093,14 @@ export function BackupsSection() { + {visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => ( + dismissRestore(job.id)} + /> + ))} + setRefusedSections([])} @@ -1133,8 +1232,9 @@ export function BackupsSection() { restore.mutate({ row, ...options }) @@ -1196,8 +1296,9 @@ export function BackupsSection() { restore.mutate({ row, ...options }) diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 27209034d..e4d1b863b 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -211,6 +211,9 @@ export const WIPE_MODELS = [ // just returned. "IdempotencyKey", "DataBackup", + // The restores run from those copies: which copy, who asked, and how each + // ended. About this account's record, so they go with it. + "BackupRestoreJob", // What this host last put in the operator's bucket for this account. The // objects themselves stay — the worker holds no DeleteObject grant and the // bucket's lifecycle rule owns their retirement — but the row saying when diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts similarity index 75% rename from src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts rename to src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts index 70053fb02..d476fa561 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts +++ b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; /** * A restore writes into the account the BACKUP RECORD names, not the one the * encrypted payload claims. * - * The route took its target from `payload.userId` — a field inside the blob — + * The restore took its target from `payload.userId` — a field inside the blob — * while logging `backup.userId` from the row. An admin selecting one user's * backup could therefore write into a different account, and the audit trail * would record the account they thought they picked. Both values are in hand at @@ -15,19 +14,6 @@ import { NextRequest } from "next/server"; * rather than an escalation: the operator's intent was not honoured. */ -vi.mock("@/lib/api-handler", () => ({ - apiHandler: (fn: unknown) => fn, - HttpError: class extends Error { - constructor( - public status: number, - message: string, - ) { - super(message); - } - }, - requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), -})); - vi.mock("@/lib/db", () => ({ prisma: { dataBackup: { findUnique: vi.fn() }, @@ -53,10 +39,6 @@ vi.mock("@/lib/validations/backup", () => ({ })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/idempotency", () => ({ - withIdempotency: (fn: unknown) => fn, - defaultUserIdResolver: vi.fn(), -})); vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeUserMoodRollups: vi.fn(), })); @@ -68,22 +50,25 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeUserRollups: vi.fn(), })); -import { POST } from "../route"; +import { restoreBackup } from "../restore-backup"; import { prisma } from "@/lib/db"; import { parseBackupPayload } from "@/lib/validations/backup"; import { auditLog } from "@/lib/auth/audit"; -function request(): NextRequest { - return new NextRequest("http://localhost/api/admin/backups/b-1/restore", { - method: "POST", - headers: { "content-type": "application/json" }, - // The route requires a typed confirmation before it will restore anything. - body: JSON.stringify({ confirm: "RESTORE" }), +/** + * Run the restore the job runs, and answer with the status the synchronous + * route used to: 200, or the refusal's status. + */ +async function restore(): Promise<{ status: number }> { + const outcome = await restoreBackup({ + backup: { id: "b-1", userId: "user-A", data: "cipher" }, + actorUserId: "admin-1", + ipAddress: null, + restoreInstanceSettings: false, }); + return { status: outcome.ok ? 200 : outcome.status }; } -const params = Promise.resolve({ id: "b-1" }); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({ @@ -101,9 +86,7 @@ describe("admin backup restore — declared owner", () => { userId: "user-B", } as never); - const res = await ( - POST as unknown as (r: NextRequest, c: unknown) => Promise - )(request(), { params }); + const res = await restore(); expect(res.status).toBe(409); // The restore must not have gone looking for the claimed user at all. @@ -127,9 +110,7 @@ describe("admin backup restore — declared owner", () => { } as never); vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); - const res = await ( - POST as unknown as (r: NextRequest, c: unknown) => Promise - )(request(), { params }); + const res = await restore(); // Reaching the owner lookup proves the mismatch branch did not fire; this // case then fails on the deleted-user branch, which is the next guard. diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts similarity index 96% rename from src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts rename to src/lib/export/__tests__/restore-backup-round-trip.test.ts index c34397817..edd290bc7 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -9,7 +9,7 @@ * * So this test does not assert on the payload. It builds a real payload from a * populated account, serialises it, parses it back through the REAL - * `parseBackupPayload`, hands it to the REAL route, and asserts on the rows the + * `parseBackupPayload`, hands it to the REAL restore, and asserts on the rows the * restore tried to write. A field that survives that survives a restore. * * The transaction stand-in is deliberately not inert: `cycleSymptom.findMany` @@ -19,7 +19,6 @@ * inert v1.33.1 fix behaved in production. */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; import { Buffer } from "node:buffer"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; @@ -32,19 +31,6 @@ const mocks = vi.hoisted(() => ({ transaction: vi.fn(), })); -vi.mock("@/lib/api-handler", () => ({ - apiHandler: (fn: unknown) => fn, - HttpError: class extends Error { - constructor( - public status: number, - message: string, - ) { - super(message); - } - }, - requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), -})); - vi.mock("@/lib/db", () => ({ prisma: { dataBackup: { findUnique: vi.fn() }, @@ -66,10 +52,6 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: vi.fn(() => null), })); -vi.mock("@/lib/idempotency", () => ({ - withIdempotency: (fn: unknown) => fn, - defaultUserIdResolver: vi.fn(), -})); vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserData: vi.fn() })); vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeUserMoodRollups: vi.fn(), @@ -82,7 +64,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeUserRollups: vi.fn(), })); -import { POST } from "../route"; +import { restoreBackup } from "../restore-backup"; import { prisma } from "@/lib/db"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; @@ -437,15 +419,10 @@ function recordingTx( ); return new Proxy({} as Record, { - get: (_t, model: string) => delegate(model), - }); -} - -function request(): NextRequest { - return new NextRequest("http://localhost/api/admin/backups/b-1/restore", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ confirm: "RESTORE" }), + // `$executeRaw` and friends: the restore's per-account lock, and the + // measurement insert, which answer with a row count. + get: (_t, model: string) => + model.startsWith("$") ? async () => 0 : delegate(model), }); } @@ -462,7 +439,7 @@ async function roundTrip( seed: Record = {}, payloadOverride?: Record, ): Promise<{ - res: Response; + res: { status: number }; written: Written[]; payload: Record; }> { @@ -483,13 +460,25 @@ async function roundTrip( fn(recordingTx(written, seed)), ); - const res = await ( - POST as unknown as (r: NextRequest, c: unknown) => Promise - )(request(), { params: Promise.resolve({ id: "b-1" }) }); + const res = await restore(); return { res, written, payload }; } +/** + * Run the restore the job runs, and answer with the status the synchronous + * route used to: 200, or the refusal's status. + */ +async function restore(): Promise<{ status: number }> { + const outcome = await restoreBackup({ + backup: { id: "b-1", userId: OWNER, data: "cipher" }, + actorUserId: "admin-1", + ipAddress: null, + restoreInstanceSettings: false, + }); + return { status: outcome.ok ? 200 : outcome.status }; +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({ diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index a9c0c5daa..b1e9a3a17 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -254,7 +254,7 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ ]; export const BACKUP_RESTORE_FILES: readonly string[] = [ - "src/app/api/admin/backups/[id]/restore/route.ts", + "src/lib/export/restore-backup.ts", "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", "src/lib/export/health-score-backup.ts", @@ -666,6 +666,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "Replay protection for requests this host already answered. Restoring it would suppress a legitimate new request.", DataBackup: "The backup catalogue itself. A backup that contains the list of backups is a recursion with no reader.", + BackupRestoreJob: + "The record of a restore this host ran from one of its own stored copies. It names a backup row that exists only in this database, so carrying it would describe a restore the receiving host never performed.", OffhostBackupState: "When this host last put this account's copy in this operator's bucket, and how big it was. It describes one deployment's relationship with one bucket, so restoring it elsewhere would assert an off-host copy that host has never written.", ImportJob: diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts new file mode 100644 index 000000000..d3d21529e --- /dev/null +++ b/src/lib/export/restore-backup.ts @@ -0,0 +1,2406 @@ +/** + * 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 { Buffer } from "node:buffer"; + +import { prisma, toJson } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { + BACKUP_UNDECRYPTABLE_CODE, + BACKUP_UNDECRYPTABLE_ERROR, + openBackupBlob, +} from "@/lib/export/backup-blob"; +import { + insertMeasurementRows, + type MeasurementInsertRow, +} from "@/lib/export/measurement-bulk-insert"; +import { + readStreamedBackup, + type BackupSource, + type StreamedBackup, +} from "@/lib/export/streamed-backup"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +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 { + parseBackupPayload, + isCompatibleSchemaVersion, + summarizeBackup, + type BackupMeasurement, + type BackupSummary, +} from "@/lib/validations/backup"; +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 { 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 { invalidateUserData } from "@/lib/cache/invalidate"; +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; + }; +} + +/** + * How long the restore transaction may run. Two minutes was the whole budget + * when a record was a few hundred thousand rows; deleting and rewriting 1.25 + * million measurements in one transaction takes longer than that (#1031). On + * the test host the whole restore of that account, reading the file twice + * included, took 94 s. The budget grows by a second per 5 000 measurements, + * which gives that account six minutes, about four times what it needed, and + * never drops below the old two minutes. + */ +function restoreTransactionTimeoutMs(measurementCount: number): number { + return 120_000 + Math.ceil(measurementCount / 5); +} + +function decodeEncryptedBytes(encoded: string): Uint8Array { + 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" + | "time_budget" + | "transaction_failed" + | "interrupted" + | "unexpected"; + +export interface RestoreBackupInput { + /** The stored copy, as read from `data_backups`. */ + backup: { id: string; userId: string; data: string }; + /** 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; + /** + * 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; + }; + +/** + * Time kept free after the transaction for the rollup folds and the audit row, + * when the restore checks it fits the caller's deadline. + */ +const AFTER_TRANSACTION_ALLOWANCE_MS = 10 * 60 * 1000; + +function refused( + status: number, + code: RestoreFailureCode, + message: string, + meta?: Record, +): RestoreBackupOutcome { + return { ok: false, status, code, message, ...(meta ? { meta } : {}) }; +} + +/** + * Restore one stored backup over its owner's record. Resolves with what the + * transaction did, or with why nothing was done; throws only for a fault + * outside the restore's own checks (the database gone, for instance). + */ +export async function restoreBackup( + input: RestoreBackupInput, +): Promise { + const { backup, restoreInstanceSettings } = input; + annotate({ + action: { name: "admin.backups.restore" }, + meta: { id: backup.id, restore_instance_settings: restoreInstanceSettings }, + }); + + const 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 the whole ciphertext first, as unpacking did. + let source: BackupSource; + try { + source = openBackupBlob(backup.data); + } 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. + return refused(422, BACKUP_UNDECRYPTABLE_CODE, BACKUP_UNDECRYPTABLE_ERROR, { + errorCode: BACKUP_UNDECRYPTABLE_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. + let raw: unknown; + let payload; + let streamed: StreamedBackup; + try { + streamed = await readStreamedBackup(source, { + onMeasurementChecked: (checked) => { + progress.measurementsChecked = checked; + report("validating"); + }, + }); + progress.measurementsTotal = streamed.measurementCount; + report("validating", true); + raw = streamed.raw; + payload = parseBackupPayload(raw); + } catch (err) { + await auditLog("admin.backups.restore.failed", { + userId: input.actorUserId, + ipAddress: input.ipAddress, + details: { + backupId: backup.id, + ownerId: backup.userId, + reason: "schema_invalid", + message: err instanceof Error ? err.message : String(err), + }, + }); + 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`, + ); + } + + // Audit the *intent* before the transaction begins so the trail + // describing "an admin is about to restore " survives even + // if the operation crashes midway. + await auditLog("admin.backups.restore.start", { + userId: input.actorUserId, + ipAddress: input.ipAddress, + details: { + backupId: backup.id, + ownerId, + ownerUsername: owner.username, + snapshotExportedAt: payload.exportedAt, + restoreInstanceSettings, + }, + }); + + // A section the file's own manifest says it carries, and does not. + // + // The restore's first act is to delete the class it is about to rebuild, so + // an absent section is not a gap in the file — it is an erasure of that part + // of the account, reported as a success. #237: a portable file whose + // document payload was gone emptied the vault and said nothing. The whole + // file is refused instead, above the transaction, for the same reason the + // metadata-only check below sits there. + // + // A DECLARED omission is not this. The portable export leaves the screener + // administrations and the consent receipts out on purpose and says so in the + // manifest; `findMissingBackupSections` reads the manifest and not the + // emptiness of an array, so those files stay restorable. So does every file + // written before the manifest existed, which declares nothing at all. + const missingSections = findMissingBackupSections(raw); + if (missingSections.length > 0) { + await auditLog("admin.backups.restore.failed", { + userId: input.actorUserId, + ipAddress: input.ipAddress, + details: { + backupId: backup.id, + ownerId, + reason: "section_missing", + sections: missingSections, + }, + }); + return refused( + 422, + "backup.section.missing", + `Backup is missing ${missingSections.join(", ")} — the file's own manifest says it carries ${missingSections.length === 1 ? "that section" : "those sections"}. Nothing was changed. Use a disaster-recovery snapshot, or export the account again.`, + { errorCode: "backup.section.missing", sections: missingSections }, + ); + } + + // Portable exports intentionally omit document ciphertext. They remain + // valid upload/download artifacts, but cannot be used to manufacture an + // InboundDocument row. A DR restore must fail before any delete rather + // than inventing empty/plaintext content. + const incompleteDocument = payload.documents.find( + (document) => + document.contentEncrypted === undefined || + document.contentSha256 === undefined || + document.contentCodec === undefined || + document.providerType === undefined || + document.reportDate === undefined || + document.documentDate === undefined || + document.errorReason === undefined || + document.summaryEncrypted === undefined || + document.summaryGeneratedAt === undefined || + document.summaryState === undefined || + document.createdAt === undefined || + document.updatedAt === undefined, + ); + 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. + const transactionTimeoutMs = restoreTransactionTimeoutMs( + streamed.measurementCount, + ); + if ( + input.deadline !== undefined && + Date.now() + transactionTimeoutMs + 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, + }, + }); + return refused( + 503, + "time_budget", + `A backup of ${streamed.measurementCount} readings needs more time than the restore job allows. Nothing was changed.`, + ); + } + + let outcome: { + cleared: RestoreResponse["cleared"]; + skipped: RestoreSkipSummary; + }; + try { + report("clearing"); + outcome = await prisma.$transaction( + async (tx) => { + // 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. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`backup-restore:${ownerId}`}))`; + // 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, + 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. + aggregationProvenance: (measurement.aggregationProvenance ?? + (measurement.source === "APPLE_HEALTH" && + measurement.externalId?.startsWith("stats:") + ? "LEGACY_UNKNOWN" + : null)) as never, + glucoseContext: (measurement.glucoseContext ?? null) as never, + sleepStage: (measurement.sleepStage ?? null) as never, + rhythmClassification: (measurement.rhythmClassification ?? + null) as never, + deviceType: measurement.deviceType ?? null, + syncVersion: measurement.syncVersion ?? 1, + deletedAt: measurement.deletedAt + ? new Date(measurement.deletedAt) + : null, + ...(measurement.createdAt + ? { createdAt: new Date(measurement.createdAt) } + : {}), + ...(measurement.updatedAt + ? { updatedAt: new Date(measurement.updatedAt) } + : {}), + }); + + // The measurements, a batch at a time from a second read of the + // file, so the restore never holds more than one batch of them. + // + // Two later sections point INTO the measurements (a record's + // `sourceMeasurementId`, an ECG strip's `measurementId`) and have + // to know which of those ids this transaction actually wrote. They + // used to be handed every written id; they only ever ask about the + // ones they reference, so only those are collected. + const referencedMeasurementIds = new Set(); + for (const record of payload.personalRecords) { + if (record.sourceMeasurementId) { + referencedMeasurementIds.add(record.sourceMeasurementId); + } + } + for (const recording of payload.ecgRecordings) { + if (recording.measurementId) { + referencedMeasurementIds.add(recording.measurementId); + } + } + const writtenReferencedIds = new Set(); + const measurementBatchSize = 1_000; + report("measurements"); + await streamed.forEachMeasurementBatch( + measurementBatchSize, + async (batch) => { + 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); + progress.measurementsWritten += batch.length; + report("measurements"); + for (const row of stableRows) { + if (referencedMeasurementIds.has(row.id)) { + writtenReferencedIds.add(row.id); + } + } + + // v1 payloads did not require stable ids. Preserve their + // historical natural-key reconciliation without routing + // canonical v2 rows through it. + for (const measurement of batch) { + if (measurement.id) continue; + const restoredData = toRestoredMeasurementData(measurement); + const existing = await tx.measurement.findFirst({ + where: { + userId: ownerId, + type: measurement.type, + source: restoredData.source, + measuredAt: restoredData.measuredAt, + sleepStage: restoredData.sleepStage, + }, + select: { id: true }, + }); + if (existing) { + await tx.measurement.update({ + where: { id: existing.id, userId: ownerId }, + data: restoredData, + }); + } else { + await tx.measurement.create({ + data: { userId: ownerId, ...restoredData }, + }); + } + } + }, + ); + + const medByName = new Map(); + const restoredMedicationIds = new Set(); + // The 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[] = []; + let medicationIndex = 0; + reportSection("medications"); + 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, + 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, + ...(m.createdAt ? { createdAt: new Date(m.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); + + // ── 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 => r !== null); + if (rows.length > 0) { + await tx.medicationIntakeEvent.createMany({ data: rows }); + } + } + + reportSection("mood"); + 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)]; + // 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. + 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: category.labelEncrypted ?? null, + }, + 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: tag.labelEncrypted ?? null, + 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: {}, + }); + } + + // 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, + ); + } + + 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, + ); + + reportSection("labs"); + const biomarkerByName = new Map(); + const restoredBiomarkerIds = new Set(); + for (const biomarker of payload.biomarkers) { + const created = await tx.biomarker.create({ + data: { + ...(biomarker.id ? { id: biomarker.id } : {}), + userId: ownerId, + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound ?? null, + upperBound: biomarker.upperBound ?? null, + panel: biomarker.panel ?? null, + hidden: biomarker.hidden ?? false, + contextEncrypted: + biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), + ...(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(); + // 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), + 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 }, + 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 } } + : {}), + }, + }); + } + } + + 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, + ...(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, + })), + }); + } + + 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, + 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)), + 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, + }; + progress.sectionsDone = RESTORE_SECTION_STEPS.length; + report("sections", true); + return { cleared, skipped: summarizeRestoreSkips(skips) }; + }, + { + 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); + await auditLog("admin.backups.restore.failed", { + userId: input.actorUserId, + ipAddress: input.ipAddress, + details: { + backupId: backup.id, + ownerId, + reason: "transaction_failed", + message: verbose, + }, + }); + annotate({ meta: { restoreFailReason: verbose } }); + return refused( + 500, + "transaction_failed", + "The restore could not be written and was rolled back. Nothing was changed.", + ); + } + + const { cleared, skipped } = outcome; + report("rebuilding"); + + // 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. + if (streamed.measurementCount > 0) { + try { + await recomputeUserRollups(ownerId); + } 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: streamed.measurementCount, + }; + + 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, + // 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); + + const response: RestoreResponse = { + restored: true, + summary, + skipped, + cleared, + }; + return { ok: true, response }; +} diff --git a/src/lib/export/streamed-backup.ts b/src/lib/export/streamed-backup.ts index 1e65d6150..e28b9f08d 100644 --- a/src/lib/export/streamed-backup.ts +++ b/src/lib/export/streamed-backup.ts @@ -79,8 +79,17 @@ function parseMeasurement(element: unknown, index: number): BackupMeasurement { * is not a JSON object and with `StreamedBackupInvalidError` for a * measurement the schema refuses. */ +export interface ReadStreamedBackupOptions { + /** + * Called after each measurement passes the element schema, with how many + * have so far. For a caller that reports progress; keep it cheap. + */ + onMeasurementChecked?: (checked: number) => void; +} + export async function readStreamedBackup( source: BackupSource, + options: ReadStreamedBackupOptions = {}, ): Promise { let firstWithoutId: number | null = null; let bytes = 0; @@ -95,6 +104,7 @@ export async function readStreamedBackup( onElement: (_key, element, index) => { const row = parseMeasurement(element, index); if (!row.id && firstWithoutId === null) firstWithoutId = index; + options.onMeasurementChecked?.(index + 1); }, }); diff --git a/src/lib/jobs/__tests__/backup-restore-outcome.test.ts b/src/lib/jobs/__tests__/backup-restore-outcome.test.ts new file mode 100644 index 000000000..121a8d614 --- /dev/null +++ b/src/lib/jobs/__tests__/backup-restore-outcome.test.ts @@ -0,0 +1,79 @@ +/** + * Every way a restore job can end serialises into a job outcome. + * + * `runJob` validates what a handler returns before pg-boss persists it: fact + * keys from an allowlist, string values lower-case words joined by + * underscores. Two of the restore's failure codes are the dotted envelope + * codes the console branches on (`backup.section.missing`, + * `backup.payload.undecryptable`). Returned as they are, the validation + * throws, the job fails with a TypeError, and a restore that correctly + * refused an incomplete file reads as a crash in the operator's job card. + * The integration tests call the handler body directly and cannot see it. + * + * Mutation check: return `outcome.code` unmapped in `runBackupRestoreJob` + * (drop `jobFactCode`) and the dotted cases here go red. + */ +import { describe, expect, it } from "vitest"; + +import { + jobFactCode, + type BackupRestoreFailureCode, +} from "@/lib/jobs/backup-restore"; +import { + jobDone, + jobFailed, + serializeJobOutcome, +} from "@/lib/jobs/job-outcome"; + +// A record over the union, so a code added to it without a row here does not +// compile. +const CODE_TABLE: Record = { + backup_not_found: true, + backup_changed: true, + "backup.payload.undecryptable": true, + schema_invalid: true, + incompatible_schema_version: true, + owner_mismatch: true, + owner_not_found: true, + "backup.section.missing": true, + document_ciphertext_missing: true, + time_budget: true, + transaction_failed: true, + interrupted: true, + not_started: true, + enqueue_failed: true, + failed_after_commit: true, + unexpected: true, +}; +const CODES = Object.keys(CODE_TABLE) as BackupRestoreFailureCode[]; + +describe("restore job outcomes serialise", () => { + it.each(CODES)("a refusal with code %s", (code) => { + expect(() => + serializeJobOutcome(jobDone({ refused: jobFactCode(code) })), + ).not.toThrow(); + }); + + it("the raw dotted code would not, which is why it is mapped", () => { + expect(() => + serializeJobOutcome(jobDone({ refused: "backup.section.missing" })), + ).toThrow(); + }); + + it("the success, claim and sweep facts", () => { + const facts: Array> = [ + { restore_measurements: 1_250_000, restore_skipped_links: 0 }, + { restore_claimed: false }, + { restore_requeued: 1, restore_failed: 0 }, + ]; + for (const did of facts) { + expect(() => serializeJobOutcome(jobDone(did))).not.toThrow(); + } + }); + + it("the transaction failure", () => { + expect(() => + serializeJobOutcome(jobFailed("restore_transaction_failed")), + ).not.toThrow(); + }); +}); diff --git a/src/lib/jobs/__tests__/job-budget.test.ts b/src/lib/jobs/__tests__/job-budget.test.ts index 27eec6bf6..23fa07b11 100644 --- a/src/lib/jobs/__tests__/job-budget.test.ts +++ b/src/lib/jobs/__tests__/job-budget.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { JOB_BUDGET_SHARE, jobBudget } from "@/lib/jobs/job-budget"; +import { + JOB_BUDGET_SHARE, + jobBudget, + jobDeadline, +} from "@/lib/jobs/job-budget"; // pg-boss declares a job dead at its expiry but does not stop the handler; // the long passes stop themselves on this budget instead (issue #1031). @@ -43,3 +47,16 @@ describe("jobBudget", () => { expect(shouldStop()).toBe(false); }); }); + +describe("jobDeadline", () => { + it("is the same budget share of the expiry, as an instant", () => { + expect(jobDeadline(job(7200), () => 1_000)).toBe( + 1_000 + 7200 * 1000 * JOB_BUDGET_SHARE, + ); + }); + + it("is undefined for a job with no usable expiry", () => { + expect(jobDeadline(job(0))).toBeUndefined(); + expect(jobDeadline(job(Number.NaN))).toBeUndefined(); + }); +}); diff --git a/src/lib/jobs/backup-restore.ts b/src/lib/jobs/backup-restore.ts new file mode 100644 index 000000000..38ccd6db5 --- /dev/null +++ b/src/lib/jobs/backup-restore.ts @@ -0,0 +1,780 @@ +/** + * Backup restores as a background job. + * + * A restore used to run inside `POST /api/admin/backups/[id]/restore`. On an + * account of 1.25 million readings that request took about a minute and a + * half, and a reverse proxy that cuts a request at 60 s (nginx's default) + * showed the operator an error for a restore that went on to finish on the + * server. So the request now does only what is quick: it checks the stored copy + * opens, admits one job for the account (`admitBackupRestore`) and answers 202. + * This queue runs the restore (`restoreBackup`, unchanged in what it checks and + * writes) and records its progress and its outcome on the job's row, which + * `GET /api/admin/backups/restores/{id}` reads back. + * + * What stays true from the synchronous restore: + * + * - Every check still happens before the first delete, and the replacement + * is still one transaction. A failed job leaves the account as it was. + * - While it runs, the account keeps reading its current data: nothing the + * transaction writes is visible to anyone until it commits. Other writes + * to the account are neither refused nor held, exactly as before; a reading + * that arrives mid-restore stays next to the restored ones. + * + * What the job adds: + * + * - One restore per account at a time. The job table's partial unique index + * refuses a second queued or running row for the same account, and the + * route answers that with 409. + * - A time budget. The job's expiry is two hours; `restoreBackup` refuses, + * before deleting anything, a file whose transaction limit would run past + * three quarters of it (`jobDeadline`), the same share the other long + * passes stop at. pg-boss does not retry the job (`retryLimit: 0`): a + * restore that failed on its checks fails the same way again. + * - Resuming after a restart. The job writes a heartbeat every few seconds. + * A worker that stops mid-restore rolls the transaction back with it, and + * the boot sweep finds the row whose heartbeat went stale and queues it + * again from the start, at most twice. The stored copy it reads is the + * `DataBackup` row, which stays where it is whatever happens, so no retry + * ever needs the file again. + */ +import { createHash } from "node:crypto"; + +import type { Job, PgBoss } from "pg-boss"; +import { z } from "zod/v4"; + +import { invalidateUserData } from "@/lib/cache/invalidate"; +import { prisma, toJson } from "@/lib/db"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { jobDeadline } from "@/lib/jobs/job-budget"; +import { reportJobProgress } from "@/lib/jobs/job-observer"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { annotate } from "@/lib/logging/context"; +import { isP2002 } from "@/lib/prisma-errors"; +import { + restoreBackup, + RESTORE_SECTION_STEPS, + type RestoreFailureCode, + type RestorePhase, + type RestoreProgress, +} from "@/lib/export/restore-backup"; + +export const BACKUP_RESTORE_QUEUE = "backup-restore"; + +/** Stable code of the 409 a second restore of the same account gets. */ +export const BACKUP_RESTORE_ACTIVE_CODE = "backup.restore.active"; + +/** + * Two hours. The restore of 1.25 million readings took a minute and a half; + * the limit is sized so a record many times that still fits, while a job whose + * worker died does not keep its pg-boss row active for longer than an operator + * would wait. + */ +export const BACKUP_RESTORE_EXPIRE_SECONDS = 2 * 60 * 60; + +export const BACKUP_RESTORE_SEND_OPTIONS = { + retryLimit: 0, + expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS, +} as const; + +/** How often a running job writes its progress and heartbeat. */ +export const BACKUP_RESTORE_PROGRESS_WRITE_MS = 2_000; + +/** A running job whose heartbeat is older than this has lost its worker. */ +export const BACKUP_RESTORE_STALE_AFTER_MS = 2 * 60_000; + +/** A queued job nobody picked up in this long is reported as not started. */ +export const BACKUP_RESTORE_UNCLAIMED_AFTER_MS = 60 * 60_000; + +/** Starts a job may have before an interrupted one is failed, not re-queued. */ +export const BACKUP_RESTORE_MAX_ATTEMPTS = 2; + +/** Finished jobs older than this are deleted when the account restores again. */ +export const BACKUP_RESTORE_RETENTION_DAYS = 30; + +export type BackupRestorePayload = { restoreJobId: string } | { sweep: true }; + +export type BackupRestoreStatus = "queued" | "running" | "succeeded" | "failed"; + +/** Every reason a job can end in `failed`, as a stable code. */ +export type BackupRestoreFailureCode = + RestoreFailureCode | "not_started" | "enqueue_failed" | "failed_after_commit"; + +export interface BackupRestoreFailure { + code: BackupRestoreFailureCode; + message: string; + /** The sections a file's manifest names and it does not carry. */ + sections?: string[]; +} + +const failureSchema = z.object({ + code: z.string(), + message: z.string(), + sections: z.array(z.string()).optional(), +}); + +const progressSchema = z.object({ + measurementsChecked: z.number().int().nonnegative(), + measurementsTotal: z.number().int().nonnegative().nullable(), + measurementsWritten: z.number().int().nonnegative(), + sectionsDone: z.number().int().nonnegative(), + sectionsTotal: z.number().int().nonnegative(), +}); + +const resultSchema = z.object({ + summary: z.record(z.string(), z.unknown()), + skipped: z.object({ + links: z.number().int(), + catalogueKeys: z.array( + z.object({ + catalogue: z.string(), + key: z.string(), + links: z.number().int(), + }), + ), + }), + cleared: z.record(z.string(), z.number()), +}); + +export type BackupRestoreResult = z.infer; + +/** What the status routes answer with. Counts and codes, never content. */ +export interface BackupRestoreJobView { + id: string; + userId: string; + username: string | null; + backupId: string; + restoreInstanceSettings: boolean; + status: BackupRestoreStatus; + phase: RestorePhase | null; + progress: RestoreProgress | null; + result: BackupRestoreResult | null; + failure: BackupRestoreFailure | null; + attempts: number; + createdAt: string; + startedAt: string | null; + completedAt: string | null; +} + +const PHASES: readonly RestorePhase[] = [ + "validating", + "clearing", + "measurements", + "sections", + "rebuilding", +]; +const STATUSES: readonly BackupRestoreStatus[] = [ + "queued", + "running", + "succeeded", + "failed", +]; + +/** + * A failure code as a job fact. Job facts are lower-case words joined by + * underscores (`job-outcome.ts`), and two of the restore's codes are the + * dotted envelope codes the console branches on. + */ +export function jobFactCode(code: BackupRestoreFailureCode): string { + return code.replace(/\./g, "_"); +} + +/** SHA-256 of a stored copy, to tell whether it changed while a job waited. */ +export function backupDigest(data: string): string { + return createHash("sha256").update(data).digest("hex"); +} + +const viewSelect = { + id: true, + userId: true, + backupId: true, + restoreInstanceSettings: true, + status: true, + phase: true, + progress: true, + result: true, + failure: true, + attempts: true, + createdAt: true, + startedAt: true, + completedAt: true, + user: { select: { username: true } }, +} as const; + +type ViewRow = { + id: string; + userId: string; + backupId: string; + restoreInstanceSettings: boolean; + status: string; + phase: string | null; + progress: unknown; + result: unknown; + failure: unknown; + attempts: number; + createdAt: Date; + startedAt: Date | null; + completedAt: Date | null; + user: { username: string } | null; +}; + +function toView(row: ViewRow): BackupRestoreJobView { + const status = (STATUSES as readonly string[]).includes(row.status) + ? (row.status as BackupRestoreStatus) + : "failed"; + const phase = + row.phase && (PHASES as readonly string[]).includes(row.phase) + ? (row.phase as RestorePhase) + : null; + const progress = progressSchema.safeParse(row.progress); + const result = resultSchema.safeParse(row.result); + const failure = failureSchema.safeParse(row.failure); + return { + id: row.id, + userId: row.userId, + username: row.user?.username ?? null, + backupId: row.backupId, + restoreInstanceSettings: row.restoreInstanceSettings, + status, + phase: status === "running" ? phase : null, + progress: progress.success ? progress.data : null, + result: result.success ? result.data : null, + failure: failure.success + ? (failure.data as BackupRestoreFailure) + : status === "failed" + ? { code: "unexpected", message: "The restore failed." } + : null, + attempts: row.attempts, + createdAt: row.createdAt.toISOString(), + startedAt: row.startedAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + }; +} + +/** One job, for the status route. Null when the id is unknown. */ +export async function readBackupRestoreJob( + id: string, +): Promise { + const row = await prisma.backupRestoreJob.findUnique({ + where: { id }, + select: viewSelect, + }); + return row ? toView(row) : null; +} + +/** + * The jobs the console shows: every queued or running one, and the finished + * ones of the last day, newest first. What a reload of the page needs to pick + * up a restore it was watching, and to show how the last one ended. + */ +export async function listRecentBackupRestoreJobs( + now: Date = new Date(), +): Promise { + const since = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const rows = await prisma.backupRestoreJob.findMany({ + where: { + OR: [ + { status: { in: ["queued", "running"] } }, + { createdAt: { gte: since } }, + ], + }, + orderBy: { createdAt: "desc" }, + take: 20, + select: viewSelect, + }); + return rows.map(toView); +} + +function failureJson(failure: BackupRestoreFailure) { + return toJson({ + code: failure.code, + message: failure.message, + ...(failure.sections ? { sections: failure.sections } : {}), + }); +} + +const INTERRUPTED: BackupRestoreFailure = { + code: "interrupted", + message: + "The restore stopped when the server restarted and was rolled back. Nothing was changed, and the backup is kept: start the restore again. If it stops again, the server likely ran out of memory.", +}; + +const NOT_STARTED: BackupRestoreFailure = { + code: "not_started", + message: + "The background worker did not pick the restore up. Nothing was changed. Check that the worker is running, then start the restore again.", +}; + +/** + * Fail the account's jobs that can no longer finish, so they stop holding the + * one-per-account slot: a running job whose worker is gone, and a queued job + * nobody picked up. Also deletes finished jobs past retention. + */ +async function releaseAbandonedJobs(userId: string, now: Date): Promise { + const staleBefore = new Date(now.getTime() - BACKUP_RESTORE_STALE_AFTER_MS); + const unclaimedBefore = new Date( + now.getTime() - BACKUP_RESTORE_UNCLAIMED_AFTER_MS, + ); + await prisma.backupRestoreJob.updateMany({ + where: { + userId, + status: "running", + OR: [{ heartbeatAt: { lt: staleBefore } }, { heartbeatAt: null }], + }, + data: { + status: "failed", + phase: null, + failure: failureJson(INTERRUPTED), + completedAt: now, + }, + }); + await prisma.backupRestoreJob.updateMany({ + where: { userId, status: "queued", createdAt: { lt: unclaimedBefore } }, + data: { + status: "failed", + failure: failureJson(NOT_STARTED), + completedAt: now, + }, + }); + await prisma.backupRestoreJob.deleteMany({ + where: { + userId, + status: { in: ["succeeded", "failed"] }, + createdAt: { + lt: new Date( + now.getTime() - BACKUP_RESTORE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + ), + }, + }, + }); +} + +export type BackupRestoreAdmission = + | { admitted: true; jobId: string } + | { admitted: false; reason: "active"; activeJobId: string | null } + | { admitted: false; reason: "enqueue_failed"; jobId: string }; + +/** + * Admit one restore of `backupId` over `userId`'s record and queue it. + * + * The partial unique index decides whether another restore of the account is + * already queued or running, so two requests arriving together cannot both + * pass a check and both insert. + */ +export async function admitBackupRestore(input: { + userId: string; + actorUserId: string; + backupId: string; + backupDigest: string; + restoreInstanceSettings: boolean; + boss?: PgBoss | null; +}): Promise { + const now = new Date(); + await releaseAbandonedJobs(input.userId, now); + + let jobId: string; + try { + const created = await prisma.backupRestoreJob.create({ + data: { + userId: input.userId, + actorUserId: input.actorUserId, + backupId: input.backupId, + backupDigest: input.backupDigest, + restoreInstanceSettings: input.restoreInstanceSettings, + status: "queued", + }, + select: { id: true }, + }); + jobId = created.id; + } catch (err) { + if (!isP2002(err)) throw err; + const active = await prisma.backupRestoreJob.findFirst({ + where: { userId: input.userId, status: { in: ["queued", "running"] } }, + select: { id: true }, + }); + return { + admitted: false, + reason: "active", + activeJobId: active?.id ?? null, + }; + } + + const boss = input.boss === undefined ? getGlobalBoss() : input.boss; + try { + if (!boss) throw new Error("worker unavailable"); + const bossJobId = await boss.send( + BACKUP_RESTORE_QUEUE, + { restoreJobId: jobId } satisfies BackupRestorePayload, + BACKUP_RESTORE_SEND_OPTIONS, + ); + if (!bossJobId) throw new Error("queue rejected job"); + await prisma.backupRestoreJob.update({ + where: { id: jobId }, + data: { pgBossJobId: bossJobId }, + }); + return { admitted: true, jobId }; + } catch { + // Written but never queued: fail it here, or it holds the account's slot + // until the unclaimed window runs out. + await prisma.backupRestoreJob.update({ + where: { id: jobId }, + data: { + status: "failed", + failure: failureJson({ + code: "enqueue_failed", + message: + "The restore could not be handed to the background worker. Nothing was changed. Check that the worker is running, then start the restore again.", + }), + completedAt: new Date(), + }, + }); + return { admitted: false, reason: "enqueue_failed", jobId }; + } +} + +/** + * Run one queued restore job to its end. Exported for the tests, which drive + * the queue without a pg-boss worker. + */ +export async function runBackupRestoreJob( + restoreJobId: string, + options: { deadline?: number } = {}, +): Promise { + const row = await prisma.backupRestoreJob.findUnique({ + where: { id: restoreJobId }, + }); + if (!row || row.status !== "queued") { + // Already taken by another delivery, finished, or failed by the admission + // check while it waited. There is nothing for this delivery to do. + return jobDone({ restore_claimed: false }); + } + const startedAt = new Date(); + const claimed = await prisma.backupRestoreJob.updateMany({ + where: { id: row.id, status: "queued" }, + data: { + status: "running", + phase: "validating", + attempts: { increment: 1 }, + heartbeatAt: startedAt, + startedAt: row.startedAt ?? startedAt, + }, + }); + if (claimed.count === 0) return jobDone({ restore_claimed: false }); + annotate({ + meta: { + restore_job_id: row.id, + restore_owner_id: row.userId, + restore_attempt: row.attempts + 1, + }, + }); + + let phase: RestorePhase = "validating"; + let progress: RestoreProgress = { + measurementsChecked: 0, + measurementsTotal: null, + measurementsWritten: 0, + sectionsDone: 0, + sectionsTotal: RESTORE_SECTION_STEPS.length, + }; + let writing: Promise | null = null; + const writeProgress = () => { + if (writing) return; + writing = prisma.backupRestoreJob + .updateMany({ + where: { id: row.id, status: "running" }, + data: { phase, progress: toJson(progress), heartbeatAt: new Date() }, + }) + .catch(() => undefined) + .finally(() => { + writing = null; + }); + }; + // A timer, not only the progress callback: a single long statement (the + // delete of a million readings) reports nothing for a while, and the + // heartbeat must not go stale while it runs. + const timer = setInterval(writeProgress, BACKUP_RESTORE_PROGRESS_WRITE_MS); + timer.unref?.(); + + const finish = async ( + status: "succeeded" | "failed", + data: { result?: BackupRestoreResult; failure?: BackupRestoreFailure }, + ) => { + clearInterval(timer); + if (writing) await writing; + const completedAt = new Date(); + await prisma.backupRestoreJob.updateMany({ + where: { id: row.id, status: "running" }, + data: { + status, + phase: null, + progress: toJson(progress), + ...(data.result ? { result: toJson(data.result) } : {}), + ...(data.failure ? { failure: failureJson(data.failure) } : {}), + heartbeatAt: completedAt, + completedAt, + }, + }); + }; + + try { + const backup = await prisma.dataBackup.findUnique({ + where: { id: row.backupId }, + select: { id: true, userId: true, data: true }, + }); + if (!backup || backup.userId !== row.userId) { + await finish("failed", { + failure: { + code: "backup_not_found", + message: + "The backup was deleted before the restore could read it. Nothing was changed.", + }, + }); + return jobDone({ refused: "backup_not_found" }); + } + if (backupDigest(backup.data) !== row.backupDigest) { + await finish("failed", { + failure: { + code: "backup_changed", + message: + "The backup was replaced by a newer copy while the restore waited. Nothing was changed. Check the new copy, then start the restore again.", + }, + }); + return jobDone({ refused: "backup_changed" }); + } + + const outcome = await restoreBackup({ + backup, + actorUserId: row.actorUserId, + ipAddress: null, + restoreInstanceSettings: row.restoreInstanceSettings, + deadline: options.deadline, + progress: (nextPhase, nextProgress) => { + const phaseChanged = nextPhase !== phase; + phase = nextPhase; + progress = nextProgress; + reportJobProgress({ + phase, + measurements_written: progress.measurementsWritten, + sections_done: progress.sectionsDone, + }); + if (phaseChanged) writeProgress(); + }, + }); + + if (outcome.ok) { + const { summary, skipped, cleared } = outcome.response; + await finish("succeeded", { + result: { + summary: { ...summary }, + skipped, + cleared, + }, + }); + return jobDone({ + restore_measurements: Number(summary.measurements ?? 0), + restore_skipped_links: skipped.links, + }); + } + + const sections = Array.isArray(outcome.meta?.sections) + ? (outcome.meta.sections as unknown[]).map(String) + : undefined; + await finish("failed", { + failure: { + code: outcome.code, + message: outcome.message, + ...(sections ? { sections } : {}), + }, + }); + // A file refused by its checks is the operator's to fix and fails the + // same way on every try; the job did what it was asked. A transaction + // that could not be written is a fault, and is reported as one. + return outcome.status >= 500 && outcome.code === "transaction_failed" + ? jobFailed("restore_transaction_failed") + : jobDone({ refused: jobFactCode(outcome.code) }); + } catch (err) { + // After "rebuilding" the transaction had committed: the data is restored + // and only a step after it failed, which the message has to say. + // (`phase` is written by the progress callback, which TypeScript cannot see.) + const committed = (phase as RestorePhase) === "rebuilding"; + await finish("failed", { + failure: committed + ? { + code: "failed_after_commit", + message: + "The data was restored, but a step after it failed, so charts may take until the next nightly run to catch up. The server log names the step.", + } + : { + code: "unexpected", + message: + "The restore stopped on an unexpected error and was rolled back. Nothing was changed, and the backup is kept. The server log names the error.", + }, + }).catch(() => undefined); + throw err; + } finally { + clearInterval(timer); + } +} + +/** + * Re-queue the restores a stopped worker left running, or fail them once they + * have been started {@link BACKUP_RESTORE_MAX_ATTEMPTS} times. The restore is + * one transaction, so a job whose worker died was rolled back with it and + * starts again from the beginning; there is no half-written account to finish. + */ +export async function sweepInterruptedRestores( + boss: PgBoss | null = getGlobalBoss(), + now: Date = new Date(), +): Promise<{ requeued: number; failed: number }> { + const staleBefore = new Date(now.getTime() - BACKUP_RESTORE_STALE_AFTER_MS); + const rows = await prisma.backupRestoreJob.findMany({ + where: { + status: "running", + OR: [{ heartbeatAt: { lt: staleBefore } }, { heartbeatAt: null }], + }, + select: { + id: true, + attempts: true, + pgBossJobId: true, + heartbeatAt: true, + }, + }); + let requeued = 0; + let failed = 0; + for (const row of rows) { + // The delivery the dead worker held stays active in pg-boss until its + // expiry and would then read as a timed-out job. It is superseded. + if (boss && row.pgBossJobId) { + await boss + .complete(BACKUP_RESTORE_QUEUE, row.pgBossJobId, { superseded: true }) + .catch(() => undefined); + } + const guard = { + id: row.id, + status: "running", + heartbeatAt: row.heartbeatAt, + }; + if (!boss || row.attempts >= BACKUP_RESTORE_MAX_ATTEMPTS) { + const done = await prisma.backupRestoreJob.updateMany({ + where: guard, + data: { + status: "failed", + phase: null, + failure: failureJson(INTERRUPTED), + completedAt: now, + }, + }); + failed += done.count; + continue; + } + const reset = await prisma.backupRestoreJob.updateMany({ + where: guard, + data: { status: "queued", phase: null, progress: toJson({}) }, + }); + if (reset.count === 0) continue; + try { + const bossJobId = await boss.send( + BACKUP_RESTORE_QUEUE, + { restoreJobId: row.id } satisfies BackupRestorePayload, + BACKUP_RESTORE_SEND_OPTIONS, + ); + if (!bossJobId) throw new Error("queue rejected job"); + await prisma.backupRestoreJob.update({ + where: { id: row.id }, + data: { pgBossJobId: bossJobId }, + }); + requeued += 1; + } catch { + await prisma.backupRestoreJob.updateMany({ + where: { id: row.id, status: "queued" }, + data: { + status: "failed", + failure: failureJson(INTERRUPTED), + completedAt: now, + }, + }); + failed += 1; + } + } + return { requeued, failed }; +} + +/** + * Queue the sweep for after a stopped worker's heartbeat has gone stale. Sent + * at worker boot: a job the previous process was running still has a fresh + * heartbeat at that moment, and would otherwise be taken for a live one. + */ +export async function enqueueBackupRestoreSweepAtBoot( + boss: PgBoss, +): Promise { + try { + await boss.send( + BACKUP_RESTORE_QUEUE, + { sweep: true } satisfies BackupRestorePayload, + { startAfter: Math.ceil(BACKUP_RESTORE_STALE_AFTER_MS / 1000) + 15 }, + ); + } catch { + // The admission check fails an abandoned job the next time the account + // is restored, so a missed sweep costs a retry, not a stuck account. + } +} + +/** The queue's handler. */ +export async function handleBackupRestore( + jobs: Job[], +): Promise { + // pg-boss hands this queue one job at a time (no batch size is set), and a + // single job's own facts are what its row in pg-boss should keep. A batch + // reports the first failure, or how many it ran. + let failure: JobOutcome | null = null; + let last: JobOutcome = jobDone({ jobs: 0 }); + for (const job of jobs) { + const outcome = await withBackgroundEvent( + "job.backup_restore", + async () => { + if (!("sweep" in job.data)) { + return runBackupRestoreJob(job.data.restoreJobId, { + deadline: jobDeadline(job), + }); + } + const swept = await sweepInterruptedRestores(); + return jobDone({ + restore_requeued: swept.requeued, + restore_failed: swept.failed, + }); + }, + ); + last = outcome; + if (!outcome.ok && failure === null) failure = outcome; + } + if (jobs.length === 1) return last; + return failure ?? jobDone({ jobs: jobs.length }); +} + +/** + * Evict the restored account's server caches in the process serving the + * console. + * + * The job evicts them in the worker's process when the restore commits. In a + * deployment that runs the worker apart from the web server + * (`HEALTHLOG_PROCESS_TYPE=web` beside an `app-worker`), those are different + * processes, and the web process would keep serving the account's pre-restore + * dashboard until its caches aged out. The first time this process reports a + * finished restore, it evicts them itself. Once per job per process; a second + * eviction would only cost the next reader a cache miss. + */ +const evictedRestoreJobs = new Set(); + +export function evictAfterRestore(job: BackupRestoreJobView): void { + if (job.status !== "succeeded" && job.status !== "failed") return; + if (evictedRestoreJobs.has(job.id)) return; + evictedRestoreJobs.add(job.id); + // A failed restore after its commit (`failed_after_commit`) changed the data + // too; any other failure changed nothing, and evicting costs one cache miss. + if ( + job.status === "succeeded" || + job.failure?.code === "failed_after_commit" + ) { + invalidateUserData(job.userId); + } +} diff --git a/src/lib/jobs/job-budget.ts b/src/lib/jobs/job-budget.ts index 8607004b1..69324dac9 100644 --- a/src/lib/jobs/job-budget.ts +++ b/src/lib/jobs/job-budget.ts @@ -46,3 +46,22 @@ export function jobBudget( return () => now() >= deadline || jobs.some((job) => job.signal?.aborted === true); } + +/** + * The same budget as an instant, for a pass that cannot stop half-way. + * + * A pass that commits unit by unit asks `jobBudget` between units. A pass + * that is one transaction cannot stop part-way without undoing everything it + * did, so it asks once, before it starts, whether its own time limit ends + * before this instant, and refuses to start when it does. `undefined` when the + * job carries no expiry. + */ +export function jobDeadline( + job: BudgetedJob, + now: () => number = Date.now, +): number | undefined { + if (!Number.isFinite(job.expireInSeconds) || job.expireInSeconds <= 0) { + return undefined; + } + return now() + job.expireInSeconds * 1000 * JOB_BUDGET_SHARE; +} diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 2f25991f4..835a23318 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -166,6 +166,9 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "reminders_due", "removed", "rows_written", + // v1.39.1 — the background restore of a stored backup, and the sweep that + // re-queues one a stopped worker left running. Counts and a claim flag. + "restore_claimed", "restore_drill_age_days", "restore_drill_ciphertext_bytes", "restore_drill_intake_events", @@ -174,6 +177,10 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "restore_drill_mood_entries", "restore_drill_plaintext_bytes", "restore_drill_stale", + "restore_failed", + "restore_measurements", + "restore_requeued", + "restore_skipped_links", "retried", "reviewed", "rollup_failed", diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 30507c800..0aa69e355 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -72,6 +72,12 @@ import { RESTORE_DRILL_CRON, RESTORE_DRILL_QUEUE, } from "@/lib/jobs/restore-drill"; +import { + BACKUP_RESTORE_QUEUE, + enqueueBackupRestoreSweepAtBoot, + handleBackupRestore, + type BackupRestorePayload, +} from "@/lib/jobs/backup-restore"; import { NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CONCURRENCY, @@ -347,6 +353,10 @@ const allQueues = [ STEP_UP_ELEVATION_CLEANUP_QUEUE, OFFHOST_BACKUP_QUEUE, RESTORE_DRILL_QUEUE, + // v1.39.1 — an admin's restore of a stored backup, run off the request, and + // the boot sweep that re-queues one a stopped worker left running. Without + // this entry every restore request would queue a job nobody works. + BACKUP_RESTORE_QUEUE, HOST_METRIC_QUEUE, FEEDBACK_AGGREGATOR_QUEUE, GEO_BACKFILL_QUEUE, @@ -737,6 +747,18 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleRestoreDrill, ); + // One restore at a time per worker: each holds a transaction over a whole + // account and a batch of readings in memory, and two at once on a small host + // is how a restore that fits alone would run out of heap. + await createAndWork( + boss, + BACKUP_RESTORE_QUEUE, + { localConcurrency: 1 }, + handleBackupRestore, + ); + // A restore the previous process was running is picked up again once its + // heartbeat has gone stale; see `sweepInterruptedRestores`. + await enqueueBackupRestoreSweepAtBoot(boss); await createAndWork( boss, HOST_METRIC_QUEUE, diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index cd9802d8e..bbfeffc72 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -101,6 +101,7 @@ const subsystemSurface = { "step-up-elevation-cleanup": { audience: "system" }, "data-backup-offhost": { audience: "system" }, "data-restore-drill": { audience: "system" }, + "backup-restore": { audience: "system" }, "host-metric-sample": { audience: "system" }, "feedback-aggregator": { audience: "system" }, "geo-backfill": { audience: "system" }, diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 49d374343..7b94581ef 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -65,7 +65,11 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.token.expired", "auth.token.invalid", ], - backup: ["backup.payload.undecryptable", "backup.section.missing"], + backup: [ + "backup.payload.undecryptable", + "backup.restore.active", + "backup.section.missing", + ], coach: [ "coach.budget.exceeded", "coach.conversation.invalidTitle", diff --git a/src/lib/openapi/routes/admin-backups.ts b/src/lib/openapi/routes/admin-backups.ts index aea64243b..59964b4b5 100644 --- a/src/lib/openapi/routes/admin-backups.ts +++ b/src/lib/openapi/routes/admin-backups.ts @@ -1,5 +1,6 @@ /** - * OpenAPI route table — the two backups-console routes that read a stored copy. + * OpenAPI route table — the backups-console routes that read a stored copy, and + * the restore job they start. * * Part of the OpenAPI route table; aggregated in `./index.ts`. * @@ -13,6 +14,10 @@ * (422, `backup.payload.undecryptable`, nothing changed) rather than whatever * the handler happens to answer, and a promise belongs somewhere it can be * quoted from. + * + * The restore answers 202 and runs as a background job, so the two routes that + * report on that job are published with it: a 202 without its status route is + * half a contract. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -42,20 +47,85 @@ const restoreSkipEntry = z "One catalogue key the file referenced and this instance could not resolve, with the number of links it cost.", }); -const restoreResult = z +const restoreQueued = z .object({ - restored: z.literal(true), - summary: z.record(z.string(), z.unknown()), - skipped: z.object({ - catalogueKeys: z.array(restoreSkipEntry), - links: z.number().int(), - }), - cleared: z.record(z.string(), z.number().int()), + jobId: z.string(), + status: z.literal("queued"), + statusUrl: z.string(), }) .meta({ - id: "AdminBackupRestoreResult", + id: "AdminBackupRestoreQueued", description: - "What the transaction wrote. `cleared` counts the rows each class held before it was rebuilt from the file, `summary` counts what the file carried, and `skipped.links` is the number of links dropped because a catalogue key in the file does not exist here — zero is the normal answer.", + "The restore was accepted and queued. Poll `statusUrl` (`GET /api/admin/backups/restores/{jobId}`) for its progress and outcome.", + }); + +const restorePhase = z + .enum(["validating", "clearing", "measurements", "sections", "rebuilding"]) + .meta({ + id: "AdminBackupRestorePhase", + description: + "Where a running restore is: reading and checking the file, clearing the account's current data, writing the readings, writing the other sections, rebuilding the chart tiers after the commit.", + }); + +const restoreProgress = z + .object({ + measurementsChecked: z.number().int(), + measurementsTotal: z.number().int().nullable(), + measurementsWritten: z.number().int(), + sectionsDone: z.number().int(), + sectionsTotal: z.number().int(), + }) + .meta({ + id: "AdminBackupRestoreProgress", + description: + "Counts only. `measurementsTotal` is null until the first read of the file has counted the readings.", + }); + +const restoreFailure = z + .object({ + code: z.string().meta({ + description: + "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed.", + }), + message: z.string(), + sections: z.array(z.string()).optional(), + }) + .meta({ + id: "AdminBackupRestoreFailure", + description: + "Why the restore did not happen, in a sentence an operator can act on. `sections` names the missing sections for `backup.section.missing`.", + }); + +const restoreJob = z + .object({ + id: z.string(), + userId: z.string(), + username: z.string().nullable(), + backupId: z.string(), + restoreInstanceSettings: z.boolean(), + status: z.enum(["queued", "running", "succeeded", "failed"]), + phase: restorePhase.nullable(), + progress: restoreProgress.nullable(), + result: z + .object({ + summary: z.record(z.string(), z.unknown()), + skipped: z.object({ + catalogueKeys: z.array(restoreSkipEntry), + links: z.number().int(), + }), + cleared: z.record(z.string(), z.number().int()), + }) + .nullable(), + failure: restoreFailure.nullable(), + attempts: z.number().int(), + createdAt: z.string(), + startedAt: z.string().nullable(), + completedAt: z.string().nullable(), + }) + .meta({ + id: "AdminBackupRestoreJob", + description: + "One restore job. `phase` and `progress` describe a running job; `result` is set once it `succeeded` (the same report the synchronous restore used to answer with, less `restored`); `failure` once it `failed`. `attempts` counts the starts: a job whose worker stopped is started again, at most twice.", }); /** @@ -122,9 +192,9 @@ export const adminBackupPaths: NonNullable = { "/api/admin/backups/{id}/restore": { post: { tags: ["Admin"], - summary: "Restore one stored backup over its owner's record", + summary: "Queue the restore of one stored backup over its owner's record", description: - "Replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it, and a payload declaring a different owner than the stored row is refused. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + "Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", requestParams: { path: z.object({ id: z.string().meta({ description: "`DataBackup.id`." }), @@ -139,11 +209,15 @@ export const adminBackupPaths: NonNullable = { content: { "application/json": { schema: restoreRequest } }, }, responses: { - "200": { - description: "The restore ran.", + "202": { + description: + "The restore is queued. Its progress and outcome are at `statusUrl`.", content: { "application/json": { - schema: dataEnvelope(restoreResult, "AdminBackupRestoreResponse"), + schema: dataEnvelope( + restoreQueued, + "AdminBackupRestoreQueuedResponse", + ), }, }, }, @@ -155,7 +229,7 @@ export const adminBackupPaths: NonNullable = { "404": notFoundResponse, "409": { description: - "Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed.", + "A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), or a request under the same `Idempotency-Key` is still in flight. Nothing was changed.", content: { "application/json": { schema: errorEnvelope } }, }, "413": { @@ -164,7 +238,64 @@ export const adminBackupPaths: NonNullable = { }, "422": { description: - "The request or the file was refused before anything was written: `confirm` missing, the copy undecryptable (`meta.errorCode` = `backup.payload.undecryptable`), the payload failing schema validation, an unsupported `schemaVersion`, a section the file's own manifest says it carries and does not (`meta.errorCode` = `backup.section.missing`), a metadata-only document entry, or an owner who no longer exists here.", + "The request was refused before anything was queued: `confirm` missing, or the copy undecryptable (`meta.errorCode` = `backup.payload.undecryptable`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: + "The restore could not be handed to the background worker. Nothing was changed.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/admin/backups/restores": { + get: { + tags: ["Admin"], + summary: "List the restores the backups console shows", + description: + "Every queued or running restore, and those created in the last day, newest first, at most 20. What the console reads when it opens, so a reload during a restore picks its progress up again. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + responses: { + "200": { + description: "The restore jobs.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ jobs: z.array(restoreJob) }), + "AdminBackupRestoreJobListResponse", + ), + }, + }, + }, + "403": adminOnlyResponse, + }, + }, + }, + "/api/admin/backups/restores/{jobId}": { + get: { + tags: ["Admin"], + summary: "Read one restore job", + description: + "The status, phase and counts of one restore, and its report or its failure once it finished. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + requestParams: { + path: z.object({ + jobId: z.string().meta({ + description: "The `jobId` the restore request answered with.", + }), + }), + }, + responses: { + "200": { + description: "The restore job.", + content: { + "application/json": { + schema: dataEnvelope(restoreJob, "AdminBackupRestoreJobResponse"), + }, + }, + }, + "403": adminOnlyResponse, + "404": { + description: "No restore job with this id.", content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 18c1720c1..8a90672d1 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -41,6 +41,11 @@ export const adminKeys = { /** v1.37.20 — restore preview: the counts a stored backup file carries. */ adminBackupSummary: (id: string) => ["admin", "backups", id, "summary"] as const, + /** + * v1.39.1 — the restore jobs the backups console shows. Its own prefix, so + * invalidating the backup list does not refetch the poll and vice versa. + */ + adminBackupRestores: () => ["admin", "backup-restores"] as const, /** v1.23 — encryption coverage + rotation progress (admin Encryption section). */ adminEncryptionStatus: () => ["admin", "encryption", "status"] as const, adminCoachFeedback: () => ["admin", "coach-feedback"] as const, diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index bc1752441..be280015f 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -677,7 +677,7 @@ describe("account grant lifecycle paths", () => { }); await signIn(admin.id); - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const response = await POST( new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { method: "POST", diff --git a/tests/integration/admin-backups-audit.test.ts b/tests/integration/admin-backups-audit.test.ts index fe7755fb4..1417525e0 100644 --- a/tests/integration/admin-backups-audit.test.ts +++ b/tests/integration/admin-backups-audit.test.ts @@ -163,8 +163,7 @@ describe("backup endpoints — AuditLog contract", () => { expect(downloadRes.status).toBe(200); // ── restore ── - const { POST: RESTORE } = - await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST: RESTORE } = await import("./restore-job-driver"); const restoreRes = await RESTORE( new Request(`http://localhost/api/admin/backups/${seeded.id}/restore`, { method: "POST", diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 487df88b1..2000158c2 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -25,7 +25,7 @@ import { backupPayloadSchema, parseBackupPayload, } from "@/lib/validations/backup"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { cookieJar, headerJar } from "./mock-next-headers"; diff --git a/tests/integration/admin-backups-restore.test.ts b/tests/integration/admin-backups-restore.test.ts index fed756f18..a27239b1b 100644 --- a/tests/integration/admin-backups-restore.test.ts +++ b/tests/integration/admin-backups-restore.test.ts @@ -208,7 +208,7 @@ describe("POST /api/admin/backups/[id]/restore", () => { }); // ── act: restore ── - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const req = makeRequest(backup.id, { confirm: "RESTORE" }); const res = await POST(req as unknown as Parameters[0], { params: Promise.resolve({ id: backup.id }), @@ -302,7 +302,7 @@ describe("POST /api/admin/backups/[id]/restore", () => { }, }); - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const req = makeRequest(backup.id, { confirm: "yes" }); const res = await POST(req as unknown as Parameters[0], { params: Promise.resolve({ id: backup.id }), @@ -358,7 +358,7 @@ describe("POST /api/admin/backups/[id]/restore", () => { }, }); - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const restore = (body: Record) => POST( makeRequest(backup.id, body) as unknown as Parameters[0], @@ -421,7 +421,7 @@ describe("POST /api/admin/backups/[id]/restore", () => { }, }); - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const res = await POST( makeRequest(backup.id, { confirm: "RESTORE", @@ -442,7 +442,7 @@ describe("POST /api/admin/backups/[id]/restore", () => { const admin = await seedAdminSession(); void admin; - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const req = makeRequest("does-not-exist", { confirm: "RESTORE" }); const res = await POST(req as unknown as Parameters[0], { params: Promise.resolve({ id: "does-not-exist" }), diff --git a/tests/integration/backup-custom-cycle-symptom-restore.test.ts b/tests/integration/backup-custom-cycle-symptom-restore.test.ts index be4709966..98ff701ba 100644 --- a/tests/integration/backup-custom-cycle-symptom-restore.test.ts +++ b/tests/integration/backup-custom-cycle-symptom-restore.test.ts @@ -40,7 +40,7 @@ process.env.ENCRYPTION_KEY = import { decrypt, encrypt } from "@/lib/crypto"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { parseBackupPayload } from "@/lib/validations/backup"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { cookieJar, headerJar } from "./mock-next-headers"; diff --git a/tests/integration/backup-cycle-symptom-severity-restore.test.ts b/tests/integration/backup-cycle-symptom-severity-restore.test.ts index c55095e2b..cf392785b 100644 --- a/tests/integration/backup-cycle-symptom-severity-restore.test.ts +++ b/tests/integration/backup-cycle-symptom-severity-restore.test.ts @@ -28,7 +28,7 @@ process.env.ENCRYPTION_KEY = import { encrypt } from "@/lib/crypto"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { parseBackupPayload } from "@/lib/validations/backup"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { cookieJar, headerJar } from "./mock-next-headers"; diff --git a/tests/integration/backup-missing-section-restore.test.ts b/tests/integration/backup-missing-section-restore.test.ts index 89702af11..1c3afad66 100644 --- a/tests/integration/backup-missing-section-restore.test.ts +++ b/tests/integration/backup-missing-section-restore.test.ts @@ -34,7 +34,7 @@ process.env.ENCRYPTION_KEY = import { encrypt } from "@/lib/crypto"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { cookieJar, headerJar } from "./mock-next-headers"; diff --git a/tests/integration/backup-portable-file-restore.test.ts b/tests/integration/backup-portable-file-restore.test.ts index 8ed6ceb21..a840163ee 100644 --- a/tests/integration/backup-portable-file-restore.test.ts +++ b/tests/integration/backup-portable-file-restore.test.ts @@ -53,7 +53,7 @@ process.env.ENCRYPTION_KEY = import { encrypt, encryptBytes } from "@/lib/crypto"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { parseBackupPayload } from "@/lib/validations/backup"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { cookieJar, headerJar } from "./mock-next-headers"; diff --git a/tests/integration/backup-restore-job.test.ts b/tests/integration/backup-restore-job.test.ts new file mode 100644 index 000000000..d58283807 --- /dev/null +++ b/tests/integration/backup-restore-job.test.ts @@ -0,0 +1,536 @@ +/** + * A backup restore runs as a background job, end to end. + * + * The restore of a large account outlived a reverse proxy's 60-second request + * limit, so the operator saw an error for a restore that went on to finish. + * The request now answers 202 with a job id, the `backup-restore` queue runs + * the restore, and `GET /api/admin/backups/restores/{id}` reports it. Pinned + * here against a real Postgres: + * + * 1. upload → 202 → the job queued → run → the status route says succeeded, + * with the counts, and the rows are back; + * 2. a copy that cannot be decrypted is refused in the request itself, and + * no job is created; + * 3. a restore that fails part-way ends `failed` with a reason, and the + * account is exactly as it was (the transaction rolled back); + * 4. a second restore of the same account while one is queued answers 409, + * and two requests racing each other admit exactly one; + * 5. a job whose worker stopped is queued again by the sweep and then + * finishes; after the second interruption it fails instead of looping; + * the listing a reloaded page reads shows the job while it runs; + * 6. a copy replaced while the job waited is not restored in its place; + * 7. a file whose transaction would outlast the job's budget is refused + * before anything is deleted. + * + * Mutation checks (each run once, each turned the named case red): + * - drop the partial unique index from migration 0347: case 4's race admits + * two jobs; + * - skip the digest comparison in `runBackupRestoreJob`: case 6 restores the + * replaced copy; + * - remove the deadline check in `restoreBackup`: case 7 deletes the rows; + * - drop the attempts cap in `sweepInterruptedRestores`: case 5's third + * interruption is queued again. + */ +import type { PgBoss } from "pg-boss"; +import { gzipSync } from "node:zlib"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { setGlobalBoss } from "@/lib/jobs/boss-instance"; +import { runJob } from "@/lib/jobs/run-job"; +import { + BACKUP_RESTORE_MAX_ATTEMPTS, + BACKUP_RESTORE_QUEUE, + handleBackupRestore, + readBackupRestoreJob, + runBackupRestoreJob, + sweepInterruptedRestores, +} from "@/lib/jobs/backup-restore"; +import { backupPayloadSchema } from "@/lib/validations/backup"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** A queue that records what it is sent and what it is told is finished. */ +function recordingBoss() { + const sent: Array<{ queue: string; data: unknown }> = []; + const completed: string[] = []; + let next = 0; + const boss = { + send: vi.fn(async (queue: string, data: unknown) => { + sent.push({ queue, data }); + next += 1; + return `boss-${next}`; + }), + complete: vi.fn(async (_queue: string, id: string) => { + completed.push(id); + return {}; + }), + } as unknown as PgBoss; + return { boss, sent, completed }; +} + +let queue: ReturnType; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + queue = recordingBoss(); + setGlobalBoss(queue.boss); +}); + +async function seedAdmin(username = "restore-job-admin") { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { username, email: `${username}@example.test`, role: "ADMIN" }, + }); + const session = await prisma.session.create({ + data: { userId: admin.id, expiresAt: new Date(Date.now() + 600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return admin; +} + +function payloadFor(userId: string, measurementIds: string[]) { + return backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-09-20T00:00:00.000Z", + userId, + measurements: measurementIds.map((id, i) => ({ + id, + type: "PULSE", + value: 60 + i, + unit: "bpm", + measuredAt: new Date(Date.UTC(2026, 8, 1, 7, i)).toISOString(), + source: "MANUAL", + })), + }); +} + +async function storeBackup(userId: string, measurementIds: string[]) { + return getPrismaClient().dataBackup.create({ + data: { + userId, + type: `MANUAL_UPLOAD_${Date.now()}_${Math.random()}`, + data: encrypt(JSON.stringify(payloadFor(userId, measurementIds))), + }, + }); +} + +async function seedMeasurements(userId: string, ids: string[]) { + await getPrismaClient().measurement.createMany({ + data: ids.map((id, i) => ({ + id, + userId, + type: "WEIGHT" as const, + value: 80 + i, + unit: "kg", + measuredAt: new Date(Date.UTC(2026, 7, 1, 7, i)), + })), + }); +} + +async function requestRestore(backupId: string, key?: string) { + const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + return POST( + new Request(`http://localhost/api/admin/backups/${backupId}/restore`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(key ? { "idempotency-key": key } : {}), + }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backupId }) }, + ); +} + +async function readStatus(jobId: string) { + const { GET } = + await import("@/app/api/admin/backups/restores/[jobId]/route"); + const res = await GET( + new Request( + `http://localhost/api/admin/backups/restores/${jobId}`, + ) as never, + { params: Promise.resolve({ jobId }) }, + ); + return res; +} + +async function listStatus() { + const { GET } = await import("@/app/api/admin/backups/restores/route"); + // The listing handler declares no parameters; `apiHandler` reads the + // request defensively, so calling it bare is what a GET without a body is. + const res = await GET(); + return (await res.json()) as { + data: { jobs: Array<{ id: string; status: string }> }; + }; +} + +async function measurementIdsOf(userId: string) { + const rows = await getPrismaClient().measurement.findMany({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true }, + }); + return rows.map((row) => row.id); +} + +describe("backup restore as a background job", () => { + it("upload → 202 → queued → run → succeeded, and the rows are back", async () => { + const admin = await seedAdmin(); + await seedMeasurements(admin.id, ["live-1", "live-2"]); + + const file = Buffer.from( + JSON.stringify(payloadFor(admin.id, ["m-a", "m-b", "m-c"])), + ); + const { POST: upload } = + await import("@/app/api/admin/backups/upload/route"); + const uploaded = await upload( + new Request("http://localhost/api/admin/backups/upload", { + method: "POST", + headers: { "content-type": "application/gzip" }, + body: gzipSync(file), + }) as never, + ); + expect(uploaded.status).toBe(201); + const { data: stored } = (await uploaded.json()) as { + data: { id: string }; + }; + + const queued = await requestRestore(stored.id); + expect(queued.status).toBe(202); + const { data } = (await queued.json()) as { + data: { jobId: string; status: string; statusUrl: string }; + }; + expect(data.status).toBe("queued"); + expect(data.statusUrl).toBe(`/api/admin/backups/restores/${data.jobId}`); + // The request queued the job and changed nothing yet. + expect(queue.sent).toEqual([ + { queue: BACKUP_RESTORE_QUEUE, data: { restoreJobId: data.jobId } }, + ]); + expect(await measurementIdsOf(admin.id)).toEqual(["live-1", "live-2"]); + const waiting = (await (await readStatus(data.jobId)).json()) as { + data: { status: string }; + }; + expect(waiting.data.status).toBe("queued"); + + // Through the same wrapper the worker binds, so the outcome pg-boss + // persists is validated too, not only the handler's return value. + const persisted = await runJob( + BACKUP_RESTORE_QUEUE, + handleBackupRestore, + )([ + { + id: "boss-1", + name: BACKUP_RESTORE_QUEUE, + data: { restoreJobId: data.jobId }, + expireInSeconds: 7200, + signal: new AbortController().signal, + } as never, + ]); + expect(persisted).toEqual({ + ok: true, + did: { restore_measurements: 3, restore_skipped_links: 0 }, + }); + + const finished = await readStatus(data.jobId); + expect(finished.status).toBe(200); + const body = (await finished.json()) as { + data: { + status: string; + phase: string | null; + attempts: number; + progress: { measurementsWritten: number; measurementsTotal: number }; + result: { + summary: { measurements: number }; + cleared: { measurements: number }; + }; + failure: unknown; + }; + }; + expect(body.data.status).toBe("succeeded"); + expect(body.data.phase).toBeNull(); + expect(body.data.attempts).toBe(1); + expect(body.data.failure).toBeNull(); + expect(body.data.result.summary.measurements).toBe(3); + expect(body.data.result.cleared.measurements).toBe(2); + expect(body.data.progress.measurementsTotal).toBe(3); + expect(body.data.progress.measurementsWritten).toBe(3); + expect(await measurementIdsOf(admin.id)).toEqual(["m-a", "m-b", "m-c"]); + + // The trail says who queued it and that it ran. + const actions = ( + await getPrismaClient().auditLog.findMany({ + where: { userId: admin.id }, + select: { action: true }, + }) + ).map((row) => row.action); + expect(actions).toEqual( + expect.arrayContaining([ + "admin.backups.restore.queued", + "admin.backups.restore.start", + "admin.backups.restore", + ]), + ); + }); + + it("refuses a copy that cannot be decrypted in the request, and queues nothing", async () => { + const admin = await seedAdmin(); + const backup = await getPrismaClient().dataBackup.create({ + data: { + userId: admin.id, + type: "MANUAL_UPLOAD_1", + data: "v1:not-a-real-ciphertext", + }, + }); + + const res = await requestRestore(backup.id); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("backup.payload.undecryptable"); + expect(queue.sent).toEqual([]); + expect(await getPrismaClient().backupRestoreJob.count()).toBe(0); + }); + + it("a restore that fails part-way ends failed with a reason, and nothing changed", async () => { + const admin = await seedAdmin(); + await seedMeasurements(admin.id, ["live-1"]); + // Another account already owns this id, so the insert fails AFTER the + // restore deleted the owner's rows, inside the transaction. + const other = await getPrismaClient().user.create({ + data: { username: "someone-else", email: "else@example.test" }, + }); + await seedMeasurements(other.id, ["taken"]); + const backup = await storeBackup(admin.id, ["m-a", "taken"]); + + const queued = await requestRestore(backup.id); + expect(queued.status).toBe(202); + const { data } = (await queued.json()) as { data: { jobId: string } }; + const outcome = await runBackupRestoreJob(data.jobId); + // A transaction that could not be written is a fault the operator's job + // card should show, not a quiet success. + expect(outcome.ok).toBe(false); + + const job = await readBackupRestoreJob(data.jobId); + expect(job?.status).toBe("failed"); + expect(job?.failure?.code).toBe("transaction_failed"); + expect(job?.failure?.message).toContain("Nothing was changed"); + expect(await measurementIdsOf(admin.id)).toEqual(["live-1"]); + expect(await measurementIdsOf(other.id)).toEqual(["taken"]); + // The staged copy stays, so a retry needs no new upload. + expect( + await getPrismaClient().dataBackup.findUnique({ + where: { id: backup.id }, + }), + ).not.toBeNull(); + }); + + it("refuses a second restore of the same account while one is queued", async () => { + const admin = await seedAdmin(); + const first = await storeBackup(admin.id, ["m-a"]); + const second = await storeBackup(admin.id, ["m-b"]); + + const accepted = await requestRestore(first.id); + expect(accepted.status).toBe(202); + const { data } = (await accepted.json()) as { data: { jobId: string } }; + + const refused = await requestRestore(second.id); + expect(refused.status).toBe(409); + const body = (await refused.json()) as { + meta?: { errorCode?: string; jobId?: string }; + }; + expect(body.meta?.errorCode).toBe("backup.restore.active"); + expect(body.meta?.jobId).toBe(data.jobId); + + // Once it has finished, the account can be restored again. + await runBackupRestoreJob(data.jobId); + expect((await requestRestore(second.id)).status).toBe(202); + }); + + it("two requests racing for one account admit exactly one", async () => { + const admin = await seedAdmin(); + const first = await storeBackup(admin.id, ["m-a"]); + const second = await storeBackup(admin.id, ["m-b"]); + + const statuses = ( + await Promise.all([ + requestRestore(first.id, "race-a"), + requestRestore(second.id, "race-b"), + ]) + ) + .map((res) => res.status) + .sort(); + expect(statuses).toEqual([202, 409]); + expect( + await getPrismaClient().backupRestoreJob.count({ + where: { status: { in: ["queued", "running"] } }, + }), + ).toBe(1); + }); + + it("picks up a restore a stopped worker left running, and stops after the second time", async () => { + const admin = await seedAdmin(); + await seedMeasurements(admin.id, ["live-1"]); + const backup = await storeBackup(admin.id, ["m-a", "m-b"]); + const prisma = getPrismaClient(); + + const queued = await requestRestore(backup.id); + const { data } = (await queued.json()) as { data: { jobId: string } }; + + // What a worker that died mid-restore leaves behind: the row says running, + // its heartbeat stopped minutes ago, the transaction rolled back with the + // process, so the account is untouched. + const stale = new Date(Date.now() - 10 * 60_000); + await prisma.backupRestoreJob.update({ + where: { id: data.jobId }, + data: { + status: "running", + phase: "measurements", + attempts: 1, + heartbeatAt: stale, + startedAt: stale, + }, + }); + + // A page reloaded now finds the job and keeps polling it. + expect((await listStatus()).data.jobs).toEqual([ + expect.objectContaining({ id: data.jobId, status: "running" }), + ]); + + const swept = await sweepInterruptedRestores(queue.boss); + expect(swept).toEqual({ requeued: 1, failed: 0 }); + // The delivery the dead worker held is finished, not left to time out. + expect(queue.completed).toEqual(["boss-1"]); + expect(queue.sent.at(-1)).toEqual({ + queue: BACKUP_RESTORE_QUEUE, + data: { restoreJobId: data.jobId }, + }); + expect((await readBackupRestoreJob(data.jobId))?.status).toBe("queued"); + + await runBackupRestoreJob(data.jobId); + const resumed = await readBackupRestoreJob(data.jobId); + expect(resumed?.status).toBe("succeeded"); + expect(resumed?.attempts).toBe(2); + expect(await measurementIdsOf(admin.id)).toEqual(["m-a", "m-b"]); + + // A job that has already been started the maximum number of times fails + // instead of being queued a third time. + const again = await requestRestore(backup.id); + const { data: next } = (await again.json()) as { data: { jobId: string } }; + await prisma.backupRestoreJob.update({ + where: { id: next.jobId }, + data: { + status: "running", + attempts: BACKUP_RESTORE_MAX_ATTEMPTS, + heartbeatAt: stale, + }, + }); + expect(await sweepInterruptedRestores(queue.boss)).toEqual({ + requeued: 0, + failed: 1, + }); + const gaveUp = await readBackupRestoreJob(next.jobId); + expect(gaveUp?.status).toBe("failed"); + expect(gaveUp?.failure?.code).toBe("interrupted"); + }); + + it("a live job's heartbeat keeps it out of the sweep", async () => { + const admin = await seedAdmin(); + const backup = await storeBackup(admin.id, ["m-a"]); + const queued = await requestRestore(backup.id); + const { data } = (await queued.json()) as { data: { jobId: string } }; + await getPrismaClient().backupRestoreJob.update({ + where: { id: data.jobId }, + data: { status: "running", attempts: 1, heartbeatAt: new Date() }, + }); + + expect(await sweepInterruptedRestores(queue.boss)).toEqual({ + requeued: 0, + failed: 0, + }); + expect((await readBackupRestoreJob(data.jobId))?.status).toBe("running"); + // And a second request for the account is still refused while it runs. + expect((await requestRestore(backup.id)).status).toBe(409); + }); + + it("does not restore a copy that was replaced while the job waited", async () => { + const admin = await seedAdmin(); + await seedMeasurements(admin.id, ["live-1"]); + const backup = await storeBackup(admin.id, ["m-a"]); + const queued = await requestRestore(backup.id); + const { data } = (await queued.json()) as { data: { jobId: string } }; + + // The weekly job overwrites its row in place. + await getPrismaClient().dataBackup.update({ + where: { id: backup.id }, + data: { + data: encrypt(JSON.stringify(payloadFor(admin.id, ["m-newer"]))), + }, + }); + + const persisted = await runJob( + BACKUP_RESTORE_QUEUE, + handleBackupRestore, + )([ + { + id: "boss-1", + name: BACKUP_RESTORE_QUEUE, + data: { restoreJobId: data.jobId }, + expireInSeconds: 7200, + signal: new AbortController().signal, + } as never, + ]); + // A refusal is the job doing what it was asked, not a crash. + expect(persisted).toEqual({ ok: true, did: { refused: "backup_changed" } }); + const job = await readBackupRestoreJob(data.jobId); + expect(job?.status).toBe("failed"); + expect(job?.failure?.code).toBe("backup_changed"); + expect(await measurementIdsOf(admin.id)).toEqual(["live-1"]); + }); + + it("refuses, before deleting anything, a file that would outlast the job's budget", async () => { + const admin = await seedAdmin(); + await seedMeasurements(admin.id, ["live-1"]); + const backup = await storeBackup(admin.id, ["m-a"]); + const queued = await requestRestore(backup.id); + const { data } = (await queued.json()) as { data: { jobId: string } }; + + // A deadline one minute out: shorter than the transaction's own + // two-minute floor, so the restore cannot promise to finish in time. + await runBackupRestoreJob(data.jobId, { deadline: Date.now() + 60_000 }); + const job = await readBackupRestoreJob(data.jobId); + expect(job?.status).toBe("failed"); + expect(job?.failure?.code).toBe("time_budget"); + expect(await measurementIdsOf(admin.id)).toEqual(["live-1"]); + }); +}); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 516d48541..2a04eddb3 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -69,7 +69,7 @@ import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; import { packBackupBlobStreaming } from "@/lib/export/backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; diff --git a/tests/integration/backup-unknown-catalogue-key-restore.test.ts b/tests/integration/backup-unknown-catalogue-key-restore.test.ts index 1a1082a28..6347ba32f 100644 --- a/tests/integration/backup-unknown-catalogue-key-restore.test.ts +++ b/tests/integration/backup-unknown-catalogue-key-restore.test.ts @@ -38,7 +38,7 @@ process.env.ENCRYPTION_KEY = import { encrypt } from "@/lib/crypto"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { parseBackupPayload } from "@/lib/validations/backup"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { cookieJar, headerJar } from "./mock-next-headers"; diff --git a/tests/integration/health-score-record.test.ts b/tests/integration/health-score-record.test.ts index 127fd93aa..53a49120b 100644 --- a/tests/integration/health-score-record.test.ts +++ b/tests/integration/health-score-record.test.ts @@ -383,7 +383,7 @@ describe("the recorded day survives a backup and a restore", () => { await prisma.healthScoreRecord.count({ where: { userId: admin.id } }), ).toBe(0); - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const res = await POST( new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { method: "POST", @@ -583,7 +583,7 @@ describe("the composition note reads the stored day, and survives a restore", () (await readAnalytics()).data!.healthScore!.compositionNotice ?? null, ).toBeNull(); - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const res = await POST( new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { method: "POST", diff --git a/tests/integration/large-backup-round-trip.test.ts b/tests/integration/large-backup-round-trip.test.ts index 61301ac42..69e77689e 100644 --- a/tests/integration/large-backup-round-trip.test.ts +++ b/tests/integration/large-backup-round-trip.test.ts @@ -117,7 +117,7 @@ async function measurementsOf(userId: string) { } async function restore(id: string) { - const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const { POST } = await import("./restore-job-driver"); const res = await POST( new Request(`http://localhost/api/admin/backups/${id}/restore`, { method: "POST", diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 310e122c5..7317d44c0 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -31,7 +31,7 @@ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { parseBackupPayload } from "@/lib/validations/backup"; -import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { POST } from "./restore-job-driver"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; diff --git a/tests/integration/restore-job-driver.ts b/tests/integration/restore-job-driver.ts new file mode 100644 index 000000000..2986bcb77 --- /dev/null +++ b/tests/integration/restore-job-driver.ts @@ -0,0 +1,90 @@ +/** + * Drive a queued restore to its end, the way the worker does, in one call. + * + * `POST /api/admin/backups/[id]/restore` answers 202 and leaves the restore to + * the `backup-restore` queue. The tests that pin what a restore checks and + * writes are about the restore, not the queue, so they call this instead of + * the route: it POSTs through the real route, runs the job the route queued + * with the real handler body, and answers the way the synchronous route did + * (200 with the report, or the refusal's status and message), read back from + * the job's row. Every hop in between is production code; only the pg-boss + * delivery is replaced by a direct call. + * + * The asynchronous contract itself (202, the status route, 409, resuming) is + * pinned by `backup-restore-job.test.ts`, which does not use this. + */ +import type { PgBoss } from "pg-boss"; + +import { POST as queueRestore } from "@/app/api/admin/backups/[id]/restore/route"; +import { getGlobalBoss, setGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + readBackupRestoreJob, + runBackupRestoreJob, +} from "@/lib/jobs/backup-restore"; + +/** The status the synchronous route answered each refusal with. */ +const LEGACY_STATUS: Record = { + backup_not_found: 404, + backup_changed: 409, + "backup.payload.undecryptable": 422, + schema_invalid: 422, + incompatible_schema_version: 422, + owner_mismatch: 409, + owner_not_found: 422, + "backup.section.missing": 422, + document_ciphertext_missing: 422, + time_budget: 503, + transaction_failed: 500, +}; + +/** A queue that accepts every send, for a test with no worker. */ +export const acceptingBoss = { + send: async () => "test-boss-job", + complete: async () => ({}), +} as unknown as PgBoss; + +export async function POST( + request: Parameters[0], + context: { params: Promise<{ id: string }> }, +): Promise { + // A file that mocks the queue already has one; otherwise stand one in for + // the duration of the request only, so nothing else in the file sees it. + const previous = getGlobalBoss(); + if (!previous) setGlobalBoss(acceptingBoss); + let queued: Response; + try { + queued = await queueRestore(request, context); + } finally { + if (!previous) setGlobalBoss(null); + } + if (queued.status !== 202) return queued; + + const { data } = (await queued.json()) as { data: { jobId: string } }; + try { + await runBackupRestoreJob(data.jobId); + } catch { + // The job recorded the failure on its row before rethrowing; the + // synchronous route turned the same throw into a 500 envelope. + } + const job = await readBackupRestoreJob(data.jobId); + if (!job) throw new Error(`restore job ${data.jobId} vanished`); + + if (job.status === "succeeded" && job.result) { + return Response.json( + { data: { restored: true, ...job.result }, error: null }, + { status: 200 }, + ); + } + const failure = job.failure ?? { code: "unexpected", message: "failed" }; + return Response.json( + { + data: null, + error: failure.message, + meta: { + errorCode: failure.code, + ...(failure.sections ? { sections: failure.sections } : {}), + }, + }, + { status: LEGACY_STATUS[failure.code] ?? 500 }, + ); +}