@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 +21 -0
- package/README.md +553 -0
- package/dist/defaults.d.ts +93 -0
- package/dist/defaults.js +74 -0
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.js +1 -0
- package/dist/hooks/dedup-versions.d.ts +28 -0
- package/dist/hooks/dedup-versions.js +123 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lib/dedup-cursor.d.ts +34 -0
- package/dist/lib/dedup-cursor.js +94 -0
- package/dist/lib/entities.d.ts +43 -0
- package/dist/lib/entities.js +98 -0
- package/dist/lib/retention-plan.d.ts +72 -0
- package/dist/lib/retention-plan.js +158 -0
- package/dist/lib/run-lock.d.ts +25 -0
- package/dist/lib/run-lock.js +105 -0
- package/dist/lib/snapshot-compare.d.ts +37 -0
- package/dist/lib/snapshot-compare.js +86 -0
- package/dist/lib/startup-checks.d.ts +55 -0
- package/dist/lib/startup-checks.js +225 -0
- package/dist/lib/state.d.ts +27 -0
- package/dist/lib/state.js +87 -0
- package/dist/lib/sweep.d.ts +72 -0
- package/dist/lib/sweep.js +799 -0
- package/dist/lib/version-row.d.ts +19 -0
- package/dist/lib/version-row.js +77 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +91 -0
- package/dist/tasks/janitor.d.ts +8 -0
- package/dist/tasks/janitor.js +116 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.js +4 -0
- package/package.json +56 -0
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
|
+
[](https://www.npmjs.com/package/@purposeinplay/payload-version-retention)
|
|
4
|
+
[](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]";
|