@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,225 @@
1
+ import { JANITOR_TASK_SLUG, JOBS_COLLECTION_SLUG, LOG_PREFIX, STATE_GLOBAL_SLUG } from '../defaults.js';
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
+ */ export const JOB_STATS_GLOBAL_SLUG = 'payload-jobs-stats';
7
+ /** Queue `handleSchedules` falls back to when an autoRun entry names none. */ const DEFAULT_AUTORUN_QUEUE = 'default';
8
+ const MISSING_TABLE_MESSAGE = [
9
+ `${LOG_PREFIX} Could not read the \`payload-jobs-stats\` global.`,
10
+ `This plugin schedules its \`${JANITOR_TASK_SLUG}\` task, which turns on Payload job`,
11
+ 'scheduling config-wide and requires the `payload_jobs_stats` table',
12
+ '(wild consumers additionally need the `payload_jobs.meta` column).',
13
+ 'Run `payload migrate:create` and `payload migrate` — the plugin adoption and',
14
+ 'that migration must ship in the same release.',
15
+ 'Until the table exists, `handleSchedules` throws on every autoRun tick before',
16
+ '`jobs.run` is reached, which stalls the entire `default` queue — not just this',
17
+ 'plugin. Pass `janitor: false` to opt out of scheduling in the meantime.'
18
+ ].join(' ');
19
+ /**
20
+ * Startup probe for the jobs-stats table. Logs an error and resolves `false`
21
+ * when the global cannot be read; never throws and never creates tables.
22
+ */ export async function checkJobStatsTable(payload) {
23
+ try {
24
+ await payload.db.findGlobal({
25
+ slug: JOB_STATS_GLOBAL_SLUG
26
+ });
27
+ return true;
28
+ } catch (error) {
29
+ payload.logger.error({
30
+ err: error
31
+ }, MISSING_TABLE_MESSAGE);
32
+ return false;
33
+ }
34
+ }
35
+ const MISSING_META_MESSAGE = [
36
+ `${LOG_PREFIX} Could not read the \`meta\` field of the \`payload-jobs\` collection.`,
37
+ 'Enabling job scheduling also sets `jobs.stats`, which adds that field — its',
38
+ '`payload_jobs.meta` column ships in the same migration as `payload_jobs_stats`.',
39
+ 'Scheduled jobs are queued with `meta.scheduled = true`, so without the column',
40
+ 'every scheduling attempt fails. Run `payload migrate:create` and `payload migrate`.'
41
+ ].join(' ');
42
+ /**
43
+ * Probe for the `payload_jobs.meta` column. Selecting the field is enough — the
44
+ * adapter names the column in the query, so a missing one throws. No raw SQL.
45
+ */ export async function checkJobsMetaColumn(payload) {
46
+ try {
47
+ await payload.db.find({
48
+ collection: JOBS_COLLECTION_SLUG,
49
+ limit: 1,
50
+ pagination: false,
51
+ select: {
52
+ meta: true
53
+ }
54
+ });
55
+ return true;
56
+ } catch (error) {
57
+ payload.logger.error({
58
+ err: error
59
+ }, MISSING_META_MESSAGE);
60
+ return false;
61
+ }
62
+ }
63
+ const MISSING_STATE_MESSAGE = [
64
+ `${LOG_PREFIX} Could not read the \`${STATE_GLOBAL_SLUG}\` global.`,
65
+ 'This plugin keeps its dedup cursor there, because Payload deletes a completed',
66
+ 'job row by default and a cursor kept in the job output would not survive to the',
67
+ 'next run. Without the `version_retention_state` table the age sweep still works,',
68
+ 'but identical-snapshot dedup restarts its cycle at the first entity every run and',
69
+ 'never reaches the rest of the corpus.',
70
+ 'Run `payload migrate:create` and `payload migrate`.'
71
+ ].join(' ');
72
+ /**
73
+ * Startup probe for the plugin's own state table. Logs an error and resolves
74
+ * `false` when the global cannot be read; never throws.
75
+ */ export async function checkStateGlobal(payload) {
76
+ try {
77
+ await payload.db.findGlobal({
78
+ slug: STATE_GLOBAL_SLUG
79
+ });
80
+ return true;
81
+ } catch (error) {
82
+ payload.logger.error({
83
+ err: error
84
+ }, MISSING_STATE_MESSAGE);
85
+ return false;
86
+ }
87
+ }
88
+ /** The two enums Payload derives from the registered task slugs on Postgres. */ export const TASK_SLUG_ENUMS = [
89
+ 'enum_payload_jobs_task_slug',
90
+ 'enum_payload_jobs_log_task_slug'
91
+ ];
92
+ function isPoolLike(value) {
93
+ return typeof value === 'object' && value !== null && typeof value.query === 'function';
94
+ }
95
+ /**
96
+ * The adapter's raw connection pool, when it has one and is a Postgres.
97
+ *
98
+ * `pool` is a Postgres-adapter detail the shared `BaseDatabaseAdapter` type
99
+ * does not declare, so it is probed rather than asserted. Returns `undefined`
100
+ * for every other adapter, which is also the "nothing to check" answer:
101
+ * Mongo and SQLite store the task slug as plain text.
102
+ */ function postgresPool(payload) {
103
+ const db = payload.db;
104
+ if (typeof db !== 'object' || db === null) {
105
+ return undefined;
106
+ }
107
+ const name = 'name' in db ? db.name : undefined;
108
+ if (typeof name !== 'string' || !name.includes('postgres')) {
109
+ return undefined;
110
+ }
111
+ const pool = 'pool' in db ? db.pool : undefined;
112
+ return isPoolLike(pool) ? pool : undefined;
113
+ }
114
+ /** The adapter's `schemaName`, or Postgres' default. */ function schemaOf(payload) {
115
+ const db = payload.db;
116
+ if (typeof db === 'object' && db !== null && 'schemaName' in db) {
117
+ const { schemaName } = db;
118
+ if (typeof schemaName === 'string' && schemaName.length > 0) {
119
+ return schemaName;
120
+ }
121
+ }
122
+ return 'public';
123
+ }
124
+ // Scoped to the adapter's schema: a multi-tenant database can hold the same
125
+ // enum name in several schemas, and grouping across them would let a sibling
126
+ // tenant's migration vouch for this one.
127
+ const ENUM_QUERY = `SELECT t.typname AS enum_name, bool_or(e.enumlabel = $1) AS has_label
128
+ FROM pg_type t
129
+ JOIN pg_namespace n ON n.oid = t.typnamespace
130
+ JOIN pg_enum e ON e.enumtypid = t.oid
131
+ WHERE t.typname = ANY($2) AND n.nspname = $3
132
+ GROUP BY t.typname`;
133
+ function missingEnumMessage(missing) {
134
+ return [
135
+ `${LOG_PREFIX} The \`${JANITOR_TASK_SLUG}\` task slug is missing from`,
136
+ `${missing.map((name)=>`\`${name}\``).join(' and ')}.`,
137
+ 'On Postgres, Payload generates `payload_jobs.task_slug` and',
138
+ '`payload_jobs_log.task_slug` as enums built from the registered tasks, so queuing',
139
+ 'the janitor fails with `invalid input value for enum ... ` until the slug is added.',
140
+ 'Run `payload migrate:create` and `payload migrate` — it emits, for each enum:',
141
+ missing.map((name)=>`ALTER TYPE "${name}" ADD VALUE '${JANITOR_TASK_SLUG}';`).join(' '),
142
+ 'The plugin adoption and that migration must ship in the same release.',
143
+ 'MongoDB and SQLite store the slug as plain text and need nothing.'
144
+ ].join(' ');
145
+ }
146
+ /**
147
+ * Verifies the janitor's slug is a member of both jobs task-slug enums.
148
+ *
149
+ * Postgres only, and only when the adapter exposes a pool — every other
150
+ * adapter stores the slug as text, and an adapter this cannot inspect is left
151
+ * alone rather than warned about. Logs an error and resolves `false` when the
152
+ * slug is missing; never throws.
153
+ */ export async function checkTaskSlugEnum(payload) {
154
+ const pool = postgresPool(payload);
155
+ if (!pool) {
156
+ return true;
157
+ }
158
+ try {
159
+ const { rows } = await pool.query(ENUM_QUERY, [
160
+ JANITOR_TASK_SLUG,
161
+ [
162
+ ...TASK_SLUG_ENUMS
163
+ ],
164
+ schemaOf(payload)
165
+ ]);
166
+ // An enum type that does not exist at all means the jobs tables are not
167
+ // there either — a different problem, already reported by the jobs-stats
168
+ // check. Only complain about an enum that exists without the slug.
169
+ const missing = rows.filter((row)=>row.has_label !== true).map((row)=>String(row.enum_name));
170
+ if (missing.length === 0) {
171
+ return true;
172
+ }
173
+ payload.logger.error(missingEnumMessage(missing));
174
+ return false;
175
+ } catch (error) {
176
+ payload.logger.warn({
177
+ err: error
178
+ }, `${LOG_PREFIX} Could not inspect the jobs task-slug enums; skipping that check. ` + 'If queuing the janitor fails with `invalid input value for enum`, the slug is ' + 'missing and a migration must add it.');
179
+ return true;
180
+ }
181
+ }
182
+ /**
183
+ * Warns when nothing in `jobs.autoRun` drains a queue the plugin schedules onto.
184
+ * `handleSchedules` silently skips every queue the running autoRun entry does
185
+ * not name (unless it sets `allQueues`), so a schedule on an undrained queue
186
+ * never fires and produces no error of its own.
187
+ *
188
+ * A warning, not an error: the consumer may instead run a dedicated worker with
189
+ * `payload jobs:run --handle-schedules`, which this cannot see. Returns false
190
+ * when a warning was emitted.
191
+ */ export function checkScheduleIsDrained(payload, scheduledQueues) {
192
+ const autoRun = payload.config.jobs?.autoRun;
193
+ if (typeof autoRun === 'function') {
194
+ // Resolved by Payload at cron-init time; not knowable here without calling
195
+ // the consumer's factory a second time.
196
+ return true;
197
+ }
198
+ const entries = autoRun ?? [];
199
+ const undrained = scheduledQueues.filter((queue)=>!entries.some((entry)=>!entry.disableScheduling && (entry.allQueues === true || (entry.queue ?? DEFAULT_AUTORUN_QUEUE) === queue)));
200
+ if (undrained.length === 0) {
201
+ return true;
202
+ }
203
+ payload.logger.warn(`${LOG_PREFIX} Nothing in \`jobs.autoRun\` drains ${undrained.map((queue)=>`\`${queue}\``).join(', ')}, which the version-retention janitor is scheduled onto. ` + '`handleSchedules` skips queues the running autoRun entry does not name (unless it sets ' + '`allQueues`), so the janitor will never be queued. Add an autoRun entry for that queue, ' + 'or run a dedicated worker with `payload jobs:run --handle-schedules` — this check cannot ' + 'see a separate worker process.');
204
+ return false;
205
+ }
206
+ /**
207
+ * The boot-time checks that only matter once a schedule exists. The state
208
+ * global is checked separately, because a manually queued janitor needs it
209
+ * just as much. Logs; never throws.
210
+ */ export async function runSchedulingStartupChecks(payload, scheduledQueues) {
211
+ const statsTableOk = await checkJobStatsTable(payload);
212
+ // The meta column ships in the same migration; probing it after a failed
213
+ // stats read would only repeat the same news.
214
+ if (statsTableOk) {
215
+ await checkJobsMetaColumn(payload);
216
+ }
217
+ await checkTaskSlugEnum(payload);
218
+ checkScheduleIsDrained(payload, scheduledQueues);
219
+ }
220
+ /**
221
+ * One-liner for `janitor: { schedule: [] }` — the task is registered but
222
+ * nothing will ever queue it, which is easy to reach by accident.
223
+ */ export function warnScheduleDisabled(payload) {
224
+ payload.logger.warn(`${LOG_PREFIX} \`janitor.schedule\` is empty: the \`${JANITOR_TASK_SLUG}\` task is ` + 'registered but never scheduled, so old versions are not deleted. Queue it yourself, or ' + 'drop `schedule` to use the default (daily 03:00 on the `default` queue).');
225
+ }
@@ -0,0 +1,27 @@
1
+ import type { GlobalConfig, Payload } from 'payload';
2
+ import type { VersionRetentionDedupCursor } from '../types.js';
3
+ /**
4
+ * The plugin's own scrap of persistent state: one hidden global holding one
5
+ * JSON field.
6
+ *
7
+ * It exists because there is nowhere else to put the dedup cursor. Payload's
8
+ * `deleteJobOnComplete` defaults to `true` and the completed job row is hard
9
+ * deleted, so a cursor written into a job's output is unreadable by the time
10
+ * the next run starts — which made the whole rotation inert under the default
11
+ * configuration, however carefully the cursor was computed.
12
+ *
13
+ * Access is denied to everyone; the plugin reads and writes it with
14
+ * `overrideAccess`, and nothing else has any business in it.
15
+ */
16
+ export declare function createStateGlobal(): GlobalConfig;
17
+ /**
18
+ * The dedup cursor the last run left behind, or `undefined` to start the cycle
19
+ * from the beginning. Never throws: a consumer who has not run the migration
20
+ * yet gets a warning and a fresh cycle, not a failed job.
21
+ */
22
+ export declare function readStoredCursor(payload: Payload): Promise<undefined | VersionRetentionDedupCursor>;
23
+ /**
24
+ * Records where dedup stopped, so the next run resumes there. `null` means the
25
+ * cycle completed and the next run starts over.
26
+ */
27
+ export declare function writeStoredCursor(payload: Payload, dedupCursor: null | VersionRetentionDedupCursor): Promise<void>;
@@ -0,0 +1,87 @@
1
+ import { LOG_PREFIX, STATE_GLOBAL_SLUG } from '../defaults.js';
2
+ import { toDedupCursor } from './dedup-cursor.js';
3
+ /**
4
+ * The plugin's own scrap of persistent state: one hidden global holding one
5
+ * JSON field.
6
+ *
7
+ * It exists because there is nowhere else to put the dedup cursor. Payload's
8
+ * `deleteJobOnComplete` defaults to `true` and the completed job row is hard
9
+ * deleted, so a cursor written into a job's output is unreadable by the time
10
+ * the next run starts — which made the whole rotation inert under the default
11
+ * configuration, however carefully the cursor was computed.
12
+ *
13
+ * Access is denied to everyone; the plugin reads and writes it with
14
+ * `overrideAccess`, and nothing else has any business in it.
15
+ */ export function createStateGlobal() {
16
+ return {
17
+ slug: STATE_GLOBAL_SLUG,
18
+ access: {
19
+ read: ()=>false,
20
+ update: ()=>false
21
+ },
22
+ admin: {
23
+ hidden: true
24
+ },
25
+ fields: [
26
+ {
27
+ name: 'state',
28
+ type: 'json',
29
+ admin: {
30
+ readOnly: true
31
+ },
32
+ label: 'Version retention state'
33
+ }
34
+ ],
35
+ graphQL: false,
36
+ label: 'Version Retention State',
37
+ // Nothing edits this in the admin, so a row in `payload_locked_documents`
38
+ // on every cursor write would be pure overhead.
39
+ lockDocuments: false,
40
+ // Versioning our own bookkeeping would be a joke in poor taste.
41
+ versions: false
42
+ };
43
+ }
44
+ function isRecord(value) {
45
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
46
+ }
47
+ /**
48
+ * The dedup cursor the last run left behind, or `undefined` to start the cycle
49
+ * from the beginning. Never throws: a consumer who has not run the migration
50
+ * yet gets a warning and a fresh cycle, not a failed job.
51
+ */ export async function readStoredCursor(payload) {
52
+ try {
53
+ const global = await payload.findGlobal({
54
+ depth: 0,
55
+ overrideAccess: true,
56
+ slug: STATE_GLOBAL_SLUG
57
+ });
58
+ const state = isRecord(global) ? global.state : undefined;
59
+ return isRecord(state) ? toDedupCursor(state.dedupCursor) : undefined;
60
+ } catch (error) {
61
+ payload.logger.warn({
62
+ err: error
63
+ }, `${LOG_PREFIX} Could not read the \`${STATE_GLOBAL_SLUG}\` global; dedup starts its cycle ` + 'from the first entity. Run the migration that creates `version_retention_state`.');
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Records where dedup stopped, so the next run resumes there. `null` means the
69
+ * cycle completed and the next run starts over.
70
+ */ export async function writeStoredCursor(payload, dedupCursor) {
71
+ try {
72
+ await payload.updateGlobal({
73
+ data: {
74
+ state: {
75
+ dedupCursor
76
+ }
77
+ },
78
+ depth: 0,
79
+ overrideAccess: true,
80
+ slug: STATE_GLOBAL_SLUG
81
+ });
82
+ } catch (error) {
83
+ payload.logger.warn({
84
+ err: error
85
+ }, `${LOG_PREFIX} Could not persist the dedup cursor to \`${STATE_GLOBAL_SLUG}\`. This run's ` + 'work still counts; the next one restarts the dedup cycle from the first entity.');
86
+ }
87
+ }
@@ -0,0 +1,72 @@
1
+ import type { Payload, SelectType } from 'payload';
2
+ import type { ResolvedEntity, VersionRetentionDedupCursor, VersionRetentionJanitorOutput } from '../types.js';
3
+ /** Mutable per-run budget, shared across every entity in one invocation. */
4
+ export interface SweepBudget {
5
+ /** Version bodies the sweep may still read for dedup. */
6
+ dedupBodiesRemaining: number;
7
+ deletionsRemaining: number;
8
+ /** Only documents that actually yield a deletion draw on this. */
9
+ documentsRemaining: number;
10
+ }
11
+ /** What one entity's sweep contributed to the run. */
12
+ export interface EntitySweepResult {
13
+ /** Last document dedup actually read, when it stopped short of the end. */
14
+ dedupResumeAfter?: number | string;
15
+ /** True when a budget stopped dedup with documents in this entity unread. */
16
+ dedupStarved: boolean;
17
+ dedupedCount: number;
18
+ deletedCount: number;
19
+ /** Documents whose sweep threw. Logged, skipped, retried next run. */
20
+ failedDocuments: number;
21
+ hasMore: boolean;
22
+ scannedDocuments: number;
23
+ skippedRaced: number;
24
+ }
25
+ /**
26
+ * Columns the retention decision reads. Anything else on a version row is a
27
+ * full document body — the reason these tables are 54% of the database — and
28
+ * pulling it into the janitor would defeat the point.
29
+ *
30
+ * A drafts-less entity has no `_status`, so `version` is left out entirely
31
+ * rather than asking the adapter for a field that does not exist.
32
+ */
33
+ export declare function narrowVersionSelect(hasDrafts: boolean): SelectType;
34
+ /**
35
+ * Sweeps one versioned collection.
36
+ *
37
+ * Rather than walking every document, the pass walks the stale-version rows
38
+ * themselves and visits only the parents that still hold deletable history.
39
+ * Paging is by an ascending `parent` cursor, not an offset, so deleting rows
40
+ * mid-pass cannot make it skip a document.
41
+ */
42
+ export declare function sweepCollection(payload: Payload, entity: ResolvedEntity, budget: SweepBudget, now?: Date,
43
+ /** Resume dedup after this parent id; see `EntitySweepResult.dedupCursor`. */
44
+ dedupCursorAfter?: number | string): Promise<EntitySweepResult>;
45
+ /** Sweeps one versioned global. A global is a single "document". */
46
+ export declare function sweepGlobal(payload: Payload, entity: ResolvedEntity, budget: SweepBudget, now?: Date): Promise<EntitySweepResult>;
47
+ export interface RunSweepArgs {
48
+ collections: ResolvedEntity[];
49
+ /** Where the previous run's dedup pass stopped, from its output. */
50
+ dedupCursor?: null | VersionRetentionDedupCursor;
51
+ globals: ResolvedEntity[];
52
+ maxDedupBodiesPerRun: number;
53
+ maxDeletionsPerRun: number;
54
+ maxDocumentsPerRun: number;
55
+ now?: Date;
56
+ payload: Payload;
57
+ }
58
+ /**
59
+ * One full janitor pass. Globals go first: there are a handful of them and
60
+ * they are cheap, and letting a large collection's backlog eat the budget
61
+ * would otherwise starve them run after run.
62
+ *
63
+ * Dedup rides a single cursor over this whole order rather than one per
64
+ * entity. That is what guarantees progress: a run resumes at the entity the
65
+ * last one stopped in, skips everything already covered in this cycle, and
66
+ * carries on into the entities behind it. With a per-entity cursor the
67
+ * entities *before* the stalled one were re-read from scratch every night and
68
+ * consumed the entire body budget, so the pass never advanced — measured on
69
+ * the production copy, where dedup collapsed pairs only in the four
70
+ * collections ahead of `categories` and never reached `pages` or `games`.
71
+ */
72
+ export declare function runRetentionSweep({ collections, dedupCursor, globals, maxDedupBodiesPerRun, maxDeletionsPerRun, maxDocumentsPerRun, now, payload, }: RunSweepArgs): Promise<VersionRetentionJanitorOutput>;