@focus-reactive/payload-plugin-translator 0.2.1 → 0.4.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.
Files changed (38) hide show
  1. package/dist/client/features/collection-translation-form/model/schema.d.ts +2 -2
  2. package/dist/client/features/translate-document-form/model/schema.d.ts +3 -3
  3. package/dist/server/features/cancel/handler.d.ts +2 -2
  4. package/dist/server/features/cancel/handler.js +3 -3
  5. package/dist/server/features/cancel-by-collection/handler.d.ts +3 -3
  6. package/dist/server/features/cancel-by-collection/handler.js +5 -5
  7. package/dist/server/features/enqueue-translation/handler.d.ts +3 -3
  8. package/dist/server/features/enqueue-translation/handler.js +4 -4
  9. package/dist/server/features/enqueue-translation/model.d.ts +4 -4
  10. package/dist/server/features/get-collection-status/handler.d.ts +3 -3
  11. package/dist/server/features/get-collection-status/handler.js +4 -4
  12. package/dist/server/features/get-document-status/handler.d.ts +3 -3
  13. package/dist/server/features/get-document-status/handler.js +4 -4
  14. package/dist/server/features/get-document-status/model.d.ts +1 -1
  15. package/dist/server/features/run-translation/handler.d.ts +2 -2
  16. package/dist/server/features/run-translation/handler.js +7 -7
  17. package/dist/server/features/translate-document/handler.d.ts +4 -4
  18. package/dist/server/features/translate-document/handler.js +4 -4
  19. package/dist/server/features/translate-document/index.d.ts +2 -3
  20. package/dist/server/features/translate-document/index.js +1 -2
  21. package/dist/server/features/translate-document/model.d.ts +4 -31
  22. package/dist/server/features/translate-document/model.js +3 -27
  23. package/dist/server/modules/task-runner/TaskRunnerProvider.interface.d.ts +6 -4
  24. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.d.ts +4 -4
  25. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.js +72 -20
  26. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +51 -56
  27. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +146 -91
  28. package/dist/server/modules/task-runner/payload-jobs-runner/normalizeJob.d.ts +2 -2
  29. package/dist/server/modules/task-runner/payload-jobs-runner/normalizeJob.js +14 -12
  30. package/dist/server/modules/task-runner/payload-jobs-runner/readCollectionRef.d.ts +22 -0
  31. package/dist/server/modules/task-runner/payload-jobs-runner/readCollectionRef.js +22 -0
  32. package/dist/server/modules/task-runner/payload-jobs-runner/types.d.ts +30 -5
  33. package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.d.ts +5 -5
  34. package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.js +7 -6
  35. package/dist/server/modules/task-runner/types.d.ts +15 -5
  36. package/package.json +1 -1
  37. package/dist/server/features/translate-document/task.d.ts +0 -40
  38. package/dist/server/features/translate-document/task.js +0 -44
