@focus-reactive/payload-plugin-translator 0.11.3 → 0.11.5

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 (23) hide show
  1. package/README.md +37 -0
  2. package/dist/client/entities/translation/model/statusRows.js +1 -1
  3. package/dist/client/entities/translation/model/types.d.ts +4 -4
  4. package/dist/client/features/collection-translation-form/model/schema.d.ts +2 -2
  5. package/dist/client/features/translate-document-form/model/schema.d.ts +1 -1
  6. package/dist/core/translation-pipeline/stages/field-collector/FieldChunkCollector.js +4 -1
  7. package/dist/server/features/cancel-by-collection/handler.d.ts +1 -3
  8. package/dist/server/features/cancel-by-collection/handler.js +13 -8
  9. package/dist/server/features/enqueue-translation/handler.js +0 -2
  10. package/dist/server/features/enqueue-translation/model.d.ts +1 -1
  11. package/dist/server/modules/provenance/Provenance.service.d.ts +6 -3
  12. package/dist/server/modules/provenance/Provenance.service.js +6 -3
  13. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.d.ts +0 -23
  14. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.js +41 -44
  15. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +16 -20
  16. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +248 -159
  17. package/dist/server/modules/task-runner/payload-jobs-runner/normalizeJob.d.ts +11 -3
  18. package/dist/server/modules/task-runner/payload-jobs-runner/normalizeJob.js +57 -15
  19. package/dist/server/modules/task-runner/payload-jobs-runner/planEnqueue.d.ts +33 -0
  20. package/dist/server/modules/task-runner/payload-jobs-runner/planEnqueue.js +51 -0
  21. package/dist/server/modules/task-runner/payload-jobs-runner/types.d.ts +21 -3
  22. package/dist/server/modules/task-runner/payload-jobs-runner/types.js +1 -3
  23. package/package.json +1 -1
package/README.md CHANGED
@@ -562,6 +562,43 @@ createPayloadJobsRunner({ taskName: "translate_document", queueName: "translatio
562
562
 
563
563
  > By default Payload deletes a job as soon as it completes, so the "Completed" status never shows in the UI. Set `jobs: { deleteJobOnComplete: false }` in your Payload config to keep it.
564
564
 
565
+ ##### One job per document
566
+
567
+ A document's target locales are queued as a **single job** that translates them one after another.
568
+ Every write Payload makes is a whole-document version snapshot, so two locales translated in parallel
569
+ build from the same base and the second silently drops the first's work.
570
+
571
+ A later request for the same document adds its locales to that job rather than replacing it — the
572
+ locales the job still owes are never lost. Two cases get a job of their own instead: re-translating a
573
+ locale the live job has already finished (its log records it as done, so it would be skipped), and a
574
+ request that picked a different source locale, strategy or publish flag — a job carries one of each
575
+ for all its locales, so it cannot take work that chose differently.
576
+
577
+ ##### Optional: strict one-at-a-time per document
578
+
579
+ Two requests landing at the same instant, or a re-translation of an already-finished locale, can still
580
+ put two jobs on one document. If your content is edited often enough for that to matter, enable
581
+ Payload's own concurrency control:
582
+
583
+ ```typescript
584
+ // payload.config.ts
585
+ export default buildConfig({
586
+ jobs: { enableConcurrencyControl: true },
587
+ // ...
588
+ });
589
+ ```
590
+
591
+ The plugin picks this up on its own — there is no option to set here. With it on, the queue holds a
592
+ second job for a document until the running one finishes, so two jobs can never write the same
593
+ document at once. Jobs for *different* documents still run in parallel.
594
+
595
+ The cost is yours to weigh: the setting adds an indexed `concurrencyKey` column to the jobs
596
+ collection, so a SQL database needs a migration (`payload migrate:create` then `payload migrate`);
597
+ MongoDB needs none. A second job also waits for the next queue run rather than starting immediately.
598
+
599
+ > With the setting on, a job stuck at `processing: true` blocks every other job for that document
600
+ > until its lock is reclaimed. The plugin clears stale locks on boot — see `staleJobTimeoutMs`.
601
+
565
602
  #### `createSyncRunner()`
566
603
 
567
604
  Runs translations inline (no queue) — handy for development or small datasets.
@@ -65,7 +65,7 @@ const toRowState = (jobStatus)=>jobStatus === "failed" || jobStatus === "running
65
65
  state: existing && !TRANSIENT.has(state) ? existing.state : state,
66
66
  at: run.updated_at,
67
67
  jobId: TRANSIENT.has(state) ? run.id : existing?.jobId,
68
- error: run.status === "failed" ? run.error.message : undefined
68
+ error: run.status === "failed" ? run.error?.message : undefined
69
69
  });
70
70
  }
71
71
  }
@@ -16,7 +16,8 @@ export type DocumentTranslationFailed = {
16
16
  created_at: string;
17
17
  updated_at: string;
18
18
  input: InputData;
19
- error: {
19
+ /** Unset until the job stops retrying, so a row can read `failed` with nothing to show. */
20
+ error?: {
20
21
  message: string;
21
22
  };
22
23
  };
@@ -36,9 +37,8 @@ export type DocumentTranslationCompleted = {
36
37
  input: InputData;
37
38
  };
38
39
  /**
39
- * One translation job for a single target locale. The document status feed is an array of these
40
- * the latest job per target locale (see `useDocumentTranslation`), because re-translate queues an
41
- * independent job per locale.
40
+ * One row per target locale in the document status feed. Several rows can share one job id a job
41
+ * carries all of a document's locales.
42
42
  */
43
43
  export type DocumentTranslation = DocumentTranslationCompleted | DocumentTranslationRunning | DocumentTranslationFailed | DocumentTranslationPending;
44
44
  export type CollectionTranslationStatusItem = {
@@ -8,14 +8,14 @@ export declare const validationSchema: z.ZodObject<{
8
8
  }, "strip", z.ZodTypeAny, {
9
9
  collection_slug: string;
10
10
  source_lng: string;
11
- target_lng: string | string[];
12
11
  strategy: "overwrite" | "skip_existing";
12
+ target_lng: string | string[];
13
13
  publish_on_translation: boolean;
14
14
  }, {
15
15
  collection_slug: string;
16
16
  source_lng: string;
17
- target_lng: string | string[];
18
17
  strategy: "overwrite" | "skip_existing";
18
+ target_lng: string | string[];
19
19
  publish_on_translation?: boolean | undefined;
20
20
  }>;
21
21
  export type FormValues = z.infer<typeof validationSchema>;
@@ -10,8 +10,8 @@ export declare const validationSchema: z.ZodObject<{
10
10
  collection_slug: string;
11
11
  collection_id: string;
12
12
  source_lng: string;
13
- target_lng: string | string[];
14
13
  strategy: "overwrite" | "skip_existing";
14
+ target_lng: string | string[];
15
15
  publish_on_translation: boolean;
16
16
  }, {
17
17
  collection_slug: string;
@@ -116,8 +116,11 @@ const asObject = (value)=>isObject(value) ? value : {};
116
116
  target: this.targetData,
117
117
  path: []
118
118
  }, walker);
119
+ // A detached copy, because write-back mutates an object-valued leaf (a rich-text tree) in
120
+ // place: seating the caller's own object here would translate their source document. Scalars
121
+ // are immutable, so they are seated as they are.
119
122
  for (const { dataRef, key, sourceValue } of selected){
120
- dataRef[key] = sourceValue;
123
+ dataRef[key] = isObject(sourceValue) ? structuredClone(sourceValue) : sourceValue;
121
124
  }
122
125
  return chunks;
123
126
  }
