@focus-reactive/payload-plugin-translator 0.6.2 → 0.7.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/README.md +55 -0
- package/dist/core/content-projection/computeSourceFingerprint.d.ts +15 -0
- package/dist/core/content-projection/computeSourceFingerprint.js +19 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/dist/core/provenance/ProvenanceStore.interface.d.ts +99 -0
- package/dist/core/provenance/ProvenanceStore.interface.js +37 -0
- package/dist/core/provenance/index.d.ts +1 -0
- package/dist/core/provenance/index.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/plugin.d.ts +26 -0
- package/dist/plugin.js +55 -15
- package/dist/server/features/translate-document/handler.d.ts +3 -1
- package/dist/server/features/translate-document/handler.js +27 -1
- package/dist/server/modules/lifecycle/LifecycleNotifier.d.ts +21 -0
- package/dist/server/modules/lifecycle/LifecycleNotifier.js +38 -0
- package/dist/server/modules/lifecycle/index.d.ts +4 -0
- package/dist/server/modules/lifecycle/index.js +7 -0
- package/dist/server/modules/lifecycle/taskMapping.d.ts +7 -0
- package/dist/server/modules/lifecycle/taskMapping.js +16 -0
- package/dist/server/modules/lifecycle/types.d.ts +45 -0
- package/dist/server/modules/lifecycle/types.js +21 -0
- package/dist/server/modules/lifecycle/withQueuedNotification.d.ts +9 -0
- package/dist/server/modules/lifecycle/withQueuedNotification.js +19 -0
- package/dist/server/modules/provenance/PayloadProvenanceStore.d.ts +21 -0
- package/dist/server/modules/provenance/PayloadProvenanceStore.js +109 -0
- package/dist/server/modules/provenance/index.d.ts +5 -0
- package/dist/server/modules/provenance/index.js +8 -0
- package/dist/server/modules/provenance/provenanceCleanupHook.d.ts +18 -0
- package/dist/server/modules/provenance/provenanceCleanupHook.js +41 -0
- package/dist/server/modules/provenance/provenanceCollection.d.ts +23 -0
- package/dist/server/modules/provenance/provenanceCollection.js +82 -0
- package/dist/server/modules/provenance/slugGuard.d.ts +16 -0
- package/dist/server/modules/provenance/slugGuard.js +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -141,6 +141,8 @@ Allowed on **`text`, `textarea`, and `richText`** fields (a compile error on oth
|
|
|
141
141
|
| `access` | `AccessGuard` | No | `undefined` | Access guard (`{ check }`) for the translation endpoints; omit to leave them open. |
|
|
142
142
|
| `basePath` | `string` | No | `'/translate'` | Base path for the plugin's API endpoints. |
|
|
143
143
|
| `levels` | `TranslationLevel[]` | No | `[documentLevel(), collectionLevel()]` | Which surfaces to enable — see [Translation surfaces](#translation-surfaces-levels). |
|
|
144
|
+
| `provenance` | `boolean \| { slug?: string }` | No | `false` (disabled) | Opt in to recording a provenance record per translation. _Since v0.7.0._ See [Provenance](#provenance-opt-in) below. |
|
|
145
|
+
| `lifecycle` | `{ onQueued?, onCompleted?, onFailed? }` | No | `undefined` | Server-side callbacks fired around each task. _Since v0.7.0._ See [Lifecycle callbacks](#lifecycle-callbacks). |
|
|
144
146
|
|
|
145
147
|
```typescript
|
|
146
148
|
translatorPlugin({
|
|
@@ -151,6 +153,59 @@ translatorPlugin({
|
|
|
151
153
|
});
|
|
152
154
|
```
|
|
153
155
|
|
|
156
|
+
### Provenance (opt-in)
|
|
157
|
+
|
|
158
|
+
_Since v0.7.0._
|
|
159
|
+
|
|
160
|
+
Set `provenance: true` (or `{}`) to record, after each successful translation, a durable per-locale
|
|
161
|
+
provenance entry — what source state a translation was derived from. Use `{ slug }` to customise the
|
|
162
|
+
sidecar collection's slug (default `'translator-provenance'`), e.g. to resolve a name collision with
|
|
163
|
+
one of your own collections. Omit (or set `false`) to leave everything as-is: no collection, no
|
|
164
|
+
migration, no behavior change.
|
|
165
|
+
|
|
166
|
+
Enabling it adds a plugin-managed, hidden sidecar collection to your config. **On a SQL database
|
|
167
|
+
(Postgres/SQLite) this requires a migration** — run `payload migrate:create` then `payload migrate`
|
|
168
|
+
(or let dev push apply it in development). MongoDB infers the collection with no migration step.
|
|
169
|
+
|
|
170
|
+
When a translated document is deleted, its provenance rows are cleaned up automatically (across all
|
|
171
|
+
locales). The cleanup is best-effort — a failure is logged and never blocks the delete. The exported
|
|
172
|
+
`TranslationProvenanceRecord` type describes a stored row if you query the sidecar collection directly.
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
translatorPlugin({
|
|
176
|
+
collections: [Posts, Pages],
|
|
177
|
+
translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
|
|
178
|
+
runner: createPayloadJobsRunner(),
|
|
179
|
+
provenance: true, // or { slug: "my-provenance" }
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Lifecycle callbacks
|
|
184
|
+
|
|
185
|
+
_Since v0.7.0._
|
|
186
|
+
|
|
187
|
+
Optional server-side hooks fired around each translation task — for logging, notifications, cache
|
|
188
|
+
invalidation, or feeding a dashboard. They need no schema or migration and are independent of the
|
|
189
|
+
`provenance` opt-in. Each receives a `TranslationTask` descriptor
|
|
190
|
+
(`{ collection, id, sourceLng, targetLng, strategy }`); `onFailed` also receives the error.
|
|
191
|
+
|
|
192
|
+
A callback that throws is caught and logged — it never fails the translation. `onCompleted` /
|
|
193
|
+
`onFailed` fire per execution attempt (the Payload Jobs runner may retry a failed task); `onQueued`
|
|
194
|
+
fires once at enqueue.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
translatorPlugin({
|
|
198
|
+
collections: [Posts, Pages],
|
|
199
|
+
translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
|
|
200
|
+
runner: createPayloadJobsRunner(),
|
|
201
|
+
lifecycle: {
|
|
202
|
+
onQueued: (task) => console.log("queued", task),
|
|
203
|
+
onCompleted: (task) => console.log("done", task),
|
|
204
|
+
onFailed: (task, error) => console.error("failed", task, error),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
154
209
|
### Providers
|
|
155
210
|
|
|
156
211
|
#### OpenAI (built in) — `createOpenAIProvider(config)`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FieldLike } from "../field-traversal";
|
|
2
|
+
/**
|
|
3
|
+
* The staleness baseline: a stable hash of a document's translatable content.
|
|
4
|
+
* `CURRENT = computeSourceFingerprint(sourceDoc, schema)` — the value provenance stores at translation
|
|
5
|
+
* time and #50 recomputes to detect source drift. A named composition of
|
|
6
|
+
* {@link projectTranslatableContent} + {@link fingerprint} so the "how to fingerprint source content"
|
|
7
|
+
* contract lives in one place (callers don't re-wire the two primitives). Reorder-invariant and
|
|
8
|
+
* text-nodes-only, inheriting those properties from its parts.
|
|
9
|
+
*
|
|
10
|
+
* @param doc - The source document to fingerprint (never mutated).
|
|
11
|
+
* @param schema - The ORIGINAL (un-sanitized) field schema.
|
|
12
|
+
* @returns A hex sha256 digest of the projected translatable content.
|
|
13
|
+
* @since 0.7.0
|
|
14
|
+
*/
|
|
15
|
+
export declare function computeSourceFingerprint(doc: Record<string, unknown>, schema: FieldLike[]): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { projectTranslatableContent } from "./contentProjector";
|
|
2
|
+
import { fingerprint } from "./fingerprinter";
|
|
3
|
+
/**
|
|
4
|
+
* The staleness baseline: a stable hash of a document's translatable content.
|
|
5
|
+
* `CURRENT = computeSourceFingerprint(sourceDoc, schema)` — the value provenance stores at translation
|
|
6
|
+
* time and #50 recomputes to detect source drift. A named composition of
|
|
7
|
+
* {@link projectTranslatableContent} + {@link fingerprint} so the "how to fingerprint source content"
|
|
8
|
+
* contract lives in one place (callers don't re-wire the two primitives). Reorder-invariant and
|
|
9
|
+
* text-nodes-only, inheriting those properties from its parts.
|
|
10
|
+
*
|
|
11
|
+
* @param doc - The source document to fingerprint (never mutated).
|
|
12
|
+
* @param schema - The ORIGINAL (un-sanitized) field schema.
|
|
13
|
+
* @returns A hex sha256 digest of the projected translatable content.
|
|
14
|
+
* @since 0.7.0
|
|
15
|
+
*/ export function computeSourceFingerprint(doc, schema) {
|
|
16
|
+
return fingerprint(projectTranslatableContent(doc, schema));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=computeSourceFingerprint.js.map
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export type { TranslationProvider, TranslationInput, TranslationOutput, TranslationIndex, } from "./translation-providers";
|
|
2
2
|
export { TranslationPipeline, translateContent } from "./translation-pipeline";
|
|
3
3
|
export type { TranslateContentArgs, TranslationStrategy } from "./translation-pipeline";
|
|
4
|
+
export type { ProvenanceKey, ProvenanceStore, TranslationProvenanceRecord } from "./provenance";
|
|
4
5
|
export { projectTranslatableContent } from "./content-projection/contentProjector";
|
|
5
6
|
export type { ProjectionEntry } from "./content-projection/contentProjector";
|
|
6
7
|
export { fingerprint } from "./content-projection/fingerprinter";
|
|
8
|
+
export { computeSourceFingerprint } from "./content-projection/computeSourceFingerprint";
|
|
7
9
|
export { makeIdPath } from "./content-projection/idPath";
|
|
8
10
|
export type { IdPath, PathSegment } from "./content-projection/idPath";
|
|
9
11
|
export { classifyField, findFieldByPath, hasFields, isBlockItem, isTabsField, matchElementById, resolveBlockFields, tabScopes, walkFields, } from "./field-traversal";
|
package/dist/core/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { TranslationPipeline, translateContent } from "./translation-pipeline";
|
|
|
7
7
|
// Content projection
|
|
8
8
|
export { projectTranslatableContent } from "./content-projection/contentProjector";
|
|
9
9
|
export { fingerprint } from "./content-projection/fingerprinter";
|
|
10
|
+
export { computeSourceFingerprint } from "./content-projection/computeSourceFingerprint";
|
|
10
11
|
export { makeIdPath } from "./content-projection/idPath";
|
|
11
12
|
// Field traversal
|
|
12
13
|
export { classifyField, findFieldByPath, hasFields, isBlockItem, isTabsField, matchElementById, resolveBlockFields, tabScopes, walkFields } from "./field-traversal";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation provenance — the durable record of *what source state a target locale was translated
|
|
3
|
+
* from* (design: `docs/plans/2026-06-26-translation-provenance-and-lifecycle-design.md`).
|
|
4
|
+
*
|
|
5
|
+
* These are framework-agnostic contracts: plain data plus a storage port, with no Payload types, so
|
|
6
|
+
* they live in the core. Only the Payload-backed implementation of {@link ProvenanceStore} (in the
|
|
7
|
+
* plugin adapter) knows about Payload.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* One provenance receipt: a single `(collection, document, target locale)` translation.
|
|
11
|
+
*
|
|
12
|
+
* `documentId` is always a string (Payload ids may be string or number — callers stringify on the
|
|
13
|
+
* way in) and `translatedAt` is an ISO-8601 string, so the record shape is stable across databases.
|
|
14
|
+
*
|
|
15
|
+
* @since 0.7.0
|
|
16
|
+
*/
|
|
17
|
+
export interface TranslationProvenanceRecord {
|
|
18
|
+
/** Slug of the translated collection. */
|
|
19
|
+
collectionSlug: string;
|
|
20
|
+
/** Stringified id of the translated document. */
|
|
21
|
+
documentId: string;
|
|
22
|
+
/** Locale that was written (translated into). */
|
|
23
|
+
targetLocale: string;
|
|
24
|
+
/** Locale the translation was derived from. */
|
|
25
|
+
sourceLocale: string;
|
|
26
|
+
/**
|
|
27
|
+
* Fingerprint of the source content at translation time —
|
|
28
|
+
* `fingerprint(projectTranslatableContent(sourceDoc, schema))`. Staleness (later, in #50) is
|
|
29
|
+
* `currentSourceFingerprint !== sourceFingerprint`.
|
|
30
|
+
*/
|
|
31
|
+
sourceFingerprint: string;
|
|
32
|
+
/** ISO-8601 timestamp of the last successful translation. */
|
|
33
|
+
translatedAt: string;
|
|
34
|
+
/**
|
|
35
|
+
* The source fingerprint an editor acknowledged as "stale but leave it" (#50's dismissable
|
|
36
|
+
* indicator). `null` until dismissed. Written by #50 — carried here now so no later migration is
|
|
37
|
+
* needed.
|
|
38
|
+
*/
|
|
39
|
+
dismissedFingerprint: string | null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The composite key that identifies exactly one provenance record. Also the {@link ProvenanceStore}
|
|
43
|
+
* `upsert`/`find` match key.
|
|
44
|
+
*
|
|
45
|
+
* @since 0.7.0
|
|
46
|
+
*/
|
|
47
|
+
export interface ProvenanceKey {
|
|
48
|
+
/** Slug of the translated collection. */
|
|
49
|
+
collectionSlug: string;
|
|
50
|
+
/** Stringified id of the translated document. */
|
|
51
|
+
documentId: string;
|
|
52
|
+
/** Locale that was written (translated into). */
|
|
53
|
+
targetLocale: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Storage port for translation provenance. The core depends only on this interface; the plugin
|
|
57
|
+
* supplies a Payload-backed implementation. Kept minimal so it maps cleanly onto any store.
|
|
58
|
+
*
|
|
59
|
+
* @since 0.7.0
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const store: ProvenanceStore = new PayloadProvenanceStore(payload, "translator-provenance");
|
|
64
|
+
* await store.upsert({
|
|
65
|
+
* collectionSlug: "posts",
|
|
66
|
+
* documentId: "42",
|
|
67
|
+
* targetLocale: "de",
|
|
68
|
+
* sourceLocale: "en",
|
|
69
|
+
* sourceFingerprint: fp,
|
|
70
|
+
* translatedAt: new Date().toISOString(),
|
|
71
|
+
* dismissedFingerprint: null,
|
|
72
|
+
* });
|
|
73
|
+
* const record = await store.find({ collectionSlug: "posts", documentId: "42", targetLocale: "de" });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export interface ProvenanceStore {
|
|
77
|
+
/**
|
|
78
|
+
* Create the record, or update it in place if one already exists for its
|
|
79
|
+
* `(collectionSlug, documentId, targetLocale)` key. Never produces a duplicate for the same key.
|
|
80
|
+
*
|
|
81
|
+
* @param record - The provenance receipt to persist.
|
|
82
|
+
*/
|
|
83
|
+
upsert(record: TranslationProvenanceRecord): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Look up the record for a key.
|
|
86
|
+
*
|
|
87
|
+
* @param key - The `(collectionSlug, documentId, targetLocale)` identity.
|
|
88
|
+
* @returns The stored record, or `null` if none exists.
|
|
89
|
+
*/
|
|
90
|
+
find(key: ProvenanceKey): Promise<TranslationProvenanceRecord | null>;
|
|
91
|
+
/**
|
|
92
|
+
* Delete every record for a document (all target locales). Used to cascade-clean when the source
|
|
93
|
+
* document is deleted.
|
|
94
|
+
*
|
|
95
|
+
* @param collectionSlug - Slug of the deleted document's collection.
|
|
96
|
+
* @param documentId - Stringified id of the deleted document.
|
|
97
|
+
*/
|
|
98
|
+
deleteByDocument(collectionSlug: string, documentId: string): Promise<void>;
|
|
99
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation provenance — the durable record of *what source state a target locale was translated
|
|
3
|
+
* from* (design: `docs/plans/2026-06-26-translation-provenance-and-lifecycle-design.md`).
|
|
4
|
+
*
|
|
5
|
+
* These are framework-agnostic contracts: plain data plus a storage port, with no Payload types, so
|
|
6
|
+
* they live in the core. Only the Payload-backed implementation of {@link ProvenanceStore} (in the
|
|
7
|
+
* plugin adapter) knows about Payload.
|
|
8
|
+
*/ /**
|
|
9
|
+
* One provenance receipt: a single `(collection, document, target locale)` translation.
|
|
10
|
+
*
|
|
11
|
+
* `documentId` is always a string (Payload ids may be string or number — callers stringify on the
|
|
12
|
+
* way in) and `translatedAt` is an ISO-8601 string, so the record shape is stable across databases.
|
|
13
|
+
*
|
|
14
|
+
* @since 0.7.0
|
|
15
|
+
*/ /**
|
|
16
|
+
* Storage port for translation provenance. The core depends only on this interface; the plugin
|
|
17
|
+
* supplies a Payload-backed implementation. Kept minimal so it maps cleanly onto any store.
|
|
18
|
+
*
|
|
19
|
+
* @since 0.7.0
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const store: ProvenanceStore = new PayloadProvenanceStore(payload, "translator-provenance");
|
|
24
|
+
* await store.upsert({
|
|
25
|
+
* collectionSlug: "posts",
|
|
26
|
+
* documentId: "42",
|
|
27
|
+
* targetLocale: "de",
|
|
28
|
+
* sourceLocale: "en",
|
|
29
|
+
* sourceFingerprint: fp,
|
|
30
|
+
* translatedAt: new Date().toISOString(),
|
|
31
|
+
* dismissedFingerprint: null,
|
|
32
|
+
* });
|
|
33
|
+
* const record = await store.find({ collectionSlug: "posts", documentId: "42", targetLocale: "de" });
|
|
34
|
+
* ```
|
|
35
|
+
*/ export { };
|
|
36
|
+
|
|
37
|
+
//# sourceMappingURL=ProvenanceStore.interface.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { ProvenanceKey, ProvenanceStore, TranslationProvenanceRecord, } from "./ProvenanceStore.interface";
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { translatorPlugin } from "./plugin";
|
|
2
2
|
export type { TranslatorPluginConfig } from "./plugin";
|
|
3
|
+
export type { TranslationTask, TranslationLifecycleCallbacks } from "./server/modules/lifecycle";
|
|
4
|
+
export type { TranslationProvenanceRecord } from "./core";
|
|
3
5
|
export type { AccessGuard, AccessGuardRequest } from "./types/AccessGuard";
|
|
4
6
|
export type { TranslationProvider, TranslationInput, TranslationOutput } from "./core";
|
|
5
7
|
export { createOpenAIProvider } from "./translation-providers";
|
package/dist/plugin.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { CollectionConfig, Config } from "payload";
|
|
|
2
2
|
import type { AccessGuard } from "./types/AccessGuard";
|
|
3
3
|
import type { TranslationProvider } from "./core/translation-providers";
|
|
4
4
|
import type { TaskRunnerProvider } from "./server/modules/task-runner";
|
|
5
|
+
import type { TranslationLifecycleCallbacks } from "./server/modules/lifecycle";
|
|
5
6
|
import type { TranslationLevel } from "./server/modules/translation-levels";
|
|
6
7
|
export type TranslatorPluginConfig = {
|
|
7
8
|
/**
|
|
@@ -43,6 +44,31 @@ export type TranslatorPluginConfig = {
|
|
|
43
44
|
* @since 0.5.0
|
|
44
45
|
*/
|
|
45
46
|
levels?: TranslationLevel[];
|
|
47
|
+
/**
|
|
48
|
+
* Opt-in translation provenance — a durable, per-locale record of what source state each
|
|
49
|
+
* translation was derived from, stored in a plugin-managed sidecar collection. Set `true` (or `{}`)
|
|
50
|
+
* to enable with the default slug, or `{ slug }` to customise it. Omit (or `false`) to disable
|
|
51
|
+
* entirely — no collection, no migration, no behaviour change. **Enabling it on a SQL database adds
|
|
52
|
+
* a table**: generate and run a migration (`payload migrate:create` + `payload migrate`; dev push in
|
|
53
|
+
* development; MongoDB infers it with no migration). The lifecycle callbacks are unaffected by this
|
|
54
|
+
* flag.
|
|
55
|
+
* @since 0.7.0
|
|
56
|
+
*/
|
|
57
|
+
provenance?: boolean | {
|
|
58
|
+
/**
|
|
59
|
+
* Slug for the sidecar collection. Change it only to resolve a collision with an existing
|
|
60
|
+
* collection.
|
|
61
|
+
* @default 'translator-provenance'
|
|
62
|
+
*/
|
|
63
|
+
slug?: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Optional server-side lifecycle callbacks fired around each translation task
|
|
67
|
+
* (`onQueued` / `onCompleted` / `onFailed`). Always available — no schema, no migration, not gated
|
|
68
|
+
* by `provenance`. A throwing callback is caught and logged, never failing the translation.
|
|
69
|
+
* @since 0.7.0
|
|
70
|
+
*/
|
|
71
|
+
lifecycle?: TranslationLifecycleCallbacks;
|
|
46
72
|
};
|
|
47
73
|
/** @deprecated Use `TranslatorPluginConfig` instead */
|
|
48
74
|
export type TranslateCollectionPluginConfig = TranslatorPluginConfig;
|
package/dist/plugin.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { CacheProviderExport } from "./client/app/cache/CacheProvider.export";
|
|
2
|
+
import { DEFAULT_PROVENANCE_SLUG, PayloadProvenanceStore, assertProvenanceSlugFree, injectProvenanceCleanup, isProvenanceCollection, makeProvenanceCollection } from "./server/modules/provenance";
|
|
2
3
|
import { TranslateDocumentHandler } from "./server/features/translate-document";
|
|
4
|
+
import { LifecycleNotifier, taskFromHandlerInput, withQueuedNotification } from "./server/modules/lifecycle";
|
|
3
5
|
import { documentLevel, collectionLevel } from "./composition/levels";
|
|
4
6
|
import { PluginConfigBuilder } from "./server/modules/translation-levels/PluginConfigBuilder";
|
|
5
7
|
import { normalizePath } from "./server/shared";
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the opt-in `provenance` config to a sidecar slug, or `null` when disabled.
|
|
10
|
+
* `false`/omitted → off; `true` or `{}` → on with the default slug; `{ slug }` → on with that slug.
|
|
11
|
+
*/ function resolveProvenanceSlug(provenance) {
|
|
12
|
+
if (!provenance) return null;
|
|
13
|
+
if (provenance === true) return DEFAULT_PROVENANCE_SLUG;
|
|
14
|
+
// `||` (not `??`) so an empty/blank slug falls back to the default instead of silently disabling.
|
|
15
|
+
return provenance.slug || DEFAULT_PROVENANCE_SLUG;
|
|
16
|
+
}
|
|
6
17
|
/** @deprecated Use `translatorPlugin` function instead */ export class TranslateCollectionPlugin {
|
|
7
18
|
pluginConfig;
|
|
8
19
|
constructor(pluginConfig){
|
|
@@ -10,7 +21,8 @@ import { normalizePath } from "./server/shared";
|
|
|
10
21
|
}
|
|
11
22
|
init() {
|
|
12
23
|
return async (config)=>{
|
|
13
|
-
const { access, translationProvider, runner, collections, levels, basePath: rawBasePath = "/translate" } = this.pluginConfig;
|
|
24
|
+
const { access, translationProvider, runner, collections, levels, provenance, lifecycle, basePath: rawBasePath = "/translate" } = this.pluginConfig;
|
|
25
|
+
const lifecycleCallbacks = lifecycle ?? {};
|
|
14
26
|
// Build schema map from deep-cloned collections
|
|
15
27
|
// Deep clone is required because Payload mutates the original collection objects,
|
|
16
28
|
// removing `localized: true` from nested fields during sanitization.
|
|
@@ -25,17 +37,31 @@ import { normalizePath } from "./server/shared";
|
|
|
25
37
|
]));
|
|
26
38
|
const collectionSlugs = new Set(schemaMap.keys());
|
|
27
39
|
const basePath = normalizePath(rawBasePath);
|
|
28
|
-
const
|
|
40
|
+
const provenanceSlug = resolveProvenanceSlug(provenance);
|
|
41
|
+
if (provenanceSlug) {
|
|
42
|
+
const existing = (config.collections ?? []).filter((collection)=>!isProvenanceCollection(collection));
|
|
43
|
+
assertProvenanceSlugFree(provenanceSlug, existing);
|
|
44
|
+
}
|
|
45
|
+
const provenanceStoreFactory = provenanceSlug ? (p)=>new PayloadProvenanceStore(p, provenanceSlug) : undefined;
|
|
46
|
+
const translateHandler = new TranslateDocumentHandler(translationProvider, schemaMap, provenanceStoreFactory);
|
|
29
47
|
const runnerContext = {
|
|
30
48
|
handler: async (payload, input)=>{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
const notifier = new LifecycleNotifier(lifecycleCallbacks, payload.logger);
|
|
50
|
+
const task = taskFromHandlerInput(input);
|
|
51
|
+
try {
|
|
52
|
+
await translateHandler.handle(payload, {
|
|
53
|
+
collection: input.collection,
|
|
54
|
+
collectionId: input.collectionId,
|
|
55
|
+
sourceLng: input.sourceLng,
|
|
56
|
+
targetLng: input.targetLng,
|
|
57
|
+
strategy: input.strategy,
|
|
58
|
+
publishOnTranslation: input.publishOnTranslation
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
await notifier.failed(task, error);
|
|
62
|
+
throw error; // rethrow so the runner marks the job failed
|
|
63
|
+
}
|
|
64
|
+
await notifier.completed(task);
|
|
39
65
|
},
|
|
40
66
|
collections: Array.from(collectionSlugs)
|
|
41
67
|
};
|
|
@@ -43,8 +69,13 @@ import { normalizePath } from "./server/shared";
|
|
|
43
69
|
// Bind the context once so routes receive a self-sufficient factory: the
|
|
44
70
|
// runner needs no mutable per-instance handler state and create() has no
|
|
45
71
|
// "configure() must run first" ordering coupling (translator plan, 0c).
|
|
72
|
+
// When an `onQueued` callback is set, decorate the runner so `enqueue` fires it.
|
|
46
73
|
const taskRunnerFactory = {
|
|
47
|
-
create: (payload)=>
|
|
74
|
+
create: (payload)=>{
|
|
75
|
+
const taskRunner = runner.create(payload, runnerContext.handler);
|
|
76
|
+
if (!lifecycleCallbacks.onQueued) return taskRunner;
|
|
77
|
+
return withQueuedNotification(taskRunner, new LifecycleNotifier(lifecycleCallbacks, payload.logger));
|
|
78
|
+
}
|
|
48
79
|
};
|
|
49
80
|
const activeLevels = levels ?? [
|
|
50
81
|
documentLevel(),
|
|
@@ -59,11 +90,20 @@ import { normalizePath } from "./server/shared";
|
|
|
59
90
|
translationProvider
|
|
60
91
|
});
|
|
61
92
|
for (const level of activeLevels)level.extend(builder);
|
|
62
|
-
// Plugin-level contributions, routed through the same single config-writer:
|
|
63
|
-
// - the runner's jobs/autorun/onInit modifier (the builder applies it first,
|
|
64
|
-
// so a modifier returning a fresh config object doesn't drop later writes),
|
|
65
|
-
// - the always-on client cache provider.
|
|
66
93
|
builder.addConfigModifier(runnerConfigModifier);
|
|
94
|
+
if (provenanceSlug && provenanceStoreFactory) {
|
|
95
|
+
builder.addConfigModifier((cfg)=>{
|
|
96
|
+
const alreadyAdded = cfg.collections?.some((collection)=>collection.slug === provenanceSlug && isProvenanceCollection(collection));
|
|
97
|
+
if (!alreadyAdded) {
|
|
98
|
+
cfg.collections = [
|
|
99
|
+
...cfg.collections ?? [],
|
|
100
|
+
makeProvenanceCollection(provenanceSlug)
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
injectProvenanceCleanup(cfg, collectionSlugs, provenanceStoreFactory, provenanceSlug);
|
|
104
|
+
return cfg;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
67
107
|
builder.addAdminProvider(new CacheProviderExport(basePath));
|
|
68
108
|
// The single place the Payload config is mutated.
|
|
69
109
|
return builder.applyTo(config);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Payload } from "payload";
|
|
2
2
|
import type { Handler } from "../../shared";
|
|
3
3
|
import type { TranslationProvider } from "../../../core/translation-providers";
|
|
4
|
+
import type { ProvenanceStoreFactory } from "../../modules/provenance";
|
|
4
5
|
import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap";
|
|
5
6
|
import type { TranslateDocumentInput, TranslateDocumentOutput } from "./model";
|
|
6
7
|
export type TranslateDocumentDependencies = {
|
|
@@ -13,7 +14,8 @@ export type TranslateDocumentDependencies = {
|
|
|
13
14
|
export declare class TranslateDocumentHandler implements Handler<TranslateDocumentInput, TranslateDocumentOutput> {
|
|
14
15
|
private readonly translationProvider;
|
|
15
16
|
private readonly schemaMap;
|
|
16
|
-
|
|
17
|
+
private readonly provenanceStoreFactory?;
|
|
18
|
+
constructor(translationProvider: TranslationProvider, schemaMap: CollectionSchemaMap, provenanceStoreFactory?: ProvenanceStoreFactory);
|
|
17
19
|
handle(payload: Payload, input: TranslateDocumentInput): Promise<TranslateDocumentOutput>;
|
|
18
20
|
private saveTranslatedDocument;
|
|
19
21
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { APIError } from "payload";
|
|
2
2
|
import { translateContent } from "../../../core/translation-pipeline";
|
|
3
|
+
import { computeSourceFingerprint } from "../../../core/content-projection/computeSourceFingerprint";
|
|
3
4
|
/**
|
|
4
5
|
* Translates a single document from source language to target language
|
|
5
6
|
*/ export class TranslateDocumentHandler {
|
|
6
7
|
translationProvider;
|
|
7
8
|
schemaMap;
|
|
8
|
-
|
|
9
|
+
provenanceStoreFactory;
|
|
10
|
+
constructor(translationProvider, schemaMap, provenanceStoreFactory){
|
|
9
11
|
this.translationProvider = translationProvider;
|
|
10
12
|
this.schemaMap = schemaMap;
|
|
13
|
+
this.provenanceStoreFactory = provenanceStoreFactory;
|
|
11
14
|
}
|
|
12
15
|
async handle(payload, input) {
|
|
13
16
|
const { collection, collectionId, sourceLng, targetLng, strategy, publishOnTranslation } = input;
|
|
@@ -41,6 +44,29 @@ import { translateContent } from "../../../core/translation-pipeline";
|
|
|
41
44
|
};
|
|
42
45
|
const collectionConfig = payload.collections[collection].config;
|
|
43
46
|
await this.saveTranslatedDocument(payload, collection, collectionId, translatedData, targetLng, sourceLng, collectionConfig, publishOnTranslation);
|
|
47
|
+
if (this.provenanceStoreFactory) {
|
|
48
|
+
const store = this.provenanceStoreFactory(payload);
|
|
49
|
+
try {
|
|
50
|
+
await store.upsert({
|
|
51
|
+
collectionSlug: collection,
|
|
52
|
+
documentId: String(collectionId),
|
|
53
|
+
targetLocale: targetLng,
|
|
54
|
+
sourceLocale: sourceLng,
|
|
55
|
+
sourceFingerprint: computeSourceFingerprint(sourceData, schema),
|
|
56
|
+
translatedAt: new Date().toISOString(),
|
|
57
|
+
dismissedFingerprint: null
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
payload.logger.error({
|
|
61
|
+
err: error,
|
|
62
|
+
collection,
|
|
63
|
+
documentId: String(collectionId),
|
|
64
|
+
targetLocale: targetLng,
|
|
65
|
+
sourceLocale: sourceLng,
|
|
66
|
+
msg: "translator: failed to record translation provenance"
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
44
70
|
return {
|
|
45
71
|
success: true
|
|
46
72
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TranslationLifecycleCallbacks, TranslationTask } from "./types";
|
|
2
|
+
/** Minimal logger surface used for swallowed callback errors (Payload's `payload.logger` satisfies it). */
|
|
3
|
+
type LifecycleLogger = {
|
|
4
|
+
error: (obj: unknown) => void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Fires the host's translation lifecycle callbacks, best-effort. Each callback is invoked in a
|
|
8
|
+
* try/catch (covering both synchronous throws and rejected promises); a failure is logged and
|
|
9
|
+
* swallowed, never propagated — a broken host callback must not fail the translation. Absent
|
|
10
|
+
* callbacks are a no-op.
|
|
11
|
+
*/
|
|
12
|
+
export declare class LifecycleNotifier {
|
|
13
|
+
private readonly callbacks;
|
|
14
|
+
private readonly logger;
|
|
15
|
+
constructor(callbacks: TranslationLifecycleCallbacks, logger: LifecycleLogger);
|
|
16
|
+
queued(task: TranslationTask): Promise<void>;
|
|
17
|
+
completed(task: TranslationTask): Promise<void>;
|
|
18
|
+
failed(task: TranslationTask, error: unknown): Promise<void>;
|
|
19
|
+
private safe;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fires the host's translation lifecycle callbacks, best-effort. Each callback is invoked in a
|
|
3
|
+
* try/catch (covering both synchronous throws and rejected promises); a failure is logged and
|
|
4
|
+
* swallowed, never propagated — a broken host callback must not fail the translation. Absent
|
|
5
|
+
* callbacks are a no-op.
|
|
6
|
+
*/ export class LifecycleNotifier {
|
|
7
|
+
callbacks;
|
|
8
|
+
logger;
|
|
9
|
+
constructor(callbacks, logger){
|
|
10
|
+
this.callbacks = callbacks;
|
|
11
|
+
this.logger = logger;
|
|
12
|
+
}
|
|
13
|
+
queued(task) {
|
|
14
|
+
const callback = this.callbacks.onQueued;
|
|
15
|
+
return this.safe("lifecycle.onQueued", callback && (()=>callback(task)));
|
|
16
|
+
}
|
|
17
|
+
completed(task) {
|
|
18
|
+
const callback = this.callbacks.onCompleted;
|
|
19
|
+
return this.safe("lifecycle.onCompleted", callback && (()=>callback(task)));
|
|
20
|
+
}
|
|
21
|
+
failed(task, error) {
|
|
22
|
+
const callback = this.callbacks.onFailed;
|
|
23
|
+
return this.safe("lifecycle.onFailed", callback && (()=>callback(task, error)));
|
|
24
|
+
}
|
|
25
|
+
async safe(name, thunk) {
|
|
26
|
+
if (!thunk) return;
|
|
27
|
+
try {
|
|
28
|
+
await thunk();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
this.logger.error({
|
|
31
|
+
err: error,
|
|
32
|
+
msg: `translator: ${name} callback threw`
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//# sourceMappingURL=LifecycleNotifier.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { LifecycleNotifier } from "./LifecycleNotifier";
|
|
2
|
+
export { withQueuedNotification } from "./withQueuedNotification";
|
|
3
|
+
export { taskFromHandlerInput, taskFromInput } from "./taskMapping";
|
|
4
|
+
export type { TranslationLifecycleCallbacks, TranslationTask } from "./types";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Translation lifecycle callbacks — always-on host hooks fired around the runner (not gated by the
|
|
2
|
+
// provenance opt-in, no schema/migration).
|
|
3
|
+
export { LifecycleNotifier } from "./LifecycleNotifier";
|
|
4
|
+
export { withQueuedNotification } from "./withQueuedNotification";
|
|
5
|
+
export { taskFromHandlerInput, taskFromInput } from "./taskMapping";
|
|
6
|
+
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TaskInput } from "../task-runner/types";
|
|
2
|
+
import type { TaskHandlerInput } from "../task-runner/TaskRunnerProvider.interface";
|
|
3
|
+
import type { TranslationTask } from "./types";
|
|
4
|
+
/** Map an enqueue-side {@link TaskInput} to the public {@link TranslationTask}. */
|
|
5
|
+
export declare const taskFromInput: (task: TaskInput) => TranslationTask;
|
|
6
|
+
/** Map an execution-side {@link TaskHandlerInput} to the public {@link TranslationTask}. */
|
|
7
|
+
export declare const taskFromHandlerInput: (input: TaskHandlerInput) => TranslationTask;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Map an enqueue-side {@link TaskInput} to the public {@link TranslationTask}. */ export const taskFromInput = (task)=>({
|
|
2
|
+
collection: task.collectionSlug,
|
|
3
|
+
id: task.collectionId,
|
|
4
|
+
sourceLng: task.sourceLng,
|
|
5
|
+
targetLng: task.targetLng,
|
|
6
|
+
strategy: task.strategy
|
|
7
|
+
});
|
|
8
|
+
/** Map an execution-side {@link TaskHandlerInput} to the public {@link TranslationTask}. */ export const taskFromHandlerInput = (input)=>({
|
|
9
|
+
collection: input.collection,
|
|
10
|
+
id: input.collectionId,
|
|
11
|
+
sourceLng: input.sourceLng,
|
|
12
|
+
targetLng: input.targetLng,
|
|
13
|
+
strategy: input.strategy
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
//# sourceMappingURL=taskMapping.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descriptor passed to the lifecycle callbacks — a stable, framework-neutral view of one translation
|
|
3
|
+
* task. Mirrors the fields the runner already threads through; `publishOnTranslation` is intentionally
|
|
4
|
+
* omitted (an internal write concern, not lifecycle-relevant). `id` is a string: the plugin is
|
|
5
|
+
* ID-agnostic and normalizes every document id to a string at ingress.
|
|
6
|
+
*
|
|
7
|
+
* @since 0.7.0
|
|
8
|
+
*/
|
|
9
|
+
export type TranslationTask = {
|
|
10
|
+
collection: string;
|
|
11
|
+
id: string;
|
|
12
|
+
sourceLng: string;
|
|
13
|
+
targetLng: string;
|
|
14
|
+
/**
|
|
15
|
+
* The resolution strategy for the task. Deliberately widened to `string` (not the internal
|
|
16
|
+
* `"overwrite" | "skip_existing"` union) so adding a strategy is not a breaking change to this
|
|
17
|
+
* public type; host callbacks should treat unknown values gracefully.
|
|
18
|
+
*/
|
|
19
|
+
strategy: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Optional server-side hooks the host can supply to react to translation lifecycle events, passed as
|
|
23
|
+
* the plugin's `lifecycle` config object. Always available (no schema, no migration) and independent
|
|
24
|
+
* of the `provenance` opt-in. A throwing callback never fails the translation — it is caught and
|
|
25
|
+
* logged (see {@link LifecycleNotifier}).
|
|
26
|
+
*
|
|
27
|
+
* `onCompleted` / `onFailed` fire per **execution attempt**: the Payload Jobs runner may retry a
|
|
28
|
+
* failed task, so a task that fails then succeeds fires `onFailed` on each failed attempt and
|
|
29
|
+
* `onCompleted` on the one that succeeds. `onQueued` fires once, when the task is enqueued.
|
|
30
|
+
*
|
|
31
|
+
* @since 0.7.0
|
|
32
|
+
*/
|
|
33
|
+
export type TranslationLifecycleCallbacks = {
|
|
34
|
+
/**
|
|
35
|
+
* Fired for each task as it is queued. Best-effort: emitted just before the task is handed to the
|
|
36
|
+
* runner, so if enqueueing then throws it may fire for a task that never actually queued.
|
|
37
|
+
*
|
|
38
|
+
* @since 0.7.0
|
|
39
|
+
*/
|
|
40
|
+
onQueued?: (task: TranslationTask) => void | Promise<void>;
|
|
41
|
+
/** Fired after a task completes without error. @since 0.7.0 */
|
|
42
|
+
onCompleted?: (task: TranslationTask) => void | Promise<void>;
|
|
43
|
+
/** Fired when a task's translation throws, with the error. @since 0.7.0 */
|
|
44
|
+
onFailed?: (task: TranslationTask, error: unknown) => void | Promise<void>;
|
|
45
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descriptor passed to the lifecycle callbacks — a stable, framework-neutral view of one translation
|
|
3
|
+
* task. Mirrors the fields the runner already threads through; `publishOnTranslation` is intentionally
|
|
4
|
+
* omitted (an internal write concern, not lifecycle-relevant). `id` is a string: the plugin is
|
|
5
|
+
* ID-agnostic and normalizes every document id to a string at ingress.
|
|
6
|
+
*
|
|
7
|
+
* @since 0.7.0
|
|
8
|
+
*/ /**
|
|
9
|
+
* Optional server-side hooks the host can supply to react to translation lifecycle events, passed as
|
|
10
|
+
* the plugin's `lifecycle` config object. Always available (no schema, no migration) and independent
|
|
11
|
+
* of the `provenance` opt-in. A throwing callback never fails the translation — it is caught and
|
|
12
|
+
* logged (see {@link LifecycleNotifier}).
|
|
13
|
+
*
|
|
14
|
+
* `onCompleted` / `onFailed` fire per **execution attempt**: the Payload Jobs runner may retry a
|
|
15
|
+
* failed task, so a task that fails then succeeds fires `onFailed` on each failed attempt and
|
|
16
|
+
* `onCompleted` on the one that succeeds. `onQueued` fires once, when the task is enqueued.
|
|
17
|
+
*
|
|
18
|
+
* @since 0.7.0
|
|
19
|
+
*/ export { };
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TaskRunner } from "../task-runner/TaskRunner.interface";
|
|
2
|
+
import type { LifecycleNotifier } from "./LifecycleNotifier";
|
|
3
|
+
/**
|
|
4
|
+
* Decorate a {@link TaskRunner} so `enqueue` fires the `queued` lifecycle callback for each task.
|
|
5
|
+
* Fired BEFORE delegating: a synchronous runner (the sync runner) may execute a task inline during
|
|
6
|
+
* `enqueue` and fire `completed`, so emitting `queued` first preserves the queued → completed order.
|
|
7
|
+
* All other methods delegate unchanged.
|
|
8
|
+
*/
|
|
9
|
+
export declare function withQueuedNotification(runner: TaskRunner, notifier: LifecycleNotifier): TaskRunner;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { taskFromInput } from "./taskMapping";
|
|
2
|
+
/**
|
|
3
|
+
* Decorate a {@link TaskRunner} so `enqueue` fires the `queued` lifecycle callback for each task.
|
|
4
|
+
* Fired BEFORE delegating: a synchronous runner (the sync runner) may execute a task inline during
|
|
5
|
+
* `enqueue` and fire `completed`, so emitting `queued` first preserves the queued → completed order.
|
|
6
|
+
* All other methods delegate unchanged.
|
|
7
|
+
*/ export function withQueuedNotification(runner, notifier) {
|
|
8
|
+
return {
|
|
9
|
+
async enqueue (tasks) {
|
|
10
|
+
await Promise.all(tasks.map((task)=>notifier.queued(taskFromInput(task))));
|
|
11
|
+
await runner.enqueue(tasks);
|
|
12
|
+
},
|
|
13
|
+
cancel: (taskIds)=>runner.cancel(taskIds),
|
|
14
|
+
run: (taskId)=>runner.run(taskId),
|
|
15
|
+
findByCollection: (collectionSlug, documentIds)=>runner.findByCollection(collectionSlug, documentIds)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=withQueuedNotification.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Payload } from "payload";
|
|
2
|
+
import type { ProvenanceKey, ProvenanceStore, TranslationProvenanceRecord } from "../../../core/provenance";
|
|
3
|
+
/** Builds a provenance store bound to a Payload instance; absent when provenance is disabled. */
|
|
4
|
+
export type ProvenanceStoreFactory = (payload: Payload) => ProvenanceStore;
|
|
5
|
+
/**
|
|
6
|
+
* Payload-backed {@link ProvenanceStore}. All Payload coupling for provenance lives here; the core
|
|
7
|
+
* knows only the port. Records are keyed by `(collectionSlug, documentId, targetLocale)`; `upsert`
|
|
8
|
+
* matches on that key so a re-translation updates the row in place instead of duplicating it.
|
|
9
|
+
*
|
|
10
|
+
* The sidecar holds custom fields that no generated collection type describes, so the slug is cast to
|
|
11
|
+
* `CollectionSlug` once; the record shape is guaranteed by {@link makeProvenanceCollection}.
|
|
12
|
+
*/
|
|
13
|
+
export declare class PayloadProvenanceStore implements ProvenanceStore {
|
|
14
|
+
private readonly payload;
|
|
15
|
+
private readonly collection;
|
|
16
|
+
constructor(payload: Payload, slug: string);
|
|
17
|
+
upsert(record: TranslationProvenanceRecord): Promise<void>;
|
|
18
|
+
find(key: ProvenanceKey): Promise<TranslationProvenanceRecord | null>;
|
|
19
|
+
deleteByDocument(collectionSlug: string, documentId: string): Promise<void>;
|
|
20
|
+
private findDoc;
|
|
21
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
function keyWhere(key) {
|
|
2
|
+
return {
|
|
3
|
+
and: [
|
|
4
|
+
{
|
|
5
|
+
collectionSlug: {
|
|
6
|
+
equals: key.collectionSlug
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
documentId: {
|
|
11
|
+
equals: key.documentId
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
targetLocale: {
|
|
16
|
+
equals: key.targetLocale
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function toRecord(doc) {
|
|
23
|
+
return {
|
|
24
|
+
collectionSlug: String(doc.collectionSlug),
|
|
25
|
+
documentId: String(doc.documentId),
|
|
26
|
+
targetLocale: String(doc.targetLocale),
|
|
27
|
+
sourceLocale: String(doc.sourceLocale),
|
|
28
|
+
sourceFingerprint: String(doc.sourceFingerprint),
|
|
29
|
+
// `translatedAt` is backed by a `date` field, which Payload may hand back as a Date; normalize to
|
|
30
|
+
// ISO-8601 so the stored contract holds and #50's fingerprint comparison stays format-stable.
|
|
31
|
+
translatedAt: new Date(doc.translatedAt).toISOString(),
|
|
32
|
+
dismissedFingerprint: doc.dismissedFingerprint == null ? null : String(doc.dismissedFingerprint)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Payload-backed {@link ProvenanceStore}. All Payload coupling for provenance lives here; the core
|
|
37
|
+
* knows only the port. Records are keyed by `(collectionSlug, documentId, targetLocale)`; `upsert`
|
|
38
|
+
* matches on that key so a re-translation updates the row in place instead of duplicating it.
|
|
39
|
+
*
|
|
40
|
+
* The sidecar holds custom fields that no generated collection type describes, so the slug is cast to
|
|
41
|
+
* `CollectionSlug` once; the record shape is guaranteed by {@link makeProvenanceCollection}.
|
|
42
|
+
*/ export class PayloadProvenanceStore {
|
|
43
|
+
payload;
|
|
44
|
+
collection;
|
|
45
|
+
constructor(payload, slug){
|
|
46
|
+
this.payload = payload;
|
|
47
|
+
this.collection = slug;
|
|
48
|
+
}
|
|
49
|
+
async upsert(record) {
|
|
50
|
+
const existing = await this.findDoc(record);
|
|
51
|
+
if (existing === null) {
|
|
52
|
+
try {
|
|
53
|
+
await this.payload.create({
|
|
54
|
+
collection: this.collection,
|
|
55
|
+
data: record
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const raceWinner = await this.findDoc(record);
|
|
59
|
+
if (raceWinner === null) throw error;
|
|
60
|
+
await this.payload.update({
|
|
61
|
+
collection: this.collection,
|
|
62
|
+
id: raceWinner.id,
|
|
63
|
+
data: record
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
await this.payload.update({
|
|
68
|
+
collection: this.collection,
|
|
69
|
+
id: existing.id,
|
|
70
|
+
data: record
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async find(key) {
|
|
75
|
+
const doc = await this.findDoc(key);
|
|
76
|
+
return doc === null ? null : toRecord(doc);
|
|
77
|
+
}
|
|
78
|
+
async deleteByDocument(collectionSlug, documentId) {
|
|
79
|
+
await this.payload.delete({
|
|
80
|
+
collection: this.collection,
|
|
81
|
+
where: {
|
|
82
|
+
and: [
|
|
83
|
+
{
|
|
84
|
+
collectionSlug: {
|
|
85
|
+
equals: collectionSlug
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
documentId: {
|
|
90
|
+
equals: documentId
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async findDoc(key) {
|
|
98
|
+
const result = await this.payload.find({
|
|
99
|
+
collection: this.collection,
|
|
100
|
+
where: keyWhere(key),
|
|
101
|
+
limit: 1,
|
|
102
|
+
depth: 0,
|
|
103
|
+
pagination: false
|
|
104
|
+
});
|
|
105
|
+
return result.docs[0] ?? null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
//# sourceMappingURL=PayloadProvenanceStore.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { DEFAULT_PROVENANCE_SLUG, isProvenanceCollection, makeProvenanceCollection, } from "./provenanceCollection";
|
|
2
|
+
export { PayloadProvenanceStore } from "./PayloadProvenanceStore";
|
|
3
|
+
export type { ProvenanceStoreFactory } from "./PayloadProvenanceStore";
|
|
4
|
+
export { injectProvenanceCleanup, makeProvenanceCleanupHook } from "./provenanceCleanupHook";
|
|
5
|
+
export { assertProvenanceSlugFree } from "./slugGuard";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Provenance adapter (Payload-backed). The framework-agnostic port + record types live in the core
|
|
2
|
+
// (src/core/provenance); this module is the plugin-side implementation.
|
|
3
|
+
export { DEFAULT_PROVENANCE_SLUG, isProvenanceCollection, makeProvenanceCollection } from "./provenanceCollection";
|
|
4
|
+
export { PayloadProvenanceStore } from "./PayloadProvenanceStore";
|
|
5
|
+
export { injectProvenanceCleanup, makeProvenanceCleanupHook } from "./provenanceCleanupHook";
|
|
6
|
+
export { assertProvenanceSlugFree } from "./slugGuard";
|
|
7
|
+
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CollectionAfterDeleteHook, Config } from "payload";
|
|
2
|
+
import type { ProvenanceStoreFactory } from "./PayloadProvenanceStore";
|
|
3
|
+
/**
|
|
4
|
+
* Build the `afterDelete` hook that cascade-deletes a document's provenance rows for `provenanceSlug`.
|
|
5
|
+
*
|
|
6
|
+
* Best-effort by design: the store's delete runs in its own transaction (the store does not thread
|
|
7
|
+
* `req`), and any failure is swallowed and logged — so cleanup can never roll back or fail the user's
|
|
8
|
+
* document delete. `id` is `number | string`, so it is stringified to match the stored `documentId`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function makeProvenanceCleanupHook(storeFactory: ProvenanceStoreFactory, provenanceSlug: string): CollectionAfterDeleteHook;
|
|
11
|
+
/**
|
|
12
|
+
* Attach the provenance cleanup hook to every managed (translatable) collection on `config`, appending
|
|
13
|
+
* to any consumer-supplied `afterDelete` array. Idempotent per `provenanceSlug`: a collection that
|
|
14
|
+
* already carries this slug's cleanup hook is skipped, so a repeated `init()` never stacks duplicates,
|
|
15
|
+
* yet a second instance with a different slug still attaches its own hook. The sidecar collection is
|
|
16
|
+
* never in `managedSlugs`, so it is never hooked (no recursion).
|
|
17
|
+
*/
|
|
18
|
+
export declare function injectProvenanceCleanup(config: Config, managedSlugs: Set<string>, storeFactory: ProvenanceStoreFactory, provenanceSlug: string): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the `afterDelete` hook that cascade-deletes a document's provenance rows for `provenanceSlug`.
|
|
3
|
+
*
|
|
4
|
+
* Best-effort by design: the store's delete runs in its own transaction (the store does not thread
|
|
5
|
+
* `req`), and any failure is swallowed and logged — so cleanup can never roll back or fail the user's
|
|
6
|
+
* document delete. `id` is `number | string`, so it is stringified to match the stored `documentId`.
|
|
7
|
+
*/ export function makeProvenanceCleanupHook(storeFactory, provenanceSlug) {
|
|
8
|
+
const hook = async ({ id, collection, req })=>{
|
|
9
|
+
try {
|
|
10
|
+
const store = storeFactory(req.payload);
|
|
11
|
+
await store.deleteByDocument(collection.slug, String(id));
|
|
12
|
+
} catch (error) {
|
|
13
|
+
req.payload.logger.error({
|
|
14
|
+
err: error,
|
|
15
|
+
collection: collection.slug,
|
|
16
|
+
documentId: String(id),
|
|
17
|
+
msg: "translator: failed to clean up provenance records after document delete"
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
hook.__translatorProvenanceCleanup = provenanceSlug;
|
|
22
|
+
return hook;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Attach the provenance cleanup hook to every managed (translatable) collection on `config`, appending
|
|
26
|
+
* to any consumer-supplied `afterDelete` array. Idempotent per `provenanceSlug`: a collection that
|
|
27
|
+
* already carries this slug's cleanup hook is skipped, so a repeated `init()` never stacks duplicates,
|
|
28
|
+
* yet a second instance with a different slug still attaches its own hook. The sidecar collection is
|
|
29
|
+
* never in `managedSlugs`, so it is never hooked (no recursion).
|
|
30
|
+
*/ export function injectProvenanceCleanup(config, managedSlugs, storeFactory, provenanceSlug) {
|
|
31
|
+
const hook = makeProvenanceCleanupHook(storeFactory, provenanceSlug);
|
|
32
|
+
for (const collection of config.collections ?? []){
|
|
33
|
+
if (!managedSlugs.has(collection.slug)) continue;
|
|
34
|
+
collection.hooks ??= {};
|
|
35
|
+
collection.hooks.afterDelete ??= [];
|
|
36
|
+
const alreadyInjected = collection.hooks.afterDelete.some((existing)=>existing.__translatorProvenanceCleanup === provenanceSlug);
|
|
37
|
+
if (!alreadyInjected) collection.hooks.afterDelete.push(hook);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//# sourceMappingURL=provenanceCleanupHook.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CollectionConfig } from "payload";
|
|
2
|
+
/** Default slug for the provenance sidecar collection. Overridable via `provenance.slug`. */
|
|
3
|
+
export declare const DEFAULT_PROVENANCE_SLUG = "translator-provenance";
|
|
4
|
+
/**
|
|
5
|
+
* True for the plugin's own provenance sidecar collection. Recognised by the {@link PROVENANCE_MARKER}
|
|
6
|
+
* on `custom` (not the slug, which is consumer-configurable), so plugin wiring can stay idempotent on
|
|
7
|
+
* a repeated run and the slug-collision guard can ignore an already-added sidecar.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isProvenanceCollection(collection: {
|
|
10
|
+
custom?: unknown;
|
|
11
|
+
}): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Build the Payload config for the provenance sidecar collection.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately minimal and DB-portable — only `text`/`date` fields and one composite index — so the
|
|
16
|
+
* same config yields a trivial migration on SQL (Postgres/SQLite) and an inferred collection on
|
|
17
|
+
* MongoDB. The collection is hidden from the admin UI: it is plugin-managed bookkeeping, not editor
|
|
18
|
+
* content. The composite index on `(collectionSlug, documentId, targetLocale)` is the upsert key.
|
|
19
|
+
*
|
|
20
|
+
* The plugin ships only this config; the consumer's Payload creates the table (see the design doc's
|
|
21
|
+
* SQL-migration note). Enabled only when the consumer opts in via `provenance` (wired in slice B).
|
|
22
|
+
*/
|
|
23
|
+
export declare function makeProvenanceCollection(slug?: string): CollectionConfig;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Default slug for the provenance sidecar collection. Overridable via `provenance.slug`. */ export const DEFAULT_PROVENANCE_SLUG = "translator-provenance";
|
|
2
|
+
/** The `custom` marker tagging the plugin's own provenance sidecar (set + read in this module). */ const PROVENANCE_MARKER = "translatorProvenance";
|
|
3
|
+
/**
|
|
4
|
+
* True for the plugin's own provenance sidecar collection. Recognised by the {@link PROVENANCE_MARKER}
|
|
5
|
+
* on `custom` (not the slug, which is consumer-configurable), so plugin wiring can stay idempotent on
|
|
6
|
+
* a repeated run and the slug-collision guard can ignore an already-added sidecar.
|
|
7
|
+
*/ export function isProvenanceCollection(collection) {
|
|
8
|
+
return collection.custom?.[PROVENANCE_MARKER] === true;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build the Payload config for the provenance sidecar collection.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately minimal and DB-portable — only `text`/`date` fields and one composite index — so the
|
|
14
|
+
* same config yields a trivial migration on SQL (Postgres/SQLite) and an inferred collection on
|
|
15
|
+
* MongoDB. The collection is hidden from the admin UI: it is plugin-managed bookkeeping, not editor
|
|
16
|
+
* content. The composite index on `(collectionSlug, documentId, targetLocale)` is the upsert key.
|
|
17
|
+
*
|
|
18
|
+
* The plugin ships only this config; the consumer's Payload creates the table (see the design doc's
|
|
19
|
+
* SQL-migration note). Enabled only when the consumer opts in via `provenance` (wired in slice B).
|
|
20
|
+
*/ export function makeProvenanceCollection(slug = DEFAULT_PROVENANCE_SLUG) {
|
|
21
|
+
return {
|
|
22
|
+
slug,
|
|
23
|
+
admin: {
|
|
24
|
+
hidden: true
|
|
25
|
+
},
|
|
26
|
+
// Marker so plugin wiring can recognise its own collection ({@link isProvenanceCollection}) — lets
|
|
27
|
+
// a repeated plugin run stay idempotent and lets the slug-collision guard ignore its own sidecar.
|
|
28
|
+
custom: {
|
|
29
|
+
[PROVENANCE_MARKER]: true
|
|
30
|
+
},
|
|
31
|
+
fields: [
|
|
32
|
+
{
|
|
33
|
+
name: "collectionSlug",
|
|
34
|
+
type: "text",
|
|
35
|
+
required: true,
|
|
36
|
+
index: true
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "documentId",
|
|
40
|
+
type: "text",
|
|
41
|
+
required: true,
|
|
42
|
+
index: true
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "targetLocale",
|
|
46
|
+
type: "text",
|
|
47
|
+
required: true
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "sourceLocale",
|
|
51
|
+
type: "text",
|
|
52
|
+
required: true
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "sourceFingerprint",
|
|
56
|
+
type: "text",
|
|
57
|
+
required: true
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "translatedAt",
|
|
61
|
+
type: "date",
|
|
62
|
+
required: true
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "dismissedFingerprint",
|
|
66
|
+
type: "text"
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
indexes: [
|
|
70
|
+
{
|
|
71
|
+
fields: [
|
|
72
|
+
"collectionSlug",
|
|
73
|
+
"documentId",
|
|
74
|
+
"targetLocale"
|
|
75
|
+
],
|
|
76
|
+
unique: true
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//# sourceMappingURL=provenanceCollection.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail fast if the provenance sidecar slug collides with a collection the consumer already defines.
|
|
3
|
+
* Sharing a table would silently corrupt both — better a clear startup error telling the consumer to
|
|
4
|
+
* set `provenance.slug`.
|
|
5
|
+
*
|
|
6
|
+
* Compares by exact slug: this catches the common footgun — a consumer collection already using this
|
|
7
|
+
* slug — and is correct on every adapter (on MongoDB the slug IS the collection name). It does NOT
|
|
8
|
+
* try to model SQL table-name derivation: `@payloadcms/drizzle` snake-cases slugs and honours
|
|
9
|
+
* `dbName` overrides, so case/separator variants (`translatorProvenance` vs `translator-provenance`)
|
|
10
|
+
* or `dbName` collisions are not detected here. Robust table-name collision detection, if it proves
|
|
11
|
+
* necessary, belongs at plugin init where the full `CollectionConfig` (including `dbName`) is
|
|
12
|
+
* available — not in this slug-only helper.
|
|
13
|
+
*/
|
|
14
|
+
export declare function assertProvenanceSlugFree(slug: string, collections: ReadonlyArray<{
|
|
15
|
+
slug: string;
|
|
16
|
+
}>): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail fast if the provenance sidecar slug collides with a collection the consumer already defines.
|
|
3
|
+
* Sharing a table would silently corrupt both — better a clear startup error telling the consumer to
|
|
4
|
+
* set `provenance.slug`.
|
|
5
|
+
*
|
|
6
|
+
* Compares by exact slug: this catches the common footgun — a consumer collection already using this
|
|
7
|
+
* slug — and is correct on every adapter (on MongoDB the slug IS the collection name). It does NOT
|
|
8
|
+
* try to model SQL table-name derivation: `@payloadcms/drizzle` snake-cases slugs and honours
|
|
9
|
+
* `dbName` overrides, so case/separator variants (`translatorProvenance` vs `translator-provenance`)
|
|
10
|
+
* or `dbName` collisions are not detected here. Robust table-name collision detection, if it proves
|
|
11
|
+
* necessary, belongs at plugin init where the full `CollectionConfig` (including `dbName`) is
|
|
12
|
+
* available — not in this slug-only helper.
|
|
13
|
+
*/ export function assertProvenanceSlugFree(slug, collections) {
|
|
14
|
+
if (collections.some((collection)=>collection.slug === slug)) {
|
|
15
|
+
throw new Error(`[payload-plugin-translator] provenance slug "${slug}" collides with an existing collection. ` + "Set a distinct slug via the plugin's provenance.slug option.");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=slugGuard.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@focus-reactive/payload-plugin-translator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|