@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,911 @@
1
+ import { DEFAULT_RETRY, REENTRY_FLAG } from './defaults.js';
2
+ import { checkPerCallLimit } from './lib/cost-guards.js';
3
+ import { emitAlert, emitEvent } from './lib/events.js';
4
+ import { isFieldEmpty } from './lib/field-empty.js';
5
+ import { mergeTranslationsIntoTargetDoc } from './lib/locale-merge.js';
6
+ import { checkLocaleRowExists } from './lib/locale-row-check.js';
7
+ import { createScopedLogger } from './lib/logger.js';
8
+ import { applyHashSkip, hashFieldValue, loadFieldHashes, pickManualEditedFields, recordHashesAfterWrite } from './lib/manual-edit-guard.js';
9
+ import { validateTranslation } from './lib/output-validation.js';
10
+ import { findByIdNoFallback } from './lib/payload-read.js';
11
+ import { truncateSourceValue } from './lib/truncate-source-value.js';
12
+ import { DEFAULT_MANUAL_EDIT_COLLECTION_SLUG } from './manual-edit-collection.js';
13
+ export async function translateForLocale(params) {
14
+ const { payload, config, sourceDoc, documentId, collection, targetLocale, fields, allLocalizedTopFields, writeMode, targetPolicy, signal, req, force } = params;
15
+ let { units } = params;
16
+ const succeeded = [];
17
+ const preserved = [];
18
+ const failed = [];
19
+ const totalUsage = {
20
+ inputTokens: 0,
21
+ outputTokens: 0,
22
+ estimatedCostUsd: 0
23
+ };
24
+ let providerLatencyMs = 0;
25
+ // Same source and target locale → no-op
26
+ if (targetLocale === config.sourceLocale) {
27
+ console.warn(`[ai-translate] Source and target locale are the same ("${targetLocale}") — skipping.`);
28
+ return {
29
+ succeeded,
30
+ preserved,
31
+ failed,
32
+ usage: totalUsage,
33
+ providerLatencyMs
34
+ };
35
+ }
36
+ // Preserve policy: skip fields that already have content in the target locale
37
+ if (targetPolicy === 'preserve') {
38
+ units = await filterPreservedFields(payload, collection, documentId, targetLocale, units, fields);
39
+ }
40
+ if (units.length === 0) {
41
+ return {
42
+ succeeded,
43
+ preserved,
44
+ failed,
45
+ usage: totalUsage,
46
+ providerLatencyMs
47
+ };
48
+ }
49
+ // BUG-21 fix: hash-skip on unchanged-content re-translate.
50
+ //
51
+ // If every translatable top-level field in this surface has stored
52
+ // source+target hashes matching the current source+target, the
53
+ // editor clicked Translate but nothing changed since the last write.
54
+ // Sending those units to the LLM would just spend tokens to produce
55
+ // identical output. Skip the LLM call AND report the units as
56
+ // 'skipped' with reason 'unchanged since last translate' so the
57
+ // editor sees clearly that nothing was re-translated.
58
+ //
59
+ // Requires `preserveManualEdits` — that's where the hashes are
60
+ // stored (and where they're recorded post-write). Without it, the
61
+ // plugin has no per-field hash history to compare against.
62
+ //
63
+ // `force` bypass: when the caller signals that the editor explicitly
64
+ // asked for a re-translate regardless of cached state (bulk-translate
65
+ // `mode: 'force'`), we skip the hash-skip path entirely so every
66
+ // translatable field hits the LLM. Hash bookkeeping after the write
67
+ // still runs so the next non-force call benefits from fresh hashes.
68
+ if (config.preserveManualEdits && !force) {
69
+ const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
70
+ const skip = await applyHashSkip({
71
+ payload,
72
+ metaCollection: metaSlug,
73
+ surfaceSlug: collection,
74
+ documentId,
75
+ targetLocale,
76
+ units,
77
+ sourceDoc,
78
+ loadTargetDoc: ()=>findByIdNoFallback(payload, collection, documentId, targetLocale, {
79
+ draft: true
80
+ })
81
+ });
82
+ units = skip.remainingUnits;
83
+ succeeded.push(...skip.hashSkipped);
84
+ if (units.length === 0) {
85
+ return {
86
+ succeeded,
87
+ preserved,
88
+ failed,
89
+ usage: totalUsage,
90
+ providerLatencyMs
91
+ };
92
+ }
93
+ }
94
+ // Batch units by perCallCharLimit + perCallItemLimit
95
+ const batches = batchUnits(units, config.costLimits.perCallCharLimit, config.costLimits.perCallItemLimit);
96
+ const retryConfig = {
97
+ ...DEFAULT_RETRY,
98
+ ...config.retry ?? {}
99
+ };
100
+ // Translate each batch
101
+ const translatedMap = new Map();
102
+ for (const batch of batches){
103
+ if (signal?.aborted) {
104
+ for (const unit of batch){
105
+ failed.push({
106
+ fieldPath: unit.fieldPath,
107
+ locale: targetLocale,
108
+ status: 'failed',
109
+ error: 'Aborted'
110
+ });
111
+ }
112
+ continue;
113
+ }
114
+ const batchResult = await translateBatch({
115
+ batch,
116
+ config,
117
+ collection,
118
+ targetLocale,
119
+ signal,
120
+ retryConfig,
121
+ payload
122
+ });
123
+ // Merge usage
124
+ totalUsage.inputTokens += batchResult.usage.inputTokens;
125
+ totalUsage.outputTokens += batchResult.usage.outputTokens;
126
+ totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (batchResult.usage.estimatedCostUsd ?? 0);
127
+ totalUsage.model = totalUsage.model ?? batchResult.usage.model;
128
+ providerLatencyMs += batchResult.latencyMs;
129
+ // Process results
130
+ for (const unit of batch){
131
+ const translated = batchResult.translations.get(unit.id);
132
+ if (translated !== undefined) {
133
+ translatedMap.set(unit.id, translated);
134
+ succeeded.push({
135
+ fieldPath: unit.fieldPath,
136
+ locale: targetLocale,
137
+ status: 'success',
138
+ characterCount: translated.length,
139
+ durationMs: batchResult.latencyMs,
140
+ // Only return raw text for `plain` units — for inline/block the
141
+ // structured patch (richText tree, blocks array) can't be safely
142
+ // applied via a flat setValue on the client.
143
+ ...unit.kind === 'plain' ? {
144
+ translatedText: translated
145
+ } : {}
146
+ });
147
+ } else if (batchResult.skipped.has(unit.id)) {
148
+ // Verbatim echo or other soft validation. Don't write the value
149
+ // (we never overwrite target with source) but mark as skipped so
150
+ // the caller doesn't surface it as a real failure. Capture the
151
+ // source text (truncated) so the Hub can show editors WHAT was
152
+ // kept instead of just a field path.
153
+ failed.push({
154
+ fieldPath: unit.fieldPath,
155
+ locale: targetLocale,
156
+ status: 'skipped',
157
+ error: batchResult.skipped.get(unit.id),
158
+ errorCode: batchResult.skippedCodes.get(unit.id),
159
+ sourceValue: truncateSourceValue(unit.text)
160
+ });
161
+ } else {
162
+ const err = batchResult.errors.get(unit.id);
163
+ failed.push({
164
+ fieldPath: unit.fieldPath,
165
+ locale: targetLocale,
166
+ status: 'failed',
167
+ error: err ?? 'No translation returned',
168
+ errorCode: batchResult.errorCodes.get(unit.id) ?? 'bulk.transient.provider'
169
+ });
170
+ }
171
+ }
172
+ }
173
+ // Read the existing target-locale doc (without source fallback). We use
174
+ // it as the merge base so required-localized siblings the user hasn't
175
+ // asked us to translate stay populated, and Payload-internal columns
176
+ // never round-trip from a source-locale read into the update body.
177
+ let targetDoc = null;
178
+ try {
179
+ // Read draft state for target locale too — if the user has staged
180
+ // partial translations as a draft, we want to preserve those.
181
+ targetDoc = await findByIdNoFallback(payload, collection, documentId, targetLocale, {
182
+ draft: true
183
+ });
184
+ } catch {
185
+ targetDoc = null;
186
+ }
187
+ // 2026-06-04 fix: Payload's `findByIdNoFallback` returns source-locale
188
+ // values for SHARED block-parent tables when the target locale has no
189
+ // row in `<collection>_locales` yet. The merge then treats those
190
+ // shared values as "real target data" and writes them back with
191
+ // their source-locale ids — which collides with the source's own
192
+ // rows in `<collection>_blocks_*` (PK violation). Fix: probe the
193
+ // per-locale row directly via the pg pool. If the locale row
194
+ // doesn't exist, force targetDoc to null so the merge falls back to
195
+ // the source-clone branch, where `stripArrayItemIds` strips the
196
+ // source ids and lets Payload generate fresh ones.
197
+ if (targetDoc !== null) {
198
+ const localeRowExists = await checkLocaleRowExists(payload, collection, documentId, targetLocale);
199
+ if (!localeRowExists) {
200
+ payload.logger?.info?.(`[ai-translate] No ${collection}_locales row for ${String(documentId)}/${targetLocale} yet — forcing source-fallback merge so Payload generates fresh per-locale ids on the first write.`);
201
+ targetDoc = null;
202
+ }
203
+ }
204
+ // BUG-28 fix: detect structural drift between source and target on any
205
+ // localized array field (layout blocks, nested arrays). If the editor
206
+ // reordered, inserted, or deleted blocks in source WITHOUT changing
207
+ // any text, `translatedMap` is empty — the legacy gate skipped the
208
+ // merge+write entirely and target locales retained the old structure
209
+ // forever. We force the structural-sync write when drift is detected.
210
+ const structuralDrift = hasStructuralDrift(sourceDoc, targetDoc, fields);
211
+ // Patch translated content and write when EITHER we have translations
212
+ // to apply, OR structural sync is needed to align target shape to source.
213
+ if (translatedMap.size > 0 || structuralDrift) {
214
+ const translatedTopFields = new Set();
215
+ for (const unit of units){
216
+ if (translatedMap.has(unit.id)) {
217
+ translatedTopFields.add(unit.fieldPath.split('.')[0]);
218
+ }
219
+ }
220
+ const writeData = mergeTranslationsIntoTargetDoc({
221
+ sourceDoc,
222
+ targetDoc,
223
+ translatedMap,
224
+ fields,
225
+ translatedTopFields,
226
+ allLocalizedTopFields,
227
+ writeMode
228
+ });
229
+ // `preserveManualEdits` opt-in: drop any top-level fields whose
230
+ // current target value diverges from the hash the plugin recorded
231
+ // on its last write. Detection is per-field on the top-level path
232
+ // so a translator can manually fix `de.title` while still letting
233
+ // automated re-translation refresh `de.content` on the next
234
+ // publish. Storage lives in the auto-registered
235
+ // `ai-translate-meta` sidecar collection — see
236
+ // `lib/manual-edit-guard.ts`.
237
+ //
238
+ // `force` bypass: when the caller signals an intentional override
239
+ // (bulk-translate `mode: 'force'`), we skip this guard and let the
240
+ // new translation overwrite whatever's in the target — that's the
241
+ // contract the editor agreed to by checking "Force re-translate
242
+ // (includes docs already up to date)" in the UI. Without this
243
+ // bypass, force-mode bulk runs silently preserve every prior
244
+ // translation and pay for tokens without writing anything.
245
+ let manualEditSkipReasons = null;
246
+ if (config.preserveManualEdits && targetDoc && !force) {
247
+ const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
248
+ const storedHashes = await loadFieldHashes(payload, metaSlug, collection, documentId, targetLocale);
249
+ const { fieldsToDrop, skipReasons } = pickManualEditedFields(writeData, targetDoc, storedHashes);
250
+ if (fieldsToDrop.length > 0) {
251
+ manualEditSkipReasons = skipReasons;
252
+ for (const field of fieldsToDrop){
253
+ delete writeData[field];
254
+ payload.logger?.info?.(`[ai-translate] Skipping ${collection}/${String(documentId)} locale=${targetLocale} field=${field}: ${skipReasons.get(field)}`);
255
+ // BUG-22 fix: move the corresponding `succeeded` entry to
256
+ // `preserved` so callers can tell "wrote" from "kept editor's
257
+ // hand-edit." The translation itself succeeded; the WRITE
258
+ // didn't happen. Pre-1.2.0 these stayed in `succeeded` and
259
+ // misled downstream automation (audit logs, change feeds,
260
+ // editor notifications).
261
+ for(let i = succeeded.length - 1; i >= 0; i--){
262
+ const entry = succeeded[i];
263
+ if (!entry) continue;
264
+ // Match by top-level field path. Inline / block units use
265
+ // dotted paths so we compare on the top segment.
266
+ const entryTop = entry.fieldPath.split('.')[0];
267
+ if (entryTop === field) {
268
+ preserved.push({
269
+ ...entry,
270
+ status: 'skipped',
271
+ error: skipReasons.get(field)
272
+ });
273
+ succeeded.splice(i, 1);
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+ // BUG-27 fix: concurrent-edit stale-source check.
280
+ // Compare a HASH of the translated source-locale fields between
281
+ // dispatch and just-before-write. If those values changed mid-flight
282
+ // (editor's autosave landed during our LLM call), abort and let the
283
+ // next save fire a fresh pass — otherwise the target gets a stale
284
+ // translation the editor has already moved past.
285
+ //
286
+ // Previous implementation compared the parent doc's `updatedAt`, but
287
+ // that field is bumped by EVERY locale write — including SIBLING
288
+ // locales translated concurrently in the same job. When two locales
289
+ // ran in parallel, whichever wrote first bumped `updatedAt`, and the
290
+ // second one then falsely detected "source advanced" and aborted.
291
+ // Hashing the source-locale field values sidesteps that: a sibling
292
+ // locale's write doesn't change the source-locale row's content, only
293
+ // the parent doc's timestamp.
294
+ const stalenessCheckFields = new Set();
295
+ for (const u of units){
296
+ const top = u.fieldPath.split('.')[0];
297
+ if (top) stalenessCheckFields.add(top);
298
+ }
299
+ const computeSourceHash = (doc)=>{
300
+ const sub = {};
301
+ for (const k of stalenessCheckFields)sub[k] = doc[k];
302
+ return hashFieldValue(sub);
303
+ };
304
+ const sourceHashAtDispatch = stalenessCheckFields.size > 0 ? computeSourceHash(sourceDoc) : null;
305
+ if (sourceHashAtDispatch !== null) {
306
+ try {
307
+ const freshSource = await findByIdNoFallback(payload, collection, documentId, config.sourceLocale, {
308
+ draft: true
309
+ });
310
+ const freshHash = freshSource ? computeSourceHash(freshSource) : null;
311
+ if (freshHash !== null && freshHash !== sourceHashAtDispatch) {
312
+ payload.logger?.warn?.(`[ai-translate] Source content advanced during translation of ${collection}/${String(documentId)} locale=${targetLocale}. Aborting this write — the next save will fire a fresh pass.`);
313
+ for (const result of succeeded){
314
+ failed.push({
315
+ ...result,
316
+ status: 'skipped',
317
+ error: 'Source advanced during translation; write aborted to avoid stale-source target content.'
318
+ });
319
+ }
320
+ succeeded.length = 0;
321
+ return {
322
+ succeeded,
323
+ preserved,
324
+ failed,
325
+ usage: totalUsage,
326
+ providerLatencyMs
327
+ };
328
+ }
329
+ } catch {
330
+ // If we can't read the fresh source, fall through to write —
331
+ // staleness check is best-effort; transient DB errors shouldn't
332
+ // block a translation that otherwise succeeded.
333
+ }
334
+ }
335
+ // BUG-25 fix: skip the DB write entirely when manual-edit-guard
336
+ // filtering left writeData empty. Pre-fix, calling `payload.update`
337
+ // with `data: {}` (after every field was preserved as manual-edit)
338
+ // tripped required-field validation on collections that have a
339
+ // required top-level field, producing noisy 'failed' Jobs entries
340
+ // even though every field was correctly preserved. An empty write
341
+ // also touches `updated_at` and causes a downstream cache flush for
342
+ // no semantic change.
343
+ const writeKeys = Object.keys(writeData);
344
+ if (writeKeys.length === 0) {
345
+ payload.logger?.info?.(`[ai-translate] No fields to write for ${collection}/${String(documentId)} locale=${targetLocale} — all fields were preserved as manual edits. Skipping payload.update.`);
346
+ // Skip the write but keep the manualEditSkipReasons / succeeded
347
+ // tally so the per-locale response shape stays consistent.
348
+ } else {
349
+ try {
350
+ // Shallow-copy req with `transactionID: undefined` so each locale
351
+ // write gets its own postgres transaction. Sharing the parent
352
+ // request's transactionID across N concurrent locale writes
353
+ // serialises Payload's update flow on a single txn — N parallel
354
+ // delete-then-insert plans on the same `<col>_blocks_*` junction
355
+ // tables collide and Payload bails with errors like
356
+ // `delete from "<col>_blocks_p_ban" where "_parent_id" = $1` or
357
+ // `field is invalid: _locale, _parent_id`. Issuing a fresh txn per
358
+ // write isolates them. We keep `req.user` (and the rest) so the
359
+ // audit-log afterChange hook can still attribute the write to the
360
+ // human editor who triggered the publish.
361
+ const writeReq = req ? {
362
+ ...req,
363
+ transactionID: undefined
364
+ } : undefined;
365
+ // Allowlist context forward: a naive `...writeReq.context` would
366
+ // propagate audit-log's AUDIT_CONTEXT_KEY (and any future
367
+ // request-scoped flags) into the locale write, causing
368
+ // cascade-guard to mis-attribute or suppress audit entries for
369
+ // the translated row. Only known-safe keys flow through.
370
+ const forwardedContext = {};
371
+ if (writeReq?.context && 'disableRevalidate' in writeReq.context) {
372
+ forwardedContext.disableRevalidate = writeReq.context.disableRevalidate;
373
+ }
374
+ await payload.update({
375
+ collection: collection,
376
+ id: documentId,
377
+ locale: targetLocale,
378
+ data: writeData,
379
+ context: {
380
+ ...forwardedContext,
381
+ [REENTRY_FLAG]: true
382
+ },
383
+ overrideAccess: true,
384
+ draft: false,
385
+ ...writeReq ? {
386
+ req: writeReq
387
+ } : {}
388
+ });
389
+ // Persist per-field source+target hashes for next-translation
390
+ // hash-skip. Best-effort — failure here doesn't roll back the
391
+ // successful translation write above (helper logs internally).
392
+ if (config.preserveManualEdits) {
393
+ const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
394
+ const { written, failed: failedHashes } = await recordHashesAfterWrite({
395
+ payload,
396
+ metaCollection: metaSlug,
397
+ surfaceSlug: collection,
398
+ documentId,
399
+ targetLocale,
400
+ writeData,
401
+ sourceDoc
402
+ });
403
+ if (failedHashes > 0) {
404
+ const localeLog = createScopedLogger(payload, {
405
+ component: 'translation',
406
+ collection,
407
+ documentId: String(documentId),
408
+ locale: targetLocale
409
+ });
410
+ localeLog.event('warn', 'translation.hash-record.partial', {
411
+ written,
412
+ failed: failedHashes
413
+ });
414
+ }
415
+ }
416
+ } catch (error) {
417
+ const localeLog = createScopedLogger(payload, {
418
+ component: 'translation',
419
+ collection,
420
+ documentId: String(documentId),
421
+ locale: targetLocale
422
+ });
423
+ localeLog.event('error', 'translation.locale-write.failed', {
424
+ err: error
425
+ });
426
+ const errMsg = error instanceof Error ? error.message : 'Unknown write error';
427
+ // Move all succeeded to failed
428
+ for (const result of succeeded){
429
+ failed.push({
430
+ ...result,
431
+ status: 'failed',
432
+ error: `Write failed: ${errMsg}`
433
+ });
434
+ }
435
+ succeeded.length = 0;
436
+ }
437
+ } // end else (write was non-empty)
438
+ }
439
+ const isCanary = targetLocale === config.quality?.canaryLocale;
440
+ // Emit per-field events for debugging
441
+ for (const result of [
442
+ ...succeeded,
443
+ ...failed
444
+ ]){
445
+ emitEvent(config, {
446
+ type: 'translation.field',
447
+ documentId,
448
+ collection,
449
+ sourceLocale: config.sourceLocale,
450
+ targetLocales: [
451
+ targetLocale
452
+ ],
453
+ timestamp: new Date(),
454
+ fields: [
455
+ result
456
+ ],
457
+ canary: isCanary || undefined
458
+ });
459
+ }
460
+ // Random sampling: log source + translated pairs for quality spot-checks
461
+ if (config.quality?.sampling && translatedMap.size > 0) {
462
+ const { rate, onSample } = config.quality.sampling;
463
+ if (onSample && Math.random() < rate) {
464
+ for (const unit of units){
465
+ const translated = translatedMap.get(unit.id);
466
+ if (translated) {
467
+ try {
468
+ const result = onSample({
469
+ documentId,
470
+ collection,
471
+ fieldPath: unit.fieldPath,
472
+ sourceLocale: config.sourceLocale,
473
+ targetLocale,
474
+ sourceText: unit.text,
475
+ translatedText: translated,
476
+ model: 'unknown',
477
+ timestamp: new Date()
478
+ });
479
+ if (result && typeof result.catch === 'function') {
480
+ result.catch(()=>{});
481
+ }
482
+ } catch {
483
+ // Sampling errors never break the main flow
484
+ }
485
+ }
486
+ }
487
+ }
488
+ }
489
+ return {
490
+ succeeded,
491
+ preserved,
492
+ failed,
493
+ usage: totalUsage,
494
+ providerLatencyMs
495
+ };
496
+ }
497
+ export async function translateBatch(params) {
498
+ const { batch, config, collection, targetLocale, signal, retryConfig, payload } = params;
499
+ const translations = new Map();
500
+ const errors = new Map();
501
+ const errorCodes = new Map();
502
+ const skipped = new Map();
503
+ const skippedCodes = new Map();
504
+ const usage = {
505
+ inputTokens: 0,
506
+ outputTokens: 0,
507
+ estimatedCostUsd: 0
508
+ };
509
+ let latencyMs = 0;
510
+ // Enforce per-call character limit
511
+ const batchChars = batch.reduce((sum, u)=>sum + u.text.length, 0);
512
+ try {
513
+ checkPerCallLimit(batchChars, config.costLimits.perCallCharLimit);
514
+ } catch {
515
+ // Oversized batch — mark all items as failed
516
+ for (const unit of batch){
517
+ errors.set(unit.id, `Batch exceeds per-call character limit (${batchChars} > ${config.costLimits.perCallCharLimit})`);
518
+ errorCodes.set(unit.id, 'cost-guard.per-call');
519
+ }
520
+ return {
521
+ translations,
522
+ errors,
523
+ errorCodes,
524
+ skipped,
525
+ skippedCodes,
526
+ usage,
527
+ latencyMs
528
+ };
529
+ }
530
+ // BUG-30 fix: dedup identical (text, kind) units before sending to the
531
+ // provider. Real-world content (CTAs, taglines, section headers, repeated
532
+ // disclaimers) often repeats the same source string across multiple
533
+ // fields. Pre-fix, every duplicate was a separate provider call → wasted
534
+ // tokens. Now we send each unique (text, kind) once, then fan the response
535
+ // back to every original unit id.
536
+ //
537
+ // Why key on (text, kind): the kind ('plain' vs 'inline' vs 'block') changes
538
+ // how the provider treats the source (markup-aware vs not). Don't dedup
539
+ // across kinds — a 'plain' "Hello" and an 'inline' "<b>Hello</b>" are
540
+ // different units even if the raw text matches after tag-stripping.
541
+ const dedupKey = (u)=>`${u.kind}\u0000${u.text}`;
542
+ const representativeByKey = new Map(); // dedupKey -> representative unit.id
543
+ const aliasMap = new Map(); // alias unit.id -> representative unit.id
544
+ const items = [];
545
+ for (const unit of batch){
546
+ const key = dedupKey(unit);
547
+ const existing = representativeByKey.get(key);
548
+ if (existing) {
549
+ aliasMap.set(unit.id, existing);
550
+ continue;
551
+ }
552
+ representativeByKey.set(key, unit.id);
553
+ items.push({
554
+ id: unit.id,
555
+ text: unit.text,
556
+ kind: unit.kind
557
+ });
558
+ }
559
+ const request = {
560
+ items,
561
+ sourceLocale: config.sourceLocale,
562
+ targetLocale,
563
+ context: {
564
+ collectionSlug: collection,
565
+ fieldPath: batch[0]?.fieldPath ?? ''
566
+ },
567
+ signal
568
+ };
569
+ let attempts = 0;
570
+ let lastError;
571
+ while(attempts <= retryConfig.attempts){
572
+ try {
573
+ // Rate limit before calling provider
574
+ if (config._rateLimiter) {
575
+ await config._rateLimiter.acquire();
576
+ }
577
+ const response = await config.provider.translate(request);
578
+ usage.inputTokens += response.usage.inputTokens;
579
+ usage.outputTokens += response.usage.outputTokens;
580
+ usage.estimatedCostUsd = (usage.estimatedCostUsd ?? 0) + (response.usage.estimatedCostUsd ?? 0);
581
+ // Capture the model on first response. Provider can switch on retry
582
+ // (rare), but the analytics value is "what billed the work" — first
583
+ // value is canonical for this batch.
584
+ usage.model = usage.model ?? response.model;
585
+ latencyMs = response.latencyMs;
586
+ // Handle item count mismatches
587
+ const responseItems = response.items.length > items.length ? response.items.slice(0, items.length) // trim extras
588
+ : response.items;
589
+ if (responseItems.length < items.length && attempts < retryConfig.attempts) {
590
+ // Retry — provider returned fewer items
591
+ attempts++;
592
+ lastError = `Provider returned ${responseItems.length} items, expected ${items.length}`;
593
+ await sleep(retryConfig.backoffMs * attempts);
594
+ continue;
595
+ }
596
+ // Validate each translated item
597
+ const retryUnits = [];
598
+ for (const item of responseItems){
599
+ const sourceUnit = batch.find((u)=>u.id === item.id);
600
+ if (!sourceUnit) {
601
+ continue;
602
+ }
603
+ const validation = validateTranslation(sourceUnit.text, item.text, config.quality?.validation, {
604
+ sourceLocale: config.sourceLocale,
605
+ targetLocale
606
+ });
607
+ if (validation.valid) {
608
+ translations.set(item.id, item.text);
609
+ } else if (validation.soft) {
610
+ // Soft fail (verbatim echo) — don't retry, don't error. Surface
611
+ // as `skipped` to the caller so the UI shows it as info-level.
612
+ skipped.set(item.id, validation.reason ?? 'Skipped');
613
+ if (validation.code) skippedCodes.set(item.id, validation.code);
614
+ } else if (attempts < retryConfig.attempts) {
615
+ retryUnits.push(sourceUnit);
616
+ } else {
617
+ errors.set(item.id, validation.reason ?? 'Validation failed');
618
+ // Hard validation failure after retries — fold into the soft-skip
619
+ // code family. UI treats both as "AI output couldn't be saved",
620
+ // varying only in whether retries were attempted.
621
+ if (validation.code) errorCodes.set(item.id, validation.code);
622
+ }
623
+ }
624
+ // Mark items not in response as errors.
625
+ //
626
+ // The `!skipped.has(item.id)` guard is load-bearing (1.2.8): when a
627
+ // batch contains a mix of soft-skip (echo / validator soft fail)
628
+ // and hard-fail items, a retry sends only the hard-fails — the
629
+ // soft-skipped ids are absent from the next response by design.
630
+ // Without the guard, this loop saw the missing soft-skipped id
631
+ // and either re-pushed it to retryUnits (wasting a retry on a
632
+ // unit we'd already decided to skip) or, on retry-exhaustion,
633
+ // promoted it to `errors` while ALSO leaving it in `skipped`.
634
+ // The downstream effects were both visible in the Hub:
635
+ // - The `persistent-failure` alert (line 959 — fires when
636
+ // `errors.size > 0`) counted those soft-skipped ids as
637
+ // hard failures, producing red "Translation failed
638
+ // repeatedly · N fields couldn't be translated to <locale>"
639
+ // banners.
640
+ // - api.ts:910-940 (translateGlobalForLocale) routes via
641
+ // `skipped.has(unit.id)` BEFORE `errors.has(unit.id)`, so the
642
+ // same units landed in `failed[]` with status:'skipped' →
643
+ // `realFailed.length === 0` → `failedCount === 0` on the
644
+ // usage row → `deriveStatus` returned `'needs-review'` (yellow)
645
+ // per `UsageTable.helpers.ts:111-113`.
646
+ // Net: a red "failed repeatedly" alert above the same row that
647
+ // showed yellow "needs review", for the same fields, from the
648
+ // same run. Excluding already-skipped ids from this loop is the
649
+ // minimal correct fix: skip status is final, don't churn it.
650
+ for (const item of items){
651
+ if (!translations.has(item.id) && !skipped.has(item.id) && !errors.has(item.id) && !retryUnits.find((u)=>u.id === item.id)) {
652
+ if (attempts < retryConfig.attempts) {
653
+ const unit = batch.find((u)=>u.id === item.id);
654
+ if (unit) retryUnits.push(unit);
655
+ } else {
656
+ errors.set(item.id, lastError ?? 'No translation returned');
657
+ // Missing-response items fall through to provider error
658
+ // bucket — UI shows generic "Translation service" copy.
659
+ errorCodes.set(item.id, 'bulk.transient.provider');
660
+ }
661
+ }
662
+ }
663
+ if (retryUnits.length === 0) {
664
+ break; // All items handled
665
+ }
666
+ // Retry with the failed units
667
+ attempts++;
668
+ request.items = retryUnits.map((u)=>({
669
+ id: u.id,
670
+ text: u.text,
671
+ kind: u.kind
672
+ }));
673
+ await sleep(retryConfig.backoffMs * attempts);
674
+ } catch (error) {
675
+ lastError = error instanceof Error ? error.message : 'Provider error';
676
+ const batchLog = payload ? createScopedLogger(payload, {
677
+ component: 'translation.batch',
678
+ collection,
679
+ locale: targetLocale
680
+ }) : undefined;
681
+ if (attempts < retryConfig.attempts) {
682
+ batchLog?.event('warn', 'translation.batch.retry', {
683
+ err: error,
684
+ attempt: attempts + 1,
685
+ maxAttempts: retryConfig.attempts,
686
+ batchSize: items.length,
687
+ locale: targetLocale
688
+ });
689
+ attempts++;
690
+ await sleep(retryConfig.backoffMs * attempts);
691
+ } else {
692
+ batchLog?.event('error', 'translation.batch.exhausted', {
693
+ err: error,
694
+ attempts,
695
+ batchSize: items.length,
696
+ locale: targetLocale
697
+ });
698
+ // All retries exhausted — classify the provider error so the UI
699
+ // shows the right friendly message (rate-limited vs config vs
700
+ // schema vs generic transient).
701
+ const exhaustedCode = classifyProviderErrorToEditorCode(error);
702
+ for (const item of items){
703
+ if (!translations.has(item.id)) {
704
+ errors.set(item.id, lastError);
705
+ errorCodes.set(item.id, exhaustedCode);
706
+ }
707
+ }
708
+ break;
709
+ }
710
+ }
711
+ }
712
+ // BUG-30 fan-out: replicate each representative's result to every alias
713
+ // id so downstream patching sees a complete translation map.
714
+ for (const [aliasId, representativeId] of aliasMap.entries()){
715
+ if (translations.has(representativeId)) {
716
+ translations.set(aliasId, translations.get(representativeId));
717
+ } else if (skipped.has(representativeId)) {
718
+ skipped.set(aliasId, skipped.get(representativeId));
719
+ const code = skippedCodes.get(representativeId);
720
+ if (code) skippedCodes.set(aliasId, code);
721
+ } else if (errors.has(representativeId)) {
722
+ errors.set(aliasId, errors.get(representativeId));
723
+ const code = errorCodes.get(representativeId);
724
+ if (code) errorCodes.set(aliasId, code);
725
+ }
726
+ }
727
+ // Fire alert if too many persistent failures
728
+ if (errors.size > 0) {
729
+ emitAlert(config, {
730
+ type: 'translation.persistent-failure',
731
+ code: 'alert.persistent-failure',
732
+ context: {
733
+ count: errors.size,
734
+ locale: targetLocale,
735
+ maxAttempts: retryConfig.attempts
736
+ },
737
+ message: `${errors.size} translation(s) failed after ${retryConfig.attempts} attempt${retryConfig.attempts === 1 ? '' : 's'} for locale "${targetLocale}"`,
738
+ collection,
739
+ timestamp: new Date(),
740
+ metadata: {
741
+ errorCount: errors.size,
742
+ locale: targetLocale
743
+ }
744
+ });
745
+ }
746
+ return {
747
+ translations,
748
+ errors,
749
+ errorCodes,
750
+ skipped,
751
+ skippedCodes,
752
+ usage,
753
+ latencyMs
754
+ };
755
+ }
756
+ /**
757
+ * Map a provider exception (network error, AI SDK throw, etc.) to an
758
+ * editor-facing code. Stays in sync with bulk-translate-doc-task's
759
+ * `classifyError` heuristics so the same provider failure surfaces
760
+ * the same friendly copy whether it's hit in a single-doc translate
761
+ * or a bulk worker.
762
+ */ function classifyProviderErrorToEditorCode(err) {
763
+ if (err == null) return 'bulk.unknown';
764
+ const message = err instanceof Error ? err.message : String(err);
765
+ const name = err instanceof Error ? err.name : '';
766
+ const codeProp = err?.code;
767
+ const codeStr = typeof codeProp === 'string' ? codeProp : '';
768
+ if (codeStr === 'PER_DOC_CEILING' || codeStr === 'PER_CALL_LIMIT') {
769
+ return 'bulk.permanent.too_large';
770
+ }
771
+ if (name === 'NoObjectGeneratedError' || /noobjectgeneratederror/i.test(message)) {
772
+ return 'bulk.permanent.schema';
773
+ }
774
+ if (/\b401\b|\b403\b|unauthorized|forbidden|api[_-]?key/i.test(message)) {
775
+ return 'bulk.permanent.config';
776
+ }
777
+ if (/\b429\b|rate[_-]?limit/i.test(message)) {
778
+ return 'bulk.transient.rate_limited';
779
+ }
780
+ return 'bulk.transient.provider';
781
+ }
782
+ // ---------------------------------------------------------------------------
783
+ // Preserve policy: filter out non-empty target fields
784
+ // ---------------------------------------------------------------------------
785
+ async function filterPreservedFields(payload, collection, documentId, targetLocale, units, fields) {
786
+ try {
787
+ const targetDoc = await findByIdNoFallback(payload, collection, documentId, targetLocale);
788
+ return units.filter((unit)=>{
789
+ // Find the matching field for this unit
790
+ const field = fields.find((f)=>unit.fieldPath === f.path || unit.fieldPath.startsWith(`${f.path}.`));
791
+ if (!field) return true; // Can't determine — keep it
792
+ const targetValue = resolvePathValue(targetDoc, field.path);
793
+ return isFieldEmpty(targetValue, field.type);
794
+ });
795
+ } catch {
796
+ // If we can't read the target doc, proceed with all units
797
+ return units;
798
+ }
799
+ }
800
+ function resolvePathValue(obj, path) {
801
+ const segments = path.split('.');
802
+ let current = obj;
803
+ for (const segment of segments){
804
+ if (current == null || typeof current !== 'object') return undefined;
805
+ current = current[segment];
806
+ }
807
+ return current;
808
+ }
809
+ /**
810
+ * Returns `true` when any localized top-level array field shape in
811
+ * `sourceDoc` diverges from `targetDoc`. Used by the BUG-28 fix to
812
+ * decide whether to force a structural-sync write even when no text
813
+ * units changed (block reorder / insert / delete with unchanged text).
814
+ *
815
+ * Detection is shallow at the top level — `alignBaseShapeToSource` in
816
+ * `locale-merge.ts` handles nested-array drift once we decide to write.
817
+ * The check covers:
818
+ * - length mismatch (insert / delete)
819
+ * - per-index `blockType` mismatch (reorder of different block types)
820
+ * - per-index nested-array length mismatch (e.g. block's `columns` array changed)
821
+ */ function hasStructuralDrift(sourceDoc, targetDoc, fields) {
822
+ if (!targetDoc) return false; // Fresh target → first-write path covers it
823
+ const topArrayFields = new Set();
824
+ for (const f of fields){
825
+ if (!f.localized) continue;
826
+ const top = f.path.split('.')[0];
827
+ if (!top) continue;
828
+ if (Array.isArray(sourceDoc[top])) {
829
+ topArrayFields.add(top);
830
+ }
831
+ }
832
+ for (const top of topArrayFields){
833
+ const src = sourceDoc[top];
834
+ const tgt = targetDoc[top];
835
+ if (!Array.isArray(src) || !Array.isArray(tgt)) continue;
836
+ if (src.length !== tgt.length) return true;
837
+ for(let i = 0; i < src.length; i++){
838
+ const s = src[i];
839
+ const t = tgt[i];
840
+ if (typeof s === 'object' && s !== null && typeof t === 'object' && t !== null) {
841
+ const sb = s.blockType;
842
+ const tb = t.blockType;
843
+ if (sb !== undefined && tb !== undefined && sb !== tb) return true;
844
+ } else if (typeof s !== typeof t || Array.isArray(s) !== Array.isArray(t)) {
845
+ return true;
846
+ }
847
+ }
848
+ }
849
+ return false;
850
+ }
851
+ // ---------------------------------------------------------------------------
852
+ // Batching
853
+ // ---------------------------------------------------------------------------
854
+ /**
855
+ * Default items-per-batch cap. Independent of `perCallCharLimit`. Exists
856
+ * because some structured-output upstreams (Gemini 2.5 Flash via OpenRouter
857
+ * is the canonical case, surfaced 2026-06-04 on wild-payload-cms) silently
858
+ * stall on response generation when the response items array grows past
859
+ * a few hundred entries — HTTP 200 + headers arrive in <2s, then the body
860
+ * never finishes streaming and the per-call timeout aborts the whole
861
+ * batch. The char cap doesn't catch this because short strings (nav
862
+ * labels, JSON keys) pack many items into modest byte counts.
863
+ *
864
+ * 250 is chosen empirically: a `loyalty` JSON sub-object on
865
+ * wild-payload-cms (286 short keys, ~25k chars) completed in ~30s and
866
+ * 100% success; a 500-item batch on the same provider timed out roughly
867
+ * 3 of every 4 attempts. 250 puts us under the observed stall threshold
868
+ * with margin. Consumers running larger-context models (Claude Sonnet,
869
+ * GPT-4o) can override with a higher value via `costLimits.perCallItemLimit`.
870
+ */ export const DEFAULT_PER_CALL_ITEM_LIMIT = 250;
871
+ export function batchUnits(units, perCallCharLimit, perCallItemLimit = DEFAULT_PER_CALL_ITEM_LIMIT) {
872
+ const batches = [];
873
+ let currentBatch = [];
874
+ let currentChars = 0;
875
+ for (const unit of units){
876
+ const unitChars = unit.text.length;
877
+ // Single unit exceeds char limit — send it solo
878
+ if (unitChars > perCallCharLimit) {
879
+ if (currentBatch.length > 0) {
880
+ batches.push(currentBatch);
881
+ currentBatch = [];
882
+ currentChars = 0;
883
+ }
884
+ batches.push([
885
+ unit
886
+ ]);
887
+ console.warn(`[ai-translate] Single translation unit exceeds perCallCharLimit (${unitChars} > ${perCallCharLimit}). Sending solo.`);
888
+ continue;
889
+ }
890
+ // Would exceed either cap — close the current batch and start a new one
891
+ const wouldExceedChars = currentChars + unitChars > perCallCharLimit;
892
+ const wouldExceedItems = currentBatch.length >= perCallItemLimit;
893
+ if (wouldExceedChars || wouldExceedItems) {
894
+ batches.push(currentBatch);
895
+ currentBatch = [];
896
+ currentChars = 0;
897
+ }
898
+ currentBatch.push(unit);
899
+ currentChars += unitChars;
900
+ }
901
+ if (currentBatch.length > 0) {
902
+ batches.push(currentBatch);
903
+ }
904
+ return batches;
905
+ }
906
+ // ---------------------------------------------------------------------------
907
+ // Helpers
908
+ // ---------------------------------------------------------------------------
909
+ function sleep(ms) {
910
+ return new Promise((resolve)=>setTimeout(resolve, ms));
911
+ }