@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.
Files changed (35) hide show
  1. package/README.md +55 -0
  2. package/dist/core/content-projection/computeSourceFingerprint.d.ts +15 -0
  3. package/dist/core/content-projection/computeSourceFingerprint.js +19 -0
  4. package/dist/core/index.d.ts +2 -0
  5. package/dist/core/index.js +1 -0
  6. package/dist/core/provenance/ProvenanceStore.interface.d.ts +99 -0
  7. package/dist/core/provenance/ProvenanceStore.interface.js +37 -0
  8. package/dist/core/provenance/index.d.ts +1 -0
  9. package/dist/core/provenance/index.js +5 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/plugin.d.ts +26 -0
  12. package/dist/plugin.js +55 -15
  13. package/dist/server/features/translate-document/handler.d.ts +3 -1
  14. package/dist/server/features/translate-document/handler.js +27 -1
  15. package/dist/server/modules/lifecycle/LifecycleNotifier.d.ts +21 -0
  16. package/dist/server/modules/lifecycle/LifecycleNotifier.js +38 -0
  17. package/dist/server/modules/lifecycle/index.d.ts +4 -0
  18. package/dist/server/modules/lifecycle/index.js +7 -0
  19. package/dist/server/modules/lifecycle/taskMapping.d.ts +7 -0
  20. package/dist/server/modules/lifecycle/taskMapping.js +16 -0
  21. package/dist/server/modules/lifecycle/types.d.ts +45 -0
  22. package/dist/server/modules/lifecycle/types.js +21 -0
  23. package/dist/server/modules/lifecycle/withQueuedNotification.d.ts +9 -0
  24. package/dist/server/modules/lifecycle/withQueuedNotification.js +19 -0
  25. package/dist/server/modules/provenance/PayloadProvenanceStore.d.ts +21 -0
  26. package/dist/server/modules/provenance/PayloadProvenanceStore.js +109 -0
  27. package/dist/server/modules/provenance/index.d.ts +5 -0
  28. package/dist/server/modules/provenance/index.js +8 -0
  29. package/dist/server/modules/provenance/provenanceCleanupHook.d.ts +18 -0
  30. package/dist/server/modules/provenance/provenanceCleanupHook.js +41 -0
  31. package/dist/server/modules/provenance/provenanceCollection.d.ts +23 -0
  32. package/dist/server/modules/provenance/provenanceCollection.js +82 -0
  33. package/dist/server/modules/provenance/slugGuard.d.ts +16 -0
  34. package/dist/server/modules/provenance/slugGuard.js +19 -0
  35. 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
@@ -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";
@@ -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";
@@ -0,0 +1,5 @@
1
+ // Provenance contracts (port + record types) only — payload-free. The Payload-backed store lives
2
+ // in the plugin (src/server/modules/provenance), outside the core.
3
+ export { };
4
+
5
+ //# sourceMappingURL=index.js.map
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 translateHandler = new TranslateDocumentHandler(translationProvider, schemaMap);
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
- await translateHandler.handle(payload, {
32
- collection: input.collection,
33
- collectionId: input.collectionId,
34
- sourceLng: input.sourceLng,
35
- targetLng: input.targetLng,
36
- strategy: input.strategy,
37
- publishOnTranslation: input.publishOnTranslation
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)=>runner.create(payload, runnerContext.handler)
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
- constructor(translationProvider: TranslationProvider, schemaMap: CollectionSchemaMap);
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
- constructor(translationProvider, schemaMap){
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.6.2",
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",