@@ -15,80 +15,75 @@ export declare class PayloadJobsTaskRunner implements TaskRunner {
15
15
  cancel(taskIds: string[]): Promise<void>;
16
16
  run(taskId: string): Promise<RunResult>;
17
17
  /**
18
- * Find translation jobs for a collection, optionally narrowed by document IDs.
18
+ * Reset stale processing locks so abandoned jobs become eligible for the
19
+ * autorun picker again. The picker requires processing:false, no error, and
20
+ * no pending waitUntil; a job abandoned mid-run (no error, no waitUntil)
21
+ * satisfies the rest, so clearing processing is sufficient for that case.
22
+ * A job that already exhausted retries (hasError:true) stays excluded from
23
+ * autorun and is only recoverable via a manual run().
19
24
  *
20
- * IMPORTANT why we filter `collection.value` in memory instead of in WHERE
21
- * ------------------------------------------------------------------------
22
- * The "natural" implementation would be a single `payload.find` with the
23
- * full where clause:
25
+ * A job is stale when it is still `processing: true`, not yet completed, and
26
+ * its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
27
+ * killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
28
+ * in flight on another live instance (fresh `updatedAt`) is left alone.
29
+ * Filters on real `payload-jobs` columns only (no JSON-path traversal), so
30
+ * the drizzle SQLite issue in `findByCollection` does not apply here.
31
+ * @returns the number of jobs reclaimed.
32
+ */
33
+ reclaimStaleJobs(): Promise<number>;
34
+ /**
35
+ * Clear the `processing` lock on every job matching `where`, returning how
36
+ * many were reset. Shared by the per-job reset in `run()` (a stale lock) and
37
+ * the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
38
+ * only the count is needed — no relationships to populate.
39
+ */
40
+ private resetProcessing;
41
+ /**
42
+ * A processing lock is stale once `updatedAt` is older than the configured
43
+ * timeout — the owning run is presumed dead.
44
+ */
45
+ private isStale;
46
+ /**
47
+ * Find translation jobs for a collection, optionally narrowed by document IDs.
24
48
  *
25
- * where: {
26
- * and: [
27
- * { 'input.collection.relationTo': { equals: collectionSlug } },
28
- * { 'input.collection.value': { in: documentIds } },
29
- * ],
30
- * }
49
+ * Narrowing is by `taskSlug` only in SQL; the collection slug and document
50
+ * IDs are matched in memory (via the normalized `Task`, which reads both the
51
+ * current flat-text shape and the legacy relationship shape). This is the
52
+ * one path that must transparently span both stored shapes during the
53
+ * ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
31
54
  *
32
- * This DOES NOT work on SQLite (and is unreliable on any adapter) because
33
- * of two compounding bugs in Payload's drizzle layer.
55
+ * IMPORTANT why slug/id are matched in memory, not in the SQL WHERE
56
+ * ------------------------------------------------------------------------
57
+ * The "natural" implementation would push `input.collection_id` into the
58
+ * where clause. This DOES NOT work on SQLite (and is unreliable on any
59
+ * adapter) because of two compounding bugs in Payload's drizzle layer.
34
60
  *
35
61
  * 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
36
62
  * drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
37
63
  * has no `case 'json'` branch, so the value is left as a raw column and
38
64
  * the path segments are passed through to `parseParams.js`, which on
39
65
  * SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
40
- * expressions like `input->>'collection'->>'value'`.
66
+ * expressions like `input->>'collection_id'`.
41
67
  *
42
68
  * 2. When `parseParams.js` formats the right-hand side of `in`/`not_in`
43
69
  * (and even `equals` when `!isNaN(val)`), it inlines values via JS
44
70
  * template literals WITHOUT wrapping strings in quotes. The string
45
71
  * `'1'` from our WHERE becomes raw `1` in the SQL. Drizzle therefore
46
- * emits queries like:
47
- *
48
- * WHERE input->>'collection'->>'value' IN (1)
49
- *
50
- * even though the caller passed `['1']` (an array of strings).
51
- *
52
- * On SQLite, `->>` preserves the JSON value's type — if the stored JSON
53
- * has `"value": "1"` (a JSON string), `->>` returns SQLite TEXT `'1'`;
54
- * if the JSON has `"value": 1` (a JSON number), `->>` returns INTEGER `1`.
55
- * SQLite's `IN (...)` does NOT coerce between TEXT and INTEGER. So:
72
+ * emits queries like `WHERE input->>'collection_id' IN (1)` even though
73
+ * the caller passed `['1']` (an array of strings).
56
74
  *
57
- * TEXT '1' IN (1) false (text vs integer, no match)
58
- * INTEGER 1 IN ('1') → false (integer vs text, no match)
59
- *
60
- * Combined with bug #2 above, any value passed by the callereven if
61
- * we normalize it to a string on write — gets re-coerced to a number in
62
- * the generated SQL and never matches the stored JSON.
63
- *
64
- * Postgres avoids most of this because `jsonb_path_query` returns text
65
- * uniformly and PG's type coercion is more permissive, but the same
66
- * un-quoted-string bug technically affects it too.
67
- *
68
- * Why we don't fix it upstream / patch the dep
69
- * --------------------------------------------
70
- * - This plugin is published to npm. Consumers install it with their own
71
- * Payload version and would not receive any local `bun patch` /
72
- * `patch-package` overrides on `@payloadcms/drizzle`. The plugin must
73
- * work against vanilla Payload.
74
- * - A PR to Payload core is the proper long-term fix, but the plugin
75
- * cannot block on its merge/release cycle.
76
- * - Forcing a non-numeric prefix on the stored ID (e.g., `"id:1"`) would
77
- * work around bug #2, but bloats the data shape and breaks anything
78
- * that reads `input.collection.value` expecting a plain id.
75
+ * On SQLite, `->>` preserves the JSON value's type and `IN (...)` does NOT
76
+ * coerce between TEXT and INTEGER, so a numeric-looking string id never
77
+ * matches once bug #2 strips its quotes. Storing the id as text (this
78
+ * migration) does not fix the SQL path drizzle re-numbers it anyway so
79
+ * we keep matching in memory.
79
80
  *
80
81
  * Why in-memory filtering is acceptable here
81
82
  * ------------------------------------------
82
- * We narrow the SQL query to `taskSlug + relationTo` (both string
83
- * equality, which Payload quotes correctly), then filter the result set
84
- * by `collection.value` in JavaScript. Per-collection job sets are
85
- * small (typically <100 rows; the plugin actively cancels superseded
86
- * jobs so they don't accumulate), so a Set-membership check in JS is
87
- * effectively free.
88
- *
89
- * If/when the upstream drizzle bug is fixed (or this plugin gains a
90
- * mirror collection with indexed flat columns), this method can collapse
91
- * back to a single SQL query.
83
+ * Per-task job sets are small (typically <100 rows; the plugin actively
84
+ * cancels superseded jobs so they don't accumulate), so the JS filtering
85
+ * is effectively free. If/when the upstream drizzle bug is fixed, this can
86
+ * collapse back to a single SQL query.
92
87
  */
93
88
  findByCollection(collectionSlug: CollectionSlug, documentIds?: Array<string | number>): Promise<Task[]>;
94
89
  /**
@@ -13,7 +13,7 @@ import { normalizeJob } from "./normalizeJob";
13
13
  async enqueue(tasks) {
14
14
  const byCollection = this.groupByCollection(tasks);
15
15
  for (const [collectionSlug, items] of byCollection){
16
- const documentIds = items.map((t)=>String(t.collectionId));
16
+ const documentIds = items.map((t)=>t.collectionId);
17
17
  const existing = await this.findByCollection(collectionSlug, documentIds);
18
18
  if (existing.length > 0) {
19
19
  await this.cancelInternal(existing.map((t)=>t.id));
@@ -23,19 +23,13 @@ import { normalizeJob } from "./normalizeJob";
23
23
  task: this.config.taskName,
24
24
  queue: this.config.queueName,
25
25
  input: {
26
- collection: {
27
- // Pass `value` through verbatim. The Payload Jobs `input` schema
28
- // declares this as a `relationship` field, which validates the
29
- // value's type against the target collection's ID type (number
30
- // for autoincrement, string for uuid). Coercing to string here
31
- // would silently fail validation for number-id collections and
32
- // leave jobs stuck in processing without ever invoking the
33
- // task handler. `findByCollection` reads back via in-memory
34
- // filtering and normalizes both sides with String(...) for the
35
- // comparison, so it does not need write-side normalization.
36
- relationTo: task.collectionSlug,
37
- value: task.collectionId
38
- },
26
+ // Flat text reference (ID-agnostic). Stored as a string — no
27
+ // relationship type validation against the collection's ID type,
28
+ // which is what previously left number-id jobs stuck in processing.
29
+ // This is the single write boundary, so `String(...)` here is the
30
+ // one place IDs are normalized for storage.
31
+ collection_slug: task.collectionSlug,
32
+ collection_id: String(task.collectionId),
39
33
  source_lng: task.sourceLng,
40
34
  target_lng: task.targetLng,
41
35
  strategy: task.strategy,
@@ -69,104 +63,164 @@ import { normalizeJob } from "./normalizeJob";
69
63
  };
70
64
  }
71
65
  if (task.status === "running") {
72
- return {
73
- success: false,
74
- error: "already_running"
75
- };
66
+ // A genuinely in-flight job is refused. A stale processing lock (left by
67
+ // a process killed mid-run) is reclaimable: clear it first so the queue
68
+ // picker below — which only selects `processing: false` — can re-run it.
69
+ if (!this.isStale(task.updatedAt)) {
70
+ return {
71
+ success: false,
72
+ error: "already_running"
73
+ };
74
+ }
75
+ await this.resetProcessing({
76
+ id: {
77
+ equals: taskId
78
+ }
79
+ });
76
80
  }
77
- this.payload.jobs.runByID({
78
- id: taskId
81
+ // Execute synchronously via the queue + `where` picker so the job runs to
82
+ // completion within this request (nothing is abandoned after the HTTP
83
+ // response — reliable on serverless too).
84
+ //
85
+ // NOT `payload.jobs.runByID({ id })`: on the drizzle adapter the id-path
86
+ // (`db.updateJobs({ id })`) writes `processing: true` but returns no rows,
87
+ // so `runJobs` reports `noJobsRemaining` and the handler never runs —
88
+ // leaving the job stuck at `processing: true` forever. The `where`-based
89
+ // picker selects, runs, and finalizes the job correctly (verified against
90
+ // sqlite). The picker also enforces processing:false / no-error / no
91
+ // pending waitUntil, so a failed (max-retries) job is not re-run here.
92
+ await this.payload.jobs.run({
93
+ queue: this.config.queueName,
94
+ where: {
95
+ id: {
96
+ equals: taskId
97
+ }
98
+ },
99
+ limit: 1
79
100
  });
80
101
  return {
81
102
  success: true
82
103
  };
83
104
  }
84
105
  /**
85
- * Find translation jobs for a collection, optionally narrowed by document IDs.
106
+ * Reset stale processing locks so abandoned jobs become eligible for the
107
+ * autorun picker again. The picker requires processing:false, no error, and
108
+ * no pending waitUntil; a job abandoned mid-run (no error, no waitUntil)
109
+ * satisfies the rest, so clearing processing is sufficient for that case.
110
+ * A job that already exhausted retries (hasError:true) stays excluded from
111
+ * autorun and is only recoverable via a manual run().
86
112
  *
87
- * IMPORTANT why we filter `collection.value` in memory instead of in WHERE
88
- * ------------------------------------------------------------------------
89
- * The "natural" implementation would be a single `payload.find` with the
90
- * full where clause:
113
+ * A job is stale when it is still `processing: true`, not yet completed, and
114
+ * its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
115
+ * killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
116
+ * in flight on another live instance (fresh `updatedAt`) is left alone.
117
+ * Filters on real `payload-jobs` columns only (no JSON-path traversal), so
118
+ * the drizzle SQLite issue in `findByCollection` does not apply here.
119
+ * @returns the number of jobs reclaimed.
120
+ */ async reclaimStaleJobs() {
121
+ const cutoff = new Date(Date.now() - this.config.staleJobTimeoutMs).toISOString();
122
+ return this.resetProcessing({
123
+ and: [
124
+ {
125
+ taskSlug: {
126
+ equals: this.config.taskName
127
+ }
128
+ },
129
+ {
130
+ processing: {
131
+ equals: true
132
+ }
133
+ },
134
+ {
135
+ completedAt: {
136
+ exists: false
137
+ }
138
+ },
139
+ {
140
+ updatedAt: {
141
+ less_than: cutoff
142
+ }
143
+ }
144
+ ]
145
+ });
146
+ }
147
+ /**
148
+ * Clear the `processing` lock on every job matching `where`, returning how
149
+ * many were reset. Shared by the per-job reset in `run()` (a stale lock) and
150
+ * the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
151
+ * only the count is needed — no relationships to populate.
152
+ */ async resetProcessing(where) {
153
+ const result = await this.payload.update({
154
+ collection: this.config.jobsCollection,
155
+ depth: 0,
156
+ where,
157
+ data: {
158
+ processing: false
159
+ }
160
+ });
161
+ return result.docs.length;
162
+ }
163
+ /**
164
+ * A processing lock is stale once `updatedAt` is older than the configured
165
+ * timeout — the owning run is presumed dead.
166
+ */ isStale(updatedAt) {
167
+ const parsed = Date.parse(updatedAt);
168
+ // Unknown/corrupt timestamp → treat as stale so the job can be recovered
169
+ // rather than permanently refused as already-running.
170
+ if (Number.isNaN(parsed)) return true;
171
+ return Date.now() - parsed > this.config.staleJobTimeoutMs;
172
+ }
173
+ /**
174
+ * Find translation jobs for a collection, optionally narrowed by document IDs.
91
175
  *
92
- * where: {
93
- * and: [
94
- * { 'input.collection.relationTo': { equals: collectionSlug } },
95
- * { 'input.collection.value': { in: documentIds } },
96
- * ],
97
- * }
176
+ * Narrowing is by `taskSlug` only in SQL; the collection slug and document
177
+ * IDs are matched in memory (via the normalized `Task`, which reads both the
178
+ * current flat-text shape and the legacy relationship shape). This is the
179
+ * one path that must transparently span both stored shapes during the
180
+ * ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
98
181
  *
99
- * This DOES NOT work on SQLite (and is unreliable on any adapter) because
100
- * of two compounding bugs in Payload's drizzle layer.
182
+ * IMPORTANT why slug/id are matched in memory, not in the SQL WHERE
183
+ * ------------------------------------------------------------------------
184
+ * The "natural" implementation would push `input.collection_id` into the
185
+ * where clause. This DOES NOT work on SQLite (and is unreliable on any
186
+ * adapter) because of two compounding bugs in Payload's drizzle layer.
101
187
  *
102
188
  * 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
103
189
  * drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
104
190
  * has no `case 'json'` branch, so the value is left as a raw column and
105
191
  * the path segments are passed through to `parseParams.js`, which on
106
192
  * SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
107
- * expressions like `input->>'collection'->>'value'`.
193
+ * expressions like `input->>'collection_id'`.
108
194
  *
109
195
  * 2. When `parseParams.js` formats the right-hand side of `in`/`not_in`
110
196
  * (and even `equals` when `!isNaN(val)`), it inlines values via JS
111
197
  * template literals WITHOUT wrapping strings in quotes. The string
112
198
  * `'1'` from our WHERE becomes raw `1` in the SQL. Drizzle therefore
113
- * emits queries like:
114
- *
115
- * WHERE input->>'collection'->>'value' IN (1)
116
- *
117
- * even though the caller passed `['1']` (an array of strings).
118
- *
119
- * On SQLite, `->>` preserves the JSON value's type — if the stored JSON
120
- * has `"value": "1"` (a JSON string), `->>` returns SQLite TEXT `'1'`;
121
- * if the JSON has `"value": 1` (a JSON number), `->>` returns INTEGER `1`.
122
- * SQLite's `IN (...)` does NOT coerce between TEXT and INTEGER. So:
199
+ * emits queries like `WHERE input->>'collection_id' IN (1)` even though
200
+ * the caller passed `['1']` (an array of strings).
123
201
  *
124
- * TEXT '1' IN (1) false (text vs integer, no match)
125
- * INTEGER 1 IN ('1') → false (integer vs text, no match)
126
- *
127
- * Combined with bug #2 above, any value passed by the callereven if
128
- * we normalize it to a string on write — gets re-coerced to a number in
129
- * the generated SQL and never matches the stored JSON.
130
- *
131
- * Postgres avoids most of this because `jsonb_path_query` returns text
132
- * uniformly and PG's type coercion is more permissive, but the same
133
- * un-quoted-string bug technically affects it too.
134
- *
135
- * Why we don't fix it upstream / patch the dep
136
- * --------------------------------------------
137
- * - This plugin is published to npm. Consumers install it with their own
138
- * Payload version and would not receive any local `bun patch` /
139
- * `patch-package` overrides on `@payloadcms/drizzle`. The plugin must
140
- * work against vanilla Payload.
141
- * - A PR to Payload core is the proper long-term fix, but the plugin
142
- * cannot block on its merge/release cycle.
143
- * - Forcing a non-numeric prefix on the stored ID (e.g., `"id:1"`) would
144
- * work around bug #2, but bloats the data shape and breaks anything
145
- * that reads `input.collection.value` expecting a plain id.
202
+ * On SQLite, `->>` preserves the JSON value's type and `IN (...)` does NOT
203
+ * coerce between TEXT and INTEGER, so a numeric-looking string id never
204
+ * matches once bug #2 strips its quotes. Storing the id as text (this
205
+ * migration) does not fix the SQL path drizzle re-numbers it anyway so
206
+ * we keep matching in memory.
146
207
  *
147
208
  * Why in-memory filtering is acceptable here
148
209
  * ------------------------------------------
149
- * We narrow the SQL query to `taskSlug + relationTo` (both string
150
- * equality, which Payload quotes correctly), then filter the result set
151
- * by `collection.value` in JavaScript. Per-collection job sets are
152
- * small (typically <100 rows; the plugin actively cancels superseded
153
- * jobs so they don't accumulate), so a Set-membership check in JS is
154
- * effectively free.
155
- *
156
- * If/when the upstream drizzle bug is fixed (or this plugin gains a
157
- * mirror collection with indexed flat columns), this method can collapse
158
- * back to a single SQL query.
210
+ * Per-task job sets are small (typically <100 rows; the plugin actively
211
+ * cancels superseded jobs so they don't accumulate), so the JS filtering
212
+ * is effectively free. If/when the upstream drizzle bug is fixed, this can
213
+ * collapse back to a single SQL query.
159
214
  */ async findByCollection(collectionSlug, documentIds) {
160
- const tasks = await this.findJobsInternal({
161
- "input.collection.relationTo": {
162
- equals: collectionSlug
163
- }
164
- }, {
215
+ const all = await this.findJobsInternal(undefined, {
165
216
  pagination: false
166
217
  });
167
- if (!documentIds?.length) return tasks;
218
+ const bySlug = all.filter((t)=>t.input.collectionSlug === collectionSlug);
219
+ if (!documentIds?.length) return bySlug;
220
+ // `documentIds` is the public `Array<string | number>` param, so normalize
221
+ // it here; `t.input.collectionId` is already `ID` (string) via normalizeJob.
168
222
  const wanted = new Set(documentIds.map(String));
169
- return tasks.filter((t)=>wanted.has(String(t.input.collectionId)));
223
+ return bySlug.filter((t)=>wanted.has(t.input.collectionId));
170
224
  }
171
225
  /**
172
226
  * Group tasks by collection slug
@@ -203,19 +257,20 @@ import { normalizeJob } from "./normalizeJob";
203
257
  /**
204
258
  * Internal method to find jobs with where clause
205
259
  */ async findJobsInternal(where, params) {
260
+ const and = [
261
+ {
262
+ taskSlug: {
263
+ equals: this.config.taskName
264
+ }
265
+ }
266
+ ];
267
+ if (where) and.push(where);
206
268
  const response = await this.payload.find({
207
269
  collection: this.config.jobsCollection,
208
270
  limit: params?.limit,
209
271
  pagination: params?.pagination,
210
272
  where: {
211
- and: [
212
- {
213
- taskSlug: {
214
- equals: this.config.taskName
215
- }
216
- },
217
- where
218
- ]
273
+ and
219
274
  }
220
275
  });
221
276
  return response.docs.map(normalizeJob);
@@ -1,5 +1,5 @@
1
- import type { Task } from '../types';
2
- import type { PayloadJob } from './types';
1
+ import type { Task } from "../types";
2
+ import type { PayloadJob } from "./types";
3
3
  /**
4
4
  * Transform Payload job to normalized Task
5
5
  */
@@ -1,15 +1,17 @@
1
+ import { readCollectionRef } from "./readCollectionRef";
1
2
  /**
2
3
  * Transform Payload job to normalized Task
3
4
  */ export function normalizeJob(job) {
5
+ const { collectionSlug, collectionId } = readCollectionRef(job.input);
4
6
  return {
5
7
  id: job.id,
6
8
  status: getJobStatus(job),
7
9
  input: {
8
- collectionSlug: job.input?.collection?.relationTo ?? '',
9
- collectionId: job.input?.collection?.value ?? '',
10
- sourceLng: job.input?.source_lng ?? '',
11
- targetLng: job.input?.target_lng ?? '',
12
- strategy: job.input?.strategy ?? 'overwrite',
10
+ collectionSlug,
11
+ collectionId,
12
+ sourceLng: job.input?.source_lng ?? "",
13
+ targetLng: job.input?.target_lng ?? "",
14
+ strategy: job.input?.strategy ?? "overwrite",
13
15
  publishOnTranslation: job.input?.publish_on_translation ?? false
14
16
  },
15
17
  createdAt: job.createdAt,
@@ -22,19 +24,19 @@
22
24
  };
23
25
  }
24
26
  function getJobStatus(job) {
25
- if (job.completedAt) return 'completed';
26
- if (job.processing) return 'running';
27
- if (job.error) return 'failed';
28
- return 'pending';
27
+ if (job.completedAt) return "completed";
28
+ if (job.processing) return "running";
29
+ if (job.error) return "failed";
30
+ return "pending";
29
31
  }
30
32
  function extractErrorMessage(error) {
31
- if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
33
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
32
34
  return error.message;
33
35
  }
34
- return 'Unknown error';
36
+ return "Unknown error";
35
37
  }
36
38
  function isCancelled(error) {
37
- return error !== null && typeof error === 'object' && 'cancelled' in error && typeof error.cancelled === 'boolean' && error.cancelled;
39
+ return error !== null && typeof error === "object" && "cancelled" in error && typeof error.cancelled === "boolean" && error.cancelled;
38
40
  }
39
41
 
40
42
  //# sourceMappingURL=normalizeJob.js.map
@@ -0,0 +1,22 @@
1
+ import type { CollectionSlug } from "payload";
2
+ import type { ID } from "../types";
3
+ import type { PayloadJob } from "./types";
4
+ /**
5
+ * Normalized, ID-agnostic document reference parsed out of a stored job input.
6
+ */
7
+ export type CollectionRef = {
8
+ collectionSlug: CollectionSlug;
9
+ collectionId: ID;
10
+ };
11
+ /**
12
+ * Single storage-read boundary for a job's collection reference.
13
+ *
14
+ * Reads the current flat-text shape (`collection_slug` / `collection_id`) and
15
+ * falls back to the legacy relationship shape (`collection.{relationTo,value}`)
16
+ * for jobs queued before the ID-agnostic migration. This is the one place a
17
+ * stored id is coerced to string — the coercion exists only because the legacy
18
+ * relationship `value` is typed `string | number`, and it goes away together
19
+ * with the legacy field in the next major.
20
+ * See docs/DEPRECATIONS.md#jobs-input-collection-field
21
+ */
22
+ export declare function readCollectionRef(input: PayloadJob["input"]): CollectionRef;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Single storage-read boundary for a job's collection reference.
3
+ *
4
+ * Reads the current flat-text shape (`collection_slug` / `collection_id`) and
5
+ * falls back to the legacy relationship shape (`collection.{relationTo,value}`)
6
+ * for jobs queued before the ID-agnostic migration. This is the one place a
7
+ * stored id is coerced to string — the coercion exists only because the legacy
8
+ * relationship `value` is typed `string | number`, and it goes away together
9
+ * with the legacy field in the next major.
10
+ * See docs/DEPRECATIONS.md#jobs-input-collection-field
11
+ */ export function readCollectionRef(input) {
12
+ // `??` is intentional: `collection_slug` / `collection_id` are `required: true`
13
+ // in the inputSchema, so they are never an empty string for any job this plugin
14
+ // writes. The fallback to the legacy shape therefore only fires when the new
15
+ // field is genuinely absent (i.e. a job queued before the ID-agnostic migration).
16
+ return {
17
+ collectionSlug: input?.collection_slug ?? input?.collection?.relationTo ?? "",
18
+ collectionId: String(input?.collection_id ?? input?.collection?.value ?? "")
19
+ };
20
+ }
21
+
22
+ //# sourceMappingURL=readCollectionRef.js.map
@@ -1,4 +1,4 @@
1
- import type { CollectionSlug } from 'payload';
1
+ import type { CollectionSlug } from "payload";
2
2
  /**
3
3
  * Configuration for automatic job processing.
4
4
  */
@@ -32,7 +32,7 @@ export type PayloadJobsRunnerOptions = {
32
32
  * Name of the Payload jobs collection.
33
33
  * @default 'payload-jobs'
34
34
  */
35
- jobsCollection?: string;
35
+ jobsCollection?: CollectionSlug;
36
36
  /**
37
37
  * Automatic job processing configuration.
38
38
  * Set to `false` to disable (for Vercel/serverless deployments).
@@ -40,6 +40,22 @@ export type PayloadJobsRunnerOptions = {
40
40
  * @default { cron: '* * * * *', limit: 50 }
41
41
  */
42
42
  autoRun?: false | AutoRunConfig;
43
+ /**
44
+ * How long (ms) a job may stay `processing: true` before its lock is
45
+ * considered stale and the job becomes eligible to be re-run.
46
+ *
47
+ * A process killed mid-run (deploy, crash, request timeout) leaves a job
48
+ * stuck at `processing: true`; the autorun picker only takes
49
+ * `processing: false`, so without recovery such a job would hang forever.
50
+ * On boot the runner resets stale locks, and manual `run()` will re-claim a
51
+ * stale-locked job instead of refusing it as already-running.
52
+ *
53
+ * MUST be larger than the longest a single document translation can
54
+ * legitimately take, otherwise a genuinely in-flight job could be reclaimed
55
+ * and run twice (safe under the idempotent `overwrite` strategy, but wasteful).
56
+ * @default 300000 (5 minutes)
57
+ */
58
+ staleJobTimeoutMs?: number;
43
59
  /**
44
60
  * Retry configuration for failed jobs.
45
61
  */
@@ -47,7 +63,7 @@ export type PayloadJobsRunnerOptions = {
47
63
  attempts?: number;
48
64
  backoff?: {
49
65
  delay?: number;
50
- type: 'exponential' | 'fixed';
66
+ type: "exponential" | "fixed";
51
67
  };
52
68
  };
53
69
  };
@@ -57,9 +73,10 @@ export type PayloadJobsRunnerOptions = {
57
73
  export type PayloadJobsRunnerConfig = {
58
74
  taskName: string;
59
75
  queueName: string;
60
- jobsCollection: string;
76
+ jobsCollection: CollectionSlug;
61
77
  autoRun: false | Required<AutoRunConfig>;
62
- retries?: PayloadJobsRunnerOptions['retries'];
78
+ staleJobTimeoutMs: number;
79
+ retries?: PayloadJobsRunnerOptions["retries"];
63
80
  };
64
81
  /**
65
82
  * Raw Payload job structure
@@ -72,6 +89,14 @@ export type PayloadJob = {
72
89
  error?: unknown;
73
90
  processing?: boolean | null;
74
91
  input?: {
92
+ /** Document reference (flat text, ID-agnostic). Current shape. */
93
+ collection_slug?: string;
94
+ collection_id?: string;
95
+ /**
96
+ * @deprecated Legacy relationship shape, read-only fallback for jobs queued
97
+ * before the ID-agnostic migration. Removed in next major.
98
+ * See docs/DEPRECATIONS.md#jobs-input-collection-field
99
+ */
75
100
  collection?: {
76
101
  relationTo: CollectionSlug;
77
102
  value: string | number;
@@ -1,8 +1,8 @@
1
- import type { Payload, CollectionSlug } from 'payload';
2
- import type { TaskRunner } from '../TaskRunner.interface';
3
- import type { TaskHandler } from '../TaskRunnerProvider.interface';
4
- import type { Task, TaskInput, RunResult } from '../types';
5
- import type { LazyMap } from '../../../shared/utils';
1
+ import type { Payload, CollectionSlug } from "payload";
2
+ import type { TaskRunner } from "../TaskRunner.interface";
3
+ import type { TaskHandler } from "../TaskRunnerProvider.interface";
4
+ import type { Task, TaskInput, RunResult } from "../types";
5
+ import type { LazyMap } from "../../../shared/utils";
6
6
  /**
7
7
  * Synchronous TaskRunner implementation.
8
8
  *