npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/payload

Requirements

  • 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 afterDelete hook 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 hasMany relationships;
  • 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 relationTo and 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, searchIndex unless indexSlug says otherwise, with title, sectionTitle, description and text as searchable fields, plus url, priority and meta for the consumer;
  • adds afterChange and afterDelete hooks 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 overrides for the generated collection config.

Installation

npm install @bbcd0/payload

Add 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:types

Querying

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