@focus-reactive/payload-plugin-translator 0.11.0 → 0.11.2

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 (31) hide show
  1. package/README.md +47 -1
  2. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.js +2 -2
  3. package/dist/client/widgets/translate-document/ui/TranslateDocument.server.js +2 -2
  4. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +10 -7
  5. package/dist/core/translation-pipeline/stages/field-collector/FieldChunkCollector.js +0 -5
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +1 -1
  8. package/dist/server/features/translate-document/handler.d.ts +1 -0
  9. package/dist/server/features/translate-document/handler.js +55 -46
  10. package/dist/server/features/translate-document/targetLayer.d.ts +37 -0
  11. package/dist/server/features/translate-document/targetLayer.js +26 -0
  12. package/dist/server/features/translate-field/handler.js +4 -10
  13. package/dist/server/modules/auto-translate/AutoTranslateEnqueue.hook.js +2 -1
  14. package/dist/server/modules/lifecycle/withQueuedNotification.js +4 -1
  15. package/dist/server/modules/task-runner/TaskRunner.interface.d.ts +20 -2
  16. package/dist/server/modules/task-runner/TaskRunner.interface.js +3 -5
  17. package/dist/server/modules/task-runner/index.d.ts +2 -1
  18. package/dist/server/modules/task-runner/index.js +1 -0
  19. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +14 -75
  20. package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +42 -93
  21. package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.d.ts +2 -2
  22. package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.js +7 -1
  23. package/dist/server/modules/task-runner/toTaskFilter.d.ts +11 -0
  24. package/dist/server/modules/task-runner/toTaskFilter.js +15 -0
  25. package/dist/server/shared/guards/index.d.ts +0 -1
  26. package/dist/server/shared/guards/index.js +0 -1
  27. package/dist/server/shared/payload/sourceDocument.d.ts +2 -4
  28. package/dist/server/shared/payload/sourceDocument.js +10 -5
  29. package/package.json +1 -1
  30. package/dist/server/shared/guards/collection-guards.d.ts +0 -6
  31. package/dist/server/shared/guards/collection-guards.js +0 -16
package/README.md CHANGED
@@ -205,6 +205,45 @@ translatorPlugin({
205
205
  });
