Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions packages/plugin-search/src/utilities/generateReindexHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PayloadHandler, Where } from 'payload'
import type { JsonObject, PayloadHandler, TypeWithID, Where } from 'payload'

import {
addLocalesToRequestFromData,
Expand All @@ -8,6 +8,7 @@ import {
initTransaction,
killTransaction,
} from 'payload'
import { hasLocalizeStatusEnabled } from 'payload/shared'

import type { SanitizedSearchPluginConfig } from '../types.js'

Expand Down Expand Up @@ -93,13 +94,29 @@ export const generateReindexHandler =
equals: 'published',
},
}
// Localized `_status` tracks published state per-locale; match docs published in any locale.
function buildPublishedWhere(collection: string): Where {
const collectionConfig = payload.collections[collection]?.config
const localeCodes = payload.config.localization ? payload.config.localization.localeCodes : []

if (collectionConfig && hasLocalizeStatusEnabled(collectionConfig) && localeCodes.length) {
return {
or: localeCodes.map((localeCode) => ({
[`_status.${localeCode}`]: {
equals: 'published',
},
})),
}
}

return whereStatusPublished
}
async function countDocuments(collection: string, drafts?: boolean): Promise<number> {
const { totalDocs } = await payload.count({
collection,
...defaultLocalApiProps,
locale: defaultLocale,
req: undefined,
where: drafts ? undefined : whereStatusPublished,
where: drafts ? undefined : buildPublishedWhere(collection),
})
return totalDocs
}
Expand All @@ -117,7 +134,11 @@ export const generateReindexHandler =
async function reindexCollection(
collection: string,
): Promise<{ docs: number; docsWithDrafts: number; errors: number }> {
const draftsEnabled = Boolean(payload.collections[collection]?.config.versions?.drafts)
const collectionConfig = payload.collections[collection]?.config
const draftsEnabled = Boolean(collectionConfig?.versions?.drafts)
const localizeStatusEnabled = collectionConfig
? hasLocalizeStatusEnabled(collectionConfig)
: false

const totalDocsWithDrafts = await countDocuments(collection, true)
const totalDocs =
Expand All @@ -134,9 +155,13 @@ export const generateReindexHandler =
collection,
depth: 0,
limit: batchSize,
locale: defaultLocale,
// Fetch all locales so each locale's `_status` is available for gating below.
locale: localizeStatusEnabled ? 'all' : defaultLocale,
page: i + 1,
where: syncDrafts || !draftsEnabled ? undefined : whereStatusPublished,
// Only `_status` is needed here; per-locale content is loaded scoped inside the loop.
// If that per-locale re-fetch is ever removed, widen this select to the indexed fields.
select: localizeStatusEnabled ? { _status: true } : undefined,
where: syncDrafts || !draftsEnabled ? undefined : buildPublishedWhere(collection),
...defaultLocalApiProps,
})

Expand All @@ -150,13 +175,37 @@ export const generateReindexHandler =
// Loop through all locales and check each one
let firstAllowedLocale = true
for (const localeToSync of allLocales) {
// For localized status, skip locales that aren't published (unless syncing drafts),
// then load the doc scoped to this locale so downstream sees per-locale scalars
// (the batch only selected `_status`).
let docToSync: JsonObject & TypeWithID = doc
if (localizeStatusEnabled) {
const localeStatus = (doc as { _status?: Record<string, string> })._status?.[
localeToSync as string
]

if (!syncDrafts && localeStatus !== 'published') {
continue
}

docToSync =
(await payload.findByID({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any concern about overhead on larger indexing jobs with this extra fetch? I'm not sure if there are implications to consider. If so, we could probably do one payload.find per locale for all the known doc IDs instead of one per doc per locale

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought about that. The extra findByID is O(docs × locales), which is the same order as the per-locale writes we're already doing, so it's more of a constant-factor bump on what's already a manual, transactional job - shouldn't be scary for normal-sized collections.

The thing is, when beforeSync is set (which is most configs) syncDocAsSearchIndex already refetches per doc per locale anyway, so batching a find-by-IDs would only cut the handler's reads, not that inner one. To actually win there we'd need to pass the scoped doc into sync and let it skip its own refetch, and that touches the live hook path.

Could add the batching but open to other's thoughts

id: doc.id,
collection,
depth: 0,
disableErrors: true,
locale: localeToSync,
...defaultLocalApiProps,
})) ?? doc
}

// Check if we should skip this locale for this document
let shouldSkip = false
if (typeof pluginConfig.skipSync === 'function') {
try {
shouldSkip = await pluginConfig.skipSync({
collectionSlug: collection,
doc,
doc: docToSync,
locale: localeToSync,
req,
})
Expand All @@ -178,8 +227,8 @@ export const generateReindexHandler =

await syncDocAsSearchIndex({
collection,
data: doc,
doc,
data: docToSync,
doc: docToSync,
locale: localeToSync,
onSyncError: () => operation === 'create' && localErrors++,
operation,
Expand Down
66 changes: 64 additions & 2 deletions test/plugin-search/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,13 +580,13 @@ describe('@payloadcms/plugin-search', () => {
await payload.update({
collection: postsSlug,
id: postId,
data: { slug: 'post-slug-es' },
data: { _status: 'published', slug: 'post-slug-es' },
locale: 'es',
})
await payload.update({
collection: postsSlug,
id: postId,
data: { slug: 'post-slug-de' },
data: { _status: 'published', slug: 'post-slug-de' },
locale: 'de',
})

Expand Down Expand Up @@ -762,6 +762,68 @@ describe('@payloadcms/plugin-search', () => {
expect(postAfterReindex?.slug).toStrictEqual(postBeforeReindex?.slug)
})

it('should reindex documents published only in a non-default locale when status is localized', async () => {
// Draft in the default locale ('en'), published only in a non-default locale ('de')
const post = await payload.create({
collection: postsSlug,
locale: 'en',
data: {
title: 'Published in DE only',
_status: 'draft',
slug: 'de-only-en',
},
})

await payload.update({
collection: postsSlug,
id: post.id,
locale: 'de',
data: {
_status: 'published',
slug: 'de-only-de',
},
})

const endpointRes = await restClient.POST(`/search/reindex`, {
body: JSON.stringify({
collections: [postsSlug],
}),
headers: {
Authorization: `JWT ${token}`,
},
})

expect(endpointRes.status).toBe(200)

const { docs } = await payload.find({
collection: 'search',
depth: 0,
pagination: false,
locale: 'all',
where: {
doc: {
equals: {
relationTo: postsSlug,
value: post.id,
},
},
},
})

// The document is published in 'de', so it must be indexed even though 'en' is a draft
expect(docs).toHaveLength(1)
Comment thread
PatrikKozak marked this conversation as resolved.

// The indexed 'de' locale must hold that locale's scalar value, not the whole `locale: 'all'` map
const deSearchDoc = await payload.findByID({
collection: 'search',
id: docs[0]!.id,
depth: 0,
locale: 'de',
})

expect(deSearchDoc.slug).toBe('de-only-de')
})

it('should sync trashed documents correctly with search plugin', async () => {
// Create a published post
const publishedPost = await payload.create({
Expand Down
Loading