@bbcd0/payload
v1.1.0
Published
Shared Payload CMS plugins and utilities for bbcd0 projects.
Readme
@bbcd0/payload
Shared plugins and utilities for Payload CMS projects.
Installation
npm install @bbcd0/payloadRequirements
- Node.js 24
- Payload CMS >= 3.75.0 and < 4.0.0
Plugins
Cascade relationship cleanup
cascadeRelationshipCleanupPlugin removes references to a collection document after that document
is deleted. It prevents stale IDs from remaining in relationship and upload fields across
collections and globals.
The plugin:
- adds an
afterDeletehook to every configured collection; - detects collections and globals whose fields can reference the deleted collection;
- handles relationships in regular fields, arrays, blocks, groups, tabs, and nested field layouts;
- removes a deleted value from
hasManyrelationships; - sets an optional single relationship to
null; - removes the nearest removable nested row or block when a required relationship can no longer be satisfied;
- supports polymorphic relationships by checking both
relationToand the referenced ID.
Add the plugin to payload.config.ts:
import { cascadeRelationshipCleanupPlugin } from "@bbcd0/payload/plugins";
import { buildConfig } from "payload";
export default buildConfig({
collections: [
// Your collections
],
globals: [
// Your globals
],
plugins: [cascadeRelationshipCleanupPlugin()],
});No collection-specific configuration is required. The plugin reads relationship targets from the Payload config.
Required relationships
Payload cannot clear a required relationship on a collection or global document without making that document invalid. When a required relationship exists inside a removable nested structure, such as an array row or block, the plugin removes that nested item. When the relationship is on the root document and no parent item can be removed, the plugin logs a warning and leaves the value unchanged.
Access and error handling
Cleanup uses Payload's Local API with overrideAccess: true so references are removed regardless of
the access rules of the affected collection or global.
Errors are logged per cleanup target and do not roll back deletion of the referenced document. This fail-open behavior prevents cleanup failures from blocking normal delete operations, but a failed target can retain stale references and should be investigated through application logs.
Performance
After a document is deleted, the plugin scans every document in each collection that can reference the deleted collection, using pages of 100 documents. It also checks matching globals. This is suitable for modest data sets, but deletion latency can grow with collection size.
Search
searchPlugin keeps a denormalized search index in its own collection and provides an in-memory
matcher on top of it. Matching is done by MiniSearch, which
ships as a dependency of this package, so prefix search, typo tolerance, field weights and relevance
ranking work without an external search engine.
The plugin:
- adds a hidden index collection,
searchIndexunlessindexSlugsays otherwise, withtitle,sectionTitle,descriptionandtextas searchable fields, plusurl,priorityandmetafor the consumer; - adds
afterChangeandafterDeletehooks to every configured collection and keeps the index in sync with them; - lets one document produce several index records, so a long document can be indexed section by section and a result can link to the matching section;
- denies every REST operation on the index collection: it is written by the hooks and read through
the Local API, and accepts
overridesfor the generated collection config.
Installation
npm install @bbcd0/payloadAdd the plugin to payload.config.ts and describe how a document becomes index records:
import { searchPlugin } from "@bbcd0/payload/plugins";
import { buildConfig } from "payload";
export default buildConfig({
collections: [
// Your collections
],
plugins: [
searchPlugin({
collections: {
articles: {
priority: 2,
toRecords: ({ doc }) => [
{
description: doc.description,
meta: { location: doc.location },
title: doc.title,
url: `/articles/${doc.slug}`,
},
...doc.sections.map((section) => ({
key: section.id,
sectionTitle: section.title,
text: section.text,
title: doc.title,
url: `/articles/${doc.slug}#${section.id}`,
})),
],
},
},
}),
],
});Import the plugin and toRecords from modules that do not import the Payload config themselves. A
barrel file that also exports the search runtime creates a circular import.
The plugin adds a collection, so regenerate types:
payload generate:typesQuerying
getPayloadSearch returns the matcher for an index collection, one instance per slug for the whole
process. It reads the records through the Local API and reports a version stamp; when the stamp
changes, the index is rebuilt on the next query. createSearch and createPayloadSearchSource are
available for a custom record source.
import { getPayloadSearch, russianProcessTerm } from "@bbcd0/payload/plugins";
const search = getPayloadSearch({
fieldWeights: { description: 4, sectionTitle: 6, text: 1, title: 10 },
payload,
processTerm: russianProcessTerm,
staleAfterMs: 5_000,
});
const hits = await search.query("query text", {
filter: (record) => record.meta?.location !== "archive",
limit: 200,
});Every option has a default: prefix search is on, fuzziness is applied to terms of four characters and
longer with a distance of 0.2, and minScoreRatio of 0.2 drops hits scoring below a fifth of the
best text relevance, which keeps the result count meaningful. Pass fuzzy: false for exact matching
or minScoreRatio: 0 to keep every hit.
A hit groups the records of one document: best is the most relevant record, fragments holds every
matched record, score includes the priority multiplier and rawScore is the text relevance before
priorities. minScoreRatio is applied to rawScore, so a high priority document cannot hide weaker
but still relevant matches.
processTerm is applied both when indexing and when querying; returning null drops the term. The
package ships Russian support: russianProcessTerm normalizes ё, drops stop words and single
characters, and stems the rest with the Porter algorithm. stemRussian and RUSSIAN_STOP_WORDS are
exported separately, and another language is a matter of passing its own processTerm.
Rich text
description and text accept a string, a Lexical document, an array of nodes, or anything else
built from root, children and text keys. The plugin walks the structure, joins the text nodes,
collapses whitespace and stores a plain string capped at 20000 characters, so a rich text field can
be passed to toRecords as is:
toRecords: ({ doc }) =>
doc.sections.map((section) => ({
sectionTitle: section.title,
text: section.content,
title: doc.title,
})),The same normalizer is exported as toPlainText for reuse outside the plugin. Markup, formatting
and embedded blocks are dropped: only text nodes reach the index.
Rebuilding the index
The hooks index a document when it is saved, so documents that already exist stay out of the index
until then. The plugin covers that on onInit: it rebuilds the index when the index is empty, or
when indexVersion differs from the version stored on the records. Changing toRecords therefore
needs no manual step, only a bumped indexVersion in the same commit:
searchPlugin({
collections: { articles: { toRecords } },
indexVersion: 2,
});Without indexVersion a non-empty index is left alone. rebuildOnInit: true rebuilds on every
boot, false never does. A failure is logged and does not break the boot, and the host onInit is
called first.
rebuildSearchIndex is exported for the cases the boot hook does not cover, such as a one-off
rebuild against another database. It is safe to run at any time:
import { rebuildSearchIndex } from "@bbcd0/payload/plugins";
await rebuildSearchIndex({
collections: {
articles: { priority: 2, toRecords },
},
payload,
});Access and error handling
Indexing uses Payload's Local API. Hook failures are logged and swallowed, so a failed index update never breaks a save; the index can drift until the next save or rebuild.
Instances booting at the same time against an empty index all rebuild it. Records of one document are deleted before they are inserted, so the result converges, but a rebuild started by hand while another one is running can produce duplicates until the next rebuild.
Performance
The index lives in the memory of each process, so every instance keeps its own copy and warms it up
on the first query. Freshness is checked no more often than staleAfterMs, through a document count
and the latest updatedAt. This suits collections of thousands of records; for much larger data sets
the whole index no longer belongs in memory.
Development
npm install
npm run test
npm run typecheck
npm run fmt
npm run lint
npm run build