@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.
- package/README.md +37 -0
- package/dist/client/entities/translation/model/statusRows.js +1 -1
- package/dist/client/entities/translation/model/types.d.ts +4 -4
- package/dist/client/features/collection-translation-form/model/schema.d.ts +2 -2
- package/dist/client/features/translate-document-form/model/schema.d.ts +1 -1
- package/dist/core/translation-pipeline/stages/field-collector/FieldChunkCollector.js +4 -1
- package/dist/server/features/cancel-by-collection/handler.d.ts +1 -3
- package/dist/server/features/cancel-by-collection/handler.js +13 -8
- package/dist/server/features/enqueue-translation/handler.js +0 -2
- package/dist/server/features/enqueue-translation/model.d.ts +1 -1
- package/dist/server/modules/provenance/Provenance.service.d.ts +6 -3
- package/dist/server/modules/provenance/Provenance.service.js +6 -3
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.d.ts +0 -23
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.js +41 -44
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +16 -20
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +248 -159
- package/dist/server/modules/task-runner/payload-jobs-runner/normalizeJob.d.ts +11 -3
- package/dist/server/modules/task-runner/payload-jobs-runner/normalizeJob.js +57 -15
- package/dist/server/modules/task-runner/payload-jobs-runner/planEnqueue.d.ts +33 -0
- package/dist/server/modules/task-runner/payload-jobs-runner/planEnqueue.js +51 -0
- package/dist/server/modules/task-runner/payload-jobs-runner/types.d.ts +21 -3
- package/dist/server/modules/task-runner/payload-jobs-runner/types.js +1 -3
- 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
|
|
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
|
-
|
|
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
|
|
40
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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;
|
|
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
|
-
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
38
|
-
private
|
|
39
|
-
private
|
|
33
|
+
private ownJobs;
|
|
34
|
+
private findJobById;
|
|
35
|
+
private findRawJobs;
|
|
40
36
|
}
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { toTaskFilter } from "../toTaskFilter";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
20
|
-
for (const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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 (
|
|
172
|
+
if (job.completedAt) {
|
|
80
173
|
return {
|
|
81
174
|
success: false,
|
|
82
175
|
error: "already_completed"
|
|
83
176
|
};
|
|
84
177
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// 3.84.1
|
|
103
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@focus-reactive/payload-plugin-translator",
|
|
3
|
-
"version": "0.11.
|
|
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",
|