@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,103 @@
|
|
|
1
|
+
export type TranslationJobStatus = 'running' | 'completed' | 'failed';
|
|
2
|
+
export type TranslationJob = {
|
|
3
|
+
jobId: string;
|
|
4
|
+
collection: string;
|
|
5
|
+
documentId: string | number;
|
|
6
|
+
totalLocales: number;
|
|
7
|
+
completedLocales: string[];
|
|
8
|
+
failedLocales: {
|
|
9
|
+
locale: string;
|
|
10
|
+
error: string;
|
|
11
|
+
}[];
|
|
12
|
+
status: TranslationJobStatus;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
};
|
|
15
|
+
export type JobProgressEvent = {
|
|
16
|
+
jobId: string;
|
|
17
|
+
completed: string[];
|
|
18
|
+
failed: {
|
|
19
|
+
locale: string;
|
|
20
|
+
error: string;
|
|
21
|
+
}[];
|
|
22
|
+
total: number;
|
|
23
|
+
status: TranslationJobStatus;
|
|
24
|
+
};
|
|
25
|
+
export type Subscriber = (event: JobProgressEvent) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Doc-level subscriber. Fires once per `createJob` for a matching
|
|
28
|
+
* (collection, documentId) pair, with the new job's first event.
|
|
29
|
+
* Lets the SSE endpoint hold an open stream for "any job on this doc"
|
|
30
|
+
* and push immediately when one starts — no client polling needed.
|
|
31
|
+
*/
|
|
32
|
+
export type DocSubscriber = (event: JobProgressEvent) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Optional persistence target. When the plugin's `persistJobs: true`
|
|
35
|
+
* is set, plugin.ts calls `setPersistenceContext()` at boot with the
|
|
36
|
+
* Payload instance + jobs-collection slug. Every mutation to `jobs`
|
|
37
|
+
* (create/update/cleanup) then also mirrors to the sidecar collection.
|
|
38
|
+
*
|
|
39
|
+
* Mirror writes are fire-and-forget — failures don't roll back the
|
|
40
|
+
* in-memory mutation. The persistence layer is for VISIBILITY across
|
|
41
|
+
* restarts; the actual translation closures still live in process
|
|
42
|
+
* memory and don't resume after a restart.
|
|
43
|
+
*/
|
|
44
|
+
type PersistenceContext = {
|
|
45
|
+
payload: {
|
|
46
|
+
create: (args: unknown) => Promise<unknown>;
|
|
47
|
+
update: (args: unknown) => Promise<unknown>;
|
|
48
|
+
delete: (args: unknown) => Promise<unknown>;
|
|
49
|
+
find: (args: unknown) => Promise<{
|
|
50
|
+
docs: unknown[];
|
|
51
|
+
}>;
|
|
52
|
+
logger?: {
|
|
53
|
+
warn?: (msg: string) => void;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
collectionSlug: string;
|
|
57
|
+
};
|
|
58
|
+
export declare function setPersistenceContext(ctx: PersistenceContext | null): void;
|
|
59
|
+
export declare function createJob(collection: string, documentId: string | number, locales: string[]): string;
|
|
60
|
+
export declare function updateJob(jobId: string, locale: string, success: boolean, error?: string): void;
|
|
61
|
+
export declare function getJob(jobId: string): TranslationJob | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Atomically return the active job for `(collection, documentId)` or
|
|
64
|
+
* create a new one in a single synchronous step. Replaces the unsafe
|
|
65
|
+
* `getActiveJobForDoc(...) ?? createJob(...)` pattern: two concurrent
|
|
66
|
+
* after-change hook fires for the same doc could both read `undefined`
|
|
67
|
+
* from `getActiveJobForDoc` and both call `createJob`, producing two
|
|
68
|
+
* jobs (and two LLM calls) for the same write.
|
|
69
|
+
*
|
|
70
|
+
* Because Node is single-threaded and this whole function runs
|
|
71
|
+
* synchronously between event-loop turns, the get + set pair is
|
|
72
|
+
* effectively atomic from the caller's perspective. No mutex needed.
|
|
73
|
+
*
|
|
74
|
+
* Returns `{ jobId, created: boolean }`. Callers can branch on
|
|
75
|
+
* `created` to decide whether to fire a new translation closure
|
|
76
|
+
* (`created: true`) or join an in-flight one (`created: false`).
|
|
77
|
+
*/
|
|
78
|
+
export declare function getOrCreateJobForDoc(collection: string, documentId: string | number, locales: string[]): {
|
|
79
|
+
jobId: string;
|
|
80
|
+
created: boolean;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Mark a still-running job as `completed` without performing any
|
|
84
|
+
* translation. Used by the after-change hooks when they decide to
|
|
85
|
+
* short-circuit (e.g. `skipAutoOnPublish: true` from `effective-locales`)
|
|
86
|
+
* AFTER a job has already been seeded by a parent flow — without this,
|
|
87
|
+
* the polling UI would show "Translating…" until the TTL sweep
|
|
88
|
+
* (2 minutes). Notifies SSE subscribers so the client can clear its
|
|
89
|
+
* progress widget immediately. No-op if `jobId` isn't tracked.
|
|
90
|
+
*/
|
|
91
|
+
export declare function completeJobNow(jobId: string): void;
|
|
92
|
+
export declare function getActiveJobForDoc(collection: string, documentId: string | number): TranslationJob | undefined;
|
|
93
|
+
export declare function subscribe(jobId: string, callback: Subscriber): () => void;
|
|
94
|
+
/**
|
|
95
|
+
* Subscribe to "any new job created for this (collection, documentId)".
|
|
96
|
+
* The callback fires once per new job started after this subscription is
|
|
97
|
+
* registered. Used by the `?docId=X` SSE endpoint to push events to open
|
|
98
|
+
* editors the moment a translation begins.
|
|
99
|
+
*/
|
|
100
|
+
export declare function subscribeToDoc(collection: string, documentId: string | number, callback: DocSubscriber): () => void;
|
|
101
|
+
export declare function cleanup(): void;
|
|
102
|
+
export declare function _resetStore(): void;
|
|
103
|
+
export {};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Store
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
const jobs = new Map();
|
|
6
|
+
const subscribers = new Map();
|
|
7
|
+
const docSubscribers = new Map();
|
|
8
|
+
let persistenceContext = null;
|
|
9
|
+
export function setPersistenceContext(ctx) {
|
|
10
|
+
persistenceContext = ctx;
|
|
11
|
+
}
|
|
12
|
+
function mirrorCreate(job) {
|
|
13
|
+
if (!persistenceContext) return;
|
|
14
|
+
void persistenceContext.payload.create({
|
|
15
|
+
collection: persistenceContext.collectionSlug,
|
|
16
|
+
data: {
|
|
17
|
+
jobId: job.jobId,
|
|
18
|
+
collection: job.collection,
|
|
19
|
+
documentId: String(job.documentId),
|
|
20
|
+
status: job.status,
|
|
21
|
+
totalLocales: job.totalLocales,
|
|
22
|
+
completedLocales: job.completedLocales,
|
|
23
|
+
failedLocales: job.failedLocales,
|
|
24
|
+
startedAt: new Date(job.startedAt).toISOString()
|
|
25
|
+
},
|
|
26
|
+
overrideAccess: true
|
|
27
|
+
}).catch((err)=>{
|
|
28
|
+
// BUG-29 fix: don't silently swallow. Under bursty parallel
|
|
29
|
+
// translates (e.g. bulk-publish of 5+ docs), one of the
|
|
30
|
+
// concurrent `payload.create` calls on `ai_translate_jobs` can
|
|
31
|
+
// hit a transient DB conflict or transaction race; pre-fix this
|
|
32
|
+
// silently dropped the row and the admin Jobs page lost one
|
|
33
|
+
// out of N entries. We log loud + warn so ops can see it
|
|
34
|
+
// happened; the in-memory job is still correct so translation
|
|
35
|
+
// proceeds.
|
|
36
|
+
persistenceContext?.payload.logger?.warn?.(`[ai-translate] mirrorCreate for ai_translate_jobs failed for ${job.collection}/${String(job.documentId)} jobId=${job.jobId}: ${err instanceof Error ? err.message : 'Unknown error'}. The in-memory job remains active; the admin Jobs page may be missing this row.`);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function mirrorUpdate(job) {
|
|
40
|
+
if (!persistenceContext) return;
|
|
41
|
+
// Find by jobId then update — Payload doesn't expose upsert.
|
|
42
|
+
void persistenceContext.payload.find({
|
|
43
|
+
collection: persistenceContext.collectionSlug,
|
|
44
|
+
where: {
|
|
45
|
+
jobId: {
|
|
46
|
+
equals: job.jobId
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
limit: 1,
|
|
50
|
+
depth: 0,
|
|
51
|
+
overrideAccess: true
|
|
52
|
+
}).then(async (result)=>{
|
|
53
|
+
const first = result.docs[0];
|
|
54
|
+
if (!first?.id) return;
|
|
55
|
+
await persistenceContext.payload.update({
|
|
56
|
+
collection: persistenceContext.collectionSlug,
|
|
57
|
+
id: first.id,
|
|
58
|
+
data: {
|
|
59
|
+
status: job.status,
|
|
60
|
+
completedLocales: job.completedLocales,
|
|
61
|
+
failedLocales: job.failedLocales
|
|
62
|
+
},
|
|
63
|
+
overrideAccess: true
|
|
64
|
+
});
|
|
65
|
+
}).catch(()=>{});
|
|
66
|
+
}
|
|
67
|
+
function mirrorDelete(jobId) {
|
|
68
|
+
if (!persistenceContext) return;
|
|
69
|
+
void persistenceContext.payload.find({
|
|
70
|
+
collection: persistenceContext.collectionSlug,
|
|
71
|
+
where: {
|
|
72
|
+
jobId: {
|
|
73
|
+
equals: jobId
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
limit: 1,
|
|
77
|
+
depth: 0,
|
|
78
|
+
overrideAccess: true
|
|
79
|
+
}).then(async (result)=>{
|
|
80
|
+
const first = result.docs[0];
|
|
81
|
+
if (!first?.id) return;
|
|
82
|
+
await persistenceContext.payload.delete({
|
|
83
|
+
collection: persistenceContext.collectionSlug,
|
|
84
|
+
id: first.id,
|
|
85
|
+
overrideAccess: true
|
|
86
|
+
});
|
|
87
|
+
}).catch(()=>{});
|
|
88
|
+
}
|
|
89
|
+
// Background cleanup interval. Lazily armed on the first `createJob` so a
|
|
90
|
+
// long-idle process (no translations ever) doesn't keep a timer alive. The
|
|
91
|
+
// previous behavior only ran `cleanup()` on new-job arrival, so finished
|
|
92
|
+
// jobs from a short burst would sit in memory forever during quiet periods.
|
|
93
|
+
let cleanupTimer = null;
|
|
94
|
+
const CLEANUP_INTERVAL_MS = 60_000; // 1 min; jobs expire at 2 min
|
|
95
|
+
function armCleanupTimer() {
|
|
96
|
+
if (cleanupTimer !== null) return;
|
|
97
|
+
cleanupTimer = setInterval(()=>{
|
|
98
|
+
cleanup();
|
|
99
|
+
// Disarm when the store is fully drained so the timer doesn't keep the
|
|
100
|
+
// process alive in test runners or short-lived scripts.
|
|
101
|
+
if (jobs.size === 0) {
|
|
102
|
+
if (cleanupTimer) {
|
|
103
|
+
clearInterval(cleanupTimer);
|
|
104
|
+
cleanupTimer = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}, CLEANUP_INTERVAL_MS);
|
|
108
|
+
// Allow the Node event loop to exit even if the timer is still scheduled.
|
|
109
|
+
cleanupTimer.unref?.();
|
|
110
|
+
}
|
|
111
|
+
function docKey(collection, documentId) {
|
|
112
|
+
return `${collection}::${String(documentId)}`;
|
|
113
|
+
}
|
|
114
|
+
const JOB_TTL_MS = 120_000; // 2 minutes
|
|
115
|
+
function toEvent(job) {
|
|
116
|
+
return {
|
|
117
|
+
jobId: job.jobId,
|
|
118
|
+
completed: [
|
|
119
|
+
...job.completedLocales
|
|
120
|
+
],
|
|
121
|
+
failed: [
|
|
122
|
+
...job.failedLocales
|
|
123
|
+
],
|
|
124
|
+
total: job.totalLocales,
|
|
125
|
+
status: job.status
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function notifySubscribers(jobId) {
|
|
129
|
+
const job = jobs.get(jobId);
|
|
130
|
+
const subs = subscribers.get(jobId);
|
|
131
|
+
if (!job || !subs || subs.size === 0) return;
|
|
132
|
+
const event = toEvent(job);
|
|
133
|
+
for (const callback of subs){
|
|
134
|
+
try {
|
|
135
|
+
callback(event);
|
|
136
|
+
} catch {
|
|
137
|
+
// Subscriber errors never break the store
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function notifyDocSubscribers(job) {
|
|
142
|
+
const subs = docSubscribers.get(docKey(job.collection, job.documentId));
|
|
143
|
+
if (!subs || subs.size === 0) return;
|
|
144
|
+
const event = toEvent(job);
|
|
145
|
+
for (const callback of subs){
|
|
146
|
+
try {
|
|
147
|
+
callback(event);
|
|
148
|
+
} catch {
|
|
149
|
+
// Subscriber errors never break the store
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Public API
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
export function createJob(collection, documentId, locales) {
|
|
157
|
+
cleanup();
|
|
158
|
+
armCleanupTimer();
|
|
159
|
+
const jobId = randomUUID();
|
|
160
|
+
const job = {
|
|
161
|
+
jobId,
|
|
162
|
+
collection,
|
|
163
|
+
documentId,
|
|
164
|
+
totalLocales: locales.length,
|
|
165
|
+
completedLocales: [],
|
|
166
|
+
failedLocales: [],
|
|
167
|
+
status: 'running',
|
|
168
|
+
startedAt: Date.now()
|
|
169
|
+
};
|
|
170
|
+
jobs.set(jobId, job);
|
|
171
|
+
mirrorCreate(job);
|
|
172
|
+
// Notify any open `?docId=X` SSE streams that a new job has started
|
|
173
|
+
// for this document. The client can then mount its progress UI
|
|
174
|
+
// immediately — no polling delay.
|
|
175
|
+
notifyDocSubscribers(job);
|
|
176
|
+
return jobId;
|
|
177
|
+
}
|
|
178
|
+
export function updateJob(jobId, locale, success, error) {
|
|
179
|
+
const job = jobs.get(jobId);
|
|
180
|
+
if (!job) return;
|
|
181
|
+
// BUG-29 fix: dedup before append. Under bursty parallel translates
|
|
182
|
+
// a retry path or duplicate fire could call updateJob twice with the
|
|
183
|
+
// same locale, producing `completed_locales: ["es", "es"]`. Set-style
|
|
184
|
+
// dedup keeps the array correct under all event orderings.
|
|
185
|
+
if (success) {
|
|
186
|
+
if (!job.completedLocales.includes(locale)) {
|
|
187
|
+
job.completedLocales.push(locale);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
const alreadyFailed = job.failedLocales.some((f)=>f.locale === locale);
|
|
191
|
+
if (!alreadyFailed) {
|
|
192
|
+
job.failedLocales.push({
|
|
193
|
+
locale,
|
|
194
|
+
error: error ?? 'Unknown error'
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const done = job.completedLocales.length + job.failedLocales.length;
|
|
199
|
+
if (done >= job.totalLocales) {
|
|
200
|
+
job.status = job.failedLocales.length > 0 ? 'failed' : 'completed';
|
|
201
|
+
}
|
|
202
|
+
mirrorUpdate(job);
|
|
203
|
+
notifySubscribers(jobId);
|
|
204
|
+
}
|
|
205
|
+
export function getJob(jobId) {
|
|
206
|
+
return jobs.get(jobId);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Atomically return the active job for `(collection, documentId)` or
|
|
210
|
+
* create a new one in a single synchronous step. Replaces the unsafe
|
|
211
|
+
* `getActiveJobForDoc(...) ?? createJob(...)` pattern: two concurrent
|
|
212
|
+
* after-change hook fires for the same doc could both read `undefined`
|
|
213
|
+
* from `getActiveJobForDoc` and both call `createJob`, producing two
|
|
214
|
+
* jobs (and two LLM calls) for the same write.
|
|
215
|
+
*
|
|
216
|
+
* Because Node is single-threaded and this whole function runs
|
|
217
|
+
* synchronously between event-loop turns, the get + set pair is
|
|
218
|
+
* effectively atomic from the caller's perspective. No mutex needed.
|
|
219
|
+
*
|
|
220
|
+
* Returns `{ jobId, created: boolean }`. Callers can branch on
|
|
221
|
+
* `created` to decide whether to fire a new translation closure
|
|
222
|
+
* (`created: true`) or join an in-flight one (`created: false`).
|
|
223
|
+
*/ export function getOrCreateJobForDoc(collection, documentId, locales) {
|
|
224
|
+
const existing = getActiveJobForDoc(collection, documentId);
|
|
225
|
+
if (existing) return {
|
|
226
|
+
jobId: existing.jobId,
|
|
227
|
+
created: false
|
|
228
|
+
};
|
|
229
|
+
const jobId = createJob(collection, documentId, locales);
|
|
230
|
+
return {
|
|
231
|
+
jobId,
|
|
232
|
+
created: true
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Mark a still-running job as `completed` without performing any
|
|
237
|
+
* translation. Used by the after-change hooks when they decide to
|
|
238
|
+
* short-circuit (e.g. `skipAutoOnPublish: true` from `effective-locales`)
|
|
239
|
+
* AFTER a job has already been seeded by a parent flow — without this,
|
|
240
|
+
* the polling UI would show "Translating…" until the TTL sweep
|
|
241
|
+
* (2 minutes). Notifies SSE subscribers so the client can clear its
|
|
242
|
+
* progress widget immediately. No-op if `jobId` isn't tracked.
|
|
243
|
+
*/ export function completeJobNow(jobId) {
|
|
244
|
+
const job = jobs.get(jobId);
|
|
245
|
+
if (!job) return;
|
|
246
|
+
if (job.status !== 'running') return; // already terminal
|
|
247
|
+
job.status = 'completed';
|
|
248
|
+
mirrorUpdate(job);
|
|
249
|
+
notifySubscribers(jobId);
|
|
250
|
+
}
|
|
251
|
+
export function getActiveJobForDoc(collection, documentId) {
|
|
252
|
+
for (const job of jobs.values()){
|
|
253
|
+
if (job.collection === collection && String(job.documentId) === String(documentId) && job.status === 'running') {
|
|
254
|
+
return job;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
export function subscribe(jobId, callback) {
|
|
260
|
+
if (!subscribers.has(jobId)) {
|
|
261
|
+
subscribers.set(jobId, new Set());
|
|
262
|
+
}
|
|
263
|
+
subscribers.get(jobId).add(callback);
|
|
264
|
+
return ()=>{
|
|
265
|
+
const subs = subscribers.get(jobId);
|
|
266
|
+
if (subs) {
|
|
267
|
+
subs.delete(callback);
|
|
268
|
+
if (subs.size === 0) {
|
|
269
|
+
subscribers.delete(jobId);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Subscribe to "any new job created for this (collection, documentId)".
|
|
276
|
+
* The callback fires once per new job started after this subscription is
|
|
277
|
+
* registered. Used by the `?docId=X` SSE endpoint to push events to open
|
|
278
|
+
* editors the moment a translation begins.
|
|
279
|
+
*/ export function subscribeToDoc(collection, documentId, callback) {
|
|
280
|
+
const key = docKey(collection, documentId);
|
|
281
|
+
if (!docSubscribers.has(key)) {
|
|
282
|
+
docSubscribers.set(key, new Set());
|
|
283
|
+
}
|
|
284
|
+
docSubscribers.get(key).add(callback);
|
|
285
|
+
return ()=>{
|
|
286
|
+
const subs = docSubscribers.get(key);
|
|
287
|
+
if (subs) {
|
|
288
|
+
subs.delete(callback);
|
|
289
|
+
if (subs.size === 0) {
|
|
290
|
+
docSubscribers.delete(key);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
export function cleanup() {
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
for (const [jobId, job] of jobs){
|
|
298
|
+
if (now - job.startedAt > JOB_TTL_MS) {
|
|
299
|
+
jobs.delete(jobId);
|
|
300
|
+
subscribers.delete(jobId);
|
|
301
|
+
mirrorDelete(jobId);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// For testing
|
|
306
|
+
export function _resetStore() {
|
|
307
|
+
jobs.clear();
|
|
308
|
+
subscribers.clear();
|
|
309
|
+
docSubscribers.clear();
|
|
310
|
+
if (cleanupTimer) {
|
|
311
|
+
clearInterval(cleanupTimer);
|
|
312
|
+
cleanupTimer = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function createRateLimiter(rpm) {
|
|
2
|
+
let tokens = rpm;
|
|
3
|
+
const refillRate = rpm / 60; // tokens per second
|
|
4
|
+
let lastRefill = Date.now();
|
|
5
|
+
const waiters = [];
|
|
6
|
+
let refillTimer = null;
|
|
7
|
+
function refill() {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const elapsed = (now - lastRefill) / 1000;
|
|
10
|
+
tokens = Math.min(rpm, tokens + elapsed * refillRate);
|
|
11
|
+
lastRefill = now;
|
|
12
|
+
}
|
|
13
|
+
function drainWaiters() {
|
|
14
|
+
while(waiters.length > 0 && tokens >= 1){
|
|
15
|
+
tokens -= 1;
|
|
16
|
+
const resolve = waiters.shift();
|
|
17
|
+
resolve();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function scheduleRefillCheck() {
|
|
21
|
+
// Clean up any existing timer to prevent leaks
|
|
22
|
+
if (refillTimer !== null) {
|
|
23
|
+
clearTimeout(refillTimer);
|
|
24
|
+
refillTimer = null;
|
|
25
|
+
}
|
|
26
|
+
if (waiters.length === 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const msPerToken = 1000 / refillRate;
|
|
30
|
+
refillTimer = setTimeout(()=>{
|
|
31
|
+
refillTimer = null;
|
|
32
|
+
refill();
|
|
33
|
+
drainWaiters();
|
|
34
|
+
scheduleRefillCheck();
|
|
35
|
+
}, msPerToken);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
acquire () {
|
|
39
|
+
refill();
|
|
40
|
+
if (tokens >= 1) {
|
|
41
|
+
tokens -= 1;
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
}
|
|
44
|
+
return new Promise((resolve)=>{
|
|
45
|
+
const wasEmpty = waiters.length === 0;
|
|
46
|
+
waiters.push(resolve);
|
|
47
|
+
if (wasEmpty) {
|
|
48
|
+
scheduleRefillCheck();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { TranslatableField } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Capture ONLY the localized translatable fields from a doc, stored
|
|
4
|
+
* as a nested object payload that can be passed directly to
|
|
5
|
+
* `payload.update({ data: snapshot })` during the revert action.
|
|
6
|
+
*
|
|
7
|
+
* Security (Decision #30 + F-SEC-SNAPSHOT): Payload's `findByID`
|
|
8
|
+
* returns the full document, which includes `password` hashes,
|
|
9
|
+
* `email`, `roles`, and other `excludeFields` content. Storing that
|
|
10
|
+
* into `bulk-translate-units.preRunSnapshot` jsonb would leak
|
|
11
|
+
* sensitive data into a queryable admin collection. This helper:
|
|
12
|
+
* 1. Rejects any path whose top-level segment is in the forbidden
|
|
13
|
+
* denylist (runtime guard, not just convention).
|
|
14
|
+
* 2. Scopes captured keys to ONLY the paths the caller provided.
|
|
15
|
+
*
|
|
16
|
+
* Shape (Decision #22 v2 / F-DA-REVERT round-trip): paths with dots
|
|
17
|
+
* (e.g. `meta.description`) are stored as NESTED objects
|
|
18
|
+
* (`{ meta: { description: <value> } }`), not as flat keys with
|
|
19
|
+
* literal dots. This makes the snapshot a valid `payload.update`
|
|
20
|
+
* payload directly — revert simply spreads it into `data`.
|
|
21
|
+
*
|
|
22
|
+
* Array-index paths (`sections.0.title`) are intentionally rejected
|
|
23
|
+
* — they expand via the existing resolveTranslatableFields walker
|
|
24
|
+
* before this helper sees them. The CALLER passes the shape paths
|
|
25
|
+
* (`title`, `sections`); this helper does NOT understand per-index
|
|
26
|
+
* traversal.
|
|
27
|
+
*/
|
|
28
|
+
export declare function captureLocalizedSnapshot(doc: Record<string, unknown>, localizedFieldPaths: readonly string[]): Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Schema-hash of the localized translatable fields. Used by the
|
|
31
|
+
* revert action: if the schema changed between snapshot and replay
|
|
32
|
+
* (field renamed, type changed, field de-localized, field removed),
|
|
33
|
+
* the hash differs and the revert refuses unless the admin sets
|
|
34
|
+
* `force: true` (Decision #34 + F-DA-REVERT-SCHEMA).
|
|
35
|
+
*
|
|
36
|
+
* Hash includes `(path, type, localized)` tuples, sorted, so order
|
|
37
|
+
* doesn't matter. The `localized` flag is included because
|
|
38
|
+
* de-localizing a field (true → false) silently breaks revert: the
|
|
39
|
+
* snapshot data lives in a locale-scoped jsonb but the field on the
|
|
40
|
+
* doc is now global, and replaying writes either error or land in
|
|
41
|
+
* the wrong slot. Including the flag in the hash catches this.
|
|
42
|
+
*/
|
|
43
|
+
export declare function hashLocalizedSchema(fields: readonly TranslatableField[]): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { hashFieldContent } from './content-hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* Hard denylist of field names that must NEVER appear in a snapshot.
|
|
4
|
+
* Converts F-SEC-SNAPSHOT from a documentation convention into a
|
|
5
|
+
* runtime invariant — a future call site that passes these by mistake
|
|
6
|
+
* will throw at snapshot time rather than silently leak them into a
|
|
7
|
+
* queryable `bulk-translate-units.preRunSnapshot` jsonb column.
|
|
8
|
+
*/ const FORBIDDEN_TOP_LEVEL_KEYS = new Set([
|
|
9
|
+
'password',
|
|
10
|
+
'email',
|
|
11
|
+
'emailVerified',
|
|
12
|
+
'roles',
|
|
13
|
+
'salt',
|
|
14
|
+
'hash',
|
|
15
|
+
'apiKey',
|
|
16
|
+
'apiKeyIndex',
|
|
17
|
+
'secret',
|
|
18
|
+
'token',
|
|
19
|
+
'resetPasswordToken',
|
|
20
|
+
'loginAttempts',
|
|
21
|
+
'lockUntil'
|
|
22
|
+
]);
|
|
23
|
+
const NUMERIC_SEGMENT_RE = /^\d+$/;
|
|
24
|
+
/**
|
|
25
|
+
* Capture ONLY the localized translatable fields from a doc, stored
|
|
26
|
+
* as a nested object payload that can be passed directly to
|
|
27
|
+
* `payload.update({ data: snapshot })` during the revert action.
|
|
28
|
+
*
|
|
29
|
+
* Security (Decision #30 + F-SEC-SNAPSHOT): Payload's `findByID`
|
|
30
|
+
* returns the full document, which includes `password` hashes,
|
|
31
|
+
* `email`, `roles`, and other `excludeFields` content. Storing that
|
|
32
|
+
* into `bulk-translate-units.preRunSnapshot` jsonb would leak
|
|
33
|
+
* sensitive data into a queryable admin collection. This helper:
|
|
34
|
+
* 1. Rejects any path whose top-level segment is in the forbidden
|
|
35
|
+
* denylist (runtime guard, not just convention).
|
|
36
|
+
* 2. Scopes captured keys to ONLY the paths the caller provided.
|
|
37
|
+
*
|
|
38
|
+
* Shape (Decision #22 v2 / F-DA-REVERT round-trip): paths with dots
|
|
39
|
+
* (e.g. `meta.description`) are stored as NESTED objects
|
|
40
|
+
* (`{ meta: { description: <value> } }`), not as flat keys with
|
|
41
|
+
* literal dots. This makes the snapshot a valid `payload.update`
|
|
42
|
+
* payload directly — revert simply spreads it into `data`.
|
|
43
|
+
*
|
|
44
|
+
* Array-index paths (`sections.0.title`) are intentionally rejected
|
|
45
|
+
* — they expand via the existing resolveTranslatableFields walker
|
|
46
|
+
* before this helper sees them. The CALLER passes the shape paths
|
|
47
|
+
* (`title`, `sections`); this helper does NOT understand per-index
|
|
48
|
+
* traversal.
|
|
49
|
+
*/ export function captureLocalizedSnapshot(doc, localizedFieldPaths) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const path of localizedFieldPaths){
|
|
52
|
+
const segments = path.split('.');
|
|
53
|
+
const top = segments[0];
|
|
54
|
+
if (top && FORBIDDEN_TOP_LEVEL_KEYS.has(top)) {
|
|
55
|
+
throw new Error(`[ai-translate] captureLocalizedSnapshot: refusing to snapshot sensitive field path "${path}" (top-level key "${top}" is in the F-SEC-SNAPSHOT denylist). Caller must exclude this path before calling.`);
|
|
56
|
+
}
|
|
57
|
+
for (const seg of segments){
|
|
58
|
+
if (NUMERIC_SEGMENT_RE.test(seg)) {
|
|
59
|
+
throw new Error(`[ai-translate] captureLocalizedSnapshot: array-index paths are not supported here. Pass the shape path (e.g. "sections") instead of the indexed path ("${path}"). Array-item traversal lives in resolveTranslatableFields.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const value = resolvePathValue(doc, segments);
|
|
63
|
+
if (value !== undefined) {
|
|
64
|
+
setNestedPath(out, segments, value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Schema-hash of the localized translatable fields. Used by the
|
|
71
|
+
* revert action: if the schema changed between snapshot and replay
|
|
72
|
+
* (field renamed, type changed, field de-localized, field removed),
|
|
73
|
+
* the hash differs and the revert refuses unless the admin sets
|
|
74
|
+
* `force: true` (Decision #34 + F-DA-REVERT-SCHEMA).
|
|
75
|
+
*
|
|
76
|
+
* Hash includes `(path, type, localized)` tuples, sorted, so order
|
|
77
|
+
* doesn't matter. The `localized` flag is included because
|
|
78
|
+
* de-localizing a field (true → false) silently breaks revert: the
|
|
79
|
+
* snapshot data lives in a locale-scoped jsonb but the field on the
|
|
80
|
+
* doc is now global, and replaying writes either error or land in
|
|
81
|
+
* the wrong slot. Including the flag in the hash catches this.
|
|
82
|
+
*/ export function hashLocalizedSchema(fields) {
|
|
83
|
+
const sorted = [
|
|
84
|
+
...fields
|
|
85
|
+
].map((f)=>`${f.path}:${f.type}:${f.localized ? 'l' : 'g'}`).sort().join('\n');
|
|
86
|
+
return hashFieldContent(sorted);
|
|
87
|
+
}
|
|
88
|
+
function resolvePathValue(doc, segments) {
|
|
89
|
+
let cursor = doc;
|
|
90
|
+
for (const part of segments){
|
|
91
|
+
if (cursor === null || cursor === undefined) return undefined;
|
|
92
|
+
if (typeof cursor !== 'object') return undefined;
|
|
93
|
+
cursor = cursor[part];
|
|
94
|
+
}
|
|
95
|
+
return cursor;
|
|
96
|
+
}
|
|
97
|
+
function setNestedPath(target, segments, value) {
|
|
98
|
+
let cursor = target;
|
|
99
|
+
for(let i = 0; i < segments.length - 1; i++){
|
|
100
|
+
const part = segments[i];
|
|
101
|
+
const existing = cursor[part];
|
|
102
|
+
if (existing === null || existing === undefined || typeof existing !== 'object') {
|
|
103
|
+
cursor[part] = {};
|
|
104
|
+
}
|
|
105
|
+
cursor = cursor[part];
|
|
106
|
+
}
|
|
107
|
+
cursor[segments[segments.length - 1]] = value;
|
|
108
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { TranslateRequest } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for the prompt + response schema shared by every
|
|
5
|
+
* AI-SDK-backed provider. Previously each provider duplicated this code
|
|
6
|
+
* (~100 lines × 4) with subtle drift.
|
|
7
|
+
*
|
|
8
|
+
* The Zod schema is enforced by `generateObject` — the model is required
|
|
9
|
+
* to produce an `items` array. Single-item, multi-item, doesn't matter:
|
|
10
|
+
* the response shape is structural, not parsed from a JSON string.
|
|
11
|
+
*/
|
|
12
|
+
export declare const TranslateResponseSchema: z.ZodObject<{
|
|
13
|
+
items: z.ZodArray<z.ZodObject<{
|
|
14
|
+
id: z.ZodString;
|
|
15
|
+
text: z.ZodString;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
export type TranslateResponseObject = z.infer<typeof TranslateResponseSchema>;
|
|
19
|
+
export declare function buildSystemPrompt(request: TranslateRequest): string;
|
|
20
|
+
export declare function buildUserMessage(request: TranslateRequest): string;
|
|
21
|
+
/**
|
|
22
|
+
* Pre-call estimate of token usage and cost. Heuristic-only — `ai` SDK has
|
|
23
|
+
* no dry-run token counter. Each provider plugs in its own pricing table.
|
|
24
|
+
*/
|
|
25
|
+
export declare function estimateUsage(request: TranslateRequest, pricing?: {
|
|
26
|
+
input: number;
|
|
27
|
+
output: number;
|
|
28
|
+
}): {
|
|
29
|
+
inputTokens: number;
|
|
30
|
+
estimatedCostUsd?: number;
|
|
31
|
+
};
|