@purposeinplay/payload-ai-translate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
@@ -0,0 +1,1000 @@
1
+ import { DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG } from '../bulk-translate-batches-collection.js';
2
+ import { DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG } from '../bulk-translate-units-collection.js';
3
+ import { resolveTranslatableFields } from '../lib/field-resolver.js';
4
+ import { createScopedLogger } from '../lib/logger.js';
5
+ import { findByIdNoFallback, findGlobalNoFallback } from '../lib/payload-read.js';
6
+ import { tryClaimAsLock } from '../lib/per-doc-claim.js';
7
+ import { captureLocalizedSnapshot } from '../lib/snapshot-select.js';
8
+ export const BULK_TRANSLATE_DOC_TASK_SLUG = 'bulk-translate-doc';
9
+ // ---------------------------------------------------------------------------
10
+ // Task config
11
+ // ---------------------------------------------------------------------------
12
+ const taskInputSchema = [
13
+ {
14
+ name: 'unitId',
15
+ type: 'text',
16
+ required: true
17
+ }
18
+ ];
19
+ export function buildBulkTranslateDocTask(options = {}) {
20
+ const unitsSlug = options.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
21
+ const batchesSlug = options.batchesCollectionSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
22
+ const maxAttempts = options.maxAttempts ?? 3;
23
+ const callbacks = options.callbacks;
24
+ const allowedCollections = options.allowedCollections ?? [];
25
+ const allowedGlobals = options.allowedGlobals ?? [];
26
+ return {
27
+ slug: BULK_TRANSLATE_DOC_TASK_SLUG,
28
+ label: 'Bulk translate — per-doc worker',
29
+ inputSchema: taskInputSchema,
30
+ // We manage our own retry semantics on the unit row. Payload's
31
+ // built-in retry would double-count attempts.
32
+ retries: 0,
33
+ handler: async ({ input, req })=>{
34
+ const typed = input;
35
+ const translateApi = options.translateApi ?? await loadDefaultTranslateApi();
36
+ const result = await runWorkerForUnit({
37
+ payload: req.payload,
38
+ unitId: typed.unitId,
39
+ unitsSlug,
40
+ batchesSlug,
41
+ maxAttempts,
42
+ translateApi,
43
+ callbacks,
44
+ allowedCollections,
45
+ allowedGlobals,
46
+ // Forward the originating request so the locale write carries
47
+ // `req.context = { disableRevalidate: true }` — same shape as
48
+ // the per-doc retry endpoint sets.
49
+ req: {
50
+ ...req,
51
+ context: {
52
+ ...req.context ?? {},
53
+ disableRevalidate: true
54
+ }
55
+ }
56
+ });
57
+ return {
58
+ output: {
59
+ ok: true,
60
+ status: result.status
61
+ }
62
+ };
63
+ }
64
+ };
65
+ }
66
+ export async function runWorkerForUnit(params) {
67
+ const { payload, unitId, unitsSlug, batchesSlug, maxAttempts, translateApi, callbacks } = params;
68
+ const allowedCollections = new Set(params.allowedCollections ?? []);
69
+ const allowedGlobals = new Set(params.allowedGlobals ?? []);
70
+ const now = params.now ?? (()=>new Date());
71
+ // -------------------------------------------------------------------
72
+ // 1. Load the unit row.
73
+ // -------------------------------------------------------------------
74
+ const unit = await payload.findByID({
75
+ collection: unitsSlug,
76
+ id: unitId,
77
+ overrideAccess: true,
78
+ depth: 0
79
+ });
80
+ if (!unit) {
81
+ return {
82
+ status: 'failed',
83
+ failureCode: 'permanent.deleted',
84
+ failureMessage: 'unit not found'
85
+ };
86
+ }
87
+ // Terminal-state guards. Re-running a finished unit must be a no-op
88
+ // — Payload re-fires the task if it sees ambiguous state. `reverted`
89
+ // surfaces as `skipped` to the caller since both mean "do not retry".
90
+ if (unit.status === 'success') return {
91
+ status: 'success'
92
+ };
93
+ if (unit.status === 'skipped' || unit.status === 'reverted') {
94
+ return {
95
+ status: 'skipped'
96
+ };
97
+ }
98
+ // -------------------------------------------------------------------
99
+ // 1c. Load the parent batch row to read `mode`. Threaded into the
100
+ // translate call as `force: batchMode === 'force'` so the UI's
101
+ // "Force re-translate (includes docs already up to date)" checkbox
102
+ // actually bypasses the field-level hash-skip + manual-edit guards
103
+ // in translate.ts. Without this, force-mode bulk runs silently
104
+ // preserve every prior translation while still paying for tokens.
105
+ //
106
+ // Tolerant on miss: if the batch row can't be loaded (deleted /
107
+ // orphan unit / db hiccup), fall through with `force: false` and
108
+ // let the per-locale guards take their default conservative path.
109
+ // That's the same behaviour as pre-v1.2.5 and never makes things
110
+ // worse.
111
+ // -------------------------------------------------------------------
112
+ let batchMode;
113
+ let batchStatus;
114
+ try {
115
+ const batch = await payload.findByID({
116
+ collection: batchesSlug,
117
+ id: unit.batchId,
118
+ overrideAccess: true,
119
+ depth: 0
120
+ });
121
+ batchMode = batch?.scope?.mode ?? batch?.mode;
122
+ batchStatus = batch?.status;
123
+ } catch {
124
+ batchMode = undefined;
125
+ batchStatus = undefined;
126
+ }
127
+ const isForceMode = batchMode === 'force';
128
+ // -------------------------------------------------------------------
129
+ // 1d. Cancel gate. The cancel endpoint sweeps `pending` units exactly
130
+ // once; units whose job was already queued (or claimed between the
131
+ // sweep's find and its updates) would otherwise still translate —
132
+ // and bill — after the admin pressed Cancel. Checking the batch
133
+ // status at worker entry closes that window. Also covers terminal
134
+ // batches: a janitor-reset unit whose batch already ended must not
135
+ // run again.
136
+ // -------------------------------------------------------------------
137
+ if (batchStatus === 'cancelling' || batchStatus === 'cancelled' || batchStatus === 'reverted') {
138
+ // Only rewrite units that are still open — a terminal unit (e.g.
139
+ // `failed`) re-fired by a duplicate job must keep its real outcome.
140
+ if (unit.status === 'pending' || unit.status === 'running') {
141
+ await payload.update({
142
+ collection: unitsSlug,
143
+ id: unitId,
144
+ data: {
145
+ status: 'skipped',
146
+ failureMessage: 'cancelled_by_admin',
147
+ completedAt: now().toISOString()
148
+ },
149
+ overrideAccess: true
150
+ });
151
+ await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'skippedUnits');
152
+ }
153
+ // The skip we just wrote may have been the last open unit — give the
154
+ // batch its chance to leave `cancelling`.
155
+ await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks);
156
+ return {
157
+ status: 'skipped',
158
+ failureMessage: 'cancelled_by_admin'
159
+ };
160
+ }
161
+ // -------------------------------------------------------------------
162
+ // 1b. Security allowlist (Decision #11 / N5). A `bulk-translate-units`
163
+ // row's `collection` field is free-text. Without this guard, an
164
+ // attacker with write access to that collection could craft a
165
+ // unit targeting `users` and the worker would call translateDocument
166
+ // with overrideAccess: true. The plugin auto-wires the allowlist
167
+ // from `options.collections` + `options.globals`.
168
+ // -------------------------------------------------------------------
169
+ const isGlobalForGate = isGlobalSlug(payload, unit.collection);
170
+ if (!isGlobalForGate && allowedCollections.size > 0 && !allowedCollections.has(unit.collection)) {
171
+ await payload.update({
172
+ collection: unitsSlug,
173
+ id: unitId,
174
+ data: {
175
+ status: 'failed',
176
+ failureCode: 'permanent.config',
177
+ failureMessage: `Collection '${unit.collection}' not in plugin allowlist`,
178
+ completedAt: now().toISOString()
179
+ },
180
+ overrideAccess: true
181
+ });
182
+ return {
183
+ status: 'failed',
184
+ failureCode: 'permanent.config',
185
+ failureMessage: `Collection '${unit.collection}' not in plugin allowlist`
186
+ };
187
+ }
188
+ if (isGlobalForGate && allowedGlobals.size > 0 && !allowedGlobals.has(unit.collection)) {
189
+ await payload.update({
190
+ collection: unitsSlug,
191
+ id: unitId,
192
+ data: {
193
+ status: 'failed',
194
+ failureCode: 'permanent.config',
195
+ failureMessage: `Global '${unit.collection}' not in plugin allowlist`,
196
+ completedAt: now().toISOString()
197
+ },
198
+ overrideAccess: true
199
+ });
200
+ return {
201
+ status: 'failed',
202
+ failureCode: 'permanent.config',
203
+ failureMessage: `Global '${unit.collection}' not in plugin allowlist`
204
+ };
205
+ }
206
+ // -------------------------------------------------------------------
207
+ // 1d. Per-doc serialization via atomic UPDATE (2026-06-05 rewrite).
208
+ //
209
+ // Previously we used `pg_try_advisory_lock` + polling. That held one
210
+ // pool connection per acquired lock for the full LLM round-trip,
211
+ // starving Payload's connection pool (default 10) when bulk runs
212
+ // touched many docs concurrently — admin UI + status endpoints
213
+ // stalled because every pool slot was tied up by a lock holder.
214
+ //
215
+ // New design: a single atomic UPDATE that conditionally transitions
216
+ // `pending → running` IFF no sibling unit for the same
217
+ // `(collection, documentId)` is already `running`. Winners proceed
218
+ // with no held connection. Losers see `0 rowsAffected` → we
219
+ // re-enqueue this unit's Payload job so a later cron tick retries.
220
+ //
221
+ // `concurrency.perDocument: 1` in plugin config only gates the
222
+ // in-process `translateDocument` API and does NOT cover the bulk-
223
+ // translate worker pool — this claim is what actually serialises
224
+ // per-doc work for bulk runs.
225
+ //
226
+ // On non-Postgres adapters the claim is a no-op (returns `'noop'`)
227
+ // and we fall through to the legacy mark-running path below.
228
+ // -------------------------------------------------------------------
229
+ const earlyLog = createScopedLogger(payload, {
230
+ component: 'bulk.worker',
231
+ batchId: unit.batchId,
232
+ unitId,
233
+ collection: unit.collection,
234
+ documentId: unit.documentId,
235
+ locale: unit.locale
236
+ });
237
+ const claim = await tryClaimAsLock({
238
+ payload,
239
+ unitsCollectionSlug: unitsSlug,
240
+ unitId,
241
+ collection: unit.collection,
242
+ documentId: unit.documentId
243
+ });
244
+ if (claim.acquired === false) {
245
+ // Couldn't claim — sibling unit for the same doc is running, OR
246
+ // the unit was already moved out of `pending` by a competing fire
247
+ // of the same job. Re-enqueue this unit so a later cron tick
248
+ // retries; don't bump attempts (deferral isn't a translation
249
+ // failure).
250
+ try {
251
+ await payload.jobs.queue({
252
+ task: BULK_TRANSLATE_DOC_TASK_SLUG,
253
+ input: {
254
+ unitId
255
+ }
256
+ });
257
+ } catch (enqueueErr) {
258
+ payload.logger?.warn?.(`[ai-translate] per-doc claim deferred but re-enqueue failed for ${unit.collection}/${String(unit.documentId)} locale=${unit.locale}: ${String(enqueueErr)}`);
259
+ }
260
+ earlyLog.event('info', 'bulk.worker.unit.per-doc-busy-deferred', {});
261
+ return {
262
+ status: 'pending'
263
+ };
264
+ }
265
+ const releaseLock = claim.release;
266
+ try {
267
+ // -------------------------------------------------------------------
268
+ // 2. Mark running + increment attempts.
269
+ //
270
+ // On Postgres the atomic claim above ALREADY transitioned the row
271
+ // to `running` and bumped `attempts`, so this block is a no-op for
272
+ // that path. It runs as the fallback when the claim returned
273
+ // `'noop'` (non-Postgres adapter) — preserving the legacy code path
274
+ // for those callers.
275
+ // -------------------------------------------------------------------
276
+ const isPgClaim = claim.acquired === true;
277
+ const nextAttempts = isPgClaim ? claim.attempts : (unit.attempts ?? 0) + 1;
278
+ const log = earlyLog;
279
+ const unitStartedAtMs = Date.now();
280
+ if (!isPgClaim) {
281
+ await payload.update({
282
+ collection: unitsSlug,
283
+ id: unitId,
284
+ data: {
285
+ status: 'running',
286
+ attempts: nextAttempts,
287
+ startedAt: now().toISOString()
288
+ },
289
+ overrideAccess: true
290
+ });
291
+ }
292
+ log.event('info', 'bulk.worker.unit.started', {
293
+ collection: unit.collection,
294
+ documentId: unit.documentId,
295
+ locale: unit.locale,
296
+ attempt: nextAttempts
297
+ });
298
+ // -------------------------------------------------------------------
299
+ // 3. F-DA-TOCTOU scenario B: another batch's unit may be racing this
300
+ // one. Round-5 pr-reviewer caught the dual-skip bug — naive "skip if
301
+ // any other pending/running" caused BOTH racing workers to mark
302
+ // themselves running, query, see each other, and skip. Zero
303
+ // translation for the doc.
304
+ //
305
+ // Fix: deterministic tiebreaker on `batchId`. Among all concurrently-
306
+ // active units (this one + any others found), the LOWEST batchId
307
+ // wins. The losers skip. Guarantees exactly one winner per
308
+ // (collection, doc, locale) tuple under any number of concurrent
309
+ // batches.
310
+ // -------------------------------------------------------------------
311
+ const concurrent = await payload.find({
312
+ collection: unitsSlug,
313
+ where: {
314
+ and: [
315
+ {
316
+ collection: {
317
+ equals: unit.collection
318
+ }
319
+ },
320
+ {
321
+ documentId: {
322
+ equals: unit.documentId
323
+ }
324
+ },
325
+ {
326
+ locale: {
327
+ equals: unit.locale
328
+ }
329
+ },
330
+ {
331
+ status: {
332
+ in: [
333
+ 'pending',
334
+ 'running'
335
+ ]
336
+ }
337
+ },
338
+ {
339
+ id: {
340
+ not_equals: unitId
341
+ }
342
+ }
343
+ ]
344
+ },
345
+ limit: 100,
346
+ overrideAccess: true,
347
+ depth: 0
348
+ });
349
+ if (concurrent.totalDocs > 0) {
350
+ const competingBatchIds = concurrent.docs.map((d)=>d.batchId).filter((b)=>typeof b === 'string' && b.length > 0);
351
+ const allBatchIds = [
352
+ unit.batchId,
353
+ ...competingBatchIds
354
+ ].sort();
355
+ if (allBatchIds[0] !== unit.batchId) {
356
+ // A different batch has the lower id — they win. We skip.
357
+ await payload.update({
358
+ collection: unitsSlug,
359
+ id: unitId,
360
+ data: {
361
+ status: 'skipped',
362
+ failureMessage: 'concurrent_batch',
363
+ completedAt: now().toISOString()
364
+ },
365
+ overrideAccess: true
366
+ });
367
+ await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'skippedUnits');
368
+ await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
369
+ return {
370
+ status: 'skipped',
371
+ failureMessage: 'concurrent_batch'
372
+ };
373
+ }
374
+ // We have the lowest batchId — we win. Continue.
375
+ payload.logger?.debug?.(`[ai-translate] worker: ${concurrent.totalDocs} concurrent unit(s) lost the tiebreaker to batch ${unit.batchId}; proceeding`);
376
+ }
377
+ // -------------------------------------------------------------------
378
+ // 4. Dispatch to the translate API. Globals route through
379
+ // `translateGlobal`, collections through `translateDocument`.
380
+ // Detection: presence of the slug in `payload.config.globals`.
381
+ // -------------------------------------------------------------------
382
+ const isGlobal = isGlobalSlug(payload, unit.collection);
383
+ const targetLocales = [
384
+ unit.locale
385
+ ];
386
+ // -------------------------------------------------------------------
387
+ // 3c. Per-locale pre-write snapshot (v1.2.12). Captures the TARGET
388
+ // locale's current localized-translatable values onto the unit
389
+ // row BEFORE any write, so Revert restores exactly what this run
390
+ // overwrote. Replaces the coordinator-era snapshot, which was
391
+ // captured from the SOURCE-locale enumeration read and reused for
392
+ // every locale — replaying it stamped source-language content
393
+ // over translated locales (2026-06-11 prod incident).
394
+ //
395
+ // First capture wins: a retry inside the same batch must not
396
+ // overwrite the true pre-batch state with the previous attempt's
397
+ // machine output. Absent paths are stored as explicit nulls so a
398
+ // revert of a fresh locale clears the run's writes back to empty.
399
+ // Best-effort: on capture failure the unit has no locale-tagged
400
+ // snapshot and Revert refuses it (safe default) while translation
401
+ // proceeds normally.
402
+ // -------------------------------------------------------------------
403
+ if (unit.snapshotLocale !== unit.locale) {
404
+ try {
405
+ const surfaceFields = isGlobal ? (payload.config.globals ?? []).find((g)=>g.slug === unit.collection)?.fields : (payload.config.collections ?? []).find((c)=>c.slug === unit.collection)?.fields;
406
+ if (surfaceFields) {
407
+ const topLevelPaths = Array.from(new Set(resolveTranslatableFields(surfaceFields).filter((f)=>f.localized).map((f)=>f.path.split('.')[0])));
408
+ const targetDoc = isGlobal ? await findGlobalNoFallback(payload, unit.collection, unit.locale) : await findByIdNoFallback(payload, unit.collection, unit.documentId, unit.locale);
409
+ const snapshot = captureLocalizedSnapshot(targetDoc ?? {}, topLevelPaths);
410
+ for (const path of topLevelPaths){
411
+ if (!(path in snapshot)) snapshot[path] = null;
412
+ }
413
+ await payload.update({
414
+ collection: unitsSlug,
415
+ id: unitId,
416
+ data: {
417
+ preRunSnapshot: snapshot,
418
+ snapshotLocale: unit.locale
419
+ },
420
+ overrideAccess: true
421
+ });
422
+ }
423
+ } catch (err) {
424
+ payload.logger?.warn?.(`[ai-translate] worker: pre-write snapshot capture failed for ${unit.collection}/${unit.documentId} locale=${unit.locale} — unit will not be revertable: ${err instanceof Error ? err.message : String(err)}`);
425
+ }
426
+ }
427
+ try {
428
+ let costUsd;
429
+ // `providerLatencyMs` is the SUM of per-batch LLM round-trip times
430
+ // reported by the provider — measured around the SDK call, so it
431
+ // excludes token-bucket wait + provider rate-limit backoff. That's
432
+ // the "how long did the AI actually take" signal the editor wants.
433
+ //
434
+ // The delta between this unit's `startedAt` and `completedAt`
435
+ // includes throttle wait and is wallclock-in-worker — still useful
436
+ // for engineering (it answers "how long was this unit blocked by
437
+ // the rate limiter?") but not what the editor needs.
438
+ let providerLatencyMs = 0;
439
+ let outcome;
440
+ if (isGlobal) {
441
+ outcome = await translateApi.translateGlobal(payload, {
442
+ global: unit.collection,
443
+ targetLocales,
444
+ req: params.req,
445
+ force: isForceMode
446
+ });
447
+ } else {
448
+ outcome = await translateApi.translateDocument(payload, {
449
+ collection: unit.collection,
450
+ id: unit.documentId,
451
+ targetLocales,
452
+ req: params.req,
453
+ force: isForceMode
454
+ });
455
+ }
456
+ costUsd = requireCostUsd(outcome);
457
+ providerLatencyMs = outcome?.providerLatencyMs ?? 0;
458
+ // ---------------------------------------------------------------------
459
+ // Unit-status truth check (v1.2.5 + v1.2.7 refinement).
460
+ //
461
+ // The translate API returns `{ succeeded, preserved, failed }` where
462
+ // `failed` mixes two outcome classes:
463
+ // - `status: 'failed'` — HARD failure (provider error, no response,
464
+ // schema rejection). These are real failures the editor must see.
465
+ // - `status: 'skipped'` — SOFT skip (verbatim echo, too-short ratio,
466
+ // placeholder mismatch). The output validator rejected the value
467
+ // but it wasn't a hardware error — the model gave us something we
468
+ // deliberately won't write. Common case: a 1-word `alt` field
469
+ // containing a brand name the model correctly returns unchanged.
470
+ //
471
+ // v1.2.5 lumped both into `failedCount` and stamped every all-rejected
472
+ // unit as `permanent.schema` with an "AI response was unreadable"
473
+ // editor message — which lies when the actual cause was an
474
+ // intentional echo on a proper noun. v1.2.7 splits them:
475
+ //
476
+ // - succeeded > 0 → 'success' (at least one write landed)
477
+ // - hardFailed > 0 && succeeded === 0 && preserved === 0
478
+ // → 'failed' / 'permanent.schema'
479
+ // (genuine output-validation failure)
480
+ // - softSkipped > 0 && hardFailed === 0 && succeeded === 0
481
+ // && preserved === 0
482
+ // → 'skipped' / `soft-skip.no-translation-needed`
483
+ // (model returned source unchanged for every
484
+ // field — typically proper nouns / short labels)
485
+ // - preserved > 0 → 'success' (manual-edit guard kept everything)
486
+ // - all zero → 'success' (empty source, no-op)
487
+ // ---------------------------------------------------------------------
488
+ const succeededCount = outcome?.succeeded?.length ?? 0;
489
+ const preservedCount = outcome?.preserved?.length ?? 0;
490
+ const failedFields = outcome?.failed ?? [];
491
+ const hardFailed = failedFields.filter((f)=>f.status === 'failed');
492
+ const softSkipped = failedFields.filter((f)=>f.status === 'skipped');
493
+ if (succeededCount === 0 && preservedCount === 0 && hardFailed.length === 0 && softSkipped.length > 0) {
494
+ const paths = softSkipped.map((f)=>f.fieldPath ?? '?').slice(0, 5).join(', ');
495
+ const message = `${softSkipped.length} field(s) returned unchanged from source${paths ? `: ${paths}` : ''} — kept as-is`;
496
+ await payload.update({
497
+ collection: unitsSlug,
498
+ id: unitId,
499
+ data: {
500
+ status: 'skipped',
501
+ failureMessage: message,
502
+ processingDurationMs: providerLatencyMs,
503
+ completedAt: now().toISOString()
504
+ },
505
+ overrideAccess: true
506
+ });
507
+ await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'skippedUnits');
508
+ await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
509
+ log.event('info', 'bulk.worker.unit.all-soft-skipped', {
510
+ durationMs: Date.now() - unitStartedAtMs,
511
+ softSkippedCount: softSkipped.length,
512
+ attempt: nextAttempts
513
+ });
514
+ return {
515
+ status: 'skipped',
516
+ failureMessage: message
517
+ };
518
+ }
519
+ if (succeededCount === 0 && preservedCount === 0 && hardFailed.length > 0) {
520
+ const paths = hardFailed.map((f)=>f.fieldPath ?? '?').slice(0, 5).join(', ');
521
+ const message = `${hardFailed.length} field(s) failed validation${paths ? `: ${paths}` : ''}`;
522
+ await payload.update({
523
+ collection: unitsSlug,
524
+ id: unitId,
525
+ data: {
526
+ status: 'failed',
527
+ failureCode: 'permanent.schema',
528
+ failureMessage: message,
529
+ processingDurationMs: providerLatencyMs,
530
+ completedAt: now().toISOString()
531
+ },
532
+ overrideAccess: true
533
+ });
534
+ await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'failedUnits');
535
+ await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
536
+ log.event('warn', 'bulk.worker.unit.all-fields-failed', {
537
+ durationMs: Date.now() - unitStartedAtMs,
538
+ failedCount: hardFailed.length,
539
+ attempt: nextAttempts,
540
+ failureCode: 'permanent.schema'
541
+ });
542
+ return {
543
+ status: 'failed',
544
+ failureCode: 'permanent.schema',
545
+ failureMessage: message
546
+ };
547
+ }
548
+ await payload.update({
549
+ collection: unitsSlug,
550
+ id: unitId,
551
+ data: {
552
+ status: 'success',
553
+ costUsd,
554
+ processingDurationMs: providerLatencyMs,
555
+ completedAt: now().toISOString()
556
+ },
557
+ overrideAccess: true
558
+ });
559
+ await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'completedUnits', costUsd);
560
+ await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
561
+ log.event('info', 'bulk.worker.unit.succeeded', {
562
+ durationMs: Date.now() - unitStartedAtMs,
563
+ costUsd,
564
+ attempt: nextAttempts
565
+ });
566
+ return {
567
+ status: 'success',
568
+ costUsd
569
+ };
570
+ } catch (err) {
571
+ const code = classifyError(err);
572
+ const message = err instanceof Error ? err.message : String(err);
573
+ // Transient errors: send back to `pending` for the cron tick to
574
+ // retry, UNLESS we've exhausted maxAttempts. Past that, mark
575
+ // failed terminally with `transient.crashed`.
576
+ const isTransient = code === 'transient.rate_limited' || code === 'transient.provider' || code === 'transient.crashed';
577
+ if (isTransient && nextAttempts < maxAttempts) {
578
+ await payload.update({
579
+ collection: unitsSlug,
580
+ id: unitId,
581
+ data: {
582
+ status: 'pending',
583
+ failureCode: code,
584
+ failureMessage: message
585
+ },
586
+ overrideAccess: true
587
+ });
588
+ log.event('warn', 'bulk.worker.unit.transient', {
589
+ err,
590
+ attempt: nextAttempts,
591
+ maxAttempts,
592
+ failureCode: code,
593
+ durationMs: Date.now() - unitStartedAtMs
594
+ });
595
+ return {
596
+ status: 'pending',
597
+ failureCode: code,
598
+ failureMessage: message
599
+ };
600
+ }
601
+ const terminalCode = isTransient ? 'transient.crashed' : code;
602
+ await payload.update({
603
+ collection: unitsSlug,
604
+ id: unitId,
605
+ data: {
606
+ status: 'failed',
607
+ failureCode: terminalCode,
608
+ failureMessage: message,
609
+ completedAt: now().toISOString()
610
+ },
611
+ overrideAccess: true
612
+ });
613
+ await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'failedUnits');
614
+ await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
615
+ log.event('error', 'bulk.worker.unit.failed', {
616
+ err,
617
+ attempt: nextAttempts,
618
+ maxAttempts,
619
+ failureCode: terminalCode,
620
+ durationMs: Date.now() - unitStartedAtMs
621
+ });
622
+ return {
623
+ status: 'failed',
624
+ failureCode: terminalCode,
625
+ failureMessage: message
626
+ };
627
+ }
628
+ } finally{
629
+ // Per-doc lock acquired above the try/catch. Release in every exit
630
+ // path (success / failed / pending) so the next sibling locale for
631
+ // this doc can proceed. No-op on non-Postgres adapters.
632
+ await releaseLock();
633
+ }
634
+ }
635
+ // ---------------------------------------------------------------------------
636
+ // Error classification
637
+ // ---------------------------------------------------------------------------
638
+ /**
639
+ * Maps a thrown error to a `failureCode`. Order matters — more
640
+ * specific patterns win. Defaults to `unknown` for anything we can't
641
+ * recognise so the row stays visible in the admin drill-down rather
642
+ * than disappearing into a generic 'failed'.
643
+ *
644
+ * Classes per design doc Decision §worker §error-classification:
645
+ * - transient.rate_limited: 429 / explicit rate-limit
646
+ * - transient.provider: 5xx / fetch timeout
647
+ * - permanent.schema: NoObjectGeneratedError (Vercel AI SDK
648
+ * throws this when the model returns
649
+ * malformed structured output)
650
+ * - permanent.too_large: cost-guard PER_DOC_CEILING/PER_CALL_LIMIT
651
+ * - permanent.config: 401 / 403 / auth
652
+ * - permanent.deleted: "Document not found" from findByIdNoFallback
653
+ * - unknown: default
654
+ */ export function classifyError(err) {
655
+ if (err === null || err === undefined) return 'unknown';
656
+ const message = err instanceof Error ? err.message : String(err);
657
+ const lower = message.toLowerCase();
658
+ const name = err instanceof Error ? err.name : '';
659
+ const codeProp = err?.code;
660
+ const codeStr = typeof codeProp === 'string' ? codeProp : '';
661
+ // Cost-guard: check first — its error messages contain '429' fragments
662
+ // in some surfaces.
663
+ if (codeStr === 'PER_DOC_CEILING' || codeStr === 'PER_CALL_LIMIT') {
664
+ return 'permanent.too_large';
665
+ }
666
+ if (lower.includes('per_doc_ceiling') || lower.includes('per_call_limit')) {
667
+ return 'permanent.too_large';
668
+ }
669
+ // Decision #29 / NEW-12 (v1.2.6): undefined / non-finite cost is a
670
+ // provider-flake signal — we hard-throw COST_UNDEFINED inside the
671
+ // worker so the bad cost row never persists as $0 spend. In practice
672
+ // (batches #5, #6 on blog-wild prod) 124 / 168 + 129 / 168 units in
673
+ // the same batch succeeded with the same model + same config, so
674
+ // "config broken" is the wrong story. Classify as transient.provider
675
+ // so the cron retries it on the next tick and the UI doesn't
676
+ // misdirect editors to the settings page. If retries exhaust, the
677
+ // worker still escalates to `transient.crashed` (terminal) — the
678
+ // unit doesn't silently disappear.
679
+ if (codeStr === 'COST_UNDEFINED') {
680
+ return 'transient.provider';
681
+ }
682
+ // Defense in depth: when COST_UNDEFINED is rethrown by an outer
683
+ // wrapper that drops the `.code` property, fall back to message
684
+ // matching. The literal text is set in `throwCostUndefined` below
685
+ // and is stable across versions.
686
+ if (lower.includes('provider returned undefined / non-finite estimatedcostusd') || lower.includes('cost instrumentation broken')) {
687
+ return 'transient.provider';
688
+ }
689
+ // Schema-invalid (AI SDK throws NoObjectGeneratedError when the
690
+ // model produces malformed JSON against the requested schema).
691
+ if (name === 'NoObjectGeneratedError' || lower.includes('noobjectgeneratederror')) {
692
+ return 'permanent.schema';
693
+ }
694
+ // Document-not-found from the plugin's `findByIdNoFallback` path.
695
+ // NEW-12 (v1.2.6): also catch the bare "Not Found" payload throws when
696
+ // a doc is hard-deleted between enqueue and worker pickup — observed
697
+ // on prod (batches #5/6 had `failure_message='Not Found'` rows that
698
+ // were silently classified as `unknown` and rendered as "Translation
699
+ // failed unexpectedly" instead of the more accurate "document was
700
+ // deleted" copy.
701
+ if (/document not found|global not found|not found in payload/.test(lower) || /^not found$/i.test(message.trim())) {
702
+ return 'permanent.deleted';
703
+ }
704
+ // Auth / config — distinct from transient 5xx.
705
+ if (/\b401\b|\b403\b|unauthorized|forbidden|api[_-]?key/i.test(message)) {
706
+ return 'permanent.config';
707
+ }
708
+ // Rate-limit BEFORE provider — both can include '429' in the
709
+ // message but rate-limit is the more specific reason.
710
+ if (/\b429\b|rate[_-]?limit/i.test(message)) {
711
+ return 'transient.rate_limited';
712
+ }
713
+ // Provider 5xx / fetch timeout / network.
714
+ if (/\b5\d\d\b|timeout|timed out|fetch failed|econnreset|enotfound|network/i.test(message) || name === 'AbortError' || name === 'FetchError') {
715
+ return 'transient.provider';
716
+ }
717
+ return 'unknown';
718
+ }
719
+ async function bumpBatchCounter(payload, batchesSlug, batchId, counter, costUsd = 0) {
720
+ const log = createScopedLogger(payload, {
721
+ component: 'bulk.worker',
722
+ batchId
723
+ });
724
+ // Atomic increment via raw SQL — `payload.update()` with a fetched
725
+ // value would race under concurrent workers (read-then-write).
726
+ // Postgres `column = column + N` is the standard fix and lets us
727
+ // bump the counter + actualCostUsd in a single statement.
728
+ //
729
+ // The column-name map mirrors Payload-Drizzle's camelCase →
730
+ // snake_case convention. Hard-coded rather than computed so a
731
+ // future column rename surfaces as a build/test failure here
732
+ // rather than a silent drop.
733
+ const columnByCounter = {
734
+ completedUnits: 'completed_units',
735
+ failedUnits: 'failed_units',
736
+ skippedUnits: 'skipped_units'
737
+ };
738
+ const column = columnByCounter[counter];
739
+ const numericBatchId = Number.parseInt(batchId, 10);
740
+ if (!Number.isFinite(numericBatchId)) {
741
+ log.event('warn', 'bulk.counter-bump.invalid-batch-id', {
742
+ counter,
743
+ batchId
744
+ });
745
+ return;
746
+ }
747
+ const drizzle = payload.db.drizzle;
748
+ if (!drizzle) {
749
+ // Non-postgres adapter — fall back to the read-modify-write path.
750
+ // Concurrency-sensitive counters will drift but the system stays
751
+ // functional. The maybeTransitionBatch reconciler re-derives the
752
+ // terminal status from the source-of-truth units count, so a
753
+ // skewed counter doesn't permanently block batch completion.
754
+ try {
755
+ const row = await payload.findByID({
756
+ collection: batchesSlug,
757
+ id: batchId,
758
+ overrideAccess: true,
759
+ depth: 0
760
+ });
761
+ if (!row) return;
762
+ const current = Number(row[counter] ?? 0);
763
+ const data = {
764
+ [counter]: current + 1
765
+ };
766
+ if (costUsd > 0) {
767
+ data.actualCostUsd = Number(row.actualCostUsd ?? 0) + costUsd;
768
+ }
769
+ await payload.update({
770
+ collection: batchesSlug,
771
+ id: batchId,
772
+ data,
773
+ overrideAccess: true
774
+ });
775
+ } catch (err) {
776
+ log.event('warn', 'bulk.counter-bump.failed', {
777
+ err,
778
+ counter,
779
+ batchId,
780
+ path: 'fallback'
781
+ });
782
+ }
783
+ return;
784
+ }
785
+ let sqlTag;
786
+ try {
787
+ const drizzleMod = await import('drizzle-orm');
788
+ sqlTag = drizzleMod.sql;
789
+ } catch {
790
+ sqlTag = undefined;
791
+ }
792
+ if (!sqlTag) {
793
+ log.event('warn', 'bulk.counter-bump.drizzle-unavailable', {
794
+ counter,
795
+ batchId
796
+ });
797
+ return;
798
+ }
799
+ try {
800
+ const columnExpr = sqlTag.raw(`"${column}"`);
801
+ if (costUsd > 0) {
802
+ await drizzle.execute(sqlTag`UPDATE "bulk_translate_batches"
803
+ SET ${columnExpr} = COALESCE(${columnExpr}, 0) + 1,
804
+ "actual_cost_usd" = COALESCE("actual_cost_usd", 0) + ${costUsd},
805
+ "updated_at" = now()
806
+ WHERE "id" = ${numericBatchId}`);
807
+ } else {
808
+ await drizzle.execute(sqlTag`UPDATE "bulk_translate_batches"
809
+ SET ${columnExpr} = COALESCE(${columnExpr}, 0) + 1,
810
+ "updated_at" = now()
811
+ WHERE "id" = ${numericBatchId}`);
812
+ }
813
+ } catch (err) {
814
+ log.event('warn', 'bulk.counter-bump.failed', {
815
+ err,
816
+ counter,
817
+ batchId
818
+ });
819
+ }
820
+ }
821
+ /**
822
+ * Check whether every unit in the batch is in a terminal state. If
823
+ * yes, transition the batch and fire the callback.
824
+ */ export async function maybeTransitionBatch(payload, batchesSlug, unitsSlug, batchId, callbacks, parentLog) {
825
+ const log = parentLog ?? createScopedLogger(payload, {
826
+ component: 'bulk.worker',
827
+ batchId
828
+ });
829
+ const row = await payload.findByID({
830
+ collection: batchesSlug,
831
+ id: batchId,
832
+ overrideAccess: true,
833
+ depth: 0
834
+ });
835
+ if (!row) return;
836
+ // v1.2.5: also handle `cancelling`. Pre-1.2.5 the cancel endpoint
837
+ // flipped the batch to `cancelling` and relied on workers finishing
838
+ // in-flight units to call maybeTransitionBatch; this function then
839
+ // bailed because status was no longer `running` / `queued`. Result:
840
+ // the batch sat in `cancelling` forever and only `force-reset`
841
+ // could unstick it. Allowing `cancelling` here lets the last worker
842
+ // to finish flip the batch to `cancelled`.
843
+ if (row.status !== 'running' && row.status !== 'queued' && row.status !== 'cancelling') return;
844
+ // Re-derive counts from the source of truth (units collection) so
845
+ // we don't trust the eventually-consistent counters on the batch
846
+ // row.
847
+ const totalsByStatus = await aggregateUnitStatuses(payload, unitsSlug, batchId);
848
+ const total = row.totalUnits ?? totalsByStatus.total;
849
+ if (total === 0) return; // coordinator hasn't stamped totals yet
850
+ const terminal = totalsByStatus.success + totalsByStatus.failed + totalsByStatus.skipped + totalsByStatus.reverted;
851
+ if (terminal < total) return;
852
+ // Determine terminal status. Vocabulary: 'success' (not 'completed')
853
+ // — aligned across schema + types + readers in v1.2.4.
854
+ //
855
+ // v1.2.5: when transitioning out of `cancelling`, the destination is
856
+ // always `cancelled` regardless of per-unit outcomes. The admin
857
+ // explicitly asked to stop; reporting `success` / `partial` /
858
+ // `failed` would erase the intent.
859
+ let nextStatus;
860
+ if (row.status === 'cancelling') {
861
+ nextStatus = 'cancelled';
862
+ } else if (totalsByStatus.failed === 0 && totalsByStatus.success > 0) {
863
+ nextStatus = 'success';
864
+ } else if (totalsByStatus.success === 0 && totalsByStatus.failed > 0) {
865
+ nextStatus = 'failed';
866
+ } else {
867
+ nextStatus = 'partial';
868
+ }
869
+ await payload.update({
870
+ collection: batchesSlug,
871
+ id: batchId,
872
+ data: {
873
+ status: nextStatus,
874
+ completedAt: new Date().toISOString()
875
+ },
876
+ overrideAccess: true
877
+ });
878
+ log.event('info', 'bulk.batch.transition', {
879
+ batchId,
880
+ nextStatus,
881
+ totalsByStatus
882
+ });
883
+ await fireBatchCallback(payload, callbacks, row, nextStatus, totalsByStatus, log);
884
+ }
885
+ async function aggregateUnitStatuses(payload, unitsSlug, batchId) {
886
+ const statuses = [
887
+ 'success',
888
+ 'failed',
889
+ 'skipped',
890
+ 'reverted',
891
+ 'pending',
892
+ 'running'
893
+ ];
894
+ const out = {
895
+ total: 0,
896
+ success: 0,
897
+ failed: 0,
898
+ skipped: 0,
899
+ reverted: 0,
900
+ pending: 0,
901
+ running: 0
902
+ };
903
+ for (const status of statuses){
904
+ const result = await payload.count({
905
+ collection: unitsSlug,
906
+ where: {
907
+ and: [
908
+ {
909
+ batchId: {
910
+ equals: batchId
911
+ }
912
+ },
913
+ {
914
+ status: {
915
+ equals: status
916
+ }
917
+ }
918
+ ]
919
+ },
920
+ overrideAccess: true
921
+ });
922
+ out[status] = result.totalDocs;
923
+ out.total += result.totalDocs;
924
+ }
925
+ return out;
926
+ }
927
+ async function fireBatchCallback(payload, callbacks, batch, status, totals, log) {
928
+ if (!callbacks) return;
929
+ const event = {
930
+ batchId: String(batch.id),
931
+ status,
932
+ scope: {
933
+ collections: batch.scope?.collections,
934
+ globals: batch.scope?.globals,
935
+ locales: batch.scope?.locales,
936
+ mode: batch.scope?.mode ?? 'changed'
937
+ },
938
+ counts: {
939
+ total: totals.total,
940
+ completed: totals.success,
941
+ failed: totals.failed,
942
+ skipped: totals.skipped
943
+ },
944
+ costUsd: {
945
+ estimated: Number(batch.estimatedCostUsd ?? 0),
946
+ actual: Number(batch.actualCostUsd ?? 0)
947
+ },
948
+ triggeredByUserId: String(batch.triggeredByUserId ?? ''),
949
+ triggeredByEmail: batch.triggeredByEmail,
950
+ durationMs: batch.startedAt ? Date.now() - new Date(batch.startedAt).getTime() : 0
951
+ };
952
+ try {
953
+ await callbacks.onBatchComplete?.(event);
954
+ if (status === 'failed') {
955
+ await callbacks.onBatchFailed?.(event);
956
+ }
957
+ } catch (err) {
958
+ log.event('error', 'bulk.batch.callback.failed', {
959
+ err,
960
+ batchId: String(batch.id),
961
+ status
962
+ });
963
+ }
964
+ }
965
+ function isGlobalSlug(payload, slug) {
966
+ const globals = payload.config?.globals ?? [];
967
+ return globals.some((g)=>g.slug === slug);
968
+ }
969
+ /**
970
+ * Decision #29 + round-5 pr-reviewer blocker #3 — `undefined`
971
+ * `estimatedCostUsd` is the canonical signal that cost instrumentation
972
+ * is broken upstream. Silently coercing it to `0` (the previous
973
+ * behavior) corrupted `actualCostUsd` aggregates on every batch using
974
+ * the OpenRouter provider, hiding real spend from the cap + reporting.
975
+ *
976
+ * Hard-throw with a classified error so `classifyError` routes it to
977
+ * `permanent.config`, surfacing the broken instrumentation in the
978
+ * admin drill-down rather than silently aggregating $0.
979
+ */ function requireCostUsd(result) {
980
+ const raw = result?.usage?.estimatedCostUsd;
981
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
982
+ throw Object.assign(new Error('Provider returned undefined / non-finite estimatedCostUsd — cost instrumentation broken; refusing to record $0'), {
983
+ code: 'COST_UNDEFINED'
984
+ });
985
+ }
986
+ return raw;
987
+ }
988
+ /**
989
+ * Lazy-load the plugin's translate API for the default builder. Tests
990
+ * inject their own implementation via `options.translateApi`.
991
+ */ async function loadDefaultTranslateApi() {
992
+ const mod = await import('../api.js');
993
+ // Single narrow-cast (NOT `as unknown as`) — the real api signatures
994
+ // are a strict subset of `TranslateApiFns` (they accept a stricter
995
+ // `req?: PayloadRequest`, we expose the looser `req?: unknown`).
996
+ return {
997
+ translateDocument: mod.translateDocument,
998
+ translateGlobal: mod.translateGlobal
999
+ };
1000
+ }