@focus-reactive/payload-plugin-translator 0.1.5 → 0.2.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.
package/README.md CHANGED
@@ -32,14 +32,14 @@ yarn add @focus-reactive/payload-plugin-translator
32
32
  ## Quick Start
33
33
 
34
34
  ```typescript
35
- import { buildConfig } from 'payload'
35
+ import { buildConfig } from "payload";
36
36
  import {
37
37
  translatorPlugin,
38
38
  createOpenAIProvider,
39
39
  createPayloadJobsRunner,
40
- } from '@focus-reactive/payload-plugin-translator'
41
- import { Posts } from './collections/Posts'
42
- import { Pages } from './collections/Pages'
40
+ } from "@focus-reactive/payload-plugin-translator";
41
+ import { Posts } from "./collections/Posts";
42
+ import { Pages } from "./collections/Pages";
43
43
 
44
44
  export default buildConfig({
45
45
  collections: [Posts, Pages],
@@ -53,10 +53,10 @@ export default buildConfig({
53
53
  }),
54
54
  ],
55
55
  localization: {
56
- locales: ['en', 'de', 'fr'],
57
- defaultLocale: 'en',
56
+ locales: ["en", "de", "fr"],
57
+ defaultLocale: "en",
58
58
  },
59
- })
59
+ });
60
60
  ```
61
61
 
62
62
  ## Configuration
