@purposeinplay/payload-version-retention 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/defaults.d.ts +93 -0
- package/dist/defaults.js +74 -0
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.js +1 -0
- package/dist/hooks/dedup-versions.d.ts +28 -0
- package/dist/hooks/dedup-versions.js +123 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lib/dedup-cursor.d.ts +34 -0
- package/dist/lib/dedup-cursor.js +94 -0
- package/dist/lib/entities.d.ts +43 -0
- package/dist/lib/entities.js +98 -0
- package/dist/lib/retention-plan.d.ts +72 -0
- package/dist/lib/retention-plan.js +158 -0
- package/dist/lib/run-lock.d.ts +25 -0
- package/dist/lib/run-lock.js +105 -0
- package/dist/lib/snapshot-compare.d.ts +37 -0
- package/dist/lib/snapshot-compare.js +86 -0
- package/dist/lib/startup-checks.d.ts +55 -0
- package/dist/lib/startup-checks.js +225 -0
- package/dist/lib/state.d.ts +27 -0
- package/dist/lib/state.js +87 -0
- package/dist/lib/sweep.d.ts +72 -0
- package/dist/lib/sweep.js +799 -0
- package/dist/lib/version-row.d.ts +19 -0
- package/dist/lib/version-row.js +77 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +91 -0
- package/dist/tasks/janitor.d.ts +8 -0
- package/dist/tasks/janitor.js +116 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.js +4 -0
- package/package.json +56 -0
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Default retention window in days for version rows. */ export const DEFAULT_RETENTION_DAYS = 30;
|
|
2
|
+
/**
|
|
3
|
+
* Floor on version rows left behind per document, whatever their age.
|
|
4
|
+
*
|
|
5
|
+
* Measured on the wild copy: 75 of 96 `pages` documents have their entire
|
|
6
|
+
* version history older than 30 days and 51 sit at the 25-version cap. Age
|
|
7
|
+
* plus the protected rows alone would leave most of them with a single
|
|
8
|
+
* version after one night, so a floor keeps a usable tail of recent history.
|
|
9
|
+
*/ export const DEFAULT_MIN_VERSIONS_PER_DOCUMENT = 5;
|
|
10
|
+
/** Default cron schedule for the janitor task: daily at 03:00. */ export const DEFAULT_JANITOR_CRON = '0 3 * * *';
|
|
11
|
+
/**
|
|
12
|
+
* Queue the janitor is scheduled onto. `handleSchedules` skips queues the
|
|
13
|
+
* running autoRun config does not drain, so this must be a queue the consumer
|
|
14
|
+
* actually drains — `default` is the one every consumer has.
|
|
15
|
+
*/ export const DEFAULT_JANITOR_QUEUE = 'default';
|
|
16
|
+
/**
|
|
17
|
+
* How long a `processing` janitor job is believed before the lock it holds is
|
|
18
|
+
* treated as debris.
|
|
19
|
+
*
|
|
20
|
+
* Payload never resets `processing` after a crash — nothing in its queue code
|
|
21
|
+
* sweeps abandoned jobs — so an OOM mid-run would otherwise wedge every future
|
|
22
|
+
* run behind one warning, for good.
|
|
23
|
+
*/ export const DEFAULT_RUN_LOCK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
24
|
+
/** Retries so one transient error does not skip the day's retention pass. */ export const DEFAULT_JANITOR_RETRIES = 3;
|
|
25
|
+
/** Slug of the janitor task, as registered in `jobs.tasks`. */ export const JANITOR_TASK_SLUG = 'version-retention-janitor';
|
|
26
|
+
/**
|
|
27
|
+
* Slug of the tiny global this plugin owns to persist its dedup cursor across
|
|
28
|
+
* runs. Its table is `version_retention_state`.
|
|
29
|
+
*
|
|
30
|
+
* A store of our own is not a nicety: Payload's `deleteJobOnComplete` defaults
|
|
31
|
+
* to `true` and hard-deletes the job row the moment it finishes, so a cursor
|
|
32
|
+
* kept only in a job's output is gone before the next run can read it.
|
|
33
|
+
*/ export const STATE_GLOBAL_SLUG = 'version-retention-state';
|
|
34
|
+
/**
|
|
35
|
+
* Slug of Payload's jobs collection. The same sanitize branch that injects the
|
|
36
|
+
* jobs-stats global adds a `meta` JSON field to this collection (`jobs.stats`),
|
|
37
|
+
* so its column is part of the very same migration.
|
|
38
|
+
*/ export const JOBS_COLLECTION_SLUG = 'payload-jobs';
|
|
39
|
+
/** Ceiling on version rows deleted per invocation, across every entity. */ export const DEFAULT_MAX_DELETIONS_PER_RUN = 5_000;
|
|
40
|
+
/** Ceiling on documents examined per invocation, across every entity. */ export const DEFAULT_MAX_DOCUMENTS_PER_RUN = 20_000;
|
|
41
|
+
/**
|
|
42
|
+
* Rows per page when walking the stale-version index to discover which parent
|
|
43
|
+
* documents still hold deletable history.
|
|
44
|
+
*/ export const PARENT_PAGE_SIZE = 500;
|
|
45
|
+
/**
|
|
46
|
+
* Newest-first window of version rows inspected per document. Must exceed the
|
|
47
|
+
* collection's `maxPerDoc` (25 in the wild consumer, 100 by Payload default)
|
|
48
|
+
* or the oldest rows fall outside the window and are never reached.
|
|
49
|
+
*/ export const MAX_VERSIONS_PER_DOCUMENT = 1_000;
|
|
50
|
+
/**
|
|
51
|
+
* Ids per `deleteVersions` call.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately small: drizzle's `deleteVersions` runs a `findMany` with **no
|
|
54
|
+
* select** over the chunk before deleting, so every row in the chunk is
|
|
55
|
+
* materialised as a full version body first. 25 bounds that peak at 25 bodies
|
|
56
|
+
* — see the README's "Memory" note.
|
|
57
|
+
*/ export const DELETE_CHUNK_SIZE = 25;
|
|
58
|
+
/**
|
|
59
|
+
* Rows per page when the nested-select fallback has to read full bodies. Keeps
|
|
60
|
+
* the peak at 50 bodies instead of `MAX_VERSIONS_PER_DOCUMENT` of them; each
|
|
61
|
+
* page is reduced to the retention columns before the next is fetched.
|
|
62
|
+
*/ export const FULL_BODY_PAGE_SIZE = 50;
|
|
63
|
+
/** Dedup runs inside the janitor sweep by default. */ export const DEFAULT_DEDUP_MODE = 'sweep';
|
|
64
|
+
/**
|
|
65
|
+
* Ceiling on version bodies the sweep reads for dedup in one invocation.
|
|
66
|
+
* Bodies are the expensive thing in these tables, so this is a separate
|
|
67
|
+
* budget from the deletion cap.
|
|
68
|
+
*/ export const DEFAULT_MAX_DEDUP_BODIES_PER_RUN = 2_000;
|
|
69
|
+
/**
|
|
70
|
+
* Request-context key the ai-translate plugin sets while a translation run is
|
|
71
|
+
* in flight. That plugin collapses a whole run into a single version row of
|
|
72
|
+
* its own, so the dedup hook must keep its hands off those writes.
|
|
73
|
+
*/ export const AI_TRANSLATE_RUN_CONTEXT_KEY = 'aiTranslateRunId';
|
|
74
|
+
/** Log prefix shared by every message this plugin emits. */ export const LOG_PREFIX = '[version-retention]';
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { versionRetentionPlugin } from '../plugin.js';
|
|
2
|
+
export type { ResolvedEntity, VersionBody, VersionRetentionDedupCursor, VersionRetentionDedupMode, VersionRetentionDedupOptions, VersionRetentionJanitorInput, VersionRetentionJanitorOptions, VersionRetentionJanitorOutput, VersionRetentionOverride, VersionRetentionPluginOptions, VersionRetentionScheduleConfig, VersionRow, } from '../types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { versionRetentionPlugin } from '../plugin.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CollectionAfterChangeHook } from 'payload';
|
|
2
|
+
/**
|
|
3
|
+
* **Experimental — `dedup: { mode: 'onSave' }` only, off by default.**
|
|
4
|
+
*
|
|
5
|
+
* Collapses a version row that is byte-identical to the one before it, at
|
|
6
|
+
* write time. Runs `afterChange`, which Payload invokes after `saveVersion`,
|
|
7
|
+
* so the row this write produced already exists and is the newest. When it
|
|
8
|
+
* matches its predecessor, the predecessor is re-flagged `latest: true` and
|
|
9
|
+
* the newer row is deleted — keeping the earlier `createdAt` (the moment the
|
|
10
|
+
* content actually first appeared) and the earlier `updatedAt` (so a no-op
|
|
11
|
+
* save does not silently reset the retention window).
|
|
12
|
+
*
|
|
13
|
+
* Why it is not the default, and why `mode: 'sweep'` is:
|
|
14
|
+
*
|
|
15
|
+
* - it runs **inside the editor's save transaction**. Catching an error here
|
|
16
|
+
* does not make the hook non-fatal: in Postgres a failed statement aborts
|
|
17
|
+
* the surrounding transaction (25P02), so every later statement in the save
|
|
18
|
+
* fails and the editor sees a cryptic error from an unrelated place;
|
|
19
|
+
* - it reads two full version bodies on every single save of every tracked
|
|
20
|
+
* document, on the write path;
|
|
21
|
+
* - a mistake in the re-flag payload rewrites the survivor row through the
|
|
22
|
+
* full `upsertRow` path — delete + reinsert of its `_locales`, block and
|
|
23
|
+
* relationship rows — inside that same transaction.
|
|
24
|
+
*
|
|
25
|
+
* The sweep does the same work off the write path, where a failure costs a
|
|
26
|
+
* log line instead of an editor's save.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createDedupVersionsHook(slug: string): CollectionAfterChangeHook;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { AI_TRANSLATE_RUN_CONTEXT_KEY, LOG_PREFIX } from '../defaults.js';
|
|
2
|
+
import { sameStatus } from '../lib/retention-plan.js';
|
|
3
|
+
import { versionBodiesMatch } from '../lib/snapshot-compare.js';
|
|
4
|
+
import { toVersionRows } from '../lib/version-row.js';
|
|
5
|
+
/**
|
|
6
|
+
* A version row this hook refuses to touch.
|
|
7
|
+
*
|
|
8
|
+
* Autosave rows carry an `autosave` column that `UpdateVersionArgs` has no
|
|
9
|
+
* slot for, so rewriting one to restore `latest` would clear the flag. They
|
|
10
|
+
* are also Payload's own churn-management path and already collapse into a
|
|
11
|
+
* single row per editing session.
|
|
12
|
+
*/ function isAutosave(row) {
|
|
13
|
+
return row.autosave === true;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* **Experimental — `dedup: { mode: 'onSave' }` only, off by default.**
|
|
17
|
+
*
|
|
18
|
+
* Collapses a version row that is byte-identical to the one before it, at
|
|
19
|
+
* write time. Runs `afterChange`, which Payload invokes after `saveVersion`,
|
|
20
|
+
* so the row this write produced already exists and is the newest. When it
|
|
21
|
+
* matches its predecessor, the predecessor is re-flagged `latest: true` and
|
|
22
|
+
* the newer row is deleted — keeping the earlier `createdAt` (the moment the
|
|
23
|
+
* content actually first appeared) and the earlier `updatedAt` (so a no-op
|
|
24
|
+
* save does not silently reset the retention window).
|
|
25
|
+
*
|
|
26
|
+
* Why it is not the default, and why `mode: 'sweep'` is:
|
|
27
|
+
*
|
|
28
|
+
* - it runs **inside the editor's save transaction**. Catching an error here
|
|
29
|
+
* does not make the hook non-fatal: in Postgres a failed statement aborts
|
|
30
|
+
* the surrounding transaction (25P02), so every later statement in the save
|
|
31
|
+
* fails and the editor sees a cryptic error from an unrelated place;
|
|
32
|
+
* - it reads two full version bodies on every single save of every tracked
|
|
33
|
+
* document, on the write path;
|
|
34
|
+
* - a mistake in the re-flag payload rewrites the survivor row through the
|
|
35
|
+
* full `upsertRow` path — delete + reinsert of its `_locales`, block and
|
|
36
|
+
* relationship rows — inside that same transaction.
|
|
37
|
+
*
|
|
38
|
+
* The sweep does the same work off the write path, where a failure costs a
|
|
39
|
+
* log line instead of an editor's save.
|
|
40
|
+
*/ export function createDedupVersionsHook(slug) {
|
|
41
|
+
return async ({ doc, req })=>{
|
|
42
|
+
// ai-translate collapses a whole translation run into one version row of
|
|
43
|
+
// its own; a second opinion from here would fight that bookkeeping.
|
|
44
|
+
if (req.context?.[AI_TRANSLATE_RUN_CONTEXT_KEY] !== undefined) {
|
|
45
|
+
return doc;
|
|
46
|
+
}
|
|
47
|
+
const parentId = doc?.id;
|
|
48
|
+
if (typeof parentId !== 'number' && typeof parentId !== 'string') {
|
|
49
|
+
return doc;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const { docs } = await req.payload.db.findVersions({
|
|
53
|
+
collection: slug,
|
|
54
|
+
limit: 2,
|
|
55
|
+
pagination: false,
|
|
56
|
+
req,
|
|
57
|
+
sort: '-updatedAt',
|
|
58
|
+
where: {
|
|
59
|
+
parent: {
|
|
60
|
+
equals: parentId
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const [newer, older] = toVersionRows(docs);
|
|
65
|
+
if (!(newer && older)) {
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
if (isAutosave(newer) || isAutosave(older)) {
|
|
69
|
+
return doc;
|
|
70
|
+
}
|
|
71
|
+
// A publish that follows a byte-identical draft must keep both rows:
|
|
72
|
+
// they are the same content in two different states, and dropping one
|
|
73
|
+
// loses either the draft or the publish.
|
|
74
|
+
if (!sameStatus(newer.version, older.version)) {
|
|
75
|
+
return doc;
|
|
76
|
+
}
|
|
77
|
+
if (!versionBodiesMatch(newer.version, older.version)) {
|
|
78
|
+
return doc;
|
|
79
|
+
}
|
|
80
|
+
// `createVersion` clears `latest` on every other row when it inserts,
|
|
81
|
+
// so deleting the row it just flagged would leave the document with
|
|
82
|
+
// none — and `getLatestCollectionVersion` resolves the admin view from
|
|
83
|
+
// exactly that flag.
|
|
84
|
+
//
|
|
85
|
+
// Order matters: flag the survivor FIRST, delete second. Both statements
|
|
86
|
+
// run on the write's `req`, so they share its transaction and normally
|
|
87
|
+
// commit together; if the pair is ever torn (a non-transactional req, a
|
|
88
|
+
// connection lost between the two), the failure mode is two rows briefly
|
|
89
|
+
// flagged `latest` — which resolves to the newest of two identical
|
|
90
|
+
// bodies — rather than zero, which would blank the document in admin.
|
|
91
|
+
if (newer.latest === true) {
|
|
92
|
+
// ONLY `latest`. Drizzle's `shouldUseOptimizedUpsertRow` then takes
|
|
93
|
+
// the plain `UPDATE ... SET latest` path; adding `version`, `parent`
|
|
94
|
+
// or `createdAt` would rewrite the entire row, deleting and
|
|
95
|
+
// reinserting its `_locales`, block and relationship rows inside the
|
|
96
|
+
// editor's own transaction.
|
|
97
|
+
await req.payload.db.updateVersion({
|
|
98
|
+
collection: slug,
|
|
99
|
+
id: older.id,
|
|
100
|
+
req,
|
|
101
|
+
versionData: {
|
|
102
|
+
latest: true
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
await req.payload.db.deleteVersions({
|
|
107
|
+
collection: slug,
|
|
108
|
+
req,
|
|
109
|
+
where: {
|
|
110
|
+
id: {
|
|
111
|
+
equals: newer.id
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
req.payload.logger.debug(`${LOG_PREFIX} ${slug} ${String(parentId)}: collapsed a version identical to its ` + 'predecessor');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
req.payload.logger.error({
|
|
118
|
+
err: error
|
|
119
|
+
}, `${LOG_PREFIX} ${slug} ${String(parentId)}: identical-snapshot dedup failed; the ` + 'duplicate version row was left in place.');
|
|
120
|
+
}
|
|
121
|
+
return doc;
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { versionRetentionPlugin } from './plugin.js';
|
|
2
|
+
export type { ResolvedEntity, VersionBody, VersionRetentionDedupCursor, VersionRetentionDedupMode, VersionRetentionDedupOptions, VersionRetentionJanitorInput, VersionRetentionJanitorOptions, VersionRetentionJanitorOutput, VersionRetentionOverride, VersionRetentionPluginOptions, VersionRetentionScheduleConfig, VersionRow, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { versionRetentionPlugin } from './plugin.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PayloadRequest } from 'payload';
|
|
2
|
+
import type { VersionRetentionDedupCursor, VersionRetentionJanitorInput } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Narrows a stored value to a dedup cursor, or `undefined` when it is not one.
|
|
5
|
+
*
|
|
6
|
+
* Anything unrecognised — including the `slug -> parentId` map an older
|
|
7
|
+
* version of this plugin wrote — restarts the cycle from the beginning, which
|
|
8
|
+
* is always safe.
|
|
9
|
+
*/
|
|
10
|
+
export declare function toDedupCursor(value: unknown): undefined | VersionRetentionDedupCursor;
|
|
11
|
+
/** Digs the janitor's `dedupCursor` out of a finished job document. */
|
|
12
|
+
export declare function readCursorFromJob(job: unknown): undefined | VersionRetentionDedupCursor;
|
|
13
|
+
/**
|
|
14
|
+
* **Legacy fallback only.** The dedup resume point left in the last completed
|
|
15
|
+
* janitor job's output.
|
|
16
|
+
*
|
|
17
|
+
* The cursor lives in the plugin's own state global now, because Payload's
|
|
18
|
+
* `deleteJobOnComplete` defaults to `true` and hard deletes the job row the
|
|
19
|
+
* moment it finishes — so under the default configuration there is usually
|
|
20
|
+
* nothing here to read. It is still consulted for consumers who turned job
|
|
21
|
+
* deletion off and have a cursor from an earlier version of this plugin.
|
|
22
|
+
*
|
|
23
|
+
* Never throws: a consumer whose jobs collection is shaped differently, or who
|
|
24
|
+
* has pruned its history, simply starts from the beginning.
|
|
25
|
+
*/
|
|
26
|
+
export declare function readPreviousDedupCursor(req: PayloadRequest): Promise<undefined | VersionRetentionDedupCursor>;
|
|
27
|
+
/**
|
|
28
|
+
* Narrows a task's `input` to the shape the janitor understands.
|
|
29
|
+
*
|
|
30
|
+
* An absent or empty cursor comes back as `undefined` rather than `{}`, so the
|
|
31
|
+
* handler can tell "no cursor was supplied" (a manual `payload.jobs.queue()`
|
|
32
|
+
* passes `input: {}`) from "the previous run finished its walk".
|
|
33
|
+
*/
|
|
34
|
+
export declare function toJanitorInput(input: unknown): VersionRetentionJanitorInput;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { JANITOR_TASK_SLUG, JOBS_COLLECTION_SLUG, LOG_PREFIX } from '../defaults.js';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Narrows a stored value to a dedup cursor, or `undefined` when it is not one.
|
|
7
|
+
*
|
|
8
|
+
* Anything unrecognised — including the `slug -> parentId` map an older
|
|
9
|
+
* version of this plugin wrote — restarts the cycle from the beginning, which
|
|
10
|
+
* is always safe.
|
|
11
|
+
*/ export function toDedupCursor(value) {
|
|
12
|
+
if (!isRecord(value) || typeof value.slug !== 'string' || value.slug.length === 0) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const { parentId } = value;
|
|
16
|
+
return typeof parentId === 'number' || typeof parentId === 'string' ? {
|
|
17
|
+
parentId,
|
|
18
|
+
slug: value.slug
|
|
19
|
+
} : {
|
|
20
|
+
slug: value.slug
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Digs the janitor's `dedupCursor` out of a finished job document. */ export function readCursorFromJob(job) {
|
|
24
|
+
if (!isRecord(job)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const log = Array.isArray(job.log) ? job.log : [];
|
|
28
|
+
for(let index = log.length - 1; index >= 0; index--){
|
|
29
|
+
const entry = log[index];
|
|
30
|
+
if (isRecord(entry) && entry.taskSlug === JANITOR_TASK_SLUG && isRecord(entry.output)) {
|
|
31
|
+
return toDedupCursor(entry.output.dedupCursor);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* **Legacy fallback only.** The dedup resume point left in the last completed
|
|
38
|
+
* janitor job's output.
|
|
39
|
+
*
|
|
40
|
+
* The cursor lives in the plugin's own state global now, because Payload's
|
|
41
|
+
* `deleteJobOnComplete` defaults to `true` and hard deletes the job row the
|
|
42
|
+
* moment it finishes — so under the default configuration there is usually
|
|
43
|
+
* nothing here to read. It is still consulted for consumers who turned job
|
|
44
|
+
* deletion off and have a cursor from an earlier version of this plugin.
|
|
45
|
+
*
|
|
46
|
+
* Never throws: a consumer whose jobs collection is shaped differently, or who
|
|
47
|
+
* has pruned its history, simply starts from the beginning.
|
|
48
|
+
*/ export async function readPreviousDedupCursor(req) {
|
|
49
|
+
try {
|
|
50
|
+
const { docs } = await req.payload.find({
|
|
51
|
+
collection: JOBS_COLLECTION_SLUG,
|
|
52
|
+
depth: 0,
|
|
53
|
+
limit: 1,
|
|
54
|
+
overrideAccess: true,
|
|
55
|
+
pagination: false,
|
|
56
|
+
sort: '-completedAt',
|
|
57
|
+
where: {
|
|
58
|
+
and: [
|
|
59
|
+
{
|
|
60
|
+
taskSlug: {
|
|
61
|
+
equals: JANITOR_TASK_SLUG
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
completedAt: {
|
|
66
|
+
exists: true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return readCursorFromJob(docs[0]);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
req.payload.logger.warn({
|
|
75
|
+
err: error
|
|
76
|
+
}, `${LOG_PREFIX} Could not read the previous run's dedup cursor; starting from the ` + 'beginning. Dedup still runs, it just re-reads documents it may have already seen.');
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Narrows a task's `input` to the shape the janitor understands.
|
|
82
|
+
*
|
|
83
|
+
* An absent or empty cursor comes back as `undefined` rather than `{}`, so the
|
|
84
|
+
* handler can tell "no cursor was supplied" (a manual `payload.jobs.queue()`
|
|
85
|
+
* passes `input: {}`) from "the previous run finished its walk".
|
|
86
|
+
*/ export function toJanitorInput(input) {
|
|
87
|
+
if (!isRecord(input)) {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
const dedupCursor = toDedupCursor(input.dedupCursor);
|
|
91
|
+
return dedupCursor === undefined ? {} : {
|
|
92
|
+
dedupCursor
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ResolvedEntity, VersionRetentionDedupMode, VersionRetentionPluginOptions } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The shape both the incoming (`Config`) and the sanitized (`payload.config`)
|
|
4
|
+
* entity carry. Payload's own configs are structurally assignable to this;
|
|
5
|
+
* declaring it here keeps the resolver testable without a real config.
|
|
6
|
+
*/
|
|
7
|
+
export interface VersionedEntityConfig {
|
|
8
|
+
slug: string;
|
|
9
|
+
versions?: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* True when the entity keeps versions at all. Incoming configs may carry
|
|
13
|
+
* `versions: true`; sanitize rewrites that to an object and deletes the key
|
|
14
|
+
* when versions are off, so both forms have to be handled.
|
|
15
|
+
*/
|
|
16
|
+
export declare function hasVersionsEnabled(entity: VersionedEntityConfig): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* True when the entity has drafts, which is what puts `_status` into the
|
|
19
|
+
* version body. `versions: true` (the shorthand) means versions without
|
|
20
|
+
* drafts. Mirrors Payload's own `hasDraftsEnabled`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hasDraftsEnabled(entity: VersionedEntityConfig): boolean;
|
|
23
|
+
/** Where dedup runs, with `true`/`false` normalised to a mode. */
|
|
24
|
+
export declare function resolveDedupMode(options: VersionRetentionPluginOptions): VersionRetentionDedupMode;
|
|
25
|
+
/** Ceiling on version bodies the sweep may read for dedup in one run. */
|
|
26
|
+
export declare function resolveMaxDedupBodies(options: VersionRetentionPluginOptions): number;
|
|
27
|
+
/**
|
|
28
|
+
* Every versioned collection the janitor should sweep, with the retention
|
|
29
|
+
* window that applies to it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveVersionedCollections(collections: VersionedEntityConfig[], options: VersionRetentionPluginOptions): ResolvedEntity[];
|
|
32
|
+
/** Every versioned global the janitor should sweep. */
|
|
33
|
+
export declare function resolveVersionedGlobals(globals: VersionedEntityConfig[], options: VersionRetentionPluginOptions): ResolvedEntity[];
|
|
34
|
+
/**
|
|
35
|
+
* Collections that get the experimental on-save dedup hook: versioned,
|
|
36
|
+
* selected, drafts enabled, and not opted out per slug — and only when the
|
|
37
|
+
* consumer asked for `dedup: { mode: 'onSave' }`.
|
|
38
|
+
*
|
|
39
|
+
* Without drafts there is no `_status`, every save is the same kind of row and
|
|
40
|
+
* the janitor's sweep is the right tool — the hook would only add a read to
|
|
41
|
+
* every write.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveOnSaveDedupCollections(collections: VersionedEntityConfig[], options: VersionRetentionPluginOptions): string[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { DEFAULT_DEDUP_MODE, DEFAULT_MAX_DEDUP_BODIES_PER_RUN, DEFAULT_MIN_VERSIONS_PER_DOCUMENT, DEFAULT_RETENTION_DAYS } from '../defaults.js';
|
|
2
|
+
/**
|
|
3
|
+
* True when the entity keeps versions at all. Incoming configs may carry
|
|
4
|
+
* `versions: true`; sanitize rewrites that to an object and deletes the key
|
|
5
|
+
* when versions are off, so both forms have to be handled.
|
|
6
|
+
*/ export function hasVersionsEnabled(entity) {
|
|
7
|
+
return Boolean(entity.versions);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* True when the entity has drafts, which is what puts `_status` into the
|
|
11
|
+
* version body. `versions: true` (the shorthand) means versions without
|
|
12
|
+
* drafts. Mirrors Payload's own `hasDraftsEnabled`.
|
|
13
|
+
*/ export function hasDraftsEnabled(entity) {
|
|
14
|
+
const { versions } = entity;
|
|
15
|
+
if (typeof versions !== 'object' || versions === null) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return Boolean(versions.drafts);
|
|
19
|
+
}
|
|
20
|
+
function applyIncludeExclude(slugs, include, exclude) {
|
|
21
|
+
const included = include ? slugs.filter((slug)=>include.includes(slug)) : slugs;
|
|
22
|
+
if (!exclude?.length) {
|
|
23
|
+
return included;
|
|
24
|
+
}
|
|
25
|
+
return included.filter((slug)=>!exclude.includes(slug));
|
|
26
|
+
}
|
|
27
|
+
function daysFor(slug, options) {
|
|
28
|
+
return options.overrides?.[slug]?.days ?? options.days ?? DEFAULT_RETENTION_DAYS;
|
|
29
|
+
}
|
|
30
|
+
function minVersionsFor(slug, options) {
|
|
31
|
+
return options.overrides?.[slug]?.minVersionsPerDocument ?? options.minVersionsPerDocument ?? DEFAULT_MIN_VERSIONS_PER_DOCUMENT;
|
|
32
|
+
}
|
|
33
|
+
/** Where dedup runs, with `true`/`false` normalised to a mode. */ export function resolveDedupMode(options) {
|
|
34
|
+
const { dedup } = options;
|
|
35
|
+
if (dedup === undefined) {
|
|
36
|
+
return DEFAULT_DEDUP_MODE;
|
|
37
|
+
}
|
|
38
|
+
if (dedup === true) {
|
|
39
|
+
return 'sweep';
|
|
40
|
+
}
|
|
41
|
+
if (dedup === false) {
|
|
42
|
+
return 'off';
|
|
43
|
+
}
|
|
44
|
+
return dedup.mode ?? DEFAULT_DEDUP_MODE;
|
|
45
|
+
}
|
|
46
|
+
/** Ceiling on version bodies the sweep may read for dedup in one run. */ export function resolveMaxDedupBodies(options) {
|
|
47
|
+
const { dedup } = options;
|
|
48
|
+
if (typeof dedup === 'object' && dedup.maxDedupBodiesPerRun !== undefined) {
|
|
49
|
+
return dedup.maxDedupBodiesPerRun;
|
|
50
|
+
}
|
|
51
|
+
return DEFAULT_MAX_DEDUP_BODIES_PER_RUN;
|
|
52
|
+
}
|
|
53
|
+
function dedupFor(slug, options) {
|
|
54
|
+
return resolveDedupMode(options) === 'sweep' && options.overrides?.[slug]?.dedup !== false;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Every versioned collection the janitor should sweep, with the retention
|
|
58
|
+
* window that applies to it.
|
|
59
|
+
*/ export function resolveVersionedCollections(collections, options) {
|
|
60
|
+
const versioned = collections.filter(hasVersionsEnabled);
|
|
61
|
+
const selected = applyIncludeExclude(versioned.map((collection)=>collection.slug), options.collections, options.excludeCollections);
|
|
62
|
+
return versioned.filter((collection)=>selected.includes(collection.slug)).map((collection)=>({
|
|
63
|
+
days: daysFor(collection.slug, options),
|
|
64
|
+
dedup: dedupFor(collection.slug, options),
|
|
65
|
+
hasDrafts: hasDraftsEnabled(collection),
|
|
66
|
+
minVersionsPerDocument: minVersionsFor(collection.slug, options),
|
|
67
|
+
slug: collection.slug
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/** Every versioned global the janitor should sweep. */ export function resolveVersionedGlobals(globals, options) {
|
|
71
|
+
const versioned = globals.filter(hasVersionsEnabled);
|
|
72
|
+
const selected = applyIncludeExclude(versioned.map((global)=>global.slug), options.globals, options.excludeGlobals);
|
|
73
|
+
return versioned.filter((global)=>selected.includes(global.slug)).map((global)=>({
|
|
74
|
+
days: daysFor(global.slug, options),
|
|
75
|
+
dedup: dedupFor(global.slug, options),
|
|
76
|
+
hasDrafts: hasDraftsEnabled(global),
|
|
77
|
+
minVersionsPerDocument: minVersionsFor(global.slug, options),
|
|
78
|
+
slug: global.slug
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Collections that get the experimental on-save dedup hook: versioned,
|
|
83
|
+
* selected, drafts enabled, and not opted out per slug — and only when the
|
|
84
|
+
* consumer asked for `dedup: { mode: 'onSave' }`.
|
|
85
|
+
*
|
|
86
|
+
* Without drafts there is no `_status`, every save is the same kind of row and
|
|
87
|
+
* the janitor's sweep is the right tool — the hook would only add a read to
|
|
88
|
+
* every write.
|
|
89
|
+
*/ export function resolveOnSaveDedupCollections(collections, options) {
|
|
90
|
+
if (resolveDedupMode(options) !== 'onSave') {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const draftsBySlug = new Map(collections.map((collection)=>[
|
|
94
|
+
collection.slug,
|
|
95
|
+
hasDraftsEnabled(collection)
|
|
96
|
+
]));
|
|
97
|
+
return resolveVersionedCollections(collections, options).filter((entity)=>draftsBySlug.get(entity.slug) === true).filter((entity)=>options.overrides?.[entity.slug]?.dedup !== false).map((entity)=>entity.slug);
|
|
98
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RetentionCandidate, VersionBody, VersionRow } from '../types.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
|
+
*/
|
|
7
|
+
export declare const PROTECTED_STATUSES: readonly ["published", "draft"];
|
|
8
|
+
export type ProtectedStatus = (typeof PROTECTED_STATUSES)[number];
|
|
9
|
+
/**
|
|
10
|
+
* The raw `_status` value, whatever shape it has.
|
|
11
|
+
*
|
|
12
|
+
* With `drafts.localizeStatus` Payload writes an **object** keyed by locale
|
|
13
|
+
* (`{ en: 'published', de: 'draft' }`) rather than a string, so this cannot be
|
|
14
|
+
* narrowed to `string` — use `hasStatus` / `sameStatus` instead of comparing
|
|
15
|
+
* the return value directly.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readStatus(version: undefined | VersionBody): unknown;
|
|
18
|
+
/**
|
|
19
|
+
* True when the row carries `status` — as the plain string, or, under
|
|
20
|
+
* `localizeStatus`, for at least one locale.
|
|
21
|
+
*
|
|
22
|
+
* A row published in any locale still holds a publish worth protecting, so
|
|
23
|
+
* "any locale" is the right test for the protected rows.
|
|
24
|
+
*/
|
|
25
|
+
export declare function hasStatus(version: undefined | VersionBody, status: ProtectedStatus): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* True when two rows carry the same `_status`. Under `localizeStatus` that
|
|
28
|
+
* means the same status in every locale, so publishing one locale never looks
|
|
29
|
+
* like a no-op against a draft of another.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sameStatus(a: undefined | VersionBody, b: undefined | VersionBody): boolean;
|
|
32
|
+
/** Narrows a raw version row to the facts the plan is built from. */
|
|
33
|
+
export declare function toCandidate(row: VersionRow): RetentionCandidate;
|
|
34
|
+
/**
|
|
35
|
+
* The rows a retention pass must never delete, whatever their age:
|
|
36
|
+
*
|
|
37
|
+
* - the row flagged `latest: true` — the one `getLatestCollectionVersion`
|
|
38
|
+
* resolves the admin document from;
|
|
39
|
+
* - the newest non-snapshot published row — **the last good publish**, which a
|
|
40
|
+
* count cap happily evicts and this plugin does not;
|
|
41
|
+
* - the newest non-snapshot draft row — the work in progress;
|
|
42
|
+
* - the newest row overall, when none of the above matched (an entity with
|
|
43
|
+
* versions but no drafts has no `_status` at all).
|
|
44
|
+
*
|
|
45
|
+
* Snapshot rows are Payload's pre-publish copies. The admin hides them, so
|
|
46
|
+
* they are never chosen as the protected publish or draft — but a snapshot row
|
|
47
|
+
* that somehow carries `latest` is still protected by the first rule.
|
|
48
|
+
*/
|
|
49
|
+
export declare function collectProtectedIds(candidates: RetentionCandidate[]): Set<number | string>;
|
|
50
|
+
/**
|
|
51
|
+
* Ids of the version rows that are both older than the cutoff and unprotected,
|
|
52
|
+
* minus whatever the floor holds back — **oldest first**.
|
|
53
|
+
*
|
|
54
|
+
* The order is load-bearing: when the caller truncates the plan to fit a
|
|
55
|
+
* per-run deletion budget it takes a prefix, and the oldest rows are the ones
|
|
56
|
+
* that should go first.
|
|
57
|
+
*
|
|
58
|
+
* Age is read from the version row's own `updatedAt`, the same column
|
|
59
|
+
* Payload's `enforceMaxVersions` orders by.
|
|
60
|
+
*
|
|
61
|
+
* `minVersions` is the floor on rows left behind for this document. On the
|
|
62
|
+
* wild copy most `pages` documents have their *entire* history older than 30
|
|
63
|
+
* days, so age alone would leave them with a single row; the floor keeps a
|
|
64
|
+
* usable tail. Only non-snapshot rows count toward it (the admin hides the
|
|
65
|
+
* others), the protected rows count toward it, and the rows held back to reach
|
|
66
|
+
* it are the newest of the deletable ones.
|
|
67
|
+
*/
|
|
68
|
+
export declare function planVersionDeletions(candidates: RetentionCandidate[], cutoff: Date, minVersions?: number): (number | string)[];
|
|
69
|
+
/** `now` minus `days`, as the exclusive upper bound on a deletable row's age. */
|
|
70
|
+
export declare function retentionCutoff(days: number, now?: Date): Date;
|
|
71
|
+
/** Splits ids into `IN (...)`-sized chunks. */
|
|
72
|
+
export declare function chunkIds<T>(ids: T[], size: number): T[][];
|