From 64dc5e8ea7e1ad03d1d27eff9dec46e8c3819d96 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Wed, 18 Mar 2026 08:33:09 -0400 Subject: [PATCH] fix: guard against concurrent restore in documentPermanentDeleter (#11775) * fix: guard against concurrent restore in documentPermanentDeleter * fix: bake deletedAt check into documentPermanentDeleter destroy WHERE clause --- .../commands/documentPermanentDeleter.test.ts | 27 +++++++++++++++++++ server/commands/documentPermanentDeleter.ts | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/server/commands/documentPermanentDeleter.test.ts b/server/commands/documentPermanentDeleter.test.ts index ab0ad273db..75a2a9f6bc 100644 --- a/server/commands/documentPermanentDeleter.test.ts +++ b/server/commands/documentPermanentDeleter.test.ts @@ -111,6 +111,33 @@ describe("documentPermanentDeleter", () => { ).toEqual(0); }); + it("should not destroy a document restored between query and destroy", async () => { + const document = await buildDocument({ + publishedAt: subDays(new Date(), 90), + deletedAt: subDays(new Date(), 60), + }); + + // Simulate the race: caller queried this document while it was soft-deleted, + // but the user restored it before documentPermanentDeleter runs the destroy. + await Document.unscoped().update( + { deletedAt: null }, + { where: { id: document.id }, paranoid: false } + ); + + // The stale in-memory object still has deletedAt set (as it would in the + // real cleanup task flow), but the DB row is now active. + const countDeletedDoc = await documentPermanentDeleter([document]); + expect(countDeletedDoc).toEqual(0); + + // Document must survive — it was restored. + expect( + await Document.unscoped().count({ + where: { id: document.id }, + paranoid: false, + }) + ).toEqual(1); + }); + it("should not destroy attachments referenced in other documents", async () => { const document1 = await buildDocument(); const document = await buildDocument({ diff --git a/server/commands/documentPermanentDeleter.ts b/server/commands/documentPermanentDeleter.ts index 006454add3..6060cc580d 100644 --- a/server/commands/documentPermanentDeleter.ts +++ b/server/commands/documentPermanentDeleter.ts @@ -93,7 +93,8 @@ export default async function documentPermanentDeleter(documents: Document[]) { return Document.scope("withDrafts").destroy({ where: { - id: documents.map((document) => document.id), + id: documentIds, + deletedAt: { [Op.ne]: null }, }, force: true, });