206
206
  ```
207
207
 
208
+ ### Drafts and publishing
209
+
210
+ Applies to every way a translation is triggered — the admin controls, `POST /translate/enqueue`, and
211
+ auto-translate. It matters most if your collections have `versions.drafts` enabled.
212
+
213
+ **Without publish-on-translation**, the translation is written as a **draft version**. The document's
214
+ published state is left alone: a live page stays live, an unpublished one stays unpublished, and the
215
+ translated locale does not appear on the public site until someone publishes it.
216
+
217
+ **With publish-on-translation**, the translation is written as a draft and the target locale is then
218
+ published — a separate step, so it happens whether or not any field actually needed translating.
219
+ Only the locale that was translated is published; other locales keep whatever state they were in.
220
+ Translating a document that is not currently published does make the document live, with just that
221
+ locale's content in it.
222
+
223
+ A translation is taken from **the source locale's own current content** — the newer draft when one
224
+ exists, else the published row, never a value Payload substitutes from another locale. Translating
225
+ *from* a locale you have not filled in therefore translates nothing.
226
+
227
+ Two consequences worth knowing before you rely on them:
228
+
229
+ - **Publishing publishes the current draft, whatever is in it.** A translation is based on the
230
+ version the editor sees, and publishing puts that live — including pending edits nobody made for
231
+ the translation's sake, and including non-localized fields, which Payload stores once per document
232
+ and so cannot scope to a locale. That is what the flag asks for, but it is worth remembering
233
+ before running "translate and publish" over a long list of documents: every unpublished draft
234
+ among them goes live.
235
+ - **`skip_existing` counts anything non-empty as translated.** A translation waiting unpublished in
236
+ a draft counts, so a reviewer's corrected text is published as it stands rather than
237
+ re-translated. It has no notion of *reviewed*, and it does not consult stale-detection — a locale
238
+ the admin marks out of date is still skipped
239
+ ([#118](https://github.com/focusreactive/payload-plugins/issues/118)).
240
+
241
+ > **Changed in 0.11.1.** Before this, translating one locale as a draft unpublished the document in
242
+ > every locale, and translating one locale with publishing pushed every other locale's unpublished
243
+ > draft live ([#102](https://github.com/focusreactive/payload-plugins/issues/102)). The source was
244
+ > also read from the published row with fallbacks, so translating from an empty locale translated
245
+ > the default locale's text.
246
+
208
247
  ### Stale-translation detection
209
248
 
210
249
  _Since v0.8.0._
@@ -221,6 +260,10 @@ Dismiss acknowledges the drift without re-translating; the marker stays hidden u
221
260
  changes again. When `provenance` is disabled nothing is shown. Note the fingerprint is text-only, so
222
261
  formatting-only edits to rich text do not mark a locale stale.
223
262
 
263
+ > **Upgrading to 0.11.1.** Records written earlier fingerprinted the source differently, so a locale
264
+ > can read out of date once after upgrading with nothing actually needing re-translation. Dismissing
265
+ > the marker or re-translating settles it.
266
+
224
267
  ### Auto-translate on source change
225
268
 
226
269
  _Since v0.9.0._
@@ -249,9 +292,12 @@ translatorPlugin({
249
292
  Behaviour: fires only on a **published** source save (draft/autosave saves are ignored; a collection
250
293
  without drafts treats every save as published); skips when no translatable content actually changed
251
294
  (same fingerprint as stale-detection); coalesces rapid edits via `debounceMs`; the translation is saved
252
- with the source document's status (published source published translation); never re-triggers on its
295
+ with the source document's status, scoped to **only the translated locale**; never re-triggers on its
253
296
  own translation writes; and never fails the editor's save (best-effort — failures are logged).
254
297
 
298
+ > See [Drafts and publishing](#drafts-and-publishing) for what a translation does to a document's
299
+ > published state.
300
+
255
301
  > **Requires a working job runner.** Auto-translate only **enqueues** jobs — they run via the task
256
302
  > runner (`createPayloadJobsRunner`) and its autorun loop. On serverless platforms such as **Vercel**,
257
303
  > cron-based autorun may not run automatically, so enqueued translations can sit unexecuted until
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { headers as getHeaders } from "next/headers";
3
- import { collectionHasDrafts } from "../../../../server/shared/guards";
3
+ import { hasDraftsEnabled } from "payload/shared";
4
4
  import { resolveAutoTranslateSummary } from "../../../entities/translation/model/autoTranslateSummary";
5
5
  import BulkTranslationDashboard from "./BulkTranslationDashboard";
6
6
  const BulkTranslationDashboardServer = async (props)=>{
@@ -15,7 +15,7 @@ const BulkTranslationDashboardServer = async (props)=>{
15
15
  if (!hasAccess) return null;
16
16
  if (!props.collectionSlug) return null;
17
17
  const collection = props.payload.collections[props.collectionSlug]?.config;
18
- const hasDrafts = collection ? collectionHasDrafts(collection) : false;
18
+ const hasDrafts = collection ? hasDraftsEnabled(collection) : false;
19
19
  const autoTranslate = resolveAutoTranslateSummary(collection, props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined);
20
20
  return /*#__PURE__*/ _jsx(BulkTranslationDashboard, {
21
21
  hasDrafts: hasDrafts,
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { headers as getHeaders } from "next/headers";
3
- import { collectionHasDrafts } from "../../../../server/shared/guards";
3
+ import { hasDraftsEnabled } from "payload/shared";
4
4
  import { resolveAutoTranslateSummary } from "../../../entities/translation/model/autoTranslateSummary";
5
5
  import TranslateDocument from "./TranslateDocument";
6
6
  async function TranslateDocumentServer(props) {
@@ -14,7 +14,7 @@ async function TranslateDocumentServer(props) {
14
14
  });
15
15
  if (!hasAccess) return null;
16
16
  if (!props.id) return null;
17
- const hasDrafts = collectionHasDrafts(props.collection);
17
+ const hasDrafts = hasDraftsEnabled(props.collection);
18
18
  const autoTranslate = resolveAutoTranslateSummary(props.collection, props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined);
19
19
  return /*#__PURE__*/ _jsx(TranslateDocument, {
20
20
  hasDrafts: hasDrafts,
@@ -47,8 +47,11 @@ import styles from "./styles.module.scss";
47
47
  const { mutateAsync, isPending } = useTranslateField();
48
48
  // `config.localization` is `false | {…}`, so the truthy check is load-bearing (not just a nil guard).
49
49
  const defaultLocale = config.localization ? config.localization.defaultLocale : undefined;
50
+ // `useLocale()` is typed `Locale` but actually returns `false | {} | Locale` until the admin's
51
+ // locale provider resolves (see the V4 TODO on the hook in @payloadcms/ui), so `code` can be absent.
52
+ const targetCode = locale?.code ?? "";
50
53
  const [isOpen, popup] = useToggle();
51
- const [sourceLng, setSourceLng] = useState(()=>defaultLocale && defaultLocale !== locale.code ? defaultLocale : "");
54
+ const [sourceLng, setSourceLng] = useState(()=>defaultLocale && defaultLocale !== targetCode ? defaultLocale : "");
52
55
  // `null` = no undo available; an object wraps the value so a legitimately `undefined` field value
53
56
  // (a real pre-translation state) stays distinguishable from "nothing to undo".
54
57
  const [undo, setUndo] = useState(null);
@@ -57,7 +60,7 @@ import styles from "./styles.module.scss";
57
60
  // once translating the unsaved value in place is supported.) All hooks above run unconditionally.
58
61
  if (id === undefined || id === null) return null;
59
62
  // Source options: every locale except the one being edited (it's the fixed target).
60
- const sourceLocaleOptions = localeOptions.filter((option)=>option.value !== locale.code);
63
+ const sourceLocaleOptions = localeOptions.filter((option)=>option.value !== targetCode);
61
64
  const canTranslate = sourceLng !== "" && !isPending;
62
65
  // One write path for every supported field type: a form UPDATE bumping value AND initialValue.
63
66
  // A Lexical (richText) editor re-mounts on the initialValue change so it shows the new content;
@@ -79,7 +82,7 @@ import styles from "./styles.module.scss";
79
82
  const { data } = await mutateAsync({
80
83
  collectionSlug,
81
84
  fieldPath: path,
82
- targetLng: locale.code,
85
+ targetLng: targetCode,
83
86
  sourceLng,
84
87
  docId: id
85
88
  });
@@ -132,7 +135,7 @@ import styles from "./styles.module.scss";
132
135
  className: styles.row,
133
136
  children: [
134
137
  /*#__PURE__*/ _jsxs("div", {
135
- "aria-label": `Translation direction: from ${sourceLng || "a source locale"} into ${locale.code}`,
138
+ "aria-label": `Translation direction: from ${sourceLng || "a source locale"} into ${targetCode}`,
136
139
  className: styles.direction,
137
140
  role: "group",
138
141
  children: [
@@ -161,9 +164,9 @@ import styles from "./styles.module.scss";
161
164
  children: "→"
162
165
  }),
163
166
  /*#__PURE__*/ _jsx("span", {
164
- "aria-label": `Target locale: ${locale.code} (the locale you're editing)`,
167
+ "aria-label": `Target locale: ${targetCode} (the locale you're editing)`,
165
168
  className: styles.current,
166
- children: locale.code.toLowerCase()
169
+ children: targetCode.toLowerCase()
167
170
  })
168
171
  ]
169
172
  }),
@@ -176,7 +179,7 @@ import styles from "./styles.module.scss";
176
179
  $isIconButton: true,
177
180
  $isLoading: isPending,
178
181
  disabled: !canTranslate,
179
- "aria-label": `Translate from ${sourceLng || "the selected locale"} into ${locale.code}`,
182
+ "aria-label": `Translate from ${sourceLng || "the selected locale"} into ${targetCode}`,
180
183
  onClick: handleTranslate,
181
184
  children: /*#__PURE__*/ _jsx(SendIcon, {})
182
185
  })
@@ -30,9 +30,6 @@ const asObject = (value)=>isObject(value) ? value : {};
30
30
  this.strategy = strategy;
31
31
  }
32
32
  /** Collects translatable field chunks that need translation. */ collect() {
33
- // The read walk SELECTS translatable leaves (via the shared selection core) and records, per
34
- // selected leaf, the source value to translate plus its write target. The mutation is applied
35
- // in a separate explicit pass below — read and write are no longer fused inside the walk.
36
33
  const selected = [];
37
34
  const chunks = [];
38
35
  const { strategy } = this;
@@ -119,8 +116,6 @@ const asObject = (value)=>isObject(value) ? value : {};
119
116
  target: this.targetData,
120
117
  path: []
121
118
  }, walker);
122
- // Apply pass: write each selected leaf's source value into filteredData — this is what gets
123
- // translated. Kept separate from the read walk above so selection stays read-only.
124
119
  for (const { dataRef, key, sourceValue } of selected){
125
120
  dataRef[key] = sourceValue;
126
121
  }
package/dist/index.d.ts CHANGED
@@ -11,8 +11,8 @@ export { createTranslationProvider } from "./translation-providers";
11
11
  export type { CompletionFn, CompletionRequest, TranslationProviderConfig, JsonSchemaObject, SystemPromptBuilder, SystemPromptContext, } from "./translation-providers";
