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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +44 -29
  2. package/dist/index.d.ts +16 -14
  3. package/dist/index.js +9 -7
  4. package/dist/plugin.d.ts +15 -4
  5. package/dist/plugin.js +34 -57
  6. package/dist/server/features/cancel/handler.d.ts +3 -3
  7. package/dist/server/features/cancel/handler.js +5 -3
  8. package/dist/server/features/cancel/route.d.ts +4 -4
  9. package/dist/server/features/cancel/route.js +4 -4
  10. package/dist/server/features/cancel-by-collection/handler.d.ts +4 -4
  11. package/dist/server/features/cancel-by-collection/handler.js +6 -6
  12. package/dist/server/features/cancel-by-collection/route.d.ts +5 -5
  13. package/dist/server/features/cancel-by-collection/route.js +4 -4
  14. package/dist/server/features/createTranslationRoutes.d.ts +23 -0
  15. package/dist/server/features/createTranslationRoutes.js +27 -0
  16. package/dist/server/features/enqueue-translation/handler.d.ts +4 -4
  17. package/dist/server/features/enqueue-translation/handler.js +5 -5
  18. package/dist/server/features/enqueue-translation/route.d.ts +5 -5
  19. package/dist/server/features/enqueue-translation/route.js +4 -4
  20. package/dist/server/features/get-collection-status/handler.d.ts +4 -4
  21. package/dist/server/features/get-collection-status/handler.js +5 -5
  22. package/dist/server/features/get-collection-status/route.d.ts +5 -5
  23. package/dist/server/features/get-collection-status/route.js +4 -4
  24. package/dist/server/features/get-document-status/handler.d.ts +4 -4
  25. package/dist/server/features/get-document-status/handler.js +5 -5
  26. package/dist/server/features/get-document-status/route.d.ts +5 -5
  27. package/dist/server/features/get-document-status/route.js +4 -4
  28. package/dist/server/features/index.d.ts +7 -6
  29. package/dist/server/features/index.js +7 -6
  30. package/dist/server/features/run-translation/handler.d.ts +3 -3
  31. package/dist/server/features/run-translation/handler.js +7 -7
  32. package/dist/server/features/run-translation/route.d.ts +4 -4
  33. package/dist/server/features/run-translation/route.js +4 -4
  34. package/dist/server/features/translate-document/handler.js +7 -11
  35. package/dist/server/modules/task-runner/TaskRunnerProvider.interface.d.ts +19 -1
  36. package/dist/server/modules/task-runner/index.d.ts +6 -6
  37. package/dist/server/modules/task-runner/index.js +2 -2
  38. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.d.ts +16 -1
  39. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.js +42 -1
  40. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +29 -0
  41. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +101 -6
  42. package/dist/server/modules/task-runner/payload-jobs-runner/types.d.ts +17 -0
  43. package/dist/server/modules/task-runner/sync-runner/SyncRunnerProvider.d.ts +18 -10
  44. package/dist/server/modules/task-runner/sync-runner/SyncRunnerProvider.js +24 -14
  45. package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +46 -0
  46. package/dist/server/modules/translation-levels/PluginConfigBuilder.js +108 -0
  47. package/dist/server/modules/translation-levels/collectionLevel.d.ts +15 -0
  48. package/dist/server/modules/translation-levels/collectionLevel.js +24 -0
  49. package/dist/server/modules/translation-levels/documentLevel.d.ts +19 -0
  50. package/dist/server/modules/translation-levels/documentLevel.js +28 -0
  51. package/dist/server/modules/translation-levels/index.d.ts +3 -0
  52. package/dist/server/modules/translation-levels/index.js +4 -0
  53. package/dist/server/modules/translation-levels/types.d.ts +42 -0
  54. package/dist/server/modules/translation-levels/types.js +10 -0
  55. package/dist/server/modules/translation-levels/useDocTranslationApi.d.ts +8 -0
  56. package/dist/server/modules/translation-levels/useDocTranslationApi.js +18 -0
  57. package/dist/server/modules/translation-pipeline/index.d.ts +5 -3
  58. package/dist/server/modules/translation-pipeline/index.js +3 -2
  59. package/dist/server/modules/translation-pipeline/translateContent.d.ts +35 -0
  60. package/dist/server/modules/translation-pipeline/translateContent.js +30 -0
  61. package/dist/server/modules/translation-providers/OpenAITranslation.provider.d.ts +4 -3
  62. package/dist/server/modules/translation-providers/OpenAITranslation.provider.js +15 -15
  63. package/dist/types/AccessGuard.d.ts +24 -1
  64. package/dist/types/AccessGuard.js +12 -1
  65. package/package.json +1 -1
