@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,293 @@
|
|
|
1
|
+
import { deserializeLexicalTree } from '../lexical/deserializer.js';
|
|
2
|
+
import { getValueAtPath } from './content-extractor.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Public API
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
/**
|
|
7
|
+
* @param base Merge base — target-locale-first state that will be mutated
|
|
8
|
+
* (via structured clone) and returned with translated leaves written in.
|
|
9
|
+
* This was previously misleadingly called `sourceDoc`; it's the BASE that
|
|
10
|
+
* accumulates translated text on top of prior target-locale state.
|
|
11
|
+
* @param translatedUnits Map of unitId → translated text.
|
|
12
|
+
* @param fields Translatable field descriptors.
|
|
13
|
+
* @param sourceDoc The TRUE source-locale doc. Read-only. Used to recover
|
|
14
|
+
* structural skeletons (e.g. Lexical trees) when the base's target-locale
|
|
15
|
+
* row is empty or otherwise missing the source shape — without this the
|
|
16
|
+
* deserializer walks an empty/stale skeleton and discards source structure.
|
|
17
|
+
*/ export function patchTranslatedContent(base, translatedUnits, fields, sourceDoc) {
|
|
18
|
+
const patched = structuredClone(base);
|
|
19
|
+
for (const field of fields){
|
|
20
|
+
switch(field.type){
|
|
21
|
+
case 'text':
|
|
22
|
+
case 'textarea':
|
|
23
|
+
{
|
|
24
|
+
const unitId = `${field.path}::0`;
|
|
25
|
+
const translated = translatedUnits.get(unitId);
|
|
26
|
+
if (translated !== undefined) {
|
|
27
|
+
setValueAtPath(patched, field.path, translated);
|
|
28
|
+
}
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case 'richText':
|
|
32
|
+
{
|
|
33
|
+
// Structure comes from the TRUE source, not the merge base. The base
|
|
34
|
+
// for a fresh target locale is often an empty-Lexical skeleton from
|
|
35
|
+
// findByIdNoFallback (Payload returns the default editor state), and
|
|
36
|
+
// walking that would discard source headings, lists, links, formatting.
|
|
37
|
+
const originalValue = getValueAtPath(sourceDoc, field.path);
|
|
38
|
+
if (!isLexicalRoot(originalValue)) break;
|
|
39
|
+
// Collect all block units belonging to this field
|
|
40
|
+
const blockMap = collectFieldUnits(field.path, translatedUnits);
|
|
41
|
+
if (blockMap.size === 0) break;
|
|
42
|
+
const rebuilt = deserializeLexicalTree(originalValue, blockMap);
|
|
43
|
+
setValueAtPath(patched, field.path, rebuilt);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case 'json':
|
|
47
|
+
{
|
|
48
|
+
// Seed from the TRUE source when available — when the source's
|
|
49
|
+
// JSON shape changes between publishes (different top-level
|
|
50
|
+
// keys, new nesting), reading from `patched` walks the OLD
|
|
51
|
+
// target shape and translations land nowhere. Falls back to
|
|
52
|
+
// `patched` when sourceDoc doesn't expose the path (legacy
|
|
53
|
+
// callers, tests, or paths the caller composes manually) so the
|
|
54
|
+
// previous behavior is preserved.
|
|
55
|
+
const seedFromSource = getValueAtPath(sourceDoc, field.path);
|
|
56
|
+
const seed = seedFromSource !== undefined && seedFromSource !== null ? seedFromSource : getValueAtPath(patched, field.path);
|
|
57
|
+
if (seed === null || typeof seed !== 'object' || Array.isArray(seed)) break;
|
|
58
|
+
const jsonClone = structuredClone(seed);
|
|
59
|
+
patchJsonValues(jsonClone, field.path, translatedUnits);
|
|
60
|
+
setValueAtPath(patched, field.path, jsonClone);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'group':
|
|
64
|
+
{
|
|
65
|
+
const value = getValueAtPath(patched, field.path);
|
|
66
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) break;
|
|
67
|
+
patchGroupLeaves(value, field.path, fields, translatedUnits, sourceDoc);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'array':
|
|
71
|
+
{
|
|
72
|
+
const value = getValueAtPath(patched, field.path);
|
|
73
|
+
if (!Array.isArray(value)) break;
|
|
74
|
+
patchArrayItems(value, field.path, fields, translatedUnits, sourceDoc);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case 'blocks':
|
|
78
|
+
{
|
|
79
|
+
const value = getValueAtPath(patched, field.path);
|
|
80
|
+
if (!Array.isArray(value)) break;
|
|
81
|
+
patchBlockItems(value, field.path, translatedUnits, sourceDoc);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return patched;
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// JSON patcher
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
function patchJsonValues(obj, basePath, translatedUnits) {
|
|
92
|
+
for (const [key, value] of Object.entries(obj)){
|
|
93
|
+
const fieldPath = `${basePath}.${key}`;
|
|
94
|
+
const unitId = `${fieldPath}::0`;
|
|
95
|
+
const translated = translatedUnits.get(unitId);
|
|
96
|
+
if (translated !== undefined) {
|
|
97
|
+
obj[key] = translated;
|
|
98
|
+
} else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
99
|
+
patchJsonValues(value, fieldPath, translatedUnits);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Container patchers
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
function patchGroupLeaves(groupValue, groupPath, allFields, translatedUnits, sourceDoc) {
|
|
107
|
+
const childLeaves = allFields.filter((f)=>f.path.startsWith(`${groupPath}.`) && (f.type === 'text' || f.type === 'textarea' || f.type === 'richText' || f.type === 'json'));
|
|
108
|
+
for (const leaf of childLeaves){
|
|
109
|
+
const relativePath = leaf.path.slice(groupPath.length + 1);
|
|
110
|
+
if (leaf.type === 'text' || leaf.type === 'textarea') {
|
|
111
|
+
const unitId = `${leaf.path}::0`;
|
|
112
|
+
const translated = translatedUnits.get(unitId);
|
|
113
|
+
if (translated !== undefined) {
|
|
114
|
+
setValueAtPath(groupValue, relativePath, translated);
|
|
115
|
+
}
|
|
116
|
+
} else if (leaf.type === 'richText') {
|
|
117
|
+
const originalValue = getValueAtPath(sourceDoc, leaf.path);
|
|
118
|
+
if (!isLexicalRoot(originalValue)) continue;
|
|
119
|
+
const blockMap = collectFieldUnits(leaf.path, translatedUnits);
|
|
120
|
+
if (blockMap.size === 0) continue;
|
|
121
|
+
const rebuilt = deserializeLexicalTree(originalValue, blockMap);
|
|
122
|
+
setValueAtPath(groupValue, relativePath, rebuilt);
|
|
123
|
+
} else if (leaf.type === 'json') {
|
|
124
|
+
// Group-nested JSON: seed from source when available, fall back to
|
|
125
|
+
// the current target value otherwise. Same reasoning as the
|
|
126
|
+
// top-level `case 'json'` — source-first propagates schema
|
|
127
|
+
// changes; target-fallback keeps the previous behavior for paths
|
|
128
|
+
// sourceDoc doesn't expose.
|
|
129
|
+
const seedFromSource = getValueAtPath(sourceDoc, leaf.path);
|
|
130
|
+
const seed = seedFromSource !== undefined && seedFromSource !== null ? seedFromSource : getValueAtPath(groupValue, relativePath);
|
|
131
|
+
if (seed === null || typeof seed !== 'object' || Array.isArray(seed)) continue;
|
|
132
|
+
const jsonClone = structuredClone(seed);
|
|
133
|
+
patchJsonValues(jsonClone, leaf.path, translatedUnits);
|
|
134
|
+
setValueAtPath(groupValue, relativePath, jsonClone);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function patchArrayItems(items, arrayPath, allFields, translatedUnits, sourceDoc) {
|
|
139
|
+
const childLeaves = allFields.filter((f)=>f.path.startsWith(`${arrayPath}.`) && (f.type === 'text' || f.type === 'textarea' || f.type === 'richText') && !pathTraversesNestedArray(f.path, arrayPath, allFields));
|
|
140
|
+
const nestedContainers = allFields.filter((f)=>f.path.startsWith(`${arrayPath}.`) && (f.type === 'array' || f.type === 'blocks') && !pathTraversesNestedArray(f.path, arrayPath, allFields));
|
|
141
|
+
for(let i = 0; i < items.length; i++){
|
|
142
|
+
const item = items[i];
|
|
143
|
+
if (!item || typeof item !== 'object') continue;
|
|
144
|
+
for (const leaf of childLeaves){
|
|
145
|
+
const relativePath = leaf.path.slice(arrayPath.length + 1);
|
|
146
|
+
const indexedPath = `${arrayPath}.${i}.${relativePath}`;
|
|
147
|
+
if (leaf.type === 'text' || leaf.type === 'textarea') {
|
|
148
|
+
const unitId = `${indexedPath}::0`;
|
|
149
|
+
const translated = translatedUnits.get(unitId);
|
|
150
|
+
if (translated !== undefined) {
|
|
151
|
+
setValueAtPath(item, relativePath, translated);
|
|
152
|
+
}
|
|
153
|
+
} else if (leaf.type === 'richText') {
|
|
154
|
+
const originalValue = getValueAtPath(sourceDoc, indexedPath);
|
|
155
|
+
if (!isLexicalRoot(originalValue)) continue;
|
|
156
|
+
const blockMap = collectFieldUnits(indexedPath, translatedUnits);
|
|
157
|
+
if (blockMap.size === 0) continue;
|
|
158
|
+
const rebuilt = deserializeLexicalTree(originalValue, blockMap);
|
|
159
|
+
setValueAtPath(item, relativePath, rebuilt);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const container of nestedContainers){
|
|
163
|
+
const relativePath = container.path.slice(arrayPath.length + 1);
|
|
164
|
+
const indexedContainerPath = `${arrayPath}.${i}.${relativePath}`;
|
|
165
|
+
const nestedValue = getValueAtPath(item, relativePath);
|
|
166
|
+
if (!Array.isArray(nestedValue)) continue;
|
|
167
|
+
const mappedFields = remapFieldsUnderPath(allFields, container.path, indexedContainerPath);
|
|
168
|
+
if (container.type === 'array') {
|
|
169
|
+
patchArrayItems(nestedValue, indexedContainerPath, mappedFields, translatedUnits, sourceDoc);
|
|
170
|
+
} else {
|
|
171
|
+
patchBlockItems(nestedValue, indexedContainerPath, translatedUnits, sourceDoc);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function pathTraversesNestedArray(path, containerPath, allFields) {
|
|
177
|
+
for (const f of allFields){
|
|
178
|
+
if (f.type !== 'array' && f.type !== 'blocks') continue;
|
|
179
|
+
if (f.path === containerPath) continue;
|
|
180
|
+
if (!f.path.startsWith(`${containerPath}.`)) continue;
|
|
181
|
+
if (path.startsWith(`${f.path}.`)) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
function remapFieldsUnderPath(allFields, fromPath, toPath) {
|
|
186
|
+
return allFields.filter((f)=>f.path === fromPath || f.path.startsWith(`${fromPath}.`)).map((f)=>({
|
|
187
|
+
...f,
|
|
188
|
+
path: toPath + f.path.slice(fromPath.length)
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
function patchBlockItems(blocks, blocksPath, translatedUnits, sourceDoc) {
|
|
192
|
+
for(let i = 0; i < blocks.length; i++){
|
|
193
|
+
const block = blocks[i];
|
|
194
|
+
if (!block || typeof block !== 'object') continue;
|
|
195
|
+
const prefix = `${blocksPath}.${i}`;
|
|
196
|
+
patchBlockFields(block, prefix, translatedUnits, sourceDoc);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function patchBlockFields(obj, pathPrefix, translatedUnits, sourceDoc) {
|
|
200
|
+
for (const [key, value] of Object.entries(obj)){
|
|
201
|
+
if (key === 'id' || key === 'blockType' || key === 'blockName') continue;
|
|
202
|
+
const fieldPath = `${pathPrefix}.${key}`;
|
|
203
|
+
if (typeof value === 'string') {
|
|
204
|
+
const unitId = `${fieldPath}::0`;
|
|
205
|
+
const translated = translatedUnits.get(unitId);
|
|
206
|
+
if (translated !== undefined) {
|
|
207
|
+
obj[key] = translated;
|
|
208
|
+
}
|
|
209
|
+
} else if (isLexicalRoot(value)) {
|
|
210
|
+
const originalValue = getValueAtPath(sourceDoc, fieldPath);
|
|
211
|
+
if (!isLexicalRoot(originalValue)) continue;
|
|
212
|
+
const blockMap = collectFieldUnits(fieldPath, translatedUnits);
|
|
213
|
+
if (blockMap.size === 0) continue;
|
|
214
|
+
obj[key] = deserializeLexicalTree(originalValue, blockMap);
|
|
215
|
+
} else if (Array.isArray(value)) {
|
|
216
|
+
for(let i = 0; i < value.length; i++){
|
|
217
|
+
const item = value[i];
|
|
218
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
219
|
+
patchBlockFields(item, `${fieldPath}.${i}`, translatedUnits, sourceDoc);
|
|
220
|
+
} else if (typeof item === 'string' && item.trim().length > 0) {
|
|
221
|
+
// Mirror of extractor: strings inside arrays (e.g. JSON `tags`)
|
|
222
|
+
// are translated at indexed paths.
|
|
223
|
+
const translated = translatedUnits.get(`${fieldPath}.${i}::0`);
|
|
224
|
+
if (translated !== undefined) {
|
|
225
|
+
value[i] = translated;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
230
|
+
patchBlockFields(value, fieldPath, translatedUnits, sourceDoc);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Path utilities
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
export function setValueAtPath(obj, path, value) {
|
|
238
|
+
const segments = path.split('.');
|
|
239
|
+
let current = obj;
|
|
240
|
+
for(let i = 0; i < segments.length - 1; i++){
|
|
241
|
+
const segment = segments[i];
|
|
242
|
+
const nextSegment = segments[i + 1];
|
|
243
|
+
const isNextIndex = /^\d+$/.test(nextSegment);
|
|
244
|
+
if (current[segment] === undefined || current[segment] === null) {
|
|
245
|
+
current[segment] = isNextIndex ? [] : {};
|
|
246
|
+
}
|
|
247
|
+
const next = current[segment];
|
|
248
|
+
if (Array.isArray(next)) {
|
|
249
|
+
const index = Number(nextSegment);
|
|
250
|
+
if (Number.isNaN(index)) return;
|
|
251
|
+
// Ensure the array is large enough
|
|
252
|
+
while(next.length <= index){
|
|
253
|
+
next.push(undefined);
|
|
254
|
+
}
|
|
255
|
+
if (i + 1 === segments.length - 2) {
|
|
256
|
+
// Next iteration will be the final segment set
|
|
257
|
+
}
|
|
258
|
+
if (next[index] === undefined || next[index] === null) {
|
|
259
|
+
const afterNext = segments[i + 2];
|
|
260
|
+
next[index] = afterNext && /^\d+$/.test(afterNext) ? [] : {};
|
|
261
|
+
}
|
|
262
|
+
current = next[index];
|
|
263
|
+
i++; // skip the index segment
|
|
264
|
+
} else if (typeof next === 'object') {
|
|
265
|
+
current = next;
|
|
266
|
+
} else {
|
|
267
|
+
// Can't traverse further
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const lastSegment = segments[segments.length - 1];
|
|
272
|
+
current[lastSegment] = value;
|
|
273
|
+
}
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Helpers
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
function collectFieldUnits(fieldPath, translatedUnits) {
|
|
278
|
+
const prefix = `${fieldPath}::`;
|
|
279
|
+
const blockMap = new Map();
|
|
280
|
+
for (const [id, text] of translatedUnits){
|
|
281
|
+
if (id.startsWith(prefix)) {
|
|
282
|
+
// Extract the blockId part after the field path prefix
|
|
283
|
+
const blockId = id.slice(prefix.length);
|
|
284
|
+
blockMap.set(blockId, text);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return blockMap;
|
|
288
|
+
}
|
|
289
|
+
function isLexicalRoot(value) {
|
|
290
|
+
if (value === null || typeof value !== 'object') return false;
|
|
291
|
+
const obj = value;
|
|
292
|
+
return 'root' in obj && obj.root !== null && typeof obj.root === 'object';
|
|
293
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function checkPerCallLimit(chars, limit) {
|
|
2
|
+
if (chars > limit) {
|
|
3
|
+
const err = new Error(`Per-call character limit exceeded: ${chars} > ${limit}`);
|
|
4
|
+
err.code = 'PER_CALL_LIMIT';
|
|
5
|
+
err.characterCount = chars;
|
|
6
|
+
err.limit = limit;
|
|
7
|
+
throw err;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function checkPerDocLimit(totalChars, ceiling) {
|
|
11
|
+
if (totalChars > ceiling) {
|
|
12
|
+
const err = new Error(`Per-document character ceiling exceeded: ${totalChars} > ${ceiling}`);
|
|
13
|
+
err.code = 'PER_DOC_CEILING';
|
|
14
|
+
err.characterCount = totalChars;
|
|
15
|
+
err.limit = ceiling;
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
export type DailySpendCapResult = {
|
|
3
|
+
allowed: true;
|
|
4
|
+
todaySpentUsd: number;
|
|
5
|
+
remainingUsd: number;
|
|
6
|
+
capUsd: number;
|
|
7
|
+
} | {
|
|
8
|
+
allowed: false;
|
|
9
|
+
reason: 'cap_exceeded' | 'invalid_estimate';
|
|
10
|
+
todaySpentUsd: number;
|
|
11
|
+
remainingUsd: number;
|
|
12
|
+
capUsd: number;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
export interface DailySpendCapOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Override the env-var cap. Used by the bulk plugin config so
|
|
18
|
+
* consumers can pin a different cap without touching env. If both
|
|
19
|
+
* env and option are set, the option wins.
|
|
20
|
+
*/
|
|
21
|
+
capUsd?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Distinguishes spend rows when multiple plugin consumers share a
|
|
24
|
+
* single database (rare). Defaults to `'default'`.
|
|
25
|
+
*/
|
|
26
|
+
consumerKey?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Override slug if the consumer renamed the collection.
|
|
29
|
+
*/
|
|
30
|
+
collectionSlug?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Override the date (UTC ISO YYYY-MM-DD) for testing. Production
|
|
33
|
+
* code should leave this undefined to use today's date.
|
|
34
|
+
*/
|
|
35
|
+
dateOverride?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Check whether `estimatedCostUsd` would push today's spend past the
|
|
39
|
+
* configured cap. On `allowed`, atomically increments the day's
|
|
40
|
+
* running total. On `!allowed`, leaves state unchanged.
|
|
41
|
+
*
|
|
42
|
+
* Idempotency note: this is NOT idempotent. Callers must call exactly
|
|
43
|
+
* once per request that will actually fire a translation. The bulk
|
|
44
|
+
* endpoint calls it at enqueue time before queueing tasks; the per-
|
|
45
|
+
* doc retry endpoint calls it at HTTP entry. The plugin coalesce
|
|
46
|
+
* path calls it at hook entry before dispatching to the LLM.
|
|
47
|
+
*/
|
|
48
|
+
export declare function checkAndIncrementDailySpend(payload: Payload, estimatedCostUsd: number | undefined, opt?: DailySpendCapOptions): Promise<DailySpendCapResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Read-only inspector — exposed for the bulk pre-flight modal which
|
|
51
|
+
* needs to display remaining budget without incrementing. Does not
|
|
52
|
+
* touch storage.
|
|
53
|
+
*/
|
|
54
|
+
export declare function getDailySpendStatus(payload: Payload, opt?: DailySpendCapOptions): Promise<{
|
|
55
|
+
todaySpentUsd: number;
|
|
56
|
+
remainingUsd: number;
|
|
57
|
+
capUsd: number;
|
|
58
|
+
}>;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { DEFAULT_TRANSLATION_DAILY_SPEND_COLLECTION_SLUG } from '../translation-daily-spend-collection.js';
|
|
2
|
+
/**
|
|
3
|
+
* Daily USD cap utility for translation cost gating.
|
|
4
|
+
*
|
|
5
|
+
* Used by all three translate entry points (bulk-translate endpoint,
|
|
6
|
+
* per-doc retry endpoint, plugin coalesce path) per Decision #13 v2 +
|
|
7
|
+
* F-SEC-TOTP-BYPASS — the TOTP gate on bulk alone is cosmetic if the
|
|
8
|
+
* per-doc retry endpoint has no equivalent enforcement. The daily cap
|
|
9
|
+
* is the real boundary; TOTP becomes friction-not-boundary.
|
|
10
|
+
*
|
|
11
|
+
* Reject semantics:
|
|
12
|
+
* - `estimatedCostUsd` MUST be a finite number. `undefined` /
|
|
13
|
+
* non-finite values reject hard (Decision #29). This forces
|
|
14
|
+
* callers to fix the upstream estimate gap rather than silently
|
|
15
|
+
* bypassing the cap.
|
|
16
|
+
* - Today's spend (`spendUsd`) is summed across all entry points.
|
|
17
|
+
* The request's `estimatedCostUsd` is added to the running total;
|
|
18
|
+
* if `running + estimate > cap`, the request is rejected and the
|
|
19
|
+
* spend row is NOT incremented (the request never happens).
|
|
20
|
+
* - Successful checks increment `spendUsd += estimatedCostUsd` and
|
|
21
|
+
* `requestCount += 1` atomically via raw SQL upsert.
|
|
22
|
+
*
|
|
23
|
+
* The cap value comes from the `BULK_TRANSLATE_DAILY_USD_CAP` env var
|
|
24
|
+
* (consumer override). Snapshotted into the row at first write of the
|
|
25
|
+
* day so mid-day env changes don't retroactively shift the limit.
|
|
26
|
+
*/ const DEFAULT_CAP_USD = 50;
|
|
27
|
+
function resolveCap(opt) {
|
|
28
|
+
if (typeof opt.capUsd === 'number' && Number.isFinite(opt.capUsd)) {
|
|
29
|
+
return opt.capUsd;
|
|
30
|
+
}
|
|
31
|
+
const envRaw = process.env.BULK_TRANSLATE_DAILY_USD_CAP;
|
|
32
|
+
if (envRaw) {
|
|
33
|
+
const parsed = Number.parseFloat(envRaw);
|
|
34
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return DEFAULT_CAP_USD;
|
|
39
|
+
}
|
|
40
|
+
function todayUtcIso() {
|
|
41
|
+
// YYYY-MM-DD in UTC. Avoids time-zone drift across Vercel functions
|
|
42
|
+
// that may run in different regions.
|
|
43
|
+
return new Date().toISOString().slice(0, 10);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Check whether `estimatedCostUsd` would push today's spend past the
|
|
47
|
+
* configured cap. On `allowed`, atomically increments the day's
|
|
48
|
+
* running total. On `!allowed`, leaves state unchanged.
|
|
49
|
+
*
|
|
50
|
+
* Idempotency note: this is NOT idempotent. Callers must call exactly
|
|
51
|
+
* once per request that will actually fire a translation. The bulk
|
|
52
|
+
* endpoint calls it at enqueue time before queueing tasks; the per-
|
|
53
|
+
* doc retry endpoint calls it at HTTP entry. The plugin coalesce
|
|
54
|
+
* path calls it at hook entry before dispatching to the LLM.
|
|
55
|
+
*/ export async function checkAndIncrementDailySpend(payload, estimatedCostUsd, opt = {}) {
|
|
56
|
+
const capUsd = resolveCap(opt);
|
|
57
|
+
const consumerKey = opt.consumerKey ?? 'default';
|
|
58
|
+
const date = opt.dateOverride ?? todayUtcIso();
|
|
59
|
+
const slug = opt.collectionSlug ?? DEFAULT_TRANSLATION_DAILY_SPEND_COLLECTION_SLUG;
|
|
60
|
+
if (typeof estimatedCostUsd !== 'number' || !Number.isFinite(estimatedCostUsd) || estimatedCostUsd < 0) {
|
|
61
|
+
return {
|
|
62
|
+
allowed: false,
|
|
63
|
+
reason: 'invalid_estimate',
|
|
64
|
+
todaySpentUsd: 0,
|
|
65
|
+
remainingUsd: capUsd,
|
|
66
|
+
capUsd,
|
|
67
|
+
message: 'Translation cost estimate is missing or non-numeric. Refusing to proceed without a valid estimate (Decision #29).'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Read today's row. Best-effort — failure here doesn't block the
|
|
71
|
+
// translation. If the read fails we still try the upsert path below
|
|
72
|
+
// and let the DB error surface if storage is broken.
|
|
73
|
+
let todaySpent = 0;
|
|
74
|
+
try {
|
|
75
|
+
const result = await payload.find({
|
|
76
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
|
|
77
|
+
collection: slug,
|
|
78
|
+
where: {
|
|
79
|
+
and: [
|
|
80
|
+
{
|
|
81
|
+
date: {
|
|
82
|
+
equals: date
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
consumerKey: {
|
|
87
|
+
equals: consumerKey
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
limit: 1,
|
|
93
|
+
overrideAccess: true
|
|
94
|
+
});
|
|
95
|
+
const existing = result.docs[0];
|
|
96
|
+
if (existing && typeof existing.spendUsd === 'number') {
|
|
97
|
+
todaySpent = existing.spendUsd;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Treat as cold day. The upsert below will create the row.
|
|
101
|
+
}
|
|
102
|
+
const projected = todaySpent + estimatedCostUsd;
|
|
103
|
+
if (projected > capUsd) {
|
|
104
|
+
return {
|
|
105
|
+
allowed: false,
|
|
106
|
+
reason: 'cap_exceeded',
|
|
107
|
+
todaySpentUsd: todaySpent,
|
|
108
|
+
remainingUsd: Math.max(0, capUsd - todaySpent),
|
|
109
|
+
capUsd,
|
|
110
|
+
message: `Daily translation cost cap exceeded: spent $${todaySpent.toFixed(4)}, request adds $${estimatedCostUsd.toFixed(4)}, cap $${capUsd.toFixed(2)}.`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Atomic upsert. Uses Payload's `find` + `create` / `update`
|
|
114
|
+
// pattern as the portable fallback; consumers who want strict
|
|
115
|
+
// multi-process atomicity can replace this with a raw SQL
|
|
116
|
+
// `INSERT ... ON CONFLICT DO UPDATE` against the unique
|
|
117
|
+
// (date, consumer_key) constraint via `payload.db.drizzle`.
|
|
118
|
+
//
|
|
119
|
+
// The race window between read-then-write is bounded: the
|
|
120
|
+
// unique index on (date, consumer_key) ensures only one row
|
|
121
|
+
// per day, and concurrent increments race-update the same
|
|
122
|
+
// row's spendUsd — the second increment may briefly over-shoot
|
|
123
|
+
// the cap, but the cap re-checks on every call so the next
|
|
124
|
+
// request after the over-shoot will reject. Acceptable for a
|
|
125
|
+
// cost-cap gate; not acceptable as exact financial tracking.
|
|
126
|
+
try {
|
|
127
|
+
const result = await payload.find({
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
|
|
129
|
+
collection: slug,
|
|
130
|
+
where: {
|
|
131
|
+
and: [
|
|
132
|
+
{
|
|
133
|
+
date: {
|
|
134
|
+
equals: date
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
consumerKey: {
|
|
139
|
+
equals: consumerKey
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
limit: 1,
|
|
145
|
+
overrideAccess: true
|
|
146
|
+
});
|
|
147
|
+
const existing = result.docs[0];
|
|
148
|
+
if (existing) {
|
|
149
|
+
await payload.update({
|
|
150
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
|
|
151
|
+
collection: slug,
|
|
152
|
+
id: existing.id,
|
|
153
|
+
data: {
|
|
154
|
+
spendUsd: (existing.spendUsd ?? 0) + estimatedCostUsd,
|
|
155
|
+
requestCount: (existing.requestCount ?? 0) + 1,
|
|
156
|
+
lastRequestAt: new Date().toISOString()
|
|
157
|
+
},
|
|
158
|
+
overrideAccess: true
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
await payload.create({
|
|
162
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
|
|
163
|
+
collection: slug,
|
|
164
|
+
data: {
|
|
165
|
+
date,
|
|
166
|
+
consumerKey,
|
|
167
|
+
spendUsd: estimatedCostUsd,
|
|
168
|
+
capUsd,
|
|
169
|
+
requestCount: 1,
|
|
170
|
+
lastRequestAt: new Date().toISOString()
|
|
171
|
+
},
|
|
172
|
+
overrideAccess: true
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
payload.logger?.error?.(`[ai-translate] daily-spend-cap upsert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
// Don't reject the request just because tracking failed —
|
|
178
|
+
// the gate already approved based on the read. Tracking is
|
|
179
|
+
// best-effort. The next request will re-read and may
|
|
180
|
+
// approve over-cap if this write was lost. Log loudly.
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
allowed: true,
|
|
184
|
+
todaySpentUsd: projected,
|
|
185
|
+
remainingUsd: Math.max(0, capUsd - projected),
|
|
186
|
+
capUsd
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Read-only inspector — exposed for the bulk pre-flight modal which
|
|
191
|
+
* needs to display remaining budget without incrementing. Does not
|
|
192
|
+
* touch storage.
|
|
193
|
+
*/ export async function getDailySpendStatus(payload, opt = {}) {
|
|
194
|
+
const capUsd = resolveCap(opt);
|
|
195
|
+
const consumerKey = opt.consumerKey ?? 'default';
|
|
196
|
+
const date = opt.dateOverride ?? todayUtcIso();
|
|
197
|
+
const slug = opt.collectionSlug ?? DEFAULT_TRANSLATION_DAILY_SPEND_COLLECTION_SLUG;
|
|
198
|
+
try {
|
|
199
|
+
const result = await payload.find({
|
|
200
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
|
|
201
|
+
collection: slug,
|
|
202
|
+
where: {
|
|
203
|
+
and: [
|
|
204
|
+
{
|
|
205
|
+
date: {
|
|
206
|
+
equals: date
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
consumerKey: {
|
|
211
|
+
equals: consumerKey
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
},
|
|
216
|
+
limit: 1,
|
|
217
|
+
overrideAccess: true
|
|
218
|
+
});
|
|
219
|
+
const existing = result.docs[0];
|
|
220
|
+
const todaySpent = existing && typeof existing.spendUsd === 'number' ? existing.spendUsd : 0;
|
|
221
|
+
return {
|
|
222
|
+
todaySpentUsd: todaySpent,
|
|
223
|
+
remainingUsd: Math.max(0, capUsd - todaySpent),
|
|
224
|
+
capUsd
|
|
225
|
+
};
|
|
226
|
+
} catch {
|
|
227
|
+
return {
|
|
228
|
+
todaySpentUsd: 0,
|
|
229
|
+
remainingUsd: capUsd,
|
|
230
|
+
capUsd
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|