12
12
  export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError, } from "./translation-providers";
13
13
  export type { TranslationFailureCode } from "./translation-providers";
14
- export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
15
- export type { TaskRunnerProvider, PayloadJobsRunnerOptions } from "./server/modules/task-runner";
14
+ export { createPayloadJobsRunner, createSyncRunner, toTaskFilter, } from "./server/modules/task-runner";
15
+ export type { TaskRunnerProvider, PayloadJobsRunnerOptions, TaskFilter, } from "./server/modules/task-runner";
16
16
  export { documentLevel, collectionLevel, fieldLevel } from "./composition/levels";
17
17
  export type { TranslationLevel } from "./server/modules/translation-levels";
18
18
  export { withFieldTranslation } from "./field-config";
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { openAIComplete } from "./translation-providers";
4
4
  export { createTranslationProvider } from "./translation-providers";
5
5
  export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError } from "./translation-providers";
6
6
  // Task runners
7
- export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
7
+ export { createPayloadJobsRunner, createSyncRunner, toTaskFilter } from "./server/modules/task-runner";
8
8
  // Translation levels
9
9
  export { documentLevel, collectionLevel, fieldLevel } from "./composition/levels";
10
10
  // Field config
@@ -16,4 +16,5 @@ export declare class TranslateDocumentHandler implements Handler<TranslateDocume
16
16
  constructor(translationProvider: TranslationProvider, schemaMap: CollectionSchemaMap, provenanceServiceFactory?: ProvenanceServiceFactory);
17
17
  handle(payload: Payload, input: TranslateDocumentInput): Promise<TranslateDocumentOutput>;
18
18
  private saveTranslatedDocument;
19
+ private publishTargetLocale;
19
20
  }
@@ -2,6 +2,10 @@ import { APIError } from "payload";
2
2
  import { translateContent } from "../../../core/translation-pipeline";
3
3
  import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
4
4
  import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