@@ -1,12 +1,12 @@
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 { TaskRunnerFactory } 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
  */
7
7
  export declare class GetCollectionStatusHandler {
8
8
  private readonly config;
9
9
  private readonly taskRunnerFactory;
10
- constructor(config: GetCollectionStatusConfig, taskRunnerFactory: TaskRunnerProvider);
10
+ constructor(config: GetCollectionStatusConfig, taskRunnerFactory: TaskRunnerFactory);
11
11
  handle(req: PayloadRequest): Promise<Response>;
12
12
  }
@@ -1,6 +1,6 @@
1
- import { ServerResponse } from '../../shared';
2
- import { isCollectionAvailable } from '../_lib/collection-utils';
3
- import { GetCollectionStatusInputSchema } from './model';
1
+ import { ServerResponse } from "../../shared";
2
+ import { isCollectionAvailable } from "../_lib/collection-utils";
3
+ import { GetCollectionStatusInputSchema } from "./model";
4
4
  /**
5
5
  * Gets translation status for all documents in a collection
6
6
  */ export class GetCollectionStatusHandler {
@@ -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,8 +1,8 @@
1
- import type { Endpoint } from 'payload';
2
- import type { AccessGuard } from '../../shared';
3
- import type { TaskRunnerProvider } from '../../modules/task-runner';
4
- import type { GetCollectionStatusConfig } from './model';
1
+ import type { Endpoint } from "payload";
2
+ import type { AccessGuard } from "../../shared";
3
+ import type { TaskRunnerFactory } from "../../modules/task-runner";
4
+ import type { GetCollectionStatusConfig } from "./model";
5
5
  /**
6
6
  * Creates the get collection status endpoint
7
7
  */
8
- export declare function createGetCollectionStatusRoute(config: GetCollectionStatusConfig, taskRunnerFactory: TaskRunnerProvider, access?: AccessGuard, basePath?: string): Endpoint;
8
+ export declare function createGetCollectionStatusRoute(config: GetCollectionStatusConfig, taskRunnerFactory: TaskRunnerFactory, access?: AccessGuard, basePath?: string): Endpoint;
@@ -1,12 +1,12 @@
1
- import { withErrorHandler, withAccessCheck } from '../../shared';
2
- import { GetCollectionStatusHandler } from './handler';
1
+ import { withErrorHandler, withAccessCheck } from "../../shared";
2
+ import { GetCollectionStatusHandler } from "./handler";
3
3
  /**
4
4
  * Creates the get collection status endpoint
5
- */ export function createGetCollectionStatusRoute(config, taskRunnerFactory, access, basePath = '/translate') {
5
+ */ export function createGetCollectionStatusRoute(config, taskRunnerFactory, access, basePath = "/translate") {
6
6
  const handler = new GetCollectionStatusHandler(config, taskRunnerFactory);
7
7
  return {
8
8
  path: `${basePath}/collection/:collection_slug`,
9
- method: 'get',
9
+ method: "get",
10
10
  handler: withAccessCheck(withErrorHandler(handler.handle.bind(handler)), access)
11
11
  };
12
12
  }
@@ -1,12 +1,12 @@
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 { TaskRunnerFactory } from "../../modules/task-runner";
3
+ import type { GetDocumentStatusConfig } from "./model";
4
4
  /**
5
5
  * Gets the translation status for a specific document
6
6
  */
7
7
  export declare class GetDocumentStatusHandler {
8
8
  private readonly config;
9
9
  private readonly taskRunnerFactory;
10
- constructor(config: GetDocumentStatusConfig, taskRunnerFactory: TaskRunnerProvider);
10
+ constructor(config: GetDocumentStatusConfig, taskRunnerFactory: TaskRunnerFactory);
11
11
  handle(req: PayloadRequest): Promise<Response>;
12
12
  }
@@ -1,6 +1,6 @@
1
- import { ServerResponse } from '../../shared';
2
- import { isCollectionAvailable } from '../_lib/collection-utils';
3
- import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from './model';
1
+ import { ServerResponse } from "../../shared";
2
+ import { isCollectionAvailable } from "../_lib/collection-utils";
3
+ import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from "./model";
4
4
  /**
5
5
  * Gets the translation status for a specific document
6
6
  */ export class GetDocumentStatusHandler {
@@ -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,8 +1,8 @@
1
- import type { Endpoint } from 'payload';
2
- import type { AccessGuard } from '../../shared';
3
- import type { TaskRunnerProvider } from '../../modules/task-runner';
4
- import type { GetDocumentStatusConfig } from './model';
1
+ import type { Endpoint } from "payload";
2
+ import type { AccessGuard } from "../../shared";
3
+ import type { TaskRunnerFactory } from "../../modules/task-runner";
4
+ import type { GetDocumentStatusConfig } from "./model";
5
5
  /**
6
6
  * Creates the get document status endpoint
7
7
  */
8
- export declare function createGetDocumentStatusRoute(config: GetDocumentStatusConfig, taskRunnerFactory: TaskRunnerProvider, access?: AccessGuard, basePath?: string): Endpoint;
8
+ export declare function createGetDocumentStatusRoute(config: GetDocumentStatusConfig, taskRunnerFactory: TaskRunnerFactory, access?: AccessGuard, basePath?: string): Endpoint;
@@ -1,12 +1,12 @@
1
- import { withErrorHandler, withAccessCheck } from '../../shared';
2
- import { GetDocumentStatusHandler } from './handler';
1
+ import { withErrorHandler, withAccessCheck } from "../../shared";
2
+ import { GetDocumentStatusHandler } from "./handler";
3
3
  /**
4
4
  * Creates the get document status endpoint
5
- */ export function createGetDocumentStatusRoute(config, taskRunnerFactory, access, basePath = '/translate') {
5
+ */ export function createGetDocumentStatusRoute(config, taskRunnerFactory, access, basePath = "/translate") {
6
6
  const handler = new GetDocumentStatusHandler(config, taskRunnerFactory);
7
7
  return {
8
8
  path: `${basePath}/document/:collection_slug/:collection_id`,
9
- method: 'get',
9
+ method: "get",
10
10
  handler: withAccessCheck(withErrorHandler(handler.handle.bind(handler)), access)
11
11
  };
12
12
  }
@@ -1,6 +1,7 @@
1
- export * from './translate-document';
2
- export * from './enqueue-translation';
3
- export * from './run-translation';
4
- export * from './cancel';
5
- export * from './get-document-status';
6
- export * from './get-collection-status';
1
+ export * from "./createTranslationRoutes";
2
+ export * from "./translate-document";
3
+ export * from "./enqueue-translation";
4
+ export * from "./run-translation";
5
+ export * from "./cancel";
6
+ export * from "./get-document-status";
7
+ export * from "./get-collection-status";
@@ -1,8 +1,9 @@
1
- export * from './translate-document';
2
- export * from './enqueue-translation';
3
- export * from './run-translation';
4
- export * from './cancel';
5
- export * from './get-document-status';
6
- export * from './get-collection-status';
1
+ export * from "./createTranslationRoutes";
2
+ export * from "./translate-document";
3
+ export * from "./enqueue-translation";
4
+ export * from "./run-translation";
5
+ export * from "./cancel";
6
+ export * from "./get-document-status";
7
+ export * from "./get-collection-status";
7
8
 
8
9
  //# sourceMappingURL=index.js.map
@@ -1,10 +1,10 @@
1
- import type { PayloadRequest } from 'payload';
2
- import type { TaskRunnerProvider } from '../../modules/task-runner';
1
+ import type { PayloadRequest } from "payload";
2
+ import type { TaskRunnerFactory } from "../../modules/task-runner";
3
3
  /**
4
4
  * Runs a translation task by ID
5
5
  */
6
6
  export declare class RunTranslationHandler {
7
7
  private readonly taskRunnerFactory;
8
- constructor(taskRunnerFactory: TaskRunnerProvider);
8
+ constructor(taskRunnerFactory: TaskRunnerFactory);
9
9
  handle(req: PayloadRequest): Promise<Response>;
10
10
  }
@@ -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();
@@ -1,7 +1,7 @@
1
- import type { Endpoint } from 'payload';
2
- import type { AccessGuard } from '../../shared';
3
- import type { TaskRunnerProvider } from '../../modules/task-runner';
1
+ import type { Endpoint } from "payload";
2
+ import type { AccessGuard } from "../../shared";
3
+ import type { TaskRunnerFactory } from "../../modules/task-runner";
4
4
  /**
5
5
  * Creates the run translation endpoint
6
6
  */
7
- export declare function createRunRoute(taskRunnerFactory: TaskRunnerProvider, access?: AccessGuard, basePath?: string): Endpoint;
7
+ export declare function createRunRoute(taskRunnerFactory: TaskRunnerFactory, access?: AccessGuard, basePath?: string): Endpoint;
@@ -1,12 +1,12 @@
1
- import { withErrorHandler, withAccessCheck } from '../../shared';
2
- import { RunTranslationHandler } from './handler';
1
+ import { withErrorHandler, withAccessCheck } from "../../shared";
2
+ import { RunTranslationHandler } from "./handler";
3
3
  /**
4
4
  * Creates the run translation endpoint
5
- */ export function createRunRoute(taskRunnerFactory, access, basePath = '/translate') {
5
+ */ export function createRunRoute(taskRunnerFactory, access, basePath = "/translate") {
6
6
  const handler = new RunTranslationHandler(taskRunnerFactory);
7
7
  return {
8
8
  path: `${basePath}/run/:id`,
9
- method: 'post',
9
+ method: "post",
10
10
  handler: withAccessCheck(withErrorHandler(handler.handle.bind(handler)), access)
11
11
  };
12
12
  }
@@ -1,6 +1,5 @@
1
1
  import { APIError } from "payload";
2
- import { TranslationPipeline } from "../../modules/translation-pipeline";
3
- import { createTranslationStrategy } from "../../modules/translation-pipeline/strategies";
2
+ import { translateContent } from "../../modules/translation-pipeline";
4
3
  /**
5
4
  * Translates a single document from source language to target language
6
5
  */ export class TranslateDocumentHandler {
@@ -28,23 +27,20 @@ import { createTranslationStrategy } from "../../modules/translation-pipeline/st
28
27
  fallbackLocale: false,
29
28
  depth: 0
30
29
  });
31
- const translationStrategy = createTranslationStrategy(strategy);
32
- const pipeline = new TranslationPipeline({
33
- translationProvider: this.translationProvider,
34
- translationStrategy
35
- });
36
- const result = await pipeline.execute({
30
+ const translatedData = await translateContent({
37
31
  schema,
38
32
  sourceData,
39
33
  targetData,
40
34
  sourceLng,
41
- targetLng
35
+ targetLng,
36
+ translationProvider: this.translationProvider,
37
+ strategy
42
38
  });
43
- if (!result) return {
39
+ if (!translatedData) return {
44
40
  success: true
45
41
  };
46
42
  const collectionConfig = payload.collections[collection].config;
47
- await this.saveTranslatedDocument(payload, collection, collectionId, result.translatedData, targetLng, sourceLng, collectionConfig, publishOnTranslation);
43
+ await this.saveTranslatedDocument(payload, collection, collectionId, translatedData, targetLng, sourceLng, collectionConfig, publishOnTranslation);
48
44
  return {
49
45
  success: true
50
46
  };
@@ -24,6 +24,16 @@ export type TaskRunnerContext = {
24
24
  handler: TaskHandler;
25
25
  collections: CollectionSlug[];
26
26
  };
27
+ /**
28
+ * The runtime half of a runner: produces a `TaskRunner` for one request.
29
+ *
30
+ * Routes depend on this narrow surface — they only ever `create()`, never
31
+ * `configure()`. The plugin binds the {@link TaskRunnerContext} once at build
32
+ * time and hands routes a factory, so a `create()` call needs no ambient state.
33
+ */
34
+ export type TaskRunnerFactory = {
35
+ create(payload: Payload): TaskRunner;
36
+ };
27
37
  /**
28
38
  * Main interface for pluggable task runner providers.
29
39
  *
@@ -36,11 +46,19 @@ export type TaskRunnerContext = {
36
46
  export interface TaskRunnerProvider {
37
47
  /**
38
48
  * Creates a TaskRunner instance for runtime operations (enqueue, cancel, find).
49
+ *
50
+ * `handler` is supplied by the caller at create time (the plugin binds it
51
+ * into a {@link TaskRunnerFactory}) — so providers hold no mutable per-instance
52
+ * state and `create()` does not depend on `configure()` having run. It is the
53
+ * only runtime dependency (collection metadata is a `configure()`-time concern).
54
+ * `PayloadJobsRunner` ignores it (the handler is baked into the registered
55
+ * task at configure time); `SyncRunner` uses it to run translations inline.
39
56
  */
40
- create(payload: Payload): TaskRunner;
57
+ create(payload: Payload, handler: TaskHandler): TaskRunner;
41
58
  /**
42
59
  * Configures the runner and returns a Payload config modifier.
43
60
  * The modifier adds necessary tasks, jobs, queues to Payload config.
61
+ * Implementations may also install a `config.onInit` hook for boot-time initialization (e.g. stale-lock recovery).
44
62
  */
45
63
  configure(context: TaskRunnerContext): (config: Config) => Config;
46
64
  }
@@ -1,6 +1,6 @@
1
- export type { TaskRunnerProvider } from './TaskRunnerProvider.interface';
2
- export type { Task, TaskStatus } from './types';
3
- export { createPayloadJobsRunner } from './payload-jobs-runner';
4
- export type { PayloadJobsRunnerOptions } from './payload-jobs-runner';
5
- export { createSyncRunner } from './sync-runner';
6
- export type { TaskRunner } from './TaskRunner.interface';
1
+ export type { TaskRunnerProvider, TaskRunnerFactory, TaskRunnerContext } from "./TaskRunnerProvider.interface";
2
+ export type { Task, TaskStatus } from "./types";
3
+ export { createPayloadJobsRunner } from "./payload-jobs-runner";
4
+ export type { PayloadJobsRunnerOptions } from "./payload-jobs-runner";
5
+ export { createSyncRunner } from "./sync-runner";
6
+ export type { TaskRunner } from "./TaskRunner.interface";
@@ -1,4 +1,4 @@
1
- export { createPayloadJobsRunner } from './payload-jobs-runner';
2
- export { createSyncRunner } from './sync-runner';
1
+ export { createPayloadJobsRunner } from "./payload-jobs-runner";
2
+ export { createSyncRunner } from "./sync-runner";
3
3
 
4
4
  //# sourceMappingURL=index.js.map
@@ -14,6 +14,21 @@ export declare class PayloadJobsRunnerProvider implements TaskRunnerProvider {
14
14
  configure(context: TaskRunnerContext): (config: Config) => Config;
15
15
  }
16
16
  /**
17
- * Creates a TaskRunnerProvider that uses Payload Jobs for task execution.
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
+ * ```
18
33
  */
19
34
  export declare function createPayloadJobsRunner(options?: PayloadJobsRunnerOptions): TaskRunnerProvider;
@@ -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,12 +148,46 @@ 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
  }
147
173
  }
148
174
  /**
149
- * Creates a TaskRunnerProvider that uses Payload Jobs for task execution.
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
+ * ```
150
191
  */ export function createPayloadJobsRunner(options) {
151
192
  return new PayloadJobsRunnerProvider(options);
152
193
  }
@@ -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