@@ -65,7 +65,6 @@ export default buildConfig({
65
65
 
66
66
  Configuration for `translatorPlugin()`.
67
67
 
68
-
69
68
  | Property | Type | Required | Default | Description |
70
69
  | --------------------- | --------------------- | -------- | -------------- | ------------------------------------------------------------------------------------------------------------------- |
71
70
  | `collections` | `CollectionConfig[]` | Yes | — | Original collection configs to enable translation for. Must be the same objects passed to `buildConfig`, not slugs. |
@@ -74,38 +73,37 @@ Configuration for `translatorPlugin()`.
74
73
  | `access` | `AccessGuard` | No | `undefined` | Access control function for translation endpoints |
75
74
  | `basePath` | `string` | No | `'/translate'` | Base path for all API endpoints |
76
75
 
77
-
78
76
  ```typescript
79
77
  translatorPlugin({
80
78
  collections: [Posts, Pages],
81
- translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
79
+ translationProvider: createOpenAIProvider({
80
+ apiKey: process.env.OPENAI_API_KEY,
81
+ }),
82
82
  runner: createPayloadJobsRunner(),
83
- access: async ({ req }) => req.user?.role === 'admin',
84
- basePath: '/translate',
85
- })
83
+ access: async ({ req }) => req.user?.role === "admin",
84
+ basePath: "/translate",
85
+ });
86
86
  ```
87
87
 
88
88
  ### OpenAIProviderConfig
89
89
 
90
90
  Configuration for `createOpenAIProvider()`.
91
91
 
92
-
93
- | Property | Type | Required | Default | Description |
94
- | -------------- | ------------------------ | -------- | --------------- | ------------------------------------------ |
95
- | `apiKey` | `string` | Yes | | OpenAI API key |
96
- | `model` | `string | ChatModel` | No | `'gpt-4o'` | OpenAI model to use for translation |
97
- | `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom function to build the system prompt |
98
- | `dryRun` | `boolean | DryRunConfig` | No | `false` | Simulate translations without API calls |
99
-
92
+ | Property | Type | Required | Default | Description |
93
+ | -------------- | ------------------------- | -------- | --------------- | ------------------------------------------ |
94
+ | `apiKey` | `string` | Yes | | OpenAI API key |
95
+ | `model` | `string \| ChatModel` | No | `'gpt-4o'` | OpenAI model to use for translation |
96
+ | `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom function to build the system prompt |
97
+ | `dryRun` | `boolean \| DryRunConfig` | No | `false` | Simulate translations without API calls |
100
98
 
101
99
  ```typescript
102
100
  createOpenAIProvider({
103
101
  apiKey: process.env.OPENAI_API_KEY,
104
- model: 'gpt-4o-mini',
102
+ model: "gpt-4o-mini",
105
103
  systemPrompt: ({ sourceLang, targetLang, defaultPrompt }) =>
106
104
  `${defaultPrompt}\nUse formal language. Keep brand names unchanged.`,
107
105
  dryRun: false,
108
- })
106
+ });
109
107
  ```
110
108
 
111
109
  #### SystemPromptBuilder
@@ -113,13 +111,13 @@ createOpenAIProvider({
113
111
  Function signature for custom system prompt:
114
112
 
115
113
  ```typescript
116
- type SystemPromptBuilder = (context: SystemPromptContext) => string
114
+ type SystemPromptBuilder = (context: SystemPromptContext) => string;
117
115
 
118
116
  type SystemPromptContext = {
119
- sourceLang: string
120
- targetLang: string
121
- defaultPrompt: string
122
- }
117
+ sourceLang: string;
118
+ targetLang: string;
119
+ defaultPrompt: string;
120
+ };
123
121
  ```
124
122
 
125
123
  #### DryRunConfig
@@ -127,64 +125,61 @@ type SystemPromptContext = {
127
125
  When `dryRun` is an object, it allows custom transformation with optional delay:
128
126
 
129
127
  ```typescript
130
- type DryRunTransformer = (text: string) => string | Promise<string>
128
+ type DryRunTransformer = (text: string) => string | Promise<string>;
131
129
 
132
130
  type DryRunConfig = {
133
- transform: DryRunTransformer // Custom transformer function
134
- timeout?: number // Delay in ms (simulates API latency)
135
- }
131
+ transform: DryRunTransformer; // Custom transformer function
132
+ timeout?: number; // Delay in ms (simulates API latency)
133
+ };
136
134
  ```
137
135
 
138
136
  ### PayloadJobsRunnerOptions
139
137
 
140
138
  Configuration for `createPayloadJobsRunner()`.
141
139
 
142
-
143
- | Property | Type | Required | Default | Description |
144
- | ----------- | ------------------------- | -------- | ---------------------------------- | ------------------------------------------------------- |
145
- | `taskName` | `string` | No | `'translate_document'` | Task name in Payload jobs collection |
146
- | `queueName` | `string` | No | `'translations'` | Queue name for grouping jobs |
147
- | `autoRun` | `false | { cron, limit }` | No | `{ cron: '* * * * *', limit: 50 }` | Auto-run config, or `false` to disable (for serverless) |
148
-
140
+ | Property | Type | Required | Default | Description |
141
+ | ----------- | -------------------------- | -------- | ---------------------------------- | ------------------------------------------------------- |
142
+ | `taskName` | `string` | No | `'translate_document'` | Task name in Payload jobs collection |
143
+ | `queueName` | `string` | No | `'translations'` | Queue name for grouping jobs |
144
+ | `autoRun` | `false \| { cron, limit }` | No | `{ cron: '* * * * *', limit: 50 }` | Auto-run config, or `false` to disable (for serverless) |
149
145
 
150
146
  ```typescript
151
147
  createPayloadJobsRunner({
152
- taskName: 'translate_document',
153
- queueName: 'translations',
148
+ taskName: "translate_document",
149
+ queueName: "translations",
154
150
  autoRun: {
155
- cron: '* * * * *',
151
+ cron: "* * * * *",
156
152
  limit: 50,
157
153
  },
158
- })
154
+ });
159
155
  ```
160
156
 
161
157
  ### FieldTranslationConfig
162
158
 
163
159
  Configuration for `withFieldTranslation()` helper or `field.custom.translateKit`.
164
160
 
165
-
166
161
  | Property | Type | Required | Default | Description |
167
162
  | --------- | --------- | -------- | ------- | ----------------------------------- |
168
163
  | `exclude` | `boolean` | No | `false` | Exclude this field from translation |
169
164
 
170
-
171
165
  ```typescript
172
- import { withFieldTranslation } from '@focus-reactive/payload-plugin-translator'
166
+ import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
173
167
 
174
- withFieldTranslation({ name: 'sku', type: 'text', localized: true }, { exclude: true })
168
+ withFieldTranslation(
169
+ { name: "sku", type: "text", localized: true },
170
+ { exclude: true },
171
+ );
175
172
  ```
176
173
 
177
174
  ## Translation Strategies
178
175
 
179
176
  When translating, you can choose how to handle existing translations:
180
177
 
181
-
182
178
  | Strategy | Description |
183
179
  | ----------------- | ------------------------------------------------------------------------ |
184
180
  | `'overwrite'` | (Default) Replaces all existing translated content with new translations |
185
181
  | `'skip_existing'` | Only translates fields that are empty in the target locale |
186
182
 
187
-
188
183
  ## Important Notes
189
184
 
190
185
  ### Explicit `localized: true` for nested fields
@@ -239,7 +234,7 @@ export default buildConfig({
239
234
  jobs: {
240
235
  deleteJobOnComplete: false,
241
236
  },
242
- })
237
+ });
243
238
  ```
244
239
 
245
240
  ## Task Runners
@@ -249,16 +244,16 @@ export default buildConfig({
249
244
  Uses Payload's built-in job queue for background processing:
250
245
 
251
246
  ```typescript
252
- import { createPayloadJobsRunner } from '@focus-reactive/payload-plugin-translator'
247
+ import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
253
248
 
254
249
  const runner = createPayloadJobsRunner({
255
- taskName: 'translate_document',
256
- queueName: 'translations',
250
+ taskName: "translate_document",
251
+ queueName: "translations",
257
252
  autoRun: {
258
- cron: '* * * * *',
253
+ cron: "* * * * *",
259
254
  limit: 50,
260
255
  },
261
- })
256
+ });
262
257
  ```
263
258
 
264
259
  ### SyncRunner
@@ -266,9 +261,9 @@ const runner = createPayloadJobsRunner({
266
261
  Executes translations synchronously (useful for development or small datasets):
267
262
 
268
263
  ```typescript
269
- import { createSyncRunner } from '@focus-reactive/payload-plugin-translator'
264
+ import { createSyncRunner } from "@focus-reactive/payload-plugin-translator";
270
265
 
271
- const runner = createSyncRunner()
266
+ const runner = createSyncRunner();
272
267
  ```
273
268
 
274
269
  ## Translation Providers
@@ -278,13 +273,13 @@ const runner = createSyncRunner()
278
273
  Built-in provider using OpenAI's API:
279
274
 
280
275
  ```typescript
281
- import { createOpenAIProvider } from '@focus-reactive/payload-plugin-translator'
276
+ import { createOpenAIProvider } from "@focus-reactive/payload-plugin-translator";
282
277
 
283
278
  const provider = createOpenAIProvider({
284
279
  apiKey: process.env.OPENAI_API_KEY,
285
- model: 'gpt-4o-mini',
280
+ model: "gpt-4o-mini",
286
281
  systemPrompt: ({ defaultPrompt }) => `${defaultPrompt}\nUse formal language.`,
287
- })
282
+ });
288
283
  ```
289
284
 
290
285
  ### Custom Provider
@@ -293,11 +288,9 @@ Create your own translation provider by implementing the `TranslationProvider` i
293
288
 
294
289
  #### TranslationProvider Interface
295
290
 
296
-
297
- | Method | Signature | Description |
298
- | ----------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
299
- | `translate` | `(content: TranslationInput, sourceLng: string, targetLng: string) => Promise<TranslationOutput | null>` | Translates content from source to target language |
300
-
291
+ | Method | Signature | Description |
292
+ | ----------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
293
+ | `translate` | `(content: TranslationInput, sourceLng: string, targetLng: string) => Promise<TranslationOutput \| null>` | Translates content from source to target language |
301
294
 
302
295
  **Important:** The numeric indices in `TranslationInput` must be preserved exactly in `TranslationOutput`. Each index maps to a specific field in the document structure, so the provider must return the same keys with translated values.
303
296
 
@@ -305,13 +298,13 @@ Create your own translation provider by implementing the `TranslationProvider` i
305
298
 
306
299
  ```typescript
