@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,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
+ }
@@ -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[][];