@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,90 @@
1
+ /**
2
+ * Per-document serialization via atomic unit claim.
3
+ *
4
+ * Replaces the earlier `tryAcquirePerDocLock` (advisory-lock + polling)
5
+ * which held one Postgres pool connection per acquired lock for the
6
+ * duration of the worker's translate + write — that starved the
7
+ * connection pool (e.g. wild-payload-cms's default pool of 10 with
8
+ * 10 docs in a bulk run → 10 held connections → 0 free → admin UI
9
+ * + status endpoints stall on the same pool).
10
+ *
11
+ * New design: one atomic SQL UPDATE that conditionally transitions
12
+ * `pending → running` IFF no sibling unit for the same `(collection,
13
+ * documentId)` is already `running`. Winners proceed with no held
14
+ * lock and no held connection. Losers see `0 rowsAffected` → caller
15
+ * re-enqueues this unit's Payload job so a later cron tick retries.
16
+ *
17
+ * Trade-off: re-enqueued workers wait until the next `autoRun` tick
18
+ * to retry (60s on wild-payload-cms). For N locales of one doc, that
19
+ * means worst-case ~N minutes of cron ticks to fully serialize. Slower
20
+ * than blocking-poll but doesn't starve the pool — and "slow" here is
21
+ * still infinitely faster than the previous failure mode (pool stalls
22
+ * the entire admin).
23
+ *
24
+ * Postgres-only — falls through to a "claimed" no-op on non-Postgres
25
+ * adapters (mongo doesn't have the same upsertRow id-collision race).
26
+ */
27
+ import type { CollectionSlug, Payload } from 'payload';
28
+ export type ClaimResult = {
29
+ claimed: true;
30
+ attempts: number;
31
+ } | {
32
+ claimed: false;
33
+ reason: 'sibling_running' | 'not_pending' | 'noop';
34
+ };
35
+ /**
36
+ * Atomic claim. Runs:
37
+ *
38
+ * UPDATE <units_table>
39
+ * SET status='running', started_at=now(), attempts=attempts+1
40
+ * WHERE id=$1 AND status='pending'
41
+ * AND NOT EXISTS (
42
+ * SELECT 1 FROM <units_table>
43
+ * WHERE collection=$2 AND document_id=$3
44
+ * AND status='running' AND id<>$1
45
+ * )
46
+ * RETURNING attempts;
47
+ *
48
+ * Returns the bumped `attempts` on success so the worker can echo it
49
+ * into logs without a second read. `0 rows` → either the unit was
50
+ * already non-pending (terminal/running) or a sibling is running →
51
+ * caller defers + re-enqueues.
52
+ *
53
+ * Falls back to `claimed: false / reason: 'noop'` on non-Postgres so
54
+ * non-Postgres callers handle the same return shape. (Production
55
+ * non-Postgres path doesn't need this race protection anyway.)
56
+ */
57
+ export declare function tryClaimUnitForDoc(args: {
58
+ payload: Payload;
59
+ unitsCollectionSlug: string;
60
+ unitId: string | number;
61
+ collection: string;
62
+ documentId: string | number;
63
+ }): Promise<ClaimResult>;
64
+ /**
65
+ * Convenience wrapper that matches the legacy `tryAcquirePerDocLock`
66
+ * signature so the worker can drop in without much restructure. Returns
67
+ * a `release` no-op because the claim's "release" is the worker's
68
+ * subsequent status update (success/failed/skipped) — which is what
69
+ * lets the next sibling claim.
70
+ */
71
+ export type LegacyLockResult = {
72
+ acquired: true;
73
+ release: () => Promise<void>;
74
+ attempts: number;
75
+ } | {
76
+ acquired: false;
77
+ release?: undefined;
78
+ } | {
79
+ acquired: 'noop';
80
+ release: () => Promise<void>;
81
+ };
82
+ export declare function tryClaimAsLock(args: {
83
+ payload: Payload;
84
+ unitsCollectionSlug: string;
85
+ unitId: string | number;
86
+ collection: string;
87
+ documentId: string | number;
88
+ }): Promise<LegacyLockResult>;
89
+ export declare const DEFAULT_UNITS_COLLECTION_SLUG: "bulk-translate-units";
90
+ export type _UnitsCollectionSlugType = CollectionSlug;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Per-document serialization via atomic unit claim.
3
+ *
4
+ * Replaces the earlier `tryAcquirePerDocLock` (advisory-lock + polling)
5
+ * which held one Postgres pool connection per acquired lock for the
6
+ * duration of the worker's translate + write — that starved the
7
+ * connection pool (e.g. wild-payload-cms's default pool of 10 with
8
+ * 10 docs in a bulk run → 10 held connections → 0 free → admin UI
9
+ * + status endpoints stall on the same pool).
10
+ *
11
+ * New design: one atomic SQL UPDATE that conditionally transitions
12
+ * `pending → running` IFF no sibling unit for the same `(collection,
13
+ * documentId)` is already `running`. Winners proceed with no held
14
+ * lock and no held connection. Losers see `0 rowsAffected` → caller
15
+ * re-enqueues this unit's Payload job so a later cron tick retries.
16
+ *
17
+ * Trade-off: re-enqueued workers wait until the next `autoRun` tick
18
+ * to retry (60s on wild-payload-cms). For N locales of one doc, that
19
+ * means worst-case ~N minutes of cron ticks to fully serialize. Slower
20
+ * than blocking-poll but doesn't starve the pool — and "slow" here is
21
+ * still infinitely faster than the previous failure mode (pool stalls
22
+ * the entire admin).
23
+ *
24
+ * Postgres-only — falls through to a "claimed" no-op on non-Postgres
25
+ * adapters (mongo doesn't have the same upsertRow id-collision race).
26
+ */ /**
27
+ * Atomic claim. Runs:
28
+ *
29
+ * UPDATE <units_table>
30
+ * SET status='running', started_at=now(), attempts=attempts+1
31
+ * WHERE id=$1 AND status='pending'
32
+ * AND NOT EXISTS (
33
+ * SELECT 1 FROM <units_table>
34
+ * WHERE collection=$2 AND document_id=$3
35
+ * AND status='running' AND id<>$1
36
+ * )
37
+ * RETURNING attempts;
38
+ *
39
+ * Returns the bumped `attempts` on success so the worker can echo it
40
+ * into logs without a second read. `0 rows` → either the unit was
41
+ * already non-pending (terminal/running) or a sibling is running →
42
+ * caller defers + re-enqueues.
43
+ *
44
+ * Falls back to `claimed: false / reason: 'noop'` on non-Postgres so
45
+ * non-Postgres callers handle the same return shape. (Production
46
+ * non-Postgres path doesn't need this race protection anyway.)
47
+ */ export async function tryClaimUnitForDoc(args) {
48
+ const { payload, unitsCollectionSlug, unitId, collection, documentId } = args;
49
+ const dbHandle = payload.db;
50
+ const pool = dbHandle?.pool;
51
+ if (!pool || typeof pool.connect !== 'function') {
52
+ // Non-Postgres or no pool — assume the platform doesn't have the
53
+ // id-collision race and return a successful "noop claim". Worker
54
+ // proceeds with the legacy mark-running path itself.
55
+ return {
56
+ claimed: false,
57
+ reason: 'noop'
58
+ };
59
+ }
60
+ // Sanitize the table name (slug → table). Slug uses `-`; Postgres
61
+ // table uses `_`. Single underscore-only alphanumeric guard against
62
+ // any callsite ever passing something weirder.
63
+ const tableBase = unitsCollectionSlug.replace(/-/g, '_');
64
+ if (!/^[a-zA-Z0-9_]+$/.test(tableBase)) {
65
+ return {
66
+ claimed: false,
67
+ reason: 'noop'
68
+ };
69
+ }
70
+ const client = await pool.connect();
71
+ try {
72
+ const res = await client.query(`UPDATE "${tableBase}"
73
+ SET "status" = 'running',
74
+ "started_at" = now(),
75
+ "attempts" = COALESCE("attempts", 0) + 1
76
+ WHERE "id" = $1
77
+ AND "status" = 'pending'
78
+ AND NOT EXISTS (
79
+ SELECT 1 FROM "${tableBase}"
80
+ WHERE "collection" = $2
81
+ AND "document_id" = $3
82
+ AND "status" = 'running'
83
+ AND "id" <> $1
84
+ )
85
+ RETURNING "attempts"`, [
86
+ unitId,
87
+ collection,
88
+ String(documentId)
89
+ ]);
90
+ if ((res.rowCount ?? res.rows.length) > 0) {
91
+ const row = res.rows[0];
92
+ const attempts = Number(row?.attempts ?? 0);
93
+ return {
94
+ claimed: true,
95
+ attempts
96
+ };
97
+ }
98
+ // Couldn't claim. Disambiguate: is the unit still pending (sibling
99
+ // running) or was it already non-pending? Helps logs.
100
+ const statusRes = await client.query(`SELECT "status" FROM "${tableBase}" WHERE "id" = $1`, [
101
+ unitId
102
+ ]);
103
+ const status = statusRes.rows[0]?.status;
104
+ if (status && status !== 'pending') {
105
+ return {
106
+ claimed: false,
107
+ reason: 'not_pending'
108
+ };
109
+ }
110
+ return {
111
+ claimed: false,
112
+ reason: 'sibling_running'
113
+ };
114
+ } finally{
115
+ client.release();
116
+ }
117
+ }
118
+ export async function tryClaimAsLock(args) {
119
+ const r = await tryClaimUnitForDoc(args);
120
+ if (r.claimed) {
121
+ return {
122
+ acquired: true,
123
+ release: async ()=>undefined,
124
+ attempts: r.attempts
125
+ };
126
+ }
127
+ if (r.reason === 'noop') {
128
+ return {
129
+ acquired: 'noop',
130
+ release: async ()=>undefined
131
+ };
132
+ }
133
+ return {
134
+ acquired: false
135
+ };
136
+ }
137
+ // Re-export the units collection slug used by callers (kept here so the
138
+ // caller doesn't need to thread the literal through). Match Payload's
139
+ // default — the consumer can override via plugin config.
140
+ export const DEFAULT_UNITS_COLLECTION_SLUG = 'bulk-translate-units';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Per-document serialization for bulk-translate workers.
3
+ *
4
+ * Why this exists: bulk-translate dispatches one Payload job per
5
+ * `(collection, documentId, locale)` unit. With Payload's `autoRun.limit`
6
+ * configured for throughput (e.g. 100/min on wild-payload-cms), workers
7
+ * pick up multiple units for the SAME document in parallel — each
8
+ * locale gets its own concurrent worker. Their per-locale `payload.update`
9
+ * calls then collide on shared junction tables (block-id sequences,
10
+ * richText fragment ids, array junction rows) producing the cryptic
11
+ * "Value must be unique: id" upsertRow error reproduced in batch #3 of
12
+ * the 2026-06-04 debug session. Plugin's `concurrency.perDocument: 1`
13
+ * config only gates the IN-PROCESS `translateDocument()` API and does
14
+ * NOT cover the bulk-translate worker pool.
15
+ *
16
+ * The fix: a per-document Postgres advisory lock. Each worker tries to
17
+ * acquire `pg_try_advisory_lock(hash(collection || '|' || documentId))`
18
+ * before processing. The first worker wins; siblings poll briefly,
19
+ * then either acquire (lock holder finished) or defer (timeout — the
20
+ * caller re-enqueues the unit so a later cron tick picks it up).
21
+ *
22
+ * Postgres-only — Payload's other adapter (mongodb) wouldn't hit the
23
+ * same upsert race, so a no-op fallback is correct there. We reach
24
+ * into `payload.db.pool` (`pg.Pool`) directly rather than via the
25
+ * drizzle handle so we don't need to dynamic-import `drizzle-orm`
26
+ * (which isn't hoisted in pnpm workspaces and resolves null from the
27
+ * plugin's own bundle).
28
+ */
29
+ import type { Payload } from 'payload';
30
+ /**
31
+ * Minimal `pg.PoolClient` shape we need. Holding the client between
32
+ * lock acquire and release guarantees the unlock runs on the same
33
+ * connection — `pg_advisory_lock` is session-scoped, not pool-scoped,
34
+ * so a release on a different connection is a no-op.
35
+ */
36
+ type PoolClient = {
37
+ query: (text: string, values?: unknown[]) => Promise<{
38
+ rows: Array<Record<string, unknown>>;
39
+ }>;
40
+ release: () => void;
41
+ };
42
+ type PgPool = {
43
+ connect: () => Promise<PoolClient>;
44
+ };
45
+ /**
46
+ * Result of an attempt to acquire the per-doc lock.
47
+ * - `acquired: true` — caller proceeds, MUST call `release()` in a
48
+ * finally block to release both the lock AND the dedicated pool
49
+ * connection.
50
+ * - `acquired: false` — timeout reached. Caller should defer (e.g.
51
+ * re-enqueue this unit so a future tick picks it up).
52
+ * - `acquired: 'noop'` — DB adapter doesn't expose `pool` (e.g.
53
+ * mongo). The race this lock prevents is Postgres-specific anyway;
54
+ * callers should proceed as if acquired.
55
+ */
56
+ export type LockResult = {
57
+ acquired: true;
58
+ release: () => Promise<void>;
59
+ } | {
60
+ acquired: false;
61
+ release?: undefined;
62
+ } | {
63
+ acquired: 'noop';
64
+ release: () => Promise<void>;
65
+ };
66
+ export declare function tryAcquirePerDocLock(args: {
67
+ payload: Payload;
68
+ collection: string;
69
+ documentId: string | number;
70
+ /** Override the default 120s polling deadline. */
71
+ timeoutMs?: number;
72
+ /** Test seam: inject a fake sleep so tests don't wait for real time. */
73
+ sleep?: (ms: number) => Promise<void>;
74
+ /**
75
+ * Test seam: inject a fake pool. Production resolves `payload.db.pool`
76
+ * (the `pg.Pool` exposed by `@payloadcms/db-postgres`).
77
+ */
78
+ pool?: PgPool;
79
+ }): Promise<LockResult>;
80
+ /**
81
+ * Map (collection, documentId) → stable signed 64-bit integer Postgres
82
+ * accepts as `bigint`. Combines two 32-bit FNV-1a hashes into one
83
+ * positive 53-bit-safe number so the value fits in a JS Number without
84
+ * precision loss AND lands inside `bigint` range.
85
+ *
86
+ * Hash collisions: with N distinct (coll, doc) tuples and 53-bit space,
87
+ * the birthday-paradox collision probability stays under 1% until
88
+ * N ≈ 2^26.5 (≈100M). Realistic upper bound for a single bulk-translate
89
+ * is ~10k docs — collision risk is negligible. If two different docs
90
+ * DID hash-collide, they'd serialize against each other (perf cost,
91
+ * not correctness).
92
+ */
93
+ export declare function lockKeyFor(collection: string, documentId: string | number): string;
94
+ export {};
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Per-document serialization for bulk-translate workers.
3
+ *
4
+ * Why this exists: bulk-translate dispatches one Payload job per
5
+ * `(collection, documentId, locale)` unit. With Payload's `autoRun.limit`
6
+ * configured for throughput (e.g. 100/min on wild-payload-cms), workers
7
+ * pick up multiple units for the SAME document in parallel — each
8
+ * locale gets its own concurrent worker. Their per-locale `payload.update`
9
+ * calls then collide on shared junction tables (block-id sequences,
10
+ * richText fragment ids, array junction rows) producing the cryptic
11
+ * "Value must be unique: id" upsertRow error reproduced in batch #3 of
12
+ * the 2026-06-04 debug session. Plugin's `concurrency.perDocument: 1`
13
+ * config only gates the IN-PROCESS `translateDocument()` API and does
14
+ * NOT cover the bulk-translate worker pool.
15
+ *
16
+ * The fix: a per-document Postgres advisory lock. Each worker tries to
17
+ * acquire `pg_try_advisory_lock(hash(collection || '|' || documentId))`
18
+ * before processing. The first worker wins; siblings poll briefly,
19
+ * then either acquire (lock holder finished) or defer (timeout — the
20
+ * caller re-enqueues the unit so a later cron tick picks it up).
21
+ *
22
+ * Postgres-only — Payload's other adapter (mongodb) wouldn't hit the
23
+ * same upsert race, so a no-op fallback is correct there. We reach
24
+ * into `payload.db.pool` (`pg.Pool`) directly rather than via the
25
+ * drizzle handle so we don't need to dynamic-import `drizzle-orm`
26
+ * (which isn't hoisted in pnpm workspaces and resolves null from the
27
+ * plugin's own bundle).
28
+ */ const POLL_INTERVAL_MS = 1500;
29
+ const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes worst-case wait per locale
30
+ export async function tryAcquirePerDocLock(args) {
31
+ const { payload, collection, documentId, timeoutMs = DEFAULT_TIMEOUT_MS, sleep = defaultSleep, pool: poolOverride } = args;
32
+ const dbHandle = payload.db;
33
+ const pool = poolOverride ?? dbHandle?.pool;
34
+ if (!pool || typeof pool.connect !== 'function') {
35
+ // Non-Postgres adapter — return a noop "lock" that does nothing.
36
+ // The race this guards against is specific to Payload's Postgres
37
+ // upsertRow implementation; mongo callers don't need protection.
38
+ return {
39
+ acquired: 'noop',
40
+ release: async ()=>undefined
41
+ };
42
+ }
43
+ const key = lockKeyFor(collection, documentId);
44
+ const deadline = Date.now() + timeoutMs;
45
+ while(true){
46
+ const client = await pool.connect();
47
+ let acquired = false;
48
+ try {
49
+ const r = await client.query('SELECT pg_try_advisory_lock($1::bigint) AS got', [
50
+ key
51
+ ]);
52
+ acquired = Boolean(r.rows[0]?.got);
53
+ if (acquired) {
54
+ // Caller holds this client for the duration of their work; the
55
+ // release closure below issues pg_advisory_unlock on the SAME
56
+ // client and then returns it to the pool.
57
+ return {
58
+ acquired: true,
59
+ release: async ()=>{
60
+ try {
61
+ await client.query('SELECT pg_advisory_unlock($1::bigint)', [
62
+ key
63
+ ]);
64
+ } catch (err) {
65
+ payload.logger?.warn?.(`[ai-translate] per-doc lock release failed for ${collection}/${String(documentId)}: ${String(err)}`);
66
+ } finally{
67
+ client.release();
68
+ }
69
+ }
70
+ };
71
+ }
72
+ } finally{
73
+ if (!acquired) {
74
+ client.release();
75
+ }
76
+ }
77
+ if (Date.now() >= deadline) {
78
+ return {
79
+ acquired: false
80
+ };
81
+ }
82
+ await sleep(POLL_INTERVAL_MS);
83
+ }
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Lock-key derivation
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * Map (collection, documentId) → stable signed 64-bit integer Postgres
90
+ * accepts as `bigint`. Combines two 32-bit FNV-1a hashes into one
91
+ * positive 53-bit-safe number so the value fits in a JS Number without
92
+ * precision loss AND lands inside `bigint` range.
93
+ *
94
+ * Hash collisions: with N distinct (coll, doc) tuples and 53-bit space,
95
+ * the birthday-paradox collision probability stays under 1% until
96
+ * N ≈ 2^26.5 (≈100M). Realistic upper bound for a single bulk-translate
97
+ * is ~10k docs — collision risk is negligible. If two different docs
98
+ * DID hash-collide, they'd serialize against each other (perf cost,
99
+ * not correctness).
100
+ */ export function lockKeyFor(collection, documentId) {
101
+ const input = `${collection}|${String(documentId)}`;
102
+ const hi = fnv1a(input + '\x00hi') >>> 0;
103
+ const lo = fnv1a(input + '\x00lo') >>> 0;
104
+ const combined = BigInt(hi) << 21n ^ BigInt(lo);
105
+ // Mask to 63 bits so it always fits Postgres signed bigint.
106
+ const masked = combined & (1n << 63n) - 1n;
107
+ return masked.toString();
108
+ }
109
+ function fnv1a(str) {
110
+ let hash = 0x811c9dc5;
111
+ for(let i = 0; i < str.length; i++){
112
+ hash ^= str.charCodeAt(i);
113
+ hash = hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
114
+ }
115
+ return hash >>> 0;
116
+ }
117
+ function defaultSleep(ms) {
118
+ return new Promise((resolve)=>setTimeout(resolve, ms));
119
+ }
@@ -0,0 +1,91 @@
1
+ import type { Payload } from 'payload';
2
+ import type { AITranslatePluginConfig, FieldLocaleResult, SkippedFieldReport, TranslationUsage } from '../types.js';
3
+ export type LocaleOutcome = {
4
+ locale: string;
5
+ status: 'succeeded' | 'failed';
6
+ /** Verbatim provider error from the first failed field for this locale. */
7
+ error?: string;
8
+ /**
9
+ * Editor-facing code for the first failed field's error. The UI keys
10
+ * off this for the per-locale outcome chip in the UsageTable expanded
11
+ * row, rendering a friendly message via `editorMessageFor(code)`
12
+ * instead of dumping the raw provider error. Optional on legacy rows.
13
+ */
14
+ errorCode?: string;
15
+ /** Field paths that failed in this locale. */
16
+ failedFields?: string[];
17
+ };
18
+ export type PersistUsageInput = {
19
+ payload: Payload;
20
+ config: AITranslatePluginConfig;
21
+ kind: 'collection' | 'global';
22
+ /** Collection slug or global slug. */
23
+ slug: string;
24
+ /** For globals this equals slug. */
25
+ documentId: string | number;
26
+ jobId: string;
27
+ status: 'succeeded' | 'failed';
28
+ sourceLocale: string;
29
+ /**
30
+ * Per-locale results. Each entry records whether the locale succeeded and
31
+ * (if it failed) the provider error + which field paths failed. Drives
32
+ * the per-locale subarray on `translation-usage` rows so admins can see
33
+ * which locale broke without correlating against logs.
34
+ */
35
+ localeOutcomes: LocaleOutcome[];
36
+ /** Field × locale result counts (matches the event's fields[] length). */
37
+ succeededCount: number;
38
+ failedCount: number;
39
+ /**
40
+ * Field-locale entries marked `success`. Split into "actually called
41
+ * the LLM" vs "hash-skipped because source content was unchanged" via
42
+ * `characterCount === 0 && durationMs === 0` — translate-for-locale
43
+ * pushes hash-skipped entries with zero counts (see translate.ts:187).
44
+ * Used to populate the breakdown columns on translation-usage so the
45
+ * Hub can distinguish a real translation from a no-op pass.
46
+ */
47
+ succeeded: FieldLocaleResult[];
48
+ /**
49
+ * Field-locale entries the manual-edit guard kept out of the write.
50
+ * NOT in `event.fields[]` (which is succeeded + failed only) — the
51
+ * caller threads these in directly from TranslateDocumentResult.
52
+ */
53
+ preserved: FieldLocaleResult[];
54
+ /**
55
+ * Soft-skipped fields (LLM echoed source verbatim or returned invalid
56
+ * output). One row per field × locale with the validator's reason.
57
+ * Stored as a sidecar array on the usage row so editors can audit
58
+ * which fields silently stayed in source.
59
+ */
60
+ softSkippedFields: SkippedFieldReport[];
61
+ usage: TranslationUsage;
62
+ durationMs?: number;
63
+ error?: string;
64
+ };
65
+ /**
66
+ * Bucket a flat `[allSucceeded, hardFailed]` result list into per-locale
67
+ * outcomes. Used by both the collection and global persistence call sites.
68
+ *
69
+ * Only HARD failures (`status: 'failed'` — provider/schema errors) drive
70
+ * a locale to `status: 'failed'`. Soft skips (`status: 'skipped'` — echo,
71
+ * length-ratio, placeholder integrity) are filtered out defensively here:
72
+ * they're surfaced separately via `softSkippedFields` on the usage row and
73
+ * must never appear in a locale's `failedFields` list (otherwise the Hub
74
+ * renders the same field twice — once under a red "Failed fields" header
75
+ * and once under amber "Soft-skipped fields" — with a misleading
76
+ * "Something went wrong" banner for a locale that didn't actually fail).
77
+ */
78
+ export declare function bucketLocaleOutcomes(targetLocales: string[], succeeded: FieldLocaleResult[], failed: FieldLocaleResult[]): LocaleOutcome[];
79
+ /**
80
+ * Write a translation-usage row after a job completes. No-op when the
81
+ * feature is disabled. Errors from `payload.create` are caught and logged —
82
+ * usage tracking must never break a translation that already produced
83
+ * content for the user.
84
+ *
85
+ * Sets `REENTRY_FLAG` on the create context so the write does not retrigger
86
+ * the auto-translate hook (which gates on it via `hooks/after-change.ts`)
87
+ * or the audit-log cascade guard. A second flag,
88
+ * `USAGE_TRACKING_CONTEXT_FLAG`, is provided for future hooks that need to
89
+ * single-out usage writes specifically.
90
+ */
91
+ export declare function persistTranslationUsage(input: PersistUsageInput): Promise<void>;
@@ -0,0 +1,116 @@
1
+ import { DEFAULT_USAGE_COLLECTION_SLUG, REENTRY_FLAG, USAGE_TRACKING_CONTEXT_FLAG } from '../defaults.js';
2
+ /**
3
+ * Bucket a flat `[allSucceeded, hardFailed]` result list into per-locale
4
+ * outcomes. Used by both the collection and global persistence call sites.
5
+ *
6
+ * Only HARD failures (`status: 'failed'` — provider/schema errors) drive
7
+ * a locale to `status: 'failed'`. Soft skips (`status: 'skipped'` — echo,
8
+ * length-ratio, placeholder integrity) are filtered out defensively here:
9
+ * they're surfaced separately via `softSkippedFields` on the usage row and
10
+ * must never appear in a locale's `failedFields` list (otherwise the Hub
11
+ * renders the same field twice — once under a red "Failed fields" header
12
+ * and once under amber "Soft-skipped fields" — with a misleading
13
+ * "Something went wrong" banner for a locale that didn't actually fail).
14
+ */ export function bucketLocaleOutcomes(targetLocales, succeeded, failed) {
15
+ return targetLocales.map((locale)=>{
16
+ const localeFailures = failed.filter((f)=>f.locale === locale && f.status === 'failed');
17
+ if (localeFailures.length === 0) {
18
+ return {
19
+ locale,
20
+ status: 'succeeded'
21
+ };
22
+ }
23
+ return {
24
+ locale,
25
+ status: 'failed',
26
+ // Use the first failure's error as the headline. Field-level details
27
+ // are in `failedFields`; consumers wanting more should query the
28
+ // `failed` event payload at runtime or extend this shape.
29
+ error: localeFailures[0]?.error,
30
+ errorCode: localeFailures[0]?.errorCode,
31
+ failedFields: localeFailures.map((f)=>f.fieldPath)
32
+ };
33
+ });
34
+ }
35
+ /**
36
+ * Write a translation-usage row after a job completes. No-op when the
37
+ * feature is disabled. Errors from `payload.create` are caught and logged —
38
+ * usage tracking must never break a translation that already produced
39
+ * content for the user.
40
+ *
41
+ * Sets `REENTRY_FLAG` on the create context so the write does not retrigger
42
+ * the auto-translate hook (which gates on it via `hooks/after-change.ts`)
43
+ * or the audit-log cascade guard. A second flag,
44
+ * `USAGE_TRACKING_CONTEXT_FLAG`, is provided for future hooks that need to
45
+ * single-out usage writes specifically.
46
+ */ export async function persistTranslationUsage(input) {
47
+ const tracking = input.config.usageTracking;
48
+ if (!tracking?.enabled) return;
49
+ const slug = tracking.collectionSlug ?? DEFAULT_USAGE_COLLECTION_SLUG;
50
+ // Hash-skipped entries are the `success` rows pushed by translate.ts
51
+ // when sourceHash === lastSourceHash → no LLM call was made. They're
52
+ // identified by characterCount === 0 && durationMs === 0. Split the
53
+ // succeeded bucket so the Hub can show "X translated · Y skipped".
54
+ let fieldsTranslated = 0;
55
+ let fieldsHashSkipped = 0;
56
+ for (const r of input.succeeded){
57
+ const noWork = (r.characterCount ?? 0) === 0 && (r.durationMs ?? 0) === 0;
58
+ if (noWork) fieldsHashSkipped++;
59
+ else fieldsTranslated++;
60
+ }
61
+ const fieldsPreserved = input.preserved.length;
62
+ const fieldsSoftSkipped = input.softSkippedFields.length;
63
+ try {
64
+ await input.payload.create({
65
+ collection: slug,
66
+ data: {
67
+ kind: input.kind,
68
+ jobId: input.jobId,
69
+ slug: input.slug,
70
+ documentId: String(input.documentId),
71
+ status: input.status,
72
+ sourceLocale: input.sourceLocale,
73
+ targetLocales: input.localeOutcomes.map((o)=>({
74
+ locale: o.locale,
75
+ status: o.status,
76
+ error: o.error ?? null,
77
+ errorCode: o.errorCode ?? null,
78
+ failedFields: (o.failedFields ?? []).map((p)=>({
79
+ path: p
80
+ }))
81
+ })),
82
+ succeededCount: input.succeededCount,
83
+ failedCount: input.failedCount,
84
+ fieldsTranslated,
85
+ fieldsHashSkipped,
86
+ fieldsPreserved,
87
+ fieldsSoftSkipped,
88
+ softSkippedFields: input.softSkippedFields.map((s)=>({
89
+ path: s.fieldPath,
90
+ locale: s.locale,
91
+ reason: s.reason ?? null,
92
+ reasonCode: s.reasonCode ?? null,
93
+ sourceValue: s.sourceValue ?? null
94
+ })),
95
+ preservedFields: input.preserved.map((p)=>({
96
+ path: p.fieldPath,
97
+ locale: p.locale
98
+ })),
99
+ inputTokens: input.usage.inputTokens,
100
+ outputTokens: input.usage.outputTokens,
101
+ estimatedCostUsd: input.usage.estimatedCostUsd ?? null,
102
+ model: input.usage.model ?? null,
103
+ durationMs: input.durationMs ?? null,
104
+ error: input.error ?? null
105
+ },
106
+ context: {
107
+ [USAGE_TRACKING_CONTEXT_FLAG]: true,
108
+ [REENTRY_FLAG]: true
109
+ },
110
+ overrideAccess: true
111
+ });
112
+ } catch (err) {
113
+ const msg = err instanceof Error ? err.message : 'Unknown persistence error';
114
+ input.payload.logger.error(`[ai-translate] Failed to persist translation usage for job ${input.jobId}: ${msg}`);
115
+ }
116
+ }