@10x-media/jobs 0.1.0-beta.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @10x-media/jobs
2
+
3
+ ## 0.1.0-beta.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Initial beta of `@10x-media/jobs`: an ops layer over Payload's built-in jobs queue, opt-in and layered, with full multi-node support.
8
+
9
+ - **Observability** (always on): a jobs dashboard over `payload-jobs` with a derived status column, a queue-health bar, error and log panels, friendlier labels, and a read-only-record model.
10
+ - **Reliability** (`reliability`): a worker heartbeat lease, a stuck-job sweeper that requeues then dead-letters, and multi-node leader election with fencing tokens, so jobs survive crashes and run safely across replicas. Works on MongoDB and PostgreSQL.
11
+ - **Execution** (`createWorker`): a graceful-drain worker that runs jobs on every node, schedules and sweeps only on the elected leader, and finishes in-flight work on SIGTERM, plus `autoRunConfig` for the simple single-node path.
12
+ - **Queue control** (`queueControl`): durable cluster-wide pause and resume, a queue-health endpoint, and a hardened, pause-aware run endpoint with real access control (including a `CRON_SECRET` checker for serverless).
13
+ - **Deployment presets** for serverless (Vercel), single-node Docker, and multi-node Docker, with a documented worker entrypoint.
14
+
15
+ Enable each layer as your topology needs; with none enabled you still get the dashboard. See the README for the per-topology guide.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 10x Media GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,351 @@
1
+ # @10x-media/jobs
2
+
3
+ A jobs ops dashboard and a production-grade reliability, execution, and queue-control layer for Payload's built-in `payload-jobs`.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@10x-media/jobs?style=flat-square)](https://www.npmjs.com/package/@10x-media/jobs)
6
+
7
+ Part of the [@10x-media Payload plugins](https://github.com/10x-media/payload-plugins) collection. In beta: published under the `beta` dist-tag until a stable 1.0.
8
+
9
+ ## Requirements
10
+
11
+ - Payload v3 (peer: `payload@^3.82.0`)
12
+ - React 19 (peer)
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pnpm add @10x-media/jobs
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import { buildConfig } from 'payload'
24
+ import { jobs } from '@10x-media/jobs'
25
+
26
+ export default buildConfig({
27
+ // ...
28
+ plugins: [
29
+ jobs({
30
+ // options
31
+ }),
32
+ ],
33
+ })
34
+ ```
35
+
36
+ ## Options
37
+
38
+ | Option | Type | Default | Description |
39
+ |---|---|---|---|
40
+ | `disabled` | `boolean` | `false` | When `true`, returns the incoming config unchanged. Useful for toggling the plugin per environment. |
41
+ | `reliability` | `ReliabilityOptions \| false` | off | Job leases, the orphan sweeper, leader election, and serverless staleness. Opt in with `{}` (defaults) or a tuned object. |
42
+ | `queueControl` | `QueueControlOptions \| false` | off | Cluster-wide pause/resume, hardened run/sweep/status endpoints, and access guards. Opt in with `{}` (defaults) or a tuned object. |
43
+
44
+ <!-- Add new options to this table as you build them. -->
45
+
46
+ ## Deployment topologies
47
+
48
+ The plugin ships four opt-in layers. You add only the ones your deployment needs, and you pick a topology preset to wire reliability and queue-control consistently.
49
+
50
+ ### Overview
51
+
52
+ | Layer | What it adds | How to enable |
53
+ |---|---|---|
54
+ | Observability | The jobs ops dashboard (status, queue health, error and log panels) and i18n. | Always on (just adding `jobs()`). |
55
+ | Reliability | Job leases, an orphan sweeper, leader election, serverless staleness. | `reliability: {}` (or a tuned `ReliabilityOptions`). |
56
+ | Execution | A standalone worker (`createWorker`) that runs jobs everywhere and schedules/sweeps only while holding the leader lease, with a graceful SIGTERM drain. | Run the worker entrypoint as its own process. |
57
+ | Queue control | Cluster-wide pause/resume plus hardened `/api/payload-jobs/queue-run`, `/queue-sweep`, and `/queue-status` endpoints with access guards. | `queueControl: {}` (or a tuned `QueueControlOptions`). |
58
+
59
+ Every layer is opt-in. The observability dashboard is always present once the plugin is installed. The other three you turn on as your topology demands.
60
+
61
+ Three facts shape every topology below.
62
+
63
+ 1. **Reliability and queue-control require at least one configured task.** The `payload-jobs` collection only materializes once you configure at least one task. With zero tasks, `createWorker` throws a clear error telling you to add one, and the lease store has no table to read. Configure your tasks on `jobs.tasks` in `buildConfig` before enabling reliability or running a worker.
64
+
65
+ 2. **The worker is a separate process, not your web server.** The execution layer (`createWorker`) is meant to run as its own long-lived process (a worker container, a separate Vercel-incompatible service). Your Next.js / Payload web server handles HTTP. The worker boots its own Payload instance against the same database and owns the run/schedule/sweep loops. The only topology that does not run a worker process is serverless, which drives everything from cron-hit endpoints instead.
66
+
67
+ 3. **Completed jobs are deleted, not kept.** Payload's `jobs.deleteJobOnComplete` defaults to `true` in v3.85, so a job that finishes successfully is removed from the queue. The dashboard and the `/queue-status` counts therefore reflect pending, processing, failed, and scheduled jobs, never completed ones. Set `jobs.deleteJobOnComplete: false` in `buildConfig` if you want completed jobs to persist for auditing.
68
+
69
+ Pick a preset:
70
+
71
+ ```ts
72
+ import { jobs, serverlessPreset, singleNodePreset, multiNodePreset } from '@10x-media/jobs'
73
+
74
+ // Serverless (Vercel): cron-driven endpoints, no long-running worker.
75
+ jobs({ ...serverlessPreset({ maxDurationMs: 800_000 }) })
76
+
77
+ // Single-node Docker: one worker container, claim races moot.
78
+ jobs({ ...singleNodePreset() })
79
+
80
+ // Multi-node Docker: many worker replicas, one elected scheduler and sweeper.
81
+ jobs({ ...multiNodePreset({ leaderId: process.env.HOSTNAME }) })
82
+ ```
83
+
84
+ Each preset returns `{ reliability, queueControl }`, so spreading it into `jobs({ ... })` configures both layers at once. An override after the spread replaces the whole group (it does not deep-merge), so to tweak one field spread the group too: `jobs({ ...multiNodePreset(), reliability: { ...multiNodePreset().reliability, leaderId: 'node-7' } })`.
85
+
86
+ ### Serverless (Vercel)
87
+
88
+ Serverless functions are killed at their `maxDuration` with no SIGTERM, so there is no long-running worker and heartbeats are meaningless. `serverlessPreset` instead derives job staleness from the platform hard-kill duration and guards the control endpoints with a shared cron secret. You drive the run and sweep from Vercel Cron.
89
+
90
+ ```ts
91
+ // payload.config.ts
92
+ import { buildConfig } from 'payload'
93
+ import { jobs, serverlessPreset } from '@10x-media/jobs'
94
+
95
+ export default buildConfig({
96
+ // ...
97
+ jobs: {
98
+ tasks: [
99
+ // ...your tasks; at least one is required.
100
+ ],
101
+ },
102
+ plugins: [jobs({ ...serverlessPreset({ maxDurationMs: 800_000 }) })],
103
+ })
104
+ ```
105
+
106
+ `serverlessPreset` sets `reliability.jobLeaseTtlMs` and `reliability.serverless.maxDurationMs` to your `maxDurationMs`, and sets `queueControl.access` to `cronSecretAccess()`. Pass `cronSecretEnvVar` if your secret lives somewhere other than `CRON_SECRET`.
107
+
108
+ Generate the `vercel.json` crons with `vercelCrons()`:
109
+
110
+ ```ts
111
+ // scripts/vercel-crons.ts (or hand-write the array below into vercel.json)
112
+ import { vercelCrons } from '@10x-media/jobs'
113
+
114
+ console.log(JSON.stringify({ crons: vercelCrons() }, null, 2))
115
+ ```
116
+
117
+ ```json
118
+ {
119
+ "crons": [
120
+ { "path": "/api/payload-jobs/queue-run?allQueues=true", "schedule": "* * * * *" },
121
+ { "path": "/api/payload-jobs/queue-sweep", "schedule": "* * * * *" }
122
+ ]
123
+ }
124
+ ```
125
+
126
+ `vercelCrons()` defaults to every minute (Vercel Pro). Override any path or schedule, for example `vercelCrons({ sweepSchedule: '*/5 * * * *' })`.
127
+
128
+ **The cron secret.** Set `CRON_SECRET` in your Vercel project. Vercel sends it as `Authorization: Bearer ${CRON_SECRET}` on every cron invocation, and `cronSecretAccess` checks exactly that header (a logged-in admin user also passes, so you can hit the endpoints manually from the panel). Without the secret set, unauthenticated cron requests are rejected.
129
+
130
+ **The endpoints.** Both are plugin-registered GET endpoints on the `payload-jobs` collection:
131
+
132
+ - `/api/payload-jobs/queue-run` runs due jobs (pause-aware, mirrors the native run params). `?allQueues=true` runs every queue, `?queue=<name>` runs one, `?limit=<n>` caps jobs per invocation, `?disableScheduling=true` skips schedule handling.
133
+ - `/api/payload-jobs/queue-sweep` runs one orphan sweep (a single cron invocation, so no leader election). Requires reliability to be enabled.
134
+
135
+ **Vercel limits.** Match your plan to a cron cadence and a function duration:
136
+
137
+ - **Hobby**: crons run at most **once per day** and functions cap at **300s**. That is unusable for real job processing. Use Hobby only for a toy or a demo.
138
+ - **Pro**: crons run **per minute** and functions extend to **800s**. That per-minute, 800s window is the practical floor for serverless job processing, which is why `serverlessPreset({ maxDurationMs: 800_000 })` and `vercelCrons()` default to it.
139
+
140
+ **`limit` guidance.** A serverless run must finish inside `maxDuration`. Set `?limit=<n>` on the run cron (or `vercelCrons({ runPath: '/api/payload-jobs/queue-run?allQueues=true&limit=20' })`) so one batch of jobs comfortably fits the window. Size the limit to `maxDuration / (slowest expected job duration)` with headroom. If a batch risks overrunning, lower the limit and let the next minute's cron pick up the rest.
141
+
142
+ ### Single-node Docker
143
+
144
+ One worker container claims and runs every job serially, so claim races are moot and leader election is a no-op (the single node always wins). `singleNodePreset()` turns on reliability and queue-control with defaults; the in-process worker runs the scheduler and sweeper directly.
145
+
146
+ ```ts
147
+ // payload.config.ts
148
+ import { buildConfig } from 'payload'
149
+ import { jobs, singleNodePreset } from '@10x-media/jobs'
150
+
151
+ export default buildConfig({
152
+ // ...
153
+ jobs: {
154
+ tasks: [
155
+ // ...your tasks; at least one is required.
156
+ ],
157
+ },
158
+ plugins: [jobs({ ...singleNodePreset() })],
159
+ })
160
+ ```
161
+
162
+ Run the worker as its own service in `docker-compose.yml`, alongside your web service and database:
163
+
164
+ ```yaml
165
+ services:
166
+ web:
167
+ build: .
168
+ command: ['node', 'server.js']
169
+ environment:
170
+ DATABASE_URI: postgres://postgres:postgres@db:5432/app
171
+ PAYLOAD_SECRET: ${PAYLOAD_SECRET}
172
+ depends_on: [db]
173
+
174
+ worker:
175
+ build: .
176
+ # Exec-form CMD so Node is PID 1 and receives SIGTERM directly.
177
+ command: ['node', 'dist/worker.js']
178
+ environment:
179
+ DATABASE_URI: postgres://postgres:postgres@db:5432/app
180
+ PAYLOAD_SECRET: ${PAYLOAD_SECRET}
181
+ depends_on: [db]
182
+
183
+ db:
184
+ image: postgres:16
185
+ environment:
186
+ POSTGRES_DB: app
187
+ POSTGRES_PASSWORD: postgres
188
+ ```
189
+
190
+ `dist/worker.js` is your compiled worker entrypoint (see below). In development you can run the TypeScript source directly with `node --import tsx worker.ts`. With one claimer, you do not need to tune leader leases; defaults are fine.
191
+
192
+ ### Multi-node Docker
193
+
194
+ Many worker replicas share one database. Every replica runs jobs, but only the replica holding the `scheduler` lease handles schedules and only the one holding the `sweeper` lease runs the orphan sweep. `multiNodePreset()` is the default: leader-elected scheduling and sweeping with no extra infrastructure (the leases live in the plugin-owned `payload-jobs-locks` collection).
195
+
196
+ ```ts
197
+ // payload.config.ts
198
+ import { buildConfig } from 'payload'
199
+ import { jobs, multiNodePreset } from '@10x-media/jobs'
200
+
201
+ export default buildConfig({
202
+ // ...
203
+ jobs: {
204
+ tasks: [
205
+ // ...your tasks; at least one is required.
206
+ ],
207
+ },
208
+ // process.env.HOSTNAME is each container's id, a natural stable leader id.
209
+ plugins: [jobs({ ...multiNodePreset({ leaderId: process.env.HOSTNAME }) })],
210
+ })
211
+ ```
212
+
213
+ `leaderId` is the stable identity this node uses when it acquires a lease. Pass `process.env.HOSTNAME` (or any per-replica stable value); omit it to let the worker generate a `hostname:pid` identity at runtime. Leadership fails over automatically: if the current leader dies, another replica acquires the lease once it expires (`leaderLeaseTtlMs`, default 30s) and a monotonic fence token prevents a revived zombie from acting.
214
+
215
+ **Env-designated-leader fallback (zero infra).** If you do not want leader election at all, you can designate one replica as the scheduler by environment. Run native auto-scheduling on a single replica and disable scheduling on the rest:
216
+
217
+ ```ts
218
+ import { autoRunConfig } from '@10x-media/jobs'
219
+
220
+ const isScheduler = process.env.JOBS_SCHEDULER === '1'
221
+
222
+ export default buildConfig({
223
+ // ...
224
+ jobs: {
225
+ tasks: [/* ... */],
226
+ // Only the designated replica handles schedules; the others just run jobs.
227
+ autoRun: autoRunConfig({ disableScheduling: !isScheduler }),
228
+ },
229
+ plugins: [jobs({ ...multiNodePreset() })],
230
+ })
231
+ ```
232
+
233
+ Set `JOBS_SCHEDULER=1` on exactly one replica (or run a single dedicated scheduler replica). This trades automatic failover for zero coordination state. Leader election (the default) is preferred when you want a replica loss to recover on its own.
234
+
235
+ **Graceful shutdown is a hard requirement under multi-node.** When an orchestrator rolls or scales down a replica, it sends SIGTERM, then SIGKILLs after a grace period. The worker's drain requeues its in-flight job (so another replica picks it up) and releases its leases, but only if it is given time to finish.
236
+
237
+ - The orchestrator grace period **must exceed** the worker's `drainTimeoutMs`. In Docker Compose set `stop_grace_period`; in Kubernetes set `terminationGracePeriodSeconds`. If the grace period is shorter, the orchestrator SIGKILLs a still-draining worker and you lose the clean requeue.
238
+ - The container `CMD` must be **exec form** (`CMD ["node", "dist/worker.js"]`, not `CMD node dist/worker.js`). Shell form runs Node as a child of `/bin/sh`, which does not forward SIGTERM, so the worker never drains and is hard-killed every time.
239
+
240
+ ```yaml
241
+ services:
242
+ worker:
243
+ build: .
244
+ command: ['node', 'dist/worker.js'] # exec form: Node receives SIGTERM
245
+ # Must be greater than the worker's drainTimeoutMs (default 30s here, so 45s of headroom).
246
+ stop_grace_period: 45s
247
+ deploy:
248
+ replicas: 3
249
+ environment:
250
+ DATABASE_URI: postgres://postgres:postgres@db:5432/app
251
+ PAYLOAD_SECRET: ${PAYLOAD_SECRET}
252
+ depends_on: [db]
253
+ ```
254
+
255
+ The Kubernetes equivalent: an exec-form `command` in the pod spec and `terminationGracePeriodSeconds: 45` (greater than `drainTimeoutMs`).
256
+
257
+ ### The worker entrypoint
258
+
259
+ The worker is a thin bootstrap: boot Payload, resolve reliability options, and start the worker. `createWorker` installs SIGTERM/SIGINT drain handlers by default, so the process drains and exits 0 on a real signal. This is the canonical pattern (mirrors `packages/jobs/dev/worker.ts`):
260
+
261
+ ```ts
262
+ // worker.ts
263
+ import { getPayload } from 'payload'
264
+ import { createWorker, resolveReliabilityOptions } from '@10x-media/jobs'
265
+
266
+ import config from './payload.config'
267
+
268
+ const RELIABILITY_OPTIONS = {
269
+ jobLeaseTtlMs: 300_000,
270
+ leaderLeaseTtlMs: 30_000,
271
+ sweepIntervalMs: 60_000,
272
+ }
273
+
274
+ const main = async (): Promise<void> => {
275
+ const payload = await getPayload({ config })
276
+ const reliability = resolveReliabilityOptions(RELIABILITY_OPTIONS)
277
+ if (!reliability) {
278
+ throw new Error('@10x-media/jobs worker: reliability resolved to null')
279
+ }
280
+ createWorker({
281
+ payload,
282
+ reliability,
283
+ drainTimeoutMs: 30_000,
284
+ runIntervalMs: 2_000,
285
+ }).start()
286
+ payload.logger.info('@10x-media/jobs worker started; awaiting jobs and signals')
287
+ }
288
+
289
+ main().catch((err) => {
290
+ console.error('@10x-media/jobs worker failed to start', err)
291
+ process.exit(1)
292
+ })
293
+ ```
294
+
295
+ Notes:
296
+
297
+ - `resolveReliabilityOptions` fully defaults your `ReliabilityOptions`. It returns `null` when reliability is off (passed `false` or `undefined`), which the worker cannot run with, hence the guard.
298
+ - Pass the same reliability tuning you give the plugin (share a constant between `payload.config.ts` and `worker.ts`) so the lease TTLs match across the cluster.
299
+ - `createWorker` registers SIGTERM and SIGINT handlers automatically (`installSignals` defaults to `true`). On signal it drains in-flight jobs (within `drainTimeoutMs`), requeues any straggler, releases leases, destroys the Payload instance, and exits 0. Keep your orchestrator grace period above `drainTimeoutMs` (see Multi-node above).
300
+ - Run it with `node --import tsx worker.ts` in development, or compile it and run `node dist/worker.js` in production.
301
+
302
+ ### CI-optional e2e recipes
303
+
304
+ Two real-process scenarios are proven in-process by the test suite, so they are not automated in CI. Both are useful to run by hand against a real database when validating a deployment. Mark them manual / CI-optional.
305
+
306
+ **Recipe 1: two workers, one elected scheduler.** Run two worker processes against the same database and confirm exactly one holds the `scheduler` lease.
307
+
308
+ ```bash
309
+ # Terminal 1 and Terminal 2 (same DATABASE_URI), distinct leader ids:
310
+ JOBS_LEADER_ID=node-a node --import tsx worker.ts
311
+ JOBS_LEADER_ID=node-b node --import tsx worker.ts
312
+ ```
313
+
314
+ Then inspect the leases collection (one row per role, `owner` names the current holder):
315
+
316
+ ```ts
317
+ const locks = await payload.find({
318
+ collection: 'payload-jobs-locks',
319
+ where: { role: { equals: 'scheduler' } },
320
+ })
321
+ // Expect exactly one row whose `owner` is node-a OR node-b, never both.
322
+ console.log(locks.docs.map((d) => ({ role: d.role, owner: d.owner, fenceToken: d.fenceToken })))
323
+ ```
324
+
325
+ Only the owning worker logs schedule handling; the other runs jobs but never schedules. (Wire `leaderId` into your worker from `process.env.JOBS_LEADER_ID` for this recipe.)
326
+
327
+ **Recipe 2: kill a worker mid-job, watch the sweeper recover the orphan.** Confirm that a hard-killed worker's in-flight job is reclaimed by another worker's sweeper after the lease expires.
328
+
329
+ ```bash
330
+ # Start two workers against the same DB (as above), then queue a long job:
331
+ # await payload.jobs.queue({ task: 'your-slow-task', input: { ... } })
332
+ # Find the PID of the worker that claimed it and hard-kill it (no drain):
333
+ kill -9 <worker-pid>
334
+ ```
335
+
336
+ A `kill -9` skips the graceful drain entirely, so the job stays marked processing with a stale lease. After `jobLeaseTtlMs` elapses, the surviving worker's sweeper detects the orphan and requeues it (up to `maxRecoveries` times, then dead-letters). Watch the job flip back to queued and then get re-claimed:
337
+
338
+ ```ts
339
+ const orphans = await payload.find({
340
+ collection: 'payload-jobs',
341
+ where: { and: [{ processing: { equals: false } }, { recoveryAttempts: { greater_than: 0 } }] },
342
+ })
343
+ // After the lease TTL, the killed worker's job appears here with recoveryAttempts >= 1.
344
+ console.log(orphans.totalDocs)
345
+ ```
346
+
347
+ Give it at least `jobLeaseTtlMs + sweepIntervalMs` before asserting recovery.
348
+
349
+ ## License
350
+
351
+ [MIT](./LICENSE). Copyright 10x Media GmbH.
@@ -0,0 +1,72 @@
1
+ //#region src/jobs/deriveJobStatus.ts
2
+ const isCancelled = (error) => typeof error === "object" && error !== null && error.cancelled === true;
3
+ /**
4
+ * Collapse a job's raw fields into one status. Payload has no status field; the
5
+ * state lives across `processing`, `hasError`, `error.cancelled`, `completedAt`,
6
+ * `waitUntil`, and `totalTried`. Order matters: the first matching rule wins.
7
+ */
8
+ const deriveJobStatus = (job, now = Date.now()) => {
9
+ if (job.processing === true) return "running";
10
+ if (job.hasError === true) return isCancelled(job.error) ? "cancelled" : "failed";
11
+ if (job.completedAt) return "succeeded";
12
+ if ((job.totalTried ?? 0) > 0) return "retrying";
13
+ if (job.waitUntil && new Date(job.waitUntil).getTime() > now) return "scheduled";
14
+ return "queued";
15
+ };
16
+ const NOT_RUNNING = { processing: { not_equals: true } };
17
+ const NO_ERROR = { hasError: { equals: false } };
18
+ const NOT_COMPLETED = { completedAt: { exists: false } };
19
+ const NOT_TRIED = { totalTried: { equals: 0 } };
20
+ /**
21
+ * A Payload `where` selecting exactly the jobs `deriveJobStatus` would label with
22
+ * `status`. Each clause negates the higher-precedence rules so the seven
23
+ * partition the collection; the health bar counts each with `payload.count`, and
24
+ * a cross-DB test asserts the counts match `deriveJobStatus` row for row.
25
+ * `processing`/`hasError`/`totalTried` always carry their Payload defaults
26
+ * (`false`/`false`/`0`), so the boolean and number clauses are null-safe.
27
+ */
28
+ const statusWhere = (status, now = Date.now()) => {
29
+ const at = new Date(now).toISOString();
30
+ return {
31
+ running: { processing: { equals: true } },
32
+ cancelled: { and: [
33
+ NOT_RUNNING,
34
+ { hasError: { equals: true } },
35
+ { "error.cancelled": { equals: true } }
36
+ ] },
37
+ failed: { and: [
38
+ NOT_RUNNING,
39
+ { hasError: { equals: true } },
40
+ { or: [{ "error.cancelled": { exists: false } }, { "error.cancelled": { equals: false } }] }
41
+ ] },
42
+ succeeded: { and: [
43
+ NOT_RUNNING,
44
+ NO_ERROR,
45
+ { completedAt: { exists: true } }
46
+ ] },
47
+ retrying: { and: [
48
+ NOT_RUNNING,
49
+ NO_ERROR,
50
+ NOT_COMPLETED,
51
+ { totalTried: { greater_than: 0 } }
52
+ ] },
53
+ scheduled: { and: [
54
+ NOT_RUNNING,
55
+ NO_ERROR,
56
+ NOT_COMPLETED,
57
+ NOT_TRIED,
58
+ { waitUntil: { greater_than: at } }
59
+ ] },
60
+ queued: { and: [
61
+ NOT_RUNNING,
62
+ NO_ERROR,
63
+ NOT_COMPLETED,
64
+ NOT_TRIED,
65
+ { or: [{ waitUntil: { exists: false } }, { waitUntil: { less_than_equal: at } }] }
66
+ ] }
67
+ }[status];
68
+ };
69
+ //#endregion
70
+ export { statusWhere as n, deriveJobStatus as t };
71
+
72
+ //# sourceMappingURL=deriveJobStatus-CM_sCsgm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deriveJobStatus-CM_sCsgm.js","names":[],"sources":["../src/jobs/deriveJobStatus.ts"],"sourcesContent":["import type { Where } from 'payload'\n\n/** The seven derived job states, by precedence (see `deriveJobStatus`). */\nexport type JobStatus =\n\t| 'cancelled'\n\t| 'failed'\n\t| 'queued'\n\t| 'retrying'\n\t| 'running'\n\t| 'scheduled'\n\t| 'succeeded'\n\n/** The subset of `payload-jobs` fields a status is derived from. */\nexport interface JobStatusInput {\n\tcompletedAt?: null | string\n\terror?: unknown\n\thasError?: boolean | null\n\tprocessing?: boolean | null\n\ttotalTried?: null | number\n\twaitUntil?: null | string\n}\n\nconst isCancelled = (error: unknown): boolean =>\n\ttypeof error === 'object' &&\n\terror !== null &&\n\t(error as { cancelled?: unknown }).cancelled === true\n\n/**\n * Collapse a job's raw fields into one status. Payload has no status field; the\n * state lives across `processing`, `hasError`, `error.cancelled`, `completedAt`,\n * `waitUntil`, and `totalTried`. Order matters: the first matching rule wins.\n */\nexport const deriveJobStatus = (job: JobStatusInput, now: number = Date.now()): JobStatus => {\n\tif (job.processing === true) {\n\t\treturn 'running'\n\t}\n\tif (job.hasError === true) {\n\t\treturn isCancelled(job.error) ? 'cancelled' : 'failed'\n\t}\n\tif (job.completedAt) {\n\t\treturn 'succeeded'\n\t}\n\tif ((job.totalTried ?? 0) > 0) {\n\t\treturn 'retrying'\n\t}\n\tif (job.waitUntil && new Date(job.waitUntil).getTime() > now) {\n\t\treturn 'scheduled'\n\t}\n\treturn 'queued'\n}\n\nconst NOT_RUNNING: Where = { processing: { not_equals: true } }\nconst NO_ERROR: Where = { hasError: { equals: false } }\nconst NOT_COMPLETED: Where = { completedAt: { exists: false } }\nconst NOT_TRIED: Where = { totalTried: { equals: 0 } }\n\n/**\n * A Payload `where` selecting exactly the jobs `deriveJobStatus` would label with\n * `status`. Each clause negates the higher-precedence rules so the seven\n * partition the collection; the health bar counts each with `payload.count`, and\n * a cross-DB test asserts the counts match `deriveJobStatus` row for row.\n * `processing`/`hasError`/`totalTried` always carry their Payload defaults\n * (`false`/`false`/`0`), so the boolean and number clauses are null-safe.\n */\nexport const statusWhere = (status: JobStatus, now: number = Date.now()): Where => {\n\tconst at = new Date(now).toISOString()\n\tconst clauses: Record<JobStatus, Where> = {\n\t\trunning: { processing: { equals: true } },\n\t\tcancelled: {\n\t\t\tand: [NOT_RUNNING, { hasError: { equals: true } }, { 'error.cancelled': { equals: true } }],\n\t\t},\n\t\tfailed: {\n\t\t\tand: [\n\t\t\t\tNOT_RUNNING,\n\t\t\t\t{ hasError: { equals: true } },\n\t\t\t\t{\n\t\t\t\t\tor: [{ 'error.cancelled': { exists: false } }, { 'error.cancelled': { equals: false } }],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t\tsucceeded: { and: [NOT_RUNNING, NO_ERROR, { completedAt: { exists: true } }] },\n\t\tretrying: { and: [NOT_RUNNING, NO_ERROR, NOT_COMPLETED, { totalTried: { greater_than: 0 } }] },\n\t\tscheduled: {\n\t\t\tand: [NOT_RUNNING, NO_ERROR, NOT_COMPLETED, NOT_TRIED, { waitUntil: { greater_than: at } }],\n\t\t},\n\t\tqueued: {\n\t\t\tand: [\n\t\t\t\tNOT_RUNNING,\n\t\t\t\tNO_ERROR,\n\t\t\t\tNOT_COMPLETED,\n\t\t\t\tNOT_TRIED,\n\t\t\t\t{ or: [{ waitUntil: { exists: false } }, { waitUntil: { less_than_equal: at } }] },\n\t\t\t],\n\t\t},\n\t}\n\treturn clauses[status]\n}\n"],"mappings":";AAsBA,MAAM,eAAe,UACpB,OAAO,UAAU,YACjB,UAAU,QACT,MAAkC,cAAc;;;;;;AAOlD,MAAa,mBAAmB,KAAqB,MAAc,KAAK,IAAI,MAAiB;CAC5F,IAAI,IAAI,eAAe,MACtB,OAAO;CAER,IAAI,IAAI,aAAa,MACpB,OAAO,YAAY,IAAI,KAAK,IAAI,cAAc;CAE/C,IAAI,IAAI,aACP,OAAO;CAER,KAAK,IAAI,cAAc,KAAK,GAC3B,OAAO;CAER,IAAI,IAAI,aAAa,IAAI,KAAK,IAAI,SAAS,EAAE,QAAQ,IAAI,KACxD,OAAO;CAER,OAAO;AACR;AAEA,MAAM,cAAqB,EAAE,YAAY,EAAE,YAAY,KAAK,EAAE;AAC9D,MAAM,WAAkB,EAAE,UAAU,EAAE,QAAQ,MAAM,EAAE;AACtD,MAAM,gBAAuB,EAAE,aAAa,EAAE,QAAQ,MAAM,EAAE;AAC9D,MAAM,YAAmB,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE;;;;;;;;;AAUrD,MAAa,eAAe,QAAmB,MAAc,KAAK,IAAI,MAAa;CAClF,MAAM,KAAK,IAAI,KAAK,GAAG,EAAE,YAAY;CA8BrC,OAAO;EA5BN,SAAS,EAAE,YAAY,EAAE,QAAQ,KAAK,EAAE;EACxC,WAAW,EACV,KAAK;GAAC;GAAa,EAAE,UAAU,EAAE,QAAQ,KAAK,EAAE;GAAG,EAAE,mBAAmB,EAAE,QAAQ,KAAK,EAAE;EAAC,EAC3F;EACA,QAAQ,EACP,KAAK;GACJ;GACA,EAAE,UAAU,EAAE,QAAQ,KAAK,EAAE;GAC7B,EACC,IAAI,CAAC,EAAE,mBAAmB,EAAE,QAAQ,MAAM,EAAE,GAAG,EAAE,mBAAmB,EAAE,QAAQ,MAAM,EAAE,CAAC,EACxF;EACD,EACD;EACA,WAAW,EAAE,KAAK;GAAC;GAAa;GAAU,EAAE,aAAa,EAAE,QAAQ,KAAK,EAAE;EAAC,EAAE;EAC7E,UAAU,EAAE,KAAK;GAAC;GAAa;GAAU;GAAe,EAAE,YAAY,EAAE,cAAc,EAAE,EAAE;EAAC,EAAE;EAC7F,WAAW,EACV,KAAK;GAAC;GAAa;GAAU;GAAe;GAAW,EAAE,WAAW,EAAE,cAAc,GAAG,EAAE;EAAC,EAC3F;EACA,QAAQ,EACP,KAAK;GACJ;GACA;GACA;GACA;GACA,EAAE,IAAI,CAAC,EAAE,WAAW,EAAE,QAAQ,MAAM,EAAE,GAAG,EAAE,WAAW,EAAE,iBAAiB,GAAG,EAAE,CAAC,EAAE;EAClF,EACD;CAEY,EAAE;AAChB"}
@@ -0,0 +1,49 @@
1
+ import { ArrayFieldClientComponent, DefaultCellComponentProps, JSONFieldClientComponent, UIFieldClientComponent } from "payload";
2
+
3
+ //#region src/jobs/JobDocDescription.d.ts
4
+ /** Document-header description for a job: the record ID, kept accessible and copyable. */
5
+ declare const JobDocDescription: () => import("react/jsx-runtime").JSX.Element | null;
6
+ //#endregion
7
+ //#region src/jobs/JobErrorPanel.d.ts
8
+ /**
9
+ * Field component for a job's `error`: renders the reason as a read-only panel
10
+ * instead of raw JSON. Cancellations read neutral; real failures read as errors.
11
+ */
12
+ declare const JobErrorPanel: JSONFieldClientComponent;
13
+ //#endregion
14
+ //#region src/jobs/JobLogTimeline.d.ts
15
+ /**
16
+ * Field component for a job's `log`: a read-only per-attempt timeline. Each
17
+ * attempt collapses to a one-line summary and expands to reveal its full data
18
+ * (timings, task ID, input, output, error) so nothing is hidden.
19
+ */
20
+ declare const JobLogTimeline: ArrayFieldClientComponent;
21
+ //#endregion
22
+ //#region src/jobs/JobStatusCell.d.ts
23
+ /**
24
+ * List cell that renders a job's derived status as a native Payload Pill. On the
25
+ * linked (first) column it wraps the badge in a link to the document, matching
26
+ * Payload's default cell behavior.
27
+ */
28
+ declare const JobStatusCell: ({
29
+ collectionSlug,
30
+ link,
31
+ linkURL,
32
+ rowData
33
+ }: DefaultCellComponentProps) => import("react/jsx-runtime").JSX.Element;
34
+ //#endregion
35
+ //#region src/jobs/JobStatusHeader.d.ts
36
+ /**
37
+ * Document header for a job: the derived status as a Pill plus the key facts,
38
+ * read from the form so it stays in sync. Rendered at the top of the edit view.
39
+ */
40
+ declare const JobStatusHeader: UIFieldClientComponent;
41
+ //#endregion
42
+ //#region src/jobs/RelativeTimeCell.d.ts
43
+ /** List cell for date fields: relative time, with the admin-formatted timestamp on hover. */
44
+ declare const RelativeTimeCell: ({
45
+ cellData
46
+ }: DefaultCellComponentProps) => import("react/jsx-runtime").JSX.Element | null;
47
+ //#endregion
48
+ export { JobDocDescription, JobErrorPanel, JobLogTimeline, JobStatusCell, JobStatusHeader, RelativeTimeCell };
49
+ //# sourceMappingURL=client.d.ts.map