@focus-reactive/payload-plugin-translator 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import type { PayloadRequest } from 'payload';
2
- import type { TaskRunnerProvider } from '../../modules/task-runner';
1
+ import type { PayloadRequest } from "payload";
2
+ import type { TaskRunnerProvider } from "../../modules/task-runner";
3
3
  /**
4
4
  * Cancels and deletes translation tasks by IDs
5
5
  */
@@ -1,5 +1,5 @@
1
- import { ServerResponse } from '../../shared';
2
- import { CancelInputSchema } from './model';
1
+ import { ServerResponse } from "../../shared";
2
+ import { CancelInputSchema } from "./model";
3
3
  /**
4
4
  * Cancels and deletes translation tasks by IDs
5
5
  */ export class CancelHandler {
@@ -9,7 +9,7 @@ import { CancelInputSchema } from './model';
9
9
  }
10
10
  async handle(req) {
11
11
  const validationResult = CancelInputSchema.safeParse(await req.json?.());
12
- if (validationResult.error) return ServerResponse.validationError(validationResult.error.errors);
12
+ if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
13
13
  const { ids } = validationResult.data;
14
14
  const runner = this.taskRunnerFactory.create(req.payload);
15
15
  await runner.cancel(ids);
@@ -1,6 +1,6 @@
1
- import type { PayloadRequest } from 'payload';
2
- import type { TaskRunnerProvider } from '../../modules/task-runner';
3
- import { type CancelConfig } from './model';
1
+ import type { PayloadRequest } from "payload";
2
+ import type { TaskRunnerProvider } from "../../modules/task-runner";
3
+ import type { CancelConfig } from './model';
4
4
  /**
5
5
  * Cancels all pending translation tasks for a collection
6
6
  */
@@ -1,5 +1,5 @@
1
- import { ServerResponse } from '../../shared';
2
- import { isCollectionAvailable } from '../_lib/collection-utils';
1
+ import { ServerResponse } from "../../shared";
2
+ import { isCollectionAvailable } from "../_lib/collection-utils";
3
3
  import { CancelByCollectionInputSchema } from './model';
4
4
  /**
5
5
  * Cancels all pending translation tasks for a collection
@@ -12,13 +12,13 @@ import { CancelByCollectionInputSchema } from './model';
12
12
  }
13
13
  async handle(req) {
14
14
  const validationResult = CancelByCollectionInputSchema.safeParse(req.routeParams);
15
- if (validationResult.error) return ServerResponse.validationError(validationResult.error.errors);
15
+ if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
16
16
  const collectionSlug = isCollectionAvailable(validationResult.data.collection_slug, this.config.availableCollections);
17
- if (!collectionSlug) return ServerResponse.badRequest('Collection not available for translation');
17
+ if (!collectionSlug) return ServerResponse.badRequest("Collection not available for translation");
18
18
  const runner = this.taskRunnerFactory.create(req.payload);
19
19
  const tasks = await runner.findByCollection(collectionSlug);
20
20
  if (tasks.length === 0) return ServerResponse.noContent();
21
- const pendingTaskIds = tasks.filter((task)=>task.status === 'pending').map((task)=>task.id);
21
+ const pendingTaskIds = tasks.filter((task)=>task.status === "pending").map((task)=>task.id);
22
22
  if (pendingTaskIds.length === 0) return ServerResponse.noContent();
23
23
  await runner.cancel(pendingTaskIds);
24
24
  return ServerResponse.noContent();
@@ -1,6 +1,6 @@
1
- import type { PayloadRequest } from 'payload';
2
- import type { TaskRunnerProvider } from '../../modules/task-runner';
3
- import { type EnqueueConfig } from './model';
1
+ import type { PayloadRequest } from "payload";
2
+ import type { TaskRunnerProvider } from "../../modules/task-runner";
3
+ import type { EnqueueConfig } from './model';
4
4
  /**
5
5
  * Enqueues translation tasks for documents
6
6
  */
@@ -1,5 +1,5 @@
1
- import { ServerResponse } from '../../shared';
2
- import { isCollectionAvailable, getAllCollectionIds } from '../_lib/collection-utils';
1
+ import { ServerResponse } from "../../shared";
2
+ import { isCollectionAvailable, getAllCollectionIds } from "../_lib/collection-utils";
3
3
  import { EnqueueInputSchema } from './model';
4
4
  /**
5
5
  * Enqueues translation tasks for documents
@@ -12,10 +12,10 @@ import { EnqueueInputSchema } from './model';
12
12
  }
13
13
  async handle(req) {
14
14
  const validationResult = EnqueueInputSchema.safeParse(await req.json?.());
15
- if (validationResult.error) return ServerResponse.validationError(validationResult.error.errors);
15
+ if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
16
16
  const { source_lng, target_lng, collection_slug, collection_id, select_all, strategy, publish_on_translation } = validationResult.data;
17
17
  const collectionSlug = isCollectionAvailable(collection_slug, this.config.availableCollections);
18
- if (!collectionSlug) return ServerResponse.badRequest('Content of this collection is not available for translation');
18
+ if (!collectionSlug) return ServerResponse.badRequest("Content of this collection is not available for translation");
19
19
  const collectionIds = select_all ? await getAllCollectionIds(req.payload, collectionSlug) : collection_id;
20
20
  const runner = this.taskRunnerFactory.create(req.payload);
21
21
  const tasks = collectionIds.map((id)=>({
@@ -1,6 +1,6 @@
1
- import type { PayloadRequest } from 'payload';
2
- import type { TaskRunnerProvider } from '../../modules/task-runner';
3
- import { type GetCollectionStatusConfig } from './model';
1
+ import type { PayloadRequest } from "payload";
2
+ import type { TaskRunnerProvider } from "../../modules/task-runner";
3
+ import type { GetCollectionStatusConfig } from './model';
4
4
  /**
5
5
  * Gets translation status for all documents in a collection
6
6
  */
@@ -1,5 +1,5 @@
1
- import { ServerResponse } from '../../shared';
2
- import { isCollectionAvailable } from '../_lib/collection-utils';
1
+ import { ServerResponse } from "../../shared";
2
+ import { isCollectionAvailable } from "../_lib/collection-utils";
3
3
  import { GetCollectionStatusInputSchema } from './model';
4
4
  /**
5
5
  * Gets translation status for all documents in a collection
@@ -12,9 +12,9 @@ import { GetCollectionStatusInputSchema } from './model';
12
12
  }
13
13
  async handle(req) {
14
14
  const validationResult = GetCollectionStatusInputSchema.safeParse(req.routeParams);
15
- if (validationResult.error) return ServerResponse.validationError(validationResult.error.errors);
15
+ if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
16
16
  const collectionSlug = isCollectionAvailable(validationResult.data.collection_slug, this.config.availableCollections);
17
- if (!collectionSlug) return ServerResponse.badRequest('Collection not available for translation');
17
+ if (!collectionSlug) return ServerResponse.badRequest("Collection not available for translation");
18
18
  const runner = this.taskRunnerFactory.create(req.payload);
19
19
  const tasks = await runner.findByCollection(collectionSlug);
20
20
  return ServerResponse.success({
@@ -1,6 +1,6 @@
1
- import type { PayloadRequest } from 'payload';
2
- import type { TaskRunnerProvider } from '../../modules/task-runner';
3
- import { type GetDocumentStatusConfig } from './model';
1
+ import type { PayloadRequest } from "payload";
2
+ import type { TaskRunnerProvider } from "../../modules/task-runner";
3
+ import type { GetDocumentStatusConfig } from './model';
4
4
  /**
5
5
  * Gets the translation status for a specific document
6
6
  */
@@ -1,5 +1,5 @@
1
- import { ServerResponse } from '../../shared';
2
- import { isCollectionAvailable } from '../_lib/collection-utils';
1
+ import { ServerResponse } from "../../shared";
2
+ import { isCollectionAvailable } from "../_lib/collection-utils";
3
3
  import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from './model';
4
4
  /**
5
5
  * Gets the translation status for a specific document
@@ -12,10 +12,10 @@ import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from './model';
12
12
  }
13
13
  async handle(req) {
14
14
  const validationResult = GetDocumentStatusInputSchema.safeParse(req.routeParams);
15
- if (validationResult.error) return ServerResponse.validationError(validationResult.error.errors);
15
+ if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
16
16
  const { collection_slug, collection_id } = validationResult.data;
17
17
  const collectionSlug = isCollectionAvailable(collection_slug, this.config.availableCollections);
18
- if (!collectionSlug) return ServerResponse.badRequest('Collection not available for translation');
18
+ if (!collectionSlug) return ServerResponse.badRequest("Collection not available for translation");
19
19
  const runner = this.taskRunnerFactory.create(req.payload);
20
20
  const tasks = await runner.findByCollection(collectionSlug, [
21
21
  collection_id
@@ -1,5 +1,5 @@
1
- import type { PayloadRequest } from 'payload';
2
- import type { TaskRunnerProvider } from '../../modules/task-runner';
1
+ import type { PayloadRequest } from "payload";
2
+ import type { TaskRunnerProvider } from "../../modules/task-runner";
3
3
  /**
4
4
  * Runs a translation task by ID
5
5
  */
@@ -1,5 +1,5 @@
1
- import { ServerResponse } from '../../shared';
2
- import { RunInputSchema } from './model';
1
+ import { ServerResponse } from "../../shared";
2
+ import { RunInputSchema } from "./model";
3
3
  /**
4
4
  * Runs a translation task by ID
5
5
  */ export class RunTranslationHandler {
@@ -9,16 +9,16 @@ import { RunInputSchema } from './model';
9
9
  }
10
10
  async handle(req) {
11
11
  const validationResult = RunInputSchema.safeParse(req.routeParams);
12
- if (validationResult.error) return ServerResponse.validationError(validationResult.error.errors);
12
+ if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
13
13
  const { id } = validationResult.data;
14
14
  const runner = this.taskRunnerFactory.create(req.payload);
15
15
  const result = await runner.run(id);
16
16
  if (!result.success) {
17
- if (result.error === 'not_found' || result.error === 'already_completed') {
18
- return ServerResponse.notFound('Queued task not found');
17
+ if (result.error === "not_found" || result.error === "already_completed") {
18
+ return ServerResponse.notFound("Queued task not found");
19
19
  }
20
- if (result.error === 'already_running') {
21
- return ServerResponse.tooManyRequests('Translation task is already in progress');
20
+ if (result.error === "already_running") {
21
+ return ServerResponse.tooManyRequests("Translation task is already in progress");
22
22
  }
23
23
  }
24
24
  return ServerResponse.noContent();
@@ -41,6 +41,7 @@ export interface TaskRunnerProvider {
41
41
  /**
42
42
  * Configures the runner and returns a Payload config modifier.
43
43
  * The modifier adds necessary tasks, jobs, queues to Payload config.
44
+ * Implementations may also install a `config.onInit` hook for boot-time initialization (e.g. stale-lock recovery).
44
45
  */
45
46
  configure(context: TaskRunnerContext): (config: Config) => Config;
46
47
  }
@@ -4,11 +4,13 @@ const defaultAutoRun = {
4
4
  cron: "* * * * *",
5
5
  limit: 50
6
6
  };
7
+ const DEFAULT_STALE_JOB_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
7
8
  const defaultValues = {
8
9
  taskName: "translate_document",
9
10
  queueName: "translations",
10
11
  jobsCollection: "payload-jobs",
11
12
  autoRun: defaultAutoRun,
13
+ staleJobTimeoutMs: DEFAULT_STALE_JOB_TIMEOUT_MS,
12
14
  retries: {
13
15
  attempts: 3,
14
16
  backoff: {
@@ -28,11 +30,16 @@ const defaultValues = {
28
30
  ...defaultAutoRun,
29
31
  ...options.autoRun
30
32
  } : defaultAutoRun;
33
+ const staleJobTimeoutMs = options?.staleJobTimeoutMs ?? defaultValues.staleJobTimeoutMs;
34
+ if (!Number.isFinite(staleJobTimeoutMs) || staleJobTimeoutMs <= 0) {
35
+ throw new Error(`[payload-plugin-translator] staleJobTimeoutMs must be a positive finite number (got ${staleJobTimeoutMs})`);
36
+ }
31
37
  this.config = {
32
38
  taskName: options?.taskName ?? defaultValues.taskName,
33
39
  queueName: options?.queueName ?? defaultValues.queueName,
34
40
  jobsCollection: options?.jobsCollection ?? defaultValues.jobsCollection,
35
41
  autoRun,
42
+ staleJobTimeoutMs,
36
43
  retries: options?.retries ?? defaultValues.retries
37
44
  };
38
45
  }
@@ -141,6 +148,25 @@ const defaultValues = {
141
148
  ];
142
149
  }
143
150
  }
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
+ const existingOnInit = config.onInit;
159
+ config.onInit = async (payload)=>{
160
+ if (existingOnInit) await existingOnInit(payload);
161
+ try {
162
+ await new PayloadJobsTaskRunner(payload, this.config).reclaimStaleJobs();
163
+ } catch (err) {
164
+ payload.logger?.error?.({
165
+ err,
166
+ msg: "[translator] failed to reclaim stale translation jobs"
167
+ });
168
+ }
169
+ };
144
170
  return config;
145
171
  };
146
172
  }
@@ -14,6 +14,35 @@ export declare class PayloadJobsTaskRunner implements TaskRunner {
14
14
  enqueue(tasks: TaskInput[]): Promise<void>;
15
15
  cancel(taskIds: string[]): Promise<void>;
16
16
  run(taskId: string): Promise<RunResult>;
17
+ /**
18
+ * Reset stale processing locks so abandoned jobs become eligible for the
19
+ * autorun picker again. The picker requires processing:false, no error, and
20
+ * no pending waitUntil; a job abandoned mid-run (no error, no waitUntil)
21
+ * satisfies the rest, so clearing processing is sufficient for that case.
22
+ * A job that already exhausted retries (hasError:true) stays excluded from
23
+ * autorun and is only recoverable via a manual run().
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.
32
+ */
33
+ reclaimStaleJobs(): Promise<number>;
34
+ /**
35
+ * Clear the `processing` lock on every job matching `where`, returning how
36
+ * many were reset. Shared by the per-job reset in `run()` (a stale lock) and
37
+ * the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
38
+ * only the count is needed — no relationships to populate.
39
+ */
40
+ private resetProcessing;
41
+ /**
42
+ * A processing lock is stale once `updatedAt` is older than the configured
43
+ * timeout — the owning run is presumed dead.
44
+ */
45
+ private isStale;
17
46
  /**
18
47
  * Find translation jobs for a collection, optionally narrowed by document IDs.
19
48
  *
@@ -63,19 +63,114 @@ import { normalizeJob } from "./normalizeJob";
63
63
  };
64
64
  }
65
65
  if (task.status === "running") {
66
- return {
67
- success: false,
68
- error: "already_running"
69
- };
66
+ // A genuinely in-flight job is refused. A stale processing lock (left by
67
+ // a process killed mid-run) is reclaimable: clear it first so the queue
68
+ // picker below — which only selects `processing: false` — can re-run it.
69
+ if (!this.isStale(task.updatedAt)) {
70
+ return {
71
+ success: false,
72
+ error: "already_running"
73
+ };
74
+ }
75
+ await this.resetProcessing({
76
+ id: {
77
+ equals: taskId
78
+ }
79
+ });
70
80
  }
71
- this.payload.jobs.runByID({
72
- id: taskId
81
+ // Execute synchronously via the queue + `where` picker so the job runs to
82
+ // completion within this request (nothing is abandoned after the HTTP
83
+ // response — reliable on serverless too).
84
+ //
85
+ // NOT `payload.jobs.runByID({ id })`: on the drizzle adapter the id-path
86
+ // (`db.updateJobs({ id })`) writes `processing: true` but returns no rows,
87
+ // so `runJobs` reports `noJobsRemaining` and the handler never runs —
88
+ // leaving the job stuck at `processing: true` forever. The `where`-based
89
+ // picker selects, runs, and finalizes the job correctly (verified against
90
+ // sqlite). The picker also enforces processing:false / no-error / no
91
+ // pending waitUntil, so a failed (max-retries) job is not re-run here.
92
+ await this.payload.jobs.run({
93
+ queue: this.config.queueName,
94
+ where: {
95
+ id: {
96
+ equals: taskId
97
+ }
98
+ },
99
+ limit: 1
73
100
  });
74
101
  return {
75
102
  success: true
76
103
  };
77
104
  }
78
105
  /**
106
+ * Reset stale processing locks so abandoned jobs become eligible for the
107
+ * autorun picker again. The picker requires processing:false, no error, and
108
+ * no pending waitUntil; a job abandoned mid-run (no error, no waitUntil)
109
+ * satisfies the rest, so clearing processing is sufficient for that case.
110
+ * A job that already exhausted retries (hasError:true) stays excluded from
111
+ * autorun and is only recoverable via a manual run().
112
+ *
113
+ * A job is stale when it is still `processing: true`, not yet completed, and
114
+ * its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
115
+ * killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
116
+ * in flight on another live instance (fresh `updatedAt`) is left alone.
117
+ * Filters on real `payload-jobs` columns only (no JSON-path traversal), so
118
+ * the drizzle SQLite issue in `findByCollection` does not apply here.
119
+ * @returns the number of jobs reclaimed.
120
+ */ async reclaimStaleJobs() {
121
+ const cutoff = new Date(Date.now() - this.config.staleJobTimeoutMs).toISOString();
122
+ return this.resetProcessing({
123
+ and: [
124
+ {
125
+ taskSlug: {
126
+ equals: this.config.taskName
127
+ }
128
+ },
129
+ {
130
+ processing: {
131
+ equals: true
132
+ }
133
+ },
134
+ {
135
+ completedAt: {
136
+ exists: false
137
+ }
138
+ },
139
+ {
140
+ updatedAt: {
141
+ less_than: cutoff
142
+ }
143
+ }
144
+ ]
145
+ });
146
+ }
147
+ /**
148
+ * Clear the `processing` lock on every job matching `where`, returning how
149
+ * many were reset. Shared by the per-job reset in `run()` (a stale lock) and
150
+ * the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
151
+ * only the count is needed — no relationships to populate.
152
+ */ async resetProcessing(where) {
153
+ const result = await this.payload.update({
154
+ collection: this.config.jobsCollection,
155
+ depth: 0,
156
+ where,
157
+ data: {
158
+ processing: false
159
+ }
160
+ });
161
+ return result.docs.length;
162
+ }
163
+ /**
164
+ * A processing lock is stale once `updatedAt` is older than the configured
165
+ * timeout — the owning run is presumed dead.
166
+ */ isStale(updatedAt) {
167
+ const parsed = Date.parse(updatedAt);
168
+ // Unknown/corrupt timestamp → treat as stale so the job can be recovered
169
+ // rather than permanently refused as already-running.
170
+ if (Number.isNaN(parsed)) return true;
171
+ return Date.now() - parsed > this.config.staleJobTimeoutMs;
172
+ }
173
+ /**
79
174
  * Find translation jobs for a collection, optionally narrowed by document IDs.
80
175
  *
81
176
  * Narrowing is by `taskSlug` only in SQL; the collection slug and document
@@ -40,6 +40,22 @@ export type PayloadJobsRunnerOptions = {
40
40
  * @default { cron: '* * * * *', limit: 50 }
41
41
  */
42
42
  autoRun?: false | AutoRunConfig;
43
+ /**
44
+ * How long (ms) a job may stay `processing: true` before its lock is
45
+ * considered stale and the job becomes eligible to be re-run.
46
+ *
47
+ * A process killed mid-run (deploy, crash, request timeout) leaves a job
48
+ * stuck at `processing: true`; the autorun picker only takes
49
+ * `processing: false`, so without recovery such a job would hang forever.
50
+ * On boot the runner resets stale locks, and manual `run()` will re-claim a
51
+ * stale-locked job instead of refusing it as already-running.
52
+ *
53
+ * MUST be larger than the longest a single document translation can
54
+ * legitimately take, otherwise a genuinely in-flight job could be reclaimed
55
+ * and run twice (safe under the idempotent `overwrite` strategy, but wasteful).
56
+ * @default 300000 (5 minutes)
57
+ */
58
+ staleJobTimeoutMs?: number;
43
59
  /**
44
60
  * Retry configuration for failed jobs.
45
61
  */
@@ -59,6 +75,7 @@ export type PayloadJobsRunnerConfig = {
59
75
  queueName: string;
60
76
  jobsCollection: CollectionSlug;
61
77
  autoRun: false | Required<AutoRunConfig>;
78
+ staleJobTimeoutMs: number;
62
79
  retries?: PayloadJobsRunnerOptions["retries"];
63
80
  };
64
81
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",