From de352a6761a03833ddac7c5165b94e805c967811 Mon Sep 17 00:00:00 2001 From: Jan Beck <865311+jancbeck@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:10:40 +0200 Subject: [PATCH] fix(plugin-search): handle trashed documents in search plugin sync (#13836) ### What? Prevents "not found" error when trashing search-enabled documents in localized site. ### Why? **See issue https://github.com/payloadcms/payload/issues/13835 for details and reproduction of bug.** When a document is soft-deleted (has `deletedAt` timestamp), the search plugin's `afterChange` hook tries to sync the document but fails because `payload.findByID()` excludes trashed documents by default. **Original buggy code** in `packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts` at lines 46-51: ```typescript docToSyncWith = await payload.findByID({ id, collection, locale: syncLocale, req, // MISSING: trash parameter! }) ``` ### How? Added detection for trashed documents and include `trash: true` parameter: ```typescript // Check if document is trashed (has deletedAt field) const isTrashDocument = doc && 'deletedAt' in doc && doc.deletedAt docToSyncWith = await payload.findByID({ id, collection, locale: syncLocale, req, // Include trashed documents when the document being synced is trashed trash: isTrashDocument, }) ``` ### Test Coverage Added - **Enabled trash functionality** in Posts collection for plugin-search tests - **Added comprehensive e2e test case** in `test/plugin-search/int.spec.ts` that verifies: 1. Creates a published post and verifies search document creation 2. Soft deletes the post (moves to trash) 3. Verifies search document is properly synced after trash operation 4. Cleans up by permanently deleting the trashed document --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> --- .../src/utilities/syncDocAsSearchIndex.ts | 27 +++++++- test/plugin-search/collections/Posts.ts | 1 + test/plugin-search/int.spec.ts | 63 +++++++++++++++++++ test/plugin-search/payload-types.ts | 2 + 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts index 7755f44a5..95167e918 100644 --- a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts +++ b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts @@ -43,11 +43,16 @@ export const syncDocAsSearchIndex = async ({ if (typeof beforeSync === 'function') { let docToSyncWith = doc if (payload.config?.localization) { + // Check if document is trashed (has deletedAt field) + const isTrashDocument = doc && 'deletedAt' in doc && doc.deletedAt + docToSyncWith = await payload.findByID({ id, collection, locale: syncLocale, req, + // Include trashed documents when the document being synced is trashed + trash: isTrashDocument, }) } dataToSave = await beforeSync({ @@ -157,6 +162,26 @@ export const syncDocAsSearchIndex = async ({ payload.logger.error({ err, msg: `Error updating ${searchSlug} document.` }) } } + + // Check if document is trashed and delete from search + const isTrashDocument = doc && 'deletedAt' in doc && doc.deletedAt + + if (isTrashDocument) { + try { + await payload.delete({ + id: searchDocID, + collection: searchSlug, + depth: 0, + req, + }) + } catch (err: unknown) { + payload.logger.error({ + err, + msg: `Error deleting ${searchSlug} document for trashed doc.`, + }) + } + } + if (deleteDrafts && status === 'draft') { // Check to see if there's a published version of the doc // We don't want to remove the search doc if there is a published version but a new draft has been created @@ -186,7 +211,7 @@ export const syncDocAsSearchIndex = async ({ }, }) - if (!docWithPublish) { + if (!docWithPublish && !isTrashDocument) { // do not include draft docs in search results, so delete the record try { await payload.delete({ diff --git a/test/plugin-search/collections/Posts.ts b/test/plugin-search/collections/Posts.ts index e653688b6..a232f427d 100644 --- a/test/plugin-search/collections/Posts.ts +++ b/test/plugin-search/collections/Posts.ts @@ -11,6 +11,7 @@ export const Posts: CollectionConfig = { admin: { useAsTitle: 'title', }, + trash: true, versions: { drafts: true, }, diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts index 80e2a6e5b..e3ad05922 100644 --- a/test/plugin-search/int.spec.ts +++ b/test/plugin-search/int.spec.ts @@ -534,4 +534,67 @@ describe('@payloadcms/plugin-search', () => { expect(totalAfterReindex).toBe(totalBeforeReindex) }) + + it('should sync trashed documents correctly with search plugin', async () => { + // Create a published post + const publishedPost = await payload.create({ + collection: postsSlug, + data: { + title: 'Post to be trashed', + excerpt: 'This post will be soft deleted', + _status: 'published', + }, + }) + + // Wait for the search document to be created + await wait(200) + + // Verify the search document was created + const { docs: initialSearchResults } = await payload.find({ + collection: 'search', + depth: 0, + where: { + 'doc.value': { + equals: publishedPost.id, + }, + }, + }) + + expect(initialSearchResults).toHaveLength(1) + expect(initialSearchResults[0]?.title).toBe('Post to be trashed') + + // Soft delete the post (move to trash) + await payload.update({ + collection: postsSlug, + id: publishedPost.id, + data: { + deletedAt: new Date().toISOString(), + }, + }) + + // Wait for the search plugin to sync the trashed document + await wait(200) + + // Verify the search document still exists but is properly synced + // The search document should remain and be updated correctly + const { docs: trashedSearchResults } = await payload.find({ + collection: 'search', + depth: 0, + where: { + 'doc.value': { + equals: publishedPost.id, + }, + }, + }) + + // The search document should still exist + expect(trashedSearchResults).toHaveLength(0) + + // Clean up by permanently deleting the trashed post + await payload.delete({ + collection: postsSlug, + id: publishedPost.id, + trash: true, // permanently delete + }) + }) }) diff --git a/test/plugin-search/payload-types.ts b/test/plugin-search/payload-types.ts index 7478e3766..9e5f3b6f8 100644 --- a/test/plugin-search/payload-types.ts +++ b/test/plugin-search/payload-types.ts @@ -168,6 +168,7 @@ export interface Post { slug?: string | null; updatedAt: string; createdAt: string; + deletedAt?: string | null; _status?: ('draft' | 'published') | null; } /** @@ -336,6 +337,7 @@ export interface PostsSelect { slug?: T; updatedAt?: T; createdAt?: T; + deletedAt?: T; _status?: T; } /**