@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.
@@ -0,0 +1,19 @@
1
+ import type { VersionRow } from '../types.js';
2
+ /**
3
+ * Narrows one row returned by `findVersions` / `findGlobalVersions` to the
4
+ * columns this plugin reads.
5
+ *
6
+ * The adapters return rows shaped by whatever `select` was passed and by which
7
+ * optional version columns the collection has (`autosave` only exists when
8
+ * autosave is on, `publishedLocale` only with locales), so every field is read
9
+ * defensively rather than asserted. Rows without a usable `id` or `updatedAt`
10
+ * are dropped — a retention decision cannot be made about them.
11
+ */
12
+ export declare function toVersionRow(doc: unknown): null | VersionRow;
13
+ /** `toVersionRow` over a result page, dropping rows that cannot be used. */
14
+ export declare function toVersionRows(docs: unknown): VersionRow[];
15
+ /**
16
+ * Parent-only variant for the stale-version index walk, where `updatedAt` is
17
+ * not selected and the row would otherwise be dropped.
18
+ */
19
+ export declare function toParentIds(docs: unknown): (number | string)[];
@@ -0,0 +1,77 @@
1
+ function isRecord(value) {
2
+ return typeof value === 'object' && value !== null;
3
+ }
4
+ function readId(value) {
5
+ return typeof value === 'number' || typeof value === 'string' ? value : undefined;
6
+ }
7
+ function readOptionalString(value) {
8
+ return typeof value === 'string' ? value : undefined;
9
+ }
10
+ function readOptionalBoolean(value) {
11
+ return typeof value === 'boolean' ? value : undefined;
12
+ }
13
+ function readBody(value) {
14
+ return isRecord(value) ? value : undefined;
15
+ }
16
+ /**
17
+ * Narrows one row returned by `findVersions` / `findGlobalVersions` to the
18
+ * columns this plugin reads.
19
+ *
20
+ * The adapters return rows shaped by whatever `select` was passed and by which
21
+ * optional version columns the collection has (`autosave` only exists when
22
+ * autosave is on, `publishedLocale` only with locales), so every field is read
23
+ * defensively rather than asserted. Rows without a usable `id` or `updatedAt`
24
+ * are dropped — a retention decision cannot be made about them.
25
+ */ export function toVersionRow(doc) {
26
+ if (!isRecord(doc)) {
27
+ return null;
28
+ }
29
+ const id = readId(doc.id);
30
+ const updatedAt = readOptionalString(doc.updatedAt);
31
+ if (id === undefined || updatedAt === undefined) {
32
+ return null;
33
+ }
34
+ return {
35
+ autosave: readOptionalBoolean(doc.autosave),
36
+ createdAt: readOptionalString(doc.createdAt),
37
+ id,
38
+ latest: readOptionalBoolean(doc.latest),
39
+ parent: readId(doc.parent),
40
+ publishedLocale: readOptionalString(doc.publishedLocale),
41
+ snapshot: readOptionalBoolean(doc.snapshot),
42
+ updatedAt,
43
+ version: readBody(doc.version)
44
+ };
45
+ }
46
+ /** `toVersionRow` over a result page, dropping rows that cannot be used. */ export function toVersionRows(docs) {
47
+ if (!Array.isArray(docs)) {
48
+ return [];
49
+ }
50
+ const rows = [];
51
+ for (const doc of docs){
52
+ const row = toVersionRow(doc);
53
+ if (row) {
54
+ rows.push(row);
55
+ }
56
+ }
57
+ return rows;
58
+ }
59
+ /**
60
+ * Parent-only variant for the stale-version index walk, where `updatedAt` is
61
+ * not selected and the row would otherwise be dropped.
62
+ */ export function toParentIds(docs) {
63
+ if (!Array.isArray(docs)) {
64
+ return [];
65
+ }
66
+ const parents = [];
67
+ for (const doc of docs){
68
+ if (!isRecord(doc)) {
69
+ continue;
70
+ }
71
+ const parent = readId(doc.parent);
72
+ if (parent !== undefined && !parents.includes(parent)) {
73
+ parents.push(parent);
74
+ }
75
+ }
76
+ return parents;
77
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from 'payload';
2
+ import type { VersionRetentionPluginOptions } from './types.js';
3
+ export declare function versionRetentionPlugin(options?: VersionRetentionPluginOptions): Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,91 @@
1
+ import { createDedupVersionsHook } from './hooks/dedup-versions.js';
2
+ import { resolveOnSaveDedupCollections } from './lib/entities.js';
3
+ import { checkStateGlobal, runSchedulingStartupChecks, warnScheduleDisabled } from './lib/startup-checks.js';
4
+ import { createStateGlobal } from './lib/state.js';
5
+ import { createJanitorTask, DEFAULT_JANITOR_SCHEDULE } from './tasks/janitor.js';
6
+ export function versionRetentionPlugin(options = {}) {
7
+ return (incomingConfig)=>{
8
+ const config = {
9
+ ...incomingConfig
10
+ };
11
+ config.custom = {
12
+ ...config.custom ?? {},
13
+ versionRetention: options
14
+ };
15
+ // Registered before every early return, deliberately. The global is schema,
16
+ // and schema must not depend on a runtime flag: gating it would make
17
+ // `migrate:create` emit a DROP in an environment where the plugin happens
18
+ // to be disabled, and `push: true` in dev drop the table outright.
19
+ config.globals = [
20
+ ...config.globals ?? [],
21
+ createStateGlobal()
22
+ ];
23
+ if (options.enabled === false) {
24
+ return config;
25
+ }
26
+ // The experimental on-save hook — off unless the consumer asked for
27
+ // `dedup: { mode: 'onSave' }`. In the default `'sweep'` mode nothing is
28
+ // attached to the write path at all.
29
+ const dedupSlugs = resolveOnSaveDedupCollections(config.collections ?? [], options);
30
+ if (dedupSlugs.length > 0) {
31
+ config.collections = (config.collections ?? []).map((collection)=>{
32
+ if (!dedupSlugs.includes(collection.slug)) {
33
+ return collection;
34
+ }
35
+ const dedupHook = createDedupVersionsHook(collection.slug);
36
+ const existingAfterChange = collection.hooks?.afterChange ?? [];
37
+ return {
38
+ ...collection,
39
+ hooks: {
40
+ ...collection.hooks,
41
+ // Last, so a consumer hook that writes again in `afterChange`
42
+ // has already produced its version row before we compare.
43
+ afterChange: [
44
+ ...existingAfterChange,
45
+ dedupHook
46
+ ]
47
+ }
48
+ };
49
+ });
50
+ }
51
+ if (options.janitor === false) {
52
+ return config;
53
+ }
54
+ const janitor = options.janitor ?? {};
55
+ const schedule = janitor.schedule ?? DEFAULT_JANITOR_SCHEDULE;
56
+ config.jobs = {
57
+ ...config.jobs,
58
+ tasks: [
59
+ ...config.jobs?.tasks ?? [],
60
+ createJanitorTask(options, janitor)
61
+ ]
62
+ };
63
+ // Scheduling a task flips `jobs.scheduling` on for the whole config, and
64
+ // `handleSchedules` then reads the `payload-jobs-stats` global on every
65
+ // autoRun tick — before `jobs.run`. Without its table that throw stalls
66
+ // the consumer's entire default queue, so surface it loudly at boot
67
+ // instead of leaving it to a silent per-minute failure. An empty schedule
68
+ // schedules nothing at all, which is worth one line too.
69
+ const scheduledQueues = [
70
+ ...new Set(schedule.map((entry)=>entry.queue))
71
+ ];
72
+ const startupCheck = async (payload)=>{
73
+ // Checked whether or not there is a schedule: a consumer who queues the
74
+ // janitor by hand still writes the dedup cursor to this global.
75
+ await checkStateGlobal(payload);
76
+ if (scheduledQueues.length === 0) {
77
+ warnScheduleDisabled(payload);
78
+ return;
79
+ }
80
+ await runSchedulingStartupChecks(payload, scheduledQueues);
81
+ };
82
+ const existingOnInit = config.onInit;
83
+ config.onInit = async (payload)=>{
84
+ if (existingOnInit) {
85
+ await existingOnInit(payload);
86
+ }
87
+ await startupCheck(payload);
88
+ };
89
+ return config;
90
+ };
91
+ }
@@ -0,0 +1,8 @@
1
+ import type { TaskConfig } from 'payload';
2
+ import { JANITOR_TASK_SLUG } from '../defaults.js';
3
+ import type { VersionRetentionJanitorOptions, VersionRetentionPluginOptions, VersionRetentionScheduleConfig } from '../types.js';
4
+ type JanitorTaskConfig = TaskConfig<typeof JANITOR_TASK_SLUG>;
5
+ /** Daily 03:00 on the `default` queue — the queue consumers actually drain. */
6
+ export declare const DEFAULT_JANITOR_SCHEDULE: VersionRetentionScheduleConfig[];
7
+ export declare function createJanitorTask(options: VersionRetentionPluginOptions, janitor?: VersionRetentionJanitorOptions): JanitorTaskConfig;
8
+ export {};
@@ -0,0 +1,116 @@
1
+ import { DEFAULT_JANITOR_CRON, DEFAULT_JANITOR_QUEUE, DEFAULT_JANITOR_RETRIES, DEFAULT_MAX_DELETIONS_PER_RUN, DEFAULT_MAX_DOCUMENTS_PER_RUN, DEFAULT_RUN_LOCK_TTL_MS, JANITOR_TASK_SLUG, LOG_PREFIX } from '../defaults.js';
2
+ import { readPreviousDedupCursor, toJanitorInput } from '../lib/dedup-cursor.js';
3
+ import { resolveMaxDedupBodies, resolveVersionedCollections, resolveVersionedGlobals } from '../lib/entities.js';
4
+ import { anotherJanitorIsRunning } from '../lib/run-lock.js';
5
+ import { readStoredCursor, writeStoredCursor } from '../lib/state.js';
6
+ import { runRetentionSweep } from '../lib/sweep.js';
7
+ /** The report of a run that declined to start. */ function skippedOutput() {
8
+ return {
9
+ dedupCursor: null,
10
+ dedupedCount: 0,
11
+ deletedCount: 0,
12
+ failedDocuments: 0,
13
+ // Not this run's business to say; the run that IS working will report it.
14
+ hasMore: false,
15
+ scannedDocuments: 0,
16
+ skippedRaced: 0
17
+ };
18
+ }
19
+ /** Daily 03:00 on the `default` queue — the queue consumers actually drain. */ export const DEFAULT_JANITOR_SCHEDULE = [
20
+ {
21
+ cron: DEFAULT_JANITOR_CRON,
22
+ queue: DEFAULT_JANITOR_QUEUE
23
+ }
24
+ ];
25
+ export function createJanitorTask(options, janitor = {}) {
26
+ const schedule = janitor.schedule ?? DEFAULT_JANITOR_SCHEDULE;
27
+ const maxDeletionsPerRun = janitor.maxDeletionsPerRun ?? DEFAULT_MAX_DELETIONS_PER_RUN;
28
+ const maxDocumentsPerRun = janitor.maxDocumentsPerRun ?? DEFAULT_MAX_DOCUMENTS_PER_RUN;
29
+ const runLockTtl = janitor.runLockTtl ?? DEFAULT_RUN_LOCK_TTL_MS;
30
+ const task = {
31
+ slug: JANITOR_TASK_SLUG,
32
+ // Without retries a single transient failure (pool exhaustion, a restart
33
+ // mid-pass) silently skips the whole day's retention pass.
34
+ retries: DEFAULT_JANITOR_RETRIES,
35
+ // Payload's sanitizer turns scheduling on for the whole config when any
36
+ // task merely *has* a `schedule` key — `[]` included, which would demand
37
+ // the payload_jobs_stats table while scheduling nothing. Omit the key
38
+ // entirely for an empty schedule.
39
+ ...schedule.length > 0 ? {
40
+ schedule
41
+ } : {},
42
+ handler: async ({ input, job, req })=>{
43
+ const { payload } = req;
44
+ if (await anotherJanitorIsRunning({
45
+ ownJob: job,
46
+ req,
47
+ ttlMs: runLockTtl
48
+ })) {
49
+ return {
50
+ output: skippedOutput()
51
+ };
52
+ }
53
+ // The cursor lives in the plugin's own global: Payload deletes a
54
+ // completed job row by default, so a cursor kept in the job's output
55
+ // would be gone before the next run could read it. An explicit input
56
+ // wins; the last job's output is consulted only for consumers carrying
57
+ // one over from an earlier version of this plugin.
58
+ const dedupCursor = toJanitorInput(input).dedupCursor ?? await readStoredCursor(payload) ?? await readPreviousDedupCursor(req);
59
+ // Resolved from the running config rather than the incoming one, so a
60
+ // collection added by a plugin registered after this one is still swept.
61
+ const collections = resolveVersionedCollections(payload.config.collections ?? [], options);
62
+ const globals = resolveVersionedGlobals(payload.config.globals ?? [], options);
63
+ const output = await runRetentionSweep({
64
+ collections,
65
+ dedupCursor,
66
+ globals,
67
+ maxDedupBodiesPerRun: resolveMaxDedupBodies(options),
68
+ maxDeletionsPerRun,
69
+ maxDocumentsPerRun,
70
+ payload
71
+ });
72
+ await writeStoredCursor(payload, output.dedupCursor);
73
+ payload.logger.info(output.deletedCount === 0 && !output.hasMore ? `${LOG_PREFIX} Janitor finished: nothing to do — ${output.scannedDocuments} ` + 'document(s) scanned, no deletable version rows.' : `${LOG_PREFIX} Janitor finished: deleted ${output.deletedCount} version row(s) ` + `(${output.dedupedCount} identical) across ${output.scannedDocuments} document(s)` + (output.skippedRaced > 0 ? `, ${output.skippedRaced} skipped as raced` : '') + (output.failedDocuments > 0 ? `, ${output.failedDocuments} failed` : '') + (output.hasMore ? ' — work remains, the next run continues' : ''));
74
+ return {
75
+ output
76
+ };
77
+ },
78
+ inputSchema: [
79
+ {
80
+ name: 'dedupCursor',
81
+ type: 'json'
82
+ }
83
+ ],
84
+ outputSchema: [
85
+ {
86
+ name: 'dedupCursor',
87
+ type: 'json'
88
+ },
89
+ {
90
+ name: 'dedupedCount',
91
+ type: 'number'
92
+ },
93
+ {
94
+ name: 'deletedCount',
95
+ type: 'number'
96
+ },
97
+ {
98
+ name: 'failedDocuments',
99
+ type: 'number'
100
+ },
101
+ {
102
+ name: 'hasMore',
103
+ type: 'checkbox'
104
+ },
105
+ {
106
+ name: 'scannedDocuments',
107
+ type: 'number'
108
+ },
109
+ {
110
+ name: 'skippedRaced',
111
+ type: 'number'
112
+ }
113
+ ]
114
+ };
115
+ return task;
116
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Cron entry for the version-retention janitor. Structurally compatible with
3
+ * Payload's `ScheduleConfig`, which is not exported from the package root.
4
+ */
5
+ export interface VersionRetentionScheduleConfig {
6
+ /**
7
+ * Cron expression. Payload accepts the standard 5-field form and an
8
+ * optional leading seconds field.
9
+ */
10
+ cron: string;
11
+ /**
12
+ * Queue the scheduled job is added to. Payload's `handleSchedules` skips
13
+ * every queue the running autoRun config does not drain (unless it is
14
+ * invoked with `allQueues`), so this must be a queue that is actually
15
+ * drained — normally `'default'`.
16
+ */
17
+ queue: string;
18
+ }
19
+ /** Per-slug overrides, keyed by collection slug or global slug. */
20
+ export interface VersionRetentionOverride {
21
+ /**
22
+ * Retention window in days for this entity only. Falls back to the
23
+ * top-level `days`.
24
+ */
25
+ days?: number;
26
+ /**
27
+ * Floor on version rows kept per document for this entity only. Falls back
28
+ * to the top-level `minVersionsPerDocument`.
29
+ */
30
+ minVersionsPerDocument?: number;
31
+ /** Disable identical-snapshot dedup for this collection or global only. */
32
+ dedup?: boolean;
33
+ }
34
+ /**
35
+ * Where identical-snapshot dedup runs.
36
+ *
37
+ * - `'sweep'` (default) — inside the nightly janitor, off the write path.
38
+ * - `'off'` — not at all.
39
+ * - `'onSave'` — **experimental**, see the README. An `afterChange` hook that
40
+ * collapses the duplicate inside the editor's own save transaction.
41
+ */
42
+ export type VersionRetentionDedupMode = 'off' | 'onSave' | 'sweep';
43
+ export interface VersionRetentionDedupOptions {
44
+ /**
45
+ * Ceiling on version bodies the sweep reads for dedup per invocation,
46
+ * across every collection and global. Bodies are what make these tables
47
+ * large, so they get a budget of their own.
48
+ * @default 2000
49
+ */
50
+ maxDedupBodiesPerRun?: number;
51
+ /** @default 'sweep' */
52
+ mode?: VersionRetentionDedupMode;
53
+ }
54
+ export interface VersionRetentionJanitorOptions {
55
+ /**
56
+ * Ceiling on version rows deleted in a single invocation, across every
57
+ * collection and global. The first run after adopting the plugin meets the
58
+ * entire accumulated backlog; without a bound it would hold a job slot for
59
+ * as long as that takes. The handler reports `hasMore` instead and the next
60
+ * scheduled run continues.
61
+ * @default 5000
62
+ */
63
+ maxDeletionsPerRun?: number;
64
+ /**
65
+ * Ceiling on documents examined in a single invocation. A second bound for
66
+ * the case where a very large backlog is spread thinly — many documents,
67
+ * few deletable rows each.
68
+ * @default 20000
69
+ */
70
+ maxDocumentsPerRun?: number;
71
+ /**
72
+ * How old a `processing` janitor job may be before its lock is ignored as
73
+ * debris from a crashed run, in milliseconds. Payload never clears
74
+ * `processing` itself.
75
+ * @default 21600000 (6 hours)
76
+ */
77
+ runLockTtl?: number;
78
+ /**
79
+ * Cron entries for the janitor.
80
+ *
81
+ * Registering a schedule turns on Payload's job scheduling config-wide,
82
+ * which requires the `payload_jobs_stats` table — see the README section
83
+ * "Scheduling requires a consumer migration".
84
+ *
85
+ * Pass `[]` to register the task without scheduling it (queue it yourself).
86
+ * @default [{ cron: '0 3 * * *', queue: 'default' }]
87
+ */
88
+ schedule?: VersionRetentionScheduleConfig[];
89
+ }
90
+ export interface VersionRetentionPluginOptions {
91
+ /**
92
+ * Collection slugs to sweep and dedup. When omitted, every collection with
93
+ * `versions` enabled is included.
94
+ */
95
+ collections?: string[];
96
+ /**
97
+ * Retention window in days. Versions whose `updatedAt` is older than this
98
+ * are deleted, except the protected rows (see the README).
99
+ * @default 30
100
+ */
101
+ days?: number;
102
+ /**
103
+ * Identical-snapshot dedup. `true` is `{ mode: 'sweep' }`, `false` is
104
+ * `{ mode: 'off' }`.
105
+ * @default { mode: 'sweep', maxDedupBodiesPerRun: 2000 }
106
+ */
107
+ dedup?: boolean | VersionRetentionDedupOptions;
108
+ /**
109
+ * Enable/disable the plugin entirely.
110
+ * @default true
111
+ */
112
+ enabled?: boolean;
113
+ /** Collection slugs to skip, applied after `collections`. */
114
+ excludeCollections?: string[];
115
+ /** Global slugs to skip, applied after `globals`. */
116
+ excludeGlobals?: string[];
117
+ /**
118
+ * Global slugs to sweep. When omitted, every global with `versions` enabled
119
+ * is included.
120
+ */
121
+ globals?: string[];
122
+ /**
123
+ * Janitor task configuration, or `false` to register neither the task nor
124
+ * the schedule (leaving only the dedup hook).
125
+ */
126
+ janitor?: false | VersionRetentionJanitorOptions;
127
+ /**
128
+ * Floor on version rows the janitor leaves behind per document (per global),
129
+ * whatever their age. The protected rows count toward it, and the rows kept
130
+ * to reach it are the newest deletable ones.
131
+ *
132
+ * Pass `0` for age-and-status only.
133
+ * @default 5
134
+ */
135
+ minVersionsPerDocument?: number;
136
+ /**
137
+ * Per-slug overrides keyed by collection or global slug. Unknown slugs are
138
+ * ignored.
139
+ */
140
+ overrides?: Record<string, VersionRetentionOverride>;
141
+ }
142
+ /** A version body as stored in the `version` column. */
143
+ export type VersionBody = Record<string, unknown>;
144
+ /**
145
+ * The subset of a version row the janitor needs. Mirrors Payload's
146
+ * `TypeWithVersion` narrowed to the columns the retention decision reads.
147
+ */
148
+ export interface VersionRow {
149
+ /** Only present when the collection has autosave enabled. */
150
+ autosave?: boolean;
151
+ createdAt?: string;
152
+ id: number | string;
153
+ latest?: boolean;
154
+ /** Id of the document this version belongs to. Absent for globals. */
155
+ parent?: number | string;
156
+ publishedLocale?: string;
157
+ /**
158
+ * Payload's pre-publish snapshot row. The admin hides these, so they are
159
+ * never a protected row and never count toward the floor.
160
+ */
161
+ snapshot?: boolean;
162
+ updatedAt: string;
163
+ /** Absent when the read narrowed the select away from the body. */
164
+ version?: VersionBody;
165
+ }
166
+ /** A version row reduced to the facts the retention plan is built from. */
167
+ export interface RetentionCandidate {
168
+ id: number | string;
169
+ /**
170
+ * True when `version._status` is `'draft'` — or, under
171
+ * `drafts.localizeStatus`, when the per-locale status object holds
172
+ * `'draft'` for any locale.
173
+ */
174
+ isDraft: boolean;
175
+ /** As `isDraft`, for `'published'`. */
176
+ isPublished: boolean;
177
+ latest: boolean;
178
+ /** Payload's pre-publish snapshot row; excluded from protection and floor. */
179
+ snapshot: boolean;
180
+ updatedAt: string;
181
+ }
182
+ /**
183
+ * Where the previous run's dedup pass stopped, as a position in the run's
184
+ * entity order — not a per-slug map.
185
+ *
186
+ * A per-slug map was not enough: the entities *before* the stalled one carried
187
+ * no cursor, so every run re-read them from the start and spent the whole body
188
+ * budget before reaching the stalled entity again. Dedup never got past it.
189
+ * One ordered position lets a run skip everything already covered in this
190
+ * cycle and pick up exactly where the last one stopped.
191
+ */
192
+ export interface VersionRetentionDedupCursor {
193
+ /** Resume after this document. Absent means "resume at the entity's start". */
194
+ parentId?: number | string;
195
+ /** Collection or global slug the pass stopped inside. */
196
+ slug: string;
197
+ }
198
+ /** Input the janitor accepts, carried forward from the previous run. */
199
+ export interface VersionRetentionJanitorInput {
200
+ dedupCursor?: VersionRetentionDedupCursor;
201
+ }
202
+ /** Outcome of one janitor invocation. */
203
+ export interface VersionRetentionJanitorOutput {
204
+ /**
205
+ * Where dedup stopped, or `null` when it completed a full cycle over every
206
+ * entity. Fed back in as the next run's input so the body budget advances
207
+ * through the whole corpus instead of restarting at the first entity.
208
+ */
209
+ dedupCursor: null | VersionRetentionDedupCursor;
210
+ /** Rows removed because they were byte-identical to their predecessor. */
211
+ dedupedCount: number;
212
+ /** Total rows removed, dedup included. */
213
+ deletedCount: number;
214
+ /** Documents (or entities) whose sweep threw. Logged, skipped, retried. */
215
+ failedDocuments: number;
216
+ /**
217
+ * True only when deletable work is known to remain: a document was cut by
218
+ * the deletion budget, the index walk stopped with parents unvisited, or a
219
+ * document held more history than one pass inspects. Reaching a budget with
220
+ * nothing left to do does NOT set it.
221
+ */
222
+ hasMore: boolean;
223
+ scannedDocuments: number;
224
+ /**
225
+ * Documents skipped because their version rows changed underneath the pass
226
+ * (a concurrent publish or unpublish). Retried on the next run.
227
+ */
228
+ skippedRaced: number;
229
+ }
230
+ /** A collection or global resolved to the settings the janitor runs with. */
231
+ export interface ResolvedEntity {
232
+ days: number;
233
+ /** Whether the sweep should dedup identical consecutive rows here. */
234
+ dedup: boolean;
235
+ /** Whether `version._status` exists at all for this entity. */
236
+ hasDrafts: boolean;
237
+ minVersionsPerDocument: number;
238
+ slug: string;
239
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Cron entry for the version-retention janitor. Structurally compatible with
3
+ * Payload's `ScheduleConfig`, which is not exported from the package root.
4
+ */ /** A collection or global resolved to the settings the janitor runs with. */ export { };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@purposeinplay/payload-version-retention",
3
+ "version": "0.1.1",
4
+ "description": "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.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/exports/index.js",
8
+ "types": "./dist/exports/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/exports/index.d.ts",
12
+ "import": "./dist/exports/index.js",
13
+ "default": "./dist/exports/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "peerDependencies": {
20
+ "payload": "^3.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@payloadcms/db-postgres": "^3.85.2",
24
+ "payload": "^3.85.2",
25
+ "typescript": "^5.9.3",
26
+ "vitest": "^4.1.10"
27
+ },
28
+ "keywords": [
29
+ "payload",
30
+ "payloadcms",
31
+ "plugin",
32
+ "versions",
33
+ "retention",
34
+ "cleanup",
35
+ "drafts"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/purposeinplay/payload-plugins.git",
40
+ "directory": "packages/payload-version-retention"
41
+ },
42
+ "homepage": "https://github.com/purposeinplay/payload-plugins/tree/main/packages/payload-version-retention#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/purposeinplay/payload-plugins/issues"
45
+ },
46
+ "scripts": {
47
+ "build": "pnpm run build:types && pnpm run build:swc && pnpm run build:fix-esm",
48
+ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
49
+ "build:swc": "swc ./src -d ./dist --config-file ../../.swcrc --strip-leading-paths --ignore \"**/__tests__/**\"",
50
+ "build:fix-esm": "node ../../scripts/fix-dist-extensions.mjs dist && node ../../scripts/check-dist-esm.mjs dist",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.int.json",
52
+ "test": "vitest run",
53
+ "clean": "rm -rf dist *.tsbuildinfo",
54
+ "test:int": "vitest run -c vitest.int.config.ts"
55
+ }
56
+ }