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>
This commit is contained in:
Jan Beck
2025-10-01 15:10:40 +02:00
committed by GitHub
parent 7eacd396b1
commit de352a6761
4 changed files with 92 additions and 1 deletions

View File

@@ -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({

View File

@@ -11,6 +11,7 @@ export const Posts: CollectionConfig = {
admin: {
useAsTitle: 'title',
},
trash: true,
versions: {
drafts: true,
},

View File

@@ -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
})
})
})

View File

@@ -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<T extends boolean = true> {
slug?: T;
updatedAt?: T;
createdAt?: T;
deletedAt?: T;
_status?: T;
}
/**