5
+ import { resolveTargetLayer } from "./targetLayer";
6
+ /** Loop guard: the auto-translate afterChange hook (#51) skips writes carrying this key. */ const translatorWriteContext = ()=>({
7
+ [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true
8
+ });
5
9
  /**
6
10
  * Translates a single document from source language to target language. Provenance is delegated to
7
11
  * {@link ProvenanceService}: this handler only decides *when* to capture the source fingerprint
@@ -17,71 +21,76 @@ import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateCon
17
21
  }
18
22
  async handle(payload, input) {
19
23
  const { collection, collectionId, sourceLng, targetLng, strategy, publishOnTranslation } = input;
20
- // Get original schema (preserves localized: true on nested fields)
21
24
  const schema = this.schemaMap.get(collection);
22
25
  if (!schema) throw new APIError(`Collection "${collection}" not found in schemaMap`, 400);
23
- const sourceData = await fetchSourceDocument(payload, collection, collectionId, sourceLng);
24
- // Capture the staleness baseline from the PRISTINE source NOW, before the pipeline runs — it
25
- // translates in place and shares object-valued source leaves (e.g. richText nodes) by reference,
26
- // so fingerprinting after `translateContent` would hash the target translation and make every
27
- // fresh translation look instantly stale. The service is best-effort (a failure returns null).
26
+ const layer = resolveTargetLayer({
27
+ versions: payload.collections[collection].config.versions,
28
+ targetLng
29
+ });
30
+ // `draft: true` is unconditional: on a collection without drafts Payload has no version to
31
+ // substitute, so it returns the only row. The WRITE cannot be so relaxed — the `no-drafts`
32
+ // layer omits `draft` entirely, because that is the argument shape `main` sent.
33
+ const [sourceData, currentTargetVersion] = await Promise.all([
34
+ fetchSourceDocument(payload, collection, collectionId, sourceLng),
35
+ payload.findByID({
36
+ collection,
37
+ id: collectionId,
38
+ locale: targetLng,
39
+ fallbackLocale: false,
40
+ depth: 0,
41
+ draft: true
42
+ })
43
+ ]);
28
44
  const provenance = this.provenanceServiceFactory?.(payload);
29
45
  const sourceFingerprint = provenance?.captureFingerprint(collection, sourceData) ?? null;
30
- const targetData = await payload.findByID({
31
- collection,
32
- id: collectionId,
33
- locale: targetLng,
34
- fallbackLocale: false,
35
- depth: 0
36
- });
37
46
  const translatedData = await translateContent({
38
47
  schema,
39
48
  sourceData,
40
- targetData,
49
+ targetData: currentTargetVersion,
41
50
  sourceLng,
42
51
  targetLng,
43
52
  translationProvider: this.translationProvider,
44
53
  strategy
45
54
  });
46
- if (!translatedData) return {
47
- success: true
48
- };
49
- const collectionConfig = payload.collections[collection].config;
50
- await this.saveTranslatedDocument(payload, collection, collectionId, translatedData, targetLng, sourceLng, collectionConfig, publishOnTranslation);
51
- if (provenance && sourceFingerprint !== null) {
52
- await provenance.record({
53
- collectionSlug: collection,
54
- documentId: String(collectionId),
55
- targetLocale: targetLng,
56
- sourceLocale: sourceLng
57
- }, sourceFingerprint);
55
+ if (translatedData) {
56
+ await this.saveTranslatedDocument(payload, input, translatedData, layer.write);
57
+ if (provenance && sourceFingerprint !== null) {
58
+ await provenance.record({
59
+ collectionSlug: collection,
60
+ documentId: String(collectionId),
61
+ targetLocale: targetLng,
62
+ sourceLocale: sourceLng
63
+ }, sourceFingerprint);
64
+ }
65
+ }
66
+ if (publishOnTranslation && layer.kind === "drafts") {
67
+ await this.publishTargetLocale(payload, input, layer.publish);
58
68
  }
59
69
  return {
60
70
  success: true
61
71
  };
62
72
  }
63
- async saveTranslatedDocument(payload, collection, collectionId, translatedData, targetLng, sourceLng, collectionConfig, publishOnTranslation) {
64
- let isAutosaveEnabled = false;
65
- const versions = collectionConfig.versions;
66
- if (versions && versions.drafts) {
67
- translatedData["_status"] = publishOnTranslation ? "published" : "draft";
68
- const drafts = versions.drafts;
69
- if (!publishOnTranslation && drafts.autosave) isAutosaveEnabled = true;
70
- }
73
+ async saveTranslatedDocument(payload, input, translatedData, write) {
71
74
  await payload.update({
72
- collection: collection,
73
- id: collectionId,
75
+ collection: input.collection,
76
+ id: input.collectionId,
74
77
  data: translatedData,
75
- autosave: isAutosaveEnabled,
76
- locale: targetLng,
77
- fallbackLocale: sourceLng,
78
- // Mark this as a translator-authored write so the auto-translate afterChange hook (#51) skips it
79
- // — the loop guard's second barrier, alongside the source-locale check. This write always targets
80
- // the TARGET locale, so it is already exempt by locale; the flag also covers any future write
81
- // path that could touch the source locale.
82
- context: {
83
- [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true
84
- }
78
+ ...write,
79
+ locale: input.targetLng,
80
+ fallbackLocale: input.sourceLng,
81
+ context: translatorWriteContext()
82
+ });
83
+ }
84
+ async publishTargetLocale(payload, input, publish) {
85
+ await payload.update({
86
+ collection: input.collection,
87
+ id: input.collectionId,
88
+ data: {
89
+ _status: publish.status
90
+ },
91
+ publishSpecificLocale: publish.publishSpecificLocale,
92
+ locale: publish.publishSpecificLocale,
93
+ context: translatorWriteContext()
85
94
  });
86
95
  }
87
96
  }
@@ -0,0 +1,37 @@
1
+ import type { CollectionConfig } from "payload";
2
+ export type VersionsSlice = CollectionConfig["versions"];
3
+ /** The arguments that publish one locale, once a collection is known to have a draft layer. */
4
+ export type PublishScope = {
5
+ publishSpecificLocale: string;
6
+ /**
7
+ * Merged into the published write's data. Looks redundant beside `publishSpecificLocale`
8
+ * and is not: without it, any other locale holding a pending draft drags the whole
9
+ * document back to `draft` (#102).
10
+ */
11
+ status: "published";
12
+ };
13
+ /**
14
+ * Where a translation is written, and — separately — how the locale is published afterwards.
15
+ *
16
+ * A union, not one shape with optional fields: `publishSpecificLocale` on a collection with
17
+ * versions but no drafts drops every other locale from the live row (Payload 3.84.1, silent).
18
+ * `publish` exists only on the `drafts` variant, so that pair is unbuildable — see
19
+ * `targetLayer.contract.test.ts`.
20
+ */
21
+ export type TargetLayer = {
22
+ kind: "no-drafts";
23
+ write: {
24
+ autosave: false;
25
+ };
26
+ } | {
27
+ kind: "drafts";
28
+ write: {
29
+ draft: true;
30
+ autosave: boolean;
31
+ };
32
+ publish: PublishScope;
33
+ };
34
+ export declare function resolveTargetLayer(args: {
35
+ versions: VersionsSlice;
36
+ targetLng: string;
37
+ }): TargetLayer;
@@ -0,0 +1,26 @@
1
+ import { hasAutosaveEnabled, hasDraftsEnabled } from "payload/shared";
2
+ export function resolveTargetLayer(args) {
3
+ const { versions, targetLng } = args;
4
+ const config = {
5
+ versions
6
+ };
7
+ if (!hasDraftsEnabled(config)) return {
8
+ kind: "no-drafts",
9
+ write: {
10
+ autosave: false
11
+ }
12
+ };
13
+ return {
14
+ kind: "drafts",
15
+ write: {
16
+ draft: true,
17
+ autosave: hasAutosaveEnabled(config)
18
+ },
19
+ publish: {
20
+ publishSpecificLocale: targetLng,
21
+ status: "published"
22
+ }
23
+ };
24
+ }
25
+
26
+ //# sourceMappingURL=targetLayer.js.map
@@ -2,6 +2,7 @@ import { getByPath, ServerResponse } from "../../shared";
2
2
  import { translateContent } from "../../../core/translation-pipeline";
3
3
  import { FieldTranslationInputSchema, MAX_FIELD_VALUE_BYTES } from "./model";
4
4
  import { resolveFieldSubtree } from "./resolveFieldSubtree";
5
+ import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
5
6
  const byteLength = (value)=>new TextEncoder().encode(JSON.stringify(value) ?? "").length;
6
7
  const noop = (value, level, message)=>({
7
8
  status: "noop",
@@ -31,16 +32,9 @@ const noop = (value, level, message)=>({
31
32
  const { collection_slug, field_path, target_lng, source_lng, doc_id } = parsed.data;
32
33
  const fields = this.config.schemaMap.get(collection_slug);
33
34
  if (!fields) return ServerResponse.badRequest(`Collection "${collection_slug}" is not available for translation`);
34
- // Read the source value from the saved document in `source_lng` (fallbackLocale: false so an
35
- // empty source reads as empty → noop, not a fallback). The doc also lets the resolver
36
- // disambiguate `blocks` their `blockType` lives in the data.
37
- const sourceDoc = await req.payload.findByID({
38
- collection: collection_slug,
39
- id: doc_id,
40
- locale: source_lng,
41
- fallbackLocale: false,
42
- depth: 0
43
- });
35
+ // The whole document, not just the field: the resolver needs it to disambiguate `blocks`, whose
36
+ // `blockType` lives in the data.
37
+ const sourceDoc = await fetchSourceDocument(req.payload, collection_slug, doc_id, source_lng);
44
38
  const sourceValue = getByPath(sourceDoc, field_path);
45
39
  // Guard the *translated* payload (held synchronously through the provider call), not the
46
40
  // request body, which now carries no field value.
@@ -1,3 +1,4 @@
1
+ import { hasDraftsEnabled } from "payload/shared";
1
2
  import { hasSourceContentChanged } from "../../../core/domain/auto-translate";
2
3
  import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../core/domain/auto-translate";
3
4
  import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
@@ -31,7 +32,7 @@ import { buildAutoTranslateTasks, passesPublishGate } from "./AutoTranslate.poli
31
32
  return doc;
32
33
  }
33
34
  if (req.locale !== sourceLocale) return doc;
34
- const hasDrafts = Boolean(collection.versions && collection.versions.drafts);
35
+ const hasDrafts = hasDraftsEnabled(collection);
35
36
  if (!passesPublishGate(doc, hasDrafts)) return doc;
36
37
  const schema = schemaMap.get(collection.slug);
37
38
  if (schema && !hasSourceContentChanged(previousDoc, doc, schema)) return doc;
@@ -1,3 +1,4 @@
1
+ import { toTaskFilter } from "../task-runner/toTaskFilter";
1
2
  import { taskFromInput } from "./taskMapping";
2
3
  /**
3
4
  * Decorate a {@link TaskRunner} so `enqueue` fires the `queued` lifecycle callback for each task.
@@ -12,7 +13,9 @@ import { taskFromInput } from "./taskMapping";
12
13
  },
13
14
  cancel: (taskIds)=>runner.cancel(taskIds),
14
15
  run: (taskId)=>runner.run(taskId),
15
- findByCollection: (collectionSlug, documentIds)=>runner.findByCollection(collectionSlug, documentIds)
16
+ // Normalized rather than forwarded as-is: the wrapper's own signature comes from the overload
17
+ // pair, so it cannot pass the deprecated array form straight through.
18
+ findByCollection: (collectionSlug, filter)=>runner.findByCollection(collectionSlug, toTaskFilter(filter))
16
19
  };
17
20
  }
18
21
 
@@ -24,7 +24,25 @@ export interface TaskRunner {
24
24
  */
25
25
  run(taskId: string): Promise<RunResult>;
26
26
  /**
27
- * Find tasks by collection and optionally filter by document IDs.
27
+ * @deprecated Pass `{ documentIds }` instead. Removed in the next major.
28
+ * See docs/DEPRECATIONS.md#find-by-collection-document-ids-array
28
29
  */
29
- findByCollection(collectionSlug: CollectionSlug, documentIds?: Array<string | number>): Promise<Task[]>;
30
+ findByCollection(collectionSlug: CollectionSlug, documentIds: Array<string | number>): Promise<Task[]>;
31
+ /** Find tasks for a collection, optionally narrowed by a {@link TaskFilter}. */
32
+ findByCollection(collectionSlug: CollectionSlug, filter?: TaskFilter): Promise<Task[]>;
30
33
  }
34
+ /**
35
+ * How a {@link TaskRunner.findByCollection} call is narrowed. Each field says whether it reaches the
36
+ * database or is applied in memory over everything the database returned.
37
+ *
38
+ * @since 0.12.0
39
+ */
40
+ export type TaskFilter = {
41
+ /** Keep only tasks for these documents. Applied in memory — see `PayloadJobsTaskRunner.findByCollection`. */
42
+ documentIds?: Array<string | number>;
43
+ /**
44
+ * Drop tasks that have finished. Keeps running and failed ones — everything a re-enqueue can still
45
+ * supersede — so it is wider than the `pending` status.
46
+ */
47
+ excludeCompleted?: boolean;
48
+ };
@@ -1,10 +1,8 @@
1
1
  /**
2
- * Interface for task execution backends.
2
+ * How a {@link TaskRunner.findByCollection} call is narrowed. Each field says whether it reaches the
3
+ * database or is applied in memory over everything the database returned.
3
4
  *
4
- * Implementations handle queuing, cancellation, status tracking,
5
- * and execution of translation tasks. All business logic
6
- * (like cancelling existing tasks before enqueue) is encapsulated
7
- * within the implementation.
5
+ * @since 0.12.0
8
6
  */ export { };
9
7
 
10
8
  //# sourceMappingURL=TaskRunner.interface.js.map
@@ -3,4 +3,5 @@ export type { Task, TaskStatus } from "./types";
3
3
  export { createPayloadJobsRunner } from "./payload-jobs-runner";
4
4
  export type { PayloadJobsRunnerOptions } from "./payload-jobs-runner";
5
5
  export { createSyncRunner } from "./sync-runner";
6
- export type { TaskRunner } from "./TaskRunner.interface";
6
+ export type { TaskFilter, TaskRunner } from "./TaskRunner.interface";
7
+ export { toTaskFilter } from "./toTaskFilter";
@@ -1,4 +1,5 @@
1
1
  export { createPayloadJobsRunner } from "./payload-jobs-runner";
2
2
  export { createSyncRunner } from "./sync-runner";
3
+ export { toTaskFilter } from "./toTaskFilter";
3
4
 
4
5
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,5 @@
1
1
  import type { Payload, CollectionSlug } from "payload";
2
- import type { TaskRunner } from "../TaskRunner.interface";
2
+ import type { TaskFilter, TaskRunner } from "../TaskRunner.interface";
3
3
  import type { Task, TaskInput, RunResult } from "../types";
4
4
  import type { PayloadJobsRunnerConfig } from "./types";
5
5
  /**
@@ -15,87 +15,26 @@ export declare class PayloadJobsTaskRunner implements TaskRunner {
15
15
  cancel(taskIds: string[]): Promise<void>;
16
16
  run(taskId: string): Promise<RunResult>;
17
17
  /**
18
- * 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.
18
+ * Clear stale `processing` locks still processing, not completed, `updatedAt` older than
19
+ * `staleJobTimeoutMs` — so abandoned jobs are eligible for the autorun picker again. A job that
20
+ * exhausted its retries carries `hasError: true` and stays excluded from autorun even after its
21
+ * lock is cleared; only a manual `run()` recovers it.
22
+ * @returns how many locks were cleared.
32
23
  */
33
24
  reclaimStaleJobs(): Promise<number>;
34
- /**
35
- * Clear the `processing` lock on every job matching `where`, returning how
36
- * many were reset. Shared by the per-job reset in `run()` (a stale lock) and
37
- * the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
38
- * only the count is needed — no relationships to populate.
39
- */
25
+ /** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */
40
26
  private resetProcessing;
41
- /**
42
- * A processing lock is stale once `updatedAt` is older than the configured
43
- * timeout — the owning run is presumed dead.
44
- */
45
27
  private isStale;
46
28
  /**
47
- * Find translation jobs for a collection, optionally narrowed by document IDs.
48
- *
49
- * Narrowing is by `taskSlug` only in SQL; the collection slug and document
50
- * IDs are matched in memory (via the normalized `Task`, which reads both the
51
- * current flat-text shape and the legacy relationship shape). This is the
52
- * one path that must transparently span both stored shapes during the
53
- * ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
54
- *
55
- * IMPORTANT — why slug/id are matched in memory, not in the SQL WHERE
56
- * ------------------------------------------------------------------------
57
- * The "natural" implementation would push `input.collection_id` into the
58
- * where clause. This DOES NOT work on SQLite (and is unreliable on any
59
- * adapter) because of two compounding bugs in Payload's drizzle layer.
60
- *
61
- * 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
62
- * drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
63
- * has no `case 'json'` branch, so the value is left as a raw column and
64
- * the path segments are passed through to `parseParams.js`, which on
65
- * SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
66
- * expressions like `input->>'collection_id'`.
29
+ * Find translation jobs for a collection.
67
30
  *
68
- * 2. When `parseParams.js` formats the right-hand side of `in`/`not_in`
69
- * (and even `equals` when `!isNaN(val)`), it inlines values via JS
70
- * template literals WITHOUT wrapping strings in quotes. The string
71
- * `'1'` from our WHERE becomes raw `1` in the SQL. Drizzle therefore
72
- * emits queries like `WHERE input->>'collection_id' IN (1)` even though
73
- * the caller passed `['1']` (an array of strings).
74
- *
75
- * On SQLite, `->>` preserves the JSON value's type and `IN (...)` does NOT
76
- * coerce between TEXT and INTEGER, so a numeric-looking string id never
77
- * matches once bug #2 strips its quotes. Storing the id as text (this
78
- * migration) does not fix the SQL path — drizzle re-numbers it anyway — so
79
- * we keep matching in memory.
80
- *
81
- * Why in-memory filtering is acceptable here
82
- * ------------------------------------------
83
- * Per-task job sets are small (typically <100 rows; the plugin actively
84
- * cancels superseded jobs so they don't accumulate), so the JS filtering
85
- * is effectively free. If/when the upstream drizzle bug is fixed, this can
86
- * collapse back to a single SQL query.
87
- */
88
- findByCollection(collectionSlug: CollectionSlug, documentIds?: Array<string | number>): Promise<Task[]>;
89
- /**
90
- * Group tasks by collection slug
31
+ * Only `taskSlug` and `completedAt` reach the database; slug and document ids are matched in memory
32
+ * because a job's collection reference may sit in either the flat-text fields or the legacy
33
+ * relationship shape (`readCollectionRef`), so a `where` on `input.collection_slug` would silently
34
+ * drop every pre-migration job. `excludeCompleted` is what bounds the read see issue #108.
91
35
  */
36
+ findByCollection(collectionSlug: CollectionSlug, filter?: Array<string | number> | TaskFilter): Promise<Task[]>;
92
37
  private groupByCollection;
93
- /**
94
- * Internal cancel implementation
95
- */
96
- private cancelInternal;
97
- /**
98
- * Internal method to find jobs with where clause
99
- */
38
+ private cancelAndDeleteJobs;
100
39
  private findJobsInternal;
101
40
  }
@@ -1,3 +1,4 @@
1
+ import { toTaskFilter } from "../toTaskFilter";
1
2
  import { normalizeJob } from "./normalizeJob";
2
3
  // A translation job's supersession identity: same document AND same target locale. IDs are
3
4
  // String()-normalized to match the stored (string) form, so a number id compares equal to its
@@ -18,14 +19,20 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
18
19
  const byCollection = this.groupByCollection(tasks);
19
20
  for (const [collectionSlug, items] of byCollection){
20
21
  const documentIds = items.map((t)=>t.collectionId);
21
- const existing = await this.findByCollection(collectionSlug, documentIds);
22
+ // Finished jobs must stay out of this set: superseding deletes (`cancelAndDeleteJobs` reaches
23
+ // `payload.delete`), so a completed job for the same (document, locale) would be erased along
24
+ // with the pending one.
25
+ const existing = await this.findByCollection(collectionSlug, {
26
+ documentIds,
27
+ excludeCompleted: true
28
+ });
22
29
  // Supersede only jobs for the SAME (document, target locale) being re-enqueued — never a
23
30
  // concurrent job for a *different* locale of the same document. Cancelling per-document would
24
31
  // kill an in-flight translation of another locale (the concurrent re-translate bug).
25
32
  const supersededKeys = new Set(items.map((t)=>documentLocaleKey(t.collectionId, t.targetLng)));
26
33
  const toCancel = existing.filter((t)=>supersededKeys.has(documentLocaleKey(t.input.collectionId, t.input.targetLng)));
27
34
  if (toCancel.length > 0) {
28
- await this.cancelInternal(toCancel.map((t)=>t.id));
35
+ await this.cancelAndDeleteJobs(toCancel.map((t)=>t.id));
29
36
  }
30
37
  }
31
38
  await Promise.all(tasks.map((task)=>this.payload.jobs.queue({
@@ -52,7 +59,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
52
59
  }
53
60
  async cancel(taskIds) {
54
61
  if (taskIds.length === 0) return;
55
- await this.cancelInternal(taskIds);
62
+ await this.cancelAndDeleteJobs(taskIds);
56
63
  }
57
64
  async run(taskId) {
58
65
  const tasks = await this.findJobsInternal({
@@ -76,9 +83,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
76
83
  };
77
84
  }
78
85
  if (task.status === "running") {
79
- // A genuinely in-flight job is refused. A stale processing lock (left by
80
- // a process killed mid-run) is reclaimable: clear it first so the queue
81
- // picker below — which only selects `processing: false` — can re-run it.
86
+ // The picker below selects only `processing: false`, so a stale lock must be cleared first.
82
87
  if (!this.isStale(task.updatedAt)) {
83
88
  return {
84
89
  success: false,
@@ -91,17 +96,10 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
91
96
  }
92
97
  });
93
98
  }
94
- // Execute synchronously via the queue + `where` picker so the job runs to
95
- // completion within this request (nothing is abandoned after the HTTP
96
- // response reliable on serverless too).
97
- //
98
- // NOT `payload.jobs.runByID({ id })`: on the drizzle adapter the id-path
99
- // (`db.updateJobs({ id })`) writes `processing: true` but returns no rows,
100
- // so `runJobs` reports `noJobsRemaining` and the handler never runs —
101
- // leaving the job stuck at `processing: true` forever. The `where`-based
102
- // picker selects, runs, and finalizes the job correctly (verified against
103
- // sqlite). The picker also enforces processing:false / no-error / no
104
- // pending waitUntil, so a failed (max-retries) job is not re-run here.
99
+ // `where` picker, not `payload.jobs.runByID({ id })`: in `runJobs` the guard block
100
+ // (processing:false, hasError not true, waitUntil due) is built only for the non-id branch, so
101
+ // the id path would re-run a job that already exhausted its retries. Checked against payload
102
+ // 3.84.1.
105
103
  await this.payload.jobs.run({
106
104
  queue: this.config.queueName,
107
105
  where: {
@@ -116,20 +114,11 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
116
114
  };
117
115
  }
118
116
  /**
119
- * Reset stale processing locks so abandoned jobs become eligible for the
120
- * autorun picker again. The picker requires processing:false, no error, and
121
- * no pending waitUntil; a job abandoned mid-run (no error, no waitUntil)
122
- * satisfies the rest, so clearing processing is sufficient for that case.
123
- * A job that already exhausted retries (hasError:true) stays excluded from
124
- * autorun and is only recoverable via a manual run().
125
- *
126
- * A job is stale when it is still `processing: true`, not yet completed, and
127
- * its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
128
- * killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
129
- * in flight on another live instance (fresh `updatedAt`) is left alone.
130
- * Filters on real `payload-jobs` columns only (no JSON-path traversal), so
131
- * the drizzle SQLite issue in `findByCollection` does not apply here.
132
- * @returns the number of jobs reclaimed.
117
+ * Clear stale `processing` locks still processing, not completed, `updatedAt` older than
118
+ * `staleJobTimeoutMs` — so abandoned jobs are eligible for the autorun picker again. A job that
119
+ * exhausted its retries carries `hasError: true` and stays excluded from autorun even after its
120
+ * lock is cleared; only a manual `run()` recovers it.
121
+ * @returns how many locks were cleared.
133
122
  */ async reclaimStaleJobs() {
134
123
  const cutoff = new Date(Date.now() - this.config.staleJobTimeoutMs).toISOString();
135
124
  return this.resetProcessing({
@@ -157,12 +146,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
157
146
  ]
158
147
  });
159
148
  }
160
- /**
161
- * Clear the `processing` lock on every job matching `where`, returning how
162
- * many were reset. Shared by the per-job reset in `run()` (a stale lock) and
163
- * the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
164
- * only the count is needed — no relationships to populate.
165
- */ async resetProcessing(where) {
149
+ /** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */ async resetProcessing(where) {
166
150
  const result = await this.payload.update({
167
151
  collection: this.config.jobsCollection,
168
152
  depth: 0,
@@ -173,10 +157,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
173
157
  });
174
158
  return result.docs.length;
175
159
  }
176
- /**
177
- * A processing lock is stale once `updatedAt` is older than the configured
178
- * timeout — the owning run is presumed dead.
179
- */ isStale(updatedAt) {
160
+ isStale(updatedAt) {
180
161
  const parsed = Date.parse(updatedAt);
181
162
  // Unknown/corrupt timestamp → treat as stale so the job can be recovered
182
163
  // rather than permanently refused as already-running.
@@ -184,60 +165,28 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
184
165
  return Date.now() - parsed > this.config.staleJobTimeoutMs;
185
166
  }
186
167
  /**
187
- * Find translation jobs for a collection, optionally narrowed by document IDs.
188
- *
189
- * Narrowing is by `taskSlug` only in SQL; the collection slug and document
190
- * IDs are matched in memory (via the normalized `Task`, which reads both the
191
- * current flat-text shape and the legacy relationship shape). This is the
192
- * one path that must transparently span both stored shapes during the
193
- * ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
194
- *
195
- * IMPORTANT — why slug/id are matched in memory, not in the SQL WHERE
196
- * ------------------------------------------------------------------------
197
- * The "natural" implementation would push `input.collection_id` into the
198
- * where clause. This DOES NOT work on SQLite (and is unreliable on any
199
- * adapter) because of two compounding bugs in Payload's drizzle layer.
200
- *
201
- * 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
202
- * drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
203
- * has no `case 'json'` branch, so the value is left as a raw column and
204
- * the path segments are passed through to `parseParams.js`, which on
205
- * SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
206
- * expressions like `input->>'collection_id'`.
168
+ * Find translation jobs for a collection.
207
169
  *
208
- * 2. When `parseParams.js` formats the right-hand side of `in`/`not_in`
209
- * (and even `equals` when `!isNaN(val)`), it inlines values via JS
210
- * template literals WITHOUT wrapping strings in quotes. The string
211
- * `'1'` from our WHERE becomes raw `1` in the SQL. Drizzle therefore
212
- * emits queries like `WHERE input->>'collection_id' IN (1)` even though
213
- * the caller passed `['1']` (an array of strings).
214
- *
215
- * On SQLite, `->>` preserves the JSON value's type and `IN (...)` does NOT
216
- * coerce between TEXT and INTEGER, so a numeric-looking string id never
217
- * matches once bug #2 strips its quotes. Storing the id as text (this
218
- * migration) does not fix the SQL path — drizzle re-numbers it anyway — so
219
- * we keep matching in memory.
220
- *
221
- * Why in-memory filtering is acceptable here
222
- * ------------------------------------------
223
- * Per-task job sets are small (typically <100 rows; the plugin actively
224
- * cancels superseded jobs so they don't accumulate), so the JS filtering
225
- * is effectively free. If/when the upstream drizzle bug is fixed, this can
226
- * collapse back to a single SQL query.
227
- */ async findByCollection(collectionSlug, documentIds) {
228
- const all = await this.findJobsInternal(undefined, {
170
+ * Only `taskSlug` and `completedAt` reach the database; slug and document ids are matched in memory
171
+ * because a job's collection reference may sit in either the flat-text fields or the legacy
172
+ * relationship shape (`readCollectionRef`), so a `where` on `input.collection_slug` would silently
173
+ * drop every pre-migration job. `excludeCompleted` is what bounds the read see issue #108.
174
+ */ async findByCollection(collectionSlug, filter) {
175
+ const { documentIds, excludeCompleted } = toTaskFilter(filter);
176
+ const where = excludeCompleted ? {
177
+ completedAt: {
178
+ exists: false
179
+ }
180
+ } : undefined;
181
+ const all = await this.findJobsInternal(where, {
229
182
  pagination: false
230
183
  });
231
184
  const bySlug = all.filter((t)=>t.input.collectionSlug === collectionSlug);
232
185
  if (!documentIds?.length) return bySlug;
233
- // `documentIds` is the public `Array<string | number>` param, so normalize
234
- // it here; `t.input.collectionId` is already `ID` (string) via normalizeJob.
235
186
  const wanted = new Set(documentIds.map(String));
236
187
  return bySlug.filter((t)=>wanted.has(t.input.collectionId));
237
188
  }
238
- /**
239
- * Group tasks by collection slug
240
- */ groupByCollection(tasks) {
189
+ groupByCollection(tasks) {
241
190
  const map = new Map();
242
191
  for (const task of tasks){
243
192
  const existing = map.get(task.collectionSlug) ?? [];
@@ -246,10 +195,12 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
246
195
  }
247
196
  return map;
248
197
  }
249
- /**
250
- * Internal cancel implementation
251
- */ async cancelInternal(taskIds) {
198
+ async cancelAndDeleteJobs(taskIds) {
252
199
  if (taskIds.length === 0) return;
200
+ // Both, in this order: `jobs.cancel` only writes `{ error: { cancelled: true }, hasError: true,
201
+ // processing: false }`, which is what signals a running handler to abort. The delete then removes
202
+ // the row — under `deleteJobOnComplete: false` a cancelled job would otherwise sit in the status
203
+ // feed forever.
253
204
  await this.payload.jobs.cancel({
254
205
  where: {
255
206
  id: {
@@ -267,9 +218,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
267
218
  }
268
219
  });
269
220
  }
270
- /**
271
- * Internal method to find jobs with where clause
272
- */ async findJobsInternal(where, params) {
221
+ async findJobsInternal(where, params) {
273
222
  const and = [
274
223
  {
275
224
  taskSlug: {
@@ -1,5 +1,5 @@
1
1
  import type { Payload, CollectionSlug } from "payload";
2
- import type { TaskRunner } from "../TaskRunner.interface";
2
+ import type { TaskFilter, TaskRunner } from "../TaskRunner.interface";
3
3
  import type { TaskHandler } from "../TaskRunnerProvider.interface";
4
4
  import type { Task, TaskInput, RunResult } from "../types";
5
5
  import type { LazyMap } from "../../../shared/utils";
@@ -17,6 +17,6 @@ export declare class SyncTaskRunner implements TaskRunner {
17
17
  enqueue(inputs: TaskInput[]): Promise<void>;
18
18
  cancel(_taskIds: string[]): Promise<void>;
19
19
  run(_taskId: string): Promise<RunResult>;
20
- findByCollection(collectionSlug: CollectionSlug, documentIds?: Array<string | number>): Promise<Task[]>;
20
+ findByCollection(collectionSlug: CollectionSlug, filter?: Array<string | number> | TaskFilter): Promise<Task[]>;
21
21
  private getKey;
22
22
  }
@@ -1,3 +1,4 @@
1
+ import { toTaskFilter } from "../toTaskFilter";
1
2
  /**
2
3
  * Synchronous TaskRunner implementation.
3
4
  *
@@ -55,12 +56,17 @@
55
56
  error: "not_found"
56
57
  };
57
58
  }
58
- async findByCollection(collectionSlug, documentIds) {
59
+ async findByCollection(collectionSlug, filter) {
60
+ const { documentIds, excludeCompleted } = toTaskFilter(filter);
59
61
  const results = [];
60
62
  const wanted = documentIds ? new Set(documentIds.map(String)) : undefined;
61
63
  for (const [, task] of this.tasks){
62
64
  if (task.input.collectionSlug !== collectionSlug) continue;
63
65
  if (wanted && !wanted.has(task.input.collectionId)) continue;
66
+ // Keyed on `completedAt`, the same field the jobs runner pushes into its where clause. Keying
67
+ // on `status` instead would agree only by accident: `getJobStatus` happens to check
68
+ // `completedAt` before `error`, and reordering it would silently split the two runners.
69
+ if (excludeCompleted && task.completedAt) continue;
64
70
  results.push(task);
65
71
  }
66
72
  return results;
@@ -0,0 +1,11 @@
1
+ import type { TaskFilter } from "./TaskRunner.interface";
2
+ /**
3
+ * Normalize the two accepted shapes of {@link TaskRunner.findByCollection}'s second argument.
4
+ *
5
+ * Every implementation of {@link TaskRunner} owes this, so it ships alongside the contract rather
6
+ * than being re-derived: the array form is deprecated and will be removed, and a hand-written
7
+ * `Array.isArray` branch in someone else's runner would outlive it.
8
+ *
9
+ * @since 0.12.0
10
+ */
11
+ export declare function toTaskFilter(filter?: Array<string | number> | TaskFilter): TaskFilter;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Normalize the two accepted shapes of {@link TaskRunner.findByCollection}'s second argument.
3
+ *
4
+ * Every implementation of {@link TaskRunner} owes this, so it ships alongside the contract rather
5
+ * than being re-derived: the array form is deprecated and will be removed, and a hand-written
6
+ * `Array.isArray` branch in someone else's runner would outlive it.
7
+ *
8
+ * @since 0.12.0
9
+ */ export function toTaskFilter(filter) {
10
+ return Array.isArray(filter) ? {
11
+ documentIds: filter
12
+ } : filter ?? {};
13
+ }
14
+
15
+ //# sourceMappingURL=toTaskFilter.js.map
@@ -1,3 +1,2 @@
1
1
  export type { TranslatableField } from "./field-guards";
2
2
  export { isTranslatableField, isLocalizedField, isRelationshipField } from "./field-guards";
3
- export { collectionHasDrafts } from "./collection-guards";
@@ -1,4 +1,3 @@
1
1
  export { isTranslatableField, isLocalizedField, isRelationshipField } from "./field-guards";
2
- export { collectionHasDrafts } from "./collection-guards";
3
2
 
4
3
  //# sourceMappingURL=index.js.map
@@ -1,8 +1,6 @@
1
1
  import type { CollectionSlug, Payload } from "payload";
2
2
  /**
3
- * Fetch a document's source-locale data with the exact shape the provenance fingerprint depends on
4
- * (`depth: 0`, the given locale). Used by the translation **write** path (fingerprints the source it
5
- * just translated) and, through {@link ProvenanceService}, by the staleness **read** path
6
- * (re-fingerprints the live source), so the fingerprint baseline can never drift between the two.
3
+ * The single source read: what "translate from X" resolves to. Both translation write paths and
4
+ * the staleness recompute must go through here, or the fingerprints they compare drift apart.
7
5
  */
8
6
  export declare function fetchSourceDocument(payload: Payload, collection: CollectionSlug, id: string, locale: string): Promise<import("payload").JsonObject & import("payload").TypeWithID>;
@@ -1,14 +1,19 @@
1
1
  /**
2
- * Fetch a document's source-locale data with the exact shape the provenance fingerprint depends on
3
- * (`depth: 0`, the given locale). Used by the translation **write** path (fingerprints the source it
4
- * just translated) and, through {@link ProvenanceService}, by the staleness **read** path
5
- * (re-fingerprints the live source), so the fingerprint baseline can never drift between the two.
2
+ * The single source read: what "translate from X" resolves to. Both translation write paths and
3
+ * the staleness recompute must go through here, or the fingerprints they compare drift apart.
6
4
  */ export function fetchSourceDocument(payload, collection, id, locale) {
7
5
  return payload.findByID({
8
6
  collection,
9
7
  id,
10
8
  locale,
11
- depth: 0
9
+ depth: 0,
10
+ // The current version, as the editor sees it. A published-row read is empty whenever the source
11
+ // locale is unpublished — which is exactly what a publish scoped to the target locale leaves
12
+ // behind — and then every fresh translation fingerprints as stale.
13
+ draft: true,
14
+ // Payload's locale fallback resolves an empty source locale to the default locale's text, so
15
+ // "translate from fr" would translate English and fingerprint English as the French source.
16
+ fallbackLocale: false
12
17
  });
13
18
  }
14
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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",
@@ -1,6 +0,0 @@
1
- import type { CollectionConfig, SanitizedCollectionConfig } from "payload";
2
- /**
3
- * Checks if a collection has drafts enabled.
4
- * Handles both CollectionConfig and SanitizedCollectionConfig.
5
- */
6
- export declare function collectionHasDrafts(collection: CollectionConfig | SanitizedCollectionConfig): boolean;
@@ -1,16 +0,0 @@
1
- /**
2
- * Type guard: Checks if versions config is an object (not boolean).
3
- */ function isVersionsObject(versions) {
4
- return typeof versions === "object" && versions !== null;
5
- }
6
- /**
7
- * Checks if a collection has drafts enabled.
8
- * Handles both CollectionConfig and SanitizedCollectionConfig.
9
- */ export function collectionHasDrafts(collection) {
10
- const { versions } = collection;
11
- if (!versions) return false;
12
- if (!isVersionsObject(versions)) return false;
13
- return "drafts" in versions && Boolean(versions.drafts);
14
- }
15
-
16
- //# sourceMappingURL=collection-guards.js.map