@purposeinplay/payload-version-retention 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Purpose In Play
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,553 @@
1
+ # @purposeinplay/payload-version-retention
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40purposeinplay%2Fpayload-version-retention)](https://www.npmjs.com/package/@purposeinplay/payload-version-retention)
4
+ [![license: MIT](https://img.shields.io/npm/l/%40purposeinplay%2Fpayload-version-retention)](https://github.com/purposeinplay/payload-plugins/blob/main/packages/payload-version-retention/LICENSE)
5
+
6
+ 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.
7
+
8
+ ## Table of contents
9
+
10
+ - [Why this exists](#why-this-exists)
11
+ - [Features](#features)
12
+ - [Requirements](#requirements)
13
+ - [Installation](#installation)
14
+ - [Quick start](#quick-start)
15
+ - [Scheduling requires a consumer migration](#scheduling-requires-a-consumer-migration)
16
+ - [Postgres consumers need a task-slug enum migration](#postgres-consumers-need-a-task-slug-enum-migration)
17
+ - [The full migration set](#the-full-migration-set)
18
+ - [What it keeps and what it deletes](#what-it-keeps-and-what-it-deletes)
19
+ - [Under `drafts.localizeStatus`, protection is per document](#under-draftslocalizestatus-protection-is-per-document)
20
+ - [Worked example](#worked-example)
21
+ - [Identical-snapshot dedup](#identical-snapshot-dedup)
22
+ - [Attribution: who a surviving version belongs to](#attribution-who-a-surviving-version-belongs-to)
23
+ - [Memory and chunk sizes](#memory-and-chunk-sizes)
24
+ - [Relationship to `maxPerDoc`](#relationship-to-maxperdoc)
25
+ - [Options](#options)
26
+ - [The janitor task](#the-janitor-task)
27
+ - [Postgres does not shrink on delete](#postgres-does-not-shrink-on-delete)
28
+ - [Exports](#exports)
29
+ - [License](#license)
30
+
31
+ ## Why this exists
32
+
33
+ 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.
34
+
35
+ Measured on the wild production copy (2026-09-02):
36
+
37
+ - version tables are **54% of a 1.9 GB database**;
38
+ - **37%** of `_games_v` rows (6,297 of 16,917) are byte-identical to the live body;
39
+ - **48%** are drafts;
40
+ - **75 of 96** `pages` documents have their *entire* version history older than 30 days, and 51 sit at the 25-version cap.
41
+
42
+ This plugin bounds history by **age, status and a per-document floor** instead of by count, and collapses no-op saves at write time.
43
+
44
+ ## Features
45
+
46
+ - **Age + status janitor** as a scheduled Payload job task — daily at 03:00 on the `default` queue
47
+ - **Protected rows** — the `latest` row, the newest `published` row and the newest `draft` row are never deleted, whatever their age
48
+ - **Per-document floor** — a document never drops below `minVersionsPerDocument` rows (default 5)
49
+ - **Identical-snapshot dedup** — byte-identical consecutive versions collapse, off the write path
50
+ - **Bounded runs** — per-run deletion and document caps, with `hasMore` so the next run continues
51
+ - **Concurrent-write guard** — a document whose protected rows move mid-pass is skipped, not guessed at
52
+ - **Failure isolation** — a document (or an entity) whose sweep throws is logged and skipped; the rest of the run continues
53
+ - **Startup checks** — the scheduling migration, the Postgres task-slug enums, the drained queue and an empty schedule are all reported at boot
54
+ - **Per-collection and per-global overrides** for the window, the floor and dedup
55
+ - Zero environment variables — configured entirely through plugin options
56
+
57
+ ## Requirements
58
+
59
+ - **Payload** `^3.0.0` (peer dependency)
60
+ - **Node.js** `>= 20`
61
+ - **ESM only** — the package ships `"type": "module"`; no CommonJS build
62
+
63
+ 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.
64
+
65
+ ## Installation
66
+
67
+ ```sh
68
+ pnpm add @purposeinplay/payload-version-retention
69
+ # or
70
+ npm install @purposeinplay/payload-version-retention
71
+ # or
72
+ yarn add @purposeinplay/payload-version-retention
73
+ ```
74
+
75
+ ## Quick start
76
+
77
+ ```ts
78
+ import { buildConfig } from 'payload'
79
+ import { versionRetentionPlugin } from '@purposeinplay/payload-version-retention'
80
+
81
+ export default buildConfig({
82
+ // ...
83
+ plugins: [
84
+ versionRetentionPlugin({
85
+ days: 30,
86
+ minVersionsPerDocument: 5,
87
+ }),
88
+ ],
89
+ })
90
+ ```
91
+
92
+ 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.
93
+
94
+ 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.
95
+
96
+ ## Scheduling requires a consumer migration
97
+
98
+ **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.**
99
+
100
+ 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.
101
+
102
+ What you must do:
103
+
104
+ 1. **Ship the plugin adoption and the migration in the same release.** After installing, run `npx payload migrate:create` and `npx payload migrate`. The migration must create `payload_jobs_stats` (wild consumers additionally need the `payload_jobs.meta` column).
105
+ 2. **Keep the schedule on a queue you actually drain.** `handleSchedules` skips every queue the running autoRun config does not drain unless it is invoked with `allQueues`, so the default schedule targets `default`. If you point `janitor.schedule` at a dedicated queue, make sure something drains that queue.
106
+ 3. **A dedicated worker must pass `--handle-schedules`.** Payload's bin only calls `handleSchedules` when the flag is present:
107
+
108
+ ```sh
109
+ payload jobs:run --handle-schedules
110
+ # or, to only queue scheduled jobs:
111
+ payload jobs:handle-schedules
112
+ ```
113
+
114
+ 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.
115
+
116
+ ## Postgres consumers need a task-slug enum migration
117
+
118
+ **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"`.**
119
+
120
+ 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.
121
+
122
+ `npx payload migrate:create` generates the statements for you; they look like this:
123
+
124
+ ```sql
125
+ ALTER TYPE "enum_payload_jobs_task_slug" ADD VALUE 'version-retention-janitor';
126
+ ALTER TYPE "enum_payload_jobs_log_task_slug" ADD VALUE 'version-retention-janitor';
127
+ ```
128
+
129
+ 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.
130
+
131
+ > `ALTER TYPE ... ADD VALUE` cannot run inside a transaction block on PostgreSQL below 12. On 12+ it can, which is what Payload's generated migration relies on.
132
+
133
+ **MongoDB and SQLite are unaffected** — both store the task slug as plain text.
134
+
135
+ 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.
136
+
137
+ ## The full migration set
138
+
139
+ 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.
140
+
141
+ | What | Why | Applies to |
142
+ |---|---|---|
143
+ | `payload_jobs_stats` table | scheduling a task turns on `jobs.scheduling` config-wide; `handleSchedules` reads this global on every autoRun tick | all adapters |
144
+ | `payload_jobs.meta` column | the same sanitize branch sets `jobs.stats`, and scheduled jobs are queued with `meta.scheduled = true` | all adapters |
145
+ | `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** |
146
+ | `version_retention_state` table | the plugin's own global, holding the dedup cursor across runs | all adapters |
147
+
148
+ 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.
149
+
150
+ Each of the four is probed at boot and reported by name. The state global is
151
+ checked whenever the janitor is enabled — including with `janitor.schedule: []`,
152
+ since a manually queued run writes the cursor too — while the other three are
153
+ checked once a schedule exists.
154
+
155
+ The state global is registered **unconditionally**, even with `enabled: false`
156
+ or `janitor: false`. Schema must not depend on a runtime flag: gating it would
157
+ make `migrate:create` emit a `DROP` in an environment where the plugin happens
158
+ to be off, and `push: true` in development drop the table outright.
159
+
160
+ ## What it keeps and what it deletes
161
+
162
+ 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:
163
+
164
+ | Always kept | Why |
165
+ |---|---|
166
+ | the row flagged `latest: true` | `getLatestCollectionVersion` resolves the admin document from exactly this flag |
167
+ | the newest row with `version._status === 'published'` | **the last good publish** — the row a count cap evicts and this plugin does not |
168
+ | the newest row with `version._status === 'draft'` | the work in progress |
169
+ | **the newest N rows, where N = `minVersionsPerDocument`** | the floor, default **5** — the protected rows above count toward it |
170
+
171
+ 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.
172
+
173
+ **Snapshot rows** (`snapshot = true`, Payload's pre-publish copies —
174
+ `_promotions_v2_v` is full of them) are never chosen as the protected publish
175
+ or draft, and never count toward the floor: the admin hides them, so they are
176
+ not history anyone can read. They remain deletable by age like any other row.
177
+
178
+ ### Dedup only visits documents the age sweep already reached
179
+
180
+ The dedup pass piggybacks on the sweep's walk of the **stale-version index**,
181
+ so it only ever looks at documents that own at least one version older than the
182
+ retention window. A document whose entire history is inside the window — one
183
+ created and edited this week — is not deduped until part of it ages out.
184
+
185
+ This is deliberate: the alternative is a second walk over every document in
186
+ every versioned collection on every run, to find duplicates in content nobody
187
+ is close to pruning. The duplicates do not go anywhere, and the first run after
188
+ they age past `days` collapses them.
189
+
190
+ ### Under `drafts.localizeStatus`, protection is per document
191
+
192
+ When `_status` is localized, Payload stores a per-locale object
193
+ (`{ en: 'published', de: 'draft' }`) instead of a string. A row counts as
194
+ published when **any** locale in it is published, and as a draft when any locale
195
+ is a draft — so the protected rows are still *one* newest published row and
196
+ *one* newest draft row **for the whole document**, not one per locale.
197
+
198
+ The consequence to be aware of: if `en` was last published five months ago and
199
+ `de` has been published weekly since, the newest any-locale published row is a
200
+ `de` one. The older row carrying `en`'s last publish is not separately
201
+ protected, and the floor may not reach far enough back to keep it. If you need
202
+ per-locale publish history preserved indefinitely, raise
203
+ `minVersionsPerDocument` for that collection or exclude it.
204
+
205
+ Status *equality* is stricter than protection: dedup only collapses two rows
206
+ whose per-locale objects match in **every** locale, so publishing one locale
207
+ never looks like a no-op against a draft of another.
208
+
209
+ 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.
210
+
211
+ Rows kept to satisfy the floor are the **newest** of the deletable ones.
212
+
213
+ ## Worked example
214
+
215
+ `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.
216
+
217
+ | Rule | Rows kept |
218
+ |---|---|
219
+ | newest row is `latest` | 1 |
220
+ | newest `published` | same row |
221
+ | no `draft` row exists | 0 |
222
+ | floor of 5 tops it up with the next-newest | 4 |
223
+ | **total kept** | **5** |
224
+ | **deleted** | **20** |
225
+
226
+ Run with `minVersionsPerDocument: 0` the same document would keep 1 row and delete 24 — which is why the floor is on by default.
227
+
228
+ This is the case the integration test pins down against real Postgres: 25
229
+ versions of a localized, blocks-bearing page, one of them a genuine no-op save,
230
+ swept down to exactly 5 — with every surviving body byte-identical to what it
231
+ was before, and the live document row untouched.
232
+
233
+ At the collection level, `_games_v` (16,924 rows) would lose **6,738 rows** on a
234
+ 30-day window with the protected rows alone. The floor holds some of those back
235
+ and dedup removes some more; the net depends on the per-document distribution,
236
+ so read it off a first run's `deletedCount` rather than assuming a number.
237
+
238
+ ## Identical-snapshot dedup
239
+
240
+ Two consecutive version rows can hold byte-identical content — a save that
241
+ changed nothing, a re-publish, an editor pressing save twice. On the production
242
+ copy there are **51 such consecutive pairs across 33 games**, and 37% of
243
+ `_games_v` rows are byte-identical to the live body.
244
+
245
+ By default dedup runs **inside the janitor**, off the write path
246
+ (`dedup: { mode: 'sweep' }`).
247
+
248
+ For each document the sweep already visits, it reads that document's version
249
+ bodies — in pages of 50, bounded across the whole run by
250
+ `maxDedupBodiesPerRun` (default 2,000) — and walks them in `updatedAt` order.
251
+ A consecutive pair collapses only when **all** of these hold:
252
+
253
+ - neither row is an `autosave` row;
254
+ - both rows carry the **same** `_status`. A publish that follows a
255
+ byte-identical draft keeps both rows, always — they are the same content in
256
+ two different states. Under `drafts.localizeStatus` the per-locale status
257
+ objects must match in every locale, so publishing one locale never looks like
258
+ a no-op against a draft of another;
259
+ - the bodies match once the noise keys are stripped.
260
+
261
+ **Noise keys**, stripped from the *top level* of the version body before
262
+ comparing: `createdAt`, `updatedAt` (Payload backfills both from the version
263
+ row, and the document's `updatedAt` moves on every save) and `id` (the parent
264
+ document id, identical for both rows by construction). Nothing else.
265
+
266
+ In particular, **nested ids are content**. The `id` on a block row or an array
267
+ row identifies that row; reordering two blocks, or replacing one, changes those
268
+ ids and is a real change. Only a save that posts the same row ids back — which
269
+ is exactly what the admin does when an editor saves without editing — produces
270
+ an identical pair.
271
+
272
+ When a pair collapses, the **newer** row is deleted and the older one survives,
273
+ keeping its original `createdAt` (the moment the content actually first
274
+ appeared) and `updatedAt` (so a no-op save cannot silently reset the retention
275
+ window). A run of three identical rows collapses to its oldest member.
276
+
277
+ If the collapsed row carried `latest`, the flag is moved to the survivor
278
+ **first** and the delete follows, so no window exists in which the document has
279
+ no `latest` row. The move sends `versionData: { latest: true }` and nothing
280
+ else — that is what keeps drizzle on its plain `UPDATE ... SET latest` path
281
+ instead of the full-row rewrite, which would delete and reinsert the survivor's
282
+ `_locales`, block and relationship rows.
283
+
284
+ Turn it off with `dedup: false`, or per entity with
285
+ `overrides: { games: { dedup: false } }`.
286
+
287
+ ### On-save mode is experimental and off
288
+
289
+ `dedup: { mode: 'onSave' }` attaches an `afterChange` hook that does the same
290
+ collapse at write time. **It is not the default and is not recommended.**
291
+
292
+ - It runs **inside the editor's save transaction**. Catching its errors does
293
+ not make it non-fatal: in Postgres a failed statement aborts the surrounding
294
+ transaction (`25P02`), so every later statement in the save fails and the
295
+ editor sees a cryptic error from somewhere unrelated.
296
+ - It reads two full version bodies on **every** save of every tracked document,
297
+ on the write path.
298
+ - Any mistake in the re-flag payload rewrites the survivor row through the full
299
+ `upsertRow` path — delete and reinsert of its `_locales`, block and
300
+ relationship rows — inside that same transaction, where
301
+ `onConflictDoUpdate` can resurrect a row another transaction deleted.
302
+
303
+ The sweep does the same work where a failure costs a log line instead of an
304
+ editor's save.
305
+
306
+ ## Attribution: who a surviving version belongs to
307
+
308
+ Worth knowing before you turn dedup on: **the older row is the one that
309
+ survives**, so a version's author field points at whoever created the *first*
310
+ occurrence of that content.
311
+
312
+ The case that surprises people: the ai-translate plugin finishes a run and
313
+ writes its version row. An editor then opens the document and saves without
314
+ changing anything, producing a byte-identical row of their own. Sweep dedup
315
+ deletes the editor's newer row and leaves the run's row as `latest` — so
316
+ `version_created_by` on the surviving row reads as the translation run, not the
317
+ editor. Nothing was lost (the bodies were identical), but the attribution is
318
+ the run's.
319
+
320
+ If per-save attribution matters more than the row count for a collection, opt
321
+ it out with `overrides: { <slug>: { dedup: false } }`.
322
+
323
+ ## Memory and chunk sizes
324
+
325
+ Version bodies are the whole problem with these tables, so every read is
326
+ bounded:
327
+
328
+ | Path | Bound | Why |
329
+ |---|---|---|
330
+ | retention decision | **0 bodies** | reads are narrowed with `select` to `id`, `latest`, `snapshot`, `updatedAt` and `version._status` |
331
+ | dedup comparison | **50 bodies** at a time | paged, with only the previous row carried across a page boundary; capped run-wide by `maxDedupBodiesPerRun` |
332
+ | nested-select fallback | **50 bodies** at a time | each page is reduced to the retention columns before the next is fetched |
333
+ | deletion | **25 bodies** per call | `DELETE_CHUNK_SIZE` |
334
+
335
+ Every version read is sorted `['-updatedAt', '-id']`, and the parent index walk
336
+ `['parent', 'id']`. `updatedAt` alone is not a total order — a burst of saves
337
+ inside one millisecond gives rows identical timestamps — and under an unstable
338
+ sort a paged read can return the same row twice, which in the dedup walk would
339
+ compare a row against itself and delete a unique version.
340
+
341
+ That last one is the non-obvious one. Drizzle's `deleteVersions` runs a
342
+ `findMany` with **no select** over the `IN (...)` list before deleting it, so
343
+ every row in a chunk is materialised as a full version body first. A chunk of
344
+ 200 would peak at 200 bodies; 25 keeps the peak at 25. On the wild copy the
345
+ largest `_games_v` body is a few hundred KB, so the bound is on the order of a
346
+ few MB per delete call rather than tens.
347
+
348
+ ## Relationship to `maxPerDoc`
349
+
350
+ **Leave `maxPerDoc` where it is (25 on wild).** This plugin is what bounds history; `maxPerDoc` is now a backstop, not the retention policy.
351
+
352
+ `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.
353
+
354
+ 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.
355
+
356
+ ## Options
357
+
358
+ All options are optional (`VersionRetentionPluginOptions`):
359
+
360
+ | Option | Type | Default | Description |
361
+ |---|---|---|---|
362
+ | `enabled` | `boolean` | `true` | Enable/disable the plugin entirely. When `false`, the plugin makes no changes other than recording its options under `config.custom.versionRetention` |
363
+ | `days` | `number` | `30` | Retention window. Version rows whose `updatedAt` is older than this are deletable, subject to the protected rows and the floor |
364
+ | `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 |
365
+ | `collections` | `string[]` | all versioned collections | Collection slugs to sweep and dedup |
366
+ | `excludeCollections` | `string[]` | `[]` | Collection slugs to skip, applied after `collections` |
367
+ | `globals` | `string[]` | all versioned globals | Global slugs to sweep |
368
+ | `excludeGlobals` | `string[]` | `[]` | Global slugs to skip, applied after `globals` |
369
+ | `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](#identical-snapshot-dedup) |
370
+ | `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 |
371
+ | `janitor` | `VersionRetentionJanitorOptions \| false` | `{}` | Janitor configuration, or `false` to register neither the task nor the schedule (leaving only the dedup hook) |
372
+ | `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](#scheduling-requires-a-consumer-migration) |
373
+ | `janitor.maxDeletionsPerRun` | `number` | `5000` | Ceiling on version rows deleted per invocation, across every collection and global. Dedup deletions count against it too |
374
+ | `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 |
375
+ | `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 |
376
+
377
+ Example:
378
+
379
+ ```ts
380
+ versionRetentionPlugin({
381
+ days: 30,
382
+ minVersionsPerDocument: 5,
383
+ excludeCollections: ['audit-logs'],
384
+ overrides: {
385
+ games: { days: 14 },
386
+ 'site-nav': { days: 365, minVersionsPerDocument: 10 },
387
+ pages: { dedup: false },
388
+ },
389
+ dedup: { maxDedupBodiesPerRun: 5_000 },
390
+ janitor: { maxDeletionsPerRun: 20_000 },
391
+ })
392
+ ```
393
+
394
+ ### `maxDedupBodiesPerRun` and `maxDeletionsPerRun` are related
395
+
396
+ A collapse is atomic: every duplicate in a run goes, or none of it does, because
397
+ the `latest` flag has to land on a row that survives. So dedup can read a
398
+ document, find duplicates, and be unable to pay for them out of the deletion
399
+ budget.
400
+
401
+ When that happens the pass **steps past** the document with a warning rather
402
+ than resuming on it — resuming would be a livelock that reads the same bodies
403
+ every run and never deletes a row. The document gets another chance on the next
404
+ cycle. If you see that warning regularly, either raise `maxDeletionsPerRun` or
405
+ bring `maxDedupBodiesPerRun` down toward it; the defaults (2,000 bodies against
406
+ 5,000 deletions) leave plenty of headroom.
407
+
408
+ ## The janitor task
409
+
410
+ Registered in `jobs.tasks` as **`version-retention-janitor`**, `retries: 3`, scheduled daily at 03:00 on the `default` queue.
411
+
412
+ **Globals are swept first.** There are a handful of them and they are cheap;
413
+ letting a large collection's backlog eat the run budget would otherwise starve
414
+ them night after night.
415
+
416
+ It resolves its collections and globals from the **running** config, so a
417
+ versioned collection injected by a plugin registered after this one is still
418
+ swept.
419
+
420
+ Rather than walking every document, each collection's pass walks the
421
+ stale-version index and visits only the parents that still hold deletable
422
+ history, paging by an ascending `parent` cursor — not an offset, so deleting
423
+ rows mid-pass cannot make it skip a document.
424
+
425
+ **Concurrent-write guard.** Payload's `updateLatestVersion` rewrites the latest
426
+ row in place on unpublish and on autosave, so a plan built from a stale read
427
+ could delete the runner-up published row and leave the document with none.
428
+ Before deleting anything for a document, the pass re-reads it and compares the
429
+ protected ids and the `latest` id against what it planned from. If anything
430
+ moved, the document is skipped for this run and counted in `skippedRaced`.
431
+
432
+ Deletion order is **oldest first**, so a plan cut short by the deletion budget
433
+ still removes the oldest rows rather than eating into recent history. The
434
+ age-sweep delete also carries `latest: { not_equals: true }` — belt and braces
435
+ for the window between the verification read and the delete: whatever else goes
436
+ wrong, the row the admin resolves the document from stays. Deletes are issued
437
+ as `deleteVersions({ where: { id: { in: [...] } } })` in chunks of 25
438
+ (see [Memory and chunk sizes](#memory-and-chunk-sizes)), deliberately
439
+ **without** a `req`, so a first-run backlog does not sit inside one hours-long
440
+ transaction.
441
+
442
+ Output:
443
+
444
+ ```ts
445
+ {
446
+ dedupCursor: Record<string, number | string> // per-slug dedup resume point
447
+ dedupedCount: number
448
+ deletedCount: number // dedup included
449
+ failedDocuments: number
450
+ hasMore: boolean
451
+ scannedDocuments: number
452
+ skippedRaced: number
453
+ }
454
+ ```
455
+
456
+ `hasMore: true` means **deletable work is known to remain**, and nothing
457
+ weaker. It is true only when:
458
+
459
+ - a deletion or document budget ran out **in a run that deleted rows** — the
460
+ cap ended the pass, not the corpus;
461
+ - the dedup body budget ran out **with documents still unread**;
462
+ - a document was skipped as raced, or its sweep failed.
463
+
464
+ A stale version row on its own is not work: on a settled corpus most stale rows
465
+ are the protected ones or held by the floor, and every run meets them again. An
466
+ earlier version treated "a stale row exists" as evidence, which made the signal
467
+ permanently true at steady state — 2,115 documents scanned, nothing deleted,
468
+ `hasMore: true`, run after run. A run that finds nothing deletable now says so:
469
+ `hasMore: false`, and the log line reads *nothing to do*.
470
+
471
+ The **age sweep** keeps no cursor: each run starts from the beginning of the
472
+ stale index, which is correct because the rows it deleted last time are gone.
473
+
474
+ **Dedup does**, because a document it has already cleaned still costs a body
475
+ read to prove it is clean. The cursor is **one position over the whole run
476
+ order** — `{ slug, parentId }` — not one per entity:
477
+
478
+ ```ts
479
+ { dedupCursor: { parentId: 21, slug: 'categories' } }
480
+ ```
481
+
482
+ A run resumes at that entity, after that document, and carries on into every
483
+ entity behind it; entities *ahead* of it were covered earlier in the cycle and
484
+ are skipped for dedup (they are still age-swept). When a run reaches the end
485
+ without running short, the cursor clears and the next run starts the cycle
486
+ again. Every entity is therefore deduped within
487
+ `ceil(total bodies / maxDedupBodiesPerRun)` runs.
488
+
489
+ A per-entity cursor is what does **not** work, and this was measured: with one
490
+ cursor per slug, the entities ahead of the stalled one carried none, so every
491
+ run re-read them from scratch and spent the entire body budget before reaching
492
+ the stall. On the production copy dedup collapsed pairs only in the four
493
+ collections ahead of `categories` and never once reached `pages` or `games`,
494
+ run after run, with `dedupCursor` frozen at `{ categories: 21 }`.
495
+
496
+ The cursor is stored in the plugin's own `version-retention-state` global, and
497
+ that is not incidental: Payload's `deleteJobOnComplete` defaults to `true`, so a
498
+ cursor kept in a job's output is deleted with the job before the next run can
499
+ read it — which made an earlier version of this rotation inert under the default
500
+ configuration, however carefully the cursor was computed.
501
+
502
+ Resolution order at the start of every run: an explicit `input.dedupCursor`,
503
+ then the state global, then (legacy, for consumers who turned job deletion off)
504
+ the last completed janitor job's output. Scheduled and manual runs behave
505
+ identically. If none is available the run starts the cycle from the beginning
506
+ and says so in a warning; a cursor naming a slug that is no longer configured
507
+ also restarts it.
508
+
509
+ **Failure isolation.** A document whose sweep throws is logged with its id,
510
+ counted in `failedDocuments` and skipped; the rest of the entity, and the
511
+ entities queued behind it, still run. An entity that fails outright is logged
512
+ and skipped the same way. Both set `hasMore`.
513
+
514
+ **One at a time.** A run that finds another janitor job already `processing`
515
+ logs a line and declines to start, rather than splitting the run budget with it
516
+ and racing it for the cursor — which is easy to reach by queueing one by hand
517
+ while the schedule fires.
518
+
519
+ The lock is time-bounded, and that matters: Payload sets `processing` when a
520
+ job starts and never clears it if the process dies, so an unbounded lock would
521
+ turn one OOM into a janitor that never runs again. A `processing` row older
522
+ than `janitor.runLockTtl` (default 6 hours) is ignored as crash debris, with a
523
+ warning naming how many were skipped.
524
+
525
+ One `info` line is logged per entity that had anything to scan, plus one
526
+ closing summary.
527
+
528
+ ## Postgres does not shrink on delete
529
+
530
+ 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.
531
+
532
+ 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.
533
+
534
+ ## Exports
535
+
536
+ The package ships a single entry point:
537
+
538
+ ```ts
539
+ import { versionRetentionPlugin } from '@purposeinplay/payload-version-retention'
540
+ import type {
541
+ VersionRetentionJanitorOptions,
542
+ VersionRetentionJanitorOutput,
543
+ VersionRetentionPluginOptions,
544
+ } from '@purposeinplay/payload-version-retention'
545
+ ```
546
+
547
+ There is no `./client` or other subpath — the plugin has no admin UI components and no client/server split. No environment variables are read.
548
+
549
+ ## License
550
+
551
+ [MIT](./LICENSE)
552
+
553
+ Part of the [purposeinplay/payload-plugins](https://github.com/purposeinplay/payload-plugins) monorepo. Issues and contributions: [GitHub issues](https://github.com/purposeinplay/payload-plugins/issues).
@@ -0,0 +1,93 @@
1
+ /** Default retention window in days for version rows. */
2
+ export declare const DEFAULT_RETENTION_DAYS = 30;
3
+ /**
4
+ * Floor on version rows left behind per document, whatever their age.
5
+ *
6
+ * Measured on the wild copy: 75 of 96 `pages` documents have their entire
7
+ * version history older than 30 days and 51 sit at the 25-version cap. Age
8
+ * plus the protected rows alone would leave most of them with a single
9
+ * version after one night, so a floor keeps a usable tail of recent history.
10
+ */
11
+ export declare const DEFAULT_MIN_VERSIONS_PER_DOCUMENT = 5;
12
+ /** Default cron schedule for the janitor task: daily at 03:00. */
13
+ export declare const DEFAULT_JANITOR_CRON = "0 3 * * *";
14
+ /**
15
+ * Queue the janitor is scheduled onto. `handleSchedules` skips queues the
16
+ * running autoRun config does not drain, so this must be a queue the consumer
17
+ * actually drains — `default` is the one every consumer has.
18
+ */
19
+ export declare const DEFAULT_JANITOR_QUEUE = "default";
20
+ /**
21
+ * How long a `processing` janitor job is believed before the lock it holds is
22
+ * treated as debris.
23
+ *
24
+ * Payload never resets `processing` after a crash — nothing in its queue code
25
+ * sweeps abandoned jobs — so an OOM mid-run would otherwise wedge every future
26
+ * run behind one warning, for good.
27
+ */
28
+ export declare const DEFAULT_RUN_LOCK_TTL_MS: number;
29
+ /** Retries so one transient error does not skip the day's retention pass. */
30
+ export declare const DEFAULT_JANITOR_RETRIES = 3;
31
+ /** Slug of the janitor task, as registered in `jobs.tasks`. */
32
+ export declare const JANITOR_TASK_SLUG = "version-retention-janitor";
33
+ /**
34
+ * Slug of the tiny global this plugin owns to persist its dedup cursor across
35
+ * runs. Its table is `version_retention_state`.
36
+ *
37
+ * A store of our own is not a nicety: Payload's `deleteJobOnComplete` defaults
38
+ * to `true` and hard-deletes the job row the moment it finishes, so a cursor
39
+ * kept only in a job's output is gone before the next run can read it.
40
+ */
41
+ export declare const STATE_GLOBAL_SLUG = "version-retention-state";
42
+ /**
43
+ * Slug of Payload's jobs collection. The same sanitize branch that injects the
44
+ * jobs-stats global adds a `meta` JSON field to this collection (`jobs.stats`),
45
+ * so its column is part of the very same migration.
46
+ */
47
+ export declare const JOBS_COLLECTION_SLUG = "payload-jobs";
48
+ /** Ceiling on version rows deleted per invocation, across every entity. */
49
+ export declare const DEFAULT_MAX_DELETIONS_PER_RUN = 5000;
50
+ /** Ceiling on documents examined per invocation, across every entity. */
51
+ export declare const DEFAULT_MAX_DOCUMENTS_PER_RUN = 20000;
52
+ /**
53
+ * Rows per page when walking the stale-version index to discover which parent
54
+ * documents still hold deletable history.
55
+ */
56
+ export declare const PARENT_PAGE_SIZE = 500;
57
+ /**
58
+ * Newest-first window of version rows inspected per document. Must exceed the
59
+ * collection's `maxPerDoc` (25 in the wild consumer, 100 by Payload default)
60
+ * or the oldest rows fall outside the window and are never reached.
61
+ */
62
+ export declare const MAX_VERSIONS_PER_DOCUMENT = 1000;
63
+ /**
64
+ * Ids per `deleteVersions` call.
65
+ *
66
+ * Deliberately small: drizzle's `deleteVersions` runs a `findMany` with **no
67
+ * select** over the chunk before deleting, so every row in the chunk is
68
+ * materialised as a full version body first. 25 bounds that peak at 25 bodies
69
+ * — see the README's "Memory" note.
70
+ */
71
+ export declare const DELETE_CHUNK_SIZE = 25;
72
+ /**
73
+ * Rows per page when the nested-select fallback has to read full bodies. Keeps
74
+ * the peak at 50 bodies instead of `MAX_VERSIONS_PER_DOCUMENT` of them; each
75
+ * page is reduced to the retention columns before the next is fetched.
76
+ */
77
+ export declare const FULL_BODY_PAGE_SIZE = 50;
78
+ /** Dedup runs inside the janitor sweep by default. */
79
+ export declare const DEFAULT_DEDUP_MODE: "sweep";
80
+ /**
81
+ * Ceiling on version bodies the sweep reads for dedup in one invocation.
82
+ * Bodies are the expensive thing in these tables, so this is a separate
83
+ * budget from the deletion cap.
84
+ */
85
+ export declare const DEFAULT_MAX_DEDUP_BODIES_PER_RUN = 2000;
86
+ /**
87
+ * Request-context key the ai-translate plugin sets while a translation run is
88
+ * in flight. That plugin collapses a whole run into a single version row of
89
+ * its own, so the dedup hook must keep its hands off those writes.
90
+ */
91
+ export declare const AI_TRANSLATE_RUN_CONTEXT_KEY = "aiTranslateRunId";
92
+ /** Log prefix shared by every message this plugin emits. */
93
+ export declare const LOG_PREFIX = "[version-retention]";