@purposeinplay/payload-version-retention
v0.1.1
Published
Version retention plugin for Payload CMS 3 — age-and-status version janitor plus identical-snapshot dedup, so version history stops growing without count caps evicting the last good publish.
Readme
@purposeinplay/payload-version-retention
Version retention plugin for Payload CMS 3 — an age-and-status janitor plus identical-snapshot dedup, so version history stops growing without a count cap evicting the last good publish.
Table of contents
- Why this exists
- Features
- Requirements
- Installation
- Quick start
- Scheduling requires a consumer migration
- Postgres consumers need a task-slug enum migration
- The full migration set
- What it keeps and what it deletes
- Worked example
- Identical-snapshot dedup
- Attribution: who a surviving version belongs to
- Memory and chunk sizes
- Relationship to
maxPerDoc - Options
- The janitor task
- Postgres does not shrink on delete
- Exports
- License
Why this exists
Payload prunes versions by count and date only. enforceMaxVersions keeps the newest maxPerDoc rows for a document and deletes everything older, with no idea which of them is the last good publish. A burst of saves inside one afternoon can therefore evict the only published row a document has — which is why lowering maxPerDoc was rejected in review as a way to shrink the tables.
Measured on the wild production copy (2026-09-02):
- version tables are 54% of a 1.9 GB database;
- 37% of
_games_vrows (6,297 of 16,917) are byte-identical to the live body; - 48% are drafts;
- 75 of 96
pagesdocuments have their entire version history older than 30 days, and 51 sit at the 25-version cap.
This plugin bounds history by age, status and a per-document floor instead of by count, and collapses no-op saves at write time.
Features
- Age + status janitor as a scheduled Payload job task — daily at 03:00 on the
defaultqueue - Protected rows — the
latestrow, the newestpublishedrow and the newestdraftrow are never deleted, whatever their age - Per-document floor — a document never drops below
minVersionsPerDocumentrows (default 5) - Identical-snapshot dedup — byte-identical consecutive versions collapse, off the write path
- Bounded runs — per-run deletion and document caps, with
hasMoreso the next run continues - Concurrent-write guard — a document whose protected rows move mid-pass is skipped, not guessed at
- Failure isolation — a document (or an entity) whose sweep throws is logged and skipped; the rest of the run continues
- Startup checks — the scheduling migration, the Postgres task-slug enums, the drained queue and an empty schedule are all reported at boot
- Per-collection and per-global overrides for the window, the floor and dedup
- Zero environment variables — configured entirely through plugin options
Requirements
- Payload
^3.0.0(peer dependency) - Node.js
>= 20 - ESM only — the package ships
"type": "module"; no CommonJS build
This plugin is server-side only: it adds no admin UI components, so there is no payload generate:importmap step and no next/react peer dependency.
Installation
pnpm add @purposeinplay/payload-version-retention
# or
npm install @purposeinplay/payload-version-retention
# or
yarn add @purposeinplay/payload-version-retentionQuick start
import { buildConfig } from 'payload'
import { versionRetentionPlugin } from '@purposeinplay/payload-version-retention'
export default buildConfig({
// ...
plugins: [
versionRetentionPlugin({
days: 30,
minVersionsPerDocument: 5,
}),
],
})With no options at all it sweeps every collection and global that has versions enabled, on a 30-day window with a floor of 5, and attaches the dedup hook to every versioned collection that has drafts.
Register it last in the plugins array if other plugins inject versioned collections — the janitor resolves its entities from the running config at run time, but the dedup hook can only be attached to collections that already exist when this plugin runs.
Scheduling requires a consumer migration
This plugin schedules its janitor task, and that turns on Payload's job scheduling for your whole config. Adopting it without the matching database migration stalls every job on the default queue.
Payload flips jobs.scheduling = true during config sanitization as soon as any task declares a schedule, and injects the payload-jobs-stats global. From then on handleSchedules reads that global on every autoRun tick, before jobs.run. If the payload_jobs_stats table does not exist, that read throws, jobs.run is never reached, and the consumer's entire default queue stops draining — every minute, with nothing else in the config having changed.
What you must do:
Ship the plugin adoption and the migration in the same release. After installing, run
npx payload migrate:createandnpx payload migrate. The migration must createpayload_jobs_stats(wild consumers additionally need thepayload_jobs.metacolumn).Keep the schedule on a queue you actually drain.
handleSchedulesskips every queue the running autoRun config does not drain unless it is invoked withallQueues, so the default schedule targetsdefault. If you pointjanitor.scheduleat a dedicated queue, make sure something drains that queue.A dedicated worker must pass
--handle-schedules. Payload's bin only callshandleScheduleswhen the flag is present:payload jobs:run --handle-schedules # or, to only queue scheduled jobs: payload jobs:handle-schedules
The plugin probes all of it at boot: an error when the payload-jobs-stats global cannot be read, an error when the task slug is missing from the Postgres jobs enums (see the next section), a warning when nothing in jobs.autoRun drains the scheduled queue, and a warning when janitor.schedule is []. Pass janitor: false to register neither the task nor the schedule.
Postgres consumers need a task-slug enum migration
On Postgres, registering a new task is a schema change. Without the matching migration the janitor cannot be queued at all — it fails with invalid input value for enum enum_payload_jobs_task_slug: "version-retention-janitor".
Payload generates payload_jobs.task_slug and payload_jobs_log.task_slug as enum columns whose members are the registered task slugs. Adding this plugin adds a slug, so both enum types have to learn it before anything can queue a job with it. This was hit on the wild production copy.
npx payload migrate:create generates the statements for you; they look like this:
ALTER TYPE "enum_payload_jobs_task_slug" ADD VALUE 'version-retention-janitor';
ALTER TYPE "enum_payload_jobs_log_task_slug" ADD VALUE 'version-retention-janitor';Both are required — the first lets the job row be written, the second lets its log entry be written. Ship them in the same release as the plugin adoption, alongside the payload_jobs_stats migration above.
ALTER TYPE ... ADD VALUEcannot run inside a transaction block on PostgreSQL below 12. On 12+ it can, which is what Payload's generated migration relies on.
MongoDB and SQLite are unaffected — both store the task slug as plain text.
The plugin checks this at boot on Postgres and logs an error naming the exact ALTER TYPE statements when a slug is missing. It reads pg_enum through the adapter's own pool and never throws; an adapter it cannot inspect is left alone.
The full migration set
Adopting this plugin adds four things a consumer's database must have. Generate them all with one npx payload migrate:create after installing, and ship that migration in the same release as the plugin.
| What | Why | Applies to |
|---|---|---|
| payload_jobs_stats table | scheduling a task turns on jobs.scheduling config-wide; handleSchedules reads this global on every autoRun tick | all adapters |
| payload_jobs.meta column | the same sanitize branch sets jobs.stats, and scheduled jobs are queued with meta.scheduled = true | all adapters |
| enum_payload_jobs_task_slug + enum_payload_jobs_log_task_slug values | the task slug is an enum member on Postgres; without it the job cannot be queued at all | Postgres only |
| version_retention_state table | the plugin's own global, holding the dedup cursor across runs | all adapters |
That last one deserves a word. Payload's deleteJobOnComplete defaults to true and hard-deletes the job row the moment it finishes, so a cursor written into a job's output is unreadable by the time the next run starts. The plugin therefore keeps it in a global of its own: slug version-retention-state, one hidden JSON field, access denied to everyone, read and written with overrideAccess. Without its table the age sweep is unaffected, but dedup restarts its cycle at the first entity on every run and never reaches the rest of the corpus.
Each of the four is probed at boot and reported by name. The state global is
checked whenever the janitor is enabled — including with janitor.schedule: [],
since a manually queued run writes the cursor too — while the other three are
checked once a schedule exists.
The state global is registered unconditionally, even with enabled: false
or janitor: false. Schema must not depend on a runtime flag: gating it would
make migrate:create emit a DROP in an environment where the plugin happens
to be off, and push: true in development drop the table outright.
What it keeps and what it deletes
For every document of every selected collection (and for every selected global), the janitor deletes version rows whose updatedAt is older than days — with four exceptions that are always kept:
| Always kept | Why |
|---|---|
| the row flagged latest: true | getLatestCollectionVersion resolves the admin document from exactly this flag |
| the newest row with version._status === 'published' | the last good publish — the row a count cap evicts and this plugin does not |
| the newest row with version._status === 'draft' | the work in progress |
| the newest N rows, where N = minVersionsPerDocument | the floor, default 5 — the protected rows above count toward it |
Plus one hard invariant: a document is never left with zero versions. On an entity with versions but no drafts (no _status at all) and no latest flag, the newest row stands in as the protected one.
Snapshot rows (snapshot = true, Payload's pre-publish copies —
_promotions_v2_v is full of them) are never chosen as the protected publish
or draft, and never count toward the floor: the admin hides them, so they are
not history anyone can read. They remain deletable by age like any other row.
Dedup only visits documents the age sweep already reached
The dedup pass piggybacks on the sweep's walk of the stale-version index, so it only ever looks at documents that own at least one version older than the retention window. A document whose entire history is inside the window — one created and edited this week — is not deduped until part of it ages out.
This is deliberate: the alternative is a second walk over every document in
every versioned collection on every run, to find duplicates in content nobody
is close to pruning. The duplicates do not go anywhere, and the first run after
they age past days collapses them.
Under drafts.localizeStatus, protection is per document
When _status is localized, Payload stores a per-locale object
({ en: 'published', de: 'draft' }) instead of a string. A row counts as
published when any locale in it is published, and as a draft when any locale
is a draft — so the protected rows are still one newest published row and
one newest draft row for the whole document, not one per locale.
The consequence to be aware of: if en was last published five months ago and
de has been published weekly since, the newest any-locale published row is a
de one. The older row carrying en's last publish is not separately
protected, and the floor may not reach far enough back to keep it. If you need
per-locale publish history preserved indefinitely, raise
minVersionsPerDocument for that collection or exclude it.
Status equality is stricter than protection: dedup only collapses two rows whose per-locale objects match in every locale, so publishing one locale never looks like a no-op against a draft of another.
The floor is what makes the age rule safe on real data. Age alone would leave most pages documents with a single version after the first night, because their whole history predates the window. The floor keeps a usable tail of recent history for every document regardless of how old it is; set minVersionsPerDocument: 0 for pure age-and-status behaviour.
Rows kept to satisfy the floor are the newest of the deletable ones.
Worked example
pages/62 on the production copy: 25 versions, all published, newest 2026-06-16 — the whole history is older than a 30-day window run on 2026-09-02.
| Rule | Rows kept |
|---|---|
| newest row is latest | 1 |
| newest published | same row |
| no draft row exists | 0 |
| floor of 5 tops it up with the next-newest | 4 |
| total kept | 5 |
| deleted | 20 |
Run with minVersionsPerDocument: 0 the same document would keep 1 row and delete 24 — which is why the floor is on by default.
This is the case the integration test pins down against real Postgres: 25 versions of a localized, blocks-bearing page, one of them a genuine no-op save, swept down to exactly 5 — with every surviving body byte-identical to what it was before, and the live document row untouched.
At the collection level, _games_v (16,924 rows) would lose 6,738 rows on a
30-day window with the protected rows alone. The floor holds some of those back
and dedup removes some more; the net depends on the per-document distribution,
so read it off a first run's deletedCount rather than assuming a number.
Identical-snapshot dedup
Two consecutive version rows can hold byte-identical content — a save that
changed nothing, a re-publish, an editor pressing save twice. On the production
copy there are 51 such consecutive pairs across 33 games, and 37% of
_games_v rows are byte-identical to the live body.
By default dedup runs inside the janitor, off the write path
(dedup: { mode: 'sweep' }).
For each document the sweep already visits, it reads that document's version
bodies — in pages of 50, bounded across the whole run by
maxDedupBodiesPerRun (default 2,000) — and walks them in updatedAt order.
A consecutive pair collapses only when all of these hold:
- neither row is an
autosaverow; - both rows carry the same
_status. A publish that follows a byte-identical draft keeps both rows, always — they are the same content in two different states. Underdrafts.localizeStatusthe per-locale status objects must match in every locale, so publishing one locale never looks like a no-op against a draft of another; - the bodies match once the noise keys are stripped.
Noise keys, stripped from the top level of the version body before
comparing: createdAt, updatedAt (Payload backfills both from the version
row, and the document's updatedAt moves on every save) and id (the parent
document id, identical for both rows by construction). Nothing else.
In particular, nested ids are content. The id on a block row or an array
row identifies that row; reordering two blocks, or replacing one, changes those
ids and is a real change. Only a save that posts the same row ids back — which
is exactly what the admin does when an editor saves without editing — produces
an identical pair.
When a pair collapses, the newer row is deleted and the older one survives,
keeping its original createdAt (the moment the content actually first
appeared) and updatedAt (so a no-op save cannot silently reset the retention
window). A run of three identical rows collapses to its oldest member.
If the collapsed row carried latest, the flag is moved to the survivor
first and the delete follows, so no window exists in which the document has
no latest row. The move sends versionData: { latest: true } and nothing
else — that is what keeps drizzle on its plain UPDATE ... SET latest path
instead of the full-row rewrite, which would delete and reinsert the survivor's
_locales, block and relationship rows.
Turn it off with dedup: false, or per entity with
overrides: { games: { dedup: false } }.
On-save mode is experimental and off
dedup: { mode: 'onSave' } attaches an afterChange hook that does the same
collapse at write time. It is not the default and is not recommended.
- It runs inside the editor's save transaction. Catching its errors does
not make it non-fatal: in Postgres a failed statement aborts the surrounding
transaction (
25P02), so every later statement in the save fails and the editor sees a cryptic error from somewhere unrelated. - It reads two full version bodies on every save of every tracked document, on the write path.
- Any mistake in the re-flag payload rewrites the survivor row through the full
upsertRowpath — delete and reinsert of its_locales, block and relationship rows — inside that same transaction, whereonConflictDoUpdatecan resurrect a row another transaction deleted.
The sweep does the same work where a failure costs a log line instead of an editor's save.
Attribution: who a surviving version belongs to
Worth knowing before you turn dedup on: the older row is the one that survives, so a version's author field points at whoever created the first occurrence of that content.
The case that surprises people: the ai-translate plugin finishes a run and
writes its version row. An editor then opens the document and saves without
changing anything, producing a byte-identical row of their own. Sweep dedup
deletes the editor's newer row and leaves the run's row as latest — so
version_created_by on the surviving row reads as the translation run, not the
editor. Nothing was lost (the bodies were identical), but the attribution is
the run's.
If per-save attribution matters more than the row count for a collection, opt
it out with overrides: { <slug>: { dedup: false } }.
Memory and chunk sizes
Version bodies are the whole problem with these tables, so every read is bounded:
| Path | Bound | Why |
|---|---|---|
| retention decision | 0 bodies | reads are narrowed with select to id, latest, snapshot, updatedAt and version._status |
| dedup comparison | 50 bodies at a time | paged, with only the previous row carried across a page boundary; capped run-wide by maxDedupBodiesPerRun |
| nested-select fallback | 50 bodies at a time | each page is reduced to the retention columns before the next is fetched |
| deletion | 25 bodies per call | DELETE_CHUNK_SIZE |
Every version read is sorted ['-updatedAt', '-id'], and the parent index walk
['parent', 'id']. updatedAt alone is not a total order — a burst of saves
inside one millisecond gives rows identical timestamps — and under an unstable
sort a paged read can return the same row twice, which in the dedup walk would
compare a row against itself and delete a unique version.
That last one is the non-obvious one. Drizzle's deleteVersions runs a
findMany with no select over the IN (...) list before deleting it, so
every row in a chunk is materialised as a full version body first. A chunk of
200 would peak at 200 bodies; 25 keeps the peak at 25. On the wild copy the
largest _games_v body is a few hundred KB, so the bound is on the order of a
few MB per delete call rather than tens.
Relationship to maxPerDoc
Leave maxPerDoc where it is (25 on wild). This plugin is what bounds history; maxPerDoc is now a backstop, not the retention policy.
enforceMaxVersions prunes by count and date only and has no notion of a protected row, so a burst of saves inside the window can delete the last publish — that is exactly why lowering the cap was rejected. Raising or lowering it changes only how much history exists before the janitor's next pass. What matters is that maxPerDoc stays above minVersionsPerDocument, or the count cap will trim below the floor the janitor is trying to hold.
One operational note: the janitor inspects at most the newest 1,000 version rows per document in a single pass. That is far above any sane maxPerDoc; if a document ever exceeds it the pass logs a warning and reports hasMore so the next run continues.
Options
All options are optional (VersionRetentionPluginOptions):
| Option | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true | Enable/disable the plugin entirely. When false, the plugin makes no changes other than recording its options under config.custom.versionRetention |
| days | number | 30 | Retention window. Version rows whose updatedAt is older than this are deletable, subject to the protected rows and the floor |
| minVersionsPerDocument | number | 5 | Floor on rows left per document (per global), whatever their age. Protected rows count toward it; rows kept to reach it are the newest deletable ones. 0 = pure age-and-status |
| collections | string[] | all versioned collections | Collection slugs to sweep and dedup |
| excludeCollections | string[] | [] | Collection slugs to skip, applied after collections |
| globals | string[] | all versioned globals | Global slugs to sweep |
| excludeGlobals | string[] | [] | Global slugs to skip, applied after globals |
| dedup | boolean \| { mode?: 'off' \| 'onSave' \| 'sweep'; maxDedupBodiesPerRun?: number } | { mode: 'sweep', maxDedupBodiesPerRun: 2000 } | Identical-snapshot dedup. true = { mode: 'sweep' }, false = { mode: 'off' }. 'onSave' is experimental — see Identical-snapshot dedup |
| overrides | Record<string, { days?: number; minVersionsPerDocument?: number; dedup?: boolean }> | {} | Per-slug overrides, keyed by collection or global slug. Unknown slugs are ignored; dedup is meaningless for globals |
| janitor | VersionRetentionJanitorOptions \| false | {} | Janitor configuration, or false to register neither the task nor the schedule (leaving only the dedup hook) |
| janitor.schedule | { cron: string; queue: string }[] | [{ cron: '0 3 * * *', queue: 'default' }] | Cron entries. [] registers the task without scheduling it — see Scheduling requires a consumer migration |
| janitor.maxDeletionsPerRun | number | 5000 | Ceiling on version rows deleted per invocation, across every collection and global. Dedup deletions count against it too |
| janitor.runLockTtl | number | 21600000 (6 h) | How old a processing janitor job may be before its lock is ignored as debris from a crashed run. Payload never clears the flag itself |
| janitor.maxDocumentsPerRun | number | 20000 | Ceiling on documents that give up rows per invocation. A document whose stale rows are all protected or floor-kept does not draw on it, so it cannot burn the budget every night |
Example:
versionRetentionPlugin({
days: 30,
minVersionsPerDocument: 5,
excludeCollections: ['audit-logs'],
overrides: {
games: { days: 14 },
'site-nav': { days: 365, minVersionsPerDocument: 10 },
pages: { dedup: false },
},
dedup: { maxDedupBodiesPerRun: 5_000 },
janitor: { maxDeletionsPerRun: 20_000 },
})maxDedupBodiesPerRun and maxDeletionsPerRun are related
A collapse is atomic: every duplicate in a run goes, or none of it does, because
the latest flag has to land on a row that survives. So dedup can read a
document, find duplicates, and be unable to pay for them out of the deletion
budget.
When that happens the pass steps past the document with a warning rather
than resuming on it — resuming would be a livelock that reads the same bodies
every run and never deletes a row. The document gets another chance on the next
cycle. If you see that warning regularly, either raise maxDeletionsPerRun or
bring maxDedupBodiesPerRun down toward it; the defaults (2,000 bodies against
5,000 deletions) leave plenty of headroom.
The janitor task
Registered in jobs.tasks as version-retention-janitor, retries: 3, scheduled daily at 03:00 on the default queue.
Globals are swept first. There are a handful of them and they are cheap; letting a large collection's backlog eat the run budget would otherwise starve them night after night.
It resolves its collections and globals from the running config, so a versioned collection injected by a plugin registered after this one is still swept.
Rather than walking every document, each collection's pass walks the
stale-version index and visits only the parents that still hold deletable
history, paging by an ascending parent cursor — not an offset, so deleting
rows mid-pass cannot make it skip a document.
Concurrent-write guard. Payload's updateLatestVersion rewrites the latest
row in place on unpublish and on autosave, so a plan built from a stale read
could delete the runner-up published row and leave the document with none.
Before deleting anything for a document, the pass re-reads it and compares the
protected ids and the latest id against what it planned from. If anything
moved, the document is skipped for this run and counted in skippedRaced.
Deletion order is oldest first, so a plan cut short by the deletion budget
still removes the oldest rows rather than eating into recent history. The
age-sweep delete also carries latest: { not_equals: true } — belt and braces
for the window between the verification read and the delete: whatever else goes
wrong, the row the admin resolves the document from stays. Deletes are issued
as deleteVersions({ where: { id: { in: [...] } } }) in chunks of 25
(see Memory and chunk sizes), deliberately
without a req, so a first-run backlog does not sit inside one hours-long
transaction.
Output:
{
dedupCursor: Record<string, number | string> // per-slug dedup resume point
dedupedCount: number
deletedCount: number // dedup included
failedDocuments: number
hasMore: boolean
scannedDocuments: number
skippedRaced: number
}hasMore: true means deletable work is known to remain, and nothing
weaker. It is true only when:
- a deletion or document budget ran out in a run that deleted rows — the cap ended the pass, not the corpus;
- the dedup body budget ran out with documents still unread;
- a document was skipped as raced, or its sweep failed.
A stale version row on its own is not work: on a settled corpus most stale rows
are the protected ones or held by the floor, and every run meets them again. An
earlier version treated "a stale row exists" as evidence, which made the signal
permanently true at steady state — 2,115 documents scanned, nothing deleted,
hasMore: true, run after run. A run that finds nothing deletable now says so:
hasMore: false, and the log line reads nothing to do.
The age sweep keeps no cursor: each run starts from the beginning of the stale index, which is correct because the rows it deleted last time are gone.
Dedup does, because a document it has already cleaned still costs a body
read to prove it is clean. The cursor is one position over the whole run
order — { slug, parentId } — not one per entity:
{ dedupCursor: { parentId: 21, slug: 'categories' } }A run resumes at that entity, after that document, and carries on into every
entity behind it; entities ahead of it were covered earlier in the cycle and
are skipped for dedup (they are still age-swept). When a run reaches the end
without running short, the cursor clears and the next run starts the cycle
again. Every entity is therefore deduped within
ceil(total bodies / maxDedupBodiesPerRun) runs.
A per-entity cursor is what does not work, and this was measured: with one
cursor per slug, the entities ahead of the stalled one carried none, so every
run re-read them from scratch and spent the entire body budget before reaching
the stall. On the production copy dedup collapsed pairs only in the four
collections ahead of categories and never once reached pages or games,
run after run, with dedupCursor frozen at { categories: 21 }.
The cursor is stored in the plugin's own version-retention-state global, and
that is not incidental: Payload's deleteJobOnComplete defaults to true, so a
cursor kept in a job's output is deleted with the job before the next run can
read it — which made an earlier version of this rotation inert under the default
configuration, however carefully the cursor was computed.
Resolution order at the start of every run: an explicit input.dedupCursor,
then the state global, then (legacy, for consumers who turned job deletion off)
the last completed janitor job's output. Scheduled and manual runs behave
identically. If none is available the run starts the cycle from the beginning
and says so in a warning; a cursor naming a slug that is no longer configured
also restarts it.
Failure isolation. A document whose sweep throws is logged with its id,
counted in failedDocuments and skipped; the rest of the entity, and the
entities queued behind it, still run. An entity that fails outright is logged
and skipped the same way. Both set hasMore.
One at a time. A run that finds another janitor job already processing
logs a line and declines to start, rather than splitting the run budget with it
and racing it for the cursor — which is easy to reach by queueing one by hand
while the schedule fires.
The lock is time-bounded, and that matters: Payload sets processing when a
job starts and never clears it if the process dies, so an unbounded lock would
turn one OOM into a janitor that never runs again. A processing row older
than janitor.runLockTtl (default 6 hours) is ignored as crash debris, with a
warning naming how many were skipped.
One info line is logged per entity that had anything to scan, plus one
closing summary.
Postgres does not shrink on delete
Deleting rows marks them dead; it does not return space to the operating system. After the first large pass the tables will still be the same size on disk, with the freed space reusable by future inserts.
To actually reclaim it you need VACUUM FULL (which takes an ACCESS EXCLUSIVE lock and rewrites the table) or pg_repack (which does not). Autovacuum will keep the bloat from growing but will not give the space back. Plan that as a separate, scheduled maintenance step after the backlog has drained — not as part of adopting this plugin.
Exports
The package ships a single entry point:
import { versionRetentionPlugin } from '@purposeinplay/payload-version-retention'
import type {
VersionRetentionJanitorOptions,
VersionRetentionJanitorOutput,
VersionRetentionPluginOptions,
} from '@purposeinplay/payload-version-retention'There is no ./client or other subpath — the plugin has no admin UI components and no client/server split. No environment variables are read.
License
Part of the purposeinplay/payload-plugins monorepo. Issues and contributions: GitHub issues.