@@ -1,9 +1,7 @@
1
1
  import type { PayloadRequest } from "payload";
2
2
  import type { TaskRunnerFactory } from "../../modules/task-runner";
3
3
  import type { CancelConfig } from "./model";
4
- /**
5
- * Cancels all pending translation tasks for a collection
6
- */
4
+ /** Cancels every queued job for a collection; jobs in flight are left alone. */
7
5
  export declare class CancelByCollectionHandler {
8
6
  private readonly config;
9
7
  private readonly taskRunnerFactory;
@@ -1,9 +1,7 @@
1
1
  import { ServerResponse } from "../../shared";
2
2
  import { isCollectionAvailable } from "../_lib/collection-utils";
3
3
  import { CancelByCollectionInputSchema } from "./model";
4
- /**
5
- * Cancels all pending translation tasks for a collection
6
- */ export class CancelByCollectionHandler {
4
+ /** Cancels every queued job for a collection; jobs in flight are left alone. */ export class CancelByCollectionHandler {
7
5
  config;
8
6
  taskRunnerFactory;
9
7
  constructor(config, taskRunnerFactory){
@@ -16,11 +14,18 @@ import { CancelByCollectionInputSchema } from "./model";
16
14
  const collectionSlug = isCollectionAvailable(validationResult.data.collection_slug, this.config.availableCollections);
17
15
  if (!collectionSlug) return ServerResponse.badRequest("Collection not available for translation");
18
16
  const runner = this.taskRunnerFactory.create(req.payload);
19
- const tasks = await runner.findByCollection(collectionSlug);
20
- if (tasks.length === 0) return ServerResponse.noContent();
21
- const pendingTaskIds = tasks.filter((task)=>task.status === "pending").map((task)=>task.id);
22
- if (pendingTaskIds.length === 0) return ServerResponse.noContent();
23
- await runner.cancel(pendingTaskIds);
17
+ const rows = await runner.findByCollection(collectionSlug, {
18
+ excludeCompleted: true
19
+ });
20
+ if (rows.length === 0) return ServerResponse.noContent();
21
+ // A job waiting to retry has every locale logged, so it has no `pending` row — filter by *not
22
+ // running* instead.
23
+ const running = new Set(rows.filter((row)=>row.status === "running").map((row)=>row.id));
24
+ const queuedJobIds = [
25
+ ...new Set(rows.map((row)=>row.id))
26
+ ].filter((id)=>!running.has(id));
27
+ if (queuedJobIds.length === 0) return ServerResponse.noContent();
28
+ await runner.cancel(queuedJobIds);
24
29
  return ServerResponse.noContent();
25
30
  }
26
31
  }
@@ -39,8 +39,6 @@ import { EnqueueInputSchema } from "./model";
39
39
  if (targets.length === 0) return ServerResponse.badRequest("No valid target locales to translate into (all requested locales were the source or unknown)");
40
40
  const collectionIds = select_all ? await getAllCollectionIds(req.payload, collectionSlug) : collection_id;
41
41
  const runner = this.taskRunnerFactory.create(req.payload);
42
- // One task per (document x target locale). The runner keys/supersedes per (document, targetLng),
43
- // so N concurrent targets of one document coexist (PR #75) — no runner change needed.
44
42
  const tasks = collectionIds.flatMap((id)=>targets.map((targetLng)=>({
45
43
  collectionSlug,
46
44
  collectionId: id,
@@ -15,8 +15,8 @@ export declare const EnqueueInputSchema: z.ZodObject<{
15
15
  collection_slug: string;
16
16
  collection_id: [string, ...string[]];
17
17
  source_lng: string;
18
- target_lng: string | string[];
19
18
  strategy: "overwrite" | "skip_existing";
19
+ target_lng: string | string[];
20
20
  publish_on_translation: boolean;
21
21
  select_all?: boolean | undefined;
22
22
  }, {
@@ -25,9 +25,12 @@ export declare class ProvenanceService {
25
25
  private readonly schemaMap;
26
26
  constructor(payload: Payload, store: ProvenanceStore, schemaMap: CollectionSchemaMap);
27
27
  /**
28
- * Hash the PRISTINE source. The caller MUST pass source fetched **before** the translation pipeline
29
- * runs — the pipeline mutates object-valued leaves (e.g. richText nodes) in place, so hashing after
30
- * it would capture the target translation and make every fresh translation look instantly stale.
28
+ * Hash the source the translation was made from the baseline staleness is later measured against.
29
+ *
30
+ * Ordering used to matter: the pipeline wrote into object-valued leaves it shared with the caller's
31
+ * source, so hashing afterwards captured the translation and reported every fresh translation as
32
+ * stale. It now detaches those leaves, so this may be called on either side of the pipeline.
33
+ *
31
34
  * Returns `null` on any failure (no schema, hashing error) so provenance is skipped, not the translation.
32
35
  */
33
36
  captureFingerprint(collection: CollectionSlug, sourceData: Record<string, unknown>): string | null;
@@ -19,9 +19,12 @@ import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
19
19
  this.schemaMap = schemaMap;
20
20
  }
21
21
  /**
22
- * Hash the PRISTINE source. The caller MUST pass source fetched **before** the translation pipeline
23
- * runs — the pipeline mutates object-valued leaves (e.g. richText nodes) in place, so hashing after
24
- * it would capture the target translation and make every fresh translation look instantly stale.
22
+ * Hash the source the translation was made from the baseline staleness is later measured against.
23
+ *
24
+ * Ordering used to matter: the pipeline wrote into object-valued leaves it shared with the caller's
25
+ * source, so hashing afterwards captured the translation and reported every fresh translation as
26
+ * stale. It now detaches those leaves, so this may be called on either side of the pipeline.
27
+ *
25
28
  * Returns `null` on any failure (no schema, hashing error) so provenance is skipped, not the translation.
26
29
  */ captureFingerprint(collection, sourceData) {
27
30
  const schema = this.schemaMap.get(collection);
@@ -2,33 +2,10 @@ import type { Config, Payload } from "payload";
2
2
  import type { TaskRunner } from "../TaskRunner.interface";
3
3
  import type { PayloadJobsRunnerOptions } from "./types";
4
4
  import type { TaskRunnerContext, TaskRunnerProvider } from "../TaskRunnerProvider.interface";
5
- /**
6
- * TaskRunnerProvider implementation using Payload Jobs.
7
- *
8
- * Configures Payload jobs, tasks, and autorun for translation processing.
9
- */
10
5
  export declare class PayloadJobsRunnerProvider implements TaskRunnerProvider {
11
6
  private readonly config;
12
7
  constructor(options?: PayloadJobsRunnerOptions);
13
8
  create(payload: Payload): TaskRunner;
14
9
  configure(context: TaskRunnerContext): (config: Config) => Config;
15
10
  }
16
- /**
17
- * Creates the **recommended** task runner: translations run as Payload Jobs
18
- * (queued, executed by autoRun cron or a manual run, with stale-lock recovery).
19
- * Durable across restarts and suited to production/serverless. Pass the result
20
- * as `translatorPlugin({ runner })`.
21
- *
22
- * @param options - Queue/task names, `autoRun` cron (or `false` to disable),
23
- * `staleJobTimeoutMs`, and retry policy. See {@link PayloadJobsRunnerOptions}.
24
- * @returns A {@link TaskRunnerProvider} for the plugin's `runner` option.
25
- * @example
26
- * ```ts
27
- * translatorPlugin({
28
- * collections: [Posts],
29
- * translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! }),
30
- * runner: createPayloadJobsRunner({ autoRun: { cron: '* * * * *' } }),
31
- * })
32
- * ```
33
- */
34
11
  export declare function createPayloadJobsRunner(options?: PayloadJobsRunnerOptions): TaskRunnerProvider;
@@ -4,7 +4,7 @@ const defaultAutoRun = {
4
4
  cron: "* * * * *",
5
5
  limit: 50
6
6
  };
7
- const DEFAULT_STALE_JOB_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
7
+ const DEFAULT_STALE_JOB_TIMEOUT_MS = 5 * 60 * 1000;
8
8
  const defaultValues = {
9
9
  taskName: "translate_document",
10
10
  queueName: "translations",
@@ -19,11 +19,7 @@ const defaultValues = {
19
19
  }
20
20
  }
21
21
  };
22
- /**
23
- * TaskRunnerProvider implementation using Payload Jobs.
24
- *
25
- * Configures Payload jobs, tasks, and autorun for translation processing.
26
- */ export class PayloadJobsRunnerProvider {
22
+ export class PayloadJobsRunnerProvider {
27
23
  config;
28
24
  constructor(options){
29
25
  const autoRun = options?.autoRun === false ? false : options?.autoRun ? {
@@ -36,6 +32,7 @@ const defaultValues = {
36
32
  }
37
33
  this.config = {
38
34
  taskName: options?.taskName ?? defaultValues.taskName,
35
+ workflowName: `${options?.taskName ?? defaultValues.taskName}_locales`,
39
36
  queueName: options?.queueName ?? defaultValues.queueName,
40
37
  jobsCollection: options?.jobsCollection ?? defaultValues.jobsCollection,
41
38
  autoRun,
@@ -47,13 +44,10 @@ const defaultValues = {
47
44
  return new PayloadJobsTaskRunner(payload, this.config);
48
45
  }
49
46
  configure(context) {
50
- const { taskName, queueName, retries, autoRun } = this.config;
47
+ const { taskName, workflowName, queueName, retries, autoRun } = this.config;
51
48
  const { handler, collections } = context;
52
49
  return (config)=>{
53
50
  const inputSchema = [
54
- // Flat text reference (ID-agnostic). Current shape that jobs are
55
- // written with — no relationship type validation against the target
56
- // collection's ID type, so string IDs work for number-id collections.
57
51
  {
58
52
  type: "text",
59
53
  name: "collection_slug",
@@ -64,14 +58,7 @@ const defaultValues = {
64
58
  name: "collection_id",
65
59
  required: true
66
60
  },
67
- /**
68
- * Legacy relationship reference, kept as a read-only fallback so jobs
69
- * queued before the ID-agnostic migration stay readable. No longer
70
- * written; demoted to `required: false` so new jobs (which omit it)
71
- * pass validation. Removed in the next major.
72
- * See docs/DEPRECATIONS.md#jobs-input-collection-field
73
- * @deprecated
74
- */ {
61
+ {
75
62
  type: "relationship",
76
63
  name: "collection",
77
64
  relationTo: collections,
@@ -105,6 +92,14 @@ const defaultValues = {
105
92
  defaultValue: false
106
93
  }
107
94
  ];
95
+ const workflowInputSchema = [
96
+ ...inputSchema.filter((f)=>"name" in f && f.name !== "target_lng"),
97
+ {
98
+ type: "json",
99
+ name: "target_lngs",
100
+ required: true
101
+ }
102
+ ];
108
103
  const task = {
109
104
  slug: taskName,
110
105
  inputSchema,
@@ -124,10 +119,36 @@ const defaultValues = {
124
119
  };
125
120
  }
126
121
  };
122
+ const workflow = {
123
+ slug: workflowName,
124
+ inputSchema: workflowInputSchema,
125
+ retries,
126
+ ...config.jobs?.enableConcurrencyControl ? {
127
+ concurrency: {
128
+ key: ({ input })=>`${input.collection_slug}:${input.collection_id}`,
129
+ exclusive: true
130
+ }
131
+ } : {},
132
+ handler: async ({ job, tasks })=>{
133
+ const runLocale = tasks[taskName];
134
+ for(let i = 0;; i++){
135
+ const { target_lngs: targets, ...shared } = job.input;
136
+ const target = targets?.[i];
137
+ if (target === undefined) return;
138
+ await runLocale(target, {
139
+ input: {
140
+ ...shared,
141
+ target_lng: target
142
+ }
143
+ });
144
+ }
145
+ }
146
+ };
127
147
  if (!config.jobs) config.jobs = {};
128
148
  if (!config.jobs.tasks) config.jobs.tasks = [];
129
149
  config.jobs.tasks.push(task);
130
- // Skip autoRun configuration when disabled (e.g., for Vercel/serverless deployments)
150
+ if (!config.jobs.workflows) config.jobs.workflows = [];
151
+ config.jobs.workflows.push(workflow);
131
152
  if (autoRun) {
132
153
  const autoRunConfig = {
133
154
  queue: queueName,
@@ -148,13 +169,6 @@ const defaultValues = {
148
169
  ];
149
170
  }
150
171
  }
151
- // Reset stale locks on boot so jobs abandoned by a killed process
152
- // (deploy/crash/timeout) become eligible for the autorun picker again.
153
- // The picker requires processing:false, no error, and no pending waitUntil;
154
- // a mid-run casualty (no error, no waitUntil) satisfies the rest, so
155
- // clearing processing is sufficient for that case. Threshold-based, so a
156
- // job genuinely in flight on another live instance (fresh updatedAt) is
157
- // left alone. Wrapped so a reclaim failure never blocks startup.
158
172
  const existingOnInit = config.onInit;
159
173
  config.onInit = async (payload)=>{
160
174
  if (existingOnInit) await existingOnInit(payload);
@@ -171,24 +185,7 @@ const defaultValues = {
171
185
  };
172
186
  }
173
187
  }
174
- /**
175
- * Creates the **recommended** task runner: translations run as Payload Jobs
176
- * (queued, executed by autoRun cron or a manual run, with stale-lock recovery).
177
- * Durable across restarts and suited to production/serverless. Pass the result
178
- * as `translatorPlugin({ runner })`.
179
- *
180
- * @param options - Queue/task names, `autoRun` cron (or `false` to disable),
181
- * `staleJobTimeoutMs`, and retry policy. See {@link PayloadJobsRunnerOptions}.
182
- * @returns A {@link TaskRunnerProvider} for the plugin's `runner` option.
183
- * @example
184
- * ```ts
185
- * translatorPlugin({
186
- * collections: [Posts],
187
- * translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! }),
188
- * runner: createPayloadJobsRunner({ autoRun: { cron: '* * * * *' } }),
189
- * })
190
- * ```
191
- */ export function createPayloadJobsRunner(options) {
188
+ export function createPayloadJobsRunner(options) {
192
189
  return new PayloadJobsRunnerProvider(options);
193
190
  }
194
191
 
@@ -2,39 +2,35 @@ import type { Payload, CollectionSlug } from "payload";
2
2
  import type { TaskFilter, TaskRunner } from "../TaskRunner.interface";
3
3
  import type { Task, TaskInput, RunResult } from "../types";
4
4
  import type { PayloadJobsRunnerConfig } from "./types";
5
- /**
6
- * TaskRunner implementation using Payload Jobs.
7
- *
8
- * Handles queuing, cancellation, status tracking, and execution of translation tasks.
9
- */
10
5
  export declare class PayloadJobsTaskRunner implements TaskRunner {
11
6
  private readonly payload;
12
7
  private readonly config;
13
8
  constructor(payload: Payload, config: PayloadJobsRunnerConfig);
14
9
  enqueue(tasks: TaskInput[]): Promise<void>;
10
+ private serve;
11
+ private extendJob;
12
+ private queueWorkflow;
15
13
  cancel(taskIds: string[]): Promise<void>;
16
14
  run(taskId: string): Promise<RunResult>;
17
15
  /**
18
- * Clear stale `processing` locks still processing, not completed, `updatedAt` older than
19
- * `staleJobTimeoutMs` so abandoned jobs are eligible for the autorun picker again. A job that
20
- * exhausted its retries carries `hasError: true` and stays excluded from autorun even after its
21
- * lock is cleared; only a manual `run()` recovers it.
16
+ * A job that exhausted its retries carries `hasError: true` and stays excluded from the picker even
17
+ * after its lock is cleared; only a manual `run()` recovers it.
22
18
  * @returns how many locks were cleared.
23
19
  */
24
20
  reclaimStaleJobs(): Promise<number>;
25
- /** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */
26
- private resetProcessing;
21
+ /**
22
+ * `payload.update`, not the adapter write `extendJob` uses, so the jobs collection's `beforeChange`
23
+ * hook still runs — it is what keeps a cancelled job cancelled.
24
+ */
25
+ private clearPickerBlockers;
27
26
  private isStale;
28
27
  /**
29
- * Find translation jobs for a collection.
30
- *
31
- * Only `taskSlug` and `completedAt` reach the database; slug and document ids are matched in memory
32
- * because a job's collection reference may sit in either the flat-text fields or the legacy
33
- * relationship shape (`readCollectionRef`), so a `where` on `input.collection_slug` would silently
34
- * drop every pre-migration job. `excludeCompleted` is what bounds the read — see issue #108.
28
+ * Matched in memory, not in a `where`: a job's collection reference sits either in the flat text
29
+ * fields or in the legacy relationship shape (see `readCollectionRef`), so `input.collection_slug`
30
+ * as a filter would silently drop every pre-migration job.
35
31
  */
36
32
  findByCollection(collectionSlug: CollectionSlug, filter?: Array<string | number> | TaskFilter): Promise<Task[]>;
37
- private groupByCollection;
38
- private cancelAndDeleteJobs;
39
- private findJobsInternal;
33
+ private ownJobs;
34
+ private findJobById;
35
+ private findRawJobs;
40
36
  }
@@ -1,14 +1,30 @@
1
1
  import { toTaskFilter } from "../toTaskFilter";
2
- import { normalizeJob } from "./normalizeJob";
3
- // A translation job's supersession identity: same document AND same target locale. IDs are
4
- // String()-normalized to match the stored (string) form, so a number id compares equal to its
5
- // persisted job.
6
- const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${targetLng}`;
2
+ import { normalizeJobLocales } from "./normalizeJob";
3
+ import { planEnqueue } from "./planEnqueue";
4
+ import { readCollectionRef } from "./readCollectionRef";
5
+ const APPEND_ATTEMPTS = 2;
7
6
  /**
8
- * TaskRunner implementation using Payload Jobs.
9
- *
10
- * Handles queuing, cancellation, status tracking, and execution of translation tasks.
11
- */ export class PayloadJobsTaskRunner {
7
+ * Groups served at once: a `select_all` enqueue can span thousands, each costing two writes and a
8
+ * read, and the ceiling is the database pool rather than the CPU.
9
+ */ const ENQUEUE_CONCURRENCY = 10;
10
+ function requestShape(task) {
11
+ return {
12
+ collectionSlug: task.collectionSlug,
13
+ collectionId: String(task.collectionId),
14
+ sourceLng: task.sourceLng,
15
+ strategy: task.strategy,
16
+ publishOnTranslation: task.publishOnTranslation
17
+ };
18
+ }
19
+ /** `pickHost` matches on the same shape, so no two groups can pick the same host job — which is what
20
+ * makes the parallel `serve` calls safe. */ function requestKey(task) {
21
+ return JSON.stringify(requestShape(task));
22
+ }
23
+ function documentKey(collectionSlug, collectionId) {
24
+ // NUL: no slug or id can contain it, so two different documents cannot produce one key.
25
+ return `${collectionSlug}\u0000${collectionId}`;
26
+ }
27
+ export class PayloadJobsTaskRunner {
12
28
  payload;
13
29
  config;
14
30
  constructor(payload, config){
@@ -16,91 +32,161 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
16
32
  this.config = config;
17
33
  }
18
34
  async enqueue(tasks) {
19
- const byCollection = this.groupByCollection(tasks);
20
- for (const [collectionSlug, items] of byCollection){
21
- const documentIds = items.map((t)=>t.collectionId);
22
- // Finished jobs must stay out of this set: superseding deletes (`cancelAndDeleteJobs` reaches
23
- // `payload.delete`), so a completed job for the same (document, locale) would be erased along
24
- // with the pending one.
25
- const existing = await this.findByCollection(collectionSlug, {
26
- documentIds,
27
- excludeCompleted: true
28
- });
29
- // Supersede only jobs for the SAME (document, target locale) being re-enqueued — never a
30
- // concurrent job for a *different* locale of the same document. Cancelling per-document would
31
- // kill an in-flight translation of another locale (the concurrent re-translate bug).
32
- const supersededKeys = new Set(items.map((t)=>documentLocaleKey(t.collectionId, t.targetLng)));
33
- const toCancel = existing.filter((t)=>supersededKeys.has(documentLocaleKey(t.input.collectionId, t.input.targetLng)));
34
- if (toCancel.length > 0) {
35
- await this.cancelAndDeleteJobs(toCancel.map((t)=>t.id));
35
+ const byRequest = new Map();
36
+ for (const task of tasks){
37
+ const key = requestKey(task);
38
+ const group = byRequest.get(key) ?? [];
39
+ group.push(task);
40
+ byRequest.set(key, group);
41
+ }
42
+ const live = await this.findRawJobs({
43
+ completedAt: {
44
+ exists: false
36
45
  }
46
+ });
47
+ const liveByDocument = new Map();
48
+ for (const job of live){
49
+ const { collectionSlug, collectionId } = readCollectionRef(job.input);
50
+ const key = documentKey(collectionSlug, collectionId);
51
+ liveByDocument.set(key, [
52
+ ...liveByDocument.get(key) ?? [],
53
+ job
54
+ ]);
37
55
  }
38
- await Promise.all(tasks.map((task)=>this.payload.jobs.queue({
39
- task: this.config.taskName,
40
- queue: this.config.queueName,
41
- // Debounce: when set, Payload holds the job until this instant. A superseding enqueue for
42
- // the same (document, targetLng) cancels the pending delayed job first (see enqueue above),
43
- // so rapid source edits coalesce to the final one. Undefined for the manual path.
44
- waitUntil: task.waitUntil,
45
- input: {
46
- // Flat text reference (ID-agnostic). Stored as a string — no
47
- // relationship type validation against the collection's ID type,
48
- // which is what previously left number-id jobs stuck in processing.
49
- // This is the single write boundary, so `String(...)` here is the
50
- // one place IDs are normalized for storage.
51
- collection_slug: task.collectionSlug,
52
- collection_id: String(task.collectionId),
53
- source_lng: task.sourceLng,
54
- target_lng: task.targetLng,
55
- strategy: task.strategy,
56
- publish_on_translation: task.publishOnTranslation
57
- }
58
- })));
56
+ const exclusiveQueue = Boolean(this.payload.config.jobs?.enableConcurrencyControl);
57
+ const groups = [
58
+ ...byRequest.values()
59
+ ];
60
+ for(let i = 0; i < groups.length; i += ENQUEUE_CONCURRENCY){
61
+ await Promise.all(groups.slice(i, i + ENQUEUE_CONCURRENCY).map((group)=>this.serve(group, liveByDocument, exclusiveQueue)));
62
+ }
63
+ }
64
+ async serve(group, liveByDocument, exclusiveQueue) {
65
+ const [first] = group;
66
+ const request = requestShape(first);
67
+ const plan = planEnqueue({
68
+ live: liveByDocument.get(documentKey(request.collectionSlug, request.collectionId)) ?? [],
69
+ request,
70
+ requested: group.map((t)=>t.targetLng),
71
+ exclusiveQueue
72
+ });
73
+ const undelivered = plan.host ? await this.extendJob(plan.host, plan.append, first.waitUntil) : [];
74
+ const queue = [
75
+ ...plan.queue,
76
+ ...undelivered
77
+ ];
78
+ if (queue.length > 0) await this.queueWorkflow(request, queue, first.waitUntil);
79
+ }
80
+ async extendJob(job, locales, waitUntil) {
81
+ let current = job;
82
+ let undelivered = locales;
83
+ // `input` is one JSON column, so a concurrent append replaces the whole list; the union makes a
84
+ // retry from the stored row harmless.
85
+ for(let attempt = 0; attempt < APPEND_ATTEMPTS; attempt++){
86
+ const listed = current.input?.target_lngs ?? [];
87
+ const missing = locales.filter((locale)=>!listed.includes(locale));
88
+ const debounce = waitUntil && !current.processing ? waitUntil.toISOString() : undefined;
89
+ if (missing.length === 0 && !debounce) return [];
90
+ // Not `payload.update`: it rewrites the whole row and reverts log entries written in between.
91
+ // See D2 of docs/plans/2026-09-08-one-live-job-per-document.task.md.
92
+ await this.payload.db.updateOne({
93
+ collection: this.config.jobsCollection,
94
+ id: job.id,
95
+ data: {
96
+ input: {
97
+ ...current.input,
98
+ target_lngs: [
99
+ ...listed,
100
+ ...missing
101
+ ]
102
+ },
103
+ ...debounce ? {
104
+ waitUntil: debounce
105
+ } : {}
106
+ },
107
+ returning: false
108
+ });
109
+ const reread = await this.findJobById(job.id);
110
+ if (!reread || reread.completedAt) return locales;
111
+ current = reread;
112
+ const stored = new Set(current.input?.target_lngs);
113
+ undelivered = locales.filter((locale)=>!stored.has(locale));
114
+ if (undelivered.length === 0) return [];
115
+ }
116
+ return undelivered;
117
+ }
118
+ async queueWorkflow(request, targetLngs, waitUntil) {
119
+ const input = {
120
+ collection_slug: request.collectionSlug,
121
+ collection_id: request.collectionId,
122
+ source_lng: request.sourceLng,
123
+ target_lngs: targetLngs,
124
+ strategy: request.strategy,
125
+ publish_on_translation: request.publishOnTranslation
126
+ };
127
+ // Cast: `jobs.queue` is typed over the host's generated slugs, which cannot include a workflow
128
+ // registered at config time.
129
+ const queueJob = this.payload.jobs.queue;
130
+ await queueJob({
131
+ workflow: this.config.workflowName,
132
+ queue: this.config.queueName,
133
+ waitUntil,
134
+ input
135
+ });
59
136
  }
60
137
  async cancel(taskIds) {
61
138
  if (taskIds.length === 0) return;
62
- await this.cancelAndDeleteJobs(taskIds);
63
- }
64
- async run(taskId) {
65
- const tasks = await this.findJobsInternal({
66
- id: {
67
- equals: taskId
139
+ // Mark then delete: the delete alone would take the row out of the status feed under
140
+ // `deleteJobOnComplete: false` without recording why it went. The mark does not reach a running
141
+ // handler — see D1 of docs/plans/2026-09-08-one-live-job-per-document.task.md.
142
+ await this.payload.jobs.cancel({
143
+ where: {
144
+ id: {
145
+ in: taskIds
146
+ }
147
+ },
148
+ queue: this.config.queueName
149
+ });
150
+ await this.payload.delete({
151
+ collection: this.config.jobsCollection,
152
+ where: {
153
+ and: [
154
+ this.ownJobs(),
155
+ {
156
+ id: {
157
+ in: taskIds
158
+ }
159
+ }
160
+ ]
68
161
  }
69
- }, {
70
- limit: 1
71
162
  });
72
- const task = tasks[0];
73
- if (!task) {
163
+ }
164
+ async run(taskId) {
165
+ const job = await this.findJobById(taskId);
166
+ if (!job) {
74
167
  return {
75
168
  success: false,
76
169
  error: "not_found"
77
170
  };
78
171
  }
79
- if (task.completedAt) {
172
+ if (job.completedAt) {
80
173
  return {
81
174
  success: false,
82
175
  error: "already_completed"
83
176
  };
84
177
  }
85
- if (task.status === "running") {
86
- // The picker below selects only `processing: false`, so a stale lock must be cleared first.
87
- if (!this.isStale(task.updatedAt)) {
88
- return {
89
- success: false,
90
- error: "already_running"
91
- };
92
- }
93
- await this.resetProcessing({
94
- id: {
95
- equals: taskId
96
- }
97
- });
178
+ if (job.processing && !this.isStale(job.updatedAt)) {
179
+ return {
180
+ success: false,
181
+ error: "already_running"
182
+ };
98
183
  }
99
- // `where` picker, not `payload.jobs.runByID({ id })`: in `runJobs` the guard block
100
- // (processing:false, hasError not true, waitUntil due) is built only for the non-id branch, so
101
- // the id path would re-run a job that already exhausted its retries. Checked against payload
102
- // 3.84.1.
103
- await this.payload.jobs.run({
184
+ if (job.processing || job.error) {
185
+ await this.clearPickerBlockers(taskId);
186
+ }
187
+ // Not `jobs.runByID`: payload 3.84.1 builds the picker guard (processing / hasError / waitUntil)
188
+ // only on the `where` path, so the id path re-runs a job that exhausted its retries.
189
+ const result = await this.payload.jobs.run({
104
190
  queue: this.config.queueName,
105
191
  where: {
106
192
  id: {
@@ -109,68 +195,81 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
109
195
  },
110
196
  limit: 1
111
197
  });
198
+ const pickerTookNothing = Object.keys(result?.jobStatus ?? {}).length === 0;
199
+ if (pickerTookNothing) {
200
+ return {
201
+ success: false,
202
+ error: "already_running"
203
+ };
204
+ }
112
205
  return {
113
206
  success: true
114
207
  };
115
208
  }
116
209
  /**
117
- * Clear stale `processing` locks still processing, not completed, `updatedAt` older than
118
- * `staleJobTimeoutMs` so abandoned jobs are eligible for the autorun picker again. A job that
119
- * exhausted its retries carries `hasError: true` and stays excluded from autorun even after its
120
- * lock is cleared; only a manual `run()` recovers it.
210
+ * A job that exhausted its retries carries `hasError: true` and stays excluded from the picker even
211
+ * after its lock is cleared; only a manual `run()` recovers it.
121
212
  * @returns how many locks were cleared.
122
213
  */ async reclaimStaleJobs() {
123
214
  const cutoff = new Date(Date.now() - this.config.staleJobTimeoutMs).toISOString();
124
- return this.resetProcessing({
125
- and: [
126
- {
127
- taskSlug: {
128
- equals: this.config.taskName
129
- }
130
- },
131
- {
132
- processing: {
133
- equals: true
134
- }
135
- },
136
- {
137
- completedAt: {
138
- exists: false
139
- }
140
- },
141
- {
142
- updatedAt: {
143
- less_than: cutoff
144
- }
145
- }
146
- ]
147
- });
148
- }
149
- /** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */ async resetProcessing(where) {
150
215
  const result = await this.payload.update({
151
216
  collection: this.config.jobsCollection,
152
217
  depth: 0,
153
- where,
218
+ where: {
219
+ and: [
220
+ this.ownJobs(),
221
+ {
222
+ processing: {
223
+ equals: true
224
+ }
225
+ },
226
+ {
227
+ completedAt: {
228
+ exists: false
229
+ }
230
+ },
231
+ {
232
+ updatedAt: {
233
+ less_than: cutoff
234
+ }
235
+ }
236
+ ]
237
+ },
154
238
  data: {
155
239
  processing: false
156
240
  }
157
241
  });
158
242
  return result.docs.length;
159
243
  }
244
+ /**
245
+ * `payload.update`, not the adapter write `extendJob` uses, so the jobs collection's `beforeChange`
246
+ * hook still runs — it is what keeps a cancelled job cancelled.
247
+ */ async clearPickerBlockers(taskId) {
248
+ await this.payload.update({
249
+ collection: this.config.jobsCollection,
250
+ depth: 0,
251
+ where: {
252
+ id: {
253
+ equals: taskId
254
+ }
255
+ },
256
+ data: {
257
+ processing: false,
258
+ hasError: false,
259
+ error: null,
260
+ waitUntil: null
261
+ }
262
+ });
263
+ }
160
264
  isStale(updatedAt) {
161
265
  const parsed = Date.parse(updatedAt);
162
- // Unknown/corrupt timestamp → treat as stale so the job can be recovered
163
- // rather than permanently refused as already-running.
164
266
  if (Number.isNaN(parsed)) return true;
165
267
  return Date.now() - parsed > this.config.staleJobTimeoutMs;
166
268
  }
167
269
  /**
168
- * Find translation jobs for a collection.
169
- *
170
- * Only `taskSlug` and `completedAt` reach the database; slug and document ids are matched in memory
171
- * because a job's collection reference may sit in either the flat-text fields or the legacy
172
- * relationship shape (`readCollectionRef`), so a `where` on `input.collection_slug` would silently
173
- * drop every pre-migration job. `excludeCompleted` is what bounds the read — see issue #108.
270
+ * Matched in memory, not in a `where`: a job's collection reference sits either in the flat text
271
+ * fields or in the legacy relationship shape (see `readCollectionRef`), so `input.collection_slug`
272
+ * as a filter would silently drop every pre-migration job.
174
273
  */ async findByCollection(collectionSlug, filter) {
175
274
  const { documentIds, excludeCompleted } = toTaskFilter(filter);
176
275
  const where = excludeCompleted ? {
@@ -178,64 +277,54 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
178
277
  exists: false
179
278
  }
180
279
  } : undefined;
181
- const all = await this.findJobsInternal(where, {
182
- pagination: false
183
- });
184
- const bySlug = all.filter((t)=>t.input.collectionSlug === collectionSlug);
185
- if (!documentIds?.length) return bySlug;
186
- const wanted = new Set(documentIds.map(String));
187
- return bySlug.filter((t)=>wanted.has(t.input.collectionId));
188
- }
189
- groupByCollection(tasks) {
190
- const map = new Map();
191
- for (const task of tasks){
192
- const existing = map.get(task.collectionSlug) ?? [];
193
- existing.push(task);
194
- map.set(task.collectionSlug, existing);
195
- }
196
- return map;
280
+ const wanted = documentIds?.length ? new Set(documentIds.map(String)) : undefined;
281
+ const jobs = await this.findRawJobs(where);
282
+ return jobs.filter((job)=>{
283
+ const ref = readCollectionRef(job.input);
284
+ return ref.collectionSlug === collectionSlug && (!wanted || wanted.has(ref.collectionId));
285
+ }).flatMap(normalizeJobLocales);
197
286
  }
198
- async cancelAndDeleteJobs(taskIds) {
199
- if (taskIds.length === 0) return;
200
- // Both, in this order: `jobs.cancel` only writes `{ error: { cancelled: true }, hasError: true,
201
- // processing: false }`, which is what signals a running handler to abort. The delete then removes
202
- // the row — under `deleteJobOnComplete: false` a cancelled job would otherwise sit in the status
203
- // feed forever.
204
- await this.payload.jobs.cancel({
205
- where: {
206
- id: {
207
- in: taskIds
208
- }
209
- },
210
- queue: this.config.queueName
211
- });
212
- await this.payload.delete({
213
- collection: this.config.jobsCollection,
214
- where: {
215
- id: {
216
- in: taskIds
287
+ ownJobs() {
288
+ // Pre-workflow jobs are still in the table: docs/DEPRECATIONS.md#jobs-per-locale-task-shape
289
+ return {
290
+ or: [
291
+ {
292
+ workflowSlug: {
293
+ equals: this.config.workflowName
294
+ }
295
+ },
296
+ {
297
+ taskSlug: {
298
+ equals: this.config.taskName
299
+ }
217
300
  }
301
+ ]
302
+ };
303
+ }
304
+ async findJobById(id) {
305
+ const [job] = await this.findRawJobs({
306
+ id: {
307
+ equals: id
218
308
  }
219
309
  });
310
+ return job;
220
311
  }
221
- async findJobsInternal(where, params) {
312
+ async findRawJobs(where) {
222
313
  const and = [
223
- {
224
- taskSlug: {
225
- equals: this.config.taskName
226
- }
227
- }
314
+ this.ownJobs()
228
315
  ];
229
316
  if (where) and.push(where);
230
317
  const response = await this.payload.find({
231
318
  collection: this.config.jobsCollection,
232
- limit: params?.limit,
233
- pagination: params?.pagination,
319
+ // The legacy `input.collection` is a declared relationship; at the default depth Payload
320
+ // populates it, and `readCollectionRef` would then read a document where it wants an id.
321
+ depth: 0,
322
+ pagination: false,
234
323
  where: {
235
324
  and
236
325
  }
237
326
  });
238
- return response.docs.map(normalizeJob);
327
+ return response.docs;
239
328
  }
240
329
  }
241
330
 
@@ -1,6 +1,14 @@
1
1
  import type { Task } from "../types";
2
- import type { PayloadJob } from "./types";
2
+ import type { JobLogEntry, PayloadJob } from "./types";
3
+ export declare function isCancelled(error: unknown): boolean;
4
+ export declare function normalizeJob(job: PayloadJob): Task;
3
5
  /**
4
- * Transform Payload job to normalized Task
6
+ * One {@link Task} per target locale, its state read from {@link latestLogByLocale}. A pre-workflow
7
+ * job carries a single `target_lng` and expands to itself.
5
8
  */
6
- export declare function normalizeJob(job: PayloadJob): Task;
9
+ export declare function normalizeJobLocales(job: PayloadJob): Task[];
10
+ /**
11
+ * Each locale's most recent log entry: Payload appends to `log` chronologically, so last-write-wins
12
+ * leaves the latest attempt.
13
+ */
14
+ export declare function latestLogByLocale(job: PayloadJob): Map<string, JobLogEntry>;
@@ -1,7 +1,20 @@
1
1
  import { readCollectionRef } from "./readCollectionRef";
2
- /**
3
- * Transform Payload job to normalized Task
4
- */ export function normalizeJob(job) {
2
+ function getJobStatus(job) {
3
+ if (job.completedAt) return "completed";
4
+ if (job.processing) return "running";
5
+ if (job.error) return "failed";
6
+ return "pending";
7
+ }
8
+ function extractErrorMessage(error) {
9
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
10
+ return error.message;
11
+ }
12
+ return "Unknown error";
13
+ }
14
+ export function isCancelled(error) {
15
+ return error !== null && typeof error === "object" && "cancelled" in error && typeof error.cancelled === "boolean" && error.cancelled;
16
+ }
17
+ export function normalizeJob(job) {
5
18
  const { collectionSlug, collectionId } = readCollectionRef(job.input);
6
19
  return {
7
20
  id: job.id,
@@ -23,20 +36,49 @@ import { readCollectionRef } from "./readCollectionRef";
23
36
  cancelled: isCancelled(job.error)
24
37
  };
25
38
  }
26
- function getJobStatus(job) {
27
- if (job.completedAt) return "completed";
28
- if (job.processing) return "running";
29
- if (job.error) return "failed";
30
- return "pending";
39
+ /**
40
+ * One {@link Task} per target locale, its state read from {@link latestLogByLocale}. A pre-workflow
41
+ * job carries a single `target_lng` and expands to itself.
42
+ */ export function normalizeJobLocales(job) {
43
+ const targets = job.input?.target_lngs;
44
+ if (!Array.isArray(targets) || targets.length === 0) return [
45
+ normalizeJob(job)
46
+ ];
47
+ const base = normalizeJob(job);
48
+ const latestByLocale = latestLogByLocale(job);
49
+ return targets.map((targetLng)=>{
50
+ const entry = latestByLocale.get(targetLng);
51
+ if (!entry) return {
52
+ ...base,
53
+ input: {
54
+ ...base.input,
55
+ targetLng
56
+ }
57
+ };
58
+ const succeeded = entry.state === "succeeded";
59
+ return {
60
+ ...base,
61
+ status: succeeded ? "completed" : "failed",
62
+ completedAt: succeeded ? entry.completedAt ?? undefined : undefined,
63
+ error: succeeded ? undefined : base.error,
64
+ cancelled: succeeded ? false : base.cancelled,
65
+ input: {
66
+ ...base.input,
67
+ targetLng
68
+ }
69
+ };
70
+ });
31
71
  }
32
- function extractErrorMessage(error) {
33
- if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
34
- return error.message;
72
+ /**
73
+ * Each locale's most recent log entry: Payload appends to `log` chronologically, so last-write-wins
74
+ * leaves the latest attempt.
75
+ */ export function latestLogByLocale(job) {
76
+ const byLocale = new Map();
77
+ for (const entry of job.log ?? []){
78
+ const lng = entry?.input?.target_lng;
79
+ if (typeof lng === "string") byLocale.set(lng, entry);
35
80
  }
36
- return "Unknown error";
37
- }
38
- function isCancelled(error) {
39
- return error !== null && typeof error === "object" && "cancelled" in error && typeof error.cancelled === "boolean" && error.cancelled;
81
+ return byLocale;
40
82
  }
41
83
 
42
84
  //# sourceMappingURL=normalizeJob.js.map
@@ -0,0 +1,33 @@
1
+ import type { CollectionSlug } from "payload";
2
+ import type { PayloadJob } from "./types";
3
+ /** The part of a request that every locale in it shares. */
4
+ export type RequestShape = {
5
+ collectionSlug: CollectionSlug;
6
+ collectionId: string;
7
+ sourceLng: string;
8
+ strategy: string;
9
+ publishOnTranslation: boolean;
10
+ };
11
+ /**
12
+ * `append` and `queue` are independent: a request can both extend a live job and need a job of its
13
+ * own, when a locale it asks for has already been translated by that job.
14
+ */
15
+ export type EnqueuePlan = {
16
+ host: PayloadJob | null;
17
+ append: string[];
18
+ queue: string[];
19
+ };
20
+ /**
21
+ * One live job per document: a later request extends that job's locale list rather than replacing it.
22
+ *
23
+ * @param live - non-completed jobs for **this document only**; the caller filters by document.
24
+ * @param requested - target locales in request order; duplicates ignored.
25
+ * @param exclusiveQueue - the host's `enableConcurrencyControl`; with it on a running job is queued
26
+ * behind rather than extended.
27
+ */
28
+ export declare function planEnqueue(args: {
29
+ live: PayloadJob[];
30
+ request: RequestShape;
31
+ requested: string[];
32
+ exclusiveQueue: boolean;
33
+ }): EnqueuePlan;
@@ -0,0 +1,51 @@
1
+ import { isCancelled, latestLogByLocale } from "./normalizeJob";
2
+ /**
3
+ * One live job per document: a later request extends that job's locale list rather than replacing it.
4
+ *
5
+ * @param live - non-completed jobs for **this document only**; the caller filters by document.
6
+ * @param requested - target locales in request order; duplicates ignored.
7
+ * @param exclusiveQueue - the host's `enableConcurrencyControl`; with it on a running job is queued
8
+ * behind rather than extended.
9
+ */ export function planEnqueue(args) {
10
+ const requested = [
11
+ ...new Set(args.requested)
12
+ ];
13
+ const host = pickHost(args.live, args.request);
14
+ if (!host) return {
15
+ host: null,
16
+ append: [],
17
+ queue: requested
18
+ };
19
+ // Already-succeeded locales need a fresh job: the workflow passes the locale as the task id, so
20
+ // Payload's restoration would skip them (see the workflow handler in PayloadJobsRunnerProvider).
21
+ const settled = latestLogByLocale(host);
22
+ const done = requested.filter((locale)=>settled.get(locale)?.state === "succeeded");
23
+ const listed = new Set(host.input?.target_lngs);
24
+ const missing = requested.filter((locale)=>!listed.has(locale));
25
+ if (args.exclusiveQueue && host.processing) {
26
+ return {
27
+ host: null,
28
+ append: [],
29
+ queue: [
30
+ ...missing,
31
+ ...done
32
+ ]
33
+ };
34
+ }
35
+ return {
36
+ host,
37
+ append: missing,
38
+ queue: done
39
+ };
40
+ }
41
+ /**
42
+ * A job carries one source locale, one strategy and one publish flag for all of its locales, so it can
43
+ * host only a request that chose the same three — otherwise the request runs under settings the user
44
+ * did not pick.
45
+ */ function pickHost(live, request) {
46
+ const usable = live.filter((job)=>Array.isArray(job.input?.target_lngs) && !isCancelled(job.error) && job.input?.source_lng === request.sourceLng && job.input?.strategy === request.strategy && (job.input?.publish_on_translation ?? false) === request.publishOnTranslation);
47
+ const newestFirst = usable.sort((a, b)=>Date.parse(b.createdAt) - Date.parse(a.createdAt));
48
+ return newestFirst[0] ?? null;
49
+ }
50
+
51
+ //# sourceMappingURL=planEnqueue.js.map
@@ -72,16 +72,16 @@ export type PayloadJobsRunnerOptions = {
72
72
  */
73
73
  export type PayloadJobsRunnerConfig = {
74
74
  taskName: string;
75
+ /** Derived from `taskName`; deliberately not a plugin option. */
76
+ workflowName: string;
75
77
  queueName: string;
76
78
  jobsCollection: CollectionSlug;
77
79
  autoRun: false | Required<AutoRunConfig>;
78
80
  staleJobTimeoutMs: number;
79
81
  retries?: PayloadJobsRunnerOptions["retries"];
80
82
  };
81
- /**
82
- * Raw Payload job structure
83
- */
84
83
  export type PayloadJob = {
84
+ log?: JobLogEntry[];
85
85
  id: string;
86
86
  completedAt?: string | null;
87
87
  createdAt: string;
@@ -103,7 +103,25 @@ export type PayloadJob = {
103
103
  };
104
104
  source_lng?: string;
105
105
  target_lng?: string;
106
+ target_lngs?: string[];
106
107
  strategy?: string;
107
108
  publish_on_translation?: boolean;
108
109
  };
109
110
  };
111
+ /** Snake_case because Payload persists these keys verbatim in the job row. */
112
+ export type StoredWorkflowInput = {
113
+ collection_slug: CollectionSlug;
114
+ collection_id: string;
115
+ source_lng: string;
116
+ target_lngs: string[];
117
+ strategy: string;
118
+ publish_on_translation: boolean;
119
+ };
120
+ /** Written by Payload only once a task settles — an absent entry means that locale has not run. */
121
+ export type JobLogEntry = {
122
+ state: "succeeded" | "failed";
123
+ completedAt?: string | null;
124
+ input?: {
125
+ target_lng?: string;
126
+ };
127
+ };
@@ -1,5 +1,3 @@
1
- /**
2
- * Raw Payload job structure
3
- */ export { };
1
+ /** Written by Payload only once a task settles — an absent entry means that locale has not run. */ export { };
4
2
 
5
3
  //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",