mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'feat/restore-background-job' into release/v1.39.1
This commit is contained in:
commit
934131ee3e
52 files changed
+5684
-2439
No files matched your search
+322
-83
@@ -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
|
||||
|
||||
+33
-1
@@ -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",
|
||||
|
||||
+33
-1
@@ -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",
|
||||
|
||||
+33
-1
@@ -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",
|
||||
|
||||
+33
-1
@@ -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",
|
||||
|
||||
+33
-1
@@ -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",
|
||||
|
||||
+33
-1
@@ -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": "위험 구역",
|
||||
|
||||
+33
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
+119
-30
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -114,7 +114,7 @@ const WRITERS: Record<string, Disposition> = {
|
||||
},
|
||||
|
||||
// ── 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",
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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>): 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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<RestoreJobStatus job={view} onDismiss={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<RestoreJobStatus>` — 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<string>,
|
||||
): 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<typeof useTranslations>["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<typeof useTranslations>["t"],
|
||||
fmt: ReturnType<typeof useFormatters>,
|
||||
): { 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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-slot="restore-job-status"
|
||||
data-status={job.status}
|
||||
className="border-border bg-muted/30 space-y-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Loader2
|
||||
className="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium">
|
||||
{job.status === "queued"
|
||||
? t("admin.section.backups.restoreJobQueuedTitle", {
|
||||
username,
|
||||
})
|
||||
: t("admin.section.backups.restoreJobRunningTitle", {
|
||||
username,
|
||||
})}
|
||||
</p>
|
||||
<p>{line.text}</p>
|
||||
{line.percent !== null ? (
|
||||
<Progress
|
||||
value={line.percent}
|
||||
aria-label={line.text}
|
||||
data-slot="restore-job-progress"
|
||||
/>
|
||||
) : null}
|
||||
{job.attempts > 1 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.restoreJobResumed")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.restoreJobRunningNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (job.status === "succeeded") {
|
||||
const count = restoredCount(job);
|
||||
return (
|
||||
<div
|
||||
data-slot="restore-job-status"
|
||||
data-status={job.status}
|
||||
className="border-success/40 bg-success/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.restoreJobSucceededTitle", {
|
||||
username,
|
||||
})}
|
||||
</p>
|
||||
{/* An empty file restores to an empty account: honest, and not a
|
||||
tick. The outcome module decides which it looks like. */}
|
||||
<WrittenOutcomeLine
|
||||
outcome={count > 0 ? "success" : "empty"}
|
||||
testId="restore-job-outcome"
|
||||
message={t(
|
||||
"admin.section.backups.restoreJobSucceededDescription",
|
||||
{
|
||||
count: fmt.integer(count),
|
||||
when: job.completedAt ? fmt.dateTime(job.completedAt) : "",
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const code = job.failure?.code ?? "unexpected";
|
||||
const changedNothing = code !== "failed_after_commit";
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="restore-job-status"
|
||||
data-status={job.status}
|
||||
data-failure-code={code}
|
||||
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.restoreJobFailedTitle", { username })}
|
||||
</p>
|
||||
<p className="text-xs">{failureText(code, t)}</p>
|
||||
{changedNothing ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.section.backups.restoreJobFailedKept")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
).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 `<RestoreRowDialog>` 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<Map<string, string>>(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<Set<string>>(
|
||||
() => 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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
|
||||
<RestoreJobStatus
|
||||
key={job.id}
|
||||
job={job}
|
||||
onDismiss={() => dismissRestore(job.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<RestoreRefusalNotice
|
||||
sections={refusedSections}
|
||||
onDismiss={() => setRefusedSections([])}
|
||||
@@ -1133,8 +1232,9 @@ export function BackupsSection() {
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending &&
|
||||
restore.variables?.row.id === row.id
|
||||
(restore.isPending &&
|
||||
restore.variables?.row.id === row.id) ||
|
||||
activeOwners.has(row.userId)
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
@@ -1196,8 +1296,9 @@ export function BackupsSection() {
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending &&
|
||||
restore.variables?.row.id === row.id
|
||||
(restore.isPending &&
|
||||
restore.variables?.row.id === row.id) ||
|
||||
activeOwners.has(row.userId)
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-34
@@ -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<Response>
|
||||
)(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<Response>
|
||||
)(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.
|
||||
+22
-33
@@ -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<string, unknown>, {
|
||||
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<string, { findUnique?: unknown; findMany?: unknown[] }> = {},
|
||||
payloadOverride?: Record<string, unknown>,
|
||||
): Promise<{
|
||||
res: Response;
|
||||
res: { status: number };
|
||||
written: Written[];
|
||||
payload: Record<string, unknown>;
|
||||
}> {
|
||||
@@ -483,13 +460,25 @@ async function roundTrip(
|
||||
fn(recordingTx(written, seed)),
|
||||
);
|
||||
|
||||
const res = await (
|
||||
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
|
||||
)(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({
|
||||
@@ -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<Record<string, string>> = {
|
||||
"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:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<StreamedBackup> {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BackupRestoreFailureCode, true> = {
|
||||
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<Record<string, number | boolean>> = [
|
||||
{ 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof resultSchema>;
|
||||
|
||||
/** 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<BackupRestoreJobView | null> {
|
||||
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<BackupRestoreJobView[]> {
|
||||
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<void> {
|
||||
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<BackupRestoreAdmission> {
|
||||
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<JobOutcome> {
|
||||
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<unknown> | 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<void> {
|
||||
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<BackupRestorePayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
// 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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -166,6 +166,9 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = 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<string> = 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",
|
||||
|
||||
@@ -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<BackupRestorePayload>(
|
||||
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<HostMetricSamplePayload>(
|
||||
boss,
|
||||
HOST_METRIC_QUEUE,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -65,7 +65,11 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"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",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
"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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof POST>[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<typeof POST>[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<string, unknown>) =>
|
||||
POST(
|
||||
makeRequest(backup.id, body) as unknown as Parameters<typeof POST>[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<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: "does-not-exist" }),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof recordingBoss>;
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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<typeof queueRestore>[0],
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user