@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,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;
|