@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,158 @@
1
+ import { deepEqual } from './snapshot-compare.js';
2
+ /**
3
+ * The `_status` values Payload writes into a version body when drafts are on.
4
+ * Anything else (a custom status, or no `_status` at all on a drafts-less
5
+ * entity) is not separately protected.
6
+ */ export const PROTECTED_STATUSES = [
7
+ 'published',
8
+ 'draft'
9
+ ];
10
+ /**
11
+ * The raw `_status` value, whatever shape it has.
12
+ *
13
+ * With `drafts.localizeStatus` Payload writes an **object** keyed by locale
14
+ * (`{ en: 'published', de: 'draft' }`) rather than a string, so this cannot be
15
+ * narrowed to `string` — use `hasStatus` / `sameStatus` instead of comparing
16
+ * the return value directly.
17
+ */ export function readStatus(version) {
18
+ return version?._status;
19
+ }
20
+ /**
21
+ * True when the row carries `status` — as the plain string, or, under
22
+ * `localizeStatus`, for at least one locale.
23
+ *
24
+ * A row published in any locale still holds a publish worth protecting, so
25
+ * "any locale" is the right test for the protected rows.
26
+ */ export function hasStatus(version, status) {
27
+ const raw = readStatus(version);
28
+ if (typeof raw === 'string') {
29
+ return raw === status;
30
+ }
31
+ if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
32
+ return Object.values(raw).includes(status);
33
+ }
34
+ return false;
35
+ }
36
+ /**
37
+ * True when two rows carry the same `_status`. Under `localizeStatus` that
38
+ * means the same status in every locale, so publishing one locale never looks
39
+ * like a no-op against a draft of another.
40
+ */ export function sameStatus(a, b) {
41
+ return deepEqual(readStatus(a), readStatus(b));
42
+ }
43
+ /** Narrows a raw version row to the facts the plan is built from. */ export function toCandidate(row) {
44
+ return {
45
+ id: row.id,
46
+ isDraft: hasStatus(row.version, 'draft'),
47
+ isPublished: hasStatus(row.version, 'published'),
48
+ latest: row.latest === true,
49
+ snapshot: row.snapshot === true,
50
+ updatedAt: row.updatedAt
51
+ };
52
+ }
53
+ function sortNewestFirst(candidates) {
54
+ return [
55
+ ...candidates
56
+ ].sort((a, b)=>new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
57
+ }
58
+ /**
59
+ * The rows a retention pass must never delete, whatever their age:
60
+ *
61
+ * - the row flagged `latest: true` — the one `getLatestCollectionVersion`
62
+ * resolves the admin document from;
63
+ * - the newest non-snapshot published row — **the last good publish**, which a
64
+ * count cap happily evicts and this plugin does not;
65
+ * - the newest non-snapshot draft row — the work in progress;
66
+ * - the newest row overall, when none of the above matched (an entity with
67
+ * versions but no drafts has no `_status` at all).
68
+ *
69
+ * Snapshot rows are Payload's pre-publish copies. The admin hides them, so
70
+ * they are never chosen as the protected publish or draft — but a snapshot row
71
+ * that somehow carries `latest` is still protected by the first rule.
72
+ */ export function collectProtectedIds(candidates) {
73
+ const protectedIds = new Set();
74
+ if (candidates.length === 0) {
75
+ return protectedIds;
76
+ }
77
+ const newestFirst = sortNewestFirst(candidates);
78
+ const visible = newestFirst.filter((candidate)=>!candidate.snapshot);
79
+ const latest = newestFirst.find((candidate)=>candidate.latest);
80
+ if (latest) {
81
+ protectedIds.add(latest.id);
82
+ }
83
+ const newestPublished = visible.find((candidate)=>candidate.isPublished);
84
+ if (newestPublished) {
85
+ protectedIds.add(newestPublished.id);
86
+ }
87
+ const newestDraft = visible.find((candidate)=>candidate.isDraft);
88
+ if (newestDraft) {
89
+ protectedIds.add(newestDraft.id);
90
+ }
91
+ // Never leave a document with zero versions. Reached when the entity has no
92
+ // drafts and nothing carries `latest` — the newest visible row stands in, or
93
+ // the newest row of any kind if every row is a snapshot.
94
+ if (protectedIds.size === 0) {
95
+ const newest = visible[0] ?? newestFirst[0];
96
+ if (newest) {
97
+ protectedIds.add(newest.id);
98
+ }
99
+ }
100
+ return protectedIds;
101
+ }
102
+ /**
103
+ * Ids of the version rows that are both older than the cutoff and unprotected,
104
+ * minus whatever the floor holds back — **oldest first**.
105
+ *
106
+ * The order is load-bearing: when the caller truncates the plan to fit a
107
+ * per-run deletion budget it takes a prefix, and the oldest rows are the ones
108
+ * that should go first.
109
+ *
110
+ * Age is read from the version row's own `updatedAt`, the same column
111
+ * Payload's `enforceMaxVersions` orders by.
112
+ *
113
+ * `minVersions` is the floor on rows left behind for this document. On the
114
+ * wild copy most `pages` documents have their *entire* history older than 30
115
+ * days, so age alone would leave them with a single row; the floor keeps a
116
+ * usable tail. Only non-snapshot rows count toward it (the admin hides the
117
+ * others), the protected rows count toward it, and the rows held back to reach
118
+ * it are the newest of the deletable ones.
119
+ */ export function planVersionDeletions(candidates, cutoff, minVersions = 0) {
120
+ if (candidates.length <= 1) {
121
+ return [];
122
+ }
123
+ const protectedIds = collectProtectedIds(candidates);
124
+ const cutoffMs = cutoff.getTime();
125
+ const deletable = sortNewestFirst(candidates).filter((candidate)=>{
126
+ if (protectedIds.has(candidate.id)) {
127
+ return false;
128
+ }
129
+ const updatedAtMs = new Date(candidate.updatedAt).getTime();
130
+ // An unparseable timestamp must not read as "infinitely old".
131
+ return Number.isFinite(updatedAtMs) && updatedAtMs < cutoffMs;
132
+ });
133
+ const visibleTotal = candidates.filter((candidate)=>!candidate.snapshot).length;
134
+ const visibleDeletable = deletable.filter((candidate)=>!candidate.snapshot).length;
135
+ let shortfall = Math.max(0, minVersions - (visibleTotal - visibleDeletable));
136
+ // `deletable` is newest-first, so walking from the head holds back the
137
+ // newest rows — and only visible ones count against the floor.
138
+ const doomed = deletable.filter((candidate)=>{
139
+ if (shortfall > 0 && !candidate.snapshot) {
140
+ shortfall--;
141
+ return false;
142
+ }
143
+ return true;
144
+ });
145
+ return doomed.map((candidate)=>candidate.id).reverse();
146
+ }
147
+ /** `now` minus `days`, as the exclusive upper bound on a deletable row's age. */ export function retentionCutoff(days, now = new Date()) {
148
+ const cutoff = new Date(now.getTime());
149
+ cutoff.setDate(cutoff.getDate() - days);
150
+ return cutoff;
151
+ }
152
+ /** Splits ids into `IN (...)`-sized chunks. */ export function chunkIds(ids, size) {
153
+ const chunks = [];
154
+ for(let index = 0; index < ids.length; index += size){
155
+ chunks.push(ids.slice(index, index + size));
156
+ }
157
+ return chunks;
158
+ }
@@ -0,0 +1,25 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ export interface RunLockArgs {
3
+ now?: Date;
4
+ ownJob: unknown;
5
+ req: PayloadRequest;
6
+ /** Age past which a `processing` job is treated as debris. */
7
+ ttlMs: number;
8
+ }
9
+ /**
10
+ * True when another janitor job is genuinely still running.
11
+ *
12
+ * Two janitors at once would split the run budget between them and race each
13
+ * other for the dedup cursor, with the loser's position overwriting the
14
+ * winner's — easy to hit by queueing one by hand while the schedule fires.
15
+ * Cheaper to skip the second than to make the pass reentrant.
16
+ *
17
+ * The lock is **time-bounded**, and that is the important part. Payload sets
18
+ * `processing` when a job starts and never clears it if the process dies —
19
+ * nothing in its queue code sweeps abandoned jobs — so an unbounded lock turns
20
+ * one OOM into a janitor that never runs again. A `processing` row older than
21
+ * `ttlMs` is therefore ignored, loudly.
22
+ *
23
+ * Never throws: if the jobs collection cannot be read, the run proceeds.
24
+ */
25
+ export declare function anotherJanitorIsRunning({ now, ownJob, req, ttlMs, }: RunLockArgs): Promise<boolean>;
@@ -0,0 +1,105 @@
1
+ import { JANITOR_TASK_SLUG, JOBS_COLLECTION_SLUG, LOG_PREFIX } from '../defaults.js';
2
+ function idOf(value) {
3
+ if (typeof value === 'number' || typeof value === 'string') {
4
+ return value;
5
+ }
6
+ if (typeof value === 'object' && value !== null && 'id' in value) {
7
+ const { id } = value;
8
+ return typeof id === 'number' || typeof id === 'string' ? id : undefined;
9
+ }
10
+ return undefined;
11
+ }
12
+ /** Most recent sign of life on a job row. */ function lastSeen(job) {
13
+ if (typeof job !== 'object' || job === null) {
14
+ return Number.NaN;
15
+ }
16
+ const record = job;
17
+ for (const value of [
18
+ record.updatedAt,
19
+ record.createdAt
20
+ ]){
21
+ if (typeof value === 'string' || value instanceof Date) {
22
+ const time = new Date(value).getTime();
23
+ if (Number.isFinite(time)) {
24
+ return time;
25
+ }
26
+ }
27
+ }
28
+ return Number.NaN;
29
+ }
30
+ /**
31
+ * True when another janitor job is genuinely still running.
32
+ *
33
+ * Two janitors at once would split the run budget between them and race each
34
+ * other for the dedup cursor, with the loser's position overwriting the
35
+ * winner's — easy to hit by queueing one by hand while the schedule fires.
36
+ * Cheaper to skip the second than to make the pass reentrant.
37
+ *
38
+ * The lock is **time-bounded**, and that is the important part. Payload sets
39
+ * `processing` when a job starts and never clears it if the process dies —
40
+ * nothing in its queue code sweeps abandoned jobs — so an unbounded lock turns
41
+ * one OOM into a janitor that never runs again. A `processing` row older than
42
+ * `ttlMs` is therefore ignored, loudly.
43
+ *
44
+ * Never throws: if the jobs collection cannot be read, the run proceeds.
45
+ */ export async function anotherJanitorIsRunning({ now = new Date(), ownJob, req, ttlMs }) {
46
+ const ownId = idOf(ownJob);
47
+ try {
48
+ const { docs } = await req.payload.find({
49
+ collection: JOBS_COLLECTION_SLUG,
50
+ depth: 0,
51
+ limit: 10,
52
+ overrideAccess: true,
53
+ pagination: false,
54
+ select: {
55
+ createdAt: true,
56
+ updatedAt: true
57
+ },
58
+ where: {
59
+ and: [
60
+ {
61
+ taskSlug: {
62
+ equals: JANITOR_TASK_SLUG
63
+ }
64
+ },
65
+ {
66
+ processing: {
67
+ equals: true
68
+ }
69
+ },
70
+ ...ownId === undefined ? [] : [
71
+ {
72
+ id: {
73
+ not_equals: ownId
74
+ }
75
+ }
76
+ ]
77
+ ]
78
+ }
79
+ });
80
+ if (docs.length === 0) {
81
+ return false;
82
+ }
83
+ const staleBefore = now.getTime() - ttlMs;
84
+ // A row with no readable timestamp is treated as live: refusing to run is
85
+ // recoverable, two concurrent janitors are messier.
86
+ const live = docs.filter((job)=>{
87
+ const seen = lastSeen(job);
88
+ return !Number.isFinite(seen) || seen > staleBefore;
89
+ });
90
+ const stale = docs.length - live.length;
91
+ if (stale > 0) {
92
+ req.payload.logger.warn(`${LOG_PREFIX} Ignoring ${stale} \`${JANITOR_TASK_SLUG}\` job(s) still marked ` + `\`processing\` after ${Math.round(ttlMs / 60_000)} minutes — Payload never clears ` + 'that flag when a run dies, so they are treated as debris from a crash.');
93
+ }
94
+ if (live.length === 0) {
95
+ return false;
96
+ }
97
+ req.payload.logger.warn(`${LOG_PREFIX} Another \`${JANITOR_TASK_SLUG}\` job is already processing; skipping this ` + 'run so the two do not split the budget or fight over the dedup cursor.');
98
+ return true;
99
+ } catch (error) {
100
+ req.payload.logger.warn({
101
+ err: error
102
+ }, `${LOG_PREFIX} Could not check for a concurrent janitor run; proceeding.`);
103
+ return false;
104
+ }
105
+ }
@@ -0,0 +1,37 @@
1
+ import type { VersionBody } from '../types.js';
2
+ /**
3
+ * Keys stripped from the *top level* of a version body before comparing.
4
+ *
5
+ * - `createdAt` / `updatedAt` — Payload backfills these into the body from the
6
+ * version row (see `getLatestGlobalVersion`), and the document row's
7
+ * `updatedAt` is bumped on every save. They differ between two consecutive
8
+ * saves even when nothing the editor typed changed.
9
+ * - `id` — the parent document id, identical for both rows by construction;
10
+ * stripping it keeps the comparison about content only.
11
+ *
12
+ * Nothing else is stripped. In particular:
13
+ *
14
+ * - `_status` stays in. The hook refuses to compare across differing statuses
15
+ * before it gets here, so leaving `_status` in the body is a second line of
16
+ * defence: a publish that follows a byte-identical draft keeps both rows.
17
+ * - Nested `id` / `updatedAt` / `createdAt` (array rows, blocks, a relationship
18
+ * embedded at depth > 0) are left alone. The comparison runs on the
19
+ * assembled `version` object `findVersions` returns at depth 0, where blocks
20
+ * arrive as nested arrays with their own stable row ids and relationships
21
+ * are plain ids — there are no adapter-side `_path` columns to see, so
22
+ * nothing there is treated as noise.
23
+ */
24
+ export declare const NOISE_KEYS: readonly string[];
25
+ /** A copy of the body without the top-level noise keys. */
26
+ export declare function normalizeVersionBody(body: VersionBody): VersionBody;
27
+ /**
28
+ * Structural equality for version bodies. Dates are compared by instant so a
29
+ * `Date` and its ISO string do not read as different; everything else is
30
+ * compared by value, recursively.
31
+ */
32
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
33
+ /**
34
+ * True when two version bodies carry the same content once the documented
35
+ * noise keys are removed.
36
+ */
37
+ export declare function versionBodiesMatch(a: VersionBody | undefined, b: VersionBody | undefined): boolean;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Keys stripped from the *top level* of a version body before comparing.
3
+ *
4
+ * - `createdAt` / `updatedAt` — Payload backfills these into the body from the
5
+ * version row (see `getLatestGlobalVersion`), and the document row's
6
+ * `updatedAt` is bumped on every save. They differ between two consecutive
7
+ * saves even when nothing the editor typed changed.
8
+ * - `id` — the parent document id, identical for both rows by construction;
9
+ * stripping it keeps the comparison about content only.
10
+ *
11
+ * Nothing else is stripped. In particular:
12
+ *
13
+ * - `_status` stays in. The hook refuses to compare across differing statuses
14
+ * before it gets here, so leaving `_status` in the body is a second line of
15
+ * defence: a publish that follows a byte-identical draft keeps both rows.
16
+ * - Nested `id` / `updatedAt` / `createdAt` (array rows, blocks, a relationship
17
+ * embedded at depth > 0) are left alone. The comparison runs on the
18
+ * assembled `version` object `findVersions` returns at depth 0, where blocks
19
+ * arrive as nested arrays with their own stable row ids and relationships
20
+ * are plain ids — there are no adapter-side `_path` columns to see, so
21
+ * nothing there is treated as noise.
22
+ */ export const NOISE_KEYS = [
23
+ 'createdAt',
24
+ 'id',
25
+ 'updatedAt'
26
+ ];
27
+ /** A copy of the body without the top-level noise keys. */ export function normalizeVersionBody(body) {
28
+ const normalized = {};
29
+ for (const [key, value] of Object.entries(body)){
30
+ if (NOISE_KEYS.includes(key)) {
31
+ continue;
32
+ }
33
+ normalized[key] = value;
34
+ }
35
+ return normalized;
36
+ }
37
+ function isPlainObject(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ }
40
+ /**
41
+ * Keys with an `undefined` value are treated as absent: a JSON column
42
+ * round-trips a missing key and an explicit `undefined` identically, so
43
+ * counting them as a difference would suppress every legitimate dedup.
44
+ */ function definedKeys(value) {
45
+ return Object.keys(value).filter((key)=>value[key] !== undefined);
46
+ }
47
+ /**
48
+ * Structural equality for version bodies. Dates are compared by instant so a
49
+ * `Date` and its ISO string do not read as different; everything else is
50
+ * compared by value, recursively.
51
+ */ export function deepEqual(a, b) {
52
+ if (a === b) {
53
+ return true;
54
+ }
55
+ if (a instanceof Date || b instanceof Date) {
56
+ const aTime = a instanceof Date ? a.getTime() : new Date(String(a)).getTime();
57
+ const bTime = b instanceof Date ? b.getTime() : new Date(String(b)).getTime();
58
+ return Number.isFinite(aTime) && Number.isFinite(bTime) && aTime === bTime;
59
+ }
60
+ if (Array.isArray(a) || Array.isArray(b)) {
61
+ if (!(Array.isArray(a) && Array.isArray(b)) || a.length !== b.length) {
62
+ return false;
63
+ }
64
+ return a.every((item, index)=>deepEqual(item, b[index]));
65
+ }
66
+ if (isPlainObject(a) && isPlainObject(b)) {
67
+ const aKeys = definedKeys(a).sort();
68
+ const bKeys = definedKeys(b).sort();
69
+ if (aKeys.length !== bKeys.length || aKeys.some((key, index)=>key !== bKeys[index])) {
70
+ return false;
71
+ }
72
+ return aKeys.every((key)=>deepEqual(a[key], b[key]));
73
+ }
74
+ // Mixed primitive types, functions, symbols: only reference equality would
75
+ // have matched, and that was checked first.
76
+ return false;
77
+ }
78
+ /**
79
+ * True when two version bodies carry the same content once the documented
80
+ * noise keys are removed.
81
+ */ export function versionBodiesMatch(a, b) {
82
+ if (!(a && b)) {
83
+ return false;
84
+ }
85
+ return deepEqual(normalizeVersionBody(a), normalizeVersionBody(b));
86
+ }
@@ -0,0 +1,55 @@
1
+ import type { Payload } from 'payload';
2
+ /**
3
+ * Slug of the global Payload injects into the config as soon as any task or
4
+ * workflow declares a `schedule` (`jobs.scheduling` is flipped on during
5
+ * config sanitization). Its table is `payload_jobs_stats`.
6
+ */
7
+ export declare const JOB_STATS_GLOBAL_SLUG = "payload-jobs-stats";
8
+ /**
9
+ * Startup probe for the jobs-stats table. Logs an error and resolves `false`
10
+ * when the global cannot be read; never throws and never creates tables.
11
+ */
12
+ export declare function checkJobStatsTable(payload: Payload): Promise<boolean>;
13
+ /**
14
+ * Probe for the `payload_jobs.meta` column. Selecting the field is enough — the
15
+ * adapter names the column in the query, so a missing one throws. No raw SQL.
16
+ */
17
+ export declare function checkJobsMetaColumn(payload: Payload): Promise<boolean>;
18
+ /**
19
+ * Startup probe for the plugin's own state table. Logs an error and resolves
20
+ * `false` when the global cannot be read; never throws.
21
+ */
22
+ export declare function checkStateGlobal(payload: Payload): Promise<boolean>;
23
+ /** The two enums Payload derives from the registered task slugs on Postgres. */
24
+ export declare const TASK_SLUG_ENUMS: readonly ["enum_payload_jobs_task_slug", "enum_payload_jobs_log_task_slug"];
25
+ /**
26
+ * Verifies the janitor's slug is a member of both jobs task-slug enums.
27
+ *
28
+ * Postgres only, and only when the adapter exposes a pool — every other
29
+ * adapter stores the slug as text, and an adapter this cannot inspect is left
30
+ * alone rather than warned about. Logs an error and resolves `false` when the
31
+ * slug is missing; never throws.
32
+ */
33
+ export declare function checkTaskSlugEnum(payload: Payload): Promise<boolean>;
34
+ /**
35
+ * Warns when nothing in `jobs.autoRun` drains a queue the plugin schedules onto.
36
+ * `handleSchedules` silently skips every queue the running autoRun entry does
37
+ * not name (unless it sets `allQueues`), so a schedule on an undrained queue
38
+ * never fires and produces no error of its own.
39
+ *
40
+ * A warning, not an error: the consumer may instead run a dedicated worker with
41
+ * `payload jobs:run --handle-schedules`, which this cannot see. Returns false
42
+ * when a warning was emitted.
43
+ */
44
+ export declare function checkScheduleIsDrained(payload: Payload, scheduledQueues: string[]): boolean;
45
+ /**
46
+ * The boot-time checks that only matter once a schedule exists. The state
47
+ * global is checked separately, because a manually queued janitor needs it
48
+ * just as much. Logs; never throws.
49
+ */
50
+ export declare function runSchedulingStartupChecks(payload: Payload, scheduledQueues: string[]): Promise<void>;
51
+ /**
52
+ * One-liner for `janitor: { schedule: [] }` — the task is registered but
53
+ * nothing will ever queue it, which is easy to reach by accident.
54
+ */
55
+ export declare function warnScheduleDisabled(payload: Payload): void;