From 1a143e71cc6327459d005156bef91feaf2f28aa2 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sun, 25 Oct 2026 12:49:54 +0100 Subject: [PATCH 01/25] feat: the pipeline is handed the leaves to translate The per-leaf decision collapses to "is this leaf translatable, is it in the selection, and is its source non-empty". `TranslationStrategy`, both of its implementations and `StrategyContext` are deleted, and the target's current value stops deciding anything. `sourceChangedByLeaf` is replaced by the selection list, so `ChangedLeaves` stops travelling through the pipeline context. The selection algebra arrives beside it: `narrowSelection` intersects a starting list with each answer, a `TranslationFilter` is either the addresses a request named or the name of a rule, and a `LeafAnswer` is complete in itself. --- .../auto-translate/getAutoTranslateConfig.ts | 9 +- .../src/core/domain/auto-translate/index.ts | 2 +- .../src/core/domain/auto-translate/types.ts | 23 +- .../computeFieldFingerprints.ts | 3 +- .../content-projection/contentProjector.ts | 9 +- .../core/domain/content-projection/idPath.ts | 13 +- .../content-projection/translatableLeaf.ts | 14 +- .../domain/locales/resolveLocales.test.ts | 14 + .../src/core/domain/locales/resolveLocales.ts | 11 + .../provenance/ProvenanceStore.interface.ts | 44 +- .../provenance/SourceFingerprint.test.ts | 85 +-- .../domain/provenance/SourceFingerprint.ts | 34 +- .../__tests__/fixtures/provenanceStore.ts | 26 + .../provenance/__tests__/theWatermark.test.ts | 114 +--- .../src/core/domain/provenance/index.ts | 5 +- .../core/domain/provenance/staleness.test.ts | 152 ++--- .../src/core/domain/provenance/staleness.ts | 50 +- .../domain/selection/TranslationFilter.ts | 23 + .../__tests__/narrowSelection.test.ts | 57 ++ .../src/core/domain/selection/index.ts | 4 + .../core/domain/selection/narrowSelection.ts | 29 + .../src/core/index.ts | 4 +- .../TranslationPipeline.test.ts | 81 ++- .../TranslationPipeline.ts | 10 +- .../__tests__/reportsWhatCameBack.test.ts | 23 +- .../theLeafThatNeverCameBack.test.ts | 74 +-- .../containerMode.test.ts | 36 +- .../src/core/translation-pipeline/index.ts | 2 - .../data-reconciler/DataReconciler.stage.ts | 2 +- .../FieldChunkCollector.stage.ts | 20 +- .../FieldChunkCollector.test.ts | 602 +++++++----------- .../field-collector/FieldChunkCollector.ts | 72 +-- .../stages/field-collector/driftGuard.test.ts | 16 +- .../strategies/Overwrite.strategy.test.ts | 55 -- .../strategies/Overwrite.strategy.ts | 12 - .../strategies/SkipExisting.strategy.test.ts | 130 ---- .../strategies/SkipExisting.strategy.ts | 23 - .../TranslationStrategy.interface.ts | 19 - .../translation-pipeline/strategies/index.ts | 24 - .../translateContent.test.ts | 26 +- .../translation-pipeline/translateContent.ts | 25 +- .../translation-pipeline/types/Pipeline.ts | 4 +- .../types/PipelineContext.ts | 15 +- .../__tests__/Lexical.differential.test.ts | 5 +- .../PayloadFields.differential.test.ts | 28 +- .../PayloadFields.nonStringLeaf.test.ts | 9 +- 46 files changed, 749 insertions(+), 1289 deletions(-) create mode 100644 packages/payload-plugin-translator/src/core/domain/provenance/__tests__/fixtures/provenanceStore.ts create mode 100644 packages/payload-plugin-translator/src/core/domain/selection/TranslationFilter.ts create mode 100644 packages/payload-plugin-translator/src/core/domain/selection/__tests__/narrowSelection.test.ts create mode 100644 packages/payload-plugin-translator/src/core/domain/selection/index.ts create mode 100644 packages/payload-plugin-translator/src/core/domain/selection/narrowSelection.ts delete mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.test.ts delete mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.ts delete mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.test.ts delete mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.ts delete mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/strategies/TranslationStrategy.interface.ts delete mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/strategies/index.ts diff --git a/packages/payload-plugin-translator/src/core/domain/auto-translate/getAutoTranslateConfig.ts b/packages/payload-plugin-translator/src/core/domain/auto-translate/getAutoTranslateConfig.ts index e570a56c2..0ab15c4f6 100644 --- a/packages/payload-plugin-translator/src/core/domain/auto-translate/getAutoTranslateConfig.ts +++ b/packages/payload-plugin-translator/src/core/domain/auto-translate/getAutoTranslateConfig.ts @@ -5,18 +5,11 @@ import { isObject } from "../../kernel/utils/isObject.js"; import type { AutoTranslateConfig } from "./types.js"; import { AUTO_TRANSLATE_CUSTOM_KEY } from "./types.js"; -/** - * Structural guard: is `value` a well-formed auto-translate config? Validates the one crash-critical - * invariant — `targets` is an array of strings — so a foreign value that a consumer happened to store - * under {@link AUTO_TRANSLATE_CUSTOM_KEY} (the key is a shared `custom` namespace, so a collision, while - * unlikely, is possible) is treated as "not opted in" rather than crashing the readers that dereference - * `targets`. - */ // TODO(core-deps): zod in `core` relaxes its dependency-free rule — see the task contract, // "Deferred — validation in core". Revisit if `@repo/translator-core` is ever extracted. const autoTranslateConfig = z.object({ targets: z.array(z.string()) }); -/** The schema is deliberately partial — `.parse` here would strip `strategy`, `debounceMs`, `sourceLocale`. */ +/** A guard only: the schema is partial, so `.parse` would strip every member it does not list. */ function isAutoTranslateConfig(value: unknown): value is AutoTranslateConfig { return autoTranslateConfig.safeParse(value).success; } diff --git a/packages/payload-plugin-translator/src/core/domain/auto-translate/index.ts b/packages/payload-plugin-translator/src/core/domain/auto-translate/index.ts index 58fed5bf9..433058cb9 100644 --- a/packages/payload-plugin-translator/src/core/domain/auto-translate/index.ts +++ b/packages/payload-plugin-translator/src/core/domain/auto-translate/index.ts @@ -1,4 +1,4 @@ export { getAutoTranslateConfig } from "./getAutoTranslateConfig.js"; export { hasSourceContentChanged } from "./hasSourceContentChanged.js"; export { AUTO_TRANSLATE_CUSTOM_KEY } from "./types.js"; -export type { AutoTranslateConfig, AutoTranslateStrategy } from "./types.js"; +export type { AutoTranslateConfig } from "./types.js"; diff --git a/packages/payload-plugin-translator/src/core/domain/auto-translate/types.ts b/packages/payload-plugin-translator/src/core/domain/auto-translate/types.ts index e48c0641e..aa23185c7 100644 --- a/packages/payload-plugin-translator/src/core/domain/auto-translate/types.ts +++ b/packages/payload-plugin-translator/src/core/domain/auto-translate/types.ts @@ -1,34 +1,17 @@ -/** - * Key under a collection's `custom` bag where its auto-translate rule is stamped by `withAutoTranslate` - * and read back by {@link getAutoTranslateConfig}. Mirrors the field-level `TRANSLATE_KIT_CUSTOM_KEY` - * pattern — a single typed key so writer and reader never diverge. - */ export const AUTO_TRANSLATE_CUSTOM_KEY = "translatorAutoTranslate"; /** - * Translation strategy for auto-enqueued jobs (matches the task-runner's strategy union): - * - `"overwrite"` — (re)translate every target field, replacing any existing target-locale value. - * - `"skip_existing"` — fill target fields that are currently empty, and, with `provenance` on, - * refresh those whose source text has moved since the last run. - * @since 0.9.0 - */ -export type AutoTranslateStrategy = "overwrite" | "skip_existing"; - -/** - * A collection's opt-in auto-translate rule (developer-configured, v1). Presence of this config on a - * collection = opted in; absence = off (the default). + * A collection's opt-in auto-translate rule. Present = opted in; absent = off. * @since 0.9.0 */ export type AutoTranslateConfig = { - /** Target locales to translate into when the source changes. The source locale is always excluded. */ + /** The source locale is always excluded. */ targets: string[]; - /** Translation strategy; defaults to `"overwrite"`. */ - strategy?: AutoTranslateStrategy; /** * Delay (ms) before the queued job runs, coalescing rapid edits via the job runner's * per-(document, locale) supersession. `0`/omitted = enqueue immediately. */ debounceMs?: number; - /** Override the source locale for this collection; defaults to `localization.defaultLocale`. */ + /** Defaults to `localization.defaultLocale`. */ sourceLocale?: string; }; diff --git a/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.ts b/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.ts index 6287a7b48..f17eded42 100644 --- a/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.ts +++ b/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import type { FieldLike } from "../../kernel/field-traversal/types.js"; import { projectTranslatableContent } from "./contentProjector.js"; +import type { LeafAddress } from "./idPath.js"; /** * A leaf's source hash. Branded so a recorded claim cannot be assigned where a hash is required @@ -11,7 +12,7 @@ import { projectTranslatableContent } from "./contentProjector.js"; export type SourceHash = string & { readonly __brand: "SourceHash" }; /** Hash of each translatable leaf's source text, keyed by the leaf's `IdPath`. */ -export type FieldFingerprints = Readonly>; +export type FieldFingerprints = Readonly>; /** * Truncated sha256: a change detector, never a security token. Stored receipts are parsed against diff --git a/packages/payload-plugin-translator/src/core/domain/content-projection/contentProjector.ts b/packages/payload-plugin-translator/src/core/domain/content-projection/contentProjector.ts index 9e1323db8..43cd06769 100644 --- a/packages/payload-plugin-translator/src/core/domain/content-projection/contentProjector.ts +++ b/packages/payload-plugin-translator/src/core/domain/content-projection/contentProjector.ts @@ -17,14 +17,13 @@ import { isObject } from "../../kernel/utils/isObject.js"; import type { IdPath, PathSegment } from "./idPath.js"; import { elementSegment, makeIdPath } from "./idPath.js"; import { isTranslatableLeaf, leafSourceText } from "./translatableLeaf.js"; +import type { LeafAddress } from "./idPath.js"; -/** One translatable leaf of a document: WHERE it lives ({@link IdPath}) and WHAT text it holds. */ export interface ProjectionEntry { idPath: IdPath; text: string; } -/** The walk position: the current data object plus the accumulated location segments. */ type Cursor = { data: Record; segments: PathSegment[]; @@ -95,3 +94,9 @@ export function projectTranslatableContent( return entries; } + +/** Every translatable leaf of a document that holds text, by address — an empty one is not among them. */ +export const everyTranslatableLeaf = ( + doc: Record, + schema: FieldLike[] +): LeafAddress[] => projectTranslatableContent(doc, schema).map((entry) => String(entry.idPath)); diff --git a/packages/payload-plugin-translator/src/core/domain/content-projection/idPath.ts b/packages/payload-plugin-translator/src/core/domain/content-projection/idPath.ts index eece243e5..7069f0523 100644 --- a/packages/payload-plugin-translator/src/core/domain/content-projection/idPath.ts +++ b/packages/payload-plugin-translator/src/core/domain/content-projection/idPath.ts @@ -21,6 +21,13 @@ import { TranslatorBug } from "../../errors/index.js"; */ export type IdPath = string & { readonly __brand: "IdPath" }; +/** + * A leaf's address in the {@link IdPath} grammar, as a value that reached us unbranded — read from a + * request, a stored row or a receipt. Nothing turns one back into an `IdPath`: re-entering the + * grammar would need a parser this module does not have. + */ +export type LeafAddress = string; + /** * One structural step of an {@link IdPath}. Callers push these while walking; only * {@link makeIdPath} renders them to a string, keeping the grammar in one place. @@ -62,19 +69,15 @@ const render = (segment: PathSegment): string => { } }; -/** One segment, rendered, for a caller that holds them one at a time. */ export const renderSegment = (segment: PathSegment): RenderedSegment => { return render(segment) as RenderedSegment; }; -/** The other half of the grammar: what already-rendered segments are joined with. */ export function joinRendered(tokens: readonly RenderedSegment[]): IdPath { return tokens.join(SEP) as IdPath; } -/** - * The sole constructor of an {@link IdPath}. Renders structural segments to the branded string. - */ +/** Renders structural segments through the grammar; {@link joinRendered} is the half that brands. */ export function makeIdPath(segments: PathSegment[]): IdPath { return joinRendered(segments.map(renderSegment)); } diff --git a/packages/payload-plugin-translator/src/core/domain/content-projection/translatableLeaf.ts b/packages/payload-plugin-translator/src/core/domain/content-projection/translatableLeaf.ts index 1a8aeed29..bdb4974ad 100644 --- a/packages/payload-plugin-translator/src/core/domain/content-projection/translatableLeaf.ts +++ b/packages/payload-plugin-translator/src/core/domain/content-projection/translatableLeaf.ts @@ -13,18 +13,14 @@ import type { FieldLike, LeafFieldLike } from "../../kernel/field-traversal/type import { collectSerializedLexicalTextNodes } from "../../kernel/lexical/collectTextNodes.js"; import { isSerializedLexicalRoot } from "../../kernel/lexical/guards.js"; -/** The field types this package translates (mirrors `isTranslatableField`, payload-free). */ -const isTranslatableFieldType = (field: { type: string }): boolean => +/** The field types this package translates. Payload-free, so `server/` narrows against it. */ +export const isTranslatableFieldType = (field: { type: string }): boolean => field.type === "text" || field.type === "textarea" || field.type === "richText"; -/** Mirrors `isLocalizedField` structurally. */ -const isLocalizedFieldLike = (field: { localized?: boolean }): boolean => field.localized === true; +export const isLocalizedFieldLike = (field: { localized?: boolean }): boolean => + field.localized === true; -/** - * The leaf-SELECTION decision shared by projection and translation: a leaf is a translation target - * iff it is a translatable text type, localized, and not excluded via `custom.translateKit`. - * Independent of the value present — the value governs whether there is text to emit, not selection. - */ +/** A leaf is a translation target unless it opted out through `custom.translateKit`. */ export const isTranslatableLeaf = (field: FieldLike | LeafFieldLike): boolean => isTranslatableFieldType(field) && isLocalizedFieldLike(field) && diff --git a/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.test.ts b/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.test.ts index 56d3598a8..c327648a0 100644 --- a/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.test.ts +++ b/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.test.ts @@ -129,3 +129,17 @@ describe("Locales.resolveTargets (composed manual-enqueue resolution)", () => { expect(r.droppedUnknown).toEqual(["xx"]); }); }); + +describe("Locales.knownCodes", () => { + it("answers nothing when localization is off or absent", () => { + expect(Locales.knownCodes(false)).toBeNull(); + expect(Locales.knownCodes(undefined)).toBeNull(); + }); + + it("reads codes from both the string and object locale forms", () => { + expect(Locales.knownCodes({ locales: ["en", "de"] })).toEqual(new Set(["en", "de"])); + expect(Locales.knownCodes({ locales: [{ code: "en" }, { code: "fr" }] })).toEqual( + new Set(["en", "fr"]) + ); + }); +}); diff --git a/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.ts b/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.ts index 89da0a1f0..0cd6745a2 100644 --- a/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.ts +++ b/packages/payload-plugin-translator/src/core/domain/locales/resolveLocales.ts @@ -64,6 +64,17 @@ export type ResolvedTargets = { droppedSource: boolean; }; +/** The project's configured locales, in either form Payload accepts: `"en"` or `{ code: "en" }`. */ +export type LocalizationLike = false | { locales: Array }; + +/** The configured locale codes, or `null` when localization is off or absent. */ +export function knownCodes(localization: LocalizationLike | undefined): Set | null { + if (!localization) return null; + return new Set( + localization.locales.map((locale) => (typeof locale === "string" ? locale : locale.code)) + ); +} + /** * One-shot resolution for the manual enqueue path: coerce scalar→array, then dedup → exclude source → * drop unknown, in that order. A pure composition of the primitives above. diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/ProvenanceStore.interface.ts b/packages/payload-plugin-translator/src/core/domain/provenance/ProvenanceStore.interface.ts index bc5b6d066..441971a6c 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/ProvenanceStore.interface.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/ProvenanceStore.interface.ts @@ -1,4 +1,4 @@ -import type { SourceFingerprint } from "./SourceFingerprint.js"; +import type { RecordedFingerprints } from "./SourceFingerprint.js"; /** * Translation provenance — the durable record of *what source state a target locale was translated @@ -16,42 +16,35 @@ import type { SourceFingerprint } from "./SourceFingerprint.js"; * `documentId` is always a string (Payload ids may be string or number — callers stringify on the * way in) and `translatedAt` is an ISO-8601 string, so the record shape is stable across databases. * - * The fingerprint columns are not here: how the plugin decides a locale is out of date is its own - * business, and the staleness endpoint answers that question without them. + * The source fingerprint is not here: how the plugin decides a locale is out of date is its own + * business, and the staleness endpoint answers that question without it. * * @since 0.7.0 */ export interface TranslationProvenanceRecord { - /** Slug of the translated collection. */ collectionSlug: string; /** Stringified id of the translated document. */ documentId: string; - /** Locale that was written (translated into). */ targetLocale: string; - /** Locale the translation was derived from. */ sourceLocale: string; translatedAt: string; } -/** The receipt as the plugin holds it: what a consumer sees, plus the fingerprints that drive staleness. */ export interface ProvenanceReceipt extends TranslationProvenanceRecord { - sourceFingerprint: SourceFingerprint | null; - /** The fingerprint an editor acknowledged as "stale but leave it" (#50). `null` until dismissed. */ - dismissedFingerprint: SourceFingerprint | null; + /** The source this translation is considered current against — written by a run, or by an editor accepting it as it stands. */ + sourceFingerprint: RecordedFingerprints | null; } /** - * The composite key that identifies exactly one provenance record. Also the {@link ProvenanceStore} - * `upsert`/`find` match key. + * The composite key that identifies exactly one provenance record, and what every method of + * {@link ProvenanceStore} but `findByDocument` matches on. * * @since 0.7.0 */ export interface ProvenanceKey { - /** Slug of the translated collection. */ collectionSlug: string; /** Stringified id of the translated document. */ documentId: string; - /** Locale that was written (translated into). */ targetLocale: string; } @@ -71,17 +64,16 @@ export interface ProvenanceKey { * sourceLocale: "en", * sourceFingerprint: fp, * translatedAt: new Date().toISOString(), - * dismissedFingerprint: null, * }); * const record = await store.find({ collectionSlug: "posts", documentId: "42", targetLocale: "de" }); * ``` */ export interface ProvenanceStore { /** - * Create the record, or update it in place if one already exists for its - * `(collectionSlug, documentId, targetLocale)` key. Never produces a duplicate for the same key. - * - * @param record - The provenance receipt to persist. + * Write the whole receipt for its `(collectionSlug, documentId, targetLocale)` key, creating the + * row when there is none. **Every column is replaced**, so this is only for a caller that owns all + * of them: writing one field through here loses a translation that finished since the read — which + * is what {@link ProvenanceStore.rebaseSourceFingerprint} exists for. */ upsert(record: ProvenanceReceipt): Promise; /** @@ -101,11 +93,17 @@ export interface ProvenanceStore { */ findByDocument(collectionSlug: string, documentId: string): Promise; /** - * Acknowledge the current source drift for one locale without re-translating (#50's dismiss): - * persist `dismissedFingerprint` so the indicator hides until the source changes again. No-op if - * the key has no record. + * Re-base one record on the source it is now considered current against, touching nothing else. + * No-op if the key has no record. + * + * Narrow on purpose: an editor accepting a translation reads the record, hashes the source and + * writes, and a run finishing in between must keep its own `translatedAt` and its own claims + * rather than be replaced by the snapshot the read took. */ - dismiss(key: ProvenanceKey, dismissedFingerprint: SourceFingerprint): Promise; + rebaseSourceFingerprint( + key: ProvenanceKey, + sourceFingerprint: RecordedFingerprints + ): Promise; /** * Delete every record for a document (all target locales). Used to cascade-clean when the source * document is deleted. diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.test.ts b/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.test.ts index 1b10b79aa..00952ba96 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.test.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.test.ts @@ -1,4 +1,4 @@ -import type { SourceFingerprint } from "./SourceFingerprint.js"; +import type { RecordedFingerprints } from "./SourceFingerprint.js"; import { asHash } from "./__tests__/fixtures/claims.js"; import { SOURCE_HASH_PATTERN } from "../content-projection/computeFieldFingerprints.js"; import { UNANSWERED } from "./SourceFingerprint.js"; @@ -10,37 +10,27 @@ const SHA256_HEX_LENGTH = 64; const LEGACY = "a".repeat(SHA256_HEX_LENGTH); describe("parseSourceFingerprint", () => { - it("reads the single sha256 a previous version wrote as the document shape", () => { - expect(parseSourceFingerprint(LEGACY)).toEqual({ kind: "document", hash: LEGACY }); - }); - - it("reads a JSON object of addresses as the per-field shape", () => { + it("reads a JSON object of addresses as the map", () => { expect( parseSourceFingerprint('{"title":"3a7f000000000000","items.a1.label":"91ce000000000000"}') - ).toEqual({ - kind: "fields", - hashes: { title: asHash("3a7f"), "items.a1.label": asHash("91ce") }, - }); + ).toEqual({ title: asHash("3a7f"), "items.a1.label": asHash("91ce") }); }); it("reads a leaf an older version declined as one to revisit", () => { expect(parseSourceFingerprint('{"title":null,"note":"91ce000000000000"}')).toEqual({ - kind: "fields", - hashes: { title: UNANSWERED, note: asHash("91ce") }, + title: UNANSWERED, + note: asHash("91ce"), }); }); - it("reads an empty per-field map as a per-field map, not as absent", () => { - expect(parseSourceFingerprint("{}")).toEqual({ kind: "fields", hashes: {} }); + it("reads an empty map as a map, not as absent", () => { + expect(parseSourceFingerprint("{}")).toEqual({}); }); - // One rule, not two: anything that is not a per-field map is whatever the old format stored. The - // reader cannot tell a corrupt value from an old hash, and no consumer needs to — both read as out - // of date and neither claims anything about a leaf. it.each([ - ["a lowercase sha256", "a".repeat(64)], - ["an uppercase one", "A".repeat(64)], - ["one made entirely of digits, which is also valid JSON", "1".repeat(64)], + ["the single sha256 a version before 0.15.0 wrote", LEGACY], + ["an uppercase one", "A".repeat(SHA256_HEX_LENGTH)], + ["one made entirely of digits, which is also valid JSON", "1".repeat(SHA256_HEX_LENGTH)], ["something shorter than a digest", "abc123"], ["whitespace", " "], ["a JSON array", '["title"]'], @@ -50,15 +40,10 @@ describe("parseSourceFingerprint", () => { ["an object whose values are not hashes", '{"title":42}'], ["an object nested a level too deep", '{"title":{"a":"b"}}'], ["unparseable JSON", '{"title":'], - ])("reads %s as the old document-wide shape", (_label, stored) => { - expect(parseSourceFingerprint(stored)).toEqual({ kind: "document", hash: stored }); - }); - - it.each([ ["an empty string", ""], ["null", null], ["undefined", undefined], - ])("reads %s as nothing stored at all", (_label, stored) => { + ])("reads %s as no receipt", (_label, stored) => { expect(parseSourceFingerprint(stored)).toBeNull(); }); @@ -70,28 +55,17 @@ describe("parseSourceFingerprint", () => { }); describe("serializeSourceFingerprint", () => { - it("renders the document shape as the bare hash", () => { - expect(serializeSourceFingerprint({ kind: "document", hash: LEGACY })).toBe(LEGACY); - }); - - it("renders the per-field shape as a JSON object", () => { - expect(serializeSourceFingerprint({ kind: "fields", hashes: { title: asHash("3a7f") } })).toBe( + it("renders the map as a JSON object", () => { + expect(serializeSourceFingerprint({ title: asHash("3a7f") })).toBe( '{"title":"3a7f000000000000"}' ); }); it.each([ - ["the document shape", { kind: "document", hash: LEGACY } as const], - [ - "the per-field shape", - { kind: "fields", hashes: { title: asHash("3a7f"), "i.a1.l": asHash("91ce") } }, - ], - [ - "a map carrying the unanswered marker", - { kind: "fields", hashes: { title: UNANSWERED, note: asHash("91ce") } }, - ], - ["an empty per-field map", { kind: "fields", hashes: {} }], - ] as [string, SourceFingerprint][])( + ["a map of hashes", { title: asHash("3a7f"), "i.a1.l": asHash("91ce") }], + ["a map carrying the unanswered marker", { title: UNANSWERED, note: asHash("91ce") }], + ["an empty map", {}], + ] as [string, RecordedFingerprints][])( "round-trips %s through the column", (_label, fingerprint) => { expect(parseSourceFingerprint(serializeSourceFingerprint(fingerprint))).toEqual(fingerprint); @@ -100,29 +74,19 @@ describe("serializeSourceFingerprint", () => { it("round-trips an address holding the characters the path grammar escapes", () => { const hashes = { "items.a1\\.weird:block.label": asHash("91ce") }; - expect(parseSourceFingerprint(serializeSourceFingerprint({ kind: "fields", hashes }))).toEqual({ - kind: "fields", - hashes, - }); + expect(parseSourceFingerprint(serializeSourceFingerprint(hashes))).toEqual(hashes); }); }); describe("what the schema accepts, since the column cannot", () => { it("refuses an unrecognised value, so the map is not read per-leaf at all", () => { - expect(parseSourceFingerprint('{"title":"not-a-hash"}')).toEqual({ - kind: "document", - hash: '{"title":"not-a-hash"}', - }); + expect(parseSourceFingerprint('{"title":"not-a-hash"}')).toBeNull(); }); it("refuses a value that is not sixteen hex characters", () => { - expect(parseSourceFingerprint('{"title":"3a7f"}')?.kind).toBe("document"); - expect(parseSourceFingerprint('{"title":"3a7f00000000000000"}')?.kind).toBe("document"); - expect(parseSourceFingerprint('{"title":"3A7F000000000000"}')?.kind).toBe("document"); - }); - - it("still reads a legacy document-wide digest, which this check never applies to", () => { - expect(parseSourceFingerprint(LEGACY)).toEqual({ kind: "document", hash: LEGACY }); + expect(parseSourceFingerprint('{"title":"3a7f"}')).toBeNull(); + expect(parseSourceFingerprint('{"title":"3a7f00000000000000"}')).toBeNull(); + expect(parseSourceFingerprint('{"title":"3A7F000000000000"}')).toBeNull(); }); it("never mistakes the unanswered marker for a hash", () => { @@ -131,8 +95,9 @@ describe("what the schema accepts, since the column cannot", () => { it("accepts the unanswered marker beside a hash, and turns an older decline into one", () => { expect(parseSourceFingerprint('{"a":"3a7f000000000000","b":"!unanswered","c":null}')).toEqual({ - kind: "fields", - hashes: { a: asHash("3a7f"), b: UNANSWERED, c: UNANSWERED }, + a: asHash("3a7f"), + b: UNANSWERED, + c: UNANSWERED, }); }); }); diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.ts b/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.ts index 3dd161a50..da00ce135 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import type { SourceHash } from "../content-projection/computeFieldFingerprints.js"; import { SOURCE_HASH_PATTERN } from "../content-projection/computeFieldFingerprints.js"; +import type { LeafAddress } from "../content-projection/idPath.js"; /** * A leaf no run has answered — sent and never got back, or declined by an older version. It reads @@ -17,16 +18,7 @@ export type RecordedClaim = SourceHash | typeof UNANSWERED; * Keyed by leaf address, holding every leaf the run saw — sent or not. An absent address is a leaf * that appeared after the translation. */ -export type RecordedFingerprints = Readonly>; - -/** - * What a receipt stores about its source. `document` is the sha256 written before per-leaf - * fingerprints, or any stored value that does not parse as a per-leaf map: it says the document - * moved without saying where. - */ -export type SourceFingerprint = - | { kind: "document"; hash: string } - | { kind: "fields"; hashes: RecordedFingerprints }; +export type RecordedFingerprints = Readonly>; const storedHash = z .string() @@ -38,7 +30,6 @@ const storedHash = z * value outside the schema degrades the whole map ({@link parseSourceFingerprint}). */ const legacyDecline = z.null().transform(() => UNANSWERED); - const recordedMap = z.record(z.union([storedHash, z.literal(UNANSWERED), legacyDecline])); const asFieldMap = (stored: string): RecordedFingerprints | null => { @@ -51,24 +42,17 @@ const asFieldMap = (stored: string): RecordedFingerprints | null => { }; /** - * Anything that is not a per-field map — including a map with one value outside the schema — reads - * as the legacy digest: deliberately not an error, it never matches and answers no leaf. The column - * is plain text, so this parse is its only check. `null` only for an empty column. + * `null` when the column claims nothing about any leaf: empty, not a per-leaf map (a pre-0.15.0 + * document digest included — docs/DEPRECATIONS.md#the-document-wide-digest-stops-being-read), or a + * map with any value outside the schema, which fails whole rather than per leaf. */ export function parseSourceFingerprint( stored: string | null | undefined -): SourceFingerprint | null { +): RecordedFingerprints | null { if (typeof stored !== "string" || stored.length === 0) return null; - - const hashes = asFieldMap(stored); - return hashes === null ? { kind: "document", hash: stored } : { kind: "fields", hashes }; + return asFieldMap(stored); } -export function serializeSourceFingerprint(fingerprint: SourceFingerprint): string { - switch (fingerprint.kind) { - case "document": - return fingerprint.hash; - case "fields": - return JSON.stringify(fingerprint.hashes); - } +export function serializeSourceFingerprint(fingerprint: RecordedFingerprints): string { + return JSON.stringify(fingerprint); } diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/__tests__/fixtures/provenanceStore.ts b/packages/payload-plugin-translator/src/core/domain/provenance/__tests__/fixtures/provenanceStore.ts new file mode 100644 index 000000000..1689477c9 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/provenance/__tests__/fixtures/provenanceStore.ts @@ -0,0 +1,26 @@ +import { vi } from "vitest"; + +import type { ProvenanceStore } from "../../ProvenanceStore.interface.js"; + +/** Every method a spy, so a check names only the one it cares about and the rest stay callable. */ +export type ProvenanceStoreDouble = { + [M in keyof ProvenanceStore]: ReturnType; +}; + +/** + * A `ProvenanceStore` whose methods all answer, overridable one at a time. + * + * It lives beside the port rather than beside any one caller: adding a method to `ProvenanceStore` + * should cost one edit here, not one in each suite that happens to construct a store. + */ +export const storeDouble = ( + overrides: Partial = {} +): ProvenanceStore & ProvenanceStoreDouble => + ({ + upsert: vi.fn().mockResolvedValue(undefined), + find: vi.fn().mockResolvedValue(null), + findByDocument: vi.fn().mockResolvedValue([]), + rebaseSourceFingerprint: vi.fn().mockResolvedValue(undefined), + deleteByDocument: vi.fn().mockResolvedValue(undefined), + ...overrides, + }) as ProvenanceStore & ProvenanceStoreDouble; diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/__tests__/theWatermark.test.ts b/packages/payload-plugin-translator/src/core/domain/provenance/__tests__/theWatermark.test.ts index 4d8b14953..d1d90bf17 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/__tests__/theWatermark.test.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/__tests__/theWatermark.test.ts @@ -4,19 +4,17 @@ import type { Field } from "payload"; import { ProvenanceService } from "../../../../server/modules/provenance/Provenance.service.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; import { makeIdPath } from "../../content-projection/idPath.js"; -import { SkipExistingStrategy } from "../../../translation-pipeline/strategies/index.js"; -import type { CurrentFingerprint } from "../staleness.js"; -import { changedLeaves, isRecordStale, recordsEachLeaf } from "../staleness.js"; +import type { FieldFingerprints } from "../../content-projection/computeFieldFingerprints.js"; +import { changedLeaves, isRecordStale } from "../staleness.js"; import { parseSourceFingerprint, serializeSourceFingerprint, UNANSWERED, } from "../SourceFingerprint.js"; -import type { ProvenanceReceipt, ProvenanceStore } from "../ProvenanceStore.interface.js"; -import type { RecordedFingerprints, SourceFingerprint } from "../SourceFingerprint.js"; +import type { ProvenanceReceipt } from "../ProvenanceStore.interface.js"; +import type { RecordedFingerprints } from "../SourceFingerprint.js"; import { asHash } from "./fixtures/claims.js"; - -const SHA256_HEX_LENGTH = 64; +import { storeDouble } from "./fixtures/provenanceStore.js"; const receipt = (over: Partial = {}): ProvenanceReceipt => ({ collectionSlug: "posts", @@ -25,29 +23,17 @@ const receipt = (over: Partial = {}): ProvenanceReceipt => ({ sourceLocale: "en", sourceFingerprint: null, translatedAt: "2026-10-22T00:00:00.000Z", - dismissedFingerprint: null, ...over, }); -const sourceNow = (fields: Record): CurrentFingerprint => ({ - document: "a".repeat(SHA256_HEX_LENGTH), - fields: Object.fromEntries( - Object.entries(fields).map(([address, seed]) => [address, asHash(seed)]) - ), -}); - -const FILLED_BY_SOMEONE_ELSE = "Von Hand"; +const sourceNow = (fields: Record): FieldFingerprints => + Object.fromEntries(Object.entries(fields).map(([address, seed]) => [address, asHash(seed)])); describe("a leaf the last run saw and skipped", () => { - const watermarked = { - kind: "fields", - hashes: { title: asHash("ace"), body: asHash("beef") }, - } as const; + const watermarked = { title: asHash("ace"), body: asHash("beef") } as const; it("is reported as changed once its source moves", () => { - expect( - changedLeaves(watermarked.hashes, sourceNow({ title: "ace", body: "cafe" }).fields) - ).toEqual({ + expect(changedLeaves(watermarked, sourceNow({ title: "ace", body: "cafe" }))).toEqual({ title: false, body: true, }); @@ -62,34 +48,8 @@ describe("a leaf the last run saw and skipped", () => { ).toBe(true); }); - it("is selected by skip_existing although its target is not empty", () => { - const changed = changedLeaves( - watermarked.hashes, - sourceNow({ title: "ace", body: "cafe" }).fields - ); - - expect( - new SkipExistingStrategy().shouldTranslate({ - sourceValue: "Some prose", - targetValue: FILLED_BY_SOMEONE_ELSE, - sourceChanged: changed.body, - }) - ).toBe(true); - }); - - it("is left alone while its source stands still", () => { - const changed = changedLeaves( - watermarked.hashes, - sourceNow({ title: "ace", body: "beef" }).fields - ); - - expect( - new SkipExistingStrategy().shouldTranslate({ - sourceValue: "Some prose", - targetValue: FILLED_BY_SOMEONE_ELSE, - sourceChanged: changed.body, - }) - ).toBe(false); + it("is reported unchanged while its source stands still", () => { + expect(changedLeaves(watermarked, sourceNow({ title: "ace", body: "beef" })).body).toBe(false); }); }); @@ -97,32 +57,17 @@ describe("a receipt written before the watermark", () => { const legacy = '{"title":"ace0000000000000","body":null}'; it("still parses leaf by leaf, so the whole record is not lost", () => { - expect(parseSourceFingerprint(legacy)?.kind).toBe("fields"); + expect(parseSourceFingerprint(legacy)).toEqual({ + title: asHash("ace"), + body: UNANSWERED, + }); }); it("revisits the leaf an old run declined, instead of protecting it for ever", () => { const stored = parseSourceFingerprint(legacy); - if (!recordsEachLeaf(stored)) - throw new Error("the legacy record must still parse leaf by leaf"); - - expect( - changedLeaves(stored.hashes, sourceNow({ title: "ace", body: "beef" }).fields).body - ).toBe(true); - }); - - it("makes that leaf selected by skip_existing over a target it did not write", () => { - const stored = parseSourceFingerprint(legacy); - if (!recordsEachLeaf(stored)) - throw new Error("the legacy record must still parse leaf by leaf"); - const changed = changedLeaves(stored.hashes, sourceNow({ title: "ace", body: "beef" }).fields); + if (stored === null) throw new TypeError("the legacy record must still parse leaf by leaf"); - expect( - new SkipExistingStrategy().shouldTranslate({ - sourceValue: "Some prose", - targetValue: FILLED_BY_SOMEONE_ELSE, - sourceChanged: changed.body, - }) - ).toBe(true); + expect(changedLeaves(stored, sourceNow({ title: "ace", body: "beef" })).body).toBe(true); }); it("carries no legacy value back into the column", () => { @@ -157,18 +102,15 @@ describe("the receipt a run leaves behind", () => { const NOT_IN_A_TRANSACTION = {}; const storedAfterRun = async ( - previous: SourceFingerprint | null + previous: RecordedFingerprints | null ): Promise => { const upsert = vi.fn().mockResolvedValue(undefined); - const store: ProvenanceStore = { + const store = storeDouble({ upsert, find: vi .fn() .mockResolvedValue(previous === null ? null : receipt({ sourceFingerprint: previous })), - findByDocument: vi.fn().mockResolvedValue([]), - dismiss: vi.fn().mockResolvedValue(undefined), - deleteByDocument: vi.fn().mockResolvedValue(undefined), - }; + }); const service = new ProvenanceService( store, new Map([[COLLECTION, schema]]) as CollectionSchemaMap, @@ -178,24 +120,22 @@ describe("the receipt a run leaves behind", () => { await service.record( { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de", sourceLocale: "en" }, - sourceNow({ title: "ace", body: "beef" }).fields, + sourceNow({ title: "ace", body: "beef" }), [{ idPath: makeIdPath([{ kind: "key", name: "title" }]), answeredInFull: true }] ); - const written = upsert.mock.calls[0]?.[0] as { - sourceFingerprint: { hashes: RecordedFingerprints }; - }; - return written.sourceFingerprint.hashes; + const written = upsert.mock.calls[0]?.[0] as { sourceFingerprint: RecordedFingerprints }; + return written.sourceFingerprint; }; - it("records where the source stands for a leaf it did not send", async () => { - expect(await storedAfterRun(null)).toEqual({ title: asHash("ace"), body: asHash("beef") }); + it("marks a leaf it did not send unanswered, there being no earlier claim to keep", async () => { + expect(await storedAfterRun(null)).toEqual({ title: asHash("ace"), body: UNANSWERED }); }); - it("brings that leaf back on the next run once its source moves", async () => { + it("brings that leaf back on the next run, whether or not its source moved", async () => { const stored = await storedAfterRun(null); - expect(changedLeaves(stored, sourceNow({ title: "ace", body: "cafe" }).fields)).toEqual({ + expect(changedLeaves(stored, sourceNow({ title: "ace", body: "beef" }))).toEqual({ title: false, body: true, }); diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/index.ts b/packages/payload-plugin-translator/src/core/domain/provenance/index.ts index 49f32ce95..9d5c7d3c9 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/index.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/index.ts @@ -4,9 +4,8 @@ export type { ProvenanceStore, TranslationProvenanceRecord, } from "./ProvenanceStore.interface.js"; -export type { ChangedLeaves, CurrentFingerprint } from "./staleness.js"; -export { changedLeaves, isRecordStale, recordsEachLeaf } from "./staleness.js"; -export type { RecordedClaim, SourceFingerprint } from "./SourceFingerprint.js"; +export { changedLeaves, driftedAddresses, isRecordStale } from "./staleness.js"; +export type { RecordedClaim, RecordedFingerprints } from "./SourceFingerprint.js"; export { UNANSWERED, parseSourceFingerprint, diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/staleness.test.ts b/packages/payload-plugin-translator/src/core/domain/provenance/staleness.test.ts index ab0479a81..f3618db68 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/staleness.test.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/staleness.test.ts @@ -1,81 +1,37 @@ import { describe, expect, it } from "vitest"; import type { ProvenanceReceipt } from "./ProvenanceStore.interface.js"; +import type { FieldFingerprints } from "../content-projection/computeFieldFingerprints.js"; import type { RecordedFingerprints } from "./SourceFingerprint.js"; -import type { CurrentFingerprint } from "./staleness.js"; import { asHash } from "./__tests__/fixtures/claims.js"; -import { changedLeaves, isRecordStale, recordsEachLeaf } from "./staleness.js"; -import { UNANSWERED } from "./SourceFingerprint.js"; +import { changedLeaves, isRecordStale } from "./staleness.js"; +import { parseSourceFingerprint, UNANSWERED } from "./SourceFingerprint.js"; -const SHA256_HEX_LENGTH = 64; -const HASH_A = "a".repeat(SHA256_HEX_LENGTH); -const HASH_B = "b".repeat(SHA256_HEX_LENGTH); -const HASH_C = "c".repeat(SHA256_HEX_LENGTH); +const hashed = (fields: Record): FieldFingerprints => + Object.fromEntries(Object.entries(fields).map(([address, seed]) => [address, asHash(seed)])); const record = (over: Partial = {}): ProvenanceReceipt => ({ collectionSlug: "posts", documentId: "1", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: { kind: "document", hash: HASH_A }, + sourceFingerprint: hashed({ title: "1111" }), translatedAt: "2026-07-07T00:00:00.000Z", - dismissedFingerprint: null, ...over, }); -const storedFields = ( - hashes: Record -): { kind: "fields"; hashes: RecordedFingerprints } => ({ - kind: "fields", - hashes: Object.fromEntries(Object.entries(hashes).map(([a, seed]) => [a, asHash(seed)])), -}); - -const hashed = (fields: Record) => - Object.fromEntries(Object.entries(fields).map(([address, seed]) => [address, asHash(seed)])); - -const current = (document: string, fields: Record = {}): CurrentFingerprint => ({ - document, - fields: hashed(fields), -}); - -describe("isRecordStale — a receipt written before per-field fingerprints", () => { - it("is not stale when the current fingerprint matches the recorded source fingerprint", () => { - expect(isRecordStale(record(), current(HASH_A))).toBe(false); - }); - - it("is stale when the source drifted and nothing was dismissed", () => { - expect(isRecordStale(record(), current(HASH_B))).toBe(true); - }); - - it("is not stale when the current drift equals the dismissed fingerprint", () => { - const dismissed = record({ dismissedFingerprint: { kind: "document", hash: HASH_B } }); - expect(isRecordStale(dismissed, current(HASH_B))).toBe(false); - }); - - it("becomes stale again when the source moves past a dismissed fingerprint", () => { - const dismissed = record({ dismissedFingerprint: { kind: "document", hash: HASH_B } }); - expect(isRecordStale(dismissed, current(HASH_C))).toBe(true); - }); -}); - -describe("isRecordStale — a receipt holding per-field fingerprints", () => { - const stored = storedFields({ title: "1111", body: "2222" }); +describe("isRecordStale", () => { + const stored = hashed({ title: "1111", body: "2222" }); it("is not stale when every leaf still hashes to what was translated", () => { expect( - isRecordStale( - record({ sourceFingerprint: stored }), - current(HASH_B, { title: "1111", body: "2222" }) - ) + isRecordStale(record({ sourceFingerprint: stored }), hashed({ title: "1111", body: "2222" })) ).toBe(false); }); it("is stale when one leaf's source moved", () => { expect( - isRecordStale( - record({ sourceFingerprint: stored }), - current(HASH_B, { title: "1111", body: "9999" }) - ) + isRecordStale(record({ sourceFingerprint: stored }), hashed({ title: "1111", body: "9999" })) ).toBe(true); }); @@ -83,118 +39,92 @@ describe("isRecordStale — a receipt holding per-field fingerprints", () => { expect( isRecordStale( record({ sourceFingerprint: stored }), - current(HASH_B, { title: "1111", body: "2222", sub: "3333" }) + hashed({ title: "1111", body: "2222", sub: "3333" }) ) ).toBe(true); }); it("is stale when a leaf was removed", () => { - expect( - isRecordStale(record({ sourceFingerprint: stored }), current(HASH_B, { title: "1111" })) - ).toBe(true); + expect(isRecordStale(record({ sourceFingerprint: stored }), hashed({ title: "1111" }))).toBe( + true + ); }); - it("hides again once that exact drift is dismissed", () => { + it("hides again once the receipt names that exact drift", () => { const drifted = { title: "1111", body: "9999" }; - const dismissed = record({ - sourceFingerprint: stored, - dismissedFingerprint: storedFields(drifted), - }); - expect(isRecordStale(dismissed, current(HASH_B, drifted))).toBe(false); + const accepted = record({ sourceFingerprint: hashed(drifted) }); + expect(isRecordStale(accepted, hashed(drifted))).toBe(false); }); it("is still stale when a leaf appeared that the receipt never saw at all", () => { - const seen = storedFields({ title: "1111", body: "2222" }); expect( isRecordStale( - record({ sourceFingerprint: seen }), - current(HASH_B, { title: "1111", body: "2222", sub: "3333" }) + record({ sourceFingerprint: hashed({ title: "1111", body: "2222" }) }), + hashed({ title: "1111", body: "2222", sub: "3333" }) ) ).toBe(true); }); - - it("does not care what the document-wide hash says", () => { - const same = { title: "1111", body: "2222" }; - expect( - isRecordStale(record({ sourceFingerprint: stored }), current("anything at all", same)) - ).toBe(false); - }); }); describe("isRecordStale — a receipt that cannot be read", () => { it("reads as out of date, because nothing shows it is current", () => { - expect(isRecordStale(record({ sourceFingerprint: null }), current(HASH_A))).toBe(true); + expect(isRecordStale(record({ sourceFingerprint: null }), hashed({ title: "1111" }))).toBe( + true + ); + }); + + it.each([ + ["the single sha256 a version before 0.15.0 wrote", "a".repeat(64)], + ["a column someone corrupted", "not a fingerprint"], + ])("reads a column holding %s as out of date, from the stored text in", (_label, stored) => { + const read = record({ sourceFingerprint: parseSourceFingerprint(stored) }); + + expect(isRecordStale(read, hashed({ title: "1111" }))).toBe(true); }); }); describe("changedLeaves — the per-leaf answer", () => { - const stored = storedFields({ title: "1111", body: "2222" }); + const stored = hashed({ title: "1111", body: "2222" }); it("says a leaf changed when its stored hash differs from now", () => { - expect(changedLeaves(stored.hashes, hashed({ title: "1111", body: "9999" })).body).toBe(true); + expect(changedLeaves(stored, hashed({ title: "1111", body: "9999" })).body).toBe(true); }); it("says a leaf did not change when its stored hash still matches", () => { - expect(changedLeaves(stored.hashes, hashed({ title: "1111", body: "2222" })).body).toBe(false); + expect(changedLeaves(stored, hashed({ title: "1111", body: "2222" })).body).toBe(false); }); it("says nothing about a leaf the document no longer has", () => { - expect(changedLeaves(stored.hashes, hashed({ title: "1111" }))).not.toHaveProperty("body"); + expect(changedLeaves(stored, hashed({ title: "1111" }))).not.toHaveProperty("body"); }); it("says a leaf the receipt never recorded changed, so the next run takes it", () => { - const answers = changedLeaves( - stored.hashes, - hashed({ title: "1111", body: "2222", sub: "3333" }) - ); + const answers = changedLeaves(stored, hashed({ title: "1111", body: "2222", sub: "3333" })); expect(answers.sub, "nothing was recorded for it, so it is owed a translation").toBe(true); }); -}); - -describe("recordsEachLeaf", () => { - it("accepts a receipt that claims leaf by leaf", () => { - expect(recordsEachLeaf(storedFields({ title: "1111" }))).toBe(true); - }); - - it.each([ - ["no receipt", null], - ["one written before per-field fingerprints", { kind: "document", hash: HASH_A } as const], - ])("refuses %s", (_label, fingerprint) => { - expect(recordsEachLeaf(fingerprint)).toBe(false); - }); -}); - -describe("changedLeaves", () => { - const stored = storedFields({ title: "1111", body: "2222" }); it("answers for every leaf the source has now", () => { expect( - changedLeaves( - stored.hashes, - hashed({ title: "1111", body: "9999", seen: "3333", fresh: "4444" }) - ) + changedLeaves(stored, hashed({ title: "1111", body: "9999", seen: "3333", fresh: "4444" })) ).toStrictEqual({ title: false, body: true, seen: true, fresh: true }); }); it("drops a recorded leaf the document no longer has", () => { - expect(changedLeaves(stored.hashes, hashed({ title: "1111" }))).toStrictEqual({ title: false }); + expect(changedLeaves(stored, hashed({ title: "1111" }))).toStrictEqual({ title: false }); }); }); describe("a leaf recorded as unanswered", () => { - const stored = { kind: "fields", hashes: { title: asHash("1111"), body: UNANSWERED } } as const; + const stored: RecordedFingerprints = { title: asHash("1111"), body: UNANSWERED }; it("makes the record stale, however the source stands", () => { expect( - isRecordStale( - record({ sourceFingerprint: stored }), - current(HASH_A, { title: "1111", body: "2222" }) - ) + isRecordStale(record({ sourceFingerprint: stored }), hashed({ title: "1111", body: "2222" })) ).toBe(true); }); it("is reported as changed, so the next run selects the leaf again", () => { - expect(changedLeaves(stored.hashes, hashed({ title: "1111", body: "2222" })).body).toBe(true); + expect(changedLeaves(stored, hashed({ title: "1111", body: "2222" })).body).toBe(true); }); }); diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/staleness.ts b/packages/payload-plugin-translator/src/core/domain/provenance/staleness.ts index ff5e2e259..163a67f00 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/staleness.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/staleness.ts @@ -1,16 +1,12 @@ import type { FieldFingerprints } from "../content-projection/computeFieldFingerprints.js"; +import type { LeafAddress } from "../content-projection/idPath.js"; import type { ProvenanceReceipt } from "./ProvenanceStore.interface.js"; -import type { RecordedFingerprints, SourceFingerprint } from "./SourceFingerprint.js"; +import type { RecordedFingerprints } from "./SourceFingerprint.js"; /** One entry per translatable leaf the source has now. */ -export type ChangedLeaves = Readonly>; - -export type CurrentFingerprint = { - document: string; - fields: FieldFingerprints; -}; +export type ChangedLeaves = Readonly>; const sameFields = (stored: RecordedFingerprints, current: FieldFingerprints): boolean => { const addresses = Object.keys(stored); @@ -20,34 +16,14 @@ const sameFields = (stored: RecordedFingerprints, current: FieldFingerprints): b ); }; -const describesCurrentSource = ( - fingerprint: SourceFingerprint | null, - current: CurrentFingerprint -): boolean => { - if (fingerprint === null) return false; - - switch (fingerprint.kind) { - case "document": - return fingerprint.hash === current.document; - case "fields": - return sameFields(fingerprint.hashes, current.fields); - } -}; - -/** Out of date unless the receipt — or the editor's dismissal — describes the source as it stands now. */ -export function isRecordStale(record: ProvenanceReceipt, current: CurrentFingerprint): boolean { - return ( - !describesCurrentSource(record.sourceFingerprint, current) && - !describesCurrentSource(record.dismissedFingerprint, current) - ); +export function isRecordStale( + record: ProvenanceReceipt, + currentFields: FieldFingerprints +): boolean { + const recorded = record.sourceFingerprint; + return recorded === null || !sameFields(recorded, currentFields); } -export const recordsEachLeaf = ( - fingerprint: SourceFingerprint | null -): fingerprint is { kind: "fields"; hashes: RecordedFingerprints } => { - return fingerprint?.kind === "fields"; -}; - /** * A leaf the receipt never recorded counts as changed: a leaf empty at the last run was not * recorded, and would otherwise never be translated again. @@ -64,3 +40,11 @@ export function changedLeaves( } return answers; } + +export function driftedAddresses( + recorded: RecordedFingerprints, + currentFields: FieldFingerprints +): LeafAddress[] { + const moved = changedLeaves(recorded, currentFields); + return Object.keys(moved).filter((address) => moved[address]); +} diff --git a/packages/payload-plugin-translator/src/core/domain/selection/TranslationFilter.ts b/packages/payload-plugin-translator/src/core/domain/selection/TranslationFilter.ts new file mode 100644 index 000000000..ae8361765 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/selection/TranslationFilter.ts @@ -0,0 +1,23 @@ +// zod in `core` relaxes its dependency-free rule, as `SourceFingerprint.ts` already does — decided in +// docs/plans/2026-10-02-per-field-fingerprints.task.md, "Deferred — validation in core". +import { z } from "zod"; + +/** + * One answer to "which leaves does this run translate" — either the addresses themselves, or the + * name of a rule that produces them. + * + * A run translates the intersection of every filter it carries, so **no filters means every + * translatable leaf**, and the order they are written in cannot change the result. + * + * `name` stays an opaque string rather than a union of the names there are: whatever answers to it + * is switched on separately, and a row may outlive it. + */ +export const TranslationFilterSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("leaves"), addresses: z.array(z.string().nonempty()).min(1) }), + z.object({ kind: z.literal("rule"), name: z.string().nonempty() }), +]); + +export type TranslationFilter = z.infer; + +/** The name of a rule that answers which leaves a run translates — `stale` is the only one today. */ +export type RuleName = string; diff --git a/packages/payload-plugin-translator/src/core/domain/selection/__tests__/narrowSelection.test.ts b/packages/payload-plugin-translator/src/core/domain/selection/__tests__/narrowSelection.test.ts new file mode 100644 index 000000000..f7dffff11 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/selection/__tests__/narrowSelection.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { narrowSelection } from "../narrowSelection.js"; + +describe("what a run translates", () => { + it("leaves the starting list as it stands when no criterion was given", () => { + expect(narrowSelection(["title", "body"], [])).toEqual(["title", "body"]); + }); + + it("keeps only what the answer accepts", () => { + expect(narrowSelection(["title", "subtitle", "body"], [["body"]])).toEqual(["body"]); + }); + + it("answers in the starting list's order, not the criterion's", () => { + expect(narrowSelection(["title", "subtitle", "body"], [["body", "title"]])).toEqual([ + "title", + "body", + ]); + }); + + it("never adds an address the run did not start from", () => { + expect(narrowSelection(["title"], [["title", "footer"]])).toEqual(["title"]); + }); + + it("keeps only what every answer accepts", () => { + const both = narrowSelection( + ["title", "subtitle", "body"], + [ + ["title", "body"], + ["body", "subtitle"], + ] + ); + + expect(both).toEqual(["body"]); + }); + + it("answers the same whichever order the lists arrive in", () => { + const first = [["title", "body"], ["body"]]; + const second = [["body"], ["title", "body"]]; + + expect(narrowSelection(["title", "body"], first)).toEqual( + narrowSelection(["title", "body"], second) + ); + }); + + it("empties the run when an answer accepts nothing", () => { + expect(narrowSelection(["title", "body"], [[]])).toEqual([]); + }); + + it("answers nothing when the run started from nothing", () => { + expect(narrowSelection([], [["title"]])).toEqual([]); + }); + + it("holds an address once even when the starting list names it twice", () => { + expect(narrowSelection(["title", "title", "body"], [])).toEqual(["title", "body"]); + }); +}); diff --git a/packages/payload-plugin-translator/src/core/domain/selection/index.ts b/packages/payload-plugin-translator/src/core/domain/selection/index.ts new file mode 100644 index 000000000..02e6b3f6d --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/selection/index.ts @@ -0,0 +1,4 @@ +export { narrowSelection } from "./narrowSelection.js"; +export type { LeafAnswer } from "./narrowSelection.js"; +export type { TranslationFilter } from "./TranslationFilter.js"; +export { TranslationFilterSchema } from "./TranslationFilter.js"; diff --git a/packages/payload-plugin-translator/src/core/domain/selection/narrowSelection.ts b/packages/payload-plugin-translator/src/core/domain/selection/narrowSelection.ts new file mode 100644 index 000000000..a99ffdbe3 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/selection/narrowSelection.ts @@ -0,0 +1,29 @@ +import type { LeafAddress } from "../content-projection/idPath.js"; + +/** + * One answer to "which leaves belong in this run" — a rule's answer, or the addresses a request + * named. Complete in itself: an answer lists every address it accepts, not only the ones already in + * the run. + */ +export type LeafAnswer = readonly LeafAddress[]; + +/** + * `start` intersected with every answer, in `start`'s order, each address once. Answers narrow and + * never add, so their order cannot change the result. + * + * No answers leaves `start` itself; an answer accepting nothing empties the run, which is an answer + * rather than a mistake — it says every leaf is already current. + */ +export type NarrowSelection = ( + start: readonly LeafAddress[], + answers: readonly LeafAnswer[] +) => LeafAddress[]; + +export const narrowSelection: NarrowSelection = (start, answers) => { + const gates = answers.map((addresses) => new Set(addresses)); + const kept = new Set(); + for (const address of start) { + if (gates.every((gate) => gate.has(address))) kept.add(address); + } + return [...kept]; +}; diff --git a/packages/payload-plugin-translator/src/core/index.ts b/packages/payload-plugin-translator/src/core/index.ts index 1a920cf43..49d30c9e1 100644 --- a/packages/payload-plugin-translator/src/core/index.ts +++ b/packages/payload-plugin-translator/src/core/index.ts @@ -6,13 +6,13 @@ export type { } from "./domain/translation-providers/index.js"; export { TranslationPipeline, translateContent } from "./translation-pipeline/index.js"; -export type { TranslateContentArgs, TranslationStrategy } from "./translation-pipeline/index.js"; +export type { TranslateContentArgs } from "./translation-pipeline/index.js"; export type { ProvenanceKey, ProvenanceReceipt, ProvenanceStore, - SourceFingerprint, + RecordedFingerprints, TranslationProvenanceRecord, } from "./domain/provenance/index.js"; export { parseSourceFingerprint } from "./domain/provenance/index.js"; diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.test.ts index c2fe8bcc2..234c855bb 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import type { Field } from "payload"; import { TranslationPipeline } from "./TranslationPipeline.js"; -import { OverwriteStrategy } from "./strategies/Overwrite.strategy.js"; -import { SkipExistingStrategy } from "./strategies/SkipExisting.strategy.js"; +import { everyTranslatableLeaf } from "../domain/content-projection/contentProjector.js"; import type { TranslationProvider } from "../domain/translation-providers/index.js"; const createMockProvider = ( @@ -29,7 +28,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -39,6 +37,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); @@ -59,7 +58,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -69,6 +67,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); @@ -89,7 +88,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -99,6 +97,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); @@ -124,7 +123,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -134,6 +132,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); @@ -170,7 +169,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -180,6 +178,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); @@ -230,7 +229,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -240,6 +238,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "es", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); @@ -307,7 +306,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -317,6 +315,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "es", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); @@ -356,7 +355,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -366,6 +364,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "es", + selection: everyTranslatableLeaf(sourceData, schema), }); // No text nodes to translate — pipeline returns null @@ -374,8 +373,8 @@ describe("TranslationPipeline", () => { }); }); - describe("strategy filtering", () => { - it("OverwriteStrategy translates all fields", async () => { + describe("what the selection leaves out", () => { + it("translates every leaf the selection names", async () => { const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const sourceData = { title: "Hello" }; const targetData = { title: "Existing translation" }; @@ -383,7 +382,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -393,13 +391,14 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).not.toBeNull(); expect(result!.translatedData.title).toBe("TR_Hello"); }); - it("SkipExistingStrategy skips fields with existing translations", async () => { + it("keeps the target text of a leaf the selection left out", async () => { const schema: Field[] = [ { name: "title", type: "text", localized: true }, { name: "description", type: "text", localized: true }, @@ -410,7 +409,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -420,12 +418,10 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: ["description"], }); expect(result).not.toBeNull(); - // Result contains full document shape - // title: kept from target (already translated) - // description: translated from source expect(result!.translatedData).toEqual({ title: "Existing", description: "TR_World", @@ -442,7 +438,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -452,13 +447,14 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).toBeNull(); expect(provider.translate).not.toHaveBeenCalled(); }); - it("returns null when all fields already translated (SkipExisting)", async () => { + it("returns null when the selection names nothing", async () => { const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const sourceData = { title: "Hello" }; const targetData = { title: "Already translated" }; @@ -466,7 +462,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -476,6 +471,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: [], }); expect(result).toBeNull(); @@ -490,7 +486,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -500,6 +495,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result).toBeNull(); @@ -517,7 +513,6 @@ describe("TranslationPipeline", () => { }; const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); await expect( @@ -528,6 +523,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }) ).rejects.toThrow("Translation provider returned null"); }); @@ -542,7 +538,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -552,6 +547,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); // Result contains translated data @@ -574,7 +570,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -584,6 +579,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result!.translatedData).toEqual({ @@ -604,7 +600,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -614,6 +609,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result!.translatedData).toEqual({ @@ -622,7 +618,7 @@ describe("TranslationPipeline", () => { }); }); - it("preserves existing target values with SkipExisting", async () => { + it("keeps a left-out leaf's target text in the full document shape", async () => { const schema: Field[] = [ { name: "title", type: "text", localized: true }, { name: "description", type: "text", localized: true }, @@ -634,7 +630,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -644,6 +639,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: ["description"], }); expect(result!.translatedData).toEqual({ @@ -654,8 +650,8 @@ describe("TranslationPipeline", () => { }); }); - describe("nested structures with strategy", () => { - it("applies SkipExisting to nested array fields", async () => { + describe("nested structures", () => { + it("sends one element of an array and leaves its sibling alone", async () => { const schema: Field[] = [ { name: "items", @@ -679,7 +675,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -689,6 +684,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: ["items.2.label"], }); // Non-localized array → shared rows → id kept. @@ -698,7 +694,7 @@ describe("TranslationPipeline", () => { ]); }); - it("applies SkipExisting to nested group fields", async () => { + it("sends one leaf of a group and leaves its sibling alone", async () => { const schema: Field[] = [ { name: "meta", @@ -715,7 +711,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -725,6 +720,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: ["meta.description"], }); expect(result!.translatedData.meta).toEqual({ @@ -733,7 +729,7 @@ describe("TranslationPipeline", () => { }); }); - it("applies SkipExisting to blocks fields", async () => { + it("sends one block and leaves its sibling alone", async () => { const schema: Field[] = [ { name: "layout", @@ -762,7 +758,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -772,6 +767,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: ["layout.2:text.content"], }); // Non-localized blocks → shared rows → id kept. @@ -799,7 +795,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -809,6 +804,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result!.translatedData.title).toBe("TR_Hello"); @@ -831,7 +827,6 @@ describe("TranslationPipeline", () => { const provider = createMockProvider(); const pipeline = new TranslationPipeline({ translationProvider: provider, - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -841,6 +836,7 @@ describe("TranslationPipeline", () => { targetData, sourceLng: "en", targetLng: "ru", + selection: everyTranslatableLeaf(sourceData, schema), }); expect(result!.translatedData.title).toBe("TR_Hello"); @@ -867,7 +863,6 @@ describe("TranslationPipeline", () => { it("names every leaf it sent, by the same address the receipt is keyed on", async () => { const pipeline = new TranslationPipeline({ translationProvider: createMockProvider(), - translationStrategy: new OverwriteStrategy(), }); const result = await pipeline.execute({ @@ -877,6 +872,7 @@ describe("TranslationPipeline", () => { targetData: {}, sourceLng: "en", targetLng: "de", + selection: everyTranslatableLeaf(sourceData, schema), }); expect((result?.leaves ?? []).map((leaf) => String(leaf.idPath)).sort()).toEqual([ @@ -886,10 +882,9 @@ describe("TranslationPipeline", () => { ]); }); - it("names only the leaves the strategy let through", async () => { + it("names only the leaves it was asked for", async () => { const pipeline = new TranslationPipeline({ translationProvider: createMockProvider(), - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -899,6 +894,7 @@ describe("TranslationPipeline", () => { targetData: { body: "Welt", items: [{ id: "a1", label: "" }] }, sourceLng: "en", targetLng: "de", + selection: ["title", "items.a1.label"], }); expect((result?.leaves ?? []).map((leaf) => String(leaf.idPath)).sort()).toEqual([ @@ -907,10 +903,9 @@ describe("TranslationPipeline", () => { ]); }); - it("passes the per-leaf answer from the config down to the strategy", async () => { + it("sends only the leaves the selection names", async () => { const pipeline = new TranslationPipeline({ translationProvider: createMockProvider(), - translationStrategy: new SkipExistingStrategy(), }); const result = await pipeline.execute({ @@ -920,7 +915,7 @@ describe("TranslationPipeline", () => { targetData: { title: "Hallo", body: "Welt", items: [{ id: "a1", label: "Erste" }] }, sourceLng: "en", targetLng: "de", - sourceChangedByLeaf: { title: true }, + selection: ["title"], }); expect(result?.leaves).toEqual([{ idPath: "title", answeredInFull: true }]); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.ts index 440a80f55..815fd1c14 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.ts @@ -7,7 +7,6 @@ import type { PipelineContext, PipelineStage, } from "./types/index.js"; -import type { TranslationStrategy } from "./strategies/index.js"; import type { TextExpander } from "./stages/index.js"; import { DataReconcilerStage, @@ -39,7 +38,6 @@ function leafOutcomes(sent: TextChunk[], written: TextChunk[]): LeafOutcome[] { */ export type TranslationPipelineOptions = { translationProvider: TranslationProvider; - translationStrategy: TranslationStrategy; textExpanders?: TextExpander[]; }; @@ -62,7 +60,7 @@ export class TranslationPipeline { constructor(options: TranslationPipelineOptions) { this.stages = [ new DataReconcilerStage(), - new FieldChunkCollectorStage(options.translationStrategy), + new FieldChunkCollectorStage(), new TextChunkExpanderStage(options.textExpanders), new TranslationStage(options.translationProvider), new TranslationMutatorStage(), @@ -81,7 +79,7 @@ export class TranslationPipeline { sourceLng: config.sourceLng, targetLng: config.targetLng, document: config.document, - sourceChangedByLeaf: config.sourceChangedByLeaf, + selection: config.selection, }; for (const stage of this.stages) { @@ -95,12 +93,12 @@ export class TranslationPipeline { } } - if (!ctx.filteredData) { + if (!ctx.reconciledData) { return null; } return { - translatedData: ctx.filteredData, + translatedData: ctx.reconciledData, leaves: leafOutcomes(ctx.textChunks ?? [], ctx.writtenChunks ?? []), }; } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/reportsWhatCameBack.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/reportsWhatCameBack.test.ts index cba2e3d65..6db440bed 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/reportsWhatCameBack.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/reportsWhatCameBack.test.ts @@ -12,9 +12,10 @@ import { RichContainerExpander, RichTextExpander, } from "../stages/text-expander/index.js"; -import { OverwriteStrategy } from "../strategies/index.js"; +import { projectTranslatableContent } from "../../domain/content-projection/contentProjector.js"; import { TranslationPipeline } from "../TranslationPipeline.js"; import type { TextExpander } from "../stages/index.js"; +import { storeDouble } from "../../domain/provenance/__tests__/fixtures/provenanceStore.js"; const hashed = (fields: Record) => Object.fromEntries(Object.entries(fields).map(([address, seed]) => [address, asHash(seed)])); @@ -56,7 +57,6 @@ const run = async (args: { }) => new TranslationPipeline({ translationProvider: provider(args.answer), - translationStrategy: new OverwriteStrategy(), textExpanders: args.expanders, }).execute({ document: { collection: "posts", id: "1" }, @@ -65,6 +65,9 @@ const run = async (args: { targetData: {}, sourceLng: "en", targetLng: "de", + selection: projectTranslatableContent(args.source, args.schema).map((entry) => + String(entry.idPath) + ), }); const claimable = (result: Awaited>): string[] => @@ -204,13 +207,8 @@ describe("a run reports the leaves that came back", () => { describe("what the run reports is what the receipt claims", () => { const store = ( previous: unknown = null - ): ProvenanceStore & { upsert: ReturnType } => ({ - upsert: vi.fn().mockResolvedValue(undefined), - find: vi.fn().mockResolvedValue(previous), - findByDocument: vi.fn().mockResolvedValue([]), - dismiss: vi.fn().mockResolvedValue(undefined), - deleteByDocument: vi.fn().mockResolvedValue(undefined), - }); + ): ProvenanceStore & { upsert: ReturnType } => + storeDouble({ find: vi.fn().mockResolvedValue(previous) }); it("declines the unanswered leaf rather than sealing its current source hash", async () => { const result = await run({ @@ -237,7 +235,7 @@ describe("what the run reports is what the receipt claims", () => { sourceFingerprint: { hashes: Record }; }; - expect(stored.sourceFingerprint.hashes).toEqual({ + expect(stored.sourceFingerprint).toEqual({ title: asHash("aaaa"), summary: UNANSWERED, }); @@ -255,9 +253,8 @@ describe("what the run reports is what the receipt claims", () => { documentId: "1", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "0d", summary: "0dbb" }) }, + sourceFingerprint: hashed({ title: "0d", summary: "0dbb" }), translatedAt: "2026-01-01T00:00:00.000Z", - dismissedFingerprint: null, }); const service = new ProvenanceService( written, @@ -276,7 +273,7 @@ describe("what the run reports is what the receipt claims", () => { sourceFingerprint: { hashes: Record }; }; - expect(stored.sourceFingerprint.hashes).toEqual({ + expect(stored.sourceFingerprint).toEqual({ title: asHash("aaaa"), summary: UNANSWERED, }); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/theLeafThatNeverCameBack.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/theLeafThatNeverCameBack.test.ts index d6ebe7d36..e0996dc77 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/theLeafThatNeverCameBack.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/__tests__/theLeafThatNeverCameBack.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import type { Field } from "payload"; -import type { ProvenanceStore, SourceFingerprint } from "../../domain/provenance/index.js"; +import type { ProvenanceStore, RecordedFingerprints } from "../../domain/provenance/index.js"; import { changedLeaves, UNANSWERED } from "../../domain/provenance/index.js"; import { asHash } from "../../domain/provenance/__tests__/fixtures/claims.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import { ProvenanceService } from "../../../server/modules/provenance/Provenance.service.js"; -import { SkipExistingStrategy } from "../strategies/index.js"; import { makeIdPath } from "../../domain/content-projection/idPath.js"; import type { LeafOutcome } from "../types/index.js"; +import { storeDouble } from "../../domain/provenance/__tests__/fixtures/provenanceStore.js"; const sent = (name: string, answeredInFull: boolean) => ({ idPath: makeIdPath([{ kind: "key", name }]), @@ -33,26 +33,22 @@ const sourceHashes = { title: asHash("aaaa"), summary: asHash("bbbb") }; type Recorded = Record; const storeHolding = ( - previous: SourceFingerprint | null -): ProvenanceStore & { upsert: ReturnType } => ({ - upsert: vi.fn().mockResolvedValue(undefined), - find: vi.fn().mockResolvedValue( - previous === null - ? null - : { - ...KEY, - sourceFingerprint: previous, - translatedAt: "2026-01-01T00:00:00.000Z", - dismissedFingerprint: null, - } - ), - findByDocument: vi.fn().mockResolvedValue([]), - dismiss: vi.fn().mockResolvedValue(undefined), - deleteByDocument: vi.fn().mockResolvedValue(undefined), -}); + previous: RecordedFingerprints | null +): ProvenanceStore & { upsert: ReturnType } => + storeDouble({ + find: vi.fn().mockResolvedValue( + previous === null + ? null + : { + ...KEY, + sourceFingerprint: previous, + translatedAt: "2026-01-01T00:00:00.000Z", + } + ), + }); const storedClaims = async (args: { - previous: SourceFingerprint | null; + previous: RecordedFingerprints | null; leaves: LeafOutcome[]; }): Promise => { const store = storeHolding(args.previous); @@ -65,10 +61,8 @@ const storedClaims = async (args: { await service.record(KEY, sourceHashes, args.leaves); - const written = store.upsert.mock.calls[0]?.[0] as { - sourceFingerprint: { hashes: Recorded }; - }; - return written.sourceFingerprint.hashes; + const written = store.upsert.mock.calls[0]?.[0] as { sourceFingerprint: Recorded }; + return written.sourceFingerprint; }; describe("a leaf the run sent and never got back", () => { @@ -83,25 +77,25 @@ describe("a leaf the run sent and never got back", () => { it("overwrites an earlier claim, because the target now holds source text rather than that translation", async () => { const hashes = await storedClaims({ - previous: { kind: "fields", hashes: { title: asHash("0d"), summary: asHash("0dbb") } }, + previous: { title: asHash("0d"), summary: asHash("0dbb") }, leaves: [sent("title", true), sent("summary", false)], }); expect(hashes.summary).toBe(UNANSWERED); }); - it("re-bases a leaf the run never sent onto where its source stands now", async () => { + it("keeps the claim an earlier run made for a leaf this one never sent", async () => { const hashes = await storedClaims({ - previous: { kind: "fields", hashes: { title: asHash("0d"), summary: asHash("0dbb") } }, + previous: { title: asHash("0d"), summary: asHash("0dbb") }, leaves: [sent("title", true)], }); - expect(hashes.summary).toBe(sourceHashes.summary); + expect(hashes.summary).toBe(asHash("0dbb")); }); it("stays unanswered across a second run that also fails to translate it", async () => { const hashes = await storedClaims({ - previous: { kind: "fields", hashes: { title: asHash("aaaa"), summary: UNANSWERED } }, + previous: { title: asHash("aaaa"), summary: UNANSWERED }, leaves: [sent("title", true)], }); @@ -110,23 +104,9 @@ describe("a leaf the run sent and never got back", () => { }); describe("what the unanswered leaf does on the second run", () => { - const strategy = new SkipExistingStrategy(); - - it("is reported as drifted, so a second run selects it again", () => { - const stored: SourceFingerprint = { - kind: "fields", - hashes: { title: sourceHashes.title, summary: UNANSWERED }, - }; - - const changed = changedLeaves(stored.hashes, sourceHashes); - - expect(changed.summary).toBe(true); - expect( - strategy.shouldTranslate({ - sourceValue: "World", - targetValue: "World", - sourceChanged: changed.summary, - }) - ).toBe(true); + it("is reported as drifted, so a run asking for what moved takes it again", () => { + const stored: RecordedFingerprints = { title: sourceHashes.title, summary: UNANSWERED }; + + expect(changedLeaves(stored, sourceHashes)).toEqual({ title: false, summary: true }); }); }); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts index d25b0b894..2073324db 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts @@ -2,6 +2,14 @@ import type { Field } from "payload"; import { describe, expect, it, vi } from "vitest"; import type { TranslationProvider } from "../domain/translation-providers/index.js"; import { translateContent } from "./translateContent.js"; +import type { TranslateContentArgs } from "./translateContent.js"; +import { everyTranslatableLeaf } from "../domain/content-projection/contentProjector.js"; + +const translateAll = (args: Omit) => + translateContent({ + ...args, + selection: everyTranslatableLeaf(args.sourceData, args.schema), + }); const text = (value: string, format = 0) => ({ type: "text", @@ -76,7 +84,7 @@ describe("container mode", () => { ]), }; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: source, @@ -107,7 +115,7 @@ describe("container mode", () => { it("stays per-node for a provider that declares the capability false", async () => { const { provider, sent } = bracketProvider({ capabilities: { inlineMarks: false } }); - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: { @@ -129,7 +137,7 @@ describe("container mode", () => { body: richText([paragraph([text("a "), text("red", 1), text(" car")])]), }; - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: source, @@ -150,7 +158,7 @@ describe("container mode", () => { body: richText([paragraph([text("a "), text("red", 1), text(" car")])]), }; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: source, @@ -173,7 +181,7 @@ describe("container mode", () => { translate: vi.fn(async () => ({ 0: "<1>une <2>rouge<3> voiture" })), } as TranslationProvider; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: { body: richText([paragraph(nodes)]) }, @@ -199,7 +207,7 @@ describe("container mode", () => { translate: vi.fn(async () => ({ 0: "<2>Dokumentation<1> lesen" })), } as TranslationProvider; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: { @@ -224,7 +232,7 @@ describe("container mode", () => { translate: vi.fn(async () => ({ 0: "<1>une voiture rouge<2><3>" })), } as TranslationProvider; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: { body: richText([paragraph([text("a "), text("red", 1), text(" car")])]) }, @@ -247,7 +255,7 @@ describe("container mode", () => { ]), }; - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: source, @@ -273,7 +281,7 @@ describe("container mode", () => { ]), }; - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: source, @@ -296,7 +304,7 @@ describe("container mode", () => { translate: vi.fn(async () => ({ 0: "<1>Achetez <2>notre produit" })), } as TranslationProvider; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: { @@ -319,7 +327,7 @@ describe("container mode", () => { body: richText([paragraph([text("Sign up now")]), paragraph([text("No card needed")])]), }; - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: source, @@ -334,7 +342,7 @@ describe("container mode", () => { it("leaves a plain text field to the plain expander", async () => { const { provider, sent } = bracketProvider({ capabilities: { inlineMarks: true } }); - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema: mixedSchema, sourceData: { @@ -359,7 +367,7 @@ describe("container mode", () => { translate: vi.fn(async () => ({ 0: "<1>une <2>rouge" })), } as TranslationProvider; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: { body: richText([paragraph([text("a "), text("red", 1), text(" car")])]) }, @@ -383,7 +391,7 @@ describe("container mode", () => { const declaring = bracketProvider({ capabilities: { inlineMarks: true } }); for (const each of [silent, declaring]) { - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData: body(), diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/index.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/index.ts index c58e0ca95..c993d668e 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/index.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/index.ts @@ -1,5 +1,3 @@ export { TranslationPipeline } from "./TranslationPipeline.js"; export { translateContent } from "./translateContent.js"; export type { TranslateContentArgs } from "./translateContent.js"; -export type { TranslationStrategy } from "./strategies/index.js"; -export { OverwriteStrategy, SkipExistingStrategy } from "./strategies/index.js"; diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/data-reconciler/DataReconciler.stage.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/data-reconciler/DataReconciler.stage.ts index 8e14d20c8..2b3e02ac7 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/data-reconciler/DataReconciler.stage.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/data-reconciler/DataReconciler.stage.ts @@ -10,7 +10,7 @@ export class DataReconcilerStage implements PipelineStage { const reconciler = new DataReconciler(ctx.schema); return { ...ctx, - filteredData: reconciler.reconcile(ctx.sourceData, ctx.targetData), + reconciledData: reconciler.reconcile(ctx.sourceData, ctx.targetData), }; } } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.stage.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.stage.ts index eca663a31..7e874c517 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.stage.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.stage.ts @@ -1,28 +1,24 @@ import { TranslatorBug } from "../../../errors/index.js"; import type { PipelineContext, PipelineStage } from "../../types/index.js"; -import type { TranslationStrategy } from "../../strategies/index.js"; import { FieldChunkCollector } from "./FieldChunkCollector.js"; /** - * Collects FieldChunks with schema metadata and data references. - * Applies all filtering logic including strategy.shouldTranslate(). - * Stage 2 of the translation pipeline. + * Collects the FieldChunks the run will send: every leaf of the selection whose source is not + * empty. Stage 2 of the translation pipeline. */ export class FieldChunkCollectorStage implements PipelineStage { - constructor(private readonly strategy: TranslationStrategy) {} - execute(ctx: PipelineContext): PipelineContext { - if (!ctx.filteredData) { - throw new TranslatorBug("FieldChunkCollectorStage requires filteredData from previous stage"); + if (!ctx.reconciledData) { + throw new TranslatorBug( + "FieldChunkCollectorStage requires reconciledData from previous stage" + ); } const collector = new FieldChunkCollector( ctx.schema, - ctx.filteredData, + ctx.reconciledData, ctx.sourceData, - ctx.targetData, - this.strategy, - ctx.sourceChangedByLeaf + ctx.selection ); return { ...ctx, diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.test.ts index a11638d57..ac7657e52 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.test.ts @@ -1,11 +1,7 @@ import { describe, it, expect } from "vitest"; import type { Field } from "payload"; import { FieldChunkCollector } from "./FieldChunkCollector.js"; -import { OverwriteStrategy } from "../../strategies/Overwrite.strategy.js"; -import { SkipExistingStrategy } from "../../strategies/SkipExisting.strategy.js"; - -const strategy = new OverwriteStrategy(); -const skipExistingStrategy = new SkipExistingStrategy(); +import { everyTranslatableLeaf } from "../../../domain/content-projection/contentProjector.js"; describe("FieldChunkCollector", () => { describe("simple fields", () => { @@ -13,7 +9,12 @@ describe("FieldChunkCollector", () => { const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const data = { title: "Hello" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -30,7 +31,12 @@ describe("FieldChunkCollector", () => { ]; const data = { title: "Hello", description: "World" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(2); @@ -44,7 +50,12 @@ describe("FieldChunkCollector", () => { ]; const data = { title: "Hello", slug: "hello" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -58,7 +69,12 @@ describe("FieldChunkCollector", () => { ]; const data = { title: "Hello", author: "123" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -72,7 +88,12 @@ describe("FieldChunkCollector", () => { ]; const data = { title: null, subtitle: undefined }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(0); @@ -90,7 +111,12 @@ describe("FieldChunkCollector", () => { ]; const data = { meta: { title: "Hello" } }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -115,7 +141,12 @@ describe("FieldChunkCollector", () => { ]; const data = { level1: { level2: { title: "Deep" } } }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -140,7 +171,12 @@ describe("FieldChunkCollector", () => { ], }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(2); @@ -160,7 +196,12 @@ describe("FieldChunkCollector", () => { ]; const data = { items: [{ id: "1", text: "Hello" }] }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks[0].idPath).toBe("items.1.text"); @@ -185,7 +226,12 @@ describe("FieldChunkCollector", () => { layout: [{ id: "1", blockType: "text", content: "Hello" }], }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -217,7 +263,12 @@ describe("FieldChunkCollector", () => { ], }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(2); @@ -242,7 +293,12 @@ describe("FieldChunkCollector", () => { layout: [{ id: "1", blockType: "unknown", content: "Hello" }], }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(0); @@ -264,7 +320,12 @@ describe("FieldChunkCollector", () => { ]; const data = { title: "Hello" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -280,7 +341,12 @@ describe("FieldChunkCollector", () => { ]; const data = { title: "Hello" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -299,7 +365,12 @@ describe("FieldChunkCollector", () => { ]; const data = { title: "Hello", description: "World" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(2); @@ -326,7 +397,12 @@ describe("FieldChunkCollector", () => { ]; const data = { section: { items: [{ id: "1", label: "Hello" }] } }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -351,7 +427,12 @@ describe("FieldChunkCollector", () => { ]; const data = { items: [{ id: "1", meta: { title: "Hello" } }] }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -381,7 +462,12 @@ describe("FieldChunkCollector", () => { ]; const data = { hero: { content: [{ id: "1", blockType: "text", body: "Hello" }] } }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -411,7 +497,12 @@ describe("FieldChunkCollector", () => { ]; const data = { layout: [{ id: "1", blockType: "card", meta: { title: "Hello" } }] }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -443,7 +534,12 @@ describe("FieldChunkCollector", () => { sections: [{ id: "1", blocks: [{ id: "b1", blockType: "text", content: "Hello" }] }], }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -466,7 +562,12 @@ describe("FieldChunkCollector", () => { ]; const data = { seo: { title: "SEO Title" } }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -485,7 +586,12 @@ describe("FieldChunkCollector", () => { ]; const data = { meta: "Value" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -536,7 +642,12 @@ describe("FieldChunkCollector", () => { }, }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); @@ -590,7 +701,12 @@ describe("FieldChunkCollector", () => { }, }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(5); @@ -609,7 +725,12 @@ describe("FieldChunkCollector", () => { const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const data = { title: "Hello" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); // Mutate through dataRef @@ -628,7 +749,12 @@ describe("FieldChunkCollector", () => { ]; const data = { items: [{ id: "1", label: "Original" }] }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); // Mutate through dataRef @@ -638,281 +764,89 @@ describe("FieldChunkCollector", () => { }); }); - describe("strategy filtering", () => { - it("skips fields with existing target values (SkipExisting)", () => { - const schema: Field[] = [{ name: "title", type: "text", localized: true }]; - const filteredData = { title: "Existing" }; - const sourceData = { title: "Hello" }; - const targetData = { title: "Existing" }; - - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); - const chunks = collector.collect(); + describe("what the selection decides", () => { + const addressed: Field[] = [ + { name: "title", type: "text", localized: true }, + { name: "meta", type: "group", fields: [{ name: "title", type: "text", localized: true }] }, + { name: "items", type: "array", fields: [{ name: "label", type: "text", localized: true }] }, + { + name: "layout", + type: "blocks", + blocks: [{ slug: "text", fields: [{ name: "content", type: "text", localized: true }] }], + }, + ]; + const addressedData = { + title: "Hello", + meta: { title: "Meta" }, + items: [ + { id: "a1", label: "First" }, + { id: "a2", label: "Second" }, + ], + layout: [ + { id: "b1", blockType: "text", content: "One" }, + { id: "b2", blockType: "text", content: "Two" }, + ], + }; + const collect = (selection: string[]) => + new FieldChunkCollector(addressed, addressedData, addressedData, selection) + .collect() + .map((chunk) => String(chunk.idPath)); - expect(chunks).toHaveLength(0); + it("collects a leaf the selection names", () => { + expect(collect(["title"])).toEqual(["title"]); }); - it("collects fields with empty target values (SkipExisting)", () => { - const schema: Field[] = [{ name: "title", type: "text", localized: true }]; - const filteredData = { title: "Hello" }; - const sourceData = { title: "Hello" }; - const targetData = { title: "" }; - - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); - const chunks = collector.collect(); - - expect(chunks).toHaveLength(1); - expect(chunks[0].key).toBe("title"); + it("leaves out a leaf the selection does not name", () => { + expect(collect(["title"])).not.toContain("meta.title"); }); - it("handles mixed fields with SkipExisting", () => { - const schema: Field[] = [ - { name: "title", type: "text", localized: true }, - { name: "description", type: "text", localized: true }, - ]; - const filteredData = { title: "Existing", description: "World" }; - const sourceData = { title: "Hello", description: "World" }; - const targetData = { title: "Existing", description: "" }; - - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); - const chunks = collector.collect(); - - expect(chunks).toHaveLength(1); - expect(chunks[0].key).toBe("description"); + it("collects nothing when the selection names nothing", () => { + expect(collect([])).toEqual([]); }); - it("collects all fields with OverwriteStrategy even when target exists", () => { - const schema: Field[] = [{ name: "title", type: "text", localized: true }]; - const filteredData = { title: "Existing" }; - const sourceData = { title: "Hello" }; - const targetData = { title: "Existing" }; - - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - strategy - ); - const chunks = collector.collect(); - - expect(chunks).toHaveLength(1); + it("reads the selection by the leaf's address, not by its field name", () => { + expect(collect(["meta.title"])).toEqual(["meta.title"]); }); - it("applies SkipExisting to nested array fields", () => { - const schema: Field[] = [ - { - name: "items", - type: "array", - fields: [{ name: "label", type: "text", localized: true }], - }, - ]; - const filteredData = { - items: [ - { id: "1", label: "Translated" }, - { id: "2", label: "Second" }, - ], - }; - const sourceData = { - items: [ - { id: "1", label: "First" }, - { id: "2", label: "Second" }, - ], - }; - const targetData = { - items: [ - { id: "1", label: "Translated" }, - { id: "2", label: "" }, - ], - }; - - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); - const chunks = collector.collect(); - - expect(chunks).toHaveLength(1); - expect(chunks[0].idPath).toBe("items.2.label"); + it("collects one array element's leaf and leaves its sibling alone", () => { + expect(collect(["items.a2.label"])).toEqual(["items.a2.label"]); }); - it("applies SkipExisting to nested group fields", () => { - const schema: Field[] = [ - { - name: "meta", - type: "group", - fields: [ - { name: "title", type: "text", localized: true }, - { name: "description", type: "text", localized: true }, - ], - }, - ]; - const filteredData = { meta: { title: "Translated", description: "World" } }; - const sourceData = { meta: { title: "Hello", description: "World" } }; - const targetData = { meta: { title: "Translated", description: "" } }; - - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); - const chunks = collector.collect(); - - expect(chunks).toHaveLength(1); - expect(chunks[0].idPath).toBe("meta.description"); - }); - - it("applies SkipExisting to blocks fields", () => { - const schema: Field[] = [ - { - name: "layout", - type: "blocks", - blocks: [ - { - slug: "text", - fields: [{ name: "content", type: "text", localized: true }], - }, - ], - }, - ]; - const filteredData = { - layout: [ - { id: "1", blockType: "text", content: "Translated" }, - { id: "2", blockType: "text", content: "World" }, - ], - }; - const sourceData = { - layout: [ - { id: "1", blockType: "text", content: "Hello" }, - { id: "2", blockType: "text", content: "World" }, - ], - }; - const targetData = { - layout: [ - { id: "1", blockType: "text", content: "Translated" }, - { id: "2", blockType: "text", content: "" }, - ], - }; - - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); - const chunks = collector.collect(); - - expect(chunks).toHaveLength(1); - expect(chunks[0].idPath).toBe("layout.2:text.content"); + it("collects one block's leaf and leaves its sibling alone", () => { + expect(collect(["layout.b1:text.content"])).toEqual(["layout.b1:text.content"]); }); - it("pairs the target by id, not position, for reordered localized blocks (SkipExisting)", () => { - // Regression: the target locale orders its blocks differently from source. Pairing target by - // position would feed shouldTranslate the WRONG block's value — skipping a block that needs - // translation and re-translating one that doesn't. - const schema: Field[] = [ - { - name: "layout", - type: "blocks", - blocks: [{ slug: "text", fields: [{ name: "content", type: "text", localized: true }] }], - }, - ]; - // filteredData is post-reconcile: source order, id stripped, blockType kept. - const filteredData = { - layout: [ - { blockType: "text", content: "Hello" }, - { blockType: "text", content: "World" }, - ], - }; - const sourceData = { - layout: [ - { id: "1", blockType: "text", content: "Hello" }, - { id: "2", blockType: "text", content: "World" }, - ], - }; - // Reordered target: id 2 first (empty → needs translation), id 1 second (already translated). - const targetData = { - layout: [ - { id: "2", blockType: "text", content: "" }, - { id: "1", blockType: "text", content: "Hallo" }, - ], - }; + it("leaves out a named leaf whose source is empty", () => { + const schema: Field[] = [{ name: "title", type: "text", localized: true }]; + const data = { title: " " }; - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); - const chunks = collector.collect(); + const chunks = new FieldChunkCollector(schema, data, data, ["title"]).collect(); - expect(chunks).toHaveLength(1); - expect(chunks[0].idPath, "positional pairing would have taken index 0").toBe( - "layout.2:text.content" - ); - expect(chunks[0].dataRef.content).toBe("World"); + expect(chunks).toEqual([]); }); }); describe("sourceValue mutation", () => { - it("writes sourceValue to filteredData when shouldTranslate is true", () => { + it("writes the source value into reconciledData for a selected leaf", () => { const schema: Field[] = [{ name: "title", type: "text", localized: true }]; - const filteredData = { title: "Target value" }; + const reconciledData = { title: "Target value" }; const sourceData = { title: "Source value" }; - const targetData = { title: "Target value" }; - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - strategy - ); + const collector = new FieldChunkCollector(schema, reconciledData, sourceData, ["title"]); collector.collect(); - expect(filteredData.title).toBe("Source value"); + expect(reconciledData.title).toBe("Source value"); }); - it("does not modify filteredData when shouldTranslate is false", () => { + it("leaves reconciledData alone for a leaf the selection left out", () => { const schema: Field[] = [{ name: "title", type: "text", localized: true }]; - const filteredData = { title: "Existing" }; + const reconciledData = { title: "Existing" }; const sourceData = { title: "Hello" }; - const targetData = { title: "Existing" }; - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - skipExistingStrategy - ); + const collector = new FieldChunkCollector(schema, reconciledData, sourceData, []); collector.collect(); - expect(filteredData.title).toBe("Existing"); + expect(reconciledData.title).toBe("Existing"); }); it("writes sourceValue in nested structures", () => { @@ -923,20 +857,13 @@ describe("FieldChunkCollector", () => { fields: [{ name: "title", type: "text", localized: true }], }, ]; - const filteredData = { meta: { title: "Target" } }; + const reconciledData = { meta: { title: "Target" } }; const sourceData = { meta: { title: "Source" } }; - const targetData = { meta: { title: "Target" } }; - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - strategy - ); + const collector = new FieldChunkCollector(schema, reconciledData, sourceData, ["meta.title"]); collector.collect(); - expect(filteredData.meta.title).toBe("Source"); + expect(reconciledData.meta.title).toBe("Source"); }); it("writes sourceValue in array items", () => { @@ -947,20 +874,15 @@ describe("FieldChunkCollector", () => { fields: [{ name: "label", type: "text", localized: true }], }, ]; - const filteredData = { items: [{ id: "1", label: "Target" }] }; + const reconciledData = { items: [{ id: "1", label: "Target" }] }; const sourceData = { items: [{ id: "1", label: "Source" }] }; - const targetData = { items: [{ id: "1", label: "Target" }] }; - const collector = new FieldChunkCollector( - schema, - filteredData, - sourceData, - targetData, - strategy - ); + const collector = new FieldChunkCollector(schema, reconciledData, sourceData, [ + "items.1.label", + ]); collector.collect(); - expect(filteredData.items[0].label).toBe("Source"); + expect(reconciledData.items[0].label).toBe("Source"); }); }); @@ -975,14 +897,19 @@ describe("FieldChunkCollector", () => { ]; const data = { items: [{ id: "1", label: "A" }, "garbage", null] }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); expect(chunks[0].idPath).toBe("items.1.label"); }); - it("falls back to empty source/target per item when the target array is shorter (SkipExisting)", () => { + it("collects nothing for an element the source array does not reach", () => { const schema: Field[] = [ { name: "items", @@ -990,25 +917,22 @@ describe("FieldChunkCollector", () => { fields: [{ name: "label", type: "text", localized: true }], }, ]; - const filteredData = { + const reconciledData = { items: [ { id: "1", label: "A" }, { id: "2", label: "B" }, ], }; - const targetData = { items: [{ id: "1", label: "T" }] }; + const sourceData = { items: [{ id: "1", label: "A" }] }; - const collector = new FieldChunkCollector( - schema, - filteredData, - filteredData, - targetData, - skipExistingStrategy - ); + const collector = new FieldChunkCollector(schema, reconciledData, sourceData, [ + "items.1.label", + "items.2.label", + ]); const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].idPath).toBe("items.2.label"); + expect(chunks[0].idPath).toBe("items.1.label"); }); }); @@ -1025,68 +949,16 @@ describe("FieldChunkCollector", () => { ]; const data = { title: "Hello", subtitle: "World" }; - const collector = new FieldChunkCollector(schema, data, data, {}, strategy); + const collector = new FieldChunkCollector( + schema, + data, + data, + everyTranslatableLeaf(data, schema) + ); const chunks = collector.collect(); expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("subtitle"); // title excluded via translateKit.exclude }); }); - - describe("what the strategy is told about a leaf's source", () => { - const schema: Field[] = [ - { name: "title", type: "text", localized: true }, - { - name: "items", - type: "array", - fields: [{ name: "label", type: "text", localized: true }], - }, - ]; - const data = { title: "Hello", items: [{ id: "a1", label: "First" }] }; - - it("reads the map by the leaf's address and hands the answer to the strategy", () => { - const asked: (boolean | undefined)[] = []; - const spy = { - shouldTranslate: (ctx: { sourceChanged?: boolean }) => { - asked.push(ctx.sourceChanged); - return true; - }, - }; - - new FieldChunkCollector(schema, data, data, {}, spy, { - title: true, - "items.a1.label": false, - }).collect(); - - expect(asked).toEqual([true, false]); - }); - - it("tells the strategy nothing about a leaf the map has no entry for", () => { - const asked: (boolean | undefined)[] = []; - const spy = { - shouldTranslate: (ctx: { sourceChanged?: boolean }) => { - asked.push(ctx.sourceChanged); - return true; - }, - }; - - new FieldChunkCollector(schema, data, data, {}, spy, { title: true }).collect(); - - expect(asked).toEqual([true, undefined]); - }); - - it("tells the strategy nothing when no map was supplied", () => { - const asked: (boolean | undefined)[] = []; - const spy = { - shouldTranslate: (ctx: { sourceChanged?: boolean }) => { - asked.push(ctx.sourceChanged); - return true; - }, - }; - - new FieldChunkCollector(schema, data, data, {}, spy).collect(); - - expect(asked).toEqual([undefined, undefined]); - }); - }); }); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.ts index 1d90f13cb..06aa3ba18 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.ts @@ -1,70 +1,52 @@ import { elementSegment, makeIdPath } from "../../../domain/content-projection/idPath.js"; import type { PathSegment } from "../../../domain/content-projection/idPath.js"; import { isTranslatableLeaf } from "../../../domain/content-projection/translatableLeaf.js"; +import type { LeafAddress } from "../../../domain/content-projection/idPath.js"; import type { ChildCursor, FieldLike, FieldWalker } from "../../../kernel/field-traversal/index.js"; -import { - matchElementById, - resolveBlockFields, - walkFields, -} from "../../../kernel/field-traversal/index.js"; +import { resolveBlockFields, walkFields } from "../../../kernel/field-traversal/index.js"; +import { isEmpty } from "../../../kernel/utils/isEmpty.js"; import { isObject } from "../../../kernel/utils/isObject.js"; -import type { ChangedLeaves } from "../../../domain/provenance/staleness.js"; -import type { TranslationStrategy } from "../../strategies/index.js"; import type { FieldChunk } from "../../types/index.js"; -/** Data position for the collect walk: the three parallel trees plus the path from root. */ type Cursor = { data: Record; source: Record; - target: Record; segments: PathSegment[]; }; const asObject = (value: unknown): Record => (isObject(value) ? value : {}); /** - * Collects FieldChunks by walking schema + data with the shared {@link walkFields} engine. - * Iteration is driven by `filteredData`; each translatable, localized, non-excluded leaf that - * the strategy approves is written with its source value and pushed as a chunk carrying a - * mutable reference to its parent object (for later write-back). Collect-only — `combine` is a - * no-op; results accumulate in a closure. - * - * Array/block elements pair their `source` by position (`filteredData` is built in source order) - * but their `target` by `id` (see {@link matchElementById}) — the target locale's elements may be - * independently ordered, so a positional target would feed `strategy.shouldTranslate` the wrong - * element's value and skip/re-translate the wrong leaf. + * Collects the FieldChunks a run will send, walking `reconciledData` against the schema with the + * shared {@link walkFields} engine. `collect()` seats each chosen leaf's source value into + * `reconciledData` — an object value as a detached copy, because write-back mutates it in place and + * would otherwise translate the caller's own source — and each chunk points at that parent. * * IMPORTANT: Expects ORIGINAL collection schemas (before Payload sanitization). Original * schemas preserve `localized: true` on nested fields. */ export class FieldChunkCollector { private readonly schema: FieldLike[]; - private readonly filteredData: Record; + private readonly reconciledData: Record; private readonly sourceData: Record; - private readonly targetData: Record; - private readonly strategy: TranslationStrategy; - private readonly sourceChangedByLeaf?: ChangedLeaves; + private readonly selection: ReadonlySet; constructor( schema: FieldLike[], - filteredData: Record, + reconciledData: Record, sourceData: Record, - targetData: Record, - strategy: TranslationStrategy, - sourceChangedByLeaf?: ChangedLeaves + selection: readonly LeafAddress[] ) { this.schema = schema; - this.filteredData = filteredData; + this.reconciledData = reconciledData; this.sourceData = sourceData; - this.targetData = targetData; - this.strategy = strategy; - this.sourceChangedByLeaf = sourceChangedByLeaf; + this.selection = new Set(selection); } collect(): FieldChunk[] { const selected: { dataRef: Record; key: string; sourceValue: unknown }[] = []; const chunks: FieldChunk[] = []; - const { strategy, sourceChangedByLeaf } = this; + const { selection } = this; const walker: FieldWalker = { enterObject(field, cursor) { @@ -73,7 +55,6 @@ export class FieldChunkCollector { return { data: value, source: asObject(cursor.source[field.name]), - target: asObject(cursor.target[field.name]), segments: [...cursor.segments, { kind: "key", name: field.name }], }; }, @@ -82,24 +63,20 @@ export class FieldChunkCollector { const value = cursor.data[field.name]; if (!Array.isArray(value)) return "skip"; const sourceValue = cursor.source[field.name]; - const targetValue = cursor.target[field.name]; const sourceArr: unknown[] = Array.isArray(sourceValue) ? sourceValue : []; - const targetArr: unknown[] = Array.isArray(targetValue) ? targetValue : []; const isBlocks = field.type === "blocks"; const children: ChildCursor[] = []; value.forEach((item, index) => { if (!isObject(item)) return; const fields = isBlocks ? resolveBlockFields(field, item) : field.fields; - if (!fields) return; // unknown blockType → skip element - // source pairs by index (filteredData shares source order); target by the source - // element's id, since the target locale may be reordered independently. + if (!fields) return; + const sourceItem = asObject(sourceArr[index]); children.push({ cursor: { data: item, source: sourceItem, - target: matchElementById(targetArr, sourceItem, isBlocks), segments: [ ...cursor.segments, { kind: "key", name: field.name }, @@ -118,16 +95,8 @@ export class FieldChunkCollector { if (value === undefined || value === null) return undefined; const sourceValue = cursor.source[field.name]; - const targetValue = cursor.target[field.name]; const idPath = makeIdPath([...cursor.segments, { kind: "key", name: field.name }]); - if ( - isTranslatableLeaf(field) && - strategy.shouldTranslate({ - sourceValue, - targetValue, - sourceChanged: sourceChangedByLeaf?.[idPath], - }) - ) { + if (isTranslatableLeaf(field) && selection.has(idPath) && !isEmpty(sourceValue)) { selected.push({ dataRef: cursor.data, key: field.name, sourceValue }); chunks.push({ schema: field, @@ -140,19 +109,16 @@ export class FieldChunkCollector { }, combine() { - return undefined; // collect-only — nothing to assemble + return undefined; }, }; walkFields( this.schema, - { data: this.filteredData, source: this.sourceData, target: this.targetData, segments: [] }, + { data: this.reconciledData, source: this.sourceData, segments: [] }, walker ); - // A detached copy, because write-back mutates an object-valued leaf (a rich-text tree) in - // place: seating the caller's own object here would translate their source document. Scalars - // are immutable, so they are seated as they are. for (const { dataRef, key, sourceValue } of selected) { dataRef[key] = isObject(sourceValue) ? structuredClone(sourceValue) : sourceValue; } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts index 7b3736afd..8944b772c 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { projectTranslatableContent } from "../../../domain/content-projection/contentProjector.js"; import type { FieldLike } from "../../../kernel/field-traversal/types.js"; -import { OverwriteStrategy } from "../../strategies/index.js"; import type { FieldChunk } from "../../types/index.js"; import { PlainTextExpander } from "../text-expander/PlainTextExpander.js"; import { RichTextExpander } from "../text-expander/RichTextExpander.js"; @@ -83,27 +82,30 @@ const asReconciled = (value: unknown): unknown => { const filtered = () => asReconciled(structuredClone(doc)) as Record; +// Fed back as the selection: a leaf the collector addresses differently matches nothing and goes +// missing below, so the guard still fails on drift. +const asTheProjectionNamesThem = (): string[] => + projectTranslatableContent(doc, schema).map((entry) => String(entry.idPath)); + const pipelineAddresses = (): string[] => new FieldChunkCollector( schema as unknown as Field[], filtered(), structuredClone(doc), - {}, - new OverwriteStrategy() + asTheProjectionNamesThem() ) .collect() .map((chunk) => chunk.idPath) .sort(); const pipelineFieldTexts = (): string[] => { - const filteredData = filtered(); + const reconciledData = filtered(); const source = structuredClone(doc); const chunks: FieldChunk[] = new FieldChunkCollector( schema as unknown as Field[], - filteredData, + reconciledData, source, - {}, // empty target → Overwrite translates every present source value - new OverwriteStrategy() + asTheProjectionNamesThem() ).collect(); // Per-node expanders only, deliberately: a container expander sends marked strings diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.test.ts deleted file mode 100644 index e95dbd0f8..000000000 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { OverwriteStrategy } from "./Overwrite.strategy.js"; - -describe("OverwriteStrategy", () => { - const strategy = new OverwriteStrategy(); - - describe("shouldTranslate", () => { - it("returns true when sourceValue is non-empty string", () => { - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: undefined })).toBe(true); - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: "existing" })).toBe( - true - ); - }); - - it("returns true when sourceValue is non-empty object", () => { - expect( - strategy.shouldTranslate({ sourceValue: { key: "value" }, targetValue: undefined }) - ).toBe(true); - }); - - it("returns true when sourceValue is non-empty array", () => { - expect(strategy.shouldTranslate({ sourceValue: [1, 2, 3], targetValue: undefined })).toBe( - true - ); - }); - - it("returns false when sourceValue is empty string", () => { - expect(strategy.shouldTranslate({ sourceValue: "", targetValue: undefined })).toBe(false); - expect(strategy.shouldTranslate({ sourceValue: " ", targetValue: "existing" })).toBe(false); - }); - - it("returns false when sourceValue is null or undefined", () => { - expect(strategy.shouldTranslate({ sourceValue: null, targetValue: undefined })).toBe(false); - expect(strategy.shouldTranslate({ sourceValue: undefined, targetValue: "existing" })).toBe( - false - ); - }); - - it("returns false when sourceValue is empty object", () => { - expect(strategy.shouldTranslate({ sourceValue: {}, targetValue: undefined })).toBe(false); - }); - - it("returns false when sourceValue is empty array", () => { - expect(strategy.shouldTranslate({ sourceValue: [], targetValue: undefined })).toBe(false); - }); - - it("ignores targetValue completely", () => { - // OverwriteStrategy only cares about sourceValue - expect( - strategy.shouldTranslate({ sourceValue: "hello", targetValue: "already translated" }) - ).toBe(true); - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: null })).toBe(true); - }); - }); -}); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.ts deleted file mode 100644 index 2c5844737..000000000 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/Overwrite.strategy.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { isEmpty } from "../../kernel/utils/isEmpty.js"; -import type { TranslationStrategy, StrategyContext } from "./TranslationStrategy.interface.js"; - -/** - * Translates all fields, overwriting existing translations. - * This is the default strategy. - */ -export class OverwriteStrategy implements TranslationStrategy { - shouldTranslate(ctx: StrategyContext): boolean { - return !isEmpty(ctx.sourceValue); - } -} diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.test.ts deleted file mode 100644 index 3f204df9d..000000000 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { SkipExistingStrategy } from "./SkipExisting.strategy.js"; - -describe("SkipExistingStrategy", () => { - const strategy = new SkipExistingStrategy(); - - describe("shouldTranslate", () => { - describe("when sourceValue is empty", () => { - it("returns false for empty string", () => { - expect(strategy.shouldTranslate({ sourceValue: "", targetValue: undefined })).toBe(false); - }); - - it("returns false for null", () => { - expect(strategy.shouldTranslate({ sourceValue: null, targetValue: undefined })).toBe(false); - }); - - it("returns false for undefined", () => { - expect(strategy.shouldTranslate({ sourceValue: undefined, targetValue: undefined })).toBe( - false - ); - }); - }); - - describe("when sourceValue is non-empty and targetValue is empty", () => { - it("returns true when targetValue is undefined", () => { - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: undefined })).toBe( - true - ); - }); - - it("returns true when targetValue is null", () => { - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: null })).toBe(true); - }); - - it("returns true when targetValue is empty string", () => { - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: "" })).toBe(true); - }); - - it("returns true when targetValue is whitespace-only string", () => { - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: " " })).toBe(true); - }); - }); - - describe("when sourceValue is non-empty and targetValue exists", () => { - it("returns false when targetValue is non-empty string", () => { - expect(strategy.shouldTranslate({ sourceValue: "hello", targetValue: "existing" })).toBe( - false - ); - }); - - it("returns false when targetValue is non-empty object", () => { - expect( - strategy.shouldTranslate({ sourceValue: "hello", targetValue: { key: "value" } }) - ).toBe(false); - }); - }); - - describe("richText handling", () => { - const emptyLexicalRoot = { - root: { - type: "root", - children: [], - direction: null, - format: "", - indent: 0, - version: 1, - }, - }; - - const nonEmptyLexicalRoot = { - root: { - type: "root", - children: [ - { - type: "paragraph", - children: [{ type: "text", text: "Hello world", version: 1 }], - direction: "ltr", - format: "", - indent: 0, - version: 1, - }, - ], - direction: "ltr", - format: "", - indent: 0, - version: 1, - }, - }; - - it("returns true when targetValue is empty Lexical root", () => { - expect( - strategy.shouldTranslate({ sourceValue: "hello", targetValue: emptyLexicalRoot }) - ).toBe(true); - }); - - it("returns false when targetValue is non-empty Lexical root", () => { - expect( - strategy.shouldTranslate({ sourceValue: "hello", targetValue: nonEmptyLexicalRoot }) - ).toBe(false); - }); - }); - }); - - describe("a target that is already filled in, and what the receipt says about its source", () => { - const filled = { sourceValue: "Hello", targetValue: "Hallo" }; - - it("refreshes it once the receipt shows the source moved", () => { - expect(strategy.shouldTranslate({ ...filled, sourceChanged: true })).toBe(true); - }); - - it("leaves it alone while the receipt shows the source is unchanged", () => { - expect(strategy.shouldTranslate({ ...filled, sourceChanged: false })).toBe(false); - }); - - it("leaves it alone when nothing is known about its source", () => { - expect(strategy.shouldTranslate({ ...filled, sourceChanged: undefined })).toBe(false); - expect(strategy.shouldTranslate(filled)).toBe(false); - }); - - it("still refuses an empty source, whatever the receipt says", () => { - expect( - strategy.shouldTranslate({ sourceValue: "", targetValue: "Hallo", sourceChanged: true }) - ).toBe(false); - }); - - it("still fills an empty target without needing a receipt", () => { - expect(strategy.shouldTranslate({ sourceValue: "Hello", targetValue: "" })).toBe(true); - }); - }); -}); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.ts deleted file mode 100644 index b1c0b2bd3..000000000 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/SkipExisting.strategy.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { isEmptyRichText } from "../../kernel/lexical/isEmptyRichText.js"; -import { isSerializedLexicalRoot } from "../../kernel/lexical/guards.js"; -import { isEmpty } from "../../kernel/utils/isEmpty.js"; -import type { TranslationStrategy, StrategyContext } from "./TranslationStrategy.interface.js"; - -/** Fills an empty target, and refreshes one whose receipt shows the source moved; anything else is left alone. */ -export class SkipExistingStrategy implements TranslationStrategy { - shouldTranslate(ctx: StrategyContext): boolean { - if (isEmpty(ctx.sourceValue)) return false; - if (this.isEmptyValue(ctx.targetValue)) return true; - return ctx.sourceChanged === true; - } - - private isEmptyValue(value: unknown): boolean { - if (isEmpty(value)) return true; - - if (isSerializedLexicalRoot(value)) { - return isEmptyRichText(value); - } - - return false; - } -} diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/TranslationStrategy.interface.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/TranslationStrategy.interface.ts deleted file mode 100644 index 9b5a506eb..000000000 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/TranslationStrategy.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Context passed to translation strategy for decision making. - */ -export type StrategyContext = { - sourceValue: unknown; - targetValue: unknown; - /** Whether this leaf's source moved since the recorded translation. `undefined` means unknown — do not translate on it. */ - sourceChanged?: boolean; -}; - -/** Called once per translatable leaf during filtering, to decide whether it is translated. */ -export interface TranslationStrategy { - /** - * Determines if a field value should be translated. - * @param ctx - context with source and target values - * @returns true if field should be translated - */ - shouldTranslate(ctx: StrategyContext): boolean; -} diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/index.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/index.ts deleted file mode 100644 index ba1aa4c56..000000000 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/strategies/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { TranslationStrategy } from "./TranslationStrategy.interface.js"; -import { OverwriteStrategy } from "./Overwrite.strategy.js"; -import { SkipExistingStrategy } from "./SkipExisting.strategy.js"; - -export type { TranslationStrategy, StrategyContext } from "./TranslationStrategy.interface.js"; -export { OverwriteStrategy, SkipExistingStrategy }; - -/** - * Strategy names for runtime selection. - */ -export type TranslationStrategyName = "overwrite" | "skip_existing"; - -/** - * Creates a TranslationStrategy instance by name. - */ -export function createTranslationStrategy(name: TranslationStrategyName): TranslationStrategy { - switch (name) { - case "skip_existing": - return new SkipExistingStrategy(); - case "overwrite": - default: - return new OverwriteStrategy(); - } -} diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts index 33756c163..23e737d16 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts @@ -4,6 +4,14 @@ import type { Field } from "payload"; import type { TranslationProvider } from "../domain/translation-providers/index.js"; import { computeSourceFingerprint } from "../domain/content-projection/computeSourceFingerprint.js"; import { translateContent } from "./translateContent.js"; +import type { TranslateContentArgs } from "./translateContent.js"; +import { everyTranslatableLeaf } from "../domain/content-projection/contentProjector.js"; + +const translateAll = (args: Omit) => + translateContent({ + ...args, + selection: everyTranslatableLeaf(args.sourceData, args.schema), + }); // Deterministic fake provider: prefixes each collected text chunk with "T:". // Preserves the numeric keys, as the contract requires. @@ -40,7 +48,7 @@ describe("what the provider is told about the request", () => { it("names the document when the caller has one", async () => { const { provider, seen } = recordingProvider(); - await translateContent({ + await translateAll({ document: { collection: "machines", id: "7" }, schema, sourceData: { title: "Home" }, @@ -55,7 +63,7 @@ describe("what the provider is told about the request", () => { const run = async (schema: Field[], sourceData: Record) => ( - await translateContent({ + await translateAll({ document: { collection: "docs", id: "1" }, schema, sourceData, @@ -160,7 +168,7 @@ describe("translateContent", () => { expect(result).toBeNull(); }); - it("respects skip_existing: keeps a non-empty target value", async () => { + it("sends nothing when the selection is empty, whatever the target holds", async () => { const result = await translateContent({ document: { collection: "docs", id: "1" }, schema: [{ name: "title", type: "text", localized: true }], @@ -169,21 +177,21 @@ describe("translateContent", () => { sourceLng: "en", targetLng: "de", translationProvider: fakeProvider, - strategy: "skip_existing", + selection: [], }); - expect(result).toBeNull(); // nothing to translate — target already filled + expect(result).toBeNull(); }); - it("respects skip_existing: translates when target is empty", async () => { + it("translates a named leaf over a target that already holds text", async () => { const result = await translateContent({ document: { collection: "docs", id: "1" }, schema: [{ name: "title", type: "text", localized: true }], sourceData: { title: "hello" }, - targetData: {}, + targetData: { title: "bereits übersetzt" }, sourceLng: "en", targetLng: "de", translationProvider: fakeProvider, - strategy: "skip_existing", + selection: ["title"], }); expect(result?.translatedData).toEqual({ title: "T:hello" }); expect(result?.leaves).toEqual([{ idPath: "title", answeredInFull: true }]); @@ -219,7 +227,7 @@ describe("translateContent", () => { translate: async () => ({ 0: "<2>Welt<1>Hallo " }), } as TranslationProvider; - const result = await translateContent({ + const result = await translateAll({ document: { collection: "docs", id: "1" }, schema: richSchema, sourceData, diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts index 8e8c376c6..e3dfb35d9 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts @@ -1,32 +1,25 @@ import type { FieldLike } from "../kernel/field-traversal/index.js"; import type { DocumentRef, TranslationProvider } from "../domain/translation-providers/index.js"; import { TranslationPipeline } from "./TranslationPipeline.js"; +import type { LeafAddress } from "../domain/content-projection/idPath.js"; import { PlainTextExpander, RichContainerExpander } from "./stages/index.js"; -import { createTranslationStrategy } from "./strategies/index.js"; -import type { TranslationStrategyName } from "./strategies/index.js"; import type { PipelineResult } from "./types/Pipeline.js"; -import type { ChangedLeaves } from "../domain/provenance/staleness.js"; export type TranslateContentArgs = { - /** Schema subtree to translate (e.g. `[declaredFieldConfig]`). */ schema: FieldLike[]; /** Source values, rooted to match `schema` (e.g. `{ [fieldName]: value }`). */ sourceData: Record; /** - * Existing target-locale values to reconcile against (under `skip_existing` a non-empty target - * wins unless `sourceChangedByLeaf` marks its leaf). Defaults to `{}` — i.e. translate the - * source in place (`overwrite`). + * Existing target-locale values; a leaf outside `selection` keeps its text. Defaults to `{}`. */ targetData?: Record; /** Source language code, or `''` for provider auto-detect. */ sourceLng: string; targetLng: string; - /** Told to the provider so a `systemPrompt` builder can scope itself. */ document: DocumentRef; translationProvider: TranslationProvider; - /** @default 'overwrite' */ - strategy?: TranslationStrategyName; - sourceChangedByLeaf?: ChangedLeaves; + /** The leaf addresses to translate, as {@link makeIdPath} mints them. */ + selection: readonly LeafAddress[]; }; export type TranslatedContent = PipelineResult; @@ -34,8 +27,8 @@ export type TranslatedContent = PipelineResult; /** * Translate a content object over a schema subtree — pure: no DB, no document. * - * Only `localized` text/richText leaves are translated; everything else is reconciled through - * unchanged. `null` when the subtree held nothing translatable. + * Only the `selection`'s leaves are translated, and only where the schema says they are + * translatable; everything else is reconciled through unchanged. `null` when nothing was sent. */ export async function translateContent({ schema, @@ -45,14 +38,12 @@ export async function translateContent({ targetLng, document, translationProvider, - strategy = "overwrite", - sourceChangedByLeaf, + selection, }: TranslateContentArgs): Promise { const marksUsable = translationProvider.capabilities?.inlineMarks === true; const pipeline = new TranslationPipeline({ translationProvider, - translationStrategy: createTranslationStrategy(strategy), textExpanders: marksUsable ? [new RichContainerExpander(), new PlainTextExpander()] : undefined, }); @@ -63,6 +54,6 @@ export async function translateContent({ sourceLng, targetLng, document, - sourceChangedByLeaf, + selection, }); } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/Pipeline.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/Pipeline.ts index bd7dc3e1e..cd5ed24a4 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/Pipeline.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/Pipeline.ts @@ -1,6 +1,6 @@ +import type { LeafAddress } from "../../domain/content-projection/idPath.js"; import type { IdPath } from "../../domain/content-projection/idPath.js"; import type { FieldLike } from "../../kernel/field-traversal/index.js"; -import type { ChangedLeaves } from "../../domain/provenance/staleness.js"; import type { DocumentRef } from "../../domain/translation-providers/index.js"; export type PipelineConfig = { @@ -10,7 +10,7 @@ export type PipelineConfig = { sourceLng: string; targetLng: string; document: DocumentRef; - sourceChangedByLeaf?: ChangedLeaves; + selection: readonly LeafAddress[]; }; export type LeafOutcome = { diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts index b8cfe5d4a..34d63d369 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts @@ -1,13 +1,9 @@ -import type { ChangedLeaves } from "../../domain/provenance/staleness.js"; +import type { LeafAddress } from "../../domain/content-projection/idPath.js"; import type { DocumentRef } from "../../domain/translation-providers/index.js"; import type { FieldLike } from "../../kernel/field-traversal/index.js"; import type { FieldChunk } from "./FieldChunk.js"; import type { TextChunk } from "./TextChunk.js"; -/** - * Pipeline context passed through all stages. - * Each stage reads from context and writes its results back. - */ export type PipelineContext = { readonly schema: FieldLike[]; readonly sourceData: Record; @@ -15,10 +11,9 @@ export type PipelineContext = { readonly sourceLng: string; readonly targetLng: string; readonly document: DocumentRef; - readonly sourceChangedByLeaf?: ChangedLeaves; + readonly selection: readonly LeafAddress[]; - /** Full document shape with reconciled source/target values */ - filteredData?: Record; + reconciledData?: Record; fieldChunks?: FieldChunk[]; textChunks?: TextChunk[]; textMap?: Record; @@ -26,10 +21,6 @@ export type PipelineContext = { writtenChunks?: TextChunk[]; }; -/** - * Interface for pipeline stages. - * Each stage transforms the context and returns updated context. - */ export interface PipelineStage { execute(ctx: PipelineContext): PipelineContext | Promise; } diff --git a/packages/payload-plugin-translator/src/languages/__tests__/Lexical.differential.test.ts b/packages/payload-plugin-translator/src/languages/__tests__/Lexical.differential.test.ts index 33b50ba79..bfb867cf3 100644 --- a/packages/payload-plugin-translator/src/languages/__tests__/Lexical.differential.test.ts +++ b/packages/payload-plugin-translator/src/languages/__tests__/Lexical.differential.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest"; import { applyTranslations } from "../../core/traversal/index.js"; import type { BlockLike, FieldLike } from "../../core/kernel/field-traversal/index.js"; import { FieldChunkCollector } from "../../core/translation-pipeline/stages/field-collector/index.js"; +import { projectTranslatableContent } from "../../core/domain/content-projection/contentProjector.js"; import { defaultExpanders } from "../../core/translation-pipeline/stages/text-expander/index.js"; import { TranslationMutator } from "../../core/translation-pipeline/stages/translation-applicator/index.js"; -import { OverwriteStrategy } from "../../core/translation-pipeline/strategies/index.js"; import type { TextChunk } from "../../core/translation-pipeline/types/index.js"; import { addressesOf } from "./addresses.js"; import type { Doc } from "./fixtures/lexical.js"; @@ -37,8 +37,7 @@ const viaPipeline = (schema: FieldLike[], source: Doc): Compared => { schema, working, source, - {}, - new OverwriteStrategy() + projectTranslatableContent(source, schema).map((entry) => String(entry.idPath)) ).collect(); const textChunks: TextChunk[] = []; diff --git a/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.differential.test.ts b/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.differential.test.ts index 99ab137e7..ff2674a6e 100644 --- a/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.differential.test.ts +++ b/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.differential.test.ts @@ -4,8 +4,8 @@ import { applyTranslations, collectTranslationTargets } from "../../core/travers import type { FieldLike } from "../../core/kernel/field-traversal/index.js"; import { addressesOf } from "./addresses.js"; import { FieldChunkCollector } from "../../core/translation-pipeline/stages/field-collector/index.js"; +import { projectTranslatableContent } from "../../core/domain/content-projection/contentProjector.js"; import { DataReconciler } from "../../core/translation-pipeline/stages/data-reconciler/index.js"; -import { OverwriteStrategy } from "../../core/translation-pipeline/strategies/index.js"; import { PAYLOAD_FIELDS_ID, payloadFieldsLanguage } from "../PayloadFields.language.js"; type Doc = Record; @@ -13,14 +13,13 @@ type Walked = { texts: unknown[]; addresses: string[]; document: Doc }; const translated = (text: unknown): string => `[de] ${String(text)}`; -const viaCollector = (schema: FieldLike[], document: Doc, data?: Doc, target: Doc = {}): Walked => { +const viaCollector = (schema: FieldLike[], document: Doc, data?: Doc): Walked => { const working = data ?? structuredClone(document); const chunks = new FieldChunkCollector( schema, working, document, - target, - new OverwriteStrategy() + projectTranslatableContent(document, schema).map((entry) => String(entry.idPath)) ).collect(); const texts = chunks.map((chunk) => chunk.dataRef[chunk.key]); @@ -326,36 +325,21 @@ describe("the same agreement, on the shape the pipeline actually produces", () = new DataReconciler(testCase.schema).reconcile(testCase.source, testCase.target); it.each(RECONCILED.map((c) => [c.name, c] as const))("%s — same text", (_n, testCase) => { - const old = viaCollector( - testCase.schema, - testCase.source, - reconciled(testCase), - testCase.target - ); + const old = viaCollector(testCase.schema, testCase.source, reconciled(testCase)); const next = viaLanguage(testCase.schema, testCase.source, reconciled(testCase)); expect(next.texts).toEqual(old.texts); }); it.each(RECONCILED.map((c) => [c.name, c] as const))("%s — same addresses", (_n, testCase) => { - const old = viaCollector( - testCase.schema, - testCase.source, - reconciled(testCase), - testCase.target - ); + const old = viaCollector(testCase.schema, testCase.source, reconciled(testCase)); const next = viaLanguage(testCase.schema, testCase.source, reconciled(testCase)); expect(next.addresses).toEqual(old.addresses); }); it.each(RECONCILED.map((c) => [c.name, c] as const))("%s — same document", (_n, testCase) => { - const old = viaCollector( - testCase.schema, - testCase.source, - reconciled(testCase), - testCase.target - ); + const old = viaCollector(testCase.schema, testCase.source, reconciled(testCase)); const next = viaLanguage(testCase.schema, testCase.source, reconciled(testCase)); expect(next.document).toEqual(old.document); diff --git a/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.nonStringLeaf.test.ts b/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.nonStringLeaf.test.ts index b1c5ff656..46a6b8690 100644 --- a/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.nonStringLeaf.test.ts +++ b/packages/payload-plugin-translator/src/languages/__tests__/PayloadFields.nonStringLeaf.test.ts @@ -5,7 +5,6 @@ import { applyTranslations, collectTranslationTargets } from "../../core/travers import { addressesOf } from "./addresses.js"; import type { FieldLike } from "../../core/kernel/field-traversal/index.js"; import { FieldChunkCollector } from "../../core/translation-pipeline/stages/field-collector/index.js"; -import { OverwriteStrategy } from "../../core/translation-pipeline/strategies/index.js"; import { PAYLOAD_FIELDS_ID, payloadFieldsLanguage } from "../PayloadFields.language.js"; const schema: FieldLike[] = [{ type: "text", name: "tags", localized: true }]; @@ -14,13 +13,7 @@ const source = { tags: ["one", "two"] }; describe("a selected leaf whose stored value is not a string", () => { it("the collector hands it over as a chunk, text and all", () => { const working = structuredClone(source); - const chunks = new FieldChunkCollector( - schema, - working, - source, - {}, - new OverwriteStrategy() - ).collect(); + const chunks = new FieldChunkCollector(schema, working, source, ["tags"]).collect(); expect(chunks).toHaveLength(1); expect(chunks[0]?.dataRef[chunks[0].key]).toEqual(["one", "two"]); From a13052d8a5a5cbc91baaed1458b891ac7678b360 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sun, 25 Oct 2026 12:49:55 +0100 Subject: [PATCH 02/25] feat!: a request names the fields to translate, not a strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each target locale carries its own filters: the leaf addresses a request named, or the name of a narrowing rule. The stored row follows — it lives inside Payload's single `input` column, so the shape changes without a migration — and `target_lngs` becomes a list of entries rather than bare locale strings. `pickHost` loses its strategy condition, so two requests over one document that differ only in what they translate now share a single run. A row written before 1.0 still reads: `strategy: "skip_existing"` maps to the drift rule, which is the nearest meaning rather than the same one. BREAKING CHANGE: `POST /enqueue` refuses a body still carrying `strategy`, `selection` or `criteria` with a 400 that names what to send instead. See docs/DEPRECATIONS.md#the-strategy-leaves-the-run. --- .../__tests__/handler.identity.test.ts | 1 - .../__tests__/handler.test.ts | 90 +++++++++++++-- .../__tests__/model.test.ts | 20 ++++ .../__tests__/route.test.ts | 4 +- .../features/enqueue-translation/handler.ts | 12 +- .../features/enqueue-translation/model.ts | 22 ++-- .../TaskRunnerProvider.interface.ts | 6 +- .../__tests__/TaskRunner.conformance.types.ts | 1 - .../__tests__/TaskRunner.invariants.ts | 1 - .../PayloadJobsRunnerProvider.ts | 17 +-- .../PayloadJobsTaskRunner.ts | 41 ++++--- .../__tests__/PayloadJobsTaskRunner.test.ts | 100 +++++++++++++---- .../__tests__/alreadyCovered.test.ts | 103 ++++++++++++++++-- .../__tests__/assignmentOf.test.ts | 42 +++++++ .../__tests__/attemptCount.test.ts | 1 - .../__tests__/covers.test.ts | 71 ++++++++++++ .../__tests__/deliveredLocales.test.ts | 10 +- .../__tests__/jobInputFields.baseline.json | 21 ++-- .../__tests__/jobInputFields.test.ts | 15 ++- .../__tests__/localesAsTheyStand.test.ts | 3 +- .../__tests__/normalizeJob.test.ts | 40 ++++--- .../normalizeJobLocales.contract.test.ts | 35 +++++- .../__tests__/owedIfGaveUp.test.ts | 7 +- .../__tests__/owedOnCancel.test.ts | 6 +- .../__tests__/planEnqueue.requester.test.ts | 14 +-- .../__tests__/planEnqueue.test.ts | 36 ++++-- .../__tests__/runTargets.test.ts | 79 ++++++++++++++ .../__tests__/stillOwed.test.ts | 1 - .../model/alreadyCovered.ts | 25 +++-- .../payload-jobs-runner/model/covers.ts | 26 +++++ .../model/isLastAttempt.ts | 6 +- .../model/localesAsTheyStand.ts | 32 ++++-- .../payload-jobs-runner/model/planEnqueue.ts | 31 +++--- .../payload-jobs-runner/model/stillOwed.ts | 6 +- .../payload-jobs-runner/store/assignmentOf.ts | 14 +-- .../payload-jobs-runner/store/index.ts | 3 + .../store/jobInput.schema.ts | 37 +++++-- .../payload-jobs-runner/store/jobsWhere.ts | 6 +- .../payload-jobs-runner/store/normalizeJob.ts | 9 +- .../store/readCollectionRef.ts | 13 +-- .../payload-jobs-runner/store/runTargets.ts | 42 +++++++ .../store/toPayloadFields.ts | 14 +-- .../payload-jobs-runner/store/types.ts | 27 ++--- .../task-runner/sync-runner/SyncTaskRunner.ts | 3 +- .../__tests__/SyncRunnerProvider.test.ts | 2 - .../__tests__/SyncTaskRunner.test.ts | 2 - .../SyncTaskRunner.transaction.test.ts | 1 - .../src/server/modules/task-runner/types.ts | 14 ++- 48 files changed, 848 insertions(+), 264 deletions(-) create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/assignmentOf.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/covers.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/runTargets.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/covers.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/runTargets.ts diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts index 2313aed97..ede61988b 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts @@ -10,7 +10,6 @@ const body = { target_lng: ["de"], collection_slug: "posts", collection_id: ["doc-1"], - strategy: "overwrite", publish_on_translation: false, }; diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts index 0fb3ba560..4c2b5416b 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts @@ -12,6 +12,8 @@ vi.mock("../../_lib/collection-utils.js", () => ({ getAllCollectionIds: vi.fn().mockResolvedValue(["doc-1", "doc-2", "doc-3"]), })); +const leaves = (...addresses: string[]) => [{ kind: "leaves", addresses }]; + describe("EnqueueTranslationHandler", () => { let handler: EnqueueTranslationHandler; let mockTaskRunner: TaskRunner; @@ -110,7 +112,6 @@ describe("EnqueueTranslationHandler", () => { target_lng: "de", collection_slug: "users", collection_id: ["doc-1"], - strategy: "overwrite", }); const response = await handler.handle(req); @@ -130,7 +131,6 @@ describe("EnqueueTranslationHandler", () => { target_lng: "de", collection_slug: "posts", collection_id: ["doc-1"], - strategy: "overwrite", }); const response = await handler.handle(req); @@ -149,7 +149,6 @@ describe("EnqueueTranslationHandler", () => { target_lng: "de", collection_slug: "posts", collection_id: ["doc-1", "doc-2"], - strategy: "overwrite", }); const response = await handler.handle(req); @@ -165,7 +164,6 @@ describe("EnqueueTranslationHandler", () => { target_lng: "fr", collection_slug: "posts", collection_id: ["doc-123"], - strategy: "skip_existing", }); await handler.handle(req); @@ -177,7 +175,7 @@ describe("EnqueueTranslationHandler", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "fr", - strategy: "skip_existing", + filters: [], publishOnTranslation: false, }, ], @@ -199,7 +197,6 @@ describe("EnqueueTranslationHandler", () => { target_lng: "de", collection_slug: "posts", collection_id: ["doc-1"], - strategy: "overwrite", }); (req as any).payload = mockPayload; @@ -304,10 +301,89 @@ describe("EnqueueTranslationHandler", () => { collectionId: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", + filters: [], publishOnTranslation: false, }, ]); }); }); + + describe("filters", () => { + const asked = (body: Record) => + handler.handle( + createLocalizedRequest({ + source_lng: "en", + target_lng: "de", + collection_slug: "posts", + collection_id: ["doc-1"], + ...body, + }) + ); + + it("carries the addresses through to every task it queues", async () => { + const response = await asked({ + collection_id: ["doc-1", "doc-2"], + filters: leaves("title", "content.#0.heading"), + }); + + const asksFor = leaves("title", "content.#0.heading"); + expect(response.status).toBe(200); + expect(mockTaskRunner.enqueue).toHaveBeenCalledWith( + [ + expect.objectContaining({ collectionId: "doc-1", filters: asksFor }), + expect.objectContaining({ collectionId: "doc-2", filters: asksFor }), + ], + expect.anything() + ); + }); + + it("carries them through select_all too", async () => { + const response = await asked({ select_all: true, filters: leaves("title") }); + + expect(response.status).toBe(200); + }); + + it("refuses an empty leaves filter rather than reading it as nothing", async () => { + const response = await asked({ filters: [{ kind: "leaves", addresses: [] }] }); + + expect(response.status).toBe(400); + }); + + it("accepts an empty filter list, which asks for every translatable leaf", async () => { + const response = await asked({ filters: [] }); + + expect(response.status).toBe(200); + expect(mockTaskRunner.enqueue).toHaveBeenCalledWith( + [expect.objectContaining({ filters: [] })], + expect.anything() + ); + }); + }); + + describe("a rule name", () => { + const asked = (names: string[]) => + handler.handle( + createLocalizedRequest({ + source_lng: "en", + target_lng: "de", + collection_slug: "posts", + collection_id: ["doc-1"], + filters: names.map((name) => ({ kind: "rule", name })), + }) + ); + + /** + * Whether anything answers to the name is settled where the run is narrowed, against the + * criteria as they stand when it runs — see `translate-document/__tests__/handler.criteria`. + */ + it("is queued without the route asking what answers to it", async () => { + const response = await asked(["nobody-answers"]); + + expect(response.status).toBe(200); + expect(mockTaskRunner.enqueue).toHaveBeenCalledWith( + [expect.objectContaining({ filters: [{ kind: "rule", name: "nobody-answers" }] })], + expect.anything() + ); + }); + }); }); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/model.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/model.test.ts index e05b9f2ff..b0271e1df 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/model.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/model.test.ts @@ -34,3 +34,23 @@ describe("EnqueueInputSchema.target_lng", () => { expect(r.success).toBe(false); }); }); + +describe("EnqueueInputSchema — members that narrowed a run and no longer exist", () => { + const asked = (body: Record) => + EnqueueInputSchema.safeParse({ ...base, target_lng: "de", ...body }); + + it.each([ + ["strategy", { strategy: "skip_existing" }], + ["selection", { selection: ["title"] }], + ["criteria", { criteria: ["stale"] }], + ])("refuses `%s` rather than translating the whole document", (_name, body) => { + const result = asked(body); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toContain("filters"); + }); + + it("accepts the shape that replaced them", () => { + expect(asked({ filters: [{ kind: "leaves", addresses: ["title"] }] }).success).toBe(true); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts index db0826954..3286ebd83 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts @@ -13,7 +13,9 @@ const OPEN_ON_PURPOSE = new AnyAccessGuard(); // relocation of route registration stays behaviour-preserving. The handler // logic and the shared wrappers (withAccessCheck / withErrorHandler) are // covered by their own unit tests; here we only assert the route is wired. -const config: EnqueueConfig = { availableCollections: new Set() }; +const config: EnqueueConfig = { + availableCollections: new Set(), +}; const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; describe("createEnqueueRoute (contract)", () => { diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts index b65c16f0d..410cc9bc6 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts @@ -3,8 +3,6 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; import { authCollectionsOf, identityOf } from "../../shared/payload/identityOf.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; -import { extractLocaleCodes } from "../../modules/auto-translate/index.js"; -import type { LocalizationLike } from "../../modules/auto-translate/index.js"; import { isCollectionAvailable, getAllCollectionIds } from "../_lib/collection-utils.js"; import { Locales } from "../../../core/domain/locales/index.js"; @@ -32,10 +30,12 @@ export class EnqueueTranslationHandler { collection_slug, collection_id, select_all, - strategy, + filters, publish_on_translation, } = validationResult.data; + const asked = filters ?? []; + const collectionSlug = isCollectionAvailable(collection_slug, this.config.availableCollections); if (!collectionSlug) { return ServerResponse.badRequest( @@ -43,9 +43,7 @@ export class EnqueueTranslationHandler { ); } - const knownLocales = extractLocaleCodes( - req.payload.config?.localization as LocalizationLike | undefined - ); + const knownLocales = Locales.knownCodes(req.payload.config?.localization); if (!knownLocales) { return ServerResponse.badRequest( "Localization is not enabled in this Payload config; there are no target locales to translate into" @@ -88,7 +86,7 @@ export class EnqueueTranslationHandler { collectionId: id, sourceLng: source_lng, targetLng, - strategy: strategy, + filters: asked, publishOnTranslation: publish_on_translation, })) ); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts index e33cbe518..c57430ed9 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts @@ -2,30 +2,36 @@ import { z } from "zod"; import type { CollectionSlug } from "payload"; import type { EnqueueAssignment } from "../../modules/task-runner/types.js"; +import { TranslationFilterSchema } from "../../../core/domain/selection/index.js"; /** - * Input validation schema + * Zod strips a key the schema does not declare, so a member that used to narrow a run has to be + * declared in order to be refused. Left undeclared, a request asking for less would be answered + * with a run that overwrites the whole target document. */ +const refused = (message: string) => z.never({ invalid_type_error: message }).optional(); + +const SAY_IT_IN_FILTERS = + "Say what the run translates in `filters` — the leaf addresses themselves, or the name of a rule that produces them."; + export const EnqueueInputSchema = z.object({ source_lng: z.string().nonempty(), - // Accept a single locale (back-compat) OR a non-empty list, for multi-target fan-out. The empty - // array is rejected here so an empty multi-select is a validation error, not a silent no-op. target_lng: z.union([z.string().nonempty(), z.array(z.string().nonempty()).min(1)]), collection_slug: z.string().nonempty(), collection_id: z.array(z.coerce.string()).nonempty(), select_all: z.boolean().optional(), - strategy: z.enum(["overwrite", "skip_existing"]).optional().default("overwrite"), + filters: z.array(TranslationFilterSchema).optional(), publish_on_translation: z.boolean().optional().default(false), + /** Removed in 1.0 — see docs/DEPRECATIONS.md#the-strategy-leaves-the-run. */ + strategy: refused(`\`strategy\` was removed. ${SAY_IT_IN_FILTERS}`), + selection: refused(`\`selection\` was merged into \`filters\`. ${SAY_IT_IN_FILTERS}`), + criteria: refused(`\`criteria\` was merged into \`filters\`. ${SAY_IT_IN_FILTERS}`), }); -/** - * Handler configuration - */ export type EnqueueConfig = { availableCollections: Set; }; -/** One enqueued locale, in the snake_case this surface answers in. */ export type EnqueueAssignmentOutput = { collection_slug: string; collection_id: string; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts index 6be7739bd..7bd15baa9 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts @@ -1,18 +1,16 @@ import type { CollectionSlug, Config, Payload } from "payload"; import type { TaskRunner } from "./TaskRunner.interface.js"; -import type { EnqueueAssignment, ID, TaskEvent } from "./types.js"; -import type { TranslationStrategyName } from "../../../core/translation-pipeline/strategies/index.js"; +import type { EnqueueAssignment, ID, RunAsk, TaskEvent } from "./types.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; /** * Input for task handler callback */ -export type TaskHandlerInput = { +export type TaskHandlerInput = RunAsk & { collection: CollectionSlug; collectionId: ID; sourceLng: string; targetLng: string; - strategy: TranslationStrategyName; publishOnTranslation: boolean; /** Zero on the first attempt, and on any runner that does not retry. */ previousAttempts: number; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts index 572986bf8..ee35b24de 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts @@ -32,7 +32,6 @@ export const answering: TaskRunnerProvider = { collectionId: task.collectionId, sourceLng: task.sourceLng, targetLng: task.targetLng, - strategy: task.strategy, handle: "run-1", })); for (const a of assigned) await context.report(payload, a, { state: "queued" }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts index cab832edd..998975012 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts @@ -11,7 +11,6 @@ const input = (collectionId: string, targetLng: string): TaskInput => ({ collectionId, sourceLng: "en", targetLng, - strategy: "overwrite", publishOnTranslation: false, }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts index ea8bd775f..f93d9f624 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts @@ -4,7 +4,6 @@ import type { Config, Payload, WorkflowConfig } from "payload"; import type { TaskRunner } from "../TaskRunner.interface.js"; import type { PayloadJobsRunnerOptions, PayloadJobsRunnerConfig, AutoRunConfig } from "./config.js"; import type { StoredTaskInput, StoredWorkflowInput } from "./store/index.js"; -import type { TranslationStrategyName } from "../../../../core/translation-pipeline/strategies/index.js"; import { PayloadJobsTaskRunner } from "./PayloadJobsTaskRunner.js"; import { chain, contribute } from "../../../shared/payload/contribute.js"; import { @@ -61,23 +60,15 @@ const defaultValues = { }; /** - * What Payload hands this plugin's registered task: the request it runs under, the row it belongs to - * — carried only so a throw can be stored against it — and the locale's stored input. - * - * `strategy` is narrowed here and nowhere else. The column holds any bounded string, because a row - * written by an older version may name a strategy this one dropped; this handler is the one place - * that hands the value to something which only accepts the names this version has. + * What Payload hands the registered task. `job` is carried only so a throw can be stored against it + * for the workflow to read back. */ type TranslateLocaleTask = { req: { payload: Payload }; job?: { id?: unknown; taskStatus?: TaskStatusByLocale }; - input: StoredTaskInput & { strategy: TranslationStrategyName }; + input: StoredTaskInput; }; -/** - * Rows queued before the scope carried one `requester` object are already on disk as two nullable - * columns, so both must be present before they read back as an identity. - */ export function requesterOf(input: { requester_id?: string | number | null; requester_collection?: string | null; @@ -140,7 +131,7 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { collectionId, sourceLng: args.input.source_lng, targetLng: args.input.target_lng, - strategy: args.input.strategy, + filters: args.input.filters, publishOnTranslation: args.input.publish_on_translation ?? false, previousAttempts: attemptsBefore( args.job?.taskStatus?.[taskName]?.[args.input.target_lng] diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts index b13277d44..a938be2e4 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts @@ -5,7 +5,8 @@ import type { TaskRunnerContext } from "../TaskRunnerProvider.interface.js"; import { toTaskFilter } from "../toTaskFilter.js"; import type { EnqueueAssignment, Task, TaskInput, RunResult } from "../types.js"; import type { PayloadJobsRunnerConfig } from "./config.js"; -import type { PayloadJob, StoredWorkflowInput } from "./store/index.js"; +import type { PayloadJob, StoredRunTarget, StoredWorkflowInput } from "./store/index.js"; +import { runLocales, runTargets } from "./store/index.js"; import { assignmentOf, handleOf, @@ -36,7 +37,6 @@ const assign = (request: RequestShape, targetLngs: string[], handle: string): En collectionId: request.collectionId, sourceLng: request.sourceLng, targetLng, - strategy: request.strategy, handle, })); @@ -45,7 +45,6 @@ function requestShape(task: TaskInput, scope: RequestScope): RequestShape { collectionSlug: task.collectionSlug, collectionId: String(task.collectionId), sourceLng: task.sourceLng, - strategy: task.strategy, publishOnTranslation: task.publishOnTranslation, requesterId: scope.requester?.userId ?? null, requesterCollection: scope.requester?.userCollection ?? null, @@ -64,6 +63,11 @@ function documentKey(collectionSlug: string, collectionId: string): string { return `${collectionSlug}\u0000${collectionId}`; } +function storedTargets(group: TaskInput[], locales: string[]): StoredRunTarget[] { + const asked = new Map(group.map((task) => [task.targetLng, task])); + return locales.map((lng) => ({ lng, filters: [...(asked.get(lng)?.filters ?? [])] })); +} + export class PayloadJobsTaskRunner implements TaskRunner { private readonly payload: Payload; private readonly config: PayloadJobsRunnerConfig; @@ -120,12 +124,15 @@ export class PayloadJobsTaskRunner implements TaskRunner { const plan = planEnqueue({ live: liveByDocument.get(documentKey(request.collectionSlug, request.collectionId)) ?? [], request, - requested: group.map((t) => t.targetLng), + requested: storedTargets( + group, + group.map((t) => t.targetLng) + ), exclusiveQueue, }); const undelivered = plan.host - ? await this.extendJob(plan.host, plan.append, first.waitUntil, scope) + ? await this.extendJob(plan.host, storedTargets(group, plan.append), first.waitUntil, scope) : []; const queue = [...plan.queue, ...undelivered]; const delivered = plan.append.filter((locale) => !undelivered.includes(locale)); @@ -136,7 +143,12 @@ export class PayloadJobsTaskRunner implements TaskRunner { : []; if (queue.length === 0) return [...onTheHost, ...onTheCovering]; - const handle = await this.queueWorkflow(request, queue, first.waitUntil, scope); + const handle = await this.queueWorkflow( + request, + storedTargets(group, queue), + first.waitUntil, + scope + ); return handle === null ? [...onTheHost, ...onTheCovering] : [...onTheHost, ...onTheCovering, ...assign(request, queue, handle)]; @@ -144,15 +156,17 @@ export class PayloadJobsTaskRunner implements TaskRunner { private async extendJob( job: PayloadJob, - locales: string[], + appending: StoredRunTarget[], waitUntil: Date | undefined, scope: RequestScope ): Promise { + const locales = appending.map((target) => target.lng); let current = job; let undelivered = locales; for (let attempt = 0; attempt < APPEND_ATTEMPTS; attempt++) { - const listed = current.input?.target_lngs ?? []; - const missing = locales.filter((locale) => !listed.includes(locale)); + const listed = runTargets(current.input); + const already = new Set(listed.map((target) => target.lng)); + const missing = appending.filter((target) => !already.has(target.lng)); const debounce = waitUntil && !current.processing ? waitUntil.toISOString() : undefined; if (missing.length === 0 && !debounce) return []; @@ -161,7 +175,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { collection: this.config.jobsCollection, id: job.id, data: { - input: { ...current.input, target_lngs: [...listed, ...missing] }, + input: { ...current.input, targets: [...listed, ...missing] }, ...(debounce ? { waitUntil: debounce } : {}), }, returning: false, @@ -171,7 +185,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { if (!reread || reread.completedAt) return locales; current = reread; - const stored = new Set(current.input?.target_lngs); + const stored = new Set(runLocales(current.input)); undelivered = locales.filter((locale) => !stored.has(locale)); if (undelivered.length === 0) return []; } @@ -180,7 +194,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { private async queueWorkflow( request: RequestShape, - targetLngs: string[], + targets: StoredRunTarget[], waitUntil: Date | undefined, scope: RequestScope ): Promise { @@ -188,8 +202,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { collection_slug: request.collectionSlug, collection_id: request.collectionId, source_lng: request.sourceLng, - target_lngs: targetLngs, - strategy: request.strategy, + targets, publish_on_translation: request.publishOnTranslation, requester_id: scope.requester?.userId ?? null, requester_collection: scope.requester?.userCollection ?? null, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts index 19256e2b8..ba8b5c8b7 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts @@ -66,7 +66,6 @@ describe("PayloadJobsTaskRunner", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, ...overrides, }); @@ -79,7 +78,6 @@ describe("PayloadJobsTaskRunner", () => { collection: { relationTo: "posts" as CollectionSlug, value: "doc-123" }, source_lng: "en", target_lng: "de", - strategy: "overwrite", }, ...overrides, }); @@ -91,9 +89,8 @@ describe("PayloadJobsTaskRunner", () => { collection_slug: "posts", collection_id: "doc-123", source_lng: "en", - strategy: "overwrite", publish_on_translation: false, - target_lngs: ["de"], + targets: [{ lng: "de", filters: [] }], }, ...overrides, }); @@ -128,8 +125,10 @@ describe("PayloadJobsTaskRunner", () => { collection_slug: "posts", collection_id: "doc-123", source_lng: "en", - target_lngs: ["de", "fr"], - strategy: "overwrite", + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], publish_on_translation: false, requester_id: null, requester_collection: null, @@ -197,7 +196,18 @@ describe("PayloadJobsTaskRunner", () => { const live = createLiveJob({ id: "live-job" }); // First `find` builds the plan, the second verifies the write. mockPayload.find.mockResolvedValueOnce({ docs: [live] }).mockResolvedValueOnce({ - docs: [{ ...live, input: { ...live.input, target_lngs: ["de", "fr"] } }], + docs: [ + { + ...live, + input: { + ...live.input, + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], + }, + }, + ], }); await runner.enqueue([createInput({ targetLng: "fr" })]); @@ -209,9 +219,11 @@ describe("PayloadJobsTaskRunner", () => { collection_slug: "posts", collection_id: "doc-123", source_lng: "en", - strategy: "overwrite", publish_on_translation: false, - target_lngs: ["de", "fr"], + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], }); expect(mockPayload.jobs.queue).not.toHaveBeenCalled(); }); @@ -225,8 +237,7 @@ describe("PayloadJobsTaskRunner", () => { collection_slug: "posts", collection_id: "doc-999", source_lng: "en", - strategy: "overwrite", - target_lngs: ["de"], + targets: [{ lng: "de", filters: [] }], }, }), ], @@ -243,7 +254,18 @@ describe("PayloadJobsTaskRunner", () => { id: "live-job", }); mockPayload.find.mockResolvedValueOnce({ docs: [live] }).mockResolvedValueOnce({ - docs: [{ ...live, input: { ...live.input, target_lngs: ["de", "fr"] } }], + docs: [ + { + ...live, + input: { + ...live.input, + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], + }, + }, + ], }); await runner.enqueue([createInput({ targetLng: "fr" })]); @@ -259,7 +281,13 @@ describe("PayloadJobsTaskRunner", () => { { ...live, completedAt: "2026-01-01T00:00:01Z", - input: { ...live.input, target_lngs: ["de", "fr"] }, + input: { + ...live.input, + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], + }, }, ], }); @@ -269,19 +297,40 @@ describe("PayloadJobsTaskRunner", () => { expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(1); expect(mockPayload.jobs.queue).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ target_lngs: ["fr"] }), + input: expect.objectContaining({ targets: [{ lng: "fr", filters: [] }] }), }) ); }); it("retries the write once when a concurrent append replaced the list", async () => { const live = createLiveJob({ id: "live-job" }); - const clobbered = { ...live, input: { ...live.input, target_lngs: ["de", "es"] } }; + const clobbered = { + ...live, + input: { + ...live.input, + targets: [ + { lng: "de", filters: [] }, + { lng: "es", filters: [] }, + ], + }, + }; mockPayload.find .mockResolvedValueOnce({ docs: [live] }) .mockResolvedValueOnce({ docs: [clobbered] }) .mockResolvedValueOnce({ - docs: [{ ...live, input: { ...live.input, target_lngs: ["de", "es", "fr"] } }], + docs: [ + { + ...live, + input: { + ...live.input, + targets: [ + { lng: "de", filters: [] }, + { lng: "es", filters: [] }, + { lng: "fr", filters: [] }, + ], + }, + }, + ], }); await runner.enqueue([createInput({ targetLng: "fr" })]); @@ -292,7 +341,16 @@ describe("PayloadJobsTaskRunner", () => { it("gives the locale its own job when even the retry does not land it", async () => { const live = createLiveJob({ id: "live-job" }); - const withoutIt = { ...live, input: { ...live.input, target_lngs: ["de", "es"] } }; + const withoutIt = { + ...live, + input: { + ...live.input, + targets: [ + { lng: "de", filters: [] }, + { lng: "es", filters: [] }, + ], + }, + }; mockPayload.find .mockResolvedValueOnce({ docs: [live] }) .mockResolvedValueOnce({ docs: [withoutIt] }) @@ -302,7 +360,7 @@ describe("PayloadJobsTaskRunner", () => { expect(mockPayload.jobs.queue).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ target_lngs: ["fr"] }), + input: expect.objectContaining({ targets: [{ lng: "fr", filters: [] }] }), }) ); }); @@ -477,8 +535,10 @@ describe("PayloadJobsTaskRunner", () => { collection_slug: "posts", collection_id: "doc-123", source_lng: "en", - target_lngs: ["de", "fr"], - strategy: "overwrite", + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], }, log: [ { state: "succeeded", completedAt: "2024-01-01T00:01:00Z", input: { target_lng: "de" } }, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts index c52990122..b66ece7d4 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { alreadyCovered } from "../model/alreadyCovered.js"; +import type { StoredRunTarget } from "../store/index.js"; import type { JobLogEntry, PayloadJob } from "../store/types.js"; const translated = (targetLng: string, completedAt = "2024-01-01T00:01:00Z"): JobLogEntry => ({ @@ -25,7 +26,6 @@ const runCarrying = (targetLngs: string[], log?: JobLogEntry[]): PayloadJob => ( collection_id: "doc-1", source_lng: "en", target_lngs: targetLngs, - strategy: "overwrite", }, }); @@ -33,19 +33,19 @@ describe("alreadyCovered", () => { describe("a locale the run lists and has not translated", () => { it("covers it when it has not been attempted, while another locale of the same run is translated", () => { const run = runCarrying(["de", "fr"], [translated("de")]); - expect(alreadyCovered(run, ["fr"])).toEqual(["fr"]); + expect(alreadyCovered(run, [{ lng: "fr", filters: [] }])).toEqual(["fr"]); }); it("covers it when its attempt failed, because the run will come back to it", () => { const run = runCarrying(["de", "fr"], [translated("de"), attemptFailed("fr")]); - expect(alreadyCovered(run, ["fr"])).toEqual(["fr"]); + expect(alreadyCovered(run, [{ lng: "fr", filters: [] }])).toEqual(["fr"]); }); }); describe("a locale the run lists and has translated", () => { it("does not cover it, while the run still has an unreached locale", () => { const run = runCarrying(["de", "fr"], [translated("de")]); - expect(alreadyCovered(run, ["de"])).toEqual([]); + expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); }); }); @@ -55,7 +55,7 @@ describe("alreadyCovered", () => { ["de", "fr"], [attemptFailed("de", "2024-01-01T00:01:00Z"), translated("de", "2024-01-01T00:03:00Z")] ); - expect(alreadyCovered(run, ["de"])).toEqual([]); + expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); }); it("does not cover a locale that was translated and has a later failure", () => { @@ -63,7 +63,7 @@ describe("alreadyCovered", () => { ["de", "fr"], [translated("de", "2024-01-01T00:01:00Z"), attemptFailed("de", "2024-01-01T00:03:00Z")] ); - expect(alreadyCovered(run, ["de"])).toEqual([]); + expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); }); }); @@ -76,35 +76,46 @@ describe("alreadyCovered", () => { { state: "succeeded", completedAt: "2024-01-01T00:02:00Z", input: {} }, ] ); - expect(alreadyCovered(run, ["de"])).toEqual(["de"]); + expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual(["de"]); }); }); describe("a locale the run does not list", () => { it("does not cover it, though the run has locales it has not reached", () => { const run = runCarrying(["de", "fr"]); - expect(alreadyCovered(run, ["it"])).toEqual([]); + expect(alreadyCovered(run, [{ lng: "it", filters: [] }])).toEqual([]); }); }); describe("a locale asked for twice", () => { it("answers it once", () => { const run = runCarrying(["de", "fr"]); - expect(alreadyCovered(run, ["de", "de"])).toEqual(["de"]); + expect( + alreadyCovered(run, [ + { lng: "de", filters: [] }, + { lng: "de", filters: [] }, + ]) + ).toEqual(["de"]); }); }); describe("locales the request did not ask for", () => { it("leaves out a locale the run carries and has not reached but nobody requested", () => { const run = runCarrying(["de", "fr"]); - expect(alreadyCovered(run, ["de"])).toEqual(["de"]); + expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual(["de"]); }); }); describe("order", () => { it("follows the request, not the order the run lists its locales in", () => { const run = runCarrying(["de", "fr", "es"]); - expect(alreadyCovered(run, ["es", "de", "fr"])).toEqual(["es", "de", "fr"]); + expect( + alreadyCovered(run, [ + { lng: "es", filters: [] }, + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ]) + ).toEqual(["es", "de", "fr"]); }); }); @@ -118,7 +129,75 @@ describe("alreadyCovered", () => { describe("a run carrying no locales", () => { it("yields nothing", () => { const run = runCarrying([]); - expect(alreadyCovered(run, ["de", "fr"])).toEqual([]); + expect( + alreadyCovered(run, [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ]) + ).toEqual([]); + }); + }); + + describe("a locale the run lists while meaning to translate less", () => { + const listing = (target: StoredRunTarget) => + ({ + ...runCarrying([target.lng]), + input: { + collection_slug: "posts", + collection_id: "doc-1", + source_lng: "en", + targets: [target], + }, + }) as PayloadJob; + + it("is not covered when the run named leaves and the request named none", () => { + expect( + alreadyCovered( + listing({ lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }), + [{ lng: "de", filters: [] }] + ) + ).toEqual([]); + }); + + it("is not covered when the request named a leaf the run did not", () => { + const run = listing({ lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }); + + expect( + alreadyCovered(run, [{ lng: "de", filters: [{ kind: "leaves", addresses: ["body"] }] }]) + ).toEqual([]); + }); + + it("is covered when the run named every leaf the request did", () => { + const run = listing({ + lng: "de", + filters: [{ kind: "leaves", addresses: ["title", "body"] }], + }); + + expect( + alreadyCovered(run, [{ lng: "de", filters: [{ kind: "leaves", addresses: ["body"] }] }]) + ).toEqual(["de"]); + }); + + it("is not covered when the run narrows by a rule the request did not name", () => { + const run = listing({ lng: "de", filters: [{ kind: "rule", name: "stale" }] }); + + expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); + }); + + it("is covered when the request names the same rule", () => { + const run = listing({ lng: "de", filters: [{ kind: "rule", name: "stale" }] }); + + expect( + alreadyCovered(run, [{ lng: "de", filters: [{ kind: "rule", name: "stale" }] }]) + ).toEqual(["de"]); + }); + + it("is covered when the run narrows by nothing and the request does", () => { + expect( + alreadyCovered(listing({ lng: "de", filters: [] }), [ + { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, + ]) + ).toEqual(["de"]); }); }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/assignmentOf.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/assignmentOf.test.ts new file mode 100644 index 000000000..02438d57f --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/assignmentOf.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { assignmentOf } from "../store/assignmentOf.js"; +import type { PayloadJob } from "../store/types.js"; + +const job = (input: PayloadJob["input"]): PayloadJob => ({ + id: "job-1", + createdAt: "2026-10-24T00:00:00.000Z", + updatedAt: "2026-10-24T00:00:00.000Z", + input, +}); + +const described = { + collection_slug: "posts", + collection_id: "doc-1", + source_lng: "en", +}; + +describe("one locale of a stored run, as the host is told about it", () => { + it("describes the document, the languages and the run", () => { + expect(assignmentOf(job(described), "de")).toEqual({ + collectionSlug: "posts", + collectionId: "doc-1", + sourceLng: "en", + targetLng: "de", + handle: "job-1", + }); + }); + + it("yields nothing when the row names no source language", () => { + expect( + assignmentOf(job({ collection_slug: "posts", collection_id: "doc-1" }), "de") + ).toBeUndefined(); + }); + + it("yields nothing when the row names no document", () => { + expect( + assignmentOf(job({ source_lng: "en" }), "de"), + "a guessed collection would reach the host as fact" + ).toBeUndefined(); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts index 2c7d28e3d..e705d281a 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts @@ -32,7 +32,6 @@ const runTheTask = async (job: unknown): Promise => { collection_id: "doc-1", source_lng: "en", target_lng: "de", - strategy: "overwrite", publish_on_translation: false, }, }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/covers.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/covers.test.ts new file mode 100644 index 000000000..07cf124b4 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/covers.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import type { TranslationFilter } from "../../../../../core/domain/selection/index.js"; +import { covers } from "../model/covers.js"; + +const leaves = (...addresses: string[]): TranslationFilter => ({ kind: "leaves", addresses }); +const rule = (name: string): TranslationFilter => ({ kind: "rule", name }); + +describe("whether a live run already translates what a request asks for", () => { + it("a run that narrows by nothing covers an ask that narrows by nothing", () => { + expect(covers([], [])).toBe(true); + }); + + it("a run that narrows by nothing covers an ask that names leaves", () => { + expect(covers([], [leaves("title")])).toBe(true); + }); + + it("a run that narrows by nothing covers an ask that names a rule", () => { + expect(covers([], [rule("stale")])).toBe(true); + }); + + it("a run that names leaves does not cover an ask that names none", () => { + expect(covers([leaves("title")], [])).toBe(false); + }); + + it("a run that names leaves covers an ask naming a subset of them", () => { + expect(covers([leaves("title", "body")], [leaves("body")])).toBe(true); + }); + + it("a run that names leaves does not cover an ask naming one it left out", () => { + expect(covers([leaves("title")], [leaves("body")])).toBe(false); + }); + + it("a run that names leaves does not cover an ask naming those and more", () => { + expect(covers([leaves("title")], [leaves("title", "body")])).toBe(false); + }); + + it("a run that names the same leaves covers the ask", () => { + expect(covers([leaves("title")], [leaves("title")])).toBe(true); + }); + + it("a run that names a rule covers an ask naming the same rule", () => { + expect(covers([rule("stale")], [rule("stale")])).toBe(true); + }); + + it("a run that names a rule does not cover an ask naming a different one", () => { + expect(covers([rule("stale")], [rule("other")])).toBe(false); + }); + + it("a run that names a rule does not cover an ask that names only leaves", () => { + expect(covers([rule("stale")], [leaves("title")])).toBe(false); + }); + + it("a run that names leaves does not cover an ask that names only a rule", () => { + expect(covers([leaves("title")], [rule("stale")])).toBe(false); + }); + + it("covers an ask only when every filter the run carries is accepted", () => { + const run = [leaves("title", "body"), rule("stale")]; + + expect(covers(run, [leaves("body"), rule("stale")])).toBe(true); + expect(covers(run, [leaves("body")])).toBe(false); + }); + + it("answers the same however the filters are ordered", () => { + const run = [rule("stale"), leaves("title", "body")]; + const ask = [leaves("body"), rule("stale")]; + + expect(covers(run, ask)).toBe(covers([...run].reverse(), [...ask].reverse())); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/deliveredLocales.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/deliveredLocales.test.ts index 20cf3104f..5c8ad9cd7 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/deliveredLocales.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/deliveredLocales.test.ts @@ -8,7 +8,15 @@ const jobWith = (log: PayloadJob["log"]): PayloadJob => ({ createdAt: "2026-10-09T10:00:00Z", updatedAt: "2026-10-09T10:01:00Z", log, - input: { collection_slug: "docs", collection_id: "1", target_lngs: ["de", "fr", "es"] }, + input: { + collection_slug: "docs", + collection_id: "1", + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + { lng: "es", filters: [] }, + ], + }, }); describe("deliveredLocales", () => { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json index 0e4172182..b73776d46 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json @@ -25,21 +25,20 @@ }, { "type": "text", - "maxLength": 256, "name": "source_lng", + "maxLength": 256, "required": true }, { "type": "text", - "maxLength": 256, "name": "target_lng", + "maxLength": 256, "required": true }, { - "type": "text", - "maxLength": 256, - "name": "strategy", - "required": true + "type": "json", + "name": "filters", + "defaultValue": [] }, { "type": "checkbox", @@ -73,14 +72,8 @@ }, { "type": "text", - "maxLength": 256, "name": "source_lng", - "required": true - }, - { - "type": "text", "maxLength": 256, - "name": "strategy", "required": true }, { @@ -90,7 +83,7 @@ }, { "type": "json", - "name": "target_lngs", + "name": "targets", "required": true }, { @@ -102,4 +95,4 @@ "name": "requester_collection" } ] -} \ No newline at end of file +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts index 726e9cc2d..76a8cb657 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts @@ -38,12 +38,21 @@ describe("the field lists handed to Payload", () => { expect(registered().workflow).toEqual(baseline.workflow); }); - it("names the run's locale list only on the workflow, and one locale only on the task", () => { + it("names the run's target list only on the workflow, and one locale only on the task", () => { const { task, workflow } = registered(); expect(names(task)).toContain("target_lng"); - expect(names(task)).not.toContain("target_lngs"); - expect(names(workflow)).toContain("target_lngs"); + expect(names(task)).not.toContain("targets"); + expect(names(workflow)).toContain("targets"); expect(names(workflow)).not.toContain("target_lng"); }); + + it("carries neither a strategy nor the two members filters replaced", () => { + const { task, workflow } = registered(); + + expect(names(task)).not.toContain("strategy"); + expect(names(workflow)).not.toContain("strategy"); + expect(names(task)).not.toContain("selection"); + expect(names(task)).not.toContain("criteria"); + }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/localesAsTheyStand.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/localesAsTheyStand.test.ts index 15692718c..45bb533b0 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/localesAsTheyStand.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/localesAsTheyStand.test.ts @@ -7,7 +7,6 @@ const runOf = (targets: string[]) => ({ collection_slug: "docs", collection_id: "1", source_lng: "en", - strategy: "overwrite", target_lngs: targets, }, }); @@ -51,8 +50,8 @@ describe("localesAsTheyStand", () => { collection_slug: "docs", collection_id: "1", source_lng: "en", - strategy: "overwrite", target_lng: "de", + filters: [], }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJob.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJob.test.ts index 888c03006..b1ae1e2e4 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJob.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJob.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import type { CollectionSlug } from "payload"; import { normalizeJob, normalizeJobLocales } from "../store/normalizeJob.js"; +import { runTargets } from "../store/runTargets.js"; import type { PayloadJob } from "../store/types.js"; describe("normalizeJob", () => { @@ -15,7 +16,6 @@ describe("normalizeJob", () => { }, source_lng: "en", target_lng: "de", - strategy: "overwrite", }, }; @@ -32,7 +32,6 @@ describe("normalizeJob", () => { collectionId: "doc-456", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, }); }); @@ -215,7 +214,6 @@ describe("normalizeJob", () => { collectionId: "", sourceLng: "", targetLng: "", - strategy: "overwrite", publishOnTranslation: false, }); }); @@ -230,22 +228,29 @@ describe("normalizeJob", () => { expect(task.input.collectionId).toBe(""); }); - it("handles missing strategy with default", () => { + it("reads a run written before 1.0 as one target per listed locale", () => { const job: PayloadJob = { ...baseJob, - input: { ...baseJob.input, strategy: undefined }, + input: { ...baseJob.input, targets: undefined, target_lngs: ["de", "fr"] }, }; - const task = normalizeJob(job); - expect(task.input.strategy).toBe("overwrite"); + + expect(normalizeJobLocales(job).map((task) => task.input.targetLng)).toEqual(["de", "fr"]); }); - it("handles skip_existing strategy", () => { + it("carries a pre-1.0 skip_existing run's intent over as the drift criterion", () => { const job: PayloadJob = { ...baseJob, - input: { ...baseJob.input, strategy: "skip_existing" }, + input: { + ...baseJob.input, + targets: undefined, + target_lngs: ["de"], + strategy: "skip_existing", + }, }; - const task = normalizeJob(job); - expect(task.input.strategy).toBe("skip_existing"); + + expect(runTargets(job.input)).toEqual([ + { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, + ]); }); }); @@ -276,8 +281,11 @@ describe("normalizeJobLocales", () => { collection_slug: "posts", collection_id: "doc-456", source_lng: "en", - target_lngs: ["de", "fr", "es"], - strategy: "overwrite", + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + { lng: "es", filters: [] }, + ], }, }; @@ -300,7 +308,7 @@ describe("normalizeJobLocales", () => { it("does not call a locale failed while its run is still retrying", () => { const [de] = normalizeJobLocales({ ...workflowJob, - input: { ...workflowJob.input, target_lngs: ["de"] }, + input: { ...workflowJob.input, targets: [{ lng: "de", filters: [] }] }, log: [{ state: "failed", input: { target_lng: "de" } }], } as PayloadJob); @@ -313,7 +321,7 @@ describe("normalizeJobLocales", () => { it("calls a locale failed once its run has given up", () => { const [de] = normalizeJobLocales({ ...workflowJob, - input: { ...workflowJob.input, target_lngs: ["de"] }, + input: { ...workflowJob.input, targets: [{ lng: "de", filters: [] }] }, error: { message: "boom" }, hasError: true, log: [{ state: "failed", input: { target_lng: "de" } }], @@ -386,7 +394,7 @@ describe("normalizeJobLocales", () => { it("takes a retried locale's most recent log entry", () => { const rows = normalizeJobLocales({ ...workflowJob, - input: { ...workflowJob.input, target_lngs: ["de"] }, + input: { ...workflowJob.input, targets: [{ lng: "de", filters: [] }] }, log: [ { state: "failed", completedAt: "2024-01-01T00:01:00Z", input: { target_lng: "de" } }, { state: "succeeded", completedAt: "2024-01-01T00:03:00Z", input: { target_lng: "de" } }, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJobLocales.contract.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJobLocales.contract.test.ts index a3bd6b26f..234141805 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJobLocales.contract.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJobLocales.contract.test.ts @@ -9,7 +9,6 @@ const inputOf = (locales: Partial): StoredInput => ({ collection_slug: "posts", collection_id: "doc-1", source_lng: "en", - strategy: "overwrite", publish_on_translation: false, ...locales, }); @@ -18,13 +17,21 @@ const run = (overrides: Partial = {}): PayloadJob => ({ id: 7, createdAt: "2026-10-09T10:00:00Z", updatedAt: "2026-10-09T10:01:00Z", - input: inputOf({ target_lngs: ["de"] }), + input: inputOf({ targets: [{ lng: "de", filters: [] }] }), ...overrides, }); describe("normalizeJobLocales", () => { it("gives a run one row per entry of its locale list, in that order", () => { - const threeLocales = run({ input: inputOf({ target_lngs: ["de", "fr", "es"] }) }); + const threeLocales = run({ + input: inputOf({ + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + { lng: "es", filters: [] }, + ], + }), + }); expect(normalizeJobLocales(threeLocales).map((task) => task.input.targetLng)).toEqual([ "de", @@ -34,7 +41,15 @@ describe("normalizeJobLocales", () => { }); it("reproduces a repeated locale as two rows", () => { - const repeated = run({ input: inputOf({ target_lngs: ["de", "fr", "de"] }) }); + const repeated = run({ + input: inputOf({ + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + { lng: "de", filters: [] }, + ], + }), + }); expect( normalizeJobLocales(repeated).map((task) => task.input.targetLng), @@ -49,7 +64,7 @@ describe("normalizeJobLocales", () => { }); it("gives a job with an empty locale list one row for the single locale it carries", () => { - const emptyList = run({ input: inputOf({ target_lngs: [], target_lng: "de" }) }); + const emptyList = run({ input: inputOf({ targets: [], target_lng: "de" }) }); expect(normalizeJobLocales(emptyList).map((task) => task.input.targetLng)).toEqual(["de"]); }); @@ -156,7 +171,15 @@ describe("normalizeJobLocales", () => { }); it("gives every row of one run the same id", () => { - const threeLocales = run({ input: inputOf({ target_lngs: ["de", "fr", "es"] }) }); + const threeLocales = run({ + input: inputOf({ + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + { lng: "es", filters: [] }, + ], + }), + }); const ids = normalizeJobLocales(threeLocales).map((task) => task.id); expect(new Set(ids).size).toBe(1); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts index 276761990..0501f5ef5 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts @@ -14,8 +14,11 @@ const run = (parts: Partial = {}): PayloadJob => collection_slug: "docs", collection_id: "doc-1", source_lng: "en", - target_lngs: ["de", "fr", "es"], - strategy: "overwrite", + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + { lng: "es", filters: [] }, + ], }, ...parts, }) as PayloadJob; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts index 34d5102cd..4cf71eaba 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts @@ -11,8 +11,10 @@ const run = (overrides: Partial = {}): PayloadJob => ({ collection_slug: "posts", collection_id: "doc-1", source_lng: "en", - target_lngs: ["de", "fr"], - strategy: "overwrite", + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], publish_on_translation: false, }, ...overrides, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.requester.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.requester.test.ts index 4cb3cd417..1d8ee1dbf 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.requester.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.requester.test.ts @@ -8,7 +8,6 @@ const anna: RequestShape = { collectionSlug: "posts", collectionId: "doc-1", sourceLng: "en", - strategy: "overwrite", publishOnTranslation: false, requesterId: "anna", requesterCollection: "users", @@ -25,9 +24,8 @@ const jobQueuedBy = ( collection_slug: "posts", collection_id: "doc-1", source_lng: "en", - strategy: "overwrite", publish_on_translation: false, - target_lngs: ["de"], + targets: [{ lng: "de", filters: [] }], requester_id: requesterId, requester_collection: requesterCollection, }, @@ -41,7 +39,7 @@ describe("planEnqueue — whose job may host a request", () => { const plan = planEnqueue({ live: [jobQueuedBy("boris")], request: anna, - requested: ["fr"], + requested: [{ lng: "fr", filters: [] }], exclusiveQueue: false, }); @@ -53,7 +51,7 @@ describe("planEnqueue — whose job may host a request", () => { const plan = planEnqueue({ live: [jobQueuedBy("anna")], request: anna, - requested: ["fr"], + requested: [{ lng: "fr", filters: [] }], exclusiveQueue: false, }); @@ -66,7 +64,7 @@ describe("planEnqueue — whose job may host a request", () => { const plan = planEnqueue({ live: [jobQueuedBy(null)], request: anna, - requested: ["fr"], + requested: [{ lng: "fr", filters: [] }], exclusiveQueue: false, }); @@ -78,7 +76,7 @@ describe("planEnqueue — whose job may host a request", () => { const plan = planEnqueue({ live: [jobQueuedBy(null)], request: { ...anna, requesterId: null, requesterCollection: null }, - requested: ["fr"], + requested: [{ lng: "fr", filters: [] }], exclusiveQueue: false, }); @@ -92,7 +90,7 @@ describe("planEnqueue — whose job may host a request", () => { const plan = planEnqueue({ live: [jobQueuedBy("anna", "admins")], request: anna, - requested: ["fr"], + requested: [{ lng: "fr", filters: [] }], exclusiveQueue: false, }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts index a88aa5905..7ec090c5b 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts @@ -9,7 +9,6 @@ const request: RequestShape = { collectionSlug: "posts", collectionId: "doc-1", sourceLng: "en", - strategy: "overwrite", publishOnTranslation: false, requesterId: null, requesterCollection: null, @@ -19,9 +18,8 @@ const storedInput = { collection_slug: "posts", collection_id: "doc-1", source_lng: "en", - strategy: "overwrite", publish_on_translation: false, - target_lngs: ["de"], + targets: [{ lng: "de", filters: [] }], }; const job = (overrides: Partial = {}): PayloadJob => ({ @@ -32,8 +30,14 @@ const job = (overrides: Partial = {}): PayloadJob => ({ ...overrides, }); -const plan = (live: PayloadJob[], requested: string[], exclusiveQueue = false) => - planEnqueue({ live, request, requested, exclusiveQueue }); +/** The cases below are about which bucket a locale lands in, so they ask for whole documents. */ +const plan = (live: PayloadJob[], locales: string[], exclusiveQueue = false) => + planEnqueue({ + live, + request, + requested: locales.map((lng) => ({ lng, filters: [] })), + exclusiveQueue, + }); const planOf = (over: Partial = {}): EnqueuePlan => ({ host: null, @@ -130,7 +134,7 @@ describe("planEnqueue", () => { it("does not extend a job in the pre-workflow shape", () => { const legacy = job({ - input: { ...storedInput, target_lngs: undefined, target_lng: "de" }, + input: { ...storedInput, targets: undefined, target_lng: "de" }, }); expect(plan([legacy], ["fr"])).toEqual(planOf({ queue: ["fr"] })); }); @@ -170,7 +174,13 @@ describe("planEnqueue", () => { describe("every requested locale is accounted for", () => { const partlyTranslated = () => job({ - input: { ...storedInput, target_lngs: ["de", "fr"] }, + input: { + ...storedInput, + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], + }, log: [{ state: "succeeded", input: { target_lng: "de" } }], }); @@ -217,12 +227,22 @@ describe("planEnqueue", () => { describe("a job can only take locales from a request it matches", () => { it.each([ - ["a different strategy", { strategy: "skip_existing" }], ["a different source locale", { source_lng: "fr" }], ["a different publish flag", { publish_on_translation: true }], ])("starts its own job for %s", (_label, storedOverride) => { const live = job({ input: { ...storedInput, ...storedOverride } }); expect(plan([live], ["fr"])).toEqual(planOf({ queue: ["fr"] })); }); + + it("takes a locale from a request that names different fields", () => { + const live = job({ + input: { + ...storedInput, + targets: [{ lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }], + }, + }); + + expect(plan([live], ["fr"])).toEqual(planOf({ host: live, append: ["fr"] })); + }); }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/runTargets.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/runTargets.test.ts new file mode 100644 index 000000000..8f0a90b4f --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/runTargets.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import type { StoredRunTarget } from "../store/index.js"; +import { DRIFT_CRITERION } from "../../../provenance/Provenance.criterion.js"; +import { runLocales, runTargets } from "../store/runTargets.js"; + +describe("a run's target locales", () => { + it("reads a row written in the current shape", () => { + const targets: StoredRunTarget[] = [ + { lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }, + { lng: "fr", filters: [] }, + ]; + + expect(runTargets({ targets })).toEqual(targets); + }); + + it("answers nothing for a row that lists no locales either way", () => { + expect(runTargets({})).toEqual([]); + }); + + describe("an entry the row carries in a shape nothing writes", () => { + it("reads a missing filter list as naming no filter", () => { + expect(runTargets({ targets: [{ lng: "de" }] })).toEqual([{ lng: "de", filters: [] }]); + }); + + it.each([ + ["a filter list that is not a list", { lng: "de", filters: "stale" }], + ["a filter of an unknown kind", { lng: "de", filters: [{ kind: "colour", name: "red" }] }], + ["no locale at all", { filters: [] }], + ["nothing", null], + ])("drops an entry carrying %s, rather than answering for it", (_label, entry) => { + expect(runTargets({ targets: [entry, { lng: "fr", filters: [] }] })).toEqual([ + { lng: "fr", filters: [] }, + ]); + }); + }); + + it("gives the locale codes on their own", () => { + expect( + runLocales({ + targets: [ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ], + }) + ).toEqual(["de", "fr"]); + }); +}); + +describe("a run row written before 1.0", () => { + it("reads its flat locale list as one target each, narrowing by nothing", () => { + expect(runTargets({ target_lngs: ["de", "fr"] })).toEqual([ + { lng: "de", filters: [] }, + { lng: "fr", filters: [] }, + ]); + }); + + it("carries a skip_existing run's intent over as a rule filter", () => { + expect(runTargets({ target_lngs: ["de"], strategy: "skip_existing" })).toEqual([ + { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, + ]); + }); + + it("narrows by nothing for any other strategy it carried", () => { + expect(runTargets({ target_lngs: ["de"], strategy: "overwrite" })).toEqual([ + { lng: "de", filters: [] }, + ]); + }); + + /** + * The store spells the rule name rather than importing it, so that `store/` does not depend on the + * optional provenance module. Nothing but this check keeps the two spellings equal. + */ + it("names the rule provenance answers to", () => { + const [target] = runTargets({ target_lngs: ["de"], strategy: "skip_existing" }); + + expect(target?.filters).toEqual([{ kind: "rule", name: DRIFT_CRITERION }]); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts index 7242c5227..ededc636d 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts @@ -9,7 +9,6 @@ const localeOf = (targetLng: string): TaskInput => ({ collectionId: "doc-1", sourceLng: "en", targetLng, - strategy: "overwrite", publishOnTranslation: false, }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/alreadyCovered.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/alreadyCovered.ts index ba64ca9c0..0b50926fe 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/alreadyCovered.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/alreadyCovered.ts @@ -1,21 +1,28 @@ -import type { PayloadJob } from "../store/index.js"; +import type { PayloadJob, StoredRunTarget } from "../store/index.js"; +import { covers } from "./covers.js"; +import { runTargets } from "../store/runTargets.js"; /** - * Which requested locales the live run will translate without being touched. + * Which requested locales the live run will translate without being touched; the caller guarantees + * the run is one this request may join. * - * A failed attempt still counts as covered — the run comes back to it. A locale it has translated - * does not: Payload will not re-run a task id under the same run. - * - * The caller guarantees the run is one this request may join and that its locale list is present. + * A failed attempt still counts as covered — the run comes back to it. A locale it has already + * translated does not: Payload will not re-run a task id under the same run. */ -export type AlreadyCovered = (run: PayloadJob, requested: string[]) => string[]; +export type AlreadyCovered = (run: PayloadJob, requested: StoredRunTarget[]) => string[]; export const alreadyCovered: AlreadyCovered = (run, requested) => { - const listed = new Set(run.input?.target_lngs); + const listed = new Map(runTargets(run.input).map((target) => [target.lng, target])); const translated = new Set( (run.log ?? []) .filter((entry) => entry.state === "succeeded") .map((entry) => entry.input?.target_lng) ); - return [...new Set(requested)].filter((locale) => listed.has(locale) && !translated.has(locale)); + const asked = new Map(requested.map((ask) => [ask.lng, ask])); + return [...asked.values()] + .filter((ask) => { + const entry = listed.get(ask.lng); + return entry !== undefined && !translated.has(ask.lng) && covers(entry.filters, ask.filters); + }) + .map((ask) => ask.lng); }; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/covers.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/covers.ts new file mode 100644 index 000000000..4f190b3de --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/covers.ts @@ -0,0 +1,26 @@ +import type { TranslationFilter } from "../../../../../core/domain/selection/index.js"; + +/** + * Whether a run already carrying `listed` will translate at least what `asked` wants. + * + * Deliberately conservative: a `rule` is only known to imply the same rule by name, so a `false` + * costs a second run, never lost work. + */ +export type Covers = ( + listed: readonly TranslationFilter[], + asked: readonly TranslationFilter[] +) => boolean; + +const impliedBySomeOf = (listed: TranslationFilter, asked: readonly TranslationFilter[]): boolean => + asked.some((ask) => { + if (listed.kind === "leaves") { + return ( + ask.kind === "leaves" && + ask.addresses.every((address) => listed.addresses.includes(address)) + ); + } + return ask.kind === "rule" && ask.name === listed.name; + }); + +export const covers: Covers = (listed, asked) => + listed.every((filter) => impliedBySomeOf(filter, asked)); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/isLastAttempt.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/isLastAttempt.ts index 1a63a42a4..23ddcdb09 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/isLastAttempt.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/isLastAttempt.ts @@ -1,10 +1,8 @@ import { attemptsBefore } from "./attemptsBefore.js"; /** - * Whether the attempt that just threw was the run's last for this locale. - * - * Payload counts the attempts *before* the current one, so `attempts: 3` executes a locale four - * times (`payload/dist/queues/errors/handleTaskError.js:54`). + * Whether the attempt that just threw was the run's last for this locale: `attempts: 3` executes a + * locale four times, so the limit is reached at `>=` ({@link attemptsBefore}). */ export type IsLastAttempt = (tried: { totalTried?: unknown } | undefined, limit: number) => boolean; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/localesAsTheyStand.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/localesAsTheyStand.ts index 142306571..c51d6f467 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/localesAsTheyStand.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/localesAsTheyStand.ts @@ -1,11 +1,18 @@ -/** Only what this reads: the run's locale list, and the rest of the input each task is handed. */ -type RunInput = { target_lngs?: string[] } & Record; +import type { StoredInput } from "../store/index.js"; +import { runTargets } from "../store/index.js"; export type LocaleRun = { target: string; input: Record; }; +const inputSharedByEveryLocale = ({ + targets: _perLocale, + target_lngs: _legacyLocales, + strategy: _legacyStrategy, + ...shared +}: StoredInput): Record => shared; + /** * The run's locales, one at a time, **re-reading the list on every step**. * @@ -18,14 +25,21 @@ export type LocaleRun = { * * @yields one locale of the run, with the input its task is handed */ -export function* localesAsTheyStand(job: { input: RunInput }): Generator { +export function* localesAsTheyStand(job: { input: StoredInput }): Generator { const walked = new Set(); for (let index = 0; ; index++) { - const { target_lngs: targets, ...shared } = job.input; - const target = targets?.[index]; - if (target === undefined) return; - if (walked.has(target)) continue; - walked.add(target); - yield { target, input: { ...shared, target_lng: target } }; + const shared = inputSharedByEveryLocale(job.input); + const entry = runTargets(job.input)[index]; + if (entry === undefined) return; + if (walked.has(entry.lng)) continue; + walked.add(entry.lng); + yield { + target: entry.lng, + input: { + ...shared, + target_lng: entry.lng, + filters: entry.filters, + }, + }; } } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts index f3d3c2378..588f66778 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts @@ -1,14 +1,13 @@ import type { CollectionSlug } from "payload"; import { alreadyCovered } from "./alreadyCovered.js"; -import { isCancelled } from "../store/index.js"; -import type { PayloadJob } from "../store/index.js"; +import { isCancelled, runLocales } from "../store/index.js"; +import type { PayloadJob, StoredRunTarget } from "../store/index.js"; export type RequestShape = { collectionSlug: CollectionSlug; collectionId: string; sourceLng: string; - strategy: string; publishOnTranslation: boolean; requesterId: string | number | null; requesterCollection: string | null; @@ -18,7 +17,8 @@ export type RequestShape = { * What to do about each locale a request asked for. **Every requested locale appears in exactly one * of `append`, `queue` and `covered`** — one in none of them is work the caller cannot report. * - * `queue` is for a locale the live run has already translated: it will not translate it again. + * `queue` is for a locale the live run cannot serve: one it has already translated, or one it + * lists while meaning to translate less than this request asks for. * `covered` needs nothing done, and `coveredBy` names the run doing it — kept apart from `host`, * which is null when the run may not be written to although it is still translating. */ @@ -31,17 +31,17 @@ export type EnqueuePlan = { }; /** - * A job carries one source locale, strategy, publish flag and requester for all of its locales, so it - * can host only a request matching all four — the invariant is per document *per requester*. + * A job carries one source locale, publish flag and requester for all of its locales, so it can host + * only a request matching those — the invariant is per document *per requester*. Filters are stored + * per locale, so they do not bar hosting. */ function pickHost(live: PayloadJob[], request: RequestShape): PayloadJob | null { const usable = live.filter( (job) => - Array.isArray(job.input?.target_lngs) && + runLocales(job.input).length > 0 && !isCancelled(job.error) && job.hasError !== true && job.input?.source_lng === request.sourceLng && - job.input?.strategy === request.strategy && (job.input?.publish_on_translation ?? false) === request.publishOnTranslation && (job.input?.requester_id ?? null) === request.requesterId && (job.input?.requester_collection ?? null) === request.requesterCollection @@ -60,17 +60,18 @@ function pickHost(live: PayloadJob[], request: RequestShape): PayloadJob | null export function planEnqueue(args: { live: PayloadJob[]; request: RequestShape; - requested: string[]; + requested: StoredRunTarget[]; exclusiveQueue: boolean; }): EnqueuePlan { - const requested = [...new Set(args.requested)]; + const asked = new Map(args.requested.map((target) => [target.lng, target])); + const requested = [...asked.keys()]; const host = pickHost(args.live, args.request); if (!host) return { host: null, append: [], queue: requested, covered: [], coveredBy: null }; - const listed = new Set(host.input?.target_lngs); + const listed = new Set(runLocales(host.input)); const missing = requested.filter((locale) => !listed.has(locale)); - const covered = alreadyCovered(host, requested); - const alreadyTranslated = requested.filter( + const covered = alreadyCovered(host, [...asked.values()]); + const needsItsOwnRun = requested.filter( (locale) => listed.has(locale) && !covered.includes(locale) ); @@ -78,7 +79,7 @@ export function planEnqueue(args: { return { host: null, append: [], - queue: [...missing, ...alreadyTranslated], + queue: [...missing, ...needsItsOwnRun], covered, coveredBy: host, }; @@ -86,7 +87,7 @@ export function planEnqueue(args: { return { host, append: missing, - queue: alreadyTranslated, + queue: needsItsOwnRun, covered, coveredBy: covered.length ? host : null, }; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts index 5ee971142..c893716c8 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts @@ -1,10 +1,6 @@ import type { Task } from "../../types.js"; -/** - * Which of a run's locales it has not delivered; a locale it never reached is owed too. - * - * The caller passes one run's tasks, one per target locale. - */ +/** A run's undelivered locales. Takes one run's tasks, one per target locale. */ export type StillOwed = (tasks: Task[]) => Task[]; export const stillOwed: StillOwed = (tasks) => tasks.filter((task) => task.status !== "completed"); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/assignmentOf.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/assignmentOf.ts index d95a9f5d9..5d8a09e1a 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/assignmentOf.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/assignmentOf.ts @@ -1,5 +1,3 @@ -import type { CollectionSlug } from "payload"; - import type { EnqueueAssignment } from "../../types.js"; import { handleOf } from "./handleOf.js"; import { readCollectionRef } from "./readCollectionRef.js"; @@ -8,24 +6,20 @@ import type { PayloadJob } from "./types.js"; /** * One locale of a stored run — or nothing, when the row cannot describe itself. * - * A row this plugin queued always carries all of it, but `PayloadJob` also models rows from before - * the id-agnostic migration. Rather than substitute a default, an undescribable row yields nothing - * and its caller sends no event: a guessed `strategy` would reach the host as fact, and an empty - * handle would be an assignment the contract says cannot exist. + * Rather than substitute a default: a guessed collection or source language would reach the host as + * fact, and an empty handle would be an assignment the contract says cannot exist. */ export const assignmentOf = (job: PayloadJob, targetLng: string): EnqueueAssignment | undefined => { const { collectionSlug, collectionId } = readCollectionRef(job.input); const sourceLng = job.input?.source_lng; - const strategy = job.input?.strategy; const handle = handleOf(job); - if (!(sourceLng && strategy && handle)) return undefined; + if (!(collectionSlug && sourceLng && handle)) return undefined; return { - collectionSlug: collectionSlug as CollectionSlug, + collectionSlug: collectionSlug, collectionId, sourceLng, targetLng, - strategy, handle, }; }; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts index 0991d33c1..1ef0c7c1d 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts @@ -1,5 +1,8 @@ +export { runLocales, runTargets } from "./runTargets.js"; export type { PayloadJob, + StoredInput, + StoredRunTarget, StoredWorkflowInput, StoredTaskInput, JobLogEntry, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts index d7a12bf38..6341aaaf7 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts @@ -1,30 +1,37 @@ import { z } from "zod"; +import { TranslationFilterSchema } from "../../../../../core/domain/selection/index.js"; + /** * What a translation run stores in its job row — the one declaration of those fields. * * The stored types are inferred from here and the `Field[]` Payload is registered with is generated * from here, so a field added once reaches both. - * - * `strategy` is a bounded string rather than the strategy union because that is what the column can - * hold: a row written by an older version may name a strategy this one no longer has. */ export const TaskInputSchema = z.object({ collection_slug: z.string(), collection_id: z.string(), source_lng: z.string().max(256), target_lng: z.string().max(256), - strategy: z.string().max(256), + filters: z.array(TranslationFilterSchema).default([]), publish_on_translation: z.boolean().default(false), }); +export const RunTargetSchema = z.object({ + lng: z.string().max(256), + filters: z.array(TranslationFilterSchema).default([]), +}); + /** * One run covers a document's whole locale list, so the row names the list and each task names one * entry of it. Derived from the task's shape rather than written out again — which is also what * keeps the two field lists in the order Payload has always been given them. */ -export const WorkflowInputSchema = TaskInputSchema.omit({ target_lng: true }).extend({ - target_lngs: z.array(z.string()), +export const WorkflowInputSchema = TaskInputSchema.omit({ + target_lng: true, + filters: true, +}).extend({ + targets: z.array(RunTargetSchema), /** * Who asked, replayed at run time so the write is checked against their rights rather than * nobody's. `null` keeps the old behaviour of writing with access control off. @@ -43,12 +50,26 @@ export const LegacyCollectionSchema = z.object({ value: z.union([z.string(), z.number()]).nullish(), }); +/** + * What a run row written before 1.0 carries instead of `targets`: a flat locale list and the + * strategy the whole run shared. Read, never written. + * + * @deprecated See docs/DEPRECATIONS.md#the-strategy-leaves-the-stored-run + */ +export const LegacyRunSchema = z.object({ + target_lngs: z.array(z.string()), + strategy: z.string().max(256), +}); + /** * A row's input as it comes back out of the database. * * Everything is optional because the column is `json` and the row may predate any field in it, and - * an old enough row carries the legacy reference instead of the flat pair. + * an old enough row carries the legacy reference instead of the flat pair. That reaches inside + * `targets` too, so its entries stay unknown here and `runTargets` is what parses them. */ export const StoredInputSchema = TaskInputSchema.merge(WorkflowInputSchema) .partial() - .extend({ collection: LegacyCollectionSchema.optional() }); + .extend({ collection: LegacyCollectionSchema.optional() }) + .extend(LegacyRunSchema.partial().shape) + .extend({ targets: z.array(z.unknown()).optional() }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts index 7b983dc24..c87e9dc8e 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts @@ -2,11 +2,9 @@ import type { CollectionSlug, Where } from "payload"; /** * Conditions over a job row's document reference, which Payload stores in a `json` column and this - * plugin has written in two shapes. Each is an `or` whose second branch is the deprecated one, so - * deleting that branch is the whole change when the legacy reference goes — - * docs/DEPRECATIONS.md#jobs-input-collection-field + * plugin has written in two shapes. The second `or` branch — and the numeric id {@link asStored} + * offers for it — is the deprecated one: docs/DEPRECATIONS.md#jobs-input-collection-field */ - export const ofCollection = (collectionSlug: CollectionSlug): Where => ({ or: [ { "input.collection_slug": { equals: collectionSlug } }, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts index a85c1b1d7..1c15ed812 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { runLocales } from "./runTargets.js"; import type { Task, TaskStatus } from "../../types.js"; import type { JobLogEntry, PayloadJob } from "./types.js"; @@ -11,7 +12,6 @@ function getJobStatus(job: PayloadJob): TaskStatus { return "pending"; } -/** Payload keeps a job's error as whatever was thrown, so both of these are read out of `unknown`. */ const ErrorWithMessage = z.object({ message: z.string() }); const CancelledError = z.object({ cancelled: z.literal(true) }); @@ -34,7 +34,6 @@ export function normalizeJob(job: PayloadJob): Task { collectionId, sourceLng: job.input?.source_lng ?? "", targetLng: job.input?.target_lng ?? "", - strategy: (job.input?.strategy as "overwrite" | "skip_existing") ?? "overwrite", publishOnTranslation: job.input?.publish_on_translation ?? false, }, createdAt: job.createdAt, @@ -59,15 +58,15 @@ export function latestLogByLocale(job: PayloadJob): Map { } /** - * One {@link Task} per entry of the run's `target_lngs`; a job without a list expands to itself. + * One {@link Task} per target locale of the run; a job that lists none expands to itself. * * A locale is `completed` only on its own succeeded log entry; every other locale takes the run's * status, because Payload appends a `failed` entry on every attempt and marks the job only once it * gives up (`queues/errors/handleTaskError.js`). */ export function normalizeJobLocales(job: PayloadJob): Task[] { - const targets = job.input?.target_lngs; - if (!Array.isArray(targets) || targets.length === 0) return [normalizeJob(job)]; + const targets = runLocales(job.input); + if (targets.length === 0) return [normalizeJob(job)]; const base = normalizeJob(job); const latestByLocale = latestLogByLocale(job); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readCollectionRef.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readCollectionRef.ts index c6e147b34..9371ea334 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readCollectionRef.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readCollectionRef.ts @@ -6,19 +6,11 @@ import type { ID } from "../../types.js"; import { StoredInputSchema } from "./jobInput.schema.js"; import type { StoredCollectionFields } from "./types.js"; -/** - * Normalized, ID-agnostic document reference parsed out of a stored job input. - */ export type CollectionRef = { collectionSlug: CollectionSlug; collectionId: ID; }; -/** - * The single storage-read boundary for a job's collection reference, and the one place allowed to - * read the legacy relationship a row carried before those references became ID-agnostic. - * See docs/DEPRECATIONS.md#jobs-input-collection-field - */ /** * A text field cleared through Payload's admin reads back as `null`, not as missing. For this row * the two mean the same thing — there is no reference here, look at the legacy one. @@ -46,6 +38,11 @@ const CollectionRefSchema = z.preprocess( const NOTHING: CollectionRef = { collectionSlug: "" as CollectionSlug, collectionId: "" }; +/** + * The single storage-read boundary for a job's collection reference, and the one place allowed to + * read the legacy relationship a row carried before those references became ID-agnostic. + * See docs/DEPRECATIONS.md#jobs-input-collection-field + */ export function readCollectionRef(input: StoredCollectionFields | undefined): CollectionRef { const read = CollectionRefSchema.safeParse(input ?? {}); return read.success ? read.data : NOTHING; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/runTargets.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/runTargets.ts new file mode 100644 index 000000000..d0f4a7f77 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/runTargets.ts @@ -0,0 +1,42 @@ +import { RunTargetSchema } from "./jobInput.schema.js"; +import type { StoredInput, StoredRunTarget } from "./types.js"; + +/** + * Spelled, not imported: `store/` must not depend on the optional provenance module. Must equal its + * `DRIFT_CRITERION` — see docs/DEPRECATIONS.md#the-strategy-leaves-the-stored-run. + */ +const DRIFT_CRITERION = "stale"; + +/** + * `skip_existing` becomes the drift rule — the nearest meaning, not the same one: an empty target + * whose source stands still is no longer filled. Anything else becomes no filter at all. + * + * @deprecated See docs/DEPRECATIONS.md#the-strategy-leaves-the-stored-run + */ +const legacyRunTargets = (input: StoredInput): StoredRunTarget[] => { + if (!Array.isArray(input.target_lngs)) return []; + + const filters: StoredRunTarget["filters"] = + input.strategy === "skip_existing" ? [{ kind: "rule", name: DRIFT_CRITERION }] : []; + return input.target_lngs.map((lng) => ({ lng, filters })); +}; + +/** + * A run's target locales, reading a row written in either shape. + * + * An entry the parser rejects is dropped rather than read as naming no filter: an unreadable entry + * says nothing about what its locale was meant to translate, and reading it as `[]` would turn a + * narrowed run into one that overwrites the whole target document. + */ +export function runTargets(input: StoredInput | undefined): StoredRunTarget[] { + if (Array.isArray(input?.targets)) { + return input.targets.flatMap((entry) => { + const parsed = RunTargetSchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }); + } + return input ? legacyRunTargets(input) : []; +} + +export const runLocales = (input: StoredInput | undefined): string[] => + runTargets(input).map((target) => target.lng); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts index fb5b6f299..fc70734f5 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts @@ -2,14 +2,6 @@ import type { Field } from "payload"; import { ZodArray, ZodBoolean, ZodDefault, ZodNullable, ZodOptional } from "zod"; import type { ZodObject, ZodRawShape, ZodTypeAny } from "zod"; -/** - * Turn a stored-input schema into the `Field[]` Payload registers for a task or a workflow. - * - * Only zod's public surface is read — `instanceof` against the exported classes, `.isOptional()`, - * `.isNullable()`, `.maxLength`, and parsing `undefined` for a default. The private `_def` is - * deliberately avoided: this repository already carries zod 3 and zod 4, and `_def.typeName` is - * `undefined` on the latter, so a generator reading it would break on a version bump. - */ const unwrapped = (schema: ZodTypeAny): ZodTypeAny => { let current = schema; for (;;) { @@ -48,6 +40,12 @@ const fieldFor = (name: string, schema: ZodTypeAny): Field => { return field as Field; }; +/** + * Turn a stored-input schema into the `Field[]` Payload registers for a task or a workflow. + * + * Reads only zod's public surface: `_def.typeName` is `undefined` on zod 4 and this repository + * carries both majors, so a generator reading `_def` would break on a version bump. + */ export const toPayloadFields = (schema: ZodObject): Field[] => Object.entries(schema.shape).map(([name, member]) => fieldFor(name, member as ZodTypeAny)); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts index 0b189ee44..afb846faa 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts @@ -1,19 +1,21 @@ import type { z } from "zod"; -import type { StoredInputSchema, WorkflowInputSchema } from "./jobInput.schema.js"; +import type { RunTargetSchema, StoredInputSchema, WorkflowInputSchema } from "./jobInput.schema.js"; -/** Payload keys a run's per-task state by the task's slug, and each task by the id it ran under — - * which for this plugin's runs is the target locale. */ type TaskSlug = string; -type TaskId = string; +/** Payload keys each task of a run by the id it ran under, which for this plugin is the locale. */ +export type LocaleCode = string; -export type TaskStatusByLocale = Record>; +export type StoredInput = z.infer; + +export type StoredRunTarget = z.infer; + +export type TaskStatusByLocale = Record>; export type PayloadJob = { /** Payload sets this once a run's retries are spent, and never clears it. */ hasError?: boolean; log?: JobLogEntry[]; - /** How many times the whole run has been executed — Payload's second give-up counter. */ totalTried?: unknown; taskStatus?: TaskStatusByLocale; id: string | number; @@ -23,28 +25,21 @@ export type PayloadJob = { error?: unknown; processing?: boolean | null; waitUntil?: string | null; - input?: z.infer; + input?: StoredInput; }; -/** - * Everything a reader needs to say which document a row is for. - * - * Named apart from the row so the one boundary allowed to read the deprecated `collection` can - * demand exactly what it reads and nothing else. - */ export type StoredCollectionFields = Pick< z.infer, "collection_slug" | "collection_id" | "collection" >; /** - * What one locale's task is handed: a row read back, narrowed to the locale this attempt - * translates. `target_lngs` belongs to the run; `target_lng` belongs to the attempt. + * A row read back, narrowed to the one locale this attempt translates: `targets` is the run's list, + * `target_lng` the entry this task was handed. */ export type StoredTaskInput = z.infer & Required, "source_lng" | "target_lng">>; -/** What this plugin writes when it queues a run. */ export type StoredWorkflowInput = z.infer; /** Written by Payload only once a task settles — an absent entry means that locale has not run. */ diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts index 3f6beb1cb..e70acd23c 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts @@ -46,7 +46,6 @@ export class SyncTaskRunner implements TaskRunner { collectionId: input.collectionId, sourceLng: input.sourceLng, targetLng: input.targetLng, - strategy: input.strategy, handle: task.id, }; assigned.push(assignment); @@ -70,7 +69,7 @@ export class SyncTaskRunner implements TaskRunner { collectionId: input.collectionId, sourceLng: input.sourceLng, targetLng: input.targetLng, - strategy: input.strategy, + filters: input.filters, publishOnTranslation: input.publishOnTranslation, previousAttempts: NEVER_RETRIED, }, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts index c8fc730a7..ed5ec2811 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts @@ -18,7 +18,6 @@ const sampleInput: TaskInput = { collectionId: "1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, }; @@ -46,7 +45,6 @@ describe("SyncRunnerProvider", () => { collectionId: "1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, }), {} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts index 43b7b406f..205694ce8 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts @@ -34,7 +34,6 @@ describe("SyncTaskRunner", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, ...overrides, }); @@ -82,7 +81,6 @@ describe("SyncTaskRunner", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, previousAttempts: 0, }, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts index 32a5a39bc..2faaa5c6f 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts @@ -10,7 +10,6 @@ const task: TaskInput = { collectionId: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, }; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts index c6ee58ac6..c59b0b578 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts @@ -1,5 +1,7 @@ import type { CollectionSlug } from "payload"; +import type { TranslationFilter } from "../../../core/domain/selection/index.js"; + /** * Document identifier. * @@ -16,15 +18,22 @@ export type ID = string; */ export type TaskStatus = "pending" | "running" | "completed" | "failed"; +/** + * What a request asked a run to translate, for one target locale: the filters it carries, whose + * answers the run intersects. **No filters means every translatable leaf of the document.** + */ +export type RunAsk = { + filters?: readonly TranslationFilter[]; +}; + /** * Input for creating a new translation task */ -export type TaskInput = { +export type TaskInput = RunAsk & { collectionSlug: CollectionSlug; collectionId: ID; sourceLng: string; targetLng: string; - strategy: "overwrite" | "skip_existing"; publishOnTranslation: boolean; /** * Optional scheduled-run time (debounce). When set, the job runs no earlier than this instant; @@ -65,7 +74,6 @@ export type EnqueueAssignment = { collectionId: ID; sourceLng: string; targetLng: string; - strategy: string; handle: string; }; From a8cbfdfa43579658919c418239596b379b45dea5 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sun, 25 Oct 2026 12:50:04 +0100 Subject: [PATCH 03/25] feat: the run carries one list of filters and one context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler expands the row's filters into leaf addresses and hands the pipeline a finished list; the pipeline knows nothing about filters. One rule exists, `stale`, answered through the criteria the plugin was built with — an unknown name fails the run rather than widening it to the whole document. What a run is fixed on becomes a named context built once per call and never held on the handler, because the queue runs jobs in batches and one instance serves them all. The permission checks leave the handler for the target document: every read carries the requester's identity, and the single write demands a value only the check can mint, so a write cannot be sent without the collection's rules having been asked about exactly what it carries. --- .../__tests__/handler.access.test.ts | 3 +- .../__tests__/handler.criteria.test.ts | 157 ++++++++ .../__tests__/handler.enforce.test.ts | 3 +- .../__tests__/handler.moduleFailure.test.ts | 3 +- .../__tests__/handler.spend.test.ts | 3 +- .../__tests__/handler.test.ts | 115 +++--- .../__tests__/handler.transaction.test.ts | 13 +- .../__tests__/targetDocument.contract.test.ts | 13 + .../features/translate-document/handler.ts | 362 +++++++++--------- .../features/translate-document/model.ts | 12 +- .../translate-document/targetDocument.ts | 158 ++++++++ .../translate-document/targetLayer.ts | 2 + .../translate-document/wireTranslateRunner.ts | 13 +- .../translation-run/Criterion.interface.ts | 24 ++ .../TranslationRun.interface.ts | 3 - .../__tests__/chainOrder.test.ts | 16 - .../server/modules/translation-run/index.ts | 2 + .../modules/translation-run/runChain.ts | 1 - 18 files changed, 602 insertions(+), 301 deletions(-) create mode 100644 packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts create mode 100644 packages/payload-plugin-translator/src/server/features/translate-document/__tests__/targetDocument.contract.test.ts create mode 100644 packages/payload-plugin-translator/src/server/features/translate-document/targetDocument.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/translation-run/Criterion.interface.ts diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts index e6d4f0230..836d13a59 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts @@ -42,7 +42,6 @@ describe("TranslateDocumentHandler — it asks before it writes", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, previousAttempts: 0, ...over, @@ -69,7 +68,7 @@ describe("TranslateDocumentHandler — it asks before it writes", () => { collections: { posts: { config: { versions: { drafts: true } } } }, } as unknown as Payload; - handler = new TranslateDocumentHandler(provider, schemaMap); + handler = new TranslateDocumentHandler({ translationProvider: provider, schemaMap }); (await permission()).mockResolvedValue(true); }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts new file mode 100644 index 000000000..112183103 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CollectionSlug, Payload } from "payload"; + +import { TranslatorConfigError } from "../../../../core/errors/index.js"; +import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; +import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; +import type { Criteria } from "../../../modules/translation-run/index.js"; +import type { TranslationFilter } from "../../../../core/domain/selection/index.js"; +import { TranslateDocumentHandler } from "../handler.js"; +import type { TranslateDocumentInput } from "../model.js"; + +const leaves = (...addresses: string[]): TranslationFilter => ({ kind: "leaves", addresses }); +const rule = (name: string): TranslationFilter => ({ kind: "rule", name }); + +vi.mock("../../../../core/translation-pipeline/index.js", () => ({ + translateContent: vi.fn().mockResolvedValue(null), +})); + +const SCHEMA = [ + { name: "title", type: "text", localized: true }, + { name: "subtitle", type: "text", localized: true }, + { name: "body", type: "text", localized: true }, +]; + +const SOURCE = { id: "doc-1", title: "One", subtitle: "Two", body: "Three" }; + +describe("what a run translates", () => { + let provider: TranslationProvider; + let schemaMap: CollectionSchemaMap; + let payload: Payload; + + const input = (overrides: Partial = {}): TranslateDocumentInput => ({ + collection: "posts" as CollectionSlug, + collectionId: "doc-1", + sourceLng: "en", + targetLng: "de", + publishOnTranslation: false, + previousAttempts: 0, + ...overrides, + }); + + const sentToTheProvider = async () => { + const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); + const call = (translateContent as unknown as ReturnType).mock.calls[0]; + return (call?.[0] as { selection?: readonly string[] })?.selection; + }; + + const run = async (over: Partial, criteria: Criteria = {}) => + await new TranslateDocumentHandler({ + translationProvider: provider, + schemaMap, + criteria, + }).handle(payload, input(over)); + + beforeEach(() => { + vi.clearAllMocks(); + provider = { translate: vi.fn().mockResolvedValue({}) }; + schemaMap = new Map([["posts" as CollectionSlug, SCHEMA]]) as CollectionSchemaMap; + payload = { + findByID: vi.fn().mockResolvedValue(SOURCE), + update: vi.fn().mockResolvedValue({}), + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + collections: { posts: { config: { versions: undefined } } }, + } as unknown as Payload; + }); + + it("sends every translatable leaf when the request carries no filters", async () => { + await run({}); + + expect(await sentToTheProvider()).toEqual(["title", "subtitle", "body"]); + }); + + it("sends only the leaves a leaves filter named", async () => { + await run({ filters: [leaves("subtitle")] }); + + expect(await sentToTheProvider()).toEqual(["subtitle"]); + }); + + it("sends what a rule answers when no leaves filter was given", async () => { + await run({ filters: [rule("drifted")] }, { drifted: async () => ["body"] }); + + expect(await sentToTheProvider()).toEqual(["body"]); + }); + + it("sends only what both filters name", async () => { + await run( + { filters: [leaves("title", "subtitle"), rule("drifted")] }, + { drifted: async () => ["subtitle", "body"] } + ); + + expect(await sentToTheProvider()).toEqual(["subtitle"]); + }); + + it("sends nothing when the rule answers nothing the leaves filter named", async () => { + await run({ filters: [leaves("title"), rule("drifted")] }, { drifted: async () => ["body"] }); + + expect(await sentToTheProvider()).toEqual([]); + }); + + it("refuses to run rather than translate everything when a rule cannot answer", async () => { + const angry: Criteria = { + drifted: async () => { + throw new Error("the store is down"); + }, + }; + + await expect(run({ filters: [rule("drifted")] }, angry)).rejects.toThrow("the store is down"); + expect(provider.translate).not.toHaveBeenCalled(); + }); + + it("refuses to run rather than translate everything when nothing answers the rule", async () => { + await expect(run({ filters: [rule("nobody-answers")] })).rejects.toThrow(TranslatorConfigError); + expect(provider.translate).not.toHaveBeenCalled(); + }); + + describe("what it says when the run translated nothing", () => { + const said = (level: "info" | "warn") => + (payload.logger[level] as unknown as ReturnType).mock.calls + .map((call) => (call[0] as { msg?: string })?.msg) + .join(" | "); + + it("warns that the leaves the request named are not here", async () => { + await run({ filters: [leaves("nowhere")] }); + + expect(said("warn")).toContain("named"); + expect(said("info")).not.toContain("up to date"); + }); + + it("reports a rule that kept nothing as having nothing to bring up to date", async () => { + const stale: Criteria = { stale: async () => [] }; + + await run({ filters: [rule("stale")] }, stale); + + expect(said("info")).toContain("up to date"); + }); + + it("does not blame a rule when the request named leaves and no rule ran", async () => { + await run({ filters: [leaves("nowhere")] }); + + expect(said("warn")).not.toContain("up to date"); + }); + + it("says the document has no translatable text when it has none", async () => { + (payload.findByID as unknown as ReturnType).mockResolvedValue({ id: "doc-1" }); + + await run({}); + + expect(said("info")).toContain("no translatable text"); + }); + + it("blames the send, not the request, when the leaves resolved and nothing came back", async () => { + await run({}); + + expect(said("warn")).toContain("was sent"); + }); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts index d4d692950..e63c69796 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts @@ -43,7 +43,6 @@ describe("TranslateDocumentHandler — where Payload's own enforcement is afford collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, previousAttempts: 0, }); @@ -65,7 +64,7 @@ describe("TranslateDocumentHandler — where Payload's own enforcement is afford collections: { posts: { config: { versions: { drafts: true } } } }, } as unknown as Payload; - handler = new TranslateDocumentHandler(provider, schemaMap); + handler = new TranslateDocumentHandler({ translationProvider: provider, schemaMap }); (await permission()).mockResolvedValue(true); (await rebuilt()).mockResolvedValue(ANNA); }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts index b4844c9c3..504bd85ae 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts @@ -22,7 +22,6 @@ const input = (): TranslateDocumentInput => ({ collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, previousAttempts: 0, }); @@ -54,7 +53,7 @@ describe("a module that fails while the editor's save is still open", () => { }); const handlerWith = (member: ChainMember) => - new TranslateDocumentHandler(provider, schemaMap, [member]); + new TranslateDocumentHandler({ translationProvider: provider, schemaMap, modules: [member] }); it("lets the failure out rather than reporting success over a transaction it may have taken", async () => { const underTest = handlerWith(failingAt("beforeTranslation", new Error("boom"))); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts index 3b5803909..a7360c46c 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts @@ -38,7 +38,6 @@ describe("TranslateDocumentHandler — a refusal costs nothing at the provider", collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, previousAttempts: 0, }); @@ -57,7 +56,7 @@ describe("TranslateDocumentHandler — a refusal costs nothing at the provider", collections: { posts: { config: { versions: { drafts: true } } } }, } as unknown as Payload; - handler = new TranslateDocumentHandler(provider, schemaMap); + handler = new TranslateDocumentHandler({ translationProvider: provider, schemaMap }); const { translate } = await mocks(); translate.mockResolvedValue({ title: "Titel" }); }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts index c07d6eb9a..fcb165971 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts @@ -6,15 +6,17 @@ import { import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, CollectionSlug } from "payload"; import { APIError } from "payload"; +import type { RecordedFingerprints } from "../../../../core/domain/provenance/index.js"; import { TranslateDocumentHandler } from "../handler.js"; import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../../types/AutoTranslateContext.js"; -import type { ProvenanceStore } from "../../../../core/domain/provenance/index.js"; import { ProvenanceService, provenanceIo } from "../../../modules/provenance/index.js"; import type { ProvenanceServiceFactory } from "../../../modules/provenance/index.js"; import type { TranslateDocumentInput } from "../model.js"; import { provenanceInAChain } from "../../../modules/provenance/__tests__/fixtures/inAChain.js"; +import { storeDouble } from "../../../../core/domain/provenance/__tests__/fixtures/provenanceStore.js"; +import type { ProvenanceStore } from "../../../../core/domain/provenance/index.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ translateContent: vi.fn().mockResolvedValue(null), @@ -44,7 +46,6 @@ describe("TranslateDocumentHandler", () => { collectionId: "doc-123", sourceLng: "en", targetLng: TARGET_LNG, - strategy: "overwrite", publishOnTranslation: false, previousAttempts: 0, ...overrides, @@ -86,7 +87,10 @@ describe("TranslateDocumentHandler", () => { }, } as unknown as Payload; - handler = new TranslateDocumentHandler(mockTranslationProvider, mockSchemaMap); + handler = new TranslateDocumentHandler({ + translationProvider: mockTranslationProvider, + schemaMap: mockSchemaMap, + }); }); describe("schema validation", () => { @@ -168,7 +172,6 @@ describe("TranslateDocumentHandler", () => { const input = createInput({ sourceLng: "en", targetLng: "fr", - strategy: "overwrite", }); await handler.handle(mockPayload, input); @@ -180,7 +183,6 @@ describe("TranslateDocumentHandler", () => { targetData: { id: "doc-123", title: "Test" }, sourceLng: "en", targetLng: "fr", - strategy: "overwrite", translationProvider: mockTranslationProvider, }) ); @@ -391,30 +393,18 @@ describe("TranslateDocumentHandler", () => { }); describe("provenance recording", () => { - let store: { - upsert: ReturnType; - find: ReturnType; - findByDocument: ReturnType; - dismiss: ReturnType; - deleteByDocument: ReturnType; - }; + let store: ReturnType; let serviceFactory: ReturnType; const makeHandlerWithProvenance = () => - new TranslateDocumentHandler( - mockTranslationProvider, - mockSchemaMap, - provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory) - ); + new TranslateDocumentHandler({ + translationProvider: mockTranslationProvider, + schemaMap: mockSchemaMap, + modules: provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory), + }); beforeEach(() => { - store = { - upsert: vi.fn(), - find: vi.fn(), - findByDocument: vi.fn(), - dismiss: vi.fn(), - deleteByDocument: vi.fn(), - }; + store = storeDouble(); // A real `ProvenanceService` over a mock store: the record's shape is the service's policy, // not the handler's. serviceFactory = vi.fn( @@ -469,8 +459,7 @@ describe("TranslateDocumentHandler", () => { documentId: "doc-123", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: { kind: "fields", hashes: { title: "fp-fixed" } }, - dismissedFingerprint: null, + sourceFingerprint: { title: "fp-fixed" }, }) ); const record = store.upsert.mock.calls[0][0] as { translatedAt: string }; @@ -523,7 +512,7 @@ describe("TranslateDocumentHandler", () => { expect(fingerprintedDoc).toEqual({ id: "doc-123", title: "Original source" }); expect(store.upsert).toHaveBeenCalledWith( expect.objectContaining({ - sourceFingerprint: { kind: "fields", hashes: { title: "fp-fixed" } }, + sourceFingerprint: { title: "fp-fixed" }, }) ); }); @@ -561,7 +550,7 @@ describe("TranslateDocumentHandler", () => { }); describe("what the handler tells provenance about the run", () => { - it("claims the leaf the pipeline sent and watermarks the one it did not", async () => { + it("claims the leaf the pipeline sent and leaves the one it did not unanswered", async () => { const { computeFieldFingerprints } = await import("../../../../core/domain/content-projection/computeFieldFingerprints.js"); (computeFieldFingerprints as unknown as ReturnType).mockReturnValue({ @@ -569,13 +558,7 @@ describe("TranslateDocumentHandler", () => { tagline: "fp-tagline", }); - const store = { - upsert: vi.fn(), - find: vi.fn().mockResolvedValue(null), - findByDocument: vi.fn(), - dismiss: vi.fn(), - deleteByDocument: vi.fn(), - }; + const store = storeDouble(); const serviceFactory = vi.fn( (payload) => new ProvenanceService( @@ -596,39 +579,31 @@ describe("TranslateDocumentHandler", () => { tagline: "Untouched", }); - const underTest = new TranslateDocumentHandler( - mockTranslationProvider, - mockSchemaMap, - provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory) - ); + const underTest = new TranslateDocumentHandler({ + translationProvider: mockTranslationProvider, + schemaMap: mockSchemaMap, + modules: provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory), + }); await underTest.handle(mockPayload, createInput({})); expect(store.upsert).toHaveBeenCalledTimes(1); const written = store.upsert.mock.calls[0][0] as { - sourceFingerprint: { kind: string; hashes: Record }; + sourceFingerprint: RecordedFingerprints; }; - expect(written.sourceFingerprint).toEqual({ - kind: "fields", - hashes: { title: "fp-title", tagline: "fp-tagline" }, - }); + expect(written.sourceFingerprint).toEqual({ title: "fp-title", tagline: "!unanswered" }); }); - it("hands the pipeline every leaf the source has now, marking an unrecorded one changed", async () => { - const store = { - upsert: vi.fn(), + it("hands the pipeline every translatable leaf when the request named none", async () => { + const store = storeDouble({ find: vi.fn().mockResolvedValue({ collectionSlug: "pages", documentId: "doc-123", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: { kind: "fields", hashes: { title: "stale-hash" } }, + sourceFingerprint: { title: "stale-hash" }, translatedAt: "2026-07-07T00:00:00.000Z", - dismissedFingerprint: null, }), - findByDocument: vi.fn(), - dismiss: vi.fn(), - deleteByDocument: vi.fn(), - }; + }); const serviceFactory = vi.fn( (payload) => new ProvenanceService( @@ -648,28 +623,24 @@ describe("TranslateDocumentHandler", () => { title: "Source", }); - const underTest = new TranslateDocumentHandler( - mockTranslationProvider, - mockSchemaMap, - provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory) - ); + const underTest = new TranslateDocumentHandler({ + translationProvider: mockTranslationProvider, + schemaMap: mockSchemaMap, + modules: provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory), + }); await underTest.handle(mockPayload, createInput({})); const passed = (translateContent as unknown as ReturnType).mock.calls[0][0] as { - sourceChangedByLeaf?: Record; + selection?: readonly string[]; }; - expect(passed.sourceChangedByLeaf).toEqual({ title: true, tagline: true }); + expect(passed.selection).toEqual(["title"]); }); }); it("translates anyway when the prior receipt cannot be read", async () => { - const store = { - upsert: vi.fn(), + const store = storeDouble({ find: vi.fn().mockRejectedValue(new Error("sidecar unavailable")), - findByDocument: vi.fn(), - dismiss: vi.fn(), - deleteByDocument: vi.fn(), - }; + }); const serviceFactory = vi.fn( (payload) => new ProvenanceService( @@ -689,11 +660,11 @@ describe("TranslateDocumentHandler", () => { title: "Source", }); - const underTest = new TranslateDocumentHandler( - mockTranslationProvider, - mockSchemaMap, - provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory) - ); + const underTest = new TranslateDocumentHandler({ + translationProvider: mockTranslationProvider, + schemaMap: mockSchemaMap, + modules: provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory), + }); await expect(underTest.handle(mockPayload, createInput({}))).resolves.toEqual({ success: true, diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts index 3ae2c24f8..dee4fde7a 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts @@ -26,7 +26,6 @@ describe("TranslateDocumentHandler — the caller's transaction", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, previousAttempts: 0, ...over, @@ -48,7 +47,7 @@ describe("TranslateDocumentHandler — the caller's transaction", () => { }, } as unknown as Payload; - handler = new TranslateDocumentHandler(provider, schemaMap); + handler = new TranslateDocumentHandler({ translationProvider: provider, schemaMap }); }); it("joins the source read to it", async () => { @@ -93,13 +92,13 @@ describe("TranslateDocumentHandler — the caller's transaction", () => { lastTranslatedFrom: vi.fn().mockResolvedValue(null), record: vi.fn(), }); - const provenanceHandler = new TranslateDocumentHandler( - { translate: vi.fn().mockResolvedValue({}) }, - new Map([ + const provenanceHandler = new TranslateDocumentHandler({ + translationProvider: { translate: vi.fn().mockResolvedValue({}) }, + schemaMap: new Map([ ["posts" as CollectionSlug, [{ name: "title", type: "text", localized: true }]], ]) as CollectionSchemaMap, - provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory) - ); + modules: provenanceInAChain(serviceFactory as unknown as ProvenanceServiceFactory), + }); await provenanceHandler.handle(payload, input(), { transactionID: TX }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/targetDocument.contract.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/targetDocument.contract.test.ts new file mode 100644 index 000000000..2e3c8ac36 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/targetDocument.contract.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expectTypeOf } from "vitest"; + +import type { ApprovedWrite } from "../targetDocument.js"; + +describe("ApprovedWrite", () => { + it("is not assignable from a plain object", () => { + expectTypeOf<{ title: string }>().not.toExtend(); + }); + + it("cannot be forged by guessing the brand", () => { + expectTypeOf<{ title: string; __brand: "ApprovedWrite" }>().not.toExtend(); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts b/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts index 7de3a1501..a93eb27c4 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts @@ -6,36 +6,93 @@ import { APIError } from "payload"; import type { Handler } from "../../shared/index.js"; import type { TranslationProvider } from "../../../core/domain/translation-providers/index.js"; import { translateContent } from "../../../core/translation-pipeline/index.js"; -import type { ChainMember } from "../../modules/translation-run/index.js"; +import type { LeafOutcome } from "../../../core/translation-pipeline/types/index.js"; +import type { + BeforeTranslation, + ChainMember, + Criteria, + CriterionQuestion, + ModuleState, + RunTarget, +} from "../../modules/translation-run/index.js"; import { runAfterSave, runBeforeTranslation } from "../../modules/translation-run/index.js"; -import { fetchSourceDocument } from "../../shared/payload/sourceDocument.js"; +import { narrowSelection } from "../../../core/domain/selection/index.js"; +import type { LeafAnswer } from "../../../core/domain/selection/index.js"; +import type { RuleName } from "../../../core/domain/selection/TranslationFilter.js"; +import type { LeafAddress } from "../../../core/domain/content-projection/idPath.js"; +import type { TranslationFilter } from "../../../core/domain/selection/index.js"; +import { everyTranslatableLeaf } from "../../../core/domain/content-projection/contentProjector.js"; +import type { FieldLike } from "../../../core/kernel/field-traversal/index.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; -import { freshReq } from "../../shared/payload/RequestScope.shapes.js"; -import { enforcedAtTheRead } from "../../shared/payload/enforcedAtTheRead.js"; -import { checkTranslationPermission, mayWrite, rebuildRequester } from "./translationPermission.js"; -import { SourceUnreadable } from "./SourceUnreadable.js"; -import type { RebuiltRequester } from "./translationPermission.js"; -import { TranslationRefused } from "./TranslationRefused.js"; +import { openTargetDocument } from "./targetDocument.js"; +import type { BothLocales, TargetDocument } from "./targetDocument.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; -import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext.js"; import type { TranslateDocumentInput, TranslateDocumentOutput } from "./model.js"; import { resolveTargetLayer } from "./targetLayer.js"; -import type { PublishScope, TargetLayer } from "./targetLayer.js"; +import type { TargetLayer } from "./targetLayer.js"; -const translatorWriteContext = () => ({ [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true }); +const moduleSeam = (run: Run): Pick => ({ + payload: run.payload, + target: run.target, + scope: run.scope, +}); /** - * Payload applies the collection's field rules to this write itself, deleting a field the requester - * may not write (`fields/hooks/beforeValidate/promise.js`) — it does not throw, so this is safe even - * inside the editor's transaction. An unattributed write keeps the behaviour it always had. + * Per call, never per instance: one handler instance (`wireTranslateRunner.ts`, built once at + * config time) serves jobs the queue runs concurrently, so none of this may be held on `this`. */ -function enforcedAtTheWrite( - requester: RebuiltRequester | null -): { overrideAccess: false; user: RebuiltRequester } | Record { - if (!requester) return {}; - return { overrideAccess: false, user: requester }; -} +type Run = { + readonly payload: Payload; + readonly target: RunTarget; + readonly scope: RequestScope; + readonly schema: FieldLike[]; + readonly layer: TargetLayer; + readonly doc: TargetDocument; +}; + +/** What narrowing concluded, when it left nothing. */ +type EmptyBecause = "no-translatable-text" | "named-leaves-are-not-here" | "rules-kept-nothing"; + +/** The collector has the last word: it drops a leaf whose source is empty, after narrowing kept it. */ +type SentNothingBecause = EmptyBecause | "collector-sent-nothing"; + +const REPORTED: Record = { + "no-translatable-text": { + level: "info", + msg: "translator: this document holds no translatable text", + }, + "named-leaves-are-not-here": { + level: "warn", + msg: "translator: no leaf the request named is translatable text in this document", + }, + "rules-kept-nothing": { + level: "info", + msg: "translator: the run found nothing to bring up to date", + }, + "collector-sent-nothing": { + level: "warn", + msg: "translator: nothing the request selected was sent — not translatable here, or its source is empty", + }, +}; + +const emptyBecause = (step: { + everything: readonly LeafAddress[]; + afterLeaves: readonly LeafAddress[]; + addresses: readonly LeafAddress[]; +}): EmptyBecause | null => { + if (step.addresses.length > 0) return null; + if (step.everything.length === 0) return "no-translatable-text"; + if (step.afterLeaves.length === 0) return "named-leaves-are-not-here"; + return "rules-kept-nothing"; +}; + +type TranslateDocumentDeps = { + readonly translationProvider: TranslationProvider; + readonly schemaMap: CollectionSchemaMap; + readonly modules?: readonly ChainMember[]; + readonly criteria?: Criteria; +}; export class TranslateDocumentHandler implements Handler< TranslateDocumentInput, @@ -44,15 +101,13 @@ export class TranslateDocumentHandler implements Handler< private readonly translationProvider: TranslationProvider; private readonly schemaMap: CollectionSchemaMap; private readonly modules: readonly ChainMember[]; + private readonly criteria: Criteria; - constructor( - translationProvider: TranslationProvider, - schemaMap: CollectionSchemaMap, - modules: readonly ChainMember[] = [] - ) { - this.translationProvider = translationProvider; - this.schemaMap = schemaMap; - this.modules = modules; + constructor(deps: TranslateDocumentDeps) { + this.translationProvider = deps.translationProvider; + this.schemaMap = deps.schemaMap; + this.modules = deps.modules ?? []; + this.criteria = deps.criteria ?? {}; } async handle( @@ -60,135 +115,98 @@ export class TranslateDocumentHandler implements Handler< input: TranslateDocumentInput, scope: RequestScope = {} ): Promise { - const { - collection, - collectionId, - sourceLng, - targetLng, - strategy, - publishOnTranslation, - previousAttempts, - } = input; - - const schema = this.schemaMap.get(collection); - if (!schema) { - throw new TranslatorConfigError(`Collection "${collection}" not found in schemaMap`); + const run = await this.beginRun(payload, input, scope); + const both = await run.doc.readBothLocales(); + await run.doc.refuseUnlessTranslationAllowed(both.sourceData); + + const owed = await this.leavesToSend(run, input.filters ?? [], both.sourceData); + const joined = await this.runModulesBefore(run, both.sourceData, input.previousAttempts); + const translated = await this.translateOrWrap(run, both, owed.addresses); + + if (translated?.translatedData) { + await run.doc.saveTranslation(translated.translatedData, run.layer.write); + await this.runModulesAfter(run, joined.moduleState, translated.leaves); + } else { + this.reportNothingSent(run, owed.emptyBecause); } - const versions = payload.collections[collection].config.versions; - const layer = resolveTargetLayer({ versions, targetLng }); - const requester = await rebuildRequester(payload, scope); - - const [sourceData, currentTargetVersion] = await Promise.all([ - fetchSourceDocument({ - payload, - collection, - id: String(collectionId), - locale: sourceLng, - user: requester, - scope, - }), - payload.findByID({ - req: freshReq(scope), - collection, - id: collectionId, - locale: targetLng, - fallbackLocale: false, - depth: 0, - ...enforcedAtTheRead(requester), - draft: true, - }), - ]); - if (!sourceData) throw new SourceUnreadable(collection, sourceLng); - if (!currentTargetVersion) throw new SourceUnreadable(collection, targetLng); - - const allowed = await checkTranslationPermission({ - payload, - collection, - id: String(collectionId), - data: sourceData, - targetLocale: targetLng, - scope, - user: requester, - }); - if (!allowed) throw new TranslationRefused(collection, targetLng); + await this.publishIfAsked(run, input.publishOnTranslation); + return { success: true }; + } + private async beginRun( + payload: Payload, + input: TranslateDocumentInput, + scope: RequestScope + ): Promise { + const schema = this.schemaMap.get(input.collection); + if (!schema) { + throw new TranslatorConfigError(`Collection "${input.collection}" not found in schemaMap`); + } + + const versions = payload.collections[input.collection].config.versions; + const layer = resolveTargetLayer({ versions, targetLng: input.targetLng }); const target = { - collection, - documentId: collectionId, - sourceLocale: sourceLng, - targetLocale: targetLng, + collection: input.collection, + documentId: input.collectionId, + sourceLocale: input.sourceLng, + targetLocale: input.targetLng, }; - const joined = await runBeforeTranslation( - this.modules, - { payload, target, scope, sourceData, previousAttempts, moduleState: {} }, - payload.logger - ); - const translated = await this.translateOrWrap({ + return { + payload, + target, + scope, schema, - sourceData, - targetData: currentTargetVersion, - sourceLng, - targetLng, - document: { collection, id: collectionId }, - translationProvider: this.translationProvider, - strategy, - sourceChangedByLeaf: joined.sourceChangedByLeaf, - }); + layer, + doc: await openTargetDocument({ payload, target, scope }), + }; + } - if (translated?.translatedData) { - const { translatedData, leaves } = translated; - await this.refuseUnlessAllowed(payload, input, translatedData, scope, requester); - await this.saveTranslatedDocument( - payload, - input, - translatedData, - layer.write, - scope, - requester - ); + private async leavesToSend( + run: Run, + filters: readonly TranslationFilter[], + sourceData: Record + ): Promise<{ addresses: readonly LeafAddress[]; emptyBecause: EmptyBecause | null }> { + const question = { payload: run.payload, scope: run.scope, target: run.target, sourceData }; + const everything = everyTranslatableLeaf(sourceData, run.schema); + const named = filters.flatMap((filter) => (filter.kind === "leaves" ? [filter.addresses] : [])); + const answered = await Promise.all( + filters.flatMap((filter) => + filter.kind === "rule" ? [this.answerTo(filter.name, question)] : [] + ) + ); - await runAfterSave( - this.modules, - { payload, target, scope, moduleState: joined.moduleState, leaves }, - payload.logger - ); - } + const afterLeaves = narrowSelection(everything, named); + const addresses = narrowSelection(afterLeaves, answered); + return { addresses, emptyBecause: emptyBecause({ everything, afterLeaves, addresses }) }; + } - if (publishOnTranslation && layer.kind === "drafts") { - const status = { _status: layer.publish.status }; - await this.refuseUnlessAllowed(payload, input, status, scope, requester); - await this.publishTargetLocale(payload, input, layer.publish, scope, requester); + private answerTo(name: RuleName, question: CriterionQuestion): Promise { + const answer = this.criteria[name]; + if (!answer) { + throw new TranslatorConfigError( + `No criterion answers to "${name}". A run naming it cannot be narrowed, and translating the whole document instead would overwrite reviewed text.` + ); } + return answer(question); + } - return { success: true }; + private runModulesBefore( + run: Run, + sourceData: Record, + previousAttempts: number + ) { + const opening = { ...moduleSeam(run), sourceData, previousAttempts, moduleState: {} }; + return runBeforeTranslation(this.modules, opening, run.payload.logger); } - /** - * The check before the provider call was asked about the source document; Payload will ask the same - * rule about the payload below. A rule that reads `data` answers differently to the two, and at the - * write a refusal is a `Forbidden` — inside the editor's transaction, that is their save. So ask - * once more with exactly what is about to be sent, while a refusal still costs only the translation. - */ - private async refuseUnlessAllowed( - payload: Payload, - input: TranslateDocumentInput, - data: Record, - scope: RequestScope, - requester: RebuiltRequester | null - ): Promise { - if (!requester) return; - const allowed = await mayWrite({ - payload, - collection: input.collection, - id: String(input.collectionId), - data, - targetLocale: input.targetLng, - scope, - user: requester, - }); - if (!allowed) throw new TranslationRefused(input.collection, input.targetLng); + private runModulesAfter(run: Run, moduleState: ModuleState, leaves: readonly LeafOutcome[]) { + return runAfterSave( + this.modules, + { ...moduleSeam(run), moduleState, leaves }, + run.payload.logger + ); } /** @@ -199,53 +217,35 @@ export class TranslateDocumentHandler implements Handler< * transaction back. Only a failure carrying no such evidence is safe to adopt as ours. */ private async translateOrWrap( - args: Parameters[0] + run: Run, + both: BothLocales, + selection: readonly LeafAddress[] ): Promise>> { try { - return await translateContent(args); + return await translateContent({ + schema: run.schema, + sourceData: both.sourceData, + targetData: both.currentTarget, + sourceLng: run.target.sourceLocale, + targetLng: run.target.targetLocale, + document: { collection: run.target.collection, id: run.target.documentId }, + translationProvider: this.translationProvider, + selection, + }); } catch (error) { if (error instanceof APIError) throw error; throw asTranslatorError(error); } } - private async saveTranslatedDocument( - payload: Payload, - input: TranslateDocumentInput, - translatedData: Record, - write: TargetLayer["write"], - scope: RequestScope, - requester: RebuiltRequester | null - ): Promise { - await payload.update({ - req: freshReq(scope), - ...enforcedAtTheWrite(requester), - collection: input.collection, - id: input.collectionId, - data: translatedData, - ...write, - locale: input.targetLng, - fallbackLocale: input.sourceLng, - context: translatorWriteContext(), - }); + private async publishIfAsked(run: Run, asked: boolean) { + if (!asked || run.layer.kind !== "drafts") return; + await run.doc.publish(run.layer.publish); } - private async publishTargetLocale( - payload: Payload, - input: TranslateDocumentInput, - publish: PublishScope, - scope: RequestScope, - requester: RebuiltRequester | null - ): Promise { - await payload.update({ - req: freshReq(scope), - ...enforcedAtTheWrite(requester), - collection: input.collection, - id: input.collectionId, - data: { _status: publish.status }, - publishSpecificLocale: publish.publishSpecificLocale, - locale: publish.publishSpecificLocale, - context: translatorWriteContext(), - }); + private reportNothingSent(run: Run, reason: EmptyBecause | null): void { + const { collection, documentId, targetLocale } = run.target; + const { level, msg } = REPORTED[reason ?? "collector-sent-nothing"]; + run.payload.logger[level]({ collection, documentId, targetLocale, msg }); } } diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/model.ts b/packages/payload-plugin-translator/src/server/features/translate-document/model.ts index 5d4dc16c8..5a8e09acd 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/model.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/model.ts @@ -1,23 +1,15 @@ import type { CollectionSlug } from "payload"; -import type { ID } from "../../modules/task-runner/types.js"; -import type { TranslationStrategyName } from "../../../core/translation-pipeline/strategies/index.js"; +import type { ID, RunAsk } from "../../modules/task-runner/types.js"; -/** - * Input for the translate document handler - */ -export type TranslateDocumentInput = { +export type TranslateDocumentInput = RunAsk & { collection: CollectionSlug; collectionId: ID; sourceLng: string; targetLng: string; - strategy: TranslationStrategyName; publishOnTranslation: boolean; previousAttempts: number; }; -/** - * Output of the translate document handler - */ export type TranslateDocumentOutput = { success: true; }; diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/targetDocument.ts b/packages/payload-plugin-translator/src/server/features/translate-document/targetDocument.ts new file mode 100644 index 000000000..973d8058f --- /dev/null +++ b/packages/payload-plugin-translator/src/server/features/translate-document/targetDocument.ts @@ -0,0 +1,158 @@ +import type { Payload } from "payload"; + +import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext.js"; +import type { RunTarget } from "../../modules/translation-run/index.js"; +import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; +import { freshReq } from "../../shared/payload/RequestScope.shapes.js"; +import { enforcedAtTheRead } from "../../shared/payload/enforcedAtTheRead.js"; +import { fetchSourceDocument } from "../../shared/payload/sourceDocument.js"; +import { SourceUnreadable } from "./SourceUnreadable.js"; +import { TranslationRefused } from "./TranslationRefused.js"; +import { checkTranslationPermission, mayWrite, rebuildRequester } from "./translationPermission.js"; +import type { RebuiltRequester } from "./translationPermission.js"; +import type { PublishScope, WriteLayer } from "./targetLayer.js"; + +export type BothLocales = { + readonly sourceData: Record; + readonly currentTarget: Record; +}; + +declare const asked: unique symbol; + +/** + * The only value {@link sendToPayload} accepts, and `approveWrite` is the only thing that mints + * one — it asks the collection's rules about exactly this data, or asks nothing when there is no + * requester. The key is a symbol this module never exports, so unlike a string brand no literal + * can name it. + */ +export type ApprovedWrite = Record & { readonly [asked]: true }; + +type WriteDestination = + | ({ + readonly locale: string; + readonly fallbackLocale: string; + readonly publishSpecificLocale?: never; + } & WriteLayer) + | { + readonly locale: string; + readonly publishSpecificLocale: string; + readonly fallbackLocale?: never; + readonly draft?: never; + readonly autosave?: never; + }; + +/** + * Payload strips a field the requester may not write rather than throwing + * (`fields/hooks/beforeValidate/promise.js`); the collection's own `update` rule does throw, which + * is why `approveWrite` asks before the write is sent. With nobody to name, the write is unchecked. + */ +function enforcedAtTheWrite( + requester: RebuiltRequester | null +): { overrideAccess: false; user: RebuiltRequester } | Record { + if (!requester) return {}; + return { overrideAccess: false, user: requester }; +} + +/** + * The run's target document, read and written under the requester's access — and under none at + * all, when the request named nobody. + */ +export type TargetDocument = { + readBothLocales(): Promise; + refuseUnlessTranslationAllowed(sourceData: Record): Promise; + saveTranslation(data: Record, write: WriteLayer): Promise; + publish(scope: PublishScope): Promise; +}; + +export async function openTargetDocument(args: { + payload: Payload; + target: RunTarget; + scope: RequestScope; +}): Promise { + const { payload, target, scope } = args; + const { collection, documentId, sourceLocale, targetLocale } = target; + const requester = await rebuildRequester(payload, scope); + + const permissionQuery = (data: Record) => ({ + payload, + collection, + id: String(documentId), + targetLocale, + scope, + user: requester, + data, + }); + + /** + * Asked with exactly the payload about to be sent: the same rule refusing at the write + * (`updateByID`) is a `Forbidden` inside the caller's transaction — on the inline path, the + * editor's own save. + */ + const approveWrite = async (data: Record): Promise => { + if (requester) { + const allowed = await mayWrite({ ...permissionQuery(data), user: requester }); + if (!allowed) throw new TranslationRefused(collection, targetLocale); + } + return data as ApprovedWrite; + }; + + const sendToPayload = (data: ApprovedWrite, where: WriteDestination) => + payload.update({ + req: freshReq(scope), + ...enforcedAtTheWrite(requester), + collection, + id: documentId, + context: { [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true }, + data, + ...where, + }); + + return { + async readBothLocales() { + const [sourceData, currentTarget] = await Promise.all([ + fetchSourceDocument({ + payload, + collection, + id: String(documentId), + locale: sourceLocale, + user: requester, + scope, + }), + payload.findByID({ + req: freshReq(scope), + collection, + id: documentId, + locale: targetLocale, + fallbackLocale: false, + depth: 0, + ...enforcedAtTheRead(requester), + draft: true, + }), + ]); + if (!sourceData) throw new SourceUnreadable(collection, sourceLocale); + if (!currentTarget) throw new SourceUnreadable(collection, targetLocale); + + return { sourceData, currentTarget }; + }, + + async refuseUnlessTranslationAllowed(sourceData) { + const allowed = await checkTranslationPermission(permissionQuery(sourceData)); + if (!allowed) throw new TranslationRefused(collection, targetLocale); + }, + + async saveTranslation(data, write) { + await sendToPayload(await approveWrite(data), { + ...write, + locale: targetLocale, + fallbackLocale: sourceLocale, + }); + }, + + async publish({ status, publishSpecificLocale }) { + await sendToPayload(await approveWrite({ _status: status }), { + publishSpecificLocale, + locale: publishSpecificLocale, + }); + }, + }; +} diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/targetLayer.ts b/packages/payload-plugin-translator/src/server/features/translate-document/targetLayer.ts index 2fd3bf690..5231f3a0f 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/targetLayer.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/targetLayer.ts @@ -33,6 +33,8 @@ export type TargetLayer = publish: PublishScope; }; +export type WriteLayer = TargetLayer["write"]; + export function resolveTargetLayer(args: { versions: VersionsSlice; targetLng: string; diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts index fc590d002..3cc7c89a6 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts @@ -3,7 +3,7 @@ import type { CollectionSlug } from "payload"; import type { TranslationProvider } from "../../../core/domain/translation-providers/index.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ConfigModifier } from "../../../types/ConfigModifier.js"; -import type { ChainMember } from "../../modules/translation-run/index.js"; +import type { ChainMember, Criteria } from "../../modules/translation-run/index.js"; import { LifecycleNotifier, taskFromAssignment } from "../../modules/lifecycle/index.js"; import type { TranslationLifecycleCallbacks } from "../../modules/lifecycle/index.js"; import type { @@ -18,6 +18,7 @@ type WireTranslateRunnerParams = { translationProvider: TranslationProvider; schemaMap: CollectionSchemaMap; modules: readonly ChainMember[]; + criteria?: Criteria; runner: TaskRunnerProvider; lifecycle: TranslationLifecycleCallbacks; collections: CollectionSlug[]; @@ -32,6 +33,7 @@ export function wireTranslateRunner({ translationProvider, schemaMap, modules, + criteria, runner, lifecycle, collections, @@ -39,7 +41,12 @@ export function wireTranslateRunner({ taskRunnerFactory: TaskRunnerFactory; configModifier: ConfigModifier; } { - const translateHandler = new TranslateDocumentHandler(translationProvider, schemaMap, modules); + const translateHandler = new TranslateDocumentHandler({ + translationProvider, + schemaMap, + modules, + criteria, + }); const runnerContext: TaskRunnerContext = { handler: async (payload, input, scope) => { @@ -50,7 +57,7 @@ export function wireTranslateRunner({ collectionId: input.collectionId, sourceLng: input.sourceLng, targetLng: input.targetLng, - strategy: input.strategy, + filters: input.filters, publishOnTranslation: input.publishOnTranslation, previousAttempts: input.previousAttempts, }, diff --git a/packages/payload-plugin-translator/src/server/modules/translation-run/Criterion.interface.ts b/packages/payload-plugin-translator/src/server/modules/translation-run/Criterion.interface.ts new file mode 100644 index 000000000..197669eb9 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/translation-run/Criterion.interface.ts @@ -0,0 +1,24 @@ +import type { Payload } from "payload"; +import type { LeafAnswer } from "../../../core/domain/selection/index.js"; +import type { RuleName } from "../../../core/domain/selection/TranslationFilter.js"; + +import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; + +import type { RunTarget } from "./TranslationRun.interface.js"; + +export type CriterionQuestion = { + readonly payload: Payload; + readonly scope: RequestScope; + readonly target: RunTarget; + readonly sourceData: Record; +}; + +/** + * Answers with every address it accepts, unfiltered by the run's own list — the run intersects the + * answers. Throws when it cannot answer: a swallowed failure drops the narrowing and the run + * overwrites reviewed text. + */ +export type Criterion = (question: CriterionQuestion) => Promise; + +/** Keyed by the name stored in job rows — renaming a key strands every queued run that names it. */ +export type Criteria = Readonly>; diff --git a/packages/payload-plugin-translator/src/server/modules/translation-run/TranslationRun.interface.ts b/packages/payload-plugin-translator/src/server/modules/translation-run/TranslationRun.interface.ts index aef44a920..9cb5f69f7 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-run/TranslationRun.interface.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-run/TranslationRun.interface.ts @@ -1,6 +1,5 @@ import type { CollectionSlug, Payload } from "payload"; -import type { ChangedLeaves } from "../../../core/domain/provenance/index.js"; import type { LeafOutcome } from "../../../core/translation-pipeline/types/index.js"; import type { ID } from "../task-runner/types.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; @@ -26,7 +25,6 @@ export type BeforeTranslation = { readonly sourceData: Record; readonly previousAttempts: number; readonly moduleState: ModuleState; - readonly sourceChangedByLeaf?: ChangedLeaves; }; export type AfterSave = { @@ -41,7 +39,6 @@ export type AfterSave = { /** What a module adds to the run; the chain reads nothing else back from it. */ export type RunContribution = { readonly moduleState?: ModuleState; - readonly sourceChangedByLeaf?: ChangedLeaves; }; /** A module must survive a replay: a retried job runs `beforeTranslation` again. */ diff --git a/packages/payload-plugin-translator/src/server/modules/translation-run/__tests__/chainOrder.test.ts b/packages/payload-plugin-translator/src/server/modules/translation-run/__tests__/chainOrder.test.ts index e0541e11f..f345c58b7 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-run/__tests__/chainOrder.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-run/__tests__/chainOrder.test.ts @@ -14,22 +14,6 @@ describe("the chain before the translation", () => { expect(await runBeforeTranslation([], state, silent)).toEqual(state); }); - it("keeps the leaf-change map when a later module contributes only its own note", async () => { - const changed = { title: true }; - const mapper: ChainMember = { - name: "mapper", - beforeTranslation: async () => ({ sourceChangedByLeaf: changed }), - }; - const noteOnly: ChainMember = { - name: "note-only", - beforeTranslation: async () => ({ moduleState: { noteOnly: 1 } }), - }; - - const joined = await runBeforeTranslation([mapper, noteOnly], beforeTranslation(), silent); - - expect(joined.sourceChangedByLeaf).toEqual(changed); - }); - it("keeps a sibling's note when a module contributes only its own", async () => { const first: ChainMember = { name: "first", diff --git a/packages/payload-plugin-translator/src/server/modules/translation-run/index.ts b/packages/payload-plugin-translator/src/server/modules/translation-run/index.ts index 312db4f77..42fba6e56 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-run/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-run/index.ts @@ -1,6 +1,8 @@ +export type { Criteria, Criterion, CriterionQuestion } from "./Criterion.interface.js"; export type { BeforeTranslation, ChainMember, + ModuleState, RunContribution, RunTarget, } from "./TranslationRun.interface.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/translation-run/runChain.ts b/packages/payload-plugin-translator/src/server/modules/translation-run/runChain.ts index 1a439a388..d571f7513 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-run/runChain.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-run/runChain.ts @@ -31,7 +31,6 @@ const withContribution = ( ): BeforeTranslation => ({ ...state, moduleState: { ...state.moduleState, ...contribution.moduleState }, - sourceChangedByLeaf: contribution.sourceChangedByLeaf ?? state.sourceChangedByLeaf, }); /** From 7195218ac60b8e4b273e86ed1c398330453529ed Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sun, 25 Oct 2026 12:50:05 +0100 Subject: [PATCH 04/25] feat: provenance answers the drift rule and keeps unsent claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provenance keeps its one job, reporting: it answers which leaves drifted and the run decides. The recorder stops re-basing a leaf the run did not send, which a selection makes wrong — a field nobody translated would otherwise start claiming it was made from the current source, and the out-of-date marker would go quiet for it. The receipt holds a per-leaf map or nothing, so a pre-0.15.0 whole-document digest now reads as "claims nothing about any leaf". The staleness route reports which leaves drifted, because the refresh button that stood for "bring this locale up to date" goes with the strategy. --- .../get-document-status/handler.test.ts | 1 - .../get-document-status/model.test.ts | 1 - .../features/get-document-status/model.ts | 2 - .../staleness/dismissStaleness.handler.ts | 1 - .../staleness/getDocumentStaleness.handler.ts | 4 +- .../src/server/features/staleness/model.ts | 21 +- .../src/server/features/staleness/route.ts | 2 - .../features/staleness/staleness.test.ts | 43 ++-- .../provenance/Provenance.collection.ts | 1 - .../provenance/Provenance.criterion.ts | 40 +++ .../modules/provenance/Provenance.module.ts | 10 +- .../modules/provenance/Provenance.service.ts | 240 +++++++++--------- .../modules/provenance/Provenance.store.ts | 17 +- .../modules/provenance/Provenance.wiring.ts | 25 +- .../Provenance.collection.access.test.ts | 8 +- .../__tests__/Provenance.collection.test.ts | 5 +- .../__tests__/Provenance.criterion.test.ts | 93 +++++++ .../__tests__/Provenance.module.test.ts | 52 +--- .../__tests__/Provenance.service.test.ts | 140 +++++----- .../__tests__/Provenance.store.test.ts | 124 ++++----- .../__tests__/ProvenanceCleanup.hook.test.ts | 11 +- .../dismissalWritesTheReceipt.test.ts | 158 ++++++++++++ .../src/server/modules/provenance/index.ts | 2 +- 23 files changed, 634 insertions(+), 367 deletions(-) create mode 100644 packages/payload-plugin-translator/src/server/modules/provenance/Provenance.criterion.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.criterion.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/provenance/__tests__/dismissalWritesTheReceipt.test.ts diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts index bcbd09618..d57c35796 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts @@ -24,7 +24,6 @@ describe("GetDocumentStatusHandler", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, }, createdAt: "2024-01-01T00:00:00Z", diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts index 5f40f4ea0..c2c7c5c2f 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts @@ -13,7 +13,6 @@ const task = (over: Partial & { targetLng: string; id: string }): Task => collectionId: "doc-1", sourceLng: "en", targetLng: over.targetLng, - strategy: "overwrite", publishOnTranslation: false, }, createdAt: over.createdAt ?? "2024-01-01T00:00:00Z", diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts index 3a23ec95e..b1002f8d2 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts @@ -33,7 +33,6 @@ export type JobInputOutput = { }; source_lng: string; target_lng: string; - strategy?: string; }; /** @@ -97,7 +96,6 @@ export function taskToJobStatusOutput(task: Task): JobStatusOutput { }, source_lng: task.input.sourceLng, target_lng: task.input.targetLng, - strategy: task.input.strategy, }, error: task.error ? { message: toClientErrorMessage(task.error.message) } : undefined, cancelled: task.cancelled, diff --git a/packages/payload-plugin-translator/src/server/features/staleness/dismissStaleness.handler.ts b/packages/payload-plugin-translator/src/server/features/staleness/dismissStaleness.handler.ts index 42fc3e385..eb62d7555 100644 --- a/packages/payload-plugin-translator/src/server/features/staleness/dismissStaleness.handler.ts +++ b/packages/payload-plugin-translator/src/server/features/staleness/dismissStaleness.handler.ts @@ -6,7 +6,6 @@ import { isCollectionAvailable } from "../_lib/collection-utils.js"; import { DismissStalenessInputSchema } from "./model.js"; import type { StalenessConfig } from "./model.js"; -/** Dismisses (acknowledges) staleness of one target locale for a document. */ export class DismissStalenessHandler { constructor(private readonly config: StalenessConfig) {} diff --git a/packages/payload-plugin-translator/src/server/features/staleness/getDocumentStaleness.handler.ts b/packages/payload-plugin-translator/src/server/features/staleness/getDocumentStaleness.handler.ts index 0c0b6d723..0d82a6705 100644 --- a/packages/payload-plugin-translator/src/server/features/staleness/getDocumentStaleness.handler.ts +++ b/packages/payload-plugin-translator/src/server/features/staleness/getDocumentStaleness.handler.ts @@ -3,7 +3,7 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; import { isCollectionAvailable } from "../_lib/collection-utils.js"; -import { GetDocumentStalenessInputSchema } from "./model.js"; +import { asStalenessBody, GetDocumentStalenessInputSchema } from "./model.js"; import type { StalenessConfig } from "./model.js"; /** @@ -29,7 +29,7 @@ export class GetDocumentStalenessHandler { try { const service = this.config.provenanceServiceFactory(req.payload); const locales = await service.getStaleness(collectionSlug, collection_id, req.user); - return ServerResponse.success({ locales }); + return ServerResponse.success({ locales: locales.map(asStalenessBody) }); } catch (error) { req.payload.logger.error({ err: error, diff --git a/packages/payload-plugin-translator/src/server/features/staleness/model.ts b/packages/payload-plugin-translator/src/server/features/staleness/model.ts index 98c6a7cf3..7b37563e5 100644 --- a/packages/payload-plugin-translator/src/server/features/staleness/model.ts +++ b/packages/payload-plugin-translator/src/server/features/staleness/model.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import type { CollectionSlug } from "payload"; import { JobIdSchema } from "../../shared/index.js"; -import type { ProvenanceServiceFactory } from "../../modules/provenance/index.js"; +import type { LeafAddress } from "../../../core/domain/content-projection/idPath.js"; +import type { LocaleStaleness, ProvenanceServiceFactory } from "../../modules/provenance/index.js"; /** * Route params for reading a document's per-locale staleness. @@ -14,13 +15,29 @@ export const GetDocumentStalenessInputSchema = z.object({ collection_slug: z.string().nonempty(), }); -/** Body for dismissing (acknowledging) staleness of one target locale. */ export const DismissStalenessInputSchema = z.object({ collection_id: JobIdSchema, collection_slug: z.string().nonempty(), target_lng: z.string().nonempty(), }); +/** One locale's staleness on the wire — snake_case, like every other translation endpoint. */ +export type StalenessLocaleBody = { + target_lng: string; + source_lng: string; + is_stale: boolean; + translated_at: string; + changed_leaves?: LeafAddress[]; +}; + +export const asStalenessBody = (locale: LocaleStaleness): StalenessLocaleBody => ({ + target_lng: locale.targetLocale, + source_lng: locale.sourceLocale, + is_stale: locale.isStale, + translated_at: locale.translatedAt, + ...(locale.changedLeaves === undefined ? {} : { changed_leaves: locale.changedLeaves }), +}); + export type StalenessConfig = { provenanceServiceFactory: ProvenanceServiceFactory; availableCollections: Set; diff --git a/packages/payload-plugin-translator/src/server/features/staleness/route.ts b/packages/payload-plugin-translator/src/server/features/staleness/route.ts index adba78662..a604e842d 100644 --- a/packages/payload-plugin-translator/src/server/features/staleness/route.ts +++ b/packages/payload-plugin-translator/src/server/features/staleness/route.ts @@ -7,7 +7,6 @@ import type { StalenessConfig } from "./model.js"; import { GetDocumentStalenessHandler } from "./getDocumentStaleness.handler.js"; import { DismissStalenessHandler } from "./dismissStaleness.handler.js"; -/** GET per-locale staleness for one document. */ export function createGetDocumentStalenessRoute( config: StalenessConfig, access: AccessGuard, @@ -22,7 +21,6 @@ export function createGetDocumentStalenessRoute( }; } -/** POST to dismiss (acknowledge) staleness of one target locale. */ export function createDismissStalenessRoute( config: StalenessConfig, access: AccessGuard, diff --git a/packages/payload-plugin-translator/src/server/features/staleness/staleness.test.ts b/packages/payload-plugin-translator/src/server/features/staleness/staleness.test.ts index fb859296b..11a4bf93f 100644 --- a/packages/payload-plugin-translator/src/server/features/staleness/staleness.test.ts +++ b/packages/payload-plugin-translator/src/server/features/staleness/staleness.test.ts @@ -3,19 +3,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Field, Payload, PayloadRequest } from "payload"; import { computeFieldFingerprints } from "../../../core/domain/content-projection/computeFieldFingerprints.js"; -import type { ProvenanceStore, ProvenanceReceipt } from "../../../core/domain/provenance/index.js"; +import type { ProvenanceReceipt, ProvenanceStore } from "../../../core/domain/provenance/index.js"; import { ProvenanceService, provenanceIo } from "../../modules/provenance/index.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import { GetDocumentStalenessHandler } from "./getDocumentStaleness.handler.js"; import { DismissStalenessHandler } from "./dismissStaleness.handler.js"; import type { StalenessConfig } from "./model.js"; +import { storeDouble } from "../../../core/domain/provenance/__tests__/fixtures/provenanceStore.js"; const COLLECTION = "posts"; const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const sourceDoc = { id: "1", title: "Hello" }; const recordedFields = computeFieldFingerprints(sourceDoc, schema); -const recordedFingerprint = { kind: "fields", hashes: recordedFields } as const; +const recordedFingerprint = recordedFields; function makeRecord(overrides: Partial = {}): ProvenanceReceipt { return { @@ -25,21 +26,11 @@ function makeRecord(overrides: Partial = {}): ProvenanceRecei sourceLocale: "en", sourceFingerprint: recordedFingerprint, translatedAt: "2026-07-07T00:00:00.000Z", - dismissedFingerprint: null, ...overrides, }; } -function makeStore(overrides: Partial = {}): ProvenanceStore { - return { - upsert: vi.fn().mockResolvedValue(undefined), - find: vi.fn().mockResolvedValue(null), - findByDocument: vi.fn().mockResolvedValue([]), - dismiss: vi.fn().mockResolvedValue(undefined), - deleteByDocument: vi.fn().mockResolvedValue(undefined), - ...overrides, - }; -} +const makeStore = storeDouble; let payload: Payload; let logger: { error: ReturnType }; @@ -86,6 +77,7 @@ describe("GetDocumentStalenessHandler", () => { source_lng: "en", is_stale: false, translated_at: "2026-07-07T00:00:00.000Z", + changed_leaves: [], }, ]); // recompute must mirror the write path exactly: locale=sourceLocale, depth:0 @@ -98,9 +90,7 @@ describe("GetDocumentStalenessHandler", () => { const store = makeStore({ findByDocument: vi .fn() - .mockResolvedValue([ - makeRecord({ sourceFingerprint: { kind: "fields", hashes: { title: asHash("fa0d") } } }), - ]), + .mockResolvedValue([makeRecord({ sourceFingerprint: { title: asHash("fa0d") } })]), }); const res = await new GetDocumentStalenessHandler(makeConfig(store)).handle(readReq()); expect((await bodyOf(res)).locales[0].is_stale).toBe(true); @@ -149,6 +139,7 @@ describe("GetDocumentStalenessHandler", () => { source_lng: "en", is_stale: false, translated_at: "2026-07-07T00:00:00.000Z", + changed_leaves: [], }, ]); expect(logger.error).toHaveBeenCalled(); @@ -164,23 +155,29 @@ describe("GetDocumentStalenessHandler", () => { describe("DismissStalenessHandler", () => { const body = { collection_slug: COLLECTION, collection_id: "1", target_lng: "de" }; - it("persists the current fingerprint as the dismissed fingerprint", async () => { - const dismiss = vi.fn().mockResolvedValue(undefined); - const store = makeStore({ find: vi.fn().mockResolvedValue(makeRecord()), dismiss }); + it("re-bases the receipt on the source as it stands", async () => { + const rebaseSourceFingerprint = vi.fn().mockResolvedValue(undefined); + const drifted = makeRecord({ + sourceFingerprint: computeFieldFingerprints({ title: "Gone" }, schema), + }); + const store = makeStore({ + find: vi.fn().mockResolvedValue(drifted), + rebaseSourceFingerprint, + }); const res = await new DismissStalenessHandler(makeConfig(store)).handle(dismissReq(body)); expect(res.status).toBe(200); - expect(dismiss).toHaveBeenCalledWith( + expect(rebaseSourceFingerprint).toHaveBeenCalledWith( { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }, recordedFingerprint ); }); it("is a no-op when the locale has no record", async () => { - const dismiss = vi.fn(); - const store = makeStore({ find: vi.fn().mockResolvedValue(null), dismiss }); + const rebaseSourceFingerprint = vi.fn(); + const store = makeStore({ find: vi.fn().mockResolvedValue(null), rebaseSourceFingerprint }); const res = await new DismissStalenessHandler(makeConfig(store)).handle(dismissReq(body)); expect(res.status).toBe(200); - expect(dismiss).not.toHaveBeenCalled(); + expect(rebaseSourceFingerprint).not.toHaveBeenCalled(); }); it("rejects an invalid body", async () => { diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.collection.ts b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.collection.ts index ea795d7d9..d2c10553e 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.collection.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.collection.ts @@ -29,7 +29,6 @@ export function makeProvenanceCollection(slug: string = DEFAULT_PROVENANCE_SLUG) { name: "sourceLocale", type: "text", required: true }, { name: "sourceFingerprint", type: "text", required: true }, { name: "translatedAt", type: "date", required: true }, - { name: "dismissedFingerprint", type: "text" }, ], indexes: [{ fields: ["collectionSlug", "documentId", "targetLocale"], unique: true }], }; diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.criterion.ts b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.criterion.ts new file mode 100644 index 000000000..82ac7b7d8 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.criterion.ts @@ -0,0 +1,40 @@ +import { driftedAddresses } from "../../../core/domain/provenance/index.js"; +import type { ProvenanceKey } from "../../../core/domain/provenance/index.js"; +import { TranslatorConfigError } from "../../../core/errors/index.js"; +import type { Criterion, RunTarget } from "../translation-run/index.js"; + +import type { ProvenanceServiceFactory } from "./Provenance.wiring.js"; + +/** + * The rule name a filter carries to ask for the leaves whose source has moved. + * + * `store/runTargets.ts` spells the same literal, for rows written before 1.0 — keep the two equal. + */ +export const DRIFT_CRITERION = "stale"; + +const receiptKey = (target: RunTarget): ProvenanceKey => ({ + collectionSlug: target.collection, + documentId: String(target.documentId), + targetLocale: target.targetLocale, +}); + +/** + * Every leaf whose source moved since this locale was last translated. With nothing readable in the + * receipt it answers every leaf — overwriting reviewed text is the accepted cost of enabling + * provenance late. + */ +export const driftedLeaves = + (serviceFactory: ProvenanceServiceFactory): Criterion => + async ({ payload, scope, target, sourceData }) => { + const service = serviceFactory(payload, scope); + const currentFields = service.captureFingerprint(target.collection, sourceData); + if (currentFields === null) { + throw new TranslatorConfigError( + `Cannot tell what has moved in "${target.collection}": the collection has no schema here, so the run would translate the whole document instead.` + ); + } + + const previous = await service.lastTranslatedFrom(receiptKey(target)); + if (previous === null) return Object.keys(currentFields); + return driftedAddresses(previous, currentFields); + }; diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.module.ts b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.module.ts index 19e2ca499..57a360a86 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.module.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.module.ts @@ -1,5 +1,4 @@ import type { FieldFingerprints } from "../../../core/domain/content-projection/computeFieldFingerprints.js"; -import { changedLeaves, recordsEachLeaf } from "../../../core/domain/provenance/index.js"; import type { ProvenanceKey } from "../../../core/domain/provenance/index.js"; import { createDismissStalenessRoute, @@ -65,14 +64,7 @@ export function provenanceModule({ const currentFields = service.captureFingerprint(state.target.collection, state.sourceData); if (currentFields === null) return {}; - const fingerprints = { moduleState: { [PROVENANCE_MODULE]: currentFields } }; - const previous = await service.lastTranslatedFrom(receiptKey(state.target)); - if (!recordsEachLeaf(previous)) return fingerprints; - - return { - ...fingerprints, - sourceChangedByLeaf: changedLeaves(previous.hashes, currentFields), - }; + return { moduleState: { [PROVENANCE_MODULE]: currentFields } }; }, async afterSave(state) { diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.service.ts b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.service.ts index 6f27a66f8..b9973c79d 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.service.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.service.ts @@ -1,59 +1,52 @@ import type { CollectionSlug } from "payload"; -import { computeSourceFingerprint } from "../../../core/domain/content-projection/computeSourceFingerprint.js"; import { computeFieldFingerprints } from "../../../core/domain/content-projection/computeFieldFingerprints.js"; import type { FieldFingerprints, SourceHash, } from "../../../core/domain/content-projection/computeFieldFingerprints.js"; import type { FieldLike } from "../../../core/kernel/field-traversal/index.js"; +import type { LeafAddress } from "../../../core/domain/content-projection/idPath.js"; import { isRecordStale } from "../../../core/domain/provenance/index.js"; import type { - CurrentFingerprint, ProvenanceKey, ProvenanceStore, RecordedClaim, - SourceFingerprint, + RecordedFingerprints, } from "../../../core/domain/provenance/index.js"; -import { recordsEachLeaf, UNANSWERED } from "../../../core/domain/provenance/index.js"; +import { driftedAddresses, UNANSWERED } from "../../../core/domain/provenance/index.js"; import type { LeafOutcome } from "../../../core/translation-pipeline/types/index.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; import { swallowOrThrow } from "../../shared/payload/swallowOrThrow.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ProvenanceLogger, SourceDocumentReader } from "./Provenance.shapes.js"; -/** Per-locale staleness for one document (snake_case, matching the other translation endpoints). */ -export type StalenessLocale = { - target_lng: string; - source_lng: string; - is_stale: boolean; - translated_at: string; +/** What one translated locale of a document is measured to be, as the domain says it. */ +export type LocaleStaleness = { + targetLocale: string; + sourceLocale: string; + isStale: boolean; + translatedAt: string; + /** Absent, not empty, when the receipt's fingerprint is empty or unreadable: nothing is known. */ + changedLeaves?: LeafAddress[]; }; /** - * An earlier `UNANSWERED` survives: re-basing it onto the current hash would mark a leaf no run has - * answered as current, and `skip_existing` would never select it again. + * An unsent leaf is never re-based on the current hash — that would mark current a leaf no run + * translated. A half-answered one has source text seated in its target, so no hash describes it. */ -function claimForUnsent( - previous: SourceFingerprint | null, - address: string, - currentHash: SourceHash +function claimToRecord( + answeredInFull: boolean | undefined, + hash: SourceHash, + alreadyClaimed: RecordedClaim | undefined ): RecordedClaim { - if (recordsEachLeaf(previous) && previous.hashes[address] === UNANSWERED) return UNANSWERED; - return currentHash; -} - -/** - * A half-answered leaf has source text seated in its target, so no hash can describe it: - * `UNANSWERED` is what brings it back on the next run. - */ -function claimFor(answeredInFull: boolean, hash: SourceHash): RecordedClaim { + if (answeredInFull === undefined) return alreadyClaimed ?? UNANSWERED; return answeredInFull ? hash : UNANSWERED; } /** - * The single owner of fingerprint policy: write and read hash through this one class, so they cannot - * drift. + * Owns the receipt: a stored fingerprint is only ever written or read here, so the recorded claim + * and the hash it is later compared against cannot be computed two different ways. * * Best-effort by contract, except where swallowing would hide a loss — see {@link swallowOrThrow}. */ @@ -77,11 +70,20 @@ export class ProvenanceService { this.readSource = io.readSource; } + /** What a failed best-effort step names in the log, beyond the error and the message. */ + private bestEffort( + about: { collection: string; documentId: string; targetLocale?: string; sourceLocale?: string }, + msg: string, + work: () => Promise + ): Promise { + return swallowOrThrow(this.scope, work, (err) => this.logger.error({ err, ...about, msg })); + } + /** * Hash the source a translation is made from — the baseline staleness is measured against. * - * Safe on either side of the pipeline: the pipeline detaches the leaves it writes into. - * `null` on any failure (no schema, hashing error), so provenance is skipped, not the translation. + * `null` when the collection has no schema here or hashing fails; the caller decides what that + * costs — `Provenance.module` skips provenance, `driftedLeaves` refuses the run. */ captureFingerprint( collection: CollectionSlug, @@ -112,18 +114,21 @@ export class ProvenanceService { currentFields: FieldFingerprints, leaves: readonly LeafOutcome[] ): Promise { - await swallowOrThrow( - this.scope, + await this.bestEffort( + { + collection: key.collectionSlug, + documentId: key.documentId, + targetLocale: key.targetLocale, + sourceLocale: key.sourceLocale, + }, + "translator: failed to record translation provenance", async () => { const previous = await this.readFingerprintOrThrow(key); + const alreadyClaimed = previous ?? {}; const sent = new Map(leaves.map((leaf) => [String(leaf.idPath), leaf.answeredInFull])); const hashes: Record = {}; for (const [address, hash] of Object.entries(currentFields)) { - const answeredInFull = sent.get(address); - hashes[address] = - answeredInFull === undefined - ? claimForUnsent(previous, address, hash) - : claimFor(answeredInFull, hash); + hashes[address] = claimToRecord(sent.get(address), hash, alreadyClaimed[address]); } await this.store.upsert({ @@ -131,141 +136,142 @@ export class ProvenanceService { documentId: key.documentId, targetLocale: key.targetLocale, sourceLocale: key.sourceLocale, - sourceFingerprint: { kind: "fields", hashes }, + sourceFingerprint: hashes, translatedAt: new Date().toISOString(), - dismissedFingerprint: null, }); - }, - (error) => - this.logger.error({ - err: error, - collection: key.collectionSlug, - documentId: key.documentId, - targetLocale: key.targetLocale, - sourceLocale: key.sourceLocale, - msg: "translator: failed to record translation provenance", - }) + } ); } - /** The receipt this document-locale holds, best-effort: an unreachable sidecar reads as `null`, i.e. every leaf unknown. */ - async lastTranslatedFrom(key: ProvenanceKey): Promise { - const read = await swallowOrThrow( - this.scope, - () => this.readFingerprintOrThrow(key), - (error) => - this.logger.error({ - err: error, - collection: key.collectionSlug, - documentId: key.documentId, - targetLocale: key.targetLocale, - msg: "translator: failed to read translation provenance", - }) + /** The receipt this document-locale holds — `null` both when there is none and when the read failed. */ + async lastTranslatedFrom(key: ProvenanceKey): Promise { + const read = await this.bestEffort( + { + collection: key.collectionSlug, + documentId: key.documentId, + targetLocale: key.targetLocale, + }, + "translator: failed to read translation provenance", + () => this.readFingerprintOrThrow(key) ); return read ?? null; } /** - * Throws rather than reporting "no receipt": a swallowed read would re-base an earlier - * `UNANSWERED` onto the current hash, and that leaf would never be selected again. + * Throws rather than reporting "no receipt": without the previous claims every leaf this run did + * not send would be written `UNANSWERED`, and the next run would re-translate reviewed text — so + * `record` writes nothing instead of writing half a receipt. */ - private async readFingerprintOrThrow(key: ProvenanceKey): Promise { + private async readFingerprintOrThrow(key: ProvenanceKey): Promise { const existing = await this.store.find(key); return existing?.sourceFingerprint ?? null; } /** * Per-locale staleness for one document. A locale whose fingerprint cannot be recomputed is dropped - * from the result; if the caller is inside a transaction, that failure propagates instead and the - * locales after it go unreported. + * and the remaining locales still report; see {@link swallowOrThrow} for when that failure escapes + * instead. */ async getStaleness( collection: CollectionSlug, documentId: string, user: Record | null = null - ): Promise { + ): Promise { const schema = this.schemaMap.get(collection); if (!schema) return []; const records = await this.store.findByDocument(collection, documentId); if (records.length === 0) return []; - const currentFingerprint = this.makeCurrentFingerprint(collection, documentId, schema, user); - const locales: StalenessLocale[] = []; + // The records of one document often share a source locale; read and hash each one once. + const seen = new Map(); + const locales: LocaleStaleness[] = []; for (const record of records) { - const recomputedOrSwallowed = await swallowOrThrow( - this.scope, - () => currentFingerprint(record.sourceLocale), - (error) => - this.logger.error({ - err: error, + const recomputedOrSwallowed = await this.bestEffort( + { + collection, + documentId, + targetLocale: record.targetLocale, + sourceLocale: record.sourceLocale, + }, + "translator: failed to compute staleness for locale", + async () => { + const known = seen.get(record.sourceLocale); + if (known !== undefined) return known; + + const fresh = await this.sourceAsItStands({ collection, documentId, - targetLocale: record.targetLocale, sourceLocale: record.sourceLocale, - msg: "translator: failed to compute staleness for locale", - }) + schema, + user, + }); + if (fresh !== null) seen.set(record.sourceLocale, fresh); + return fresh; + } ); if (recomputedOrSwallowed == null) continue; + const recorded = record.sourceFingerprint; locales.push({ - target_lng: record.targetLocale, - source_lng: record.sourceLocale, - is_stale: isRecordStale(record, recomputedOrSwallowed), - translated_at: record.translatedAt, + targetLocale: record.targetLocale, + sourceLocale: record.sourceLocale, + isStale: isRecordStale(record, recomputedOrSwallowed), + translatedAt: record.translatedAt, + ...(recorded === null + ? {} + : { changedLeaves: driftedAddresses(recorded, recomputedOrSwallowed) }), }); } return locales; } /** - * Acknowledge the current source drift for one target locale: persist the current fingerprint as the - * dismissed one, so the indicator hides until the source changes again. No-op when the collection has - * no schema, the locale has no record, or the source is not readable by this caller. + * Accept the translation as it stands: the receipt is re-based on the source as it is now. + * + * A leaf no run ever answered is re-based too — the person speaks for the whole document-locale, + * so the `UNANSWERED` preservation in {@link record} deliberately does not apply. A receipt that + * predates per-leaf fingerprints is upgraded to one, so the panel can name moved leaves for that + * locale from then on. + * + * Alone among these methods it is not best-effort: a person pressed a button, so a store failure + * reaches them rather than becoming a log line they will never see. */ async dismiss(key: ProvenanceKey, user: Record | null = null): Promise { - const schema = this.schemaMap.get(key.collectionSlug as CollectionSlug); + const schema = this.schemaMap.get(key.collectionSlug); if (!schema) return; const record = await this.store.find(key); if (!record) return; - const currentFingerprint = this.makeCurrentFingerprint( - key.collectionSlug as CollectionSlug, - key.documentId, + const fingerprint = await this.sourceAsItStands({ + collection: key.collectionSlug, + documentId: key.documentId, + sourceLocale: record.sourceLocale, schema, - user - ); - const fingerprint = await currentFingerprint(record.sourceLocale); + user, + }); if (fingerprint === null) return; - await this.store.dismiss(key, { kind: "fields", hashes: fingerprint.fields }); + await this.store.rebaseSourceFingerprint(key, fingerprint); } - private makeCurrentFingerprint( - collection: CollectionSlug, - documentId: string, - schema: FieldLike[], - user: Record | null - ) { - const cache = new Map(); - return async (sourceLocale: string): Promise => { - const cached = cache.get(sourceLocale); - if (cached !== undefined) return cached; - const sourceData = await this.readSource({ - collection, - id: documentId, - locale: sourceLocale, - user, - }); - if (!sourceData) return null; + /** `null` when the source document is not readable by this caller. */ + private async sourceAsItStands(where: { + collection: CollectionSlug; + documentId: string; + sourceLocale: string; + schema: FieldLike[]; + user: Record | null; + }): Promise { + const sourceData = await this.readSource({ + collection: where.collection, + id: where.documentId, + locale: where.sourceLocale, + user: where.user, + }); + if (!sourceData) return null; - const fingerprint: CurrentFingerprint = { - document: computeSourceFingerprint(sourceData, schema), - fields: computeFieldFingerprints(sourceData, schema), - }; - cache.set(sourceLocale, fingerprint); - return fingerprint; - }; + return computeFieldFingerprints(sourceData, where.schema); } } diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.store.ts b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.store.ts index 007bacb65..50fe375f0 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.store.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.store.ts @@ -5,7 +5,7 @@ import { freshReq } from "../../shared/payload/RequestScope.shapes.js"; import type { ProvenanceKey, ProvenanceStore, - SourceFingerprint, + RecordedFingerprints, ProvenanceReceipt, } from "../../../core/domain/provenance/index.js"; import { @@ -35,10 +35,9 @@ function keyWhere(key: ProvenanceKey): Where { }; } -const readFingerprint = (stored: unknown): SourceFingerprint | null => +const readFingerprint = (stored: unknown): RecordedFingerprints | null => parseSourceFingerprint(stored == null ? null : String(stored)); -/** The only place stored text becomes a {@link SourceFingerprint}. */ function toRecord(doc: ProvenanceDoc): ProvenanceReceipt { return { collectionSlug: String(doc.collectionSlug), @@ -47,7 +46,6 @@ function toRecord(doc: ProvenanceDoc): ProvenanceReceipt { sourceLocale: String(doc.sourceLocale), sourceFingerprint: readFingerprint(doc.sourceFingerprint), translatedAt: new Date(doc.translatedAt as string | number | Date).toISOString(), - dismissedFingerprint: readFingerprint(doc.dismissedFingerprint), }; } @@ -58,10 +56,6 @@ function toStoredData(record: ProvenanceReceipt): Record { record.sourceFingerprint === null ? null : serializeSourceFingerprint(record.sourceFingerprint), - dismissedFingerprint: - record.dismissedFingerprint === null - ? null - : serializeSourceFingerprint(record.dismissedFingerprint), }; } @@ -130,14 +124,17 @@ export class PayloadProvenanceStore implements ProvenanceStore { return (result.docs as ProvenanceDoc[]).map(toRecord); } - async dismiss(key: ProvenanceKey, dismissedFingerprint: SourceFingerprint): Promise { + async rebaseSourceFingerprint( + key: ProvenanceKey, + sourceFingerprint: RecordedFingerprints + ): Promise { const existing = await this.findDoc(key); if (existing === null) return; await this.payload.update({ req: this.req(), collection: this.collection, id: existing.id, - data: { dismissedFingerprint: serializeSourceFingerprint(dismissedFingerprint) }, + data: { sourceFingerprint: serializeSourceFingerprint(sourceFingerprint) }, }); } diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.wiring.ts b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.wiring.ts index 225f9acc4..7afac000d 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.wiring.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.wiring.ts @@ -1,4 +1,6 @@ import type { Payload } from "payload"; +import type { Criteria } from "../translation-run/index.js"; +import { DRIFT_CRITERION, driftedLeaves } from "./Provenance.criterion.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ConfigModifier } from "../../../types/ConfigModifier.js"; @@ -35,7 +37,6 @@ function resolveProvenanceSlug(option: ProvenanceOption): string | null { const OUTSIDE_THE_CALLERS_TRANSACTION = undefined; -/** The two things {@link ProvenanceService} needs from Payload, bound to one instance. */ export const provenanceIo = (payload: Payload) => ({ logger: payload.logger, readSource: ({ collection, id, locale, user }: Parameters[0]) => @@ -54,10 +55,15 @@ export type ProvenanceServiceFactory = ( scope?: RequestScope ) => ProvenanceService; +export type ProvenanceContribution = { + module: TranslationModule; + criteria: Criteria; +}; + export function configureProvenance( option: ProvenanceOption, schemaMap: CollectionSchemaMap -): TranslationModule | undefined { +): ProvenanceContribution | undefined { const slug = resolveProvenanceSlug(option); if (!slug) return undefined; @@ -91,10 +97,13 @@ export function configureProvenance( return config; }; - return provenanceModule({ - serviceFactory, - refuseACollidingSlug, - addTheCollection, - cleanUpOnDelete, - }); + return { + module: provenanceModule({ + serviceFactory, + refuseACollidingSlug, + addTheCollection, + cleanUpOnDelete, + }), + criteria: { [DRIFT_CRITERION]: driftedLeaves(serviceFactory) }, + }; } diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.access.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.access.test.ts index 3c2a8f4f2..3dfc22d85 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.access.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.access.test.ts @@ -2,11 +2,9 @@ import { describe, it, expect } from "vitest"; import { makeProvenanceCollection } from "../Provenance.collection.js"; -// A collection that declares no `access` inherits Payload's default, which is "any signed-in user" -// — measured: a signed-in caller can read every row, overwrite `dismissedFingerprint` to suppress the -// out-of-date indicator, and delete history outright. Nothing legitimate reaches these rows through -// the collection's REST surface: the panel reads staleness through the plugin's own endpoint, and the -// plugin itself goes through the Local API, which bypasses access rules. +// With no `access`, Payload defaults to "any signed-in user" — enough to rewrite +// `sourceFingerprint` and hide the out-of-date mark. Nothing legitimate needs REST here: the panel +// reads the plugin's endpoint and the plugin uses the Local API, which bypasses access rules. describe("the provenance collection is closed to the outside", () => { const collection = makeProvenanceCollection(); diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.test.ts index 708cae206..9f8bb262c 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.collection.test.ts @@ -38,7 +38,10 @@ describe("makeProvenanceCollection", () => { expect(byName.get("sourceLocale")).toBe("text"); expect(byName.get("sourceFingerprint")).toBe("text"); expect(byName.get("translatedAt")).toBe("date"); - expect(byName.get("dismissedFingerprint")).toBe("text"); + expect( + byName.has("dismissedFingerprint"), + "a receipt holds one fingerprint: the source it is considered current against" + ).toBe(false); }); it("has a composite unique index on the upsert key", () => { diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.criterion.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.criterion.test.ts new file mode 100644 index 000000000..cf388e1bc --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.criterion.test.ts @@ -0,0 +1,93 @@ +import type { Field, Payload } from "payload"; +import { describe, expect, it, vi } from "vitest"; + +import type { + ProvenanceReceipt, + ProvenanceStore, + RecordedFingerprints, +} from "../../../../core/domain/provenance/index.js"; +import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; +import { TranslatorConfigError } from "../../../../core/errors/index.js"; +import { driftedLeaves } from "../Provenance.criterion.js"; +import { ProvenanceService } from "../Provenance.service.js"; +import { storeDouble } from "../../../../core/domain/provenance/__tests__/fixtures/provenanceStore.js"; + +const COLLECTION = "posts"; +const schema: Field[] = [ + { name: "title", type: "text", localized: true }, + { name: "body", type: "text", localized: true }, +]; + +const receipt = (sourceFingerprint: RecordedFingerprints | null): ProvenanceReceipt => ({ + collectionSlug: COLLECTION, + documentId: "doc-1", + targetLocale: "de", + sourceLocale: "en", + sourceFingerprint, + translatedAt: "2026-10-22T00:00:00.000Z", +}); + +const answering = (stored: RecordedFingerprints | null, collections = [COLLECTION]) => { + const store = storeDouble({ + find: vi.fn().mockResolvedValue(stored === null ? null : receipt(stored)), + }); + + return driftedLeaves( + () => + new ProvenanceService( + store, + new Map(collections.map((slug) => [slug, schema])) as CollectionSchemaMap, + {}, + { logger: { error: vi.fn() }, readSource: vi.fn() } + ) + ); +}; + +const ask = (sourceData: Record = { title: "Hello", body: "World" }) => ({ + payload: {} as Payload, + scope: {}, + target: { + collection: COLLECTION, + documentId: "doc-1", + sourceLocale: "en", + targetLocale: "de", + }, + sourceData, +}); + +/** What the receipt holds after a run over this source — the criterion's own "nothing moved" state. */ +const asTranslated = async () => { + const everything = await answering(null)(ask()); + const service = new ProvenanceService( + {} as unknown as ProvenanceStore, + new Map([[COLLECTION, schema]]) as CollectionSchemaMap, + {}, + { logger: { error: vi.fn() }, readSource: vi.fn() } + ); + expect(everything).toEqual(["title", "body"]); + return service.captureFingerprint(COLLECTION, { title: "Hello", body: "World" }) ?? {}; +}; + +describe("the leaves provenance answers", () => { + it("is none of them when the source stands where the receipt says", async () => { + const hashes = await asTranslated(); + + expect(await answering(hashes)(ask())).toEqual([]); + }); + + it("is the one whose source moved since that receipt", async () => { + const hashes = await asTranslated(); + + const answered = await answering(hashes)(ask({ title: "Hello", body: "Moved" })); + + expect(answered).toEqual(["body"]); + }); + + it("is every leaf when nothing can be read from the receipt", async () => { + expect(await answering(null)(ask())).toEqual(["title", "body"]); + }); + + it("refuses to answer for a collection it has no schema for, rather than answer nothing", async () => { + await expect(answering(null, [])(ask())).rejects.toThrow(TranslatorConfigError); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.module.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.module.test.ts index f7af6a7c2..419a12302 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.module.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.module.test.ts @@ -5,13 +5,13 @@ import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap. import type { ConfigModifier } from "../../../../types/ConfigModifier.js"; import type { ProvenanceReceipt, - ProvenanceStore, - SourceFingerprint, + RecordedFingerprints, } from "../../../../core/domain/provenance/index.js"; import type { BeforeTranslation, RunContribution } from "../../translation-run/index.js"; import type { FieldFingerprints } from "../../../../core/domain/content-projection/computeFieldFingerprints.js"; import { PROVENANCE_MODULE, provenanceModule } from "../Provenance.module.js"; import { ProvenanceService } from "../Provenance.service.js"; +import { storeDouble } from "../../../../core/domain/provenance/__tests__/fixtures/provenanceStore.js"; const COLLECTION = "posts"; const NOT_IN_A_TRANSACTION = {}; @@ -22,24 +22,19 @@ const schema: Field[] = [ { name: "body", type: "text", localized: true }, ]; -const receipt = (sourceFingerprint: SourceFingerprint | null): ProvenanceReceipt => ({ +const receipt = (sourceFingerprint: RecordedFingerprints | null): ProvenanceReceipt => ({ collectionSlug: COLLECTION, documentId: "doc-1", targetLocale: "de", sourceLocale: "en", sourceFingerprint, translatedAt: "2026-10-22T00:00:00.000Z", - dismissedFingerprint: null, }); -const moduleReading = (stored: SourceFingerprint | null) => { - const store = { - upsert: vi.fn().mockResolvedValue(undefined), +const moduleReading = (stored: RecordedFingerprints | null) => { + const store = storeDouble({ find: vi.fn().mockResolvedValue(stored === null ? null : receipt(stored)), - findByDocument: vi.fn().mockResolvedValue([]), - dismiss: vi.fn().mockResolvedValue(undefined), - deleteByDocument: vi.fn().mockResolvedValue(undefined), - } as unknown as ProvenanceStore; + }); return provenanceModule({ serviceFactory: () => @@ -71,7 +66,7 @@ const run = (over: Partial = {}): BeforeTranslation => ({ }); const joinedWith = async ( - stored: SourceFingerprint | null, + stored: RecordedFingerprints | null, over: Partial = {} ) => { const tenant = moduleReading(stored); @@ -85,37 +80,8 @@ const fingerprintsLeftBy = (contribution: RunContribution): FieldFingerprints => return left as FieldFingerprints; }; -describe("what provenance tells the pipeline about drift", () => { - it("answers leaf by leaf when the receipt records leaf by leaf", async () => { - const asTranslated = fingerprintsLeftBy(await joinedWith(null)); - - const joined = await joinedWith({ kind: "fields", hashes: asTranslated }); - - expect(joined.sourceChangedByLeaf).toEqual({ title: false, body: false }); - }); - - it("marks the leaf whose source moved since that receipt", async () => { - const asTranslated = fingerprintsLeftBy(await joinedWith(null)); - - const joined = await joinedWith( - { kind: "fields", hashes: asTranslated }, - { sourceData: { title: "Hello", body: "Moved" } } - ); - - expect(joined.sourceChangedByLeaf).toEqual({ title: false, body: true }); - }); - - it("says nothing at all when no receipt exists", async () => { - expect((await joinedWith(null)).sourceChangedByLeaf).toBeUndefined(); - }); - - it("says nothing at all for a receipt written before per-leaf fingerprints", async () => { - const legacy: SourceFingerprint = { kind: "document", hash: "a".repeat(64) }; - - expect((await joinedWith(legacy)).sourceChangedByLeaf).toBeUndefined(); - }); - - it("still leaves the fingerprints behind for the write, whatever the receipt was", async () => { +describe("what provenance leaves behind for the write", () => { + it("leaves the fingerprints whatever the receipt was", async () => { expect(fingerprintsLeftBy(await joinedWith(null))).toEqual( expect.objectContaining({ title: expect.any(String), body: expect.any(String) }) ); diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.service.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.service.test.ts index 112164623..212dac87a 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.service.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.service.test.ts @@ -3,15 +3,14 @@ import { describe, it, expect, vi } from "vitest"; import type { Field } from "payload"; import { APIError } from "payload"; -import type { - ProvenanceStore, - ProvenanceReceipt, -} from "../../../../core/domain/provenance/index.js"; +import type { ProvenanceReceipt } from "../../../../core/domain/provenance/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; import { ProvenanceService } from "../Provenance.service.js"; import { asHash } from "../../../../core/domain/provenance/__tests__/fixtures/claims.js"; import { makeIdPath } from "../../../../core/domain/content-projection/idPath.js"; +import { UNANSWERED } from "../../../../core/domain/provenance/SourceFingerprint.js"; +import { storeDouble } from "../../../../core/domain/provenance/__tests__/fixtures/provenanceStore.js"; const answered = (name: string) => ({ idPath: makeIdPath([{ kind: "key", name }]), @@ -26,16 +25,7 @@ const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const schemaMap = new Map([[COLLECTION, schema]]) as CollectionSchemaMap; const sourceDoc = { id: "1", title: "Hello" }; -function makeStore(overrides: Partial = {}): ProvenanceStore { - return { - upsert: vi.fn().mockResolvedValue(undefined), - find: vi.fn().mockResolvedValue(null), - findByDocument: vi.fn().mockResolvedValue([]), - dismiss: vi.fn().mockResolvedValue(undefined), - deleteByDocument: vi.fn().mockResolvedValue(undefined), - ...overrides, - }; -} +const makeStore = storeDouble; function makeIo(read: (args: { locale: string }) => Promise | null>) { return { @@ -49,9 +39,8 @@ const record = (over: Partial = {}): ProvenanceReceipt => ({ documentId: "1", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "ace" }) }, + sourceFingerprint: hashed({ title: "ace" }), translatedAt: "2026-07-15T00:00:00.000Z", - dismissedFingerprint: null, ...over, }); @@ -77,11 +66,7 @@ describe("ProvenanceService", () => { expect(writeFingerprint).toEqual(expect.objectContaining({ title: expect.any(String) })); const freshStore = makeStore({ - findByDocument: vi - .fn() - .mockResolvedValue([ - record({ sourceFingerprint: { kind: "fields", hashes: writeFingerprint! } }), - ]), + findByDocument: vi.fn().mockResolvedValue([record({ sourceFingerprint: writeFingerprint! })]), }); const freshService = new ProvenanceService( freshStore, @@ -91,15 +76,17 @@ describe("ProvenanceService", () => { ); const fresh = await freshService.getStaleness(COLLECTION, "1"); expect(fresh).toEqual([ - { target_lng: "de", source_lng: "en", is_stale: false, translated_at: record().translatedAt }, + { + targetLocale: "de", + sourceLocale: "en", + isStale: false, + translatedAt: record().translatedAt, + changedLeaves: [], + }, ]); const driftStore = makeStore({ - findByDocument: vi - .fn() - .mockResolvedValue([ - record({ sourceFingerprint: { kind: "fields", hashes: writeFingerprint! } }), - ]), + findByDocument: vi.fn().mockResolvedValue([record({ sourceFingerprint: writeFingerprint! })]), }); const driftService = new ProvenanceService( driftStore, @@ -108,7 +95,42 @@ describe("ProvenanceService", () => { makeIo(async () => ({ id: "1", title: "Changed" })) ); const drifted = await driftService.getStaleness(COLLECTION, "1"); - expect(drifted[0].is_stale).toBe(true); + expect(drifted[0].isStale).toBe(true); + }); + + it("names the leaves that moved, so a field tree can mark them", async () => { + const store = makeStore({ + findByDocument: vi.fn().mockResolvedValue([ + record({ + sourceFingerprint: hashed({ title: "ace" }), + }), + ]), + }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => ({ id: "1", title: "Moved on" })) + ); + + expect((await service.getStaleness(COLLECTION, "1"))[0].changedLeaves).toEqual(["title"]); + }); + + it("names no leaves when nothing can be read from the receipt", async () => { + const store = makeStore({ + findByDocument: vi.fn().mockResolvedValue([record({ sourceFingerprint: null })]), + }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + const [locale] = await service.getStaleness(COLLECTION, "1"); + + expect(locale.isStale, "a digest still says the document moved").toBe(true); + expect(locale.changedLeaves, "but not where").toBeUndefined(); }); it("getStaleness drops a locale it cannot recompute and keeps going", async () => { @@ -128,7 +150,7 @@ describe("ProvenanceService", () => { const locales = await service.getStaleness(COLLECTION, "1"); expect( - locales.map((l) => l.target_lng), + locales.map((l) => l.targetLocale), "no transaction carried this read, so one unreadable locale must not cost the others" ).toEqual(["fr"]); expect(io.logger.error as ReturnType).toHaveBeenCalled(); @@ -217,9 +239,14 @@ describe("ProvenanceService", () => { ).resolves.toBeUndefined(); }); - it("dismiss persists the current source fingerprint for a locale that has a record", async () => { - const dismiss = vi.fn().mockResolvedValue(undefined); - const store = makeStore({ find: vi.fn().mockResolvedValue(record()), dismiss }); + it("dismiss re-bases the receipt, touching nothing a run may have just written", async () => { + const rebaseSourceFingerprint = vi.fn().mockResolvedValue(undefined); + const upsert = vi.fn(); + const store = makeStore({ + find: vi.fn().mockResolvedValue(record()), + rebaseSourceFingerprint, + upsert, + }); const service = new ProvenanceService( store, schemaMap, @@ -229,13 +256,14 @@ describe("ProvenanceService", () => { await service.dismiss({ collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }); - expect(dismiss).toHaveBeenCalledTimes(1); - const [key, fingerprint] = dismiss.mock.calls[0]; - expect(key).toEqual({ collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }); - expect(fingerprint, "a later source edit must be able to move past the dismissal").toEqual({ - kind: "fields", - hashes: service.captureFingerprint(COLLECTION, sourceDoc), - }); + expect(rebaseSourceFingerprint).toHaveBeenCalledWith( + { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }, + service.captureFingerprint(COLLECTION, sourceDoc) + ); + expect( + upsert, + "replacing the row would undo a run that finished mid-dismissal" + ).not.toHaveBeenCalled(); }); describe("record merges rather than replaces — a partial run must not claim the whole document", () => { @@ -247,11 +275,7 @@ describe("ProvenanceService", () => { }; const storedWith = (hashes: Record) => makeStore({ - find: vi - .fn() - .mockResolvedValue( - record({ sourceFingerprint: { kind: "fields", hashes: hashed(hashes) } }) - ), + find: vi.fn().mockResolvedValue(record({ sourceFingerprint: hashed(hashes) })), upsert: vi.fn().mockResolvedValue(undefined), }); @@ -268,12 +292,12 @@ describe("ProvenanceService", () => { expect(store.upsert).toHaveBeenCalledWith( expect.objectContaining({ - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "feed", body: "dadbed" }) }, + sourceFingerprint: hashed({ title: "feed", body: "dadbed" }), }) ); }); - it("re-bases a leaf it skipped onto where its source stands now", async () => { + it("keeps the claim already made for a leaf it did not send", async () => { const store = storedWith({ title: "dad", body: "dadbed" }); const service = new ProvenanceService( store, @@ -286,7 +310,7 @@ describe("ProvenanceService", () => { expect(store.upsert).toHaveBeenCalledWith( expect.objectContaining({ - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "feed", body: "deed" }) }, + sourceFingerprint: hashed({ title: "feed", body: "dadbed" }), }) ); }); @@ -304,18 +328,14 @@ describe("ProvenanceService", () => { expect(store.upsert).toHaveBeenCalledWith( expect.objectContaining({ - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "feed" }) }, + sourceFingerprint: hashed({ title: "feed" }), }) ); }); - it("watermarks every leaf when the receipt predates per-field fingerprints", async () => { + it("leaves a leaf it did not send unanswered when nothing can be read from the receipt", async () => { const store = makeStore({ - find: vi - .fn() - .mockResolvedValue( - record({ sourceFingerprint: { kind: "document", hash: "a".repeat(64) } }) - ), + find: vi.fn().mockResolvedValue(record({ sourceFingerprint: null })), upsert: vi.fn().mockResolvedValue(undefined), }); const service = new ProvenanceService( @@ -329,7 +349,7 @@ describe("ProvenanceService", () => { expect(store.upsert).toHaveBeenCalledWith( expect.objectContaining({ - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "feed", body: "cafe" }) }, + sourceFingerprint: { ...hashed({ title: "feed" }), body: UNANSWERED }, }) ); }); @@ -371,7 +391,7 @@ describe("ProvenanceService", () => { ).rejects.toThrow(APIError); }); - it("watermarks a leaf it saw and did not translate, rather than leaving it out", async () => { + it("names a leaf it saw and did not send, rather than leaving it out", async () => { const store = makeStore({ find: vi.fn().mockResolvedValue(null), upsert: vi.fn().mockResolvedValue(undefined), @@ -387,12 +407,12 @@ describe("ProvenanceService", () => { expect(store.upsert).toHaveBeenCalledWith( expect.objectContaining({ - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "feed", body: "cafe" }) }, + sourceFingerprint: { ...hashed({ title: "feed" }), body: UNANSWERED }, }) ); }); - it("watermarks every leaf when there is no receipt at all", async () => { + it("leaves a leaf it did not send unanswered when there is no receipt at all", async () => { const store = makeStore({ find: vi.fn().mockResolvedValue(null), upsert: vi.fn().mockResolvedValue(undefined), @@ -408,7 +428,7 @@ describe("ProvenanceService", () => { expect(store.upsert).toHaveBeenCalledWith( expect.objectContaining({ - sourceFingerprint: { kind: "fields", hashes: hashed({ title: "feed", body: "cafe" }) }, + sourceFingerprint: { ...hashed({ title: "feed" }), body: UNANSWERED }, }) ); }); @@ -416,7 +436,7 @@ describe("ProvenanceService", () => { describe("lastTranslatedFrom — the receipt, read best-effort", () => { const key = { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }; - const stored = { kind: "fields", hashes: hashed({ title: "abc" }) } as const; + const stored = hashed({ title: "abc" }); it("hands back the receipt when it can be read", async () => { const store = makeStore({ diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.store.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.store.test.ts index 36abb97fb..eb730152f 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.store.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/Provenance.store.test.ts @@ -8,8 +8,7 @@ const SLUG = "translator-provenance"; const HASHES = { title: asHash("3a7f1c"), body: asHash("91ce04") }; const SERIALIZED = JSON.stringify(HASHES); -const DISMISSED_HASHES = { title: asHash("3a7f1c"), body: asHash("ffffff") }; -const SERIALIZED_DISMISSED = JSON.stringify(DISMISSED_HASHES); +const ACCEPTED_HASHES = { title: asHash("beef"), body: asHash("cafe") }; const SHA256_HEX_LENGTH = 64; const LEGACY = "a".repeat(SHA256_HEX_LENGTH); @@ -18,15 +17,13 @@ const record: ProvenanceReceipt = { documentId: "doc-1", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: { kind: "fields", hashes: HASHES }, + sourceFingerprint: HASHES, translatedAt: "2026-07-02T00:00:00.000Z", - dismissedFingerprint: null, }; const row = { ...record, sourceFingerprint: SERIALIZED, - dismissedFingerprint: null as string | null, }; describe("PayloadProvenanceStore", () => { @@ -62,12 +59,14 @@ describe("PayloadProvenanceStore", () => { it("updates the existing record in place, never duplicating", async () => { setFound([{ id: 7, ...row }]); - await store.upsert({ ...record, sourceFingerprint: { kind: "document", hash: LEGACY } }); + await store.upsert({ ...record, sourceFingerprint: ACCEPTED_HASHES }); expect(payload.update).toHaveBeenCalledWith( expect.objectContaining({ collection: SLUG, id: 7, - data: expect.objectContaining({ sourceFingerprint: LEGACY }), + data: expect.objectContaining({ + sourceFingerprint: JSON.stringify(ACCEPTED_HASHES), + }), }) ); expect(payload.create).not.toHaveBeenCalled(); @@ -154,37 +153,64 @@ describe("PayloadProvenanceStore", () => { expect(result?.translatedAt).toBe("2026-07-02T00:00:00.000Z"); }); - it("parses a non-null dismissedFingerprint into the union", async () => { - setFound([{ id: 7, ...row, dismissedFingerprint: SERIALIZED_DISMISSED }]); + it.each([ + ["a receipt written before per-field fingerprints", LEGACY], + ["a value it cannot make sense of", "not a fingerprint"], + ])("reads %s as a receipt claiming nothing, without throwing", async (_label, stored) => { + setFound([{ id: 7, ...row, sourceFingerprint: stored }]); const result = await store.find({ collectionSlug: "posts", documentId: "doc-1", targetLocale: "de", }); - expect(result?.dismissedFingerprint).toEqual({ kind: "fields", hashes: DISMISSED_HASHES }); + expect(result?.translatedAt, "the row is still read").toBe("2026-07-02T00:00:00.000Z"); + expect(result?.sourceFingerprint).toBeNull(); }); + }); - it("parses a receipt written before per-field fingerprints as the document shape", async () => { - setFound([{ id: 7, ...row, sourceFingerprint: LEGACY }]); - const result = await store.find({ - collectionSlug: "posts", - documentId: "doc-1", - targetLocale: "de", - }); - expect(result?.sourceFingerprint).toEqual({ kind: "document", hash: LEGACY }); + describe("rebaseSourceFingerprint", () => { + const key = { collectionSlug: "posts", documentId: "doc-1", targetLocale: "de" }; + const accepted = ACCEPTED_HASHES; + + it("writes the fingerprint and nothing else", async () => { + setFound([{ id: 7, ...row }]); + await store.rebaseSourceFingerprint(key, accepted); + expect( + payload.update, + "a run finishing mid-dismissal keeps its own translatedAt and its own claims" + ).toHaveBeenCalledWith( + expect.objectContaining({ + collection: SLUG, + id: 7, + data: { sourceFingerprint: JSON.stringify(ACCEPTED_HASHES) }, + }) + ); }); - it("reads a value it cannot make sense of as the old shape, without throwing", async () => { - setFound([{ id: 7, ...row, sourceFingerprint: "not a fingerprint" }]); - const result = await store.find({ - collectionSlug: "posts", - documentId: "doc-1", - targetLocale: "de", - }); - expect(result?.sourceFingerprint).toEqual({ - kind: "document", - hash: "not a fingerprint", - }); + it("matches the record by the composite key", async () => { + setFound([{ id: 7, ...row }]); + await store.rebaseSourceFingerprint(key, accepted); + expect(payload.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + and: [ + { collectionSlug: { equals: "posts" } }, + { documentId: { equals: "doc-1" } }, + { targetLocale: { equals: "de" } }, + ], + }, + }) + ); + }); + + it("creates nothing when no record exists for the key", async () => { + setFound([]); + await store.rebaseSourceFingerprint(key, accepted); + expect(payload.update).not.toHaveBeenCalled(); + expect( + payload.create, + "a dismissal must not resurrect a deleted document's receipt" + ).not.toHaveBeenCalled(); }); }); @@ -234,7 +260,6 @@ describe("PayloadProvenanceStore", () => { ...row, targetLocale: "fr", translatedAt: new Date("2026-07-02T00:00:00.000Z"), - dismissedFingerprint: SERIALIZED_DISMISSED, }, ]); const result = await store.findByDocument("posts", "doc-1"); @@ -244,7 +269,6 @@ describe("PayloadProvenanceStore", () => { ...record, targetLocale: "fr", translatedAt: "2026-07-02T00:00:00.000Z", - dismissedFingerprint: { kind: "fields", hashes: DISMISSED_HASHES }, }, ]); }); @@ -254,42 +278,4 @@ describe("PayloadProvenanceStore", () => { expect(await store.findByDocument("posts", "missing")).toEqual([]); }); }); - - describe("dismiss", () => { - const key = { collectionSlug: "posts", documentId: "doc-1", targetLocale: "de" }; - - it("updates dismissedFingerprint on the matched record", async () => { - setFound([{ id: 7, ...row }]); - await store.dismiss(key, { kind: "fields", hashes: DISMISSED_HASHES }); - expect(payload.update).toHaveBeenCalledWith( - expect.objectContaining({ - collection: SLUG, - id: 7, - data: { dismissedFingerprint: SERIALIZED_DISMISSED }, - }) - ); - }); - - it("matches the record by the composite key", async () => { - setFound([{ id: 7, ...row }]); - await store.dismiss(key, { kind: "fields", hashes: DISMISSED_HASHES }); - expect(payload.find).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - and: [ - { collectionSlug: { equals: "posts" } }, - { documentId: { equals: "doc-1" } }, - { targetLocale: { equals: "de" } }, - ], - }, - }) - ); - }); - - it("is a no-op when no record exists for the key", async () => { - setFound([]); - await store.dismiss(key, { kind: "fields", hashes: DISMISSED_HASHES }); - expect(payload.update).not.toHaveBeenCalled(); - }); - }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/ProvenanceCleanup.hook.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/ProvenanceCleanup.hook.test.ts index d4f6fc84b..2da0ce4e6 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/ProvenanceCleanup.hook.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/ProvenanceCleanup.hook.test.ts @@ -1,18 +1,11 @@ import { describe, it, expect, vi } from "vitest"; import type { CollectionAfterDeleteHook, Payload } from "payload"; -import type { ProvenanceStore } from "../../../../core/domain/provenance/index.js"; import type { ManagedCollectionsConfig } from "../Provenance.shapes.js"; import { injectProvenanceCleanup, makeProvenanceCleanupHook } from "../ProvenanceCleanup.hook.js"; +import { storeDouble } from "../../../../core/domain/provenance/__tests__/fixtures/provenanceStore.js"; -const makeStore = () => - ({ - upsert: vi.fn().mockResolvedValue(undefined), - find: vi.fn().mockResolvedValue(null), - findByDocument: vi.fn().mockResolvedValue([]), - dismiss: vi.fn().mockResolvedValue(undefined), - deleteByDocument: vi.fn().mockResolvedValue(undefined), - }) satisfies ProvenanceStore; +const makeStore = () => storeDouble(); const makePayload = () => ({ logger: { error: vi.fn() } }) as unknown as Payload; diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/dismissalWritesTheReceipt.test.ts b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/dismissalWritesTheReceipt.test.ts new file mode 100644 index 000000000..f4ddb69f6 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/provenance/__tests__/dismissalWritesTheReceipt.test.ts @@ -0,0 +1,158 @@ +import type { Field, Payload } from "payload"; +import { describe, expect, it, vi } from "vitest"; + +import { computeFieldFingerprints } from "../../../../core/domain/content-projection/computeFieldFingerprints.js"; +import type { + ProvenanceReceipt, + ProvenanceStore, +} from "../../../../core/domain/provenance/index.js"; +import { UNANSWERED } from "../../../../core/domain/provenance/index.js"; +import type { RequestScope } from "../../../shared/payload/RequestScope.shapes.js"; +import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; +import { driftedLeaves } from "../Provenance.criterion.js"; +import { ProvenanceService } from "../Provenance.service.js"; + +const COLLECTION = "posts"; +const schema: Field[] = [ + { name: "title", type: "text", localized: true }, + { name: "body", type: "text", localized: true }, +]; +const schemaMap = new Map([[COLLECTION, schema]]) as CollectionSchemaMap; + +const KEY = { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }; +const TRANSLATED_AT = "2026-07-15T00:00:00.000Z"; + +/** `swallowOrThrow` reads `transactionID` alone, so an empty scope means no transaction to lose. */ +const NO_TRANSACTION: RequestScope = {}; +/** Threaded to the service factory by the criterion; nothing in these checks reads it. */ +const UNUSED_PAYLOAD = {} as Payload; + +const sourceWas = { id: "1", title: "Hello", body: "World" }; +const sourceNow = { id: "1", title: "Hello", body: "Moved on" }; + +const hashesOf = (doc: Record) => computeFieldFingerprints(doc, schema); + +const receiptFrom = ( + doc: Record, + over: Partial = {} +): ProvenanceReceipt => ({ + collectionSlug: COLLECTION, + documentId: "1", + targetLocale: "de", + sourceLocale: "en", + sourceFingerprint: hashesOf(doc), + translatedAt: TRANSLATED_AT, + ...over, +}); + +function storeHoldingOneRow(seed: ProvenanceReceipt) { + let row = seed; + const store: ProvenanceStore = { + upsert: async (receipt) => { + row = receipt; + }, + find: async () => row, + findByDocument: async () => [row], + rebaseSourceFingerprint: async (_key, sourceFingerprint) => { + row = { ...row, sourceFingerprint }; + }, + deleteByDocument: async () => {}, + }; + return { store, row: () => row }; +} + +const serviceOver = (store: ProvenanceStore, source: Record | null) => + new ProvenanceService(store, schemaMap, NO_TRANSACTION, { + logger: { error: vi.fn() }, + readSource: async () => source, + }); + +const ask = (sourceData: Record) => ({ + payload: UNUSED_PAYLOAD, + scope: NO_TRANSACTION, + target: { + collection: COLLECTION, + documentId: "1", + sourceLocale: "en", + targetLocale: "de", + }, + sourceData, +}); + +const dismissedAgainst = async ( + source: Record | null, + seed: ProvenanceReceipt = receiptFrom(sourceWas) +) => { + const kept = storeHoldingOneRow(seed); + await serviceOver(kept.store, source).dismiss(KEY); + return kept; +}; + +describe("hiding the out-of-date mark", () => { + it("leaves the locale reading as current, naming no moved leaves", async () => { + const kept = await dismissedAgainst(sourceNow); + + const [locale] = await serviceOver(kept.store, sourceNow).getStaleness(COLLECTION, "1"); + + expect(locale?.isStale).toBe(false); + expect( + locale?.changedLeaves, + "the panel and the field tree read one receipt, so they cannot disagree" + ).toEqual([]); + }); + + it("keeps those leaves out of a run that narrows by the drift rule", async () => { + const kept = await dismissedAgainst(sourceNow); + const rule = driftedLeaves(() => serviceOver(kept.store, sourceNow)); + + await expect( + rule(ask(sourceNow)), + "a person said this translation is fine, so a run must not overwrite it" + ).resolves.toEqual([]); + }); + + it("claims a leaf the receipt never answered", async () => { + const neverAnswered = receiptFrom(sourceWas, { + sourceFingerprint: { ...hashesOf(sourceWas), body: UNANSWERED }, + }); + + const kept = await dismissedAgainst(sourceNow, neverAnswered); + + expect(kept.row().sourceFingerprint).toEqual(hashesOf(sourceNow)); + }); + + it("upgrades a receipt nothing could be read from", async () => { + const digest = receiptFrom(sourceWas, { sourceFingerprint: null }); + + const kept = await dismissedAgainst(sourceNow, digest); + + expect( + kept.row().sourceFingerprint, + "the panel can name moved leaves for this locale from now on" + ).toEqual(hashesOf(sourceNow)); + }); + + it("does not move the moment the translation was made", async () => { + const kept = await dismissedAgainst(sourceNow); + + expect(kept.row().translatedAt).toBe(TRANSLATED_AT); + }); + + it("writes nothing when the source is not readable by this caller", async () => { + const seed = receiptFrom(sourceWas); + + const kept = await dismissedAgainst(null, seed); + + expect(kept.row()).toEqual(seed); + }); + + it("comes back when the source moves again", async () => { + const kept = await dismissedAgainst(sourceNow); + const movedTwice = { id: "1", title: "Hello", body: "Moved twice" }; + + const [locale] = await serviceOver(kept.store, movedTwice).getStaleness(COLLECTION, "1"); + + expect(locale?.isStale).toBe(true); + expect(locale?.changedLeaves).toEqual(["body"]); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/index.ts b/packages/payload-plugin-translator/src/server/modules/provenance/index.ts index eaf278b0a..1ab6ad03e 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/index.ts @@ -10,7 +10,7 @@ export type { ProvenanceStoreFactory } from "./Provenance.store.js"; export { injectProvenanceCleanup, makeProvenanceCleanupHook } from "./ProvenanceCleanup.hook.js"; export { assertProvenanceSlugFree } from "./slugGuard.js"; export { ProvenanceService } from "./Provenance.service.js"; -export type { StalenessLocale } from "./Provenance.service.js"; +export type { LocaleStaleness } from "./Provenance.service.js"; export { PROVENANCE_MODULE, provenanceModule } from "./Provenance.module.js"; export { configureProvenance, provenanceIo } from "./Provenance.wiring.js"; export type { ProvenanceOption, ProvenanceServiceFactory } from "./Provenance.wiring.js"; From 1fa6e5d2d61a992101b10010c747588db6f1b2fa Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sun, 25 Oct 2026 12:50:06 +0100 Subject: [PATCH 05/25] feat!: auto-translate loses the strategy option An automatic run names no filters, so it covers the whole document until auto-translate gains its own control over what a run translates. BREAKING CHANGE: `AutoTranslateStrategy` is no longer exported and `AutoTranslateConfig.strategy` is removed. A host that set `skip_existing` had automatic runs that left translated fields alone; those runs now re-translate whole documents, at full provider cost, over reviewed text. The option is refused rather than ignored. See docs/DEPRECATIONS.md#the-strategy-leaves-the-run. --- .../src/__tests__/plugin.test.ts | 8 ++-- .../src/auto-translate-config.ts | 6 +-- .../payload-plugin-translator/src/index.ts | 2 +- .../payload-plugin-translator/src/plugin.ts | 9 ++-- .../cancel-by-collection/handler.test.ts | 1 - .../get-collection-status/handler.test.ts | 1 - .../features/translate-field/handler.ts | 9 ++-- .../auto-translate/AutoTranslate.policy.ts | 44 ++++--------------- .../auto-translate/AutoTranslate.shapes.ts | 12 +---- .../auto-translate/AutoTranslate.wiring.ts | 36 +++++++++++---- .../__tests__/AutoTranslate.policy.test.ts | 30 +------------ .../AutoTranslateEnqueue.hook.test.ts | 1 - .../AutoTranslateEnqueue.identity.test.ts | 1 - .../AutoTranslateEnqueue.transaction.test.ts | 1 - .../__tests__/legacyStrategyWarning.test.ts | 41 +++++++++++++++++ .../server/modules/auto-translate/index.ts | 6 --- .../lifecycle/LifecycleNotifier.test.ts | 1 - .../modules/lifecycle/taskMapping.test.ts | 2 - .../server/modules/lifecycle/taskMapping.ts | 5 --- .../src/server/modules/lifecycle/types.ts | 8 +--- .../src/server/shared/guards/field-guards.ts | 9 +++- .../src/types/AutoTranslateContext.ts | 3 +- 22 files changed, 105 insertions(+), 131 deletions(-) create mode 100644 packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts diff --git a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts index 7dfffe19d..2e08e9b2a 100644 --- a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts +++ b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts @@ -402,7 +402,6 @@ describe("translatorPlugin — lifecycle callbacks", () => { collectionId: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, ...overrides, }); @@ -410,7 +409,7 @@ describe("translatorPlugin — lifecycle callbacks", () => { const mockPayload = () => ({ findByID: vi.fn().mockResolvedValue({ id: "doc-1" }), - logger: { error: vi.fn() }, + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, collections: { posts: { config: { versions: undefined } } }, }) as unknown as Payload; @@ -461,7 +460,6 @@ describe("translatorPlugin — lifecycle callbacks", () => { collectionId: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", handle: "run-1", }; @@ -483,7 +481,9 @@ describe("translatorPlugin — lifecycle callbacks", () => { return captured as TaskRunnerContext; }; - const payload = { logger: { error: vi.fn() } } as unknown as Payload; + const payload = { + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, + } as unknown as Payload; const mappings: Array<[string, keyof TranslationLifecycleCallbacks, TaskEvent]> = [ ["queued", "onQueued", { state: "queued" }], diff --git a/packages/payload-plugin-translator/src/auto-translate-config.ts b/packages/payload-plugin-translator/src/auto-translate-config.ts index 8ccfa2ab7..3e1954969 100644 --- a/packages/payload-plugin-translator/src/auto-translate-config.ts +++ b/packages/payload-plugin-translator/src/auto-translate-config.ts @@ -4,7 +4,6 @@ import type { AutoTranslateConfig } from "./core/domain/auto-translate/index.js" import { AUTO_TRANSLATE_CUSTOM_KEY } from "./core/domain/auto-translate/index.js"; export type { AutoTranslateConfig }; -export type { AutoTranslateStrategy } from "./core/domain/auto-translate/index.js"; /** * Enable opt-in **auto-translate** for a collection: when a document's source-locale content changes @@ -16,7 +15,8 @@ export type { AutoTranslateStrategy } from "./core/domain/auto-translate/index.j * Behaviour: fires only on a **published** source save (draft/autosave saves are ignored; a collection * without drafts treats every save as published); skips when no translatable content actually changed * (drift-gate); coalesces rapid edits via `debounceMs`; never re-triggers on its own translation - * writes; and never fails the editor's save (best-effort). + * writes; and never fails the editor's save (best-effort). An automatic run translates the whole + * document — there is no way to narrow it. * * **Requires a working job runner.** Auto-translate only ENQUEUES jobs — they run via the plugin's * task runner (`createPayloadJobsRunner`) and its autorun loop. On serverless platforms such as @@ -25,7 +25,7 @@ export type { AutoTranslateStrategy } from "./core/domain/auto-translate/index.j * self-hosted worker. Ensure your deployment actually executes queued jobs before relying on this. * * @param collection - The collection to opt in. - * @param config - Target locales (+ optional strategy, debounce, source-locale override). + * @param config - The rule to stamp; see {@link AutoTranslateConfig}. * @returns A new collection with the auto-translate rule applied (the input is not mutated). * * @example diff --git a/packages/payload-plugin-translator/src/index.ts b/packages/payload-plugin-translator/src/index.ts index ae87fac31..e64c29810 100644 --- a/packages/payload-plugin-translator/src/index.ts +++ b/packages/payload-plugin-translator/src/index.ts @@ -80,7 +80,7 @@ export type { FieldTranslationConfig } from "./field-config.js"; // collection with `withAutoTranslate`; requires a working job runner/autorun to execute (see the // JSDoc for the Vercel/serverless caveat). Since v0.9.0. export { withAutoTranslate } from "./auto-translate-config.js"; -export type { AutoTranslateConfig, AutoTranslateStrategy } from "./auto-translate-config.js"; +export type { AutoTranslateConfig } from "./auto-translate-config.js"; export { createOpenAIProvider, OpenAITranslationProvider } from "./translation-providers/index.js"; export type { OpenAIProviderConfig } from "./translation-providers/index.js"; diff --git a/packages/payload-plugin-translator/src/plugin.ts b/packages/payload-plugin-translator/src/plugin.ts index 9197c5e7f..2d9f64498 100644 --- a/packages/payload-plugin-translator/src/plugin.ts +++ b/packages/payload-plugin-translator/src/plugin.ts @@ -162,17 +162,18 @@ export function translatorPlugin( ); const collectionSlugs = new Set(schemaMap.keys()); - const provenanceModule = configureProvenance(provenance, schemaMap); + const provenanceParts = configureProvenance(provenance, schemaMap); - // Provenance goes first: the staleness route re-hashes the stored source, so a fingerprint - // taken after another module replaced `sourceData` would never match. - const chain: TranslationModule[] = provenanceModule ? [provenanceModule, ...modules] : modules; + const chain: TranslationModule[] = provenanceParts + ? [provenanceParts.module, ...modules] + : modules; refuseDuplicateNames(chain); const { taskRunnerFactory, configModifier: runnerConfigModifier } = wireTranslateRunner({ translationProvider, schemaMap, modules: chain, + criteria: provenanceParts?.criteria, runner, lifecycle: lifecycle ?? {}, collections: Array.from(collectionSlugs), diff --git a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts index 4924076f4..89bbc3ab9 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts @@ -18,7 +18,6 @@ describe("CancelByCollectionHandler", () => { collectionId: "doc-123", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, }, createdAt: "2024-01-01T00:00:00Z", diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts index 3b4555aa9..c8e0aa088 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts @@ -27,7 +27,6 @@ describe("GetCollectionStatusHandler", () => { collectionId, sourceLng: "en", targetLng, - strategy: "overwrite", publishOnTranslation: false, }, createdAt: "2024-01-01T00:00:00Z", diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts index 11ff04999..c226a3829 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts @@ -2,8 +2,8 @@ import type { PayloadRequest } from "payload"; import { getByPath, ServerResponse } from "../../shared/index.js"; import { translateContent } from "../../../core/translation-pipeline/index.js"; -import { extractLocaleCodes } from "../../modules/auto-translate/index.js"; -import type { LocalizationLike } from "../../modules/auto-translate/index.js"; +import { everyTranslatableLeaf } from "../../../core/domain/content-projection/contentProjector.js"; +import { Locales } from "../../../core/domain/locales/index.js"; import type { FieldTranslationNotice, @@ -55,9 +55,7 @@ export class TranslateFieldHandler { ); } - const known = extractLocaleCodes( - req.payload?.config?.localization as LocalizationLike | undefined - ); + const known = Locales.knownCodes(req.payload?.config?.localization); if (!known) { return ServerResponse.badRequest( "Localization is not enabled in this Payload config; there is nothing to translate between" @@ -133,6 +131,7 @@ export class TranslateFieldHandler { const translated = await translateContent({ schema: resolution.schema, sourceData: resolution.sourceData, + selection: everyTranslatableLeaf(resolution.sourceData, resolution.schema), sourceLng: source_lng, targetLng: target_lng, document: { collection: collection_slug, id: doc_id }, diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.ts index 712aa3376..405373098 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.ts @@ -4,10 +4,8 @@ import type { AutoTranslateConfig } from "../../../core/domain/auto-translate/in import { Locales } from "../../../core/domain/locales/index.js"; import type { TaskInput } from "../task-runner/types.js"; -/** A collection's auto-translate rule with defaults resolved — the shape the hook consumes. */ export type NormalizedAutoTranslatePolicy = { targets: string[]; - strategy: "overwrite" | "skip_existing"; debounceMs: number; sourceLocale?: string; }; @@ -23,38 +21,19 @@ export type AutoTranslatePolicyResolver = ( doc: Record ) => NormalizedAutoTranslatePolicy | null; -/** Apply defaults to a raw config: strategy → "overwrite", debounce → 0. Duplicate target locales are - * de-duplicated so a misconfigured `targets: ["de","de"]` never enqueues two racing jobs for the same - * (document, locale) in one batch (the runner's supersession only dedupes against already-stored jobs). */ +/** De-duplicates `targets`: a misconfigured `["de","de"]` would enqueue two racing jobs for one + * (document, locale) — the runner's supersession only dedupes against already-stored jobs. */ export function normalizeAutoTranslateConfig( config: AutoTranslateConfig ): NormalizedAutoTranslatePolicy { return { targets: Locales.dedupe(config.targets), - strategy: config.strategy ?? "overwrite", debounceMs: config.debounceMs ?? 0, sourceLocale: config.sourceLocale, }; } -/** - * A minimal localization shape — the set of configured locale codes, in either form Payload accepts - * (`"en"` or `{ code: "en" }`). Payload's `Config["localization"]` is structurally assignable, so the - * caller passes it straight in with no adapter and tests pass a tiny literal. - */ -export type LocalizationLike = false | { locales: Array }; - -/** Extract the configured locale codes, or `null` when localization is disabled/absent. */ -export function extractLocaleCodes(localization: LocalizationLike | undefined): Set | null { - if (!localization) return null; - return new Set( - localization.locales.map((locale) => (typeof locale === "string" ? locale : locale.code)) - ); -} - -/** Outcome of validating a policy's locales against the configured set — the filtered policy plus what - * was dropped, so the caller can warn precisely. */ -export type PolicyLocaleFilter = { +type PolicyLocaleFilter = { policy: NormalizedAutoTranslatePolicy; droppedTargets: string[]; droppedSourceLocale: string | null; @@ -105,18 +84,12 @@ export function passesPublishGate(doc: Record, hasDrafts: boole } /** - * Mirror the source document's status onto the translation (#51 D9). Combined with the publish-gate, - * the source is published whenever we reach enqueue, so translations publish; a no-drafts collection - * publishes too. Deliberately identical to {@link passesPublishGate} today — kept as a SEPARATE - * function (not merged) because it diverges once a future document-level manager (R8) can bypass the - * gate and translate a still-draft source; do not collapse the two. + * Whether the translation itself is published. A separate name from {@link passesPublishGate}, which + * answers whether to translate at all: the two give the same answer only while nothing can translate + * a still-draft source. */ -export function resolvePublishOnTranslation( - doc: Record, - hasDrafts: boolean -): boolean { - if (!hasDrafts) return true; - return doc._status === "published"; +function resolvePublishOnTranslation(doc: Record, hasDrafts: boolean): boolean { + return passesPublishGate(doc, hasDrafts); } /** @@ -140,7 +113,6 @@ export function buildAutoTranslateTasks(args: { collectionId: documentId, sourceLng: sourceLocale, targetLng: target, - strategy: policy.strategy, publishOnTranslation, waitUntil, })); diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.shapes.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.shapes.ts index dac214a3c..d8d77c775 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.shapes.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.shapes.ts @@ -1,22 +1,12 @@ import type { CollectionAfterChangeHook } from "payload"; -/** - * The minimal slice of a Payload collection the auto-translate wiring mutates: its `slug` and the - * `afterChange` hook slot. A real `CollectionConfig` is structurally assignable to this, so call sites - * pass the live collection with no adapter and tests pass a `{ slug: "posts" }` literal. Keeps - * `injectAutoTranslateHook` off the god-`CollectionConfig` type (own shape — provenance's is not reused, - * per the module-owns-its-shape convention). The only Payload type imported is the hook callback - * contract, which legitimately stays framework-typed. - */ +/** The slice of a Payload collection that auto-translate's config-time wiring reads and mutates. */ export type AutoTranslateManagedEntry = { slug: string; hooks?: { afterChange?: CollectionAfterChangeHook[] }; - /** The `custom` bag — the opt-in is propagated here so the admin UI can read it back off the - * REGISTERED collection (which may be a different object than the one `withAutoTranslate` wrapped). */ custom?: Record; }; -/** The minimal config host: a mutable `collections` array. A real Payload `Config` plugs straight in. */ export type AutoTranslateManagedConfig = { collections?: AutoTranslateManagedEntry[]; }; diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts index b78a92d0e..54df4fa4e 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts @@ -1,32 +1,32 @@ -import { getAutoTranslateConfig } from "../../../core/domain/auto-translate/index.js"; +import { + AUTO_TRANSLATE_CUSTOM_KEY, + getAutoTranslateConfig, +} from "../../../core/domain/auto-translate/index.js"; +import { Locales } from "../../../core/domain/locales/index.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ConfigModifier } from "../../../types/ConfigModifier.js"; import type { TaskRunnerFactory } from "../task-runner/index.js"; import { - extractLocaleCodes, filterPolicyToKnownLocales, makeCollectionPolicyResolver, normalizeAutoTranslateConfig, } from "./AutoTranslate.policy.js"; -import type { LocalizationLike, NormalizedAutoTranslatePolicy } from "./AutoTranslate.policy.js"; +import type { NormalizedAutoTranslatePolicy } from "./AutoTranslate.policy.js"; import { injectAutoTranslateHook, makeAutoTranslateHook, propagateAutoTranslateCustom, } from "./AutoTranslateEnqueue.hook.js"; -/** A collection as the plugin receives it — only `slug` + `custom` are read to resolve the opt-in. */ type ConfigurableCollection = { slug: string; custom?: Record }; -/** Everything the auto-translate module contributes at config time (mirrors `ProvenanceModule`). */ export type AutoTranslateModule = { configure(managedSlugs: Set): ConfigModifier; }; const NOOP: ConfigModifier = (config) => config; -/** Emit one clear config-time warning per collection whose auto-translate config named unknown locales. */ function warnDroppedLocales( slug: string, filtered: ReturnType, @@ -51,11 +51,11 @@ function warnDroppedLocales( * translations under a locale the project cannot serve. */ function dropLocalesTheProjectDoesNotHave( - config: { localization?: LocalizationLike }, + config: { localization?: Locales.LocalizationLike }, slugs: Set, policies: Map ): void { - const knownLocales = extractLocaleCodes(config.localization); + const knownLocales = Locales.knownCodes(config.localization); if (!knownLocales) { if (slugs.size > 0) { console.warn( @@ -73,6 +73,24 @@ function dropLocalesTheProjectDoesNotHave( } } +/** + * A removed option must not become a silent no-op: `strategy: "skip_existing"` kept an automatic run + * to the drifted leaves, and a run now covers the whole document — so a host that believes the + * option still holds overwrites reviewed text on every source save. + * See docs/DEPRECATIONS.md#the-strategy-leaves-the-run + */ +function warnAboutTheRemovedStrategy(collections: ConfigurableCollection[]): void { + for (const collection of collections) { + const raw = collection.custom?.[AUTO_TRANSLATE_CUSTOM_KEY]; + if (typeof raw !== "object" || raw === null) continue; + if (!("strategy" in raw)) continue; + + console.warn( + `[payload-plugin-translator] auto-translate on "${collection.slug}": \`strategy\` was removed and is ignored. An automatic run now translates the whole document — see docs/DEPRECATIONS.md#the-strategy-leaves-the-run.` + ); + } +} + /** * Turn the opt-in `withAutoTranslate` config (read from each collection's `custom`) into a * self-contained {@link AutoTranslateModule} — mirrors `configureProvenance`. Builds the per-collection @@ -85,6 +103,8 @@ export function configureAutoTranslate( schemaMap: CollectionSchemaMap, taskRunnerFactory: TaskRunnerFactory ): AutoTranslateModule { + warnAboutTheRemovedStrategy(collections); + const policies = new Map(); for (const collection of collections) { const config = getAutoTranslateConfig(collection); diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.policy.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.policy.test.ts index 58d02e21e..81b2d870c 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.policy.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.policy.test.ts @@ -2,12 +2,10 @@ import { describe, it, expect } from "vitest"; import { buildAutoTranslateTasks, - extractLocaleCodes, filterPolicyToKnownLocales, makeCollectionPolicyResolver, normalizeAutoTranslateConfig, passesPublishGate, - resolvePublishOnTranslation, } from "../AutoTranslate.policy.js"; import type { NormalizedAutoTranslatePolicy } from "../AutoTranslate.policy.js"; @@ -15,7 +13,6 @@ const policy = ( over: Partial = {} ): NormalizedAutoTranslatePolicy => ({ targets: ["de", "fr"], - strategy: "overwrite", debounceMs: 0, ...over, }); @@ -24,7 +21,6 @@ describe("normalizeAutoTranslateConfig", () => { it("applies defaults (strategy overwrite, debounce 0)", () => { expect(normalizeAutoTranslateConfig({ targets: ["de"] })).toEqual({ targets: ["de"], - strategy: "overwrite", debounceMs: 0, sourceLocale: undefined, }); @@ -34,11 +30,10 @@ describe("normalizeAutoTranslateConfig", () => { expect( normalizeAutoTranslateConfig({ targets: ["de"], - strategy: "skip_existing", debounceMs: 1000, sourceLocale: "en", }) - ).toEqual({ targets: ["de"], strategy: "skip_existing", debounceMs: 1000, sourceLocale: "en" }); + ).toEqual({ targets: ["de"], debounceMs: 1000, sourceLocale: "en" }); }); // Guards the call site (not just the shared primitive): a duplicate in config.targets must collapse @@ -71,28 +66,6 @@ describe("passesPublishGate (D8)", () => { }); }); -describe("resolvePublishOnTranslation (D9 — mirror source)", () => { - it("mirrors the source status", () => { - expect(resolvePublishOnTranslation({ _status: "published" }, true)).toBe(true); - expect(resolvePublishOnTranslation({ _status: "draft" }, true)).toBe(false); - expect(resolvePublishOnTranslation({}, false)).toBe(true); - }); -}); - -describe("extractLocaleCodes", () => { - it("returns null when localization is disabled/absent", () => { - expect(extractLocaleCodes(false)).toBeNull(); - expect(extractLocaleCodes(undefined)).toBeNull(); - }); - - it("reads codes from both the string and object locale forms", () => { - expect(extractLocaleCodes({ locales: ["en", "de"] })).toEqual(new Set(["en", "de"])); - expect(extractLocaleCodes({ locales: [{ code: "en" }, { code: "fr" }] })).toEqual( - new Set(["en", "fr"]) - ); - }); -}); - describe("filterPolicyToKnownLocales", () => { const known = new Set(["en", "de", "fr"]); @@ -137,7 +110,6 @@ describe("buildAutoTranslateTasks", () => { collectionSlug: "posts", collectionId: "1", sourceLng: "en", - strategy: "overwrite", publishOnTranslation: true, }); expect(tasks[0].waitUntil).toBeUndefined(); diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts index ee1aac60f..023e9f3d9 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts @@ -22,7 +22,6 @@ const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const schemaMap: CollectionSchemaMap = new Map([["posts" as CollectionSlug, schema]]); const policy: NormalizedAutoTranslatePolicy = { targets: ["de", "fr"], - strategy: "overwrite", debounceMs: 0, }; diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts index 46f63db97..8b0513688 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts @@ -12,7 +12,6 @@ const schemaMap: CollectionSchemaMap = new Map([ ]); const policy: NormalizedAutoTranslatePolicy = { targets: ["de"], - strategy: "overwrite", debounceMs: 0, }; const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts index be054a964..a5f1f6375 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts @@ -12,7 +12,6 @@ const schemaMap: CollectionSchemaMap = new Map([ ]); const policy: NormalizedAutoTranslatePolicy = { targets: ["de"], - strategy: "overwrite", debounceMs: 0, }; diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts new file mode 100644 index 000000000..62fa5f705 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../../core/domain/auto-translate/index.js"; +import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; +import type { TaskRunnerFactory } from "../../task-runner/index.js"; +import { configureAutoTranslate } from "../AutoTranslate.wiring.js"; + +const runner = { create: vi.fn() } as unknown as TaskRunnerFactory; +const schemaMap = new Map() as CollectionSchemaMap; + +const collection = (custom: Record) => [{ slug: "posts", custom }]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("a collection still carrying the removed auto-translate option", () => { + it("is warned about at boot, rather than quietly translating the whole document", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + configureAutoTranslate( + collection({ [AUTO_TRANSLATE_CUSTOM_KEY]: { targets: ["de"], strategy: "skip_existing" } }), + schemaMap, + runner + ); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("`strategy` was removed")); + }); + + it("is left alone when it carries no such option", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + configureAutoTranslate( + collection({ [AUTO_TRANSLATE_CUSTOM_KEY]: { targets: ["de"] } }), + schemaMap, + runner + ); + + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("`strategy` was removed")); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/index.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/index.ts index 684bce7ea..80d03e659 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/index.ts @@ -1,6 +1,3 @@ -// Auto-translate adapter (Payload-backed). The payload-free drift predicate + config reader live in -// the core (src/core/domain/auto-translate, src/core/domain/content-projection); this module is the config-time -// wiring + the afterChange hook that enqueues translations on a source-locale change (#51). export { configureAutoTranslate } from "./AutoTranslate.wiring.js"; export type { AutoTranslateModule } from "./AutoTranslate.wiring.js"; export { makeAutoTranslateHook, injectAutoTranslateHook } from "./AutoTranslateEnqueue.hook.js"; @@ -8,6 +5,3 @@ export type { AutoTranslatePolicyResolver, NormalizedAutoTranslatePolicy, } from "./AutoTranslate.policy.js"; -// Generic locale-set extraction, reused by the manual enqueue path to validate multi-target input. -export { extractLocaleCodes } from "./AutoTranslate.policy.js"; -export type { LocalizationLike } from "./AutoTranslate.policy.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.test.ts index cf0af0b47..1148c24d5 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.test.ts @@ -7,7 +7,6 @@ const task: TranslationTask = { id: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", }; const makeLogger = () => ({ error: vi.fn() }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts index 9c4452129..233db068c 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts @@ -10,7 +10,6 @@ describe("taskFromAssignment", () => { collectionId: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "skip_existing", handle: "run-9", }; @@ -19,7 +18,6 @@ describe("taskFromAssignment", () => { id: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "skip_existing", handle: "run-9", }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts index 1c8208fe8..0b3cabd2a 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts @@ -1,15 +1,10 @@ import type { EnqueueAssignment } from "../task-runner/types.js"; import type { TranslationTask } from "./types.js"; -/** - * `publishOnTranslation` is deliberately not carried: it is a write concern of the translation, - * not a fact about the task. - */ export const taskFromAssignment = (assignment: EnqueueAssignment): TranslationTask => ({ collection: assignment.collectionSlug, id: assignment.collectionId, sourceLng: assignment.sourceLng, targetLng: assignment.targetLng, - strategy: assignment.strategy, handle: assignment.handle, }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts index 9544afc3e..e4d28aed4 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts @@ -11,12 +11,6 @@ export type TranslationTask = { id: string; sourceLng: string; targetLng: string; - /** - * The resolution strategy for the task. Deliberately widened to `string` (not the internal - * `"overwrite" | "skip_existing"` union) so adding a strategy is not a breaking change to this - * public type; host callbacks should treat unknown values gracefully. - */ - strategy: string; /** * The run translating this locale, as the enqueue answer named it. Every callback carries it, * `onQueued` included: the runner names the run before it says it took the work. @@ -51,7 +45,7 @@ export type TranslationLifecycleCallbacks = { * @since 0.7.0 */ onQueued?: (task: TranslationTask) => void | Promise; - /** Fired after a task completes without error. @since 0.7.0 */ + /** @since 0.7.0 */ onCompleted?: (task: TranslationTask) => void | Promise; /** * Fired once when a locale will not be translated, with the failure that ended the run — which for diff --git a/packages/payload-plugin-translator/src/server/shared/guards/field-guards.ts b/packages/payload-plugin-translator/src/server/shared/guards/field-guards.ts index b135b8ed5..800a10e46 100644 --- a/packages/payload-plugin-translator/src/server/shared/guards/field-guards.ts +++ b/packages/payload-plugin-translator/src/server/shared/guards/field-guards.ts @@ -1,5 +1,10 @@ import type { Field, RichTextField, TextareaField, TextField } from "payload"; +import { + isLocalizedFieldLike, + isTranslatableFieldType, +} from "../../../core/domain/content-projection/translatableLeaf.js"; + /** Translatable field types that contain text content */ export type TranslatableField = RichTextField | TextareaField | TextField; @@ -9,7 +14,7 @@ export type TranslatableField = RichTextField | TextareaField | TextField; * union. */ export function isTranslatableField(field: { type: string }): field is TranslatableField { - return field.type === "text" || field.type === "textarea" || field.type === "richText"; + return isTranslatableFieldType(field); } /** @@ -18,7 +23,7 @@ export function isTranslatableField(field: { type: string }): field is Translata export function isLocalizedField( field: T ): field is T & { localized: true } { - return "localized" in field && field.localized === true; + return isLocalizedFieldLike(field); } /** diff --git a/packages/payload-plugin-translator/src/types/AutoTranslateContext.ts b/packages/payload-plugin-translator/src/types/AutoTranslateContext.ts index 257845570..2898c2101 100644 --- a/packages/payload-plugin-translator/src/types/AutoTranslateContext.ts +++ b/packages/payload-plugin-translator/src/types/AutoTranslateContext.ts @@ -1,8 +1,7 @@ /** * The `req.context` flag the translator sets on its OWN document writes so the auto-translate * `afterChange` hook skips them — the loop guard's second barrier (#51 D5). A single exported constant - * shared by the setter (`TranslateDocumentHandler.saveTranslatedDocument`) and the reader (the - * auto-translate hook), so the set-side and honor-side keys can never diverge. + * shared by the translator's own document writes and the auto-translate hook, so the set-side and honor-side keys can never diverge. * * Lives in `types/` (a leaf contract) so both `server/features/translate-document` and * `server/modules/auto-translate` import it without creating a cross-module edge. From 1fc96d42eed57cc3eddc9afb6713cd5fcc1054cf Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sun, 25 Oct 2026 12:50:06 +0100 Subject: [PATCH 06/25] feat: the admin forms stop asking for a strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strategy select goes from both forms, and with it the refresh action on the status list — its job, "bring this locale up to date", is now a request naming the drift rule. --- .../mutations/useQueueDocumentTranslation.ts | 4 -- .../entities/translation/model/types.ts | 1 + .../TranslationStatusList.tsx | 21 ------ .../model/constants.ts | 4 -- .../model/schema.test.ts | 1 - .../model/schema.ts | 1 - .../ui/CollectionTranslationForm.tsx | 2 - .../model/constants.ts | 4 -- .../model/schema.test.ts | 1 - .../translate-document-form/model/schema.ts | 1 - .../ui/DocumentTranslationForm.tsx | 2 - .../FormSelectStrategy/FormSelectStrategy.tsx | 66 ------------------- .../ui/form/FormSelectStrategy/index.ts | 2 - .../FormSelectStrategy/styles.module.scss | 16 ----- 14 files changed, 1 insertion(+), 125 deletions(-) delete mode 100644 packages/payload-plugin-translator/src/client/shared/ui/form/FormSelectStrategy/FormSelectStrategy.tsx delete mode 100644 packages/payload-plugin-translator/src/client/shared/ui/form/FormSelectStrategy/index.ts delete mode 100644 packages/payload-plugin-translator/src/client/shared/ui/form/FormSelectStrategy/styles.module.scss diff --git a/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts b/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts index cd9bb511d..13a9ac561 100644 --- a/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts +++ b/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts @@ -5,16 +5,12 @@ import { useTranslateKitConfig } from "../../../../app/config/index.js"; import { handleNextApiError } from "../../../../shared/lib/errors/handleApiError.js"; import { DOCUMENT_STALENESS_QUERY_KEY } from "../queries/useDocumentStaleness.js"; -type TranslationStrategy = "overwrite" | "skip_existing"; - type Variables = { source_lng: string; - // string (single mode) or string[] (multi mode) — the /enqueue endpoint accepts both and fans out. target_lng: string | string[]; collection_slug: string; collection_id: Array; select_all?: boolean; - strategy?: TranslationStrategy; publish_on_translation?: boolean; }; diff --git a/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts b/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts index 8646245bc..42418756b 100644 --- a/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts +++ b/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts @@ -77,6 +77,7 @@ export type StalenessLocale = { source_lng: string; is_stale: boolean; translated_at: string; + changed_leaves?: string[]; }; /** Per-locale staleness for a single document. */ diff --git a/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx b/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx index 2c264ab55..20f7b6392 100644 --- a/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx +++ b/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx @@ -8,7 +8,6 @@ import { CloseIcon } from "../../../../shared/lib/assets/icons/CloseIcon.js"; import { ReloadIcon } from "../../../../shared/lib/assets/icons/ReloadIcon.js"; import { useCancelDocumentTranslation } from "../../api/mutations/useCancelDocumentTranslation.js"; import { useDismissStaleness } from "../../api/mutations/useDismissStaleness.js"; -import { useQueueDocumentTranslation } from "../../api/mutations/useQueueDocumentTranslation.js"; import { useRunDocumentTranslation } from "../../api/mutations/useRunDocumentTranslation.js"; import type { TranslationStatusRow } from "../../model/statusRows.js"; import { ActionButton } from "../ActionButton/index.js"; @@ -52,7 +51,6 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu const runApi = useRunDocumentTranslation(); const cancelApi = useCancelDocumentTranslation(); const dismissApi = useDismissStaleness(); - const queueApi = useQueueDocumentTranslation(); const [dismissing, setDismissing] = useState>(new Set()); const dismiss = async (targetLocale: string) => { @@ -68,19 +66,6 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu }); }; - const refreshChangedLeaves = (row: TranslationStatusRow) => - withToast( - () => - queueApi.mutateAsync({ - source_lng: row.sourceLocale, - target_lng: row.targetLocale, - collection_slug: collection, - collection_id: [id], - strategy: "skip_existing", - }), - "Failed to queue translation" - ); - const retry = (row: TranslationStatusRow) => row.jobId && withToast(() => runApi.mutateAsync({ id: row.jobId! }), "Error retrying"); const runNow = (row: TranslationStatusRow) => @@ -132,12 +117,6 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu case "stale": return ( - } - title={`Re-translate from ${row.sourceLocale}`} - onClick={() => refreshChangedLeaves(row)} - loading={queueApi.isPending} - /> } title="Hide this notice until the source changes again" diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts index ff7d28bb1..e0cab0d35 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts @@ -1,10 +1,7 @@ -import type { TranslationStrategy } from "../../../shared/ui/form/FormSelectStrategy/index.js"; - export const FORM_FIELDS = { SOURCE_LNG: "source_lng", TARGET_LNG: "target_lng", HIDDEN_COLLECTION_SLUG: "collection_slug", - STRATEGY: "strategy", PUBLISH_ON_TRANSLATION: "publish_on_translation", } as const; @@ -12,6 +9,5 @@ export const defaultValues = { [FORM_FIELDS.SOURCE_LNG]: "", [FORM_FIELDS.TARGET_LNG]: "", [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: "", - [FORM_FIELDS.STRATEGY]: "overwrite" as TranslationStrategy, [FORM_FIELDS.PUBLISH_ON_TRANSLATION]: false, }; diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts index 6f9f01dab..67f1ae6b3 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts @@ -6,7 +6,6 @@ import { validationSchema } from "./schema.js"; const base = { [FORM_FIELDS.SOURCE_LNG]: "en", [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: "posts", - [FORM_FIELDS.STRATEGY]: "overwrite" as const, [FORM_FIELDS.PUBLISH_ON_TRANSLATION]: false, }; diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts index bcd37f3d8..b11482967 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts @@ -10,7 +10,6 @@ export const validationSchema = z.object({ .union([z.string(), z.array(z.string())]) .refine((value) => value.length > 0, { message: "Select at least one language" }), [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: z.string().nonempty(), - [FORM_FIELDS.STRATEGY]: z.enum(["overwrite", "skip_existing"]), [FORM_FIELDS.PUBLISH_ON_TRANSLATION]: z.boolean().default(false), }); diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx b/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx index 2a7ca905f..05b484d7f 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx @@ -9,7 +9,6 @@ import { useLocaleOptions } from "../../../shared/lib/payload/hooks/useLocaleOpt import Button from "../../../shared/ui/Button/index.js"; import FormSelect from "../../../shared/ui/form/FormSelect/index.js"; import FormMultiSelect from "../../../shared/ui/form/FormMultiSelect/index.js"; -import { FormSelectStrategy } from "../../../shared/ui/form/FormSelectStrategy/index.js"; import { FormCheckboxPublish } from "../../../shared/ui/form/FormCheckboxPublish/index.js"; import { FORM_FIELDS } from "../model/constants.js"; @@ -74,7 +73,6 @@ export function CollectionTranslationForm({ /> )} - {hasDrafts && }