@bbcd0/payload
v1.2.3
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.
SmartCaptcha
SmartCaptcha is a utility module, not a plugin that changes Payload collections or hooks. Protect each public request before saving data or queueing jobs. Admin operations and background jobs do not need a browser captcha.
The module has separate entry points:
| Import | Purpose |
| -------------------------------------- | ------------------------------------------------------ |
| @bbcd0/payload/smart-captcha | Server-side token verification and its types |
| @bbcd0/payload/smart-captcha/react | Client-key context, React hook and its types |
| @bbcd0/payload/smart-captcha/valibot | Token field schema and a custom-message schema factory |
React 19 is required only by the React entry. Valibot >= 1.2 and < 2 is required only by the
Valibot entry. These are optional peer dependencies; install the ones your application uses.
The Yandex React SDK ships as a dependency. The React entry preserves "use client" in the build
and does not import the server verifier.
Server verification
Import verifySmartCaptchaToken in the request handler. After validating the request body, pass
the token, the application's server key, its logger and optionally the visitor IP:
import { verifySmartCaptchaToken } from "@bbcd0/payload/smart-captcha";
const captcha = await verifySmartCaptchaToken({
serverKey: process.env.YANDEX_SMARTCAPTCHA_SERVER_KEY,
logger: payload.logger,
token: input.smartCaptchaToken,
ip: clientIp,
});
if (!captcha.ok) {
return Response.json({ error: "captcha" }, { status: captcha.status });
}Here input is the validated request body, payload is the application's Payload instance, and
clientIp is resolved by the application using its trusted-proxy configuration. The verifier does
not read environment variables or forwarding headers itself and has no Payload runtime dependency.
Only { ok: true } permits the protected operation. Rejected tokens return 403; a missing key,
an unavailable service or an invalid JSON response returns 503. Verification has a three-second
timeout and does not retry a token. Never persist the token or expose the server key to the browser.
Verify in the same request that creates the record. A separate public verification endpoint does not protect an unguarded record-creation endpoint.
React forms
Provide the public key above the forms using SmartCaptchaContext. In Next.js, the server layout
can pass the application's runtime key to the client context:
import { SmartCaptchaContext } from "@bbcd0/payload/smart-captcha/react";
<SmartCaptchaContext value={clientKey}>{children}</SmartCaptchaContext>;Inside a client form:
import { useSmartCaptcha } from "@bbcd0/payload/smart-captcha/react";
const { captcha, hasConnectionError, requestToken, cancelTokenRequest } = useSmartCaptcha({ lang: "ru" });Render captcha inside the form. In its submit handler, await requestToken() and stop submission
if it returns undefined; otherwise add the returned value as smartCaptchaToken to the request.
Tokens are requested at submission time rather than cached while the visitor fills in the form.
lang accepts the SDK's language values and defaults to "ru".
hideShield defaults to true. Set it to false to show the SDK's data-processing notice.
When hiding it, provide an alternative notice linking to
Yandex SmartCaptcha's data-processing terms.
test defaults to false. Use useSmartCaptcha({ test: true }) during development
to make SmartCaptcha always show a challenge. Do not enable this mode in production.
Call cancelTokenRequest() immediately when closing a dialog, before resetting its form.
Unmounting the hook also settles a pending request. Old widget events cannot complete a newer
request. Challenge dismissal and network errors allow another attempt. Once the SDK is loaded,
JavaScript errors affect only their own widget: a pending request ends without a token, and the
widget can be recreated for another attempt. A broken idle widget is replaced on the next submit,
not in an automatic remount loop.
Script-loading errors while the SDK is unavailable fail current and later token requests for the page lifetime because the SDK caches script loading. Reload the page after resolving that failure.
hasConnectionError reports script-loading failures and SDK network errors so the form can show
connection guidance and a page-reload action instead of a generic submission error. Script-loading
failure is shared by current and later hook consumers and persists until reload; a widget's network
error clears when a new token request starts. Dismissal, cancellation, missing client keys, and
widget JavaScript errors while the SDK is available do not set this flag. It does not detect VPN
use or report server-side token-verification failures.
Valibot schemas
Keep the form schema separate from the API schema: the form validates contact fields, while the API also requires the transport token.
import {
SMART_CAPTCHA_TOKEN_FIELD_SCHEMA,
createSmartCaptchaTokenFieldSchema,
} from "@bbcd0/payload/smart-captcha/valibot";
import * as v from "valibot";
const CONTACT_FORM_SCHEMA = v.object({
email: v.pipe(v.string(), v.email()),
});
const CONTACT_API_SCHEMA = v.object({
...CONTACT_FORM_SCHEMA.entries,
smartCaptchaToken: SMART_CAPTCHA_TOKEN_FIELD_SCHEMA,
});
const ENGLISH_TOKEN_FIELD_SCHEMA = createSmartCaptchaTokenFieldSchema("Please confirm that you are not a robot");The default schema rejects non-string and empty tokens. The factory changes the empty-token error
message; the default is "Не удалось подтвердить, что вы не робот".
Development
npm install
npm run test
npm run typecheck
npm run fmt
npm run lint
npm run buildWhen testing a consuming pnpm application with a local file: dependency, rebuild this package
with npm run build, then run pnpm update @bbcd0/payload in the application to refresh its
installed snapshot. pnpm install alone can keep the previous build.
Local paths are for development. Before deploying the application, publish the package and replace its local dependency with the published version.
