fix(backup): explain a second restore of one file into another account

Restored rows keep the ids they had in the file. Once an erased
account's copy had gone into one account, the same file uploaded into a
second account restored into a primary-key conflict, reported only as
the generic "the database rejected a record" failure.

For a copy put into a chosen account, a primary-key conflict now ends
the restore with backup.restore.rows_exist_elsewhere and a sentence that
says the file was most likely restored into another account already.
The transaction still rolls back, so neither account changes. The code
is in the catalogue, the console names it, and the runbook says a file
goes into one account only.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 21:50:34 +01:00
1 parent 94854bd8f7
commit c920266f32
20 files changed
+183 -35

No files matched your search

+36 -33
View File
@@ -38716,36 +38716,37 @@ components:
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
backup.payload.undecryptable, backup.restore.active, backup.section.missing,
backup.target.admin_account, backup.target.holds_records, backup.target.managed_profile,
backup.target.not_found, backup.target.pending_deletion, backup.target.unconfirmed,
backup.target.username_taken, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
body-site.invalid; 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.confirmRequired,
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; data_wipe — data_wipe.copy_failed; dawarich —
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; 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.notDeleted, documents.inbound.notFound,
documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere,
backup.section.missing, backup.target.admin_account, backup.target.holds_records,
backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion,
backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers —
biomarkers.unit.locked; body-site — body-site.invalid; 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.confirmRequired, 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; data_wipe — data_wipe.copy_failed;
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
demo.readOnly; 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.notDeleted,
documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit,
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
documents.sources.organizationRequired, documents.sources.originNotAllowed,
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
@@ -90612,9 +90613,11 @@ components:
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`, `backup.key.missing`, `backup.foreign_reference`, `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."
`document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`,
`backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account
already holds, because the same file was restored there before), `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:
+8
View File
@@ -143,6 +143,14 @@ restored managed profile is an ordinary account.
Once stored, the copy restores like any other: a restore run later, after the
account has gained records, replaces those too.
A file goes into one account only. Its records keep the ids they had in the
erased account, so once it has been restored into one account, restoring the
same file into a second account would write those ids twice. The upload still
stores the second copy, but its restore is refused with
`backup.restore.rows_exist_elsewhere` and rolled back, and nothing changes in
either account. To move the record to another account, delete the first
account (or its records) before restoring into the second.
### What a restore does not touch
API tokens, trusted devices, step-up elevations, known devices, clinician share
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.",
"restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.",
"restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.",
"restoreFailureRowsElsewhere": "Die Datensätze dieser Datei liegen schon in einem anderen Konto auf diesem Server: Dieselbe Datei wurde dort bereits wiederhergestellt. Eine Datei lässt sich nur in ein Konto wiederherstellen. Es wurde nichts geschrieben.",
"previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.",
"restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)",
"restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)",
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.",
"restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.",
"restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.",
"restoreFailureRowsElsewhere": "The records in this file are already in another account on this server: the same file was restored there before. A file can be restored into one account only. Nothing was written.",
"previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.",
"restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)",
"restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)",
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.",
"restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.",
"restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.",
"restoreFailureRowsElsewhere": "Los registros de este archivo ya están en otra cuenta de este servidor: el mismo archivo ya se restauró allí. Un archivo solo puede restaurarse en una cuenta. No se escribió nada.",
"previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.",
"restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)",
"restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)",
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur.",
"restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.",
"restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.",
"restoreFailureRowsElsewhere": "Les enregistrements de ce fichier se trouvent déjà dans un autre compte de ce serveur : le même fichier y a déjà été restauré. Un fichier ne peut être restauré que dans un seul compte. Rien n’a été écrit.",
"previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.",
"restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)",
"restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)",
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.",
"restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.",
"restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.",
"restoreFailureRowsElsewhere": "I record di questo file sono già in un altro account di questo server: lo stesso file è già stato ripristinato lì. Un file può essere ripristinato in un solo account. Non è stato scritto nulla.",
"previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.",
"restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)",
"restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)",
+1
View File
@@ -8320,6 +8320,7 @@
"restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.",
"restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
"restoreFailureRowsElsewhere": "이 파일의 기록은 이미 이 서버의 다른 계정에 있습니다. 같은 파일이 그 계정에 먼저 복원되었습니다. 파일은 한 계정에만 복원할 수 있습니다. 아무것도 기록되지 않았습니다.",
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Het terugzetten stopte door een onverwachte fout en is teruggedraaid. In het serverlog staat de fout.",
"restoreFailureKeyMissing": "De back-up heeft versleutelingssleutel {keys} nodig, waarmee deze server hem niet kan openen. Voeg de sleutel weer toe aan ENCRYPTION_KEYS, herstart en start het terugzetten opnieuw.",
"restoreFailureForeignReference": "Het bestand verwijst naar records die bij een ander account horen. Er is niets geschreven.",
"restoreFailureRowsElsewhere": "De records in dit bestand staan al in een ander account op deze server: hetzelfde bestand is daar al teruggezet. Een bestand kan maar in één account worden teruggezet. Er is niets geschreven.",
"previewKeyMissing": "Deze back-up kan niet op deze server worden teruggezet: er is versleutelingssleutel {keys} voor nodig. Voeg de sleutel eerst weer toe aan ENCRYPTION_KEYS en herstart.",
"restoreSkippedSymptomEpisodeReference": "Symptoomregistratie gekoppeld aan een ziekte die niet in het bestand staat (behouden zonder de koppeling)",
"restoreSkippedCycleCiphertext": "Cyclusnotitie of -label versleuteld met een sleutel die deze server niet heeft (dag of symptoom behouden zonder)",
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.",
"restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.",
"restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.",
"restoreFailureRowsElsewhere": "Rekordy z tego pliku są już na innym koncie na tym serwerze: ten sam plik został tam wcześniej przywrócony. Plik można przywrócić tylko na jedno konto. Nic nie zostało zapisane.",
"previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.",
"restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)",
"restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)",
+1
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "A restauração parou em um erro inesperado e foi revertida. O log do servidor nomeia o erro.",
"restoreFailureKeyMissing": "O backup precisa da chave de criptografia {keys}, que este servidor não consegue usar para abri-lo. Adicione a chave de volta a ENCRYPTION_KEYS, reinicie e inicie a restauração novamente.",
"restoreFailureForeignReference": "O arquivo aponta para registros que pertencem a outra conta. Nada foi gravado.",
"restoreFailureRowsElsewhere": "Os registros deste arquivo já estão em outra conta deste servidor: o mesmo arquivo já foi restaurado nela. Um arquivo só pode ser restaurado em uma conta. Nada foi gravado.",
"previewKeyMissing": "Este backup não pode ser restaurado neste servidor: ele precisa da chave de criptografia {keys}. Adicione a chave de volta a ENCRYPTION_KEYS e reinicie primeiro.",
"restoreSkippedSymptomEpisodeReference": "Entrada de sintoma vinculada a uma doença que o arquivo não contém (mantida sem o link)",
"restoreSkippedCycleCiphertext": "Nota ou rótulo do ciclo selado com uma chave que este servidor não possui (dia ou sintoma mantido sem ele)",
@@ -98,6 +98,8 @@ function failureText(
});
case "backup.foreign_reference":
return t("admin.section.backups.restoreFailureForeignReference");
case "backup.restore.rows_exist_elsewhere":
return t("admin.section.backups.restoreFailureRowsElsewhere");
case "time_budget":
return t("admin.section.backups.restoreFailureTimeBudget");
case "transaction_failed":
@@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest";
import {
classifyRestoreFailure,
isPrimaryKeyConflict,
RESTORE_FAILURE_CAUSE_MESSAGES,
RESTORE_FAILURE_CAUSES,
} from "@/lib/export/restore-failure-cause";
@@ -147,3 +148,43 @@ describe("classifyRestoreFailure", () => {
}
});
});
describe("isPrimaryKeyConflict", () => {
it("names a duplicate primary key, as the driver reports it", () => {
expect(
isPrimaryKeyConflict(
adapterError("P2010", "23505", {
kind: "UniqueConstraintViolation",
constraint: { index: "measurements_pkey" },
}),
),
).toBe(true);
// Without the structured name, read from the message.
expect(
isPrimaryKeyConflict(
adapterError("P2010", "23505", {
originalMessage:
'duplicate key value violates unique constraint "mood_entries_pkey"',
}),
),
).toBe(true);
});
it("does not name another unique rule, or another failure", () => {
expect(
isPrimaryKeyConflict(
adapterError("P2002", "23505", {
constraint: { index: "measurements_user_id_external_id_key" },
}),
),
).toBe(false);
expect(
isPrimaryKeyConflict(
adapterError("P2010", "23503", {
constraint: { index: "measurements_pkey" },
}),
),
).toBe(false);
expect(isPrimaryKeyConflict(new Error("measurements_pkey"))).toBe(false);
});
});
+23 -1
View File
@@ -49,6 +49,7 @@ import {
import {
classifyRestoreFailure,
RESTORE_FAILURE_CAUSE_MESSAGES,
isPrimaryKeyConflict,
} from "@/lib/export/restore-failure-cause";
import { takeRestoreLock } from "@/lib/export/restore-lock";
import {
@@ -135,6 +136,9 @@ export type RestoreProgressSink = (
* before the first delete or rolls the whole transaction back, so each means
* the account is unchanged.
*/
/** A retargeted copy whose rows another account already holds. */
const ROWS_EXIST_ELSEWHERE_CODE = "backup.restore.rows_exist_elsewhere";
export type RestoreFailureCode =
| "backup_not_found"
| "backup_changed"
@@ -147,6 +151,7 @@ export type RestoreFailureCode =
| "document_ciphertext_missing"
| "backup.key.missing"
| "backup.foreign_reference"
| "backup.restore.rows_exist_elsewhere"
| "time_budget"
| "transaction_failed"
| "interrupted"
@@ -345,6 +350,7 @@ async function transactionRefusal(
input: RestoreBackupInput,
ownerId: string,
transactionTimeoutMs: number,
retargeted: boolean,
err: unknown,
): Promise<RestoreBackupOutcome> {
const verbose = err instanceof Error ? err.message : String(err);
@@ -375,6 +381,16 @@ async function transactionRefusal(
references: err.edges,
});
}
if (retargeted && isPrimaryKeyConflict(err)) {
// Rows keep their ids from the file, so the copy of an erased account
// can live in one account only. Rolled back, so nothing was changed.
return refused(
409,
ROWS_EXIST_ELSEWHERE_CODE,
"Records in this copy are already in this database, so the file was most likely already restored into another account. A file can be restored into one account only. Nothing was changed.",
{ errorCode: ROWS_EXIST_ELSEWHERE_CODE },
);
}
if (isStoredBackupReadError(err)) {
// The second read, inside the transaction, found the copy replaced or
// altered. The transaction rolled back, so nothing was changed.
@@ -614,7 +630,13 @@ export async function restoreBackup(
prepass,
);
} catch (err) {
return transactionRefusal(input, owner.id, transactionTimeoutMs, err);
return transactionRefusal(
input,
owner.id,
transactionTimeoutMs,
retargeted,
err,
);
}
const response = await afterCommit(input, read, owner, result, tracker);
+19
View File
@@ -108,6 +108,25 @@ export function classifyRestoreFailure(err: unknown): ClassifiedRestoreFailure {
return { cause: "other", code: prisma };
}
/**
* Whether a restore failed because a row it inserted carries a primary key
* already taken. The restore deletes the receiving account's own rows first,
* so the key belongs to a row of another account: for a copy put into a
* chosen account, the same file restored into a different account before.
*/
export function isPrimaryKeyConflict(err: unknown): boolean {
if (sqlState(err) !== "23505") return false;
const cause = field(field(field(err, "meta"), "driverAdapterError"), "cause");
const index = field(field(cause, "constraint"), "index");
const name =
typeof index === "string"
? index
: /unique constraint "([^"]+)"/.exec(
String(field(cause, "originalMessage") ?? field(err, "message")),
)?.[1];
return typeof name === "string" && name.endsWith("_pkey");
}
/** The sentence the job row and the audit trail carry for each cause. */
export const RESTORE_FAILURE_CAUSE_MESSAGES: Record<
RestoreFailureCause,
@@ -39,6 +39,7 @@ const CODE_TABLE: Record<BackupRestoreFailureCode, true> = {
document_ciphertext_missing: true,
"backup.key.missing": true,
"backup.foreign_reference": true,
"backup.restore.rows_exist_elsewhere": true,
time_budget: true,
transaction_failed: true,
interrupted: true,
+1
View File
@@ -104,6 +104,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"backup.owner.missing",
"backup.payload.undecryptable",
"backup.restore.active",
"backup.restore.rows_exist_elsewhere",
"backup.section.missing",
"backup.target.admin_account",
"backup.target.holds_records",
+1 -1
View File
@@ -96,7 +96,7 @@ 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`, `backup.key.missing`, `backup.foreign_reference`, `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.",
"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`, `backup.key.missing`, `backup.foreign_reference`, `backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account already holds, because the same file was restored there before), `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(),
@@ -899,6 +899,47 @@ describe("restoring the backup of an erased account into a chosen account", () =
SLOW,
);
it(
"refuses a second restore of the same file into another account, explained and with nothing changed",
async () => {
const prisma = getPrismaClient();
const { json, counts } = await eraseAfterExport(prisma);
const first = await uploadInto(json, {
into: "new",
username: "first-home",
});
expect(first.status, JSON.stringify(first.body)).toBe(201);
expect((await restore(first.body.data!.id)).status).toBe(200);
const home = await prisma.user.findUniqueOrThrow({
where: { username: "first-home" },
});
await prisma.rateLimit.deleteMany();
const second = await uploadInto(json, {
into: "new",
username: "second-home",
});
expect(second.status, JSON.stringify(second.body)).toBe(201);
const again = await restore(second.body.data!.id);
expect(again.status, JSON.stringify(again.body)).toBe(409);
expect(
(again.body as { meta?: { errorCode?: string } }).meta?.errorCode,
).toBe("backup.restore.rows_exist_elsewhere");
expect(again.body.error).toMatch(/already restored into another account/);
// Rolled back: the second account holds nothing, the first all of it.
const twin = await prisma.user.findUniqueOrThrow({
where: { username: "second-home" },
});
expect(
Object.values(await countsOf(prisma, twin.id)).every((n) => n === 0),
).toBe(true);
expect(await countsOf(prisma, home.id)).toEqual(counts);
},
SLOW,
);
it(
"leaves a trace of an account made for a refused file when it cannot be removed again",
async () => {
+1
View File
@@ -30,6 +30,7 @@ const LEGACY_STATUS: Record<string, number> = {
owner_mismatch: 409,
owner_not_found: 422,
"backup.section.missing": 422,
"backup.restore.rows_exist_elsewhere": 409,
document_ciphertext_missing: 422,
time_budget: 503,
transaction_failed: 500,