307
300
  // Numeric index representing position in document structure
308
- type TranslationIndex = number
301
+ type TranslationIndex = number;
309
302
 
310
303
  // Input: Map of numeric indices to text strings
311
- type TranslationInput = Record<TranslationIndex, string>
304
+ type TranslationInput = Record<TranslationIndex, string>;
312
305
 
313
306
  // Output: Same indices with translated values
314
- type TranslationOutput = Record<TranslationIndex, string>
307
+ type TranslationOutput = Record<TranslationIndex, string>;
315
308
  ```
316
309
 
317
310
  #### Example Implementation
@@ -321,40 +314,44 @@ import type {
321
314
  TranslationProvider,
322
315
  TranslationInput,
323
316
  TranslationOutput,
324
- } from '@focus-reactive/payload-plugin-translator'
317
+ } from "@focus-reactive/payload-plugin-translator";
325
318
 
326
319
  class DeepLProvider implements TranslationProvider {
327
320
  constructor(private apiKey: string) {}
328
321
 
329
- async translate(content: TranslationInput, sourceLng: string, targetLng: string): Promise<TranslationOutput | null> {
322
+ async translate(
323
+ content: TranslationInput,
324
+ sourceLng: string,
325
+ targetLng: string,
326
+ ): Promise<TranslationOutput | null> {
330
327
  try {
331
328
  // content example: { "0": "Hello", "1": "World" }
332
- const texts = Object.values(content)
329
+ const texts = Object.values(content);
333
330
 
334
- const response = await fetch('https://api.deepl.com/v2/translate', {
335
- method: 'POST',
331
+ const response = await fetch("https://api.deepl.com/v2/translate", {
332
+ method: "POST",
336
333
  headers: {
337
334
  Authorization: `DeepL-Auth-Key ${this.apiKey}`,
338
- 'Content-Type': 'application/json',
335
+ "Content-Type": "application/json",
339
336
  },
340
337
  body: JSON.stringify({
341
338
  text: texts,
342
339
  source_lang: sourceLng.toUpperCase(),
343
340
  target_lang: targetLng.toUpperCase(),
344
341
  }),
345
- })
342
+ });
346
343
 
347
- const data = await response.json()
344
+ const data = await response.json();
348
345
 
349
346
  // Reconstruct the result with same keys
350
- const result: TranslationOutput = {}
347
+ const result: TranslationOutput = {};
351
348
  Object.keys(content).forEach((key, index) => {
352
- result[key] = data.translations[index].text
353
- })
349
+ result[key] = data.translations[index].text;
350
+ });
354
351
 
355
- return result
352
+ return result;
356
353
  } catch {
357
- return null
354
+ return null;
358
355
  }
359
356
  }
360
357
  }
@@ -364,7 +361,7 @@ translatorPlugin({
364
361
  collections: [Posts],
365
362
  translationProvider: new DeepLProvider(process.env.DEEPL_API_KEY),
366
363
  runner: createPayloadJobsRunner(),
367
- })
364
+ });
368
365
  ```
369
366
 
370
367
  ## UI Components
