@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
|
@@ -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
|
+
}
|
package/dist/plugin.d.ts
ADDED
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
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
|
+
}
|