Plugins
Dedupe

Configuration

Every option of the plugin and its default.

Plugin options

OptionDefault
disabledfalseTurns the plugin off. Its collections stay in the schema.
translationsunsetOverrides for the plugin's strings, per locale.
collections{}Per collection: true for the merge screen alone, the object below, or false.
access.reviewlogged inWho opens the queue and the merge screen. The collection's read access on the documents applies too: the queue lists no group with a document the reader may not read.
access.scanaccess.reviewWho starts a scan with "Scan now".
access.mergelogged inWho applies a merge. The merge writes as the reviewer, so the collection's and the fields' own access apply too.
requireTransactionsfalseRefuse every merge without a database transaction. Without it, only a merge that would delete a merged-in document first is refused. The merge screen then says merging is not available, and the reason is logged at startup.
maxGroupSize5Most documents in one merge, 2 or more. A larger queue group opens its most alike documents; the merge screen counts the rest, which stay in the queue.
adapterkeysWhere candidates come from in every collection without its own, see Adapters.
disableJobsQueuefalseRun the save check and the scan inside the request, for a deployment without a job worker.
queuededupeJobs queue of the dedupe-check task.
scanQueuequeueJobs queue of the dedupe-scan task, the scheduled scan included.
scan.cronunsetScan every matched collection on a schedule. Needs a job runner.
view{ path: '/dedupe', queue: true }Admin path of the queue and the merge screen; false removes the views. queue: false mounts only the merge screen, for a host that renders the queue in a page of its own with DedupeQueue from @10x-media/dedupe/rsc (<DedupeQueue req={req} searchParams={searchParams} basePath="/dedupe" />).
overrides{}keys, pairs: a function over the plugin's collection. Hooks are refused.
collectionAccess.readnobodyREST and GraphQL read access on the plugin's collections.
multiTenancyofftrue or { tenantFieldName, collections }, see below.
eventsunset{ emit(event, req) }, see below.
hooks.beforeRemoveunsetRuns in every merge before the merged-in documents leave, see References.

Per collection

OptionDefault
match.fieldsrequired for search{ path, weight, compare?, onDiffer?, key?, tolerance?, toleranceType?, typos? } per field, see Searching.
match.minScore0.35Lowest score kept as a pair.
match.maxBucket2000Larger key buckets are skipped by the scan.
match.candidateLimit200Most documents the save check compares.
adapterthe plugin'sWhere this collection's candidates come from: (base) => adapter, given the plugin's adapter. Needs match.
fieldsderived(derived) => spec, see Merging.
absorbedtrash, or delete without a trashWhat happens to the merged-in documents: trash or delete. trash needs trash: true on the collection.
draftfalseOn a collection with drafts, merge the latest drafts and save the result as a draft. By default the published documents are merged and the result is published.
writeLocalethe default locale, or the first the primary is complete inThe locale the values every locale shares are written in: a code, or ({ req, survivor }) => code. See Merging.
checkOnSavetrue with a matchLook for duplicates of the document on every save and queue the pairs. The document is indexed on every save either way, so findDuplicates finds it before the next scan.
sectionsnoneParts of the merge screen you draw, whose values reach hooks.beforeRemove: { key, Component, validate? } each, see Sections.
titleuseAsTitleHow a document is named on the merge screen, in the queue and in the duplicate check: ({ req, doc, title }) => string, see Titles.

An invalid match config stops the app at startup with an error naming the field.

Titles

A document is named by its useAsTitle field. Where duplicates share that title, title names them apart, for instance by an id kept in a field hidden in the admin:

payload.config.ts
dedupe({
  collections: {
    customers: {
      match: { fields: [{ path: 'email', weight: 45 }] },
      title: ({ doc, title }) => (doc.externalId ? `${title} (${doc.externalId})` : title),
    },
  },
})

title is the useAsTitle value in the reader's locale, or the document's id where it has none or the reader may not read that field. doc is the document as the plugin read it, so check any field you show: the merge screen reads documents past field access, and a value the reviewer may not read still reaches the function there. An empty answer keeps title.

Multi-tenancy

payload.config.ts
dedupe({
  multiTenancy: true,
  collections: { customers: { match } },
})

Documents of different tenants are never compared or merged together, and the queue shows the selected tenant only. A collection that is not scoped shows under every tenant. Set tenantFieldName when yours differs from @payloadcms/plugin-multi-tenant's.

Without collections, every collection with the tenant field is scoped. Pass the multi-tenant plugin's own collections to scope only those, so a tenant field that means something else stays ordinary data:

payload.config.ts
const collections = { customers: {}, settings: { isGlobal: true } }

multiTenantPlugin({ collections })
dedupe({
  multiTenancy: { collections },
  collections: { customers: { match } },
})

A collection with isGlobal: true holds one document per tenant: configuring it in dedupe stops the app at startup.

Events

payload.config.ts
dedupe({
  events: {
    emit: async (event, req) => {
      if (event.type === 'merge.applied') {
        await notify(req, `${event.absorbedIds.join(', ')} merged into ${event.survivorId}`)
      }
    },
  },
})

Event types: pair.found (also for a pair found again after it was closed), pair.dismissed, pair.reopened, merge.applied, merge.failed, scan.finished. A sink that throws is logged and changes nothing. pair.found is sent inside the transaction of the save (with disableJobsQueue) or of the merge, so one that fails afterwards may have announced a pair it never stored.

On this page