@@ -404,17 +401,9 @@ import type {
404
401
 
405
402
  // Field config
406
403
  FieldTranslationConfig,
407
- } from '@focus-reactive/payload-plugin-translator'
404
+ } from "@focus-reactive/payload-plugin-translator";
408
405
  ```
409
406
 
410
- ## Known Issues
411
-
412
- ### SQLite: Nested JSON queries not supported
413
-
414
- SQLite adapter doesn't support nested JSON field queries like `{ 'input.collection.value': { equals: '5' } }`.
415
-
416
- **Affected databases:** SQLite only
417
-
418
407
  ## Roadmap
419
408
 
420
409
  Planned features for future releases:
@@ -423,4 +412,3 @@ Planned features for future releases:
423
412
  - **Global translation dashboard** — Translate all collections at once from a single interface, with progress tracking across the entire CMS
424
413
  - **Vercel Cron Jobs runner** — Built-in runner for seamless Vercel/serverless deployments without manual API route configuration
425
414
  - Auto-translate on source change — Automatically trigger translation when the default locale content is updated
426
-
@@ -1,5 +1,5 @@
1
- import type { UseFormReturn } from 'react-hook-form';
2
- import type { FormInput, FormValues } from './schema';
1
+ import type { UseFormReturn } from "react-hook-form";
2
+ import type { FormInput, FormValues } from "./schema";
3
3
  type UseFormReturn_ = {
4
4
  form: UseFormReturn<FormValues>;
5
5
  };
@@ -1,9 +1,9 @@
1
- 'use client';
2
- import { zodResolver } from '@hookform/resolvers/zod';
3
- import { useEffect, useMemo } from 'react';
4
- import { useForm } from 'react-hook-form';
5
- import { defaultValues } from './constants';
6
- import { validationSchema } from './schema';
1
+ "use client";
2
+ import { zodResolver } from "@hookform/resolvers/zod";
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import { useForm } from "react-hook-form";
5
+ import { defaultValues } from "./constants";
6
+ import { validationSchema } from "./schema";
7
7
  export const useCollectionTranslationForm = ({ initialValues, disabled } = {})=>{
8
8
  const defaultFormValues = useMemo(()=>({
9
9
  ...defaultValues,
@@ -14,15 +14,18 @@ export const useCollectionTranslationForm = ({ initialValues, disabled } = {})=>
14
14
  const form = useForm({
15
15
  defaultValues: defaultFormValues,
16
16
  resolver: zodResolver(validationSchema),
17
- mode: 'onTouched',
17
+ mode: "onTouched",
18
18
  disabled
19
19
  });
20
+ const isFirstRender = useRef(true);
20
21
  useEffect(()=>{
22
+ if (isFirstRender.current) {
23
+ isFirstRender.current = false;
24
+ return;
25
+ }
21
26
  form.reset(defaultFormValues);
22
27
  }, [
23
- defaultFormValues,
24
- form,
25
- form.reset
28
+ defaultFormValues
26
29
  ]);
27
30
  return {
28
31
  form
@@ -1,5 +1,5 @@
1
- import type { UseFormReturn } from 'react-hook-form';
2
- import type { FormInput, FormValues } from './schema';
1
+ import type { UseFormReturn } from "react-hook-form";
2
+ import type { FormInput, FormValues } from "./schema";
3
3
  type UseFormReturn_ = {
4
4
  form: UseFormReturn<FormValues>;
5
5
  };
@@ -1,9 +1,9 @@
1
- 'use client';
2
- import { zodResolver } from '@hookform/resolvers/zod';
3
- import { useEffect, useMemo } from 'react';
4
- import { useForm } from 'react-hook-form';
5
- import { defaultValues } from './constants';
6
- import { validationSchema } from './schema';
1
+ "use client";
2
+ import { zodResolver } from "@hookform/resolvers/zod";
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import { useForm } from "react-hook-form";
5
+ import { defaultValues } from "./constants";
6
+ import { validationSchema } from "./schema";
7
7
  export const useTranslateDocumentForm = ({ initialValues, disabled } = {})=>{
8
8
  const defaultFormValues = useMemo(()=>({
9
9
  ...defaultValues,
@@ -14,15 +14,18 @@ export const useTranslateDocumentForm = ({ initialValues, disabled } = {})=>{
14
14
  const form = useForm({
15
15
  defaultValues: defaultFormValues,
16
16
  resolver: zodResolver(validationSchema),
17
- mode: 'onTouched',
17
+ mode: "onTouched",
18
18
  disabled
19
19
  });
20
+ const isFirstRender = useRef(true);
20
21
  useEffect(()=>{
22
+ if (isFirstRender.current) {
23
+ isFirstRender.current = false;
24
+ return;
25
+ }
21
26
  form.reset(defaultFormValues);
22
27
  }, [
23
- defaultFormValues,
24
- form,
25
- form.reset
28
+ defaultFormValues
26
29
  ]);
27
30
  return {
28
31
  form
@@ -1,11 +1,11 @@
1
- import { z } from 'zod';
1
+ import { z } from "zod";
2
2
  /**
3
- * Input validation schema for batch cancel
3
+ * Input validation schema for batch cancel.
4
4
  */
5
5
  export declare const CancelInputSchema: z.ZodObject<{
6
- ids: z.ZodArray<z.ZodString, "many">;
6
+ ids: z.ZodArray<z.ZodEffects<z.ZodUnion<[z.ZodEffects<z.ZodString, string, string>, z.ZodNumber]>, string, string | number>, "many">;
7
7
  }, "strip", z.ZodTypeAny, {
8
8
  ids: string[];
9
9
  }, {
10
- ids: string[];
10
+ ids: (string | number)[];
11
11
  }>;
@@ -1,8 +1,9 @@
1
- import { z } from 'zod';
1
+ import { z } from "zod";
2
+ import { JobIdSchema } from "../../shared";
2
3
  /**
3
- * Input validation schema for batch cancel
4
+ * Input validation schema for batch cancel.
4
5
  */ export const CancelInputSchema = z.object({
5
- ids: z.array(z.string().nonempty()).min(1)
6
+ ids: z.array(JobIdSchema).min(1)
6
7
  });
7
8
 
8
9
  //# sourceMappingURL=model.js.map
@@ -1,18 +1,22 @@
1
- import { z } from 'zod';
2
- import type { CollectionSlug } from 'payload';
3
- import type { Task, TaskStatus } from '../../modules/task-runner';
1
+ import { z } from "zod";
2
+ import type { CollectionSlug } from "payload";
3
+ import type { Task, TaskStatus } from "../../modules/task-runner";
4
4
  /**
5
- * Input validation schema
5
+ * Input validation schema.
6
+ *
7
+ * `collection_id` accepts any of the shapes Payload allows as a document ID
8
+ * (integer autoincrement, UUID, MongoDB ObjectId, etc.). See JobIdSchema for
9
+ * the rationale.
6
10
  */
7
11
  export declare const GetDocumentStatusInputSchema: z.ZodObject<{
8
- collection_id: z.ZodEffects<z.ZodString, string, string>;
12
+ collection_id: z.ZodEffects<z.ZodUnion<[z.ZodEffects<z.ZodString, string, string>, z.ZodNumber]>, string, string | number>;
9
13
  collection_slug: z.ZodString;
10
14
  }, "strip", z.ZodTypeAny, {
11
15
  collection_slug: string;
12
16
  collection_id: string;
13
17
  }, {
14
18
  collection_slug: string;
15
- collection_id: string;
19
+ collection_id: string | number;
16
20
  }>;
17
21
  export type GetDocumentStatusInput = z.infer<typeof GetDocumentStatusInputSchema>;
18
22
  /**
@@ -1,10 +1,13 @@
1
- import { z } from 'zod';
1
+ import { z } from "zod";
2
+ import { JobIdSchema } from "../../shared";
2
3
  /**
3
- * Input validation schema
4
+ * Input validation schema.
5
+ *
6
+ * `collection_id` accepts any of the shapes Payload allows as a document ID
7
+ * (integer autoincrement, UUID, MongoDB ObjectId, etc.). See JobIdSchema for
8
+ * the rationale.
4
9
  */ export const GetDocumentStatusInputSchema = z.object({
5
- collection_id: z.coerce.string().refine((val)=>val.length > 0 && val !== 'undefined', {
6
- message: 'Required'
7
- }),
10
+ collection_id: JobIdSchema,
8
11
  collection_slug: z.string().nonempty()
9
12
  });
10
13
  /**
@@ -1,11 +1,15 @@
1
- import { z } from 'zod';
1
+ import { z } from "zod";
2
2
  /**
3
- * Input validation schema
3
+ * Input validation schema.
4
+ *
5
+ * `id` comes from the URL route param (always a string at the HTTP edge),
6
+ * but we reuse the canonical JobIdSchema so the contract stays consistent
7
+ * with batch cancel.
4
8
  */
5
9
  export declare const RunInputSchema: z.ZodObject<{
6
- id: z.ZodString;
10
+ id: z.ZodEffects<z.ZodUnion<[z.ZodEffects<z.ZodString, string, string>, z.ZodNumber]>, string, string | number>;
7
11
  }, "strip", z.ZodTypeAny, {
8
12
  id: string;
9
13
  }, {
10
- id: string;
14
+ id: string | number;
11
15
  }>;
@@ -1,8 +1,13 @@
1
- import { z } from 'zod';
1
+ import { z } from "zod";
2
+ import { JobIdSchema } from "../../shared";
2
3
  /**
3
- * Input validation schema
4
+ * Input validation schema.
5
+ *
6
+ * `id` comes from the URL route param (always a string at the HTTP edge),
7
+ * but we reuse the canonical JobIdSchema so the contract stays consistent
8
+ * with batch cancel.
4
9
  */ export const RunInputSchema = z.object({
5
- id: z.string().nonempty()
10
+ id: JobIdSchema
6
11
  });
7
12
 
8
13
  //# sourceMappingURL=model.js.map
@@ -1,7 +1,7 @@
1
- import type { Payload, CollectionSlug } from 'payload';
2
- import type { TaskRunner } from '../TaskRunner.interface';
3
- import type { Task, TaskInput, RunResult } from '../types';
4
- import type { PayloadJobsRunnerConfig } from './types';
1
+ import type { Payload, CollectionSlug } from "payload";
2
+ import type { TaskRunner } from "../TaskRunner.interface";
3
+ import type { Task, TaskInput, RunResult } from "../types";
4
+ import type { PayloadJobsRunnerConfig } from "./types";
5
5
  /**
6
6
  * TaskRunner implementation using Payload Jobs.
7
7
  *
@@ -14,6 +14,82 @@ 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
+ * Find translation jobs for a collection, optionally narrowed by document IDs.
19
+ *
20
+ * IMPORTANT — why we filter `collection.value` in memory instead of in WHERE
21
+ * ------------------------------------------------------------------------
22
+ * The "natural" implementation would be a single `payload.find` with the
23
+ * full where clause:
24
+ *
25
+ * where: {
26
+ * and: [
27
+ * { 'input.collection.relationTo': { equals: collectionSlug } },
28
+ * { 'input.collection.value': { in: documentIds } },
29
+ * ],
30
+ * }
31
+ *
32
+ * This DOES NOT work on SQLite (and is unreliable on any adapter) because
33
+ * of two compounding bugs in Payload's drizzle layer.
34
+ *
35
+ * 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
36
+ * drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
37
+ * has no `case 'json'` branch, so the value is left as a raw column and
38
+ * the path segments are passed through to `parseParams.js`, which on
39
+ * SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
40
+ * expressions like `input->>'collection'->>'value'`.
41
+ *
42
+ * 2. When `parseParams.js` formats the right-hand side of `in`/`not_in`
43
+ * (and even `equals` when `!isNaN(val)`), it inlines values via JS
44
+ * template literals WITHOUT wrapping strings in quotes. The string
45
+ * `'1'` from our WHERE becomes raw `1` in the SQL. Drizzle therefore
46
+ * emits queries like:
47
+ *
48
+ * WHERE input->>'collection'->>'value' IN (1)
49
+ *
50
+ * even though the caller passed `['1']` (an array of strings).
51
+ *
52
+ * On SQLite, `->>` preserves the JSON value's type — if the stored JSON
53
+ * has `"value": "1"` (a JSON string), `->>` returns SQLite TEXT `'1'`;
54
+ * if the JSON has `"value": 1` (a JSON number), `->>` returns INTEGER `1`.
55
+ * SQLite's `IN (...)` does NOT coerce between TEXT and INTEGER. So:
56
+ *
57
+ * TEXT '1' IN (1) → false (text vs integer, no match)
58
+ * INTEGER 1 IN ('1') → false (integer vs text, no match)
59
+ *
60
+ * Combined with bug #2 above, any value passed by the caller — even if
61
+ * we normalize it to a string on write — gets re-coerced to a number in
62
+ * the generated SQL and never matches the stored JSON.
63
+ *
64
+ * Postgres avoids most of this because `jsonb_path_query` returns text
65
+ * uniformly and PG's type coercion is more permissive, but the same
66
+ * un-quoted-string bug technically affects it too.
67
+ *
68
+ * Why we don't fix it upstream / patch the dep
69
+ * --------------------------------------------
70
+ * - This plugin is published to npm. Consumers install it with their own
71
+ * Payload version and would not receive any local `bun patch` /
72
+ * `patch-package` overrides on `@payloadcms/drizzle`. The plugin must
73
+ * work against vanilla Payload.
74
+ * - A PR to Payload core is the proper long-term fix, but the plugin
75
+ * cannot block on its merge/release cycle.
76
+ * - Forcing a non-numeric prefix on the stored ID (e.g., `"id:1"`) would
77
+ * work around bug #2, but bloats the data shape and breaks anything
78
+ * that reads `input.collection.value` expecting a plain id.
79
+ *
80
+ * Why in-memory filtering is acceptable here
81
+ * ------------------------------------------
82
+ * We narrow the SQL query to `taskSlug + relationTo` (both string
83
+ * equality, which Payload quotes correctly), then filter the result set
84
+ * by `collection.value` in JavaScript. Per-collection job sets are
85
+ * small (typically <100 rows; the plugin actively cancels superseded
86
+ * jobs so they don't accumulate), so a Set-membership check in JS is
87
+ * effectively free.
88
+ *
89
+ * If/when the upstream drizzle bug is fixed (or this plugin gains a
90
+ * mirror collection with indexed flat columns), this method can collapse
91
+ * back to a single SQL query.
92
+ */
17
93
  findByCollection(collectionSlug: CollectionSlug, documentIds?: Array<string | number>): Promise<Task[]>;
18
94
  /**
19
95
  * Group tasks by collection slug
@@ -1,4 +1,4 @@
1
- import { normalizeJob } from './normalizeJob';
1
+ import { normalizeJob } from "./normalizeJob";
2
2
  /**
3
3
  * TaskRunner implementation using Payload Jobs.
4
4
  *
@@ -13,21 +13,8 @@ import { normalizeJob } from './normalizeJob';
13
13
  async enqueue(tasks) {
14
14
  const byCollection = this.groupByCollection(tasks);
15
15
  for (const [collectionSlug, items] of byCollection){
16
- const documentIds = items.map((t)=>t.collectionId);
17
- const existing = await this.findJobsInternal({
18
- and: [
19
- {
20
- 'input.collection.relationTo': {
21
- equals: collectionSlug
22
- }
23
- },
24
- {
25
- 'input.collection.value': {
26
- in: documentIds
27
- }
28
- }
29
- ]
30
- });
16
+ const documentIds = items.map((t)=>String(t.collectionId));
17
+ const existing = await this.findByCollection(collectionSlug, documentIds);
31
18
  if (existing.length > 0) {
32
19
  await this.cancelInternal(existing.map((t)=>t.id));
33
20
  }
@@ -37,6 +24,15 @@ import { normalizeJob } from './normalizeJob';
37
24
  queue: this.config.queueName,
38
25
  input: {
39
26
  collection: {
27
+ // Pass `value` through verbatim. The Payload Jobs `input` schema
28
+ // declares this as a `relationship` field, which validates the
29
+ // value's type against the target collection's ID type (number
30
+ // for autoincrement, string for uuid). Coercing to string here
31
+ // would silently fail validation for number-id collections and
32
+ // leave jobs stuck in processing without ever invoking the
33
+ // task handler. `findByCollection` reads back via in-memory
34
+ // filtering and normalizes both sides with String(...) for the
35
+ // comparison, so it does not need write-side normalization.
40
36
  relationTo: task.collectionSlug,
41
37
  value: task.collectionId
42
38
  },
@@ -63,19 +59,19 @@ import { normalizeJob } from './normalizeJob';
63
59
  if (!task) {
64
60
  return {
65
61
  success: false,
66
- error: 'not_found'
62
+ error: "not_found"
67
63
  };
68
64
  }
69
65
  if (task.completedAt) {
70
66
  return {
71
67
  success: false,
72
- error: 'already_completed'
68
+ error: "already_completed"
73
69
  };
74
70
  }
75
- if (task.status === 'running') {
71
+ if (task.status === "running") {
76
72
  return {
77
73
  success: false,
78
- error: 'already_running'
74
+ error: "already_running"
79
75
  };
80
76
  }
81
77
  this.payload.jobs.runByID({
@@ -85,28 +81,92 @@ import { normalizeJob } from './normalizeJob';
85
81
  success: true
86
82
  };
87
83
  }
88
- async findByCollection(collectionSlug, documentIds) {
89
- const where = documentIds?.length ? {
90
- and: [
91
- {
92
- 'input.collection.relationTo': {
93
- equals: collectionSlug
94
- }
95
- },
96
- {
97
- 'input.collection.value': {
98
- in: documentIds
99
- }
100
- }
101
- ]
102
- } : {
103
- 'input.collection.relationTo': {
84
+ /**
85
+ * Find translation jobs for a collection, optionally narrowed by document IDs.
86
+ *
87
+ * IMPORTANT — why we filter `collection.value` in memory instead of in WHERE
88
+ * ------------------------------------------------------------------------
89
+ * The "natural" implementation would be a single `payload.find` with the
90
+ * full where clause:
91
+ *
92
+ * where: {
93
+ * and: [
94
+ * { 'input.collection.relationTo': { equals: collectionSlug } },
95
+ * { 'input.collection.value': { in: documentIds } },
96
+ * ],
97
+ * }
98
+ *
99
+ * This DOES NOT work on SQLite (and is unreliable on any adapter) because
100
+ * of two compounding bugs in Payload's drizzle layer.
101
+ *
102
+ * 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
103
+ * drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
104
+ * has no `case 'json'` branch, so the value is left as a raw column and
105
+ * the path segments are passed through to `parseParams.js`, which on
106
+ * SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
107
+ * expressions like `input->>'collection'->>'value'`.
108
+ *
109
+ * 2. When `parseParams.js` formats the right-hand side of `in`/`not_in`
110
+ * (and even `equals` when `!isNaN(val)`), it inlines values via JS
111
+ * template literals WITHOUT wrapping strings in quotes. The string
112
+ * `'1'` from our WHERE becomes raw `1` in the SQL. Drizzle therefore
113
+ * emits queries like:
114
+ *
115
+ * WHERE input->>'collection'->>'value' IN (1)
116
+ *
117
+ * even though the caller passed `['1']` (an array of strings).
118
+ *
119
+ * On SQLite, `->>` preserves the JSON value's type — if the stored JSON
120
+ * has `"value": "1"` (a JSON string), `->>` returns SQLite TEXT `'1'`;
121
+ * if the JSON has `"value": 1` (a JSON number), `->>` returns INTEGER `1`.
122
+ * SQLite's `IN (...)` does NOT coerce between TEXT and INTEGER. So:
123
+ *
124
+ * TEXT '1' IN (1) → false (text vs integer, no match)
125
+ * INTEGER 1 IN ('1') → false (integer vs text, no match)
126
+ *
127
+ * Combined with bug #2 above, any value passed by the caller — even if
128
+ * we normalize it to a string on write — gets re-coerced to a number in
129
+ * the generated SQL and never matches the stored JSON.
130
+ *
131
+ * Postgres avoids most of this because `jsonb_path_query` returns text
132
+ * uniformly and PG's type coercion is more permissive, but the same
133
+ * un-quoted-string bug technically affects it too.
134
+ *
135
+ * Why we don't fix it upstream / patch the dep
136
+ * --------------------------------------------
137
+ * - This plugin is published to npm. Consumers install it with their own
138
+ * Payload version and would not receive any local `bun patch` /
139
+ * `patch-package` overrides on `@payloadcms/drizzle`. The plugin must
140
+ * work against vanilla Payload.
141
+ * - A PR to Payload core is the proper long-term fix, but the plugin
142
+ * cannot block on its merge/release cycle.
143
+ * - Forcing a non-numeric prefix on the stored ID (e.g., `"id:1"`) would
144
+ * work around bug #2, but bloats the data shape and breaks anything
145
+ * that reads `input.collection.value` expecting a plain id.
146
+ *
147
+ * Why in-memory filtering is acceptable here
148
+ * ------------------------------------------
149
+ * We narrow the SQL query to `taskSlug + relationTo` (both string
150
+ * equality, which Payload quotes correctly), then filter the result set
151
+ * by `collection.value` in JavaScript. Per-collection job sets are
152
+ * small (typically <100 rows; the plugin actively cancels superseded
153
+ * jobs so they don't accumulate), so a Set-membership check in JS is
154
+ * effectively free.
155
+ *
156
+ * If/when the upstream drizzle bug is fixed (or this plugin gains a
157
+ * mirror collection with indexed flat columns), this method can collapse
158
+ * back to a single SQL query.
159
+ */ async findByCollection(collectionSlug, documentIds) {
160
+ const tasks = await this.findJobsInternal({
161
+ "input.collection.relationTo": {
104
162
  equals: collectionSlug
105
163
  }
106
- };
107
- return this.findJobsInternal(where, {
164
+ }, {
108
165
  pagination: false
109
166
  });
167
+ if (!documentIds?.length) return tasks;
168
+ const wanted = new Set(documentIds.map(String));
169
+ return tasks.filter((t)=>wanted.has(String(t.input.collectionId)));
110
170
  }
111
171
  /**
112
172
  * Group tasks by collection slug
@@ -1,10 +1,11 @@
1
- export { ServerResponse, withErrorHandler, withAccessCheck } from './http';
2
- export { AnyAccessGuard } from './access';
3
- export type { AccessGuard, AccessGuardRequest, Handler } from './access';
4
- export { isEmpty, isObject, normalizePath, pipe, getByPath, setByPath, filterLocalizedFields } from './utils';
5
- export type { TranslatableField } from './guards';
6
- export { isTranslatableField, isLocalizedField, isRelationshipField, isTabsField, isBlockItem, hasFields, } from './guards';
7
- export { isFieldExcludedFromTranslation, getTranslateKitFieldConfig } from './field-config';
8
- export type { TranslateKitFieldConfig } from './field-config';
9
- export { isSerializedLexicalRoot, isEmptyRichText, traverseLexicalTree, collectSerializedLexicalTextNodes, } from './lexical';
10
- export type { SerializedTextNodeRef } from './lexical';
1
+ export { ServerResponse, withErrorHandler, withAccessCheck } from "./http";
2
+ export { AnyAccessGuard } from "./access";
3
+ export type { AccessGuard, AccessGuardRequest, Handler } from "./access";
4
+ export { isEmpty, isObject, normalizePath, pipe, getByPath, setByPath, filterLocalizedFields, } from "./utils";
5
+ export type { TranslatableField } from "./guards";
6
+ export { isTranslatableField, isLocalizedField, isRelationshipField, isTabsField, isBlockItem, hasFields, } from "./guards";
7
+ export { isFieldExcludedFromTranslation, getTranslateKitFieldConfig, } from "./field-config";
8
+ export type { TranslateKitFieldConfig } from "./field-config";
9
+ export { isSerializedLexicalRoot, isEmptyRichText, traverseLexicalTree, collectSerializedLexicalTextNodes, } from "./lexical";
10
+ export type { SerializedTextNodeRef } from "./lexical";
11
+ export { JobIdSchema } from "./validation";
@@ -1,13 +1,15 @@
1
1
  // HTTP utilities
2
- export { ServerResponse, withErrorHandler, withAccessCheck } from './http';
2
+ export { ServerResponse, withErrorHandler, withAccessCheck } from "./http";
3
3
  // Access control
4
- export { AnyAccessGuard } from './access';
4
+ export { AnyAccessGuard } from "./access";
5
5
  // General utilities
6
- export { isEmpty, isObject, normalizePath, pipe, getByPath, setByPath, filterLocalizedFields } from './utils';
7
- export { isTranslatableField, isLocalizedField, isRelationshipField, isTabsField, isBlockItem, hasFields } from './guards';
6
+ export { isEmpty, isObject, normalizePath, pipe, getByPath, setByPath, filterLocalizedFields } from "./utils";
7
+ export { isTranslatableField, isLocalizedField, isRelationshipField, isTabsField, isBlockItem, hasFields } from "./guards";
8
8
  // Field config
9
- export { isFieldExcludedFromTranslation, getTranslateKitFieldConfig } from './field-config';
9
+ export { isFieldExcludedFromTranslation, getTranslateKitFieldConfig } from "./field-config";
10
10
  // Lexical utilities
11
- export { isSerializedLexicalRoot, isEmptyRichText, traverseLexicalTree, collectSerializedLexicalTextNodes } from './lexical';
11
+ export { isSerializedLexicalRoot, isEmptyRichText, traverseLexicalTree, collectSerializedLexicalTextNodes } from "./lexical";
12
+ // Validation primitives
13
+ export { JobIdSchema } from "./validation";
12
14
 
13
15
  //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ export { JobIdSchema } from "./jobId";
@@ -0,0 +1,3 @@
1
+ export { JobIdSchema } from "./jobId";
2
+
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Zod schema for a Payload document/job ID over the HTTP boundary.
4
+ *
5
+ * Accepts the two shapes IDs can arrive in: a JS string (UUID, MongoDB
6
+ * ObjectId hex, custom string id) or a JS number (SQLite/Postgres integer
7
+ * autoincrement). Anything else (null, undefined, boolean, object, array)
8
+ * is rejected by the union itself. The schema normalizes to a non-empty
9
+ * string so callers can compare and Set-membership-check uniformly.
10
+ *
11
+ * NOTE: This is intentionally a wire-format guard, not a domain check. We
12
+ * do not pattern-match for UUID v4 or ObjectId here — different deployments
13
+ * use different ID formats, and a stricter regex would lock the plugin to
14
+ * one adapter. Callers that need DB-specific validation should layer it
15
+ * on top.
16
+ */
17
+ export declare const JobIdSchema: z.ZodEffects<z.ZodUnion<[z.ZodEffects<z.ZodString, string, string>, z.ZodNumber]>, string, string | number>;
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Zod schema for a Payload document/job ID over the HTTP boundary.
4
+ *
5
+ * Accepts the two shapes IDs can arrive in: a JS string (UUID, MongoDB
6
+ * ObjectId hex, custom string id) or a JS number (SQLite/Postgres integer
7
+ * autoincrement). Anything else (null, undefined, boolean, object, array)
8
+ * is rejected by the union itself. The schema normalizes to a non-empty
9
+ * string so callers can compare and Set-membership-check uniformly.
10
+ *
11
+ * NOTE: This is intentionally a wire-format guard, not a domain check. We
12
+ * do not pattern-match for UUID v4 or ObjectId here — different deployments
13
+ * use different ID formats, and a stricter regex would lock the plugin to
14
+ * one adapter. Callers that need DB-specific validation should layer it
15
+ * on top.
16
+ */ export const JobIdSchema = z.union([
17
+ z.string().refine((val)=>val.length > 0 && val !== "undefined", {
18
+ message: "ID must be a non-empty string"
19
+ }),
20
+ z.number().finite()
21
+ ]).transform(String);
22
+
23
+ //# sourceMappingURL=jobId.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.1.5",
3
+ "version": "0.2.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",
@@ -47,15 +47,15 @@
47
47
  "scripts": {
48
48
  "build": "bun run clean && bun run build:swc && bun run build:types && bun run copyfiles",
49
49
  "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths --ignore '**/*.test.ts' --ignore '**/*.d.ts' --ignore '**/__tests__/**' --ignore '**/__mocks__/**'",
50
- "build:types": "tsc --emitDeclarationOnly --outDir dist",
50
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
51
51
  "copyfiles": "copyfiles -u 1 \"src/**/*.scss\" dist",
52
52
  "clean": "rm -rf dist",
53
53
  "dev": "bun run build:swc -- --watch",
54
- "lint": "eslint src",
55
- "lint:fix": "eslint src --fix",
54
+ "lint": "ultracite check",
55
+ "lint:fix": "ultracite fix",
56
56
  "test": "vitest run",
57
57
  "test:watch": "vitest",
58
- "check-types": "tsc --noEmit"
58
+ "check-types": "tsgo --noEmit"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@payloadcms/richtext-lexical": "^3.76.0",
@@ -84,14 +84,9 @@
84
84
  "@types/react": "19.2.9",
85
85
  "@vitest/coverage-v8": "3.2.4",
86
86
  "copyfiles": "2.4.1",
87
- "eslint": "9.0.0",
88
- "eslint-config-prettier": "9.0.0",
89
- "next": "15.4.11",
90
87
  "payload": "3.79.0",
91
88
  "react": "19.0.0",
92
89
  "typescript": "5.5.2",
93
- "eslint-plugin-react-hooks": "6.0.0-rc.2",
94
- "typescript-eslint": "8.0.0",
95
90
  "vitest": "3.2.4"
96
91
  }
97
92
  }