@focus-reactive/payload-plugin-translator 0.11.1 → 0.11.3
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 +34 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/server/modules/lifecycle/withQueuedNotification.js +4 -1
- package/dist/server/modules/task-runner/TaskRunner.interface.d.ts +20 -2
- package/dist/server/modules/task-runner/TaskRunner.interface.js +3 -5
- package/dist/server/modules/task-runner/index.d.ts +2 -1
- package/dist/server/modules/task-runner/index.js +1 -0
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +14 -75
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +42 -93
- package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.d.ts +2 -2
- package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.js +7 -1
- package/dist/server/modules/task-runner/toTaskFilter.d.ts +11 -0
- package/dist/server/modules/task-runner/toTaskFilter.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -618,6 +618,40 @@ Payload lets a wrapper field (group, array, blocks, tabs) be `localized`, which
|
|
|
618
618
|
|
|
619
619
|
See the `deleteJobOnComplete: false` note under [Runners](#createpayloadjobsrunneroptions-recommended).
|
|
620
620
|
|
|
621
|
+
Keeping them means they accumulate: nothing in the plugin removes a completed translation job, and the
|
|
622
|
+
status panels read a collection's jobs on every open. How long that history is worth keeping is your
|
|
623
|
+
call, not the plugin's, so pruning is left to you — the same way `deleteJobOnComplete` is.
|
|
624
|
+
|
|
625
|
+
_Applies only to `createPayloadJobsRunner`._ `createSyncRunner` translates inline and writes no jobs at
|
|
626
|
+
all, so there is nothing to prune.
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
// Run from your own cron / scheduled task.
|
|
630
|
+
// Must match the `taskName` you passed to createPayloadJobsRunner — "translate_document" by default.
|
|
631
|
+
const TRANSLATOR_TASK = "translate_document";
|
|
632
|
+
|
|
633
|
+
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
634
|
+
|
|
635
|
+
await payload.delete({
|
|
636
|
+
collection: "payload-jobs",
|
|
637
|
+
where: {
|
|
638
|
+
and: [
|
|
639
|
+
{ taskSlug: { equals: TRANSLATOR_TASK } },
|
|
640
|
+
{ completedAt: { exists: true } },
|
|
641
|
+
{ completedAt: { less_than: cutoff } },
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
Three things the clauses buy you, in order: only this plugin's jobs, only finished ones — so nothing
|
|
648
|
+
queued or in flight is touched — and only those older than your cutoff, so a run someone may still want
|
|
649
|
+
to look at survives. Keep a cutoff of at least a day for that last reason; deleting everything
|
|
650
|
+
completed would erase a translation that finished minutes ago.
|
|
651
|
+
|
|
652
|
+
All three filter on real columns rather than paths inside the job's JSON input, so this behaves the same
|
|
653
|
+
on SQLite, Postgres and MongoDB.
|
|
654
|
+
|
|
621
655
|
## TypeScript
|
|
622
656
|
|
|
623
657
|
The package ships its types. Besides the factories, the following are exported for typing your own code:
|
package/dist/index.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export { createTranslationProvider } from "./translation-providers";
|
|
|
11
11
|
export type { CompletionFn, CompletionRequest, TranslationProviderConfig, JsonSchemaObject, SystemPromptBuilder, SystemPromptContext, } from "./translation-providers";
|
|
12
12
|
export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError, } from "./translation-providers";
|
|
13
13
|
export type { TranslationFailureCode } from "./translation-providers";
|
|
14
|
-
export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
|
|
15
|
-
export type { TaskRunnerProvider, PayloadJobsRunnerOptions } from "./server/modules/task-runner";
|
|
14
|
+
export { createPayloadJobsRunner, createSyncRunner, toTaskFilter, } from "./server/modules/task-runner";
|
|
15
|
+
export type { TaskRunnerProvider, PayloadJobsRunnerOptions, TaskFilter, } from "./server/modules/task-runner";
|
|
16
16
|
export { documentLevel, collectionLevel, fieldLevel } from "./composition/levels";
|
|
17
17
|
export type { TranslationLevel } from "./server/modules/translation-levels";
|
|
18
18
|
export { withFieldTranslation } from "./field-config";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { openAIComplete } from "./translation-providers";
|
|
|
4
4
|
export { createTranslationProvider } from "./translation-providers";
|
|
5
5
|
export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError } from "./translation-providers";
|
|
6
6
|
// Task runners
|
|
7
|
-
export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
|
|
7
|
+
export { createPayloadJobsRunner, createSyncRunner, toTaskFilter } from "./server/modules/task-runner";
|
|
8
8
|
// Translation levels
|
|
9
9
|
export { documentLevel, collectionLevel, fieldLevel } from "./composition/levels";
|
|
10
10
|
// Field config
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toTaskFilter } from "../task-runner/toTaskFilter";
|
|
1
2
|
import { taskFromInput } from "./taskMapping";
|
|
2
3
|
/**
|
|
3
4
|
* Decorate a {@link TaskRunner} so `enqueue` fires the `queued` lifecycle callback for each task.
|
|
@@ -12,7 +13,9 @@ import { taskFromInput } from "./taskMapping";
|
|
|
12
13
|
},
|
|
13
14
|
cancel: (taskIds)=>runner.cancel(taskIds),
|
|
14
15
|
run: (taskId)=>runner.run(taskId),
|
|
15
|
-
|
|
16
|
+
// Normalized rather than forwarded as-is: the wrapper's own signature comes from the overload
|
|
17
|
+
// pair, so it cannot pass the deprecated array form straight through.
|
|
18
|
+
findByCollection: (collectionSlug, filter)=>runner.findByCollection(collectionSlug, toTaskFilter(filter))
|
|
16
19
|
};
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -24,7 +24,25 @@ export interface TaskRunner {
|
|
|
24
24
|
*/
|
|
25
25
|
run(taskId: string): Promise<RunResult>;
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* @deprecated Pass `{ documentIds }` instead. Removed in the next major.
|
|
28
|
+
* See docs/DEPRECATIONS.md#find-by-collection-document-ids-array
|
|
28
29
|
*/
|
|
29
|
-
findByCollection(collectionSlug: CollectionSlug, documentIds
|
|
30
|
+
findByCollection(collectionSlug: CollectionSlug, documentIds: Array<string | number>): Promise<Task[]>;
|
|
31
|
+
/** Find tasks for a collection, optionally narrowed by a {@link TaskFilter}. */
|
|
32
|
+
findByCollection(collectionSlug: CollectionSlug, filter?: TaskFilter): Promise<Task[]>;
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* How a {@link TaskRunner.findByCollection} call is narrowed. Each field says whether it reaches the
|
|
36
|
+
* database or is applied in memory over everything the database returned.
|
|
37
|
+
*
|
|
38
|
+
* @since 0.11.2
|
|
39
|
+
*/
|
|
40
|
+
export type TaskFilter = {
|
|
41
|
+
/** Keep only tasks for these documents. Applied in memory — see `PayloadJobsTaskRunner.findByCollection`. */
|
|
42
|
+
documentIds?: Array<string | number>;
|
|
43
|
+
/**
|
|
44
|
+
* Drop tasks that have finished. Keeps running and failed ones — everything a re-enqueue can still
|
|
45
|
+
* supersede — so it is wider than the `pending` status.
|
|
46
|
+
*/
|
|
47
|
+
excludeCompleted?: boolean;
|
|
48
|
+
};
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* How a {@link TaskRunner.findByCollection} call is narrowed. Each field says whether it reaches the
|
|
3
|
+
* database or is applied in memory over everything the database returned.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* and execution of translation tasks. All business logic
|
|
6
|
-
* (like cancelling existing tasks before enqueue) is encapsulated
|
|
7
|
-
* within the implementation.
|
|
5
|
+
* @since 0.11.2
|
|
8
6
|
*/ export { };
|
|
9
7
|
|
|
10
8
|
//# sourceMappingURL=TaskRunner.interface.js.map
|
|
@@ -3,4 +3,5 @@ export type { Task, TaskStatus } from "./types";
|
|
|
3
3
|
export { createPayloadJobsRunner } from "./payload-jobs-runner";
|
|
4
4
|
export type { PayloadJobsRunnerOptions } from "./payload-jobs-runner";
|
|
5
5
|
export { createSyncRunner } from "./sync-runner";
|
|
6
|
-
export type { TaskRunner } from "./TaskRunner.interface";
|
|
6
|
+
export type { TaskFilter, TaskRunner } from "./TaskRunner.interface";
|
|
7
|
+
export { toTaskFilter } from "./toTaskFilter";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Payload, CollectionSlug } from "payload";
|
|
2
|
-
import type { TaskRunner } from "../TaskRunner.interface";
|
|
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
5
|
/**
|
|
@@ -15,87 +15,26 @@ export declare class PayloadJobsTaskRunner implements TaskRunner {
|
|
|
15
15
|
cancel(taskIds: string[]): Promise<void>;
|
|
16
16
|
run(taskId: string): Promise<RunResult>;
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* autorun picker again.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* autorun and is only recoverable via a manual run().
|
|
24
|
-
*
|
|
25
|
-
* A job is stale when it is still `processing: true`, not yet completed, and
|
|
26
|
-
* its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
|
|
27
|
-
* killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
|
|
28
|
-
* in flight on another live instance (fresh `updatedAt`) is left alone.
|
|
29
|
-
* Filters on real `payload-jobs` columns only (no JSON-path traversal), so
|
|
30
|
-
* the drizzle SQLite issue in `findByCollection` does not apply here.
|
|
31
|
-
* @returns the number of jobs reclaimed.
|
|
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.
|
|
22
|
+
* @returns how many locks were cleared.
|
|
32
23
|
*/
|
|
33
24
|
reclaimStaleJobs(): Promise<number>;
|
|
34
|
-
/**
|
|
35
|
-
* Clear the `processing` lock on every job matching `where`, returning how
|
|
36
|
-
* many were reset. Shared by the per-job reset in `run()` (a stale lock) and
|
|
37
|
-
* the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
|
|
38
|
-
* only the count is needed — no relationships to populate.
|
|
39
|
-
*/
|
|
25
|
+
/** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */
|
|
40
26
|
private resetProcessing;
|
|
41
|
-
/**
|
|
42
|
-
* A processing lock is stale once `updatedAt` is older than the configured
|
|
43
|
-
* timeout — the owning run is presumed dead.
|
|
44
|
-
*/
|
|
45
27
|
private isStale;
|
|
46
28
|
/**
|
|
47
|
-
* Find translation jobs for a collection
|
|
48
|
-
*
|
|
49
|
-
* Narrowing is by `taskSlug` only in SQL; the collection slug and document
|
|
50
|
-
* IDs are matched in memory (via the normalized `Task`, which reads both the
|
|
51
|
-
* current flat-text shape and the legacy relationship shape). This is the
|
|
52
|
-
* one path that must transparently span both stored shapes during the
|
|
53
|
-
* ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
|
|
54
|
-
*
|
|
55
|
-
* IMPORTANT — why slug/id are matched in memory, not in the SQL WHERE
|
|
56
|
-
* ------------------------------------------------------------------------
|
|
57
|
-
* The "natural" implementation would push `input.collection_id` into the
|
|
58
|
-
* where clause. This DOES NOT work on SQLite (and is unreliable on any
|
|
59
|
-
* adapter) because of two compounding bugs in Payload's drizzle layer.
|
|
60
|
-
*
|
|
61
|
-
* 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
|
|
62
|
-
* drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
|
|
63
|
-
* has no `case 'json'` branch, so the value is left as a raw column and
|
|
64
|
-
* the path segments are passed through to `parseParams.js`, which on
|
|
65
|
-
* SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
|
|
66
|
-
* expressions like `input->>'collection_id'`.
|
|
29
|
+
* Find translation jobs for a collection.
|
|
67
30
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* emits queries like `WHERE input->>'collection_id' IN (1)` even though
|
|
73
|
-
* the caller passed `['1']` (an array of strings).
|
|
74
|
-
*
|
|
75
|
-
* On SQLite, `->>` preserves the JSON value's type and `IN (...)` does NOT
|
|
76
|
-
* coerce between TEXT and INTEGER, so a numeric-looking string id never
|
|
77
|
-
* matches once bug #2 strips its quotes. Storing the id as text (this
|
|
78
|
-
* migration) does not fix the SQL path — drizzle re-numbers it anyway — so
|
|
79
|
-
* we keep matching in memory.
|
|
80
|
-
*
|
|
81
|
-
* Why in-memory filtering is acceptable here
|
|
82
|
-
* ------------------------------------------
|
|
83
|
-
* Per-task job sets are small (typically <100 rows; the plugin actively
|
|
84
|
-
* cancels superseded jobs so they don't accumulate), so the JS filtering
|
|
85
|
-
* is effectively free. If/when the upstream drizzle bug is fixed, this can
|
|
86
|
-
* collapse back to a single SQL query.
|
|
87
|
-
*/
|
|
88
|
-
findByCollection(collectionSlug: CollectionSlug, documentIds?: Array<string | number>): Promise<Task[]>;
|
|
89
|
-
/**
|
|
90
|
-
* Group tasks by collection slug
|
|
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.
|
|
91
35
|
*/
|
|
36
|
+
findByCollection(collectionSlug: CollectionSlug, filter?: Array<string | number> | TaskFilter): Promise<Task[]>;
|
|
92
37
|
private groupByCollection;
|
|
93
|
-
|
|
94
|
-
* Internal cancel implementation
|
|
95
|
-
*/
|
|
96
|
-
private cancelInternal;
|
|
97
|
-
/**
|
|
98
|
-
* Internal method to find jobs with where clause
|
|
99
|
-
*/
|
|
38
|
+
private cancelAndDeleteJobs;
|
|
100
39
|
private findJobsInternal;
|
|
101
40
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toTaskFilter } from "../toTaskFilter";
|
|
1
2
|
import { normalizeJob } from "./normalizeJob";
|
|
2
3
|
// A translation job's supersession identity: same document AND same target locale. IDs are
|
|
3
4
|
// String()-normalized to match the stored (string) form, so a number id compares equal to its
|
|
@@ -18,14 +19,20 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
18
19
|
const byCollection = this.groupByCollection(tasks);
|
|
19
20
|
for (const [collectionSlug, items] of byCollection){
|
|
20
21
|
const documentIds = items.map((t)=>t.collectionId);
|
|
21
|
-
|
|
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
|
+
});
|
|
22
29
|
// Supersede only jobs for the SAME (document, target locale) being re-enqueued — never a
|
|
23
30
|
// concurrent job for a *different* locale of the same document. Cancelling per-document would
|
|
24
31
|
// kill an in-flight translation of another locale (the concurrent re-translate bug).
|
|
25
32
|
const supersededKeys = new Set(items.map((t)=>documentLocaleKey(t.collectionId, t.targetLng)));
|
|
26
33
|
const toCancel = existing.filter((t)=>supersededKeys.has(documentLocaleKey(t.input.collectionId, t.input.targetLng)));
|
|
27
34
|
if (toCancel.length > 0) {
|
|
28
|
-
await this.
|
|
35
|
+
await this.cancelAndDeleteJobs(toCancel.map((t)=>t.id));
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
await Promise.all(tasks.map((task)=>this.payload.jobs.queue({
|
|
@@ -52,7 +59,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
52
59
|
}
|
|
53
60
|
async cancel(taskIds) {
|
|
54
61
|
if (taskIds.length === 0) return;
|
|
55
|
-
await this.
|
|
62
|
+
await this.cancelAndDeleteJobs(taskIds);
|
|
56
63
|
}
|
|
57
64
|
async run(taskId) {
|
|
58
65
|
const tasks = await this.findJobsInternal({
|
|
@@ -76,9 +83,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
76
83
|
};
|
|
77
84
|
}
|
|
78
85
|
if (task.status === "running") {
|
|
79
|
-
//
|
|
80
|
-
// a process killed mid-run) is reclaimable: clear it first so the queue
|
|
81
|
-
// picker below — which only selects `processing: false` — can re-run it.
|
|
86
|
+
// The picker below selects only `processing: false`, so a stale lock must be cleared first.
|
|
82
87
|
if (!this.isStale(task.updatedAt)) {
|
|
83
88
|
return {
|
|
84
89
|
success: false,
|
|
@@ -91,17 +96,10 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
91
96
|
}
|
|
92
97
|
});
|
|
93
98
|
}
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// NOT `payload.jobs.runByID({ id })`: on the drizzle adapter the id-path
|
|
99
|
-
// (`db.updateJobs({ id })`) writes `processing: true` but returns no rows,
|
|
100
|
-
// so `runJobs` reports `noJobsRemaining` and the handler never runs —
|
|
101
|
-
// leaving the job stuck at `processing: true` forever. The `where`-based
|
|
102
|
-
// picker selects, runs, and finalizes the job correctly (verified against
|
|
103
|
-
// sqlite). The picker also enforces processing:false / no-error / no
|
|
104
|
-
// pending waitUntil, so a failed (max-retries) job is not re-run here.
|
|
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.
|
|
105
103
|
await this.payload.jobs.run({
|
|
106
104
|
queue: this.config.queueName,
|
|
107
105
|
where: {
|
|
@@ -116,20 +114,11 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
116
114
|
};
|
|
117
115
|
}
|
|
118
116
|
/**
|
|
119
|
-
*
|
|
120
|
-
* autorun picker again.
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* autorun and is only recoverable via a manual run().
|
|
125
|
-
*
|
|
126
|
-
* A job is stale when it is still `processing: true`, not yet completed, and
|
|
127
|
-
* its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
|
|
128
|
-
* killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
|
|
129
|
-
* in flight on another live instance (fresh `updatedAt`) is left alone.
|
|
130
|
-
* Filters on real `payload-jobs` columns only (no JSON-path traversal), so
|
|
131
|
-
* the drizzle SQLite issue in `findByCollection` does not apply here.
|
|
132
|
-
* @returns the number of jobs reclaimed.
|
|
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.
|
|
121
|
+
* @returns how many locks were cleared.
|
|
133
122
|
*/ async reclaimStaleJobs() {
|
|
134
123
|
const cutoff = new Date(Date.now() - this.config.staleJobTimeoutMs).toISOString();
|
|
135
124
|
return this.resetProcessing({
|
|
@@ -157,12 +146,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
157
146
|
]
|
|
158
147
|
});
|
|
159
148
|
}
|
|
160
|
-
/**
|
|
161
|
-
* Clear the `processing` lock on every job matching `where`, returning how
|
|
162
|
-
* many were reset. Shared by the per-job reset in `run()` (a stale lock) and
|
|
163
|
-
* the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
|
|
164
|
-
* only the count is needed — no relationships to populate.
|
|
165
|
-
*/ async resetProcessing(where) {
|
|
149
|
+
/** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */ async resetProcessing(where) {
|
|
166
150
|
const result = await this.payload.update({
|
|
167
151
|
collection: this.config.jobsCollection,
|
|
168
152
|
depth: 0,
|
|
@@ -173,10 +157,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
173
157
|
});
|
|
174
158
|
return result.docs.length;
|
|
175
159
|
}
|
|
176
|
-
|
|
177
|
-
* A processing lock is stale once `updatedAt` is older than the configured
|
|
178
|
-
* timeout — the owning run is presumed dead.
|
|
179
|
-
*/ isStale(updatedAt) {
|
|
160
|
+
isStale(updatedAt) {
|
|
180
161
|
const parsed = Date.parse(updatedAt);
|
|
181
162
|
// Unknown/corrupt timestamp → treat as stale so the job can be recovered
|
|
182
163
|
// rather than permanently refused as already-running.
|
|
@@ -184,60 +165,28 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
184
165
|
return Date.now() - parsed > this.config.staleJobTimeoutMs;
|
|
185
166
|
}
|
|
186
167
|
/**
|
|
187
|
-
* Find translation jobs for a collection
|
|
188
|
-
*
|
|
189
|
-
* Narrowing is by `taskSlug` only in SQL; the collection slug and document
|
|
190
|
-
* IDs are matched in memory (via the normalized `Task`, which reads both the
|
|
191
|
-
* current flat-text shape and the legacy relationship shape). This is the
|
|
192
|
-
* one path that must transparently span both stored shapes during the
|
|
193
|
-
* ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
|
|
194
|
-
*
|
|
195
|
-
* IMPORTANT — why slug/id are matched in memory, not in the SQL WHERE
|
|
196
|
-
* ------------------------------------------------------------------------
|
|
197
|
-
* The "natural" implementation would push `input.collection_id` into the
|
|
198
|
-
* where clause. This DOES NOT work on SQLite (and is unreliable on any
|
|
199
|
-
* adapter) because of two compounding bugs in Payload's drizzle layer.
|
|
200
|
-
*
|
|
201
|
-
* 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
|
|
202
|
-
* drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
|
|
203
|
-
* has no `case 'json'` branch, so the value is left as a raw column and
|
|
204
|
-
* the path segments are passed through to `parseParams.js`, which on
|
|
205
|
-
* SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
|
|
206
|
-
* expressions like `input->>'collection_id'`.
|
|
168
|
+
* Find translation jobs for a collection.
|
|
207
169
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*
|
|
221
|
-
* Why in-memory filtering is acceptable here
|
|
222
|
-
* ------------------------------------------
|
|
223
|
-
* Per-task job sets are small (typically <100 rows; the plugin actively
|
|
224
|
-
* cancels superseded jobs so they don't accumulate), so the JS filtering
|
|
225
|
-
* is effectively free. If/when the upstream drizzle bug is fixed, this can
|
|
226
|
-
* collapse back to a single SQL query.
|
|
227
|
-
*/ async findByCollection(collectionSlug, documentIds) {
|
|
228
|
-
const all = await this.findJobsInternal(undefined, {
|
|
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.
|
|
174
|
+
*/ async findByCollection(collectionSlug, filter) {
|
|
175
|
+
const { documentIds, excludeCompleted } = toTaskFilter(filter);
|
|
176
|
+
const where = excludeCompleted ? {
|
|
177
|
+
completedAt: {
|
|
178
|
+
exists: false
|
|
179
|
+
}
|
|
180
|
+
} : undefined;
|
|
181
|
+
const all = await this.findJobsInternal(where, {
|
|
229
182
|
pagination: false
|
|
230
183
|
});
|
|
231
184
|
const bySlug = all.filter((t)=>t.input.collectionSlug === collectionSlug);
|
|
232
185
|
if (!documentIds?.length) return bySlug;
|
|
233
|
-
// `documentIds` is the public `Array<string | number>` param, so normalize
|
|
234
|
-
// it here; `t.input.collectionId` is already `ID` (string) via normalizeJob.
|
|
235
186
|
const wanted = new Set(documentIds.map(String));
|
|
236
187
|
return bySlug.filter((t)=>wanted.has(t.input.collectionId));
|
|
237
188
|
}
|
|
238
|
-
|
|
239
|
-
* Group tasks by collection slug
|
|
240
|
-
*/ groupByCollection(tasks) {
|
|
189
|
+
groupByCollection(tasks) {
|
|
241
190
|
const map = new Map();
|
|
242
191
|
for (const task of tasks){
|
|
243
192
|
const existing = map.get(task.collectionSlug) ?? [];
|
|
@@ -246,10 +195,12 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
246
195
|
}
|
|
247
196
|
return map;
|
|
248
197
|
}
|
|
249
|
-
|
|
250
|
-
* Internal cancel implementation
|
|
251
|
-
*/ async cancelInternal(taskIds) {
|
|
198
|
+
async cancelAndDeleteJobs(taskIds) {
|
|
252
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.
|
|
253
204
|
await this.payload.jobs.cancel({
|
|
254
205
|
where: {
|
|
255
206
|
id: {
|
|
@@ -267,9 +218,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
267
218
|
}
|
|
268
219
|
});
|
|
269
220
|
}
|
|
270
|
-
|
|
271
|
-
* Internal method to find jobs with where clause
|
|
272
|
-
*/ async findJobsInternal(where, params) {
|
|
221
|
+
async findJobsInternal(where, params) {
|
|
273
222
|
const and = [
|
|
274
223
|
{
|
|
275
224
|
taskSlug: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Payload, CollectionSlug } from "payload";
|
|
2
|
-
import type { TaskRunner } from "../TaskRunner.interface";
|
|
2
|
+
import type { TaskFilter, TaskRunner } from "../TaskRunner.interface";
|
|
3
3
|
import type { TaskHandler } from "../TaskRunnerProvider.interface";
|
|
4
4
|
import type { Task, TaskInput, RunResult } from "../types";
|
|
5
5
|
import type { LazyMap } from "../../../shared/utils";
|
|
@@ -17,6 +17,6 @@ export declare class SyncTaskRunner implements TaskRunner {
|
|
|
17
17
|
enqueue(inputs: TaskInput[]): Promise<void>;
|
|
18
18
|
cancel(_taskIds: string[]): Promise<void>;
|
|
19
19
|
run(_taskId: string): Promise<RunResult>;
|
|
20
|
-
findByCollection(collectionSlug: CollectionSlug,
|
|
20
|
+
findByCollection(collectionSlug: CollectionSlug, filter?: Array<string | number> | TaskFilter): Promise<Task[]>;
|
|
21
21
|
private getKey;
|
|
22
22
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toTaskFilter } from "../toTaskFilter";
|
|
1
2
|
/**
|
|
2
3
|
* Synchronous TaskRunner implementation.
|
|
3
4
|
*
|
|
@@ -55,12 +56,17 @@
|
|
|
55
56
|
error: "not_found"
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
|
-
async findByCollection(collectionSlug,
|
|
59
|
+
async findByCollection(collectionSlug, filter) {
|
|
60
|
+
const { documentIds, excludeCompleted } = toTaskFilter(filter);
|
|
59
61
|
const results = [];
|
|
60
62
|
const wanted = documentIds ? new Set(documentIds.map(String)) : undefined;
|
|
61
63
|
for (const [, task] of this.tasks){
|
|
62
64
|
if (task.input.collectionSlug !== collectionSlug) continue;
|
|
63
65
|
if (wanted && !wanted.has(task.input.collectionId)) continue;
|
|
66
|
+
// Keyed on `completedAt`, the same field the jobs runner pushes into its where clause. Keying
|
|
67
|
+
// on `status` instead would agree only by accident: `getJobStatus` happens to check
|
|
68
|
+
// `completedAt` before `error`, and reordering it would silently split the two runners.
|
|
69
|
+
if (excludeCompleted && task.completedAt) continue;
|
|
64
70
|
results.push(task);
|
|
65
71
|
}
|
|
66
72
|
return results;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TaskFilter } from "./TaskRunner.interface";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize the two accepted shapes of {@link TaskRunner.findByCollection}'s second argument.
|
|
4
|
+
*
|
|
5
|
+
* Every implementation of {@link TaskRunner} owes this, so it ships alongside the contract rather
|
|
6
|
+
* than being re-derived: the array form is deprecated and will be removed, and a hand-written
|
|
7
|
+
* `Array.isArray` branch in someone else's runner would outlive it.
|
|
8
|
+
*
|
|
9
|
+
* @since 0.11.2
|
|
10
|
+
*/
|
|
11
|
+
export declare function toTaskFilter(filter?: Array<string | number> | TaskFilter): TaskFilter;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize the two accepted shapes of {@link TaskRunner.findByCollection}'s second argument.
|
|
3
|
+
*
|
|
4
|
+
* Every implementation of {@link TaskRunner} owes this, so it ships alongside the contract rather
|
|
5
|
+
* than being re-derived: the array form is deprecated and will be removed, and a hand-written
|
|
6
|
+
* `Array.isArray` branch in someone else's runner would outlive it.
|
|
7
|
+
*
|
|
8
|
+
* @since 0.11.2
|
|
9
|
+
*/ export function toTaskFilter(filter) {
|
|
10
|
+
return Array.isArray(filter) ? {
|
|
11
|
+
documentIds: filter
|
|
12
|
+
} : filter ?? {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
//# sourceMappingURL=toTaskFilter.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
|
+
"version": "0.11.3",
|
|
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",
|