@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.
- package/LICENSE +21 -0
- package/README.md +714 -0
- package/dist/alerts-collection.d.ts +21 -0
- package/dist/alerts-collection.js +159 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +918 -0
- package/dist/bulk-translate-batches-collection.d.ts +29 -0
- package/dist/bulk-translate-batches-collection.js +404 -0
- package/dist/bulk-translate-units-collection.d.ts +35 -0
- package/dist/bulk-translate-units-collection.js +310 -0
- package/dist/client/estimated-cost-cell.d.ts +6 -0
- package/dist/client/estimated-cost-cell.js +12 -0
- package/dist/client/excluded-fields-field.d.ts +45 -0
- package/dist/client/excluded-fields-field.js +553 -0
- package/dist/client/field-translate-button.d.ts +6 -0
- package/dist/client/field-translate-button.js +199 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +6 -0
- package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
- package/dist/client/lib/use-global-kill-switches.js +58 -0
- package/dist/client/translate-button.d.ts +2 -0
- package/dist/client/translate-button.js +228 -0
- package/dist/client/translate-modal.d.ts +16 -0
- package/dist/client/translate-modal.js +549 -0
- package/dist/client/translation-progress.d.ts +10 -0
- package/dist/client/translation-progress.js +297 -0
- package/dist/components/TranslationNavGroup.d.ts +45 -0
- package/dist/components/TranslationNavGroup.js +104 -0
- package/dist/defaults.d.ts +11 -0
- package/dist/defaults.js +16 -0
- package/dist/endpoints/client-config.d.ts +44 -0
- package/dist/endpoints/client-config.js +145 -0
- package/dist/endpoints/estimate.d.ts +5 -0
- package/dist/endpoints/estimate.js +237 -0
- package/dist/endpoints/progress.d.ts +2 -0
- package/dist/endpoints/progress.js +314 -0
- package/dist/endpoints/translate.d.ts +11 -0
- package/dist/endpoints/translate.js +376 -0
- package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
- package/dist/endpoints/translation-hub/_helpers.js +297 -0
- package/dist/endpoints/translation-hub/active.d.ts +21 -0
- package/dist/endpoints/translation-hub/active.js +220 -0
- package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
- package/dist/endpoints/translation-hub/cancel.js +233 -0
- package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
- package/dist/endpoints/translation-hub/enqueue.js +529 -0
- package/dist/endpoints/translation-hub/failures.d.ts +12 -0
- package/dist/endpoints/translation-hub/failures.js +67 -0
- package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
- package/dist/endpoints/translation-hub/force-reset.js +144 -0
- package/dist/endpoints/translation-hub/index.d.ts +21 -0
- package/dist/endpoints/translation-hub/index.js +20 -0
- package/dist/endpoints/translation-hub/list.d.ts +40 -0
- package/dist/endpoints/translation-hub/list.js +182 -0
- package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
- package/dist/endpoints/translation-hub/preflight.js +141 -0
- package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
- package/dist/endpoints/translation-hub/retry-failed.js +235 -0
- package/dist/endpoints/translation-hub/revert.d.ts +88 -0
- package/dist/endpoints/translation-hub/revert.js +405 -0
- package/dist/endpoints/translation-hub/status.d.ts +45 -0
- package/dist/endpoints/translation-hub/status.js +391 -0
- package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
- package/dist/endpoints/translation-hub/usage-summary.js +481 -0
- package/dist/exports/client.d.ts +6 -0
- package/dist/exports/client.js +6 -0
- package/dist/exports/components.d.ts +6 -0
- package/dist/exports/components.js +5 -0
- package/dist/exports/index.d.ts +8 -0
- package/dist/exports/index.js +7 -0
- package/dist/exports/providers.d.ts +9 -0
- package/dist/exports/providers.js +5 -0
- package/dist/exports/views-client.d.ts +23 -0
- package/dist/exports/views-client.js +22 -0
- package/dist/exports/views.d.ts +30 -0
- package/dist/exports/views.js +29 -0
- package/dist/hooks/after-change-global.d.ts +4 -0
- package/dist/hooks/after-change-global.js +109 -0
- package/dist/hooks/after-change.d.ts +16 -0
- package/dist/hooks/after-change.js +205 -0
- package/dist/hooks/after-delete.d.ts +30 -0
- package/dist/hooks/after-delete.js +95 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/jobs-collection.d.ts +17 -0
- package/dist/jobs-collection.js +139 -0
- package/dist/lexical/classifier.d.ts +3 -0
- package/dist/lexical/classifier.js +108 -0
- package/dist/lexical/deserializer.d.ts +4 -0
- package/dist/lexical/deserializer.js +263 -0
- package/dist/lexical/placeholder-integrity.d.ts +6 -0
- package/dist/lexical/placeholder-integrity.js +21 -0
- package/dist/lexical/placeholders.d.ts +21 -0
- package/dist/lexical/placeholders.js +117 -0
- package/dist/lexical/serializer.d.ts +21 -0
- package/dist/lexical/serializer.js +233 -0
- package/dist/lexical/types.d.ts +32 -0
- package/dist/lexical/types.js +1 -0
- package/dist/lib/auth-diagnostics.d.ts +14 -0
- package/dist/lib/auth-diagnostics.js +19 -0
- package/dist/lib/batch-counts.d.ts +58 -0
- package/dist/lib/batch-counts.js +105 -0
- package/dist/lib/bulk-translate-migrations.d.ts +92 -0
- package/dist/lib/bulk-translate-migrations.js +153 -0
- package/dist/lib/coalescing-queue.d.ts +38 -0
- package/dist/lib/coalescing-queue.js +69 -0
- package/dist/lib/content-extractor.d.ts +16 -0
- package/dist/lib/content-extractor.js +410 -0
- package/dist/lib/content-hash.d.ts +1 -0
- package/dist/lib/content-hash.js +19 -0
- package/dist/lib/content-patcher.d.ts +15 -0
- package/dist/lib/content-patcher.js +293 -0
- package/dist/lib/cost-guards.d.ts +2 -0
- package/dist/lib/cost-guards.js +18 -0
- package/dist/lib/daily-spend-cap.d.ts +58 -0
- package/dist/lib/daily-spend-cap.js +233 -0
- package/dist/lib/effective-locales.d.ts +181 -0
- package/dist/lib/effective-locales.js +302 -0
- package/dist/lib/error-messages.d.ts +245 -0
- package/dist/lib/error-messages.js +626 -0
- package/dist/lib/events.d.ts +39 -0
- package/dist/lib/events.js +146 -0
- package/dist/lib/exclude-fields.d.ts +3 -0
- package/dist/lib/exclude-fields.js +64 -0
- package/dist/lib/field-breadcrumb.d.ts +31 -0
- package/dist/lib/field-breadcrumb.js +227 -0
- package/dist/lib/field-diff.d.ts +1 -0
- package/dist/lib/field-diff.js +25 -0
- package/dist/lib/field-empty.d.ts +2 -0
- package/dist/lib/field-empty.js +68 -0
- package/dist/lib/field-resolver.d.ts +3 -0
- package/dist/lib/field-resolver.js +164 -0
- package/dist/lib/group-soft-skips.d.ts +39 -0
- package/dist/lib/group-soft-skips.js +45 -0
- package/dist/lib/locale-merge.d.ts +44 -0
- package/dist/lib/locale-merge.js +357 -0
- package/dist/lib/locale-row-check.d.ts +30 -0
- package/dist/lib/locale-row-check.js +64 -0
- package/dist/lib/logger.d.ts +74 -0
- package/dist/lib/logger.js +97 -0
- package/dist/lib/manual-edit-guard.d.ts +128 -0
- package/dist/lib/manual-edit-guard.js +393 -0
- package/dist/lib/output-validation.d.ts +48 -0
- package/dist/lib/output-validation.js +148 -0
- package/dist/lib/payload-read.d.ts +16 -0
- package/dist/lib/payload-read.js +51 -0
- package/dist/lib/per-doc-claim.d.ts +90 -0
- package/dist/lib/per-doc-claim.js +140 -0
- package/dist/lib/per-doc-lock.d.ts +94 -0
- package/dist/lib/per-doc-lock.js +119 -0
- package/dist/lib/persist-usage.d.ts +91 -0
- package/dist/lib/persist-usage.js +116 -0
- package/dist/lib/progress-store.d.ts +103 -0
- package/dist/lib/progress-store.js +314 -0
- package/dist/lib/rate-limiter.d.ts +3 -0
- package/dist/lib/rate-limiter.js +53 -0
- package/dist/lib/snapshot-select.d.ts +43 -0
- package/dist/lib/snapshot-select.js +108 -0
- package/dist/lib/translate-prompt.d.ts +31 -0
- package/dist/lib/translate-prompt.js +66 -0
- package/dist/lib/translation-token-bucket.d.ts +57 -0
- package/dist/lib/translation-token-bucket.js +365 -0
- package/dist/lib/truncate-source-value.d.ts +1 -0
- package/dist/lib/truncate-source-value.js +27 -0
- package/dist/manual-edit-collection.d.ts +22 -0
- package/dist/manual-edit-collection.js +124 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +934 -0
- package/dist/providers/ai-sdk-adapter.d.ts +35 -0
- package/dist/providers/ai-sdk-adapter.js +100 -0
- package/dist/providers/anthropic.d.ts +31 -0
- package/dist/providers/anthropic.js +66 -0
- package/dist/providers/custom.d.ts +36 -0
- package/dist/providers/custom.js +24 -0
- package/dist/providers/gemini.d.ts +20 -0
- package/dist/providers/gemini.js +48 -0
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.js +29 -0
- package/dist/providers/openai.d.ts +28 -0
- package/dist/providers/openai.js +69 -0
- package/dist/settings-global.d.ts +74 -0
- package/dist/settings-global.js +216 -0
- package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
- package/dist/tasks/bulk-translate-coordinator.js +708 -0
- package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
- package/dist/tasks/bulk-translate-doc-task.js +1000 -0
- package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
- package/dist/tasks/bulk-translate-janitor.js +311 -0
- package/dist/tasks/translate-job-task.d.ts +51 -0
- package/dist/tasks/translate-job-task.js +154 -0
- package/dist/translate.d.ts +113 -0
- package/dist/translate.js +911 -0
- package/dist/translation-daily-spend-collection.d.ts +24 -0
- package/dist/translation-daily-spend-collection.js +133 -0
- package/dist/translation-rate-limits-collection.d.ts +30 -0
- package/dist/translation-rate-limits-collection.js +144 -0
- package/dist/types.d.ts +672 -0
- package/dist/types.js +1 -0
- package/dist/usage-collection.d.ts +14 -0
- package/dist/usage-collection.js +377 -0
- package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
- package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
- package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
- package/dist/views/BulkRunsHub/BucketRow.js +982 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
- package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
- package/dist/views/BulkRunsHub/EmptyState.js +64 -0
- package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
- package/dist/views/BulkRunsHub/FilterBar.js +284 -0
- package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
- package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
- package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
- package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
- package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
- package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
- package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
- package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
- package/dist/views/BulkRunsHub/index.d.ts +17 -0
- package/dist/views/BulkRunsHub/index.js +80 -0
- package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
- package/dist/views/BulkRunsHub/urlFilters.js +50 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
- package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
- package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
- package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
- package/dist/views/TranslationHub/ActiveJobs.js +320 -0
- package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
- package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
- package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
- package/dist/views/TranslationHub/AlertBanner.js +568 -0
- package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
- package/dist/views/TranslationHub/AuditPanel.js +1367 -0
- package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
- package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
- package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
- package/dist/views/TranslationHub/Hub.client.js +357 -0
- package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
- package/dist/views/TranslationHub/ModelCombobox.js +415 -0
- package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
- package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
- package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
- package/dist/views/TranslationHub/SettingsRail.js +382 -0
- package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
- package/dist/views/TranslationHub/StatusStrip.js +451 -0
- package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
- package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
- package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
- package/dist/views/TranslationHub/UsageTable.js +1240 -0
- package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
- package/dist/views/TranslationHub/alertGrouping.js +99 -0
- package/dist/views/TranslationHub/index.d.ts +20 -0
- package/dist/views/TranslationHub/index.js +109 -0
- package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
- package/dist/views/TranslationHub/tabNavigation.js +74 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
- package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
- package/dist/views/TranslationHub/useFocusTrap.js +81 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
- package/dist/views/shared/EditorError.d.ts +97 -0
- package/dist/views/shared/EditorError.js +205 -0
- package/dist/views/shared/ModelCell.d.ts +18 -0
- package/dist/views/shared/ModelCell.js +31 -0
- package/dist/views/shared/docHref.d.ts +16 -0
- package/dist/views/shared/docHref.js +26 -0
- package/dist/views/shared/fetch-error-body.d.ts +25 -0
- package/dist/views/shared/fetch-error-body.js +42 -0
- package/dist/views/shared/filterPillStyle.d.ts +35 -0
- package/dist/views/shared/filterPillStyle.js +40 -0
- package/dist/views/shared/format.d.ts +75 -0
- package/dist/views/shared/format.js +131 -0
- 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
